이벤트를 놓친 Region은 어떻게 따라잡을까?
JetStream 이벤트가 끊기거나 데이터베이스가 PITR로 과거로 돌아갔을 때 변경 내역과 스냅샷으로 Region을 복구한 과정.
Sevenwiki — The Knowledge InfrastructureAugust 31, 2026
처음에는 consumer를 다시 켜면 되는 줄 알았습니다
Auth는 사용자 정보와 보안 상태의 변경을 각 Region에 이벤트로 전달합니다.
JetStream의 durable consumer가 마지막으로 ACK한 위치를 기억하니, Region 워커가 잠깐 멈춰도 다시 실행하면 이어서 받을 수 있습니다. 같은 메시지가 다시 오면 중복만 제거하면 됩니다.
처음에는 이 정도면 복구도 충분하다고 생각했습니다.
하지만 운영 상황을 하나씩 넣어보니 전제가 너무 많았습니다.
- 워커가 메시지 보존 기간보다 오래 멈출 수 있습니다.
- JetStream의 스트림이나 consumer가 다시 만들어질 수 있습니다.
- 같은 이벤트가 여러 번 오거나 중간 번호를 건너뛴 이벤트가 먼저 올 수 있습니다.
- Region 데이터베이스는 PITR로 어제로 돌아갔는데 ACK 위치는 오늘에 남을 수 있습니다.
- 반대로 Auth 데이터베이스가 과거로 복구되면 Region이 원본보다 더 높은 버전을 기억할 수 있습니다.
이 상태에서 consumer만 지우고 처음부터 재생하면 될까요?
JetStream에 전체 변경 이력이 남아 있다는 보장이 없습니다. 설령 메시지가 남아 있더라도, 그 이벤트가 PITR 이후의 현재 Auth와 같은 역사에서 만들어진 것인지 확인해야 합니다.
결국 메시지를 다시 받는 것과 데이터를 복구하는 것을 따로 봐야 했습니다.
브로커는 빠르게 알려주고, Auth DB가 현재를 결정합니다
V7에서 JetStream은 변경을 빠르게 전달하는 역할을 맡습니다.
하지만 Region이 무엇을 현재 사용자 상태로 인정할지는 Auth PostgreSQL의 변경 기록과 스냅샷을 기준으로 정합니다.
JetStream
→ 빠른 전달과 재전달
Auth PostgreSQL
→ 현재 상태와 순서가 있는 변경 기록
Region PostgreSQL
→ 조회용 데이터와 마지막 처리 위치 Region은 자신이 마지막으로 빠짐없이 반영한 버전을 저장합니다. 단순히 가장 큰 숫자를 본 시점이 아닙니다.
이 구분을 해두면 JetStream의 메시지가 사라져도 Auth의 변경 내역 API나 스냅샷에서 다시 따라갈 수 있습니다. 반대로 broker가 멀쩡하다고 해서 Region 데이터도 정확하다고 섣불리 판단하지 않습니다.
메시징 시스템이 제공하는 복구와 서비스 데이터가 필요로 하는 복구가 서로 다른 문제였습니다.
PITR를 해보니 버전 번호가 다시 작아졌습니다
이벤트마다 1, 2, 3처럼 증가하는 버전을 붙이면 순서를 확인하기 쉽습니다.
Region이 70번까지 처리했고 다음에 71번이 오면 그대로 적용하면 됩니다. 72번이 먼저 오면 71번이 빠졌다고 판단할 수 있습니다.
문제는 원본 데이터베이스를 PITR로 과거 시점에 복구했을 때입니다.
복구 전 Auth: version 70
Region: version 70
Auth PITR 후: version 35 PITR 이후 Auth가 새 변경을 만들어 version 36을 발행하면, Region은 이미 처리한 오래된 이벤트라고 생각할 수 있습니다. 숫자만 보면 36이 70보다 작기 때문입니다.
하지만 이 36은 과거의 36과 같은 사건이 아닙니다. 데이터베이스의 역사가 한 번 갈라진 뒤 새로 생긴 36입니다.
그래서 V7은 버전과 함께 recovery_generation을 관리합니다.
(old generation, 70)
≠
(new generation, 35) 버전 크기는 같은 복구 세대 안에서만 비교합니다. Auth가 PITR 뒤 새 세대로 전환되면 Region은 이전 세대의 처리 위치를 그대로 이어 쓰지 않습니다.
접근 토큰에도 같은 복구 세대를 넣습니다. 복구 전에 발급한 토큰은 서명이 올바르더라도 현재 Auth와 다른 역사에서 나온 토큰으로 구분할 수 있습니다.
PITR는 데이터만 과거로 돌리는 작업이 아니었습니다. 다른 서비스가 기억하는 미래와의 관계까지 다시 정해야 했습니다.
프로필 변경과 세션 폐기를 같은 줄에 세우지 않았습니다
Auth 이벤트에는 성격이 다른 데이터가 섞여 있습니다.
사용자 이름과 프로필 같은 identity 변경이 있고, 세션 폐기나 계정 정지처럼 즉시 반영할수록 좋은 security 변경이 있습니다.
처음에는 Auth 전체에 하나의 버전만 두는 편이 단순해 보였습니다. 그런데 프로필 변경이 많이 쌓였다는 이유로 세션 폐기 복구가 늦어지는 것은 이상했습니다. 사용자 이름만 필요한 서비스가 모든 보안 이벤트까지 받을 이유도 없었습니다.
현재 Region은 같은 recovery_generation 안에서 두 처리 위치를 따로 기억합니다.
recovery_generation
identity_source_watermark
security_source_watermark Auth의 현재 세대와 identity/security 최신 버전은 함께 확인합니다. 실제 따라잡기는 각각 독립적으로 수행합니다.
하나의 Auth 역사 안에 있다는 사실은 공유하되, 프로필 변경과 보안 변경이 서로의 진행을 막지는 않게 한 것입니다.
코드를 보기 좋게 나눈 것이 아니라, 덜 중요한 이벤트가 세션 폐기 반영을 밀어내지 않도록 나눈 경계였습니다.
새 이벤트가 오면 세 경우만 허용했습니다
Region이 마지막으로 처리한 버전을 watermark라고 하면 새 이벤트는 세 가지 중 하나입니다.
received <= watermark
→ 이미 처리한 이벤트
received == watermark + 1
→ 정상적으로 다음 이벤트
received > watermark + 1
→ 중간 이벤트가 빠짐 이미 처리한 이벤트가 다시 오면 데이터를 또 바꾸지 않고 ACK합니다.
정확히 다음 이벤트라면 조회용 데이터 변경과 watermark 증가를 같은 PostgreSQL 트랜잭션에 넣습니다. 데이터만 바뀌고 처리 위치는 그대로이거나, 처리 위치만 앞으로 가고 데이터가 반영되지 않는 상태를 만들지 않습니다.
중간 번호가 빠졌다면 큰 숫자로 점프하지 않습니다.
보안 버전 42에 세션 폐기가 있었는데 43을 먼저 적용해 watermark를 올려버리면 42는 영원히 놓칠 수 있습니다. 새 메시지가 더 최신처럼 보여도, 연속성이 깨졌다면 일반 처리를 멈춥니다.
사용자 삭제도 같은 규칙을 따릅니다. 삭제 표시와 Region에 남은 개인정보 정리, 검색 제거 작업을 한 트랜잭션에 기록합니다. 같은 이벤트가 다시 와도 안전하게 같은 결과로 수렴해야 합니다.
조금 빠졌다면 변경 내역을, 많이 빠졌다면 스냅샷을 씁니다
이벤트 누락을 발견하면 Region은 먼저 Auth의 현재 세대와 최신 버전을 확인합니다.
같은 복구 세대이고 빠진 범위가 아직 변경 기록에 남아 있다면 마지막 watermark 다음부터 변경 내역 API로 가져옵니다.
Region watermark
│
└─ GET changes(after=watermark, limit=N)
│
▼
연속된 이벤트 페이지를 받을 때마다 버전이 정확히 이어지는지 확인합니다. 중간 번호가 없거나 응답 도중 복구 세대가 바뀌면 적용을 중단합니다.
빠진 범위가 보존 기간을 넘어갔거나, Auth의 복구 세대 자체가 달라졌다면 스냅샷을 사용합니다.
스냅샷은 생성 시점의 사용자 정보와 보안 상태, 두 watermark를 고정된 조회 결과로 제공합니다. 여러 페이지를 읽는 동안 Auth에서 새 변경이 생겨도 페이지마다 서로 다른 현재를 섞지 않습니다.
같은 세대에서 스냅샷을 적용할 때는 이미 가진 더 최신 값을 과거로 되돌리지 않습니다. 세대가 바뀐 경우에는 이전 조회용 데이터를 새 세대의 상태로 교체합니다.
두 상황을 같은 upsert 코드로 처리하면 삭제된 사용자나 폐기된 세션이 잘못 되살아날 수 있었습니다. 그래서 같은 세대의 따라잡기와 새 세대로의 전환을 별도 경로로 뒀습니다.
스냅샷을 받는 동안 절반만 새 데이터면 안 됐습니다
스냅샷이 크면 여러 페이지로 나눠 받아야 합니다.
이 페이지를 받는 즉시 실제 사용자 테이블에 쓰면 복구 도중에는 앞쪽 사용자만 새 상태이고 뒤쪽 사용자는 아직 옛 상태가 됩니다. 인증 요청이 들어오는 시점에 따라 서로 다른 세대의 데이터를 볼 수 있습니다.
V7은 스냅샷을 임시 영역에 먼저 적재합니다. 모든 페이지를 받고 검증한 뒤 마지막 단계에서 실제 조회용 데이터로 전환합니다.
이때 일반 이벤트 수신 워커와 접근 토큰을 이용한 사용자별 갱신도 같은 순서로 잠금을 얻습니다.
그렇지 않으면 스냅샷을 적용하는 중간에 이전 세대의 토큰이 사용자를 다시 갱신하거나, 일반 이벤트가 절반짜리 새 데이터 위에 끼어들 수 있습니다.
스냅샷 복구는 단순히 많은 행을 UPSERT하는 일이 아니었습니다. 실제 인증 요청이 읽는 상태를 한 세대에서 다른 세대로 바꾸는 작업이었습니다.
워커가 살아 있다는 것만으로는 안심할 수 없었습니다
프로세스 health check가 초록색이어도 Region은 Auth를 따라잡지 못한 상태일 수 있습니다.
그래서 내부 상태 API에서 다음 정보를 볼 수 있게 했습니다.
- 현재
recovery_generation - identity와 security의 마지막 처리 버전
- 최근 확인한 Auth 세대와 최신 버전
- 각 최신 버전까지 빠짐없이 반영했는지
- 마지막 확인 시각과 스냅샷 시각
- 증분 복구와 스냅샷 실행 횟수
- 복구 실패 횟수
고위험 관리 작업은 이 정보를 바탕으로 security 데이터가 최근 확인한 Auth 최신 버전까지 따라왔는지 확인합니다.
이 지표가 필요한 이유는 장애가 났을 때 원인을 구분하기 위해서입니다.
메시지가 늦는 것인지, 중간 버전이 빠진 것인지, 스냅샷을 다시 받고 있는지, Auth가 새 복구 세대로 넘어간 것인지가 모두 다른 문제입니다. 단순한 worker_alive = true로는 아무것도 설명할 수 없었습니다.
복구 코드는 평소에는 조용하지만 꼭 필요했습니다
이 구조에는 구현할 것이 많습니다.
현재 상태 조회 API와 변경 내역 API, immutable snapshot, 임시 저장 영역, 세대 전환, 두 watermark, 상태 지표가 필요합니다. 단순히 JetStream 메시지를 받아 UPSERT하는 consumer보다 훨씬 복잡합니다.
그래도 저는 이 복잡도를 넣는 편을 택했습니다.
이벤트 보존 기간을 넘기는 일이나 PITR가 자주 일어나지는 않습니다. 하지만 한 번 일어났을 때 “consumer를 지우고 다시 켜보자” 외에는 방법이 없다면, Region이 가진 보안 데이터를 신뢰하기 어렵습니다.
중앙 Auth를 매 요청마다 호출하지 않으려면 Region이 자신이 어느 역사의 어느 지점까지 반영했는지 설명할 수 있어야 했습니다.
JetStream은 빠르게 알려주고, Auth DB는 현재를 정하고, Region DB는 어디까지 따라왔는지를 증명합니다.
이 셋의 역할을 나누고 나서야 이벤트를 놓쳐도 다시 현재로 돌아갈 수 있는 인증 구조가 됐습니다.
다음 글에서는 이렇게 복구한 사용자와 보안 상태를 실제 문서 권한에 어떻게 적용했는지, 그리고 왜 단순한 admin > moderator > user 등급만으로는 위키 ACL을 만들기 어려웠는지를 이야기해보겠습니다.