본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
선택 심화 · 13장 : 공통 코드 설계

코드 캐시 동기화

DB 이름 변경 뒤 서버별 로컬 캐시가 갈라지는 오류를 재현하고 그룹 버전·변경 아웃박스·커서로 동기화 규칙을 만듭니다.

공통 코드는 작고 읽기가 많아 로컬 메모리 캐시에 잘 맞습니다.

TTL(Time To Live, 캐시 유효 시간)은 캐시 항목을 얼마 뒤 다시 읽을지 정하는 시간입니다.

문제는 빠른 조회가 아니라 DB가 바뀐 뒤 어느 서버가 언제 새 값을 읽었는지 알 수 없다는 점입니다.

TTL만 있으면 언젠가 맞아지지만 그동안 얼마나 오래된 값을 허용할지, 어떤 갱신이 누락됐는지 설명하기 어렵습니다.

변경마다 단조 증가 버전을 발행하고 소비자는 자신이 적용한 버전을 보고해야 합니다.

세 서버가 PUBLISHED의 이름을 캐시한 뒤 운영자가 DB에서 공개됨으로 바꿉니다.

서버 A만 재시작해 새 이름을 읽고 B·C는 오래된 이름을 계속 돌려주는 상황을 데이터로 재현합니다.


버전 없는 캐시의 문제

스냅샷에 생성 시각만 있으면 이후 변경이 몇 번 있었는지, 서버가 정확히 어느 상태까지 적용했는지 비교할 기준이 없습니다.

여러 서버의 TTL 경계가 달라 사용자 응답도 요청 경로에 따라 바뀝니다.

버전 없는 서버별 코드 복사본
DROP TABLE IF EXISTS code_cache_bad;

CREATE TABLE code_cache_bad (
  server_id VARCHAR(40) NOT NULL,
  group_code VARCHAR(40) NOT NULL,
  code VARCHAR(40) NOT NULL,
  cached_name VARCHAR(100) NOT NULL,
  loaded_at DATETIME(6) NOT NULL,
  PRIMARY KEY (server_id, group_code, code)
);

INSERT INTO code_cache_bad
  (server_id, group_code, code, cached_name, loaded_at)
VALUES
  ('server-a', 'POST_STATUS', 'PUBLISHED', '게시됨', '2026-07-14 09:00:00'),
  ('server-b', 'POST_STATUS', 'PUBLISHED', '게시됨', '2026-07-14 09:00:00'),
  ('server-c', 'POST_STATUS', 'PUBLISHED', '게시됨', '2026-07-14 09:00:00');

UPDATE final_code
SET display_name_ko = '공개됨'
WHERE group_code = 'POST_STATUS'
  AND code = 'PUBLISHED';

UPDATE code_cache_bad
SET cached_name = '공개됨', loaded_at = '2026-07-14 10:00:00'
WHERE server_id = 'server-a'
  AND group_code = 'POST_STATUS'
  AND code = 'PUBLISHED';

DB와 서버-a는 새 이름이고 B·C는 이전 이름입니다.

loaded_at만으로 누락된 변경 수를 계산할 수 없습니다.

오류 실행 결과
source DB: 공개됨
server-a : 공개됨
server-b : 게시됨
server-c : 게시됨

known source revision: absent
measurable lag by revision: impossible

TTL은 최대 데이터 지연 시간을 제한할 수 있지만 갱신 오류나 시계 차이를 숨깁니다.

변경 ID가 없으면 소비자가 중간 이벤트를 놓쳤는지 전체 재적재가 필요한지도 판단하지 못합니다.

공통 코드 수정 트랜잭션에서 그룹 버전과 아웃박스 행을 함께 확정하면 DB 행과 변경 알림 사이의 이중 쓰기를 없앨 수 있습니다.

소비자는 커서 이후 이벤트를 순서대로 읽거나 버전 차이가 크면 그룹 전체를 다시 로드합니다.

세 서버의 이름이 갈라졌는데 오류가 없는 이유

  1. 원본 버전 — DB 그룹 상태를 대표하는 단조 증가 값을 찾습니다.
  2. 원자적 발행 — 코드 UPDATE와 변경 이벤트가 같은 트랜잭션인지 봅니다.
  3. 소비자 커서 — 서버마다 마지막 적용 change_id와 버전을 저장합니다.
  4. 복구 — 이벤트 보존 기간을 넘긴 커서가 전체 재적재로 복구되는지 확인합니다.

원본 버전과 캐시 신선도

final_code_revision의 버전은 그룹 단위 스냅샷 버전입니다.

코드가 하나라도 바뀔 때 1 증가하며 같은 트랜잭션의 아웃박스가 바뀐 코드와 새 버전을 기록합니다.

서버가 보고한 applied_revisionsource_revision의 차이가 0이면 최신입니다.

차이가 양수면 캐시가 최신 상태가 아니며, 커서가 아웃박스 최소 ID보다 오래되면 증분 재처리 대신 전체 그룹을 재적재합니다.

원본 버전과 소비자 커서를 규칙으로 만들기

  1. 단위 — 전체 코드가 아니라 group_code마다 버전을 둡니다.
  2. 원자성 — 이름 UPDATE·버전 증가·아웃박스 INSERT를 한 트랜잭션에 묶습니다.
  3. 순서AUTO_INCREMENT change_id와 그룹 버전을 모두 보존합니다.
  4. 소비 — 서버 커서는 적용 성공 뒤에만 전진합니다.

버전과 변경 이벤트 발행

오류 재현에서 바꾼 표시명을 기준 상태인 게시됨으로 되돌린 뒤 버전 기준선과 추가 전용 변경 행을 만듭니다.

이름 변경 트랜잭션은 버전을 잠그고 증가시킨 뒤 해당 값을 아웃박스에 기록합니다.

코드 revision·outbox 기준선
DROP TABLE IF EXISTS final_code_consumer_cursor;
DROP TABLE IF EXISTS final_code_change;
DROP TABLE IF EXISTS final_code_revision;

CREATE TABLE final_code_revision (
  group_code VARCHAR(40) NOT NULL,
  revision BIGINT UNSIGNED NOT NULL DEFAULT 0,
  changed_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (group_code),
  CONSTRAINT fk_code_revision_group FOREIGN KEY (group_code)
    REFERENCES final_code_group (group_code) ON DELETE RESTRICT
) ENGINE=InnoDB;

CREATE TABLE final_code_change (
  change_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  group_code VARCHAR(40) NOT NULL,
  revision BIGINT UNSIGNED NOT NULL,
  code VARCHAR(40) NOT NULL,
  change_type VARCHAR(16) NOT NULL,
  changed_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (change_id),
  CONSTRAINT uq_code_change_revision UNIQUE (group_code, revision),
  CONSTRAINT fk_code_change_code FOREIGN KEY (group_code, code)
    REFERENCES final_code (group_code, code) ON DELETE RESTRICT,
  CONSTRAINT chk_code_change_type
    CHECK (change_type IN ('CREATE', 'UPDATE', 'RETIRE')),
  INDEX ix_code_change_group_id (group_code, change_id)
) ENGINE=InnoDB;

CREATE TABLE final_code_consumer_cursor (
  consumer_id VARCHAR(80) NOT NULL,
  group_code VARCHAR(40) NOT NULL,
  applied_revision BIGINT UNSIGNED NOT NULL,
  last_change_id BIGINT UNSIGNED NOT NULL,
  refreshed_at DATETIME(6) NOT NULL,
  PRIMARY KEY (consumer_id, group_code),
  CONSTRAINT fk_cursor_group FOREIGN KEY (group_code)
    REFERENCES final_code_group (group_code) ON DELETE RESTRICT
) ENGINE=InnoDB;

UPDATE final_code
SET display_name_ko = '게시됨'
WHERE group_code = 'POST_STATUS'
  AND code = 'PUBLISHED';

INSERT INTO final_code_revision (group_code, revision)
SELECT group_code, 0
FROM final_code_group;

START TRANSACTION;

SELECT revision
FROM final_code_revision
WHERE group_code = 'POST_STATUS'
FOR UPDATE;

UPDATE final_code
SET display_name_ko = '공개됨'
WHERE group_code = 'POST_STATUS'
  AND code = 'PUBLISHED';

UPDATE final_code_revision
SET revision = LAST_INSERT_ID(revision + 1),
    changed_at = CURRENT_TIMESTAMP(6)
WHERE group_code = 'POST_STATUS';

INSERT INTO final_code_change
  (group_code, revision, code, change_type)
VALUES
  ('POST_STATUS', LAST_INSERT_ID(), 'PUBLISHED', 'UPDATE');

COMMIT;

코드 이름, 원본 버전, 아웃박스가 한 번에 확정됩니다.

알림 전송에 오류가 발생해도 소비자는 DB 아웃박스를 다시 읽을 수 있습니다.

개선 실행 결과
POST_STATUS source revision: 1
outbox rows for revision 1: 1
changed code: PUBLISHED
new display name: 공개됨

code update without outbox: 0

아웃박스는 알림 전달 그 자체가 아니라 재처리 가능한 원본입니다.

발행자가 같은 change_id를 여러 번 보내도 소비자는 커서와 버전으로 중복 적용을 무시해야 합니다.

한 트랜잭션에서 그룹 내 여러 코드를 바꾼다면 버전당 아웃박스 한 행이라는 UNIQUE가 맞지 않습니다.

이 모델은 변경 명령 하나가 코드 하나를 바꾼다는 규칙이며 배치 변경이 필요하면 변경 묶음 부모와 항목 자식을 분리합니다.

중복 전송·이벤트 누락 구간·전체 재적재 확인하기

  1. 초기화 — 모든 그룹 버전을 0으로 생성합니다.
  2. 동시 변경 — 같은 그룹 두 트랜잭션이 FOR UPDATE에서 직렬화되는지 봅니다.
  3. 재전송 — 같은 change_id를 두 번 받아 커서가 한 번만 전진하는지 확인합니다.
  4. 전체 재적재 — 커서가 보존 범위 밖이면 그룹 스냅샷과 버전을 함께 바꿉니다.

서버별 적용 버전 측정

세 서버 커서를 넣고 원본과 차이를 계산합니다.

갱신 성공 서버만 버전 1이며 나머지는 지연 1로 명확히 드러나야 합니다.

서버별 code cache lag 대사
INSERT INTO final_code_consumer_cursor
  (consumer_id, group_code, applied_revision,
   last_change_id, refreshed_at)
SELECT 'server-a', 'POST_STATUS', 1, MAX(change_id),
       '2026-07-14 10:00:00'
FROM final_code_change
WHERE group_code = 'POST_STATUS';

INSERT INTO final_code_consumer_cursor
  (consumer_id, group_code, applied_revision,
   last_change_id, refreshed_at)
VALUES
  ('server-b', 'POST_STATUS', 0, 0, '2026-07-14 09:00:00'),
  ('server-c', 'POST_STATUS', 0, 0, '2026-07-14 09:00:00');

SELECT consumer.consumer_id,
       source.revision AS source_revision,
       consumer.applied_revision,
       source.revision - consumer.applied_revision AS revision_lag,
       consumer.refreshed_at
FROM final_code_consumer_cursor AS consumer
JOIN final_code_revision AS source
  ON source.group_code = consumer.group_code
WHERE consumer.group_code = 'POST_STATUS'
ORDER BY consumer.consumer_id;

SELECT change_id, revision, code, change_type
FROM final_code_change
WHERE group_code = 'POST_STATUS'
  AND change_id > 0
ORDER BY change_id;

서버 A의 버전 지연은 0, 서버 B와 서버 C의 버전 지연은 1입니다.

오래된 값을 가진 두 서버는 change_id 1을 적용한 뒤 커서를 버전 1로 갱신할 수 있습니다.

TTL과 아웃박스가 서로 대신할 수 없는 역할

TTL은 버전 규칙을 대체하지 않지만 안전망으로 함께 사용할 수 있습니다.

이벤트 소비자가 멈춰도 최대 TTL 뒤 전체 재적재가 수행되고 버전 지연 지표는 그동안의 데이터 지연을 보여 줍니다.

코드 변경 아웃박스의 보존 기간보다 서버 장애가 길 수 있습니다.

최소 보존 change_id와 커서를 비교해 누락 구간을 발견하면 이벤트 재처리를 중단하고 전체 스냅샷을 원자 교체합니다.

JOIN·폴링·푸시 캐시 동기화 비교

선택얻는 효과감수할 비용적합한 조건
매 요청 DB JOIN항상 최신네트워크·쿼리 결합트래픽이 작고 신선도가 엄격할 때
TTL 로컬 캐시가장 단순하고 빠름TTL 동안 오래된 값 제공분 단위 지연을 허용할 때
버전 폴링지연 측정·단순 복구주기 폴링작은 코드 집합을 운영할 때
아웃박스 푸시빠른 전파·재처리발행자·커서 운영서버가 많고 지연 SLA가 짧을 때

동시 변경과 오래된 커서를 만드는 실험

  1. 동시성 — 두 게시글에서 같은 그룹 버전을 잠그고 증가 순서를 확인합니다.
  2. 중복change_id 1을 두 번 소비해 적용 결과가 한 번과 같은지 봅니다.
  3. 누락 구간 — 아웃박스 첫 행을 보존 범위 밖으로 가정하고 전체 재적재 경로를 실행합니다.
  4. TTL — 소비자 이벤트를 멈춘 뒤 TTL 기반 재적재가 지연을 0으로 만드는지 측정합니다.

버전 지연과 적체량을 서버별로 관찰하기

그룹별 원본 버전, 소비자 버전 지연, 가장 오래된 커서 경과 시간, 아웃박스 적체량과 발행자 재시도 수를 관찰합니다.

코드 스냅샷은 새 맵을 완성한 뒤 참조를 교체하여 갱신 중 부분 목록이 노출되지 않게 합니다.

시계·배치 변경·다중 리전의 경계

  • 서버 시계가 달라도 버전 비교는 영향을 받지 않지만 refreshed_at 지연 지표는 시계 동기화가 필요합니다.
  • 비활성 코드도 과거 행 표시를 위해 캐시 스냅샷에 포함하고 신규 선택 목록에서만 필터링합니다.
  • 대량 배치 변경은 버전 하나와 항목 여러 개를 갖는 변경 묶음 모델이 적합합니다.
  • 다중 리전은 전역 이벤트 순서보다 그룹별 버전과 리전 커서를 비교합니다.

다음 문서는 DB 코드 목록과 애플리케이션 ENUM이 서로 다른 배포 주기를 가질 때 릴리스마다 동기화를 확인하는 규칙을 만듭니다.


코드 캐시 동기화 선택

판단 축확인할 질문
지연허용 가능한 데이터 지연이 몇 초·분인가?
규모서버와 코드 그룹 수가 얼마나 되는가?
측정어느 서버가 몇 버전 뒤인지 알 수 있는가?
복구이벤트 누락 구간 뒤 전체 스냅샷으로 돌아갈 수 있는가?
원자성코드 변경과 발행 기록이 한 트랜잭션인가?

캐시를 쓴다는 결정은 오래된 값을 없애는 것이 아니라 허용 범위와 복구 방법을 정하는 일입니다.

버전이 있어야 그 범위를 수치로 말할 수 있습니다.


연습 문제

소비자가 일정 시간 이상 지연 상태인지를 기록하는 처리 확인 표와 경고 쿼리를 만드세요.

같은 버전 재보고는 한 행 UPDATE로 끝나야 합니다.

해설과 예시 답안

기존 커서 표가 처리 확인 역할을 하므로 원자 UPSERT 대신 명시적 UPDATE 후 누락 INSERT를 사용합니다.

경고는 원본 버전 차이와 refreshed_at 경과 시간을 함께 봅니다.

UPDATE final_code_consumer_cursor
SET applied_revision = 1,
    last_change_id = 1,
    refreshed_at = CURRENT_TIMESTAMP(6)
WHERE consumer_id = 'server-b'
  AND group_code = 'POST_STATUS';

INSERT INTO final_code_consumer_cursor
  (consumer_id, group_code, applied_revision,
   last_change_id, refreshed_at)
SELECT 'server-d', 'POST_STATUS', 1, 1, CURRENT_TIMESTAMP(6)
WHERE NOT EXISTS (
  SELECT 1
  FROM final_code_consumer_cursor
  WHERE consumer_id = 'server-d'
    AND group_code = 'POST_STATUS'
);

SELECT c.consumer_id,
       r.revision - c.applied_revision AS revision_lag,
       TIMESTAMPDIFF(SECOND, c.refreshed_at, CURRENT_TIMESTAMP(6)) AS age_seconds
FROM final_code_consumer_cursor AS c
JOIN final_code_revision AS r ON r.group_code = c.group_code
WHERE r.revision > c.applied_revision
   OR c.refreshed_at < CURRENT_TIMESTAMP(6) - INTERVAL 5 MINUTE;

서버-b를 같은 값으로 다시 보고해도 행 수는 늘지 않습니다.

원본보다 뒤처졌거나 5분 넘게 보고하지 않은 소비자만 경고 쿼리에 남습니다.


핵심 정리

  • TTL 시각만으로는 누락된 변경 수를 알 수 없습니다.
  • 그룹 버전은 캐시 스냅샷의 원본 버전입니다.
  • 코드 변경과 아웃박스 발행은 한 트랜잭션이어야 합니다.
  • 소비자 커서와 전체 재적재가 중복·누락 구간 복구를 맡습니다.

다음 문서에서는 DB 코드와 애플리케이션 ENUM의 릴리스별 불일치를 검사합니다.