본문 바로가기

코드 캐시 동기화

데이터베이스에서 코드 이름을 바꾼 뒤 서버별 로컬 캐시가 갈라지는 상황을 재현하고, 그룹 버전과 변경 아웃박스, 소비자 커서로 캐시가 몇 번의 변경만큼 뒤처졌는지 측정하는 동기화 규칙을 만듭니다.

공통 코드는 작고 읽기가 많아 로컬 메모리 캐시에 잘 맞습니다. TTL(Time To Live, 캐시 유효 시간)은 캐시 항목을 얼마 뒤에 다시 읽을지 정하는 시간입니다. 문제는 조회 속도가 아니라, 데이터베이스가 바뀐 뒤 어느 서버가 언제 새 값을 읽었는지 알 수 없다는 점입니다.

TTL이 지난 뒤 다시 읽기에 성공해야 새 값으로 바뀌므로, TTL만으로는 갱신이 실패했을 때 얼마나 오래된 값을 허용할지, 어떤 변경을 놓쳤는지 알기 어렵습니다. 그래서 변경마다 단조 증가하는 버전을 발행하고, 소비자는 자신이 적용한 버전을 보고하게 합니다.

예제는 세 서버가 PUBLISHED의 이름을 캐시한 뒤 운영자가 데이터베이스에서 이름을 공개됨으로 바꾸고, 서버 A만 새 이름을 읽은 상황을 만듭니다. 서버의 캐시는 SQL 테이블의 복사본으로 흉내 낸 모형이며, 실제 서버의 재시작이나 메모리 캐시, TTL 처리기를 실행하는 코드는 아닙니다.

버전 없는 캐시의 문제

스냅샷에 읽은 시각만 있으면, 그 뒤에 변경이 몇 번 있었는지, 서버가 정확히 어느 상태까지 적용했는지 비교할 기준이 없습니다. 서버마다 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';

SELECT c.server_id, c.cached_name,
       f.display_name_ko AS source_name, c.loaded_at
FROM code_cache_bad AS c
JOIN final_code AS f
  ON f.group_code = c.group_code AND f.code = c.code
ORDER BY c.server_id;
server_id | cached_name | source_name | loaded_at
server-a  | 공개됨      | 공개됨      | 2026-07-14 10:00:00.000000
server-b  | 게시됨      | 공개됨      | 2026-07-14 09:00:00.000000
server-c  | 게시됨      | 공개됨      | 2026-07-14 09:00:00.000000

데이터베이스와 서버 A는 새 이름이고, 서버 B와 C는 이전 이름입니다. 어떤 문장도 오류를 내지 않았고, loaded_at만으로는 B와 C가 몇 번의 변경을 놓쳤는지 계산할 수 없습니다.

TTL이 지난 값을 더는 내보내지 않고 다시 읽기가 성공한다는 정책이 있어야 지연의 상한을 말할 수 있으며, 갱신에 실패했을 때 이전 값을 계속 내보낸다면 TTL만으로는 상한을 보장하지 못합니다. 변경 ID가 없으면 소비자가 중간의 이벤트를 놓쳤는지, 전체를 다시 적재해야 하는지도 판단하지 못합니다.

해결의 방향은 네 가지입니다. 그룹 상태를 대표하는 단조 증가 버전을 원본에 두고, 코드의 UPDATE와 변경 이벤트를 같은 트랜잭션에서 확정합니다. 서버마다 마지막으로 적용한 change_id와 버전을 저장하며, 이벤트의 보존 기간을 넘긴 커서는 전체 재적재로 복구합니다. 코드를 고친 뒤 변경 알림을 메시지 시스템 같은 다른 곳에 따로 보내면 둘 중 하나만 성공할 수 있는데, 이를 이중 쓰기(dual write) 문제라고 하며 8장 5편의 이중 쓰기 버퍼(doublewrite buffer)와는 다른 개념입니다. 코드 수정 트랜잭션에서 그룹 버전과 아웃박스(outbox) 행을 함께 확정하면 코드의 변경과 알림의 기록이 함께 확정되거나 함께 취소되므로, 이 문제가 생기지 않습니다.

원본 버전과 캐시의 신선도

final_code_revision의 버전은 그룹 단위의 스냅샷 버전입니다. 그룹의 코드가 하나라도 바뀌면 1씩 늘고, 같은 트랜잭션의 아웃박스가 바뀐 코드와 새 버전을 기록합니다. 서버가 스냅샷의 적용을 마친 뒤에 그 버전을 정확히 보고한다는 전제에서, 원본과의 차이가 0이면 최신 상태로 해석합니다. 커서 행의 숫자만 바꾼다고 캐시의 적용이 끝난 것은 아닙니다.

차이가 양수면 캐시가 최신이 아닙니다. 전체 재적재가 필요한지는 다음 그룹 버전의 이벤트가 남아 있는지, 보존 정책이 필요한 구간을 지웠는지로 판단하며, last_change_id < MIN(change_id)라는 이유만으로 누락을 확정하지 않습니다.

전역 ID와 그룹 버전은 역할이 다릅니다. POST_STATUS의 이벤트 ID가 10 다음에 15이고 그룹 버전은 1 다음에 2라면, 중간의 11부터 14까지는 다른 그룹이 쓴 것일 수 있으며, 이 그룹에서 변경 네 개를 잃었다는 뜻이 아닙니다. 전역 ID는 읽는 위치이고, 그룹 버전은 이 모델에서 연속된 변경의 수입니다.

규칙으로 정리하면 이렇습니다. 버전은 코드 전체가 아니라 group_code마다 두고, 이름의 UPDATE와 버전의 증가, 아웃박스의 INSERT를 한 트랜잭션으로 묶습니다. AUTO_INCREMENT인 change_id와 그룹 버전을 모두 보존하고, 서버의 커서는 적용에 성공한 뒤에만 앞으로 옮깁니다.

버전과 변경 이벤트의 발행

앞에서 바꾼 표시 이름을 기준 상태인 게시됨으로 되돌린 뒤, 버전의 기준선과 추가만 하는 변경 행을 만듭니다. 이름을 바꾸는 트랜잭션은 버전 행을 잠그고 늘린 뒤 그 값을 아웃박스에 기록합니다. 아래는 모든 문장이 성공하는 경로이며, 오류가 나면 뒤의 문장과 COMMIT을 실행하지 말고 바로 ROLLBACK합니다.

코드 버전·아웃박스 기준선
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;

FOR UPDATE는 POST_STATUS의 버전 행을 잠그고 현재 값 0을 돌려줍니다. 같은 그룹을 바꾸려는 다른 트랜잭션은 이 잠금에서 기다리므로, 같은 그룹의 버전은 한 번에 하나씩 늘어납니다.

LAST_INSERT_ID(revision + 1)은 새 버전 1을 이 연결의 마지막 ID로 기억해 두고, 다음 INSERT의 LAST_INSERT_ID()가 그 값을 읽습니다. 두 문장은 같은 연결에서 실행해야 하며, INSERT가 AUTO_INCREMENT의 change_id를 만든 뒤에는 그 연결의 마지막 ID가 change_id로 바뀝니다. 예제에서 두 값이 모두 1인 것은 우연이므로, 그 뒤의 LAST_INSERT_ID()를 그룹 버전으로 취급하면 안 됩니다.

버전과 아웃박스 확인
SELECT r.group_code, r.revision,
       (SELECT COUNT(*)
        FROM final_code_change AS ch
        WHERE ch.group_code = r.group_code) AS outbox_rows
FROM final_code_revision AS r
ORDER BY r.group_code;

SELECT ch.change_id, ch.revision, ch.code, ch.change_type,
       c.display_name_ko
FROM final_code_change AS ch
JOIN final_code AS c
  ON c.group_code = ch.group_code AND c.code = ch.code;
group_code      | revision | outbox_rows
MEMBER_STATUS   | 0        | 0
POST_STATUS     | 1        | 1
POST_VISIBILITY | 0        | 0

change_id | revision | code      | change_type | display_name_ko
1         | 1        | PUBLISHED | UPDATE      | 공개됨

COMMIT으로 코드의 이름과 원본 버전, 아웃박스가 함께 확정되었습니다. 알림 전송이 실패해도 소비자는 데이터베이스의 아웃박스를 다시 읽을 수 있으므로, 아웃박스는 알림의 전달 자체가 아니라 다시 처리할 수 있는 원본입니다. 발행자가 같은 change_id를 여러 번 보내더라도, 소비자는 커서와 버전으로 중복 적용을 무시해야 합니다.

이 모델은 변경 명령 하나가 코드 하나를 바꾼다는 규칙입니다. 한 트랜잭션에서 그룹 안의 여러 코드를 바꾼다면 버전마다 아웃박스 한 행이라는 UNIQUE가 맞지 않으므로, 변경 묶음의 부모와 항목의 자식을 나눕니다. 또 같은 그룹은 버전 행의 잠금으로 직렬화되지만, 전역 AUTO_INCREMENT ID가 여러 그룹 사이의 커밋 순서까지 보장하지는 않으므로, 아래의 커서 조회처럼 그룹별 범위를 유지합니다.

서버별 적용 버전의 측정

세 서버의 커서를 넣고 원본과의 차이를 계산합니다. 아래는 서버 A에 버전 1을, B와 C에 버전 0을 직접 기록하는 모형이며, 아웃박스에 이벤트가 한 행만 있는 앞의 상태를 전제로 합니다.

부호 없는 버전끼리의 뺄셈은 적용 버전이 원본을 넘지 않는다는 전제에서만 안전합니다. 잘못 보고된 더 큰 버전은 음수 지연이 아니라 범위 오류를 내므로, 보고값의 범위와 단조 증가를 따로 검증합니다.

서버별 코드 캐시 지연 대사
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;
consumer_id | source_revision | applied_revision | revision_lag | refreshed_at
server-a    | 1               | 1                | 0            | 2026-07-14 10:00:00.000000
server-b    | 1               | 0                | 1            | 2026-07-14 09:00:00.000000
server-c    | 1               | 0                | 1            | 2026-07-14 09:00:00.000000

change_id | revision | code      | change_type
1         | 1        | PUBLISHED | UPDATE

서버 A의 버전 지연은 0이고, B와 C는 1입니다. 두 번째 쿼리는 B와 C의 커서 last_change_id 0 뒤에 남은 이벤트로, 둘은 change_id 1을 적용한 뒤 커서를 버전 1로 옮길 수 있습니다.

코드·버전·아웃박스를 함께 확정하고 서버별 적용을 비교한다

서버가 이전 이름을 읽는다. 각 소비자가 자기 커서부터 처리한다. 각 단계의 값과 전환 이유를 비교합니다.

CODE CACHE · TRANSACTION / REVISION / CURSOR

코드·버전·아웃박스를 함께 확정하고 서버별 적용을 비교한다

POST_STATUS 버전 1의 표시명은 공개됨입니다. A는 적용 버전 1, B·C는 0이므로 각 서버가 적용을 완료한 뒤 커서를 보고해야 지연을 해석할 수 있습니다.

원본에서각서버로독립적으로적용되는그룹버전원본POST_STATUS버전1의표시명은공개됨입니다. 원본에서세서버로분기한화살표는해당그룹스냅샷의적용관계입니다. A만적용완료인실선이고B와C는미적용인점선입니다. 서버끼리차례로전송하는경로가아닙니다.실선: 적용 완료원본 POST_STATUSrevision 1새 이름: 공개됨서버 A · 적용 11 − 1 = 지연 0표시명: 공개됨왼쪽 점선: 미적용서버 B · 적용 01 − 0 = 지연 1표시명: 게시됨오른쪽 점선: 미적용서버 C · 적용 01 − 0 = 지연 1표시명: 게시됨
원본에서각서버로독립적으로적용되는그룹버전원본POST_STATUS버전1의표시명은공개됨입니다. 원본에서세서버로분기한화살표는해당그룹스냅샷의적용관계입니다. A만적용완료인실선이고B와C는미적용인점선입니다. 서버끼리차례로전송하는경로가아닙니다.실선: 적용 완료원본 POST_STATUSrevision 1새 이름: 공개됨서버 A · 적용 11 − 1 = 지연 0표시명: 공개됨왼쪽 점선: 미적용서버 B · 적용 01 − 0 = 지연 1표시명: 게시됨오른쪽 점선: 미적용서버 C · 적용 01 − 0 = 지연 1표시명: 게시됨

실선은 적용 완료, 점선은 이 스냅샷에 아직 도달하지 않은 서버입니다. B와 C 사이에는 전달 간선이 없습니다. 아래 SQL은 처리기 실행 대신 버전·커서를 직접 기록한 모형입니다.

한 트랜잭션 · 원본 변경 발행

  1. 그룹 revision 행 FOR UPDATE
  2. PUBLISHED 이름 → 공개됨
  3. revision 0 → 1 · outbox 1행 INSERT
  4. 모두 성공 → COMMIT / 오류 → 중단 후 ROLLBACK

server-a · 적용 완료

원본 1 − 적용 1 = 지연 0

새 이름: 공개됨

server-b / server-c · 미적용

원본 1 − 적용 0 = 지연 1

이전 이름: 게시됨

소비자 복구 순서

  1. 그룹 안에서 커서 이후 이벤트 읽기
  2. 캐시 반영 성공 후 커서 전진 · 중복 무시
  3. 필요한 그룹 revision의 이벤트 삭제 확인 → 전체 재적재

커서 숫자만 바꾸면 적용 증거가 되지 않습니다. 전역 change_id는 그룹 간 커밋 순서를 보장하지 않고, TTL만으로 갱신 실패 때의 지연 상한을 보장하지 않습니다. 본문은 SQL 복사본·커서 모형이며 실제 캐시 처리기를 실행하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 개정 0부터 각자 따라잡기까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목원본에서 각 서버로 독립적으로 적용되는 그룹 버전
관계 설명원본 POST_STATUS 버전 1의 표시명은 공개됨입니다. 원본에서 세 서버로 분기한 화살표는 해당 그룹 스냅샷의 적용 관계입니다. A만 적용 완료인 실선이고 B와 C는 미적용인 점선입니다. 서버끼리 차례로 전송하는 경로가 아닙니다.
설명·조건실선은 적용 완료, 점선은 이 스냅샷에 아직 도달하지 않은 서버를 나타냅니다. B와 C 사이에는 전달 간선이 없습니다. 아래 SQL은 처리기 실행 대신 버전·커서를 직접 기록한 모형입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
개정 0

서버가 이전 이름을 읽는다.

  • 개정·소비 커서 1: group revision0
  • 개정·소비 커서 2: A/B/C applied0
  • 게시됨 표시
개정 1 함께 확정

그룹 잠금 뒤 이름과 outbox를 저장한다.

  • 개정·소비 커서 1: 공개됨
  • 개정·소비 커서 2: revision1
  • 개정·소비 커서 3: outbox1
  • 한 COMMIT으로 원본 확정
A만 적용

B/C는 아직 이전 캐시를 사용한다.

  • 개정·소비 커서 1: A applied 1/lag0
  • 개정·소비 커서 2: B/C applied0/lag1
  • A 적용 확인 뒤 커서 1
각자 따라잡기

각 소비자가 자기 커서부터 처리한다.

  • 개정·소비 커서 1: B/C도 성공 뒤 applied 1
  • 개정·소비 커서 2: 이벤트 누락이면 전체 재적재
  • 독립 적용; TTL로 지연 상한 보장 없음

코드·버전·아웃박스를 함께 확정하고 서버별 적용을 비교한다

서버가 이전 이름을 읽는다. 각 소비자가 자기 커서부터 처리한다. 각 단계의 값과 전환 이유를 비교합니다.

CODE CACHE · TRANSACTION / REVISION / CURSOR

코드·버전·아웃박스를 함께 확정하고 서버별 적용을 비교한다

POST_STATUS 버전 1의 표시명은 공개됨입니다. A는 적용 버전 1, B·C는 0이므로 각 서버가 적용을 완료한 뒤 커서를 보고해야 지연을 해석할 수 있습니다.

원본에서각서버로독립적으로적용되는그룹버전원본POST_STATUS버전1의표시명은공개됨입니다. 원본에서세서버로분기한화살표는해당그룹스냅샷의적용관계입니다. A만적용완료인실선이고B와C는미적용인점선입니다. 서버끼리차례로전송하는경로가아닙니다.실선: 적용 완료원본 POST_STATUSrevision 1새 이름: 공개됨서버 A · 적용 11 − 1 = 지연 0표시명: 공개됨왼쪽 점선: 미적용서버 B · 적용 01 − 0 = 지연 1표시명: 게시됨오른쪽 점선: 미적용서버 C · 적용 01 − 0 = 지연 1표시명: 게시됨
원본에서각서버로독립적으로적용되는그룹버전원본POST_STATUS버전1의표시명은공개됨입니다. 원본에서세서버로분기한화살표는해당그룹스냅샷의적용관계입니다. A만적용완료인실선이고B와C는미적용인점선입니다. 서버끼리차례로전송하는경로가아닙니다.실선: 적용 완료원본 POST_STATUSrevision 1새 이름: 공개됨서버 A · 적용 11 − 1 = 지연 0표시명: 공개됨왼쪽 점선: 미적용서버 B · 적용 01 − 0 = 지연 1표시명: 게시됨오른쪽 점선: 미적용서버 C · 적용 01 − 0 = 지연 1표시명: 게시됨

실선은 적용 완료, 점선은 이 스냅샷에 아직 도달하지 않은 서버입니다. B와 C 사이에는 전달 간선이 없습니다. 아래 SQL은 처리기 실행 대신 버전·커서를 직접 기록한 모형입니다.

한 트랜잭션 · 원본 변경 발행

  1. 그룹 revision 행 FOR UPDATE
  2. PUBLISHED 이름 → 공개됨
  3. revision 0 → 1 · outbox 1행 INSERT
  4. 모두 성공 → COMMIT / 오류 → 중단 후 ROLLBACK

server-a · 적용 완료

원본 1 − 적용 1 = 지연 0

새 이름: 공개됨

server-b / server-c · 미적용

원본 1 − 적용 0 = 지연 1

이전 이름: 게시됨

소비자 복구 순서

  1. 그룹 안에서 커서 이후 이벤트 읽기
  2. 캐시 반영 성공 후 커서 전진 · 중복 무시
  3. 필요한 그룹 revision의 이벤트 삭제 확인 → 전체 재적재

커서 숫자만 바꾸면 적용 증거가 되지 않습니다. 전역 change_id는 그룹 간 커밋 순서를 보장하지 않고, TTL만으로 갱신 실패 때의 지연 상한을 보장하지 않습니다. 본문은 SQL 복사본·커서 모형이며 실제 캐시 처리기를 실행하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 개정 0부터 각자 따라잡기까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목원본에서 각 서버로 독립적으로 적용되는 그룹 버전
관계 설명원본 POST_STATUS 버전 1의 표시명은 공개됨입니다. 원본에서 세 서버로 분기한 화살표는 해당 그룹 스냅샷의 적용 관계입니다. A만 적용 완료인 실선이고 B와 C는 미적용인 점선입니다. 서버끼리 차례로 전송하는 경로가 아닙니다.
설명·조건실선은 적용 완료, 점선은 이 스냅샷에 아직 도달하지 않은 서버를 나타냅니다. B와 C 사이에는 전달 간선이 없습니다. 아래 SQL은 처리기 실행 대신 버전·커서를 직접 기록한 모형입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
개정 0

서버가 이전 이름을 읽는다.

  • 개정·소비 커서 1: group revision0
  • 개정·소비 커서 2: A/B/C applied0
  • 게시됨 표시
개정 1 함께 확정

그룹 잠금 뒤 이름과 outbox를 저장한다.

  • 개정·소비 커서 1: 공개됨
  • 개정·소비 커서 2: revision1
  • 개정·소비 커서 3: outbox1
  • 한 COMMIT으로 원본 확정
A만 적용

B/C는 아직 이전 캐시를 사용한다.

  • 개정·소비 커서 1: A applied 1/lag0
  • 개정·소비 커서 2: B/C applied0/lag1
  • A 적용 확인 뒤 커서 1
각자 따라잡기

각 소비자가 자기 커서부터 처리한다.

  • 개정·소비 커서 1: B/C도 성공 뒤 applied 1
  • 개정·소비 커서 2: 이벤트 누락이면 전체 재적재
  • 독립 적용; TTL로 지연 상한 보장 없음

TTL과 아웃박스의 역할

TTL은 버전 규칙을 대신하지 못하지만, 안전망으로 함께 쓸 수 있습니다. 이벤트 소비자가 멈추었을 때 TTL을 기준으로 다시 적재하는 처리기는 따로 구현해야 하고, 버전 지연의 지표는 마지막으로 보고된 적용 상태를 보여 줍니다.

코드 변경 아웃박스의 보존 기간보다 서버의 장애가 길 수 있습니다. 그룹별로 마지막에 적용한 버전과 다음에 필요한 버전을 비교하고, 어느 구간까지 지웠는지 보존 범위도 명시적으로 기록합니다. 필요한 이벤트가 이미 지워졌다면 증분 처리를 멈추고 전체 스냅샷을 원자적으로 교체하며, 최소 change_id와 커서의 크기 비교만으로는 소비하지 않은 이벤트가 지워진 것인지 정상적인 ID의 빈틈인지 구분할 수 없습니다.

방식장점비용알맞은 경우
매 요청 데이터베이스 JOIN그 읽기 시점의 데이터베이스 값을 쓴다네트워크와 쿼리의 비용이 붙는다트래픽이 작고 신선도의 요구가 엄격할 때
TTL 로컬 캐시구조가 단순하고 빠르다TTL 동안 오래된 값을 낼 수 있다분 단위의 지연을 허용할 때
버전 폴링지연을 측정하고 단순하게 복구한다주기적인 조회가 필요하다코드 집합이 작을 때
아웃박스 푸시전파가 빠르고 다시 처리할 수 있다발행자와 커서를 운영해야 한다서버가 많고 지연 목표가 짧을 때

데이터베이스 JOIN도 현재 트랜잭션의 일관된 읽기 스냅샷이나 복제본의 지연에 영향을 받으므로, 어느 환경에서나 마지막으로 커밋된 값을 읽는다는 뜻은 아닙니다.

코드 스냅샷은 새 맵을 다 만든 뒤에 참조를 바꾸어, 갱신 중에 일부만 채운 목록이 드러나지 않게 합니다. 데이터와 버전을 서로 다른 읽기 시점에서 가져오면, 새 이름을 버전 0의 것으로 표시하는 식의 잘못된 짝이 생길 수 있습니다. InnoDB에서 전체 재적재를 구현한다면, 읽기 전용 REPEATABLE READ 트랜잭션의 일관된 SELECT처럼 두 조회가 같은 스냅샷을 쓰게 하고, READ COMMITTED에서는 같은 트랜잭션 안이라도 일관된 SELECT마다 새 스냅샷을 읽는다는 점에 주의합니다(InnoDB의 일관된 읽기).

운영에서는 그룹별 원본 버전과 소비자의 버전 지연, 가장 오래된 커서의 경과 시간, 아웃박스의 적체량, 발행자의 재시도 수를 지켜봅니다. 서버의 시계가 달라도 버전의 비교는 영향을 받지 않지만, refreshed_at으로 계산하는 지연은 시계의 동기화가 필요합니다. 비활성 코드도 과거 행을 보여 주기 위해 캐시 스냅샷에 넣고 새 선택 목록에서만 거르며, 여러 리전이 있다면 전역 이벤트의 순서보다 그룹별 버전과 리전별 커서를 비교합니다.

따져 볼 것질문
지연허용할 수 있는 데이터의 지연이 몇 초 또는 몇 분인가
규모서버와 코드 그룹이 얼마나 많은가
측정어느 서버가 몇 번의 변경만큼 뒤처졌는지 알 수 있는가
복구이벤트가 빠진 구간 뒤에 전체 스냅샷으로 돌아갈 수 있는가
원자성코드의 변경과 발행 기록이 한 트랜잭션인가

캐시를 쓴다는 결정은 오래된 값을 없애는 것이 아니라, 허용할 범위와 복구 방법을 정하는 일입니다. 버전이 있어야 그 범위를 숫자로 말할 수 있습니다.

정리

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

연습 문제

1. 증분 처리와 전체 재적재

이 모델처럼 변경 한 번마다 그룹 버전이 1씩 늘고 모든 변경이 이벤트로 남는다고 합시다. 소비자의 적용 버전은 1이고 원본은 3입니다. 남아 있는 POST_STATUS 이벤트가 버전 2와 3인 경우와, 버전 3만 남고 버전 2는 지워졌다는 보존 기록이 있는 경우에 소비자는 각각 어떻게 해야 할까요?

해설 보기

첫 경우는 버전 2를 적용한 뒤 3을 적용하고, 각 적용에 성공할 때마다 커서를 앞으로 옮깁니다. 두 번째 경우는 필요한 버전 2가 사라졌으므로, 3만 적용하고 완전한 상태라고 보고할 수 없습니다. 이때는 코드 전체와 그 시점의 버전을 같은 읽기 상태에서 가져와 새 맵을 만든 뒤 한 번에 교체하고, 커서를 버전 3으로 옮깁니다.

이 판단은 그룹 버전이 연속되고, 발행이 원자적이며, 지운 구간이 명시적으로 기록된다는 조건에 기댑니다. 전역 ID의 차이를 계산하는 것으로 대신할 수 없습니다.

2. 지연된 소비자의 경고

소비자가 원본보다 뒤처졌거나 오랫동안 보고하지 않은 상태를 찾는 경고 쿼리를 만들어 보세요. 같은 버전을 다시 보고하면 행이 늘지 않고 한 행의 UPDATE로 끝나야 하며, 늦게 도착한 오래된 보고가 새 커서를 덮어쓰지 않아야 합니다.

해설 보기

기존 커서인 서버 B의 갱신과, 새 서버 D의 최초 등록을 따로 처리합니다. 갱신에는 보고하는 버전이 현재 값보다 작지 않을 때만 바꾸는 조건을 붙입니다.

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'
  AND applied_revision <= 1;

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
ORDER BY c.consumer_id;

서버 B를 같은 값으로 다시 보고해도 행은 늘지 않고, applied_revision <= 1 조건 때문에 버전 2를 이미 적용한 커서라면 늦게 온 버전 1의 보고가 0행을 바꾸고 끝납니다. 서버 D의 NOT EXISTS 뒤 INSERT는 두 요청이 동시에 등록하면 기본 키 오류 1062가 날 수 있으므로, 호출하는 쪽이 그 오류를 이미 등록된 것으로 처리합니다.

경고 쿼리에는 서버 A와 C가 남습니다. 서버 A는 버전은 최신이지만 마지막 보고가 2026-07-14 10:00이라 5분이 훨씬 지났고, 서버 C는 버전 지연 1에 보고도 오래되었습니다. 방금 보고한 B와 D는 최신이므로 빠지며, age_seconds의 값은 쿼리를 실행한 시각에 따라 달라집니다.

다음 글 DB 코드와 애플리케이션 열거형에서는 데이터베이스의 코드 목록과 애플리케이션의 ENUM이 서로 다른 배포 주기를 가질 때, 릴리스마다 둘의 일치를 확인하는 규칙을 만듭니다.