levish
levish
이전으로

그렇다고 JetStream이 필요 없었던 건 아닙니다

서비스 사이의 변경을 Transactional Outbox와 JetStream으로 전달하고, 중복과 누락은 원본 데이터에서 복구한 과정.

Sevenwiki — The Knowledge Infrastructure

August 31, 2026

개발SevenwikiNATSDistributed Systems

앞 글에서 NATS를 뺐다고 했지만요

앞 글에서는 문서 분석과 검색 색인처럼 Region 안에서 끝나는 작업을 JetStream에서 PostgreSQL 큐로 옮긴 이야기를 했습니다.

그렇다고 V7에서 JetStream이 필요 없어진 것은 아닙니다.

Auth에서 세션을 폐기하면 각 Region의 보안 정보도 바뀌어야 합니다. Media가 새 파일 버전을 공개하면 그 파일을 사용하는 사이트에 알려야 합니다. Sitelink 연결이 수정되면 관계에 참여한 Region이 새 상태를 받아야 합니다.

이 작업들은 원본과 결과가 서로 다른 데이터베이스에 있습니다.

Auth DB      → Region DB
Media DB     → Region DB
Sitelink DB  → Region DB

한 PostgreSQL 트랜잭션으로 묶을 수 없습니다. 원본 서비스가 다른 서비스의 DB에 직접 접속하게 만들고 싶지도 않았습니다.

같은 데이터베이스 안의 작업에는 PostgreSQL 큐가 잘 맞았지만, 실제 서비스 경계를 건너는 변경에는 여전히 메시지 전달 계층이 필요했습니다.

HTTP로 하나씩 호출하면 되지 않을까?

가장 단순한 방법은 원본 서비스가 대상 Region의 HTTP API를 직접 호출하는 것입니다.

예를 들어 Auth가 세션을 폐기한 뒤 등록된 Region을 하나씩 돌며 POST /session-revoked를 보내는 방식입니다.

대상이 하나뿐이고 항상 켜져 있다면 충분할 수 있습니다. 하지만 Region이 여러 개가 되면 바로 질문이 생깁니다.

  • Region 하나가 꺼져 있으면 Auth의 세션 폐기까지 실패시킬까?
  • 세 곳에는 전달됐고 한 곳만 실패했다면 어디까지 성공한 것으로 볼까?
  • 실패한 Region의 주소와 재시도 횟수는 누가 기억할까?
  • Auth DB를 커밋한 직후 첫 HTTP 요청 전에 프로세스가 죽으면 어떻게 할까?

원본 변경은 이미 유효한데 대상 하나의 장애 때문에 롤백하는 것도 이상했습니다. 반대로 원본만 커밋하고 전달 상태를 메모리에 두면 프로세스 종료와 함께 작업을 잃습니다.

결국 원본 변경과 전달할 일을 먼저 같은 데이터베이스에 남겨야 했습니다.

원본 변경과 outbox를 같이 저장했습니다

다른 서비스에 알려야 하는 변경은 원본 데이터와 outbox 행을 같은 트랜잭션에 기록합니다.

원본 서비스의 transaction
  ├─ 실제 domain data 변경
  ├─ 순서가 있는 event 기록
  └─ outbox 행

Auth의 보안 세대가 바뀌었다면 그 변경을 Region에 전달할 outbox도 같이 생깁니다. Media가 새 rendition을 공개하거나 Sitelink가 새 리비전을 만들 때도 마찬가지입니다.

이렇게 하면 원본 변경은 커밋됐는데 전달할 기록만 없는 상태를 만들지 않습니다. 반대로 원본 트랜잭션이 롤백됐는데 메시지만 JetStream에 남는 일도 없습니다.

별도 relay 워커가 아직 보내지 않은 outbox를 가져가 JetStream에 발행합니다. 워커는 일정 시간 동안 행을 할당받고, 브로커의 PUBACK를 받은 뒤에만 전달 완료로 표시합니다.

pending outbox
      ↓ claim
JetStream publish
      ↓ PUBACK
outbox delivered

NATS가 잠시 멈춰도 원본 서비스의 유효한 변경을 되돌리지는 않습니다. outbox가 데이터베이스에 남아 있으므로 브로커가 돌아온 뒤 다시 발행할 수 있습니다.

메시지는 두 번 올 수밖에 없었습니다

relay가 메시지를 정상적으로 발행한 뒤 PUBACK를 받기 전에 종료될 수 있습니다.

브로커에는 메시지가 들어갔지만 outbox는 아직 pending입니다. 워커가 다시 실행되면 같은 메시지를 또 보냅니다.

이를 완전히 피하려고 exactly-once publish를 가정하면 구현은 복잡해지고, 네트워크 경계에서 여전히 애매한 순간이 남습니다.

V7은 같은 메시지가 다시 오는 것을 정상적인 상황으로 봅니다.

메시지에는 고정된 ID와 원본 버전이 들어갑니다. 수신 서비스는 이를 inbox와 마지막 처리 버전에 대조합니다.

메시지 수신
  ↓
세대와 버전 확인
  ↓
조회용 데이터 갱신
+ inbox 기록
+ watermark 증가
  ↓
DB commit
  ↓
ACK

이미 처리한 메시지라면 데이터를 다시 바꾸지 않고 ACK합니다.

새 메시지라면 조회용 데이터 변경, inbox 기록과 처리 버전 증가를 같은 로컬 트랜잭션에 넣습니다.

DB 커밋 뒤 ACK 전에 수신 워커가 죽으면 JetStream은 메시지를 다시 보냅니다. 하지만 inbox가 이미 남아 있으므로 두 번 적용되지는 않습니다.

ACK를 먼저 보내고 DB 저장이 실패하는 순서는 허용하지 않습니다. 그 경우 브로커는 전달이 끝났다고 생각하지만 실제 데이터는 바뀌지 않아 이벤트를 잃기 때문입니다.

중복 처리는 단순히 함수 시작에서 ID를 한 번 조회하는 것으로 끝나지 않았습니다. 같은 메시지를 두 번 받아도 데이터와 후속 작업, watermark가 한 번 처리된 것처럼 남아야 했습니다.

subject는 이름이면서 권한이었습니다

처음에는 모든 이벤트를 넓은 와일드카드 subject에 넣고, 수신 워커가 payload를 본 뒤 자신에게 필요한지 판단하는 방식도 생각했습니다.

구현은 편하지만 필요하지 않은 데이터까지 각 Region에 전달됩니다. 잘못된 구독 설정 하나로 다른 사이트의 이벤트를 읽을 가능성도 커집니다.

V7에서는 이벤트 종류와 대상 사이트 또는 Region을 subject에 포함합니다.

원본 서비스
  └─ 이벤트 종류
       └─ 대상 site / region

NATS 자격 증명도 필요한 subject만 발행하거나 구독할 수 있게 설정합니다.

따라서 subject 이름은 단순한 분류가 아닙니다. 어느 서비스가 무엇을 보낼 수 있고, 어느 Region이 어떤 메시지를 읽을 수 있는지를 제한하는 권한의 일부입니다.

새 subject를 추가할 때 애플리케이션 코드만 바꾸면 안 됩니다.

  • NATS provisioning 설정
  • stream이 받아들일 subject 목록
  • consumer 이름과 대상
  • 발행·구독 권한
  • smoke test

를 함께 수정해야 합니다.

이 부분이 어긋나면 로컬 테스트에서는 잘 되는데 실제 배포에서만 permission error가 나는 상황이 생깁니다. 메시지 계약과 배포 설정을 같은 변경으로 다루려는 이유입니다.

41 다음에 43이 오면 일단 멈췄습니다

중복은 비교적 단순합니다. 이미 처리한 버전보다 작거나 같다면 다시 적용하지 않으면 됩니다.

더 위험한 것은 중간 버전이 빠진 경우입니다.

Region watermark: 41
received version: 43

43이 더 최신이라고 먼저 적용하고 watermark를 올리면 42를 영원히 놓칠 수 있습니다. 42가 프로필 변경이라면 잠시 어색한 정도일 수 있지만, 세션 폐기나 계정 삭제라면 보안 문제가 됩니다.

그래서 정확히 다음 버전이 아니면 일반 이벤트 처리를 멈춥니다.

  • 같은 recovery_generation이고 빠진 범위가 작다면 원본 서비스의 변경 내역 API로 채웁니다.
  • 변경 기록의 보존 기간을 넘겼다면 스냅샷으로 조회용 데이터를 다시 만듭니다.
  • 원본 DB가 PITR되어 recovery_generation이 달라졌다면 이전 버전과 숫자 크기를 비교하지 않고 새 세대로 전환합니다.

JetStream의 redelivery는 전송에 실패한 메시지를 다시 보내주는 기능입니다. 원본 변경 이력의 일부가 보존 기간 밖으로 사라졌거나, PITR로 데이터베이스 역사가 바뀐 상황까지 설명해주지는 않습니다.

그래서 메시지 전달과 데이터 복구를 별도 기능으로 만들었습니다.

JetStream을 백업처럼 사용하지 않았습니다

JetStream의 stream과 durable consumer는 분명 중요한 운영 데이터입니다.

하지만 이것을 Region의 조회용 데이터를 복구하는 유일한 근거로 사용하지는 않습니다.

Auth, Media와 Sitelink의 PostgreSQL에는 현재 상태와 순서가 있는 변경 기록이 남습니다. Region은 자신이 마지막으로 빠짐없이 처리한 버전을 알고 있습니다.

JetStream 메시지가 사라져도 둘을 비교해 변경 내역이나 스냅샷으로 다시 만들 수 있습니다.

JetStream
  → 빠른 전달과 wake-up

원본 PostgreSQL
  → 현재 상태와 변경 이력

Region PostgreSQL
  → 조회용 데이터와 처리 위치

실시간 SSE도 비슷합니다. PostgreSQL NOTIFY와 프로세스 내부 broadcast는 새 활동 로그가 생겼다고 빠르게 알려주는 역할을 합니다.

클라이언트가 알림을 놓치면 마지막 커서 이후의 활동 로그를 REST API로 다시 읽습니다. NOTIFY 자체를 이벤트의 유일한 원본으로 보지는 않습니다.

전달 수단이 사라져도 원본 데이터에서 다시 시작할 수 있어야 브로커와 캐시를 필요할 때 재구축할 수 있습니다.

결국 PostgreSQL 큐와 JetStream을 둘 다 남겼습니다

하나의 큐 기술로 모든 비동기 작업을 통일하면 다이어그램은 단순해집니다.

하지만 실제로는 두 종류의 작업이 있었습니다.

작업시작과 결과처리 방식
문서 참조 분석같은 RegionPostgreSQL 큐
검색 색인 갱신같은 RegionPostgreSQL 큐
Auth 세션 폐기 전달Auth → Regionoutbox + JetStream
Media 공개 버전 전달Media → Regionoutbox + JetStream
Sitelink 변경 전달Sitelink → Regionoutbox + JetStream

같은 DB 안에서는 원본 변경과 작업 등록을 한 트랜잭션에 넣을 수 있습니다. 서비스가 다르면 원본 서비스가 대상 DB를 직접 쓸 수 없으므로 이벤트 전달과 복구 절차가 필요합니다.

저는 처음에 비동기라는 공통점만 보고 둘을 한곳에 넣었습니다. 나중에는 작업이 어느 데이터베이스에서 시작해 어디에서 끝나는지를 기준으로 다시 나눴습니다.

PostgreSQL 큐와 JetStream을 함께 쓰는 것이 중복처럼 보일 수 있지만, 둘이 해결하는 실패는 서로 달랐습니다.

장애가 나면 어디에 무엇이 남는지를 정했습니다

분산된 전달에서는 “문제가 생겨도 재시도합니다”만으로는 부족했습니다.

각 실패 순간에 무엇이 남아 있는지 확인했습니다.

  • 원본 커밋 뒤 relay가 중단됨
    pending outbox가 남아 다시 발행합니다.

  • 발행은 성공했지만 PUBACK를 잃음
    같은 메시지가 다시 발행되고 수신 측이 중복을 제거합니다.

  • 수신 DB 커밋 뒤 ACK 전에 중단됨
    inbox와 watermark가 재적용을 막습니다.

  • 중간 버전이 빠짐
    큰 버전으로 건너뛰지 않고 변경 내역 또는 스냅샷으로 복구합니다.

  • JetStream의 메시지 기록을 잃음
    원본 DB의 현재 상태와 변경 이력에서 Region 데이터를 다시 만듭니다.

  • 원본 DB가 PITR됨
    recovery_generation으로 이전 역사와 새 역사를 구분합니다.

이 실패들은 하나의 queue healthy 지표로 합칠 수 없습니다.

outbox 적체, consumer 지연, Region의 watermark, 원본 최신 버전, 스냅샷 횟수와 DLQ를 따로 봐야 어느 구간에서 멈췄는지 알 수 있습니다.

시스템을 나누면서 코드만큼이나 “장애 뒤에 남는 증거”를 설계하는 일이 중요해졌습니다.

JetStream의 역할을 줄인 것이 아니라 제자리를 찾았습니다

JetStream은 V7에서 여전히 중요한 구성 요소입니다.

여러 Region에 이벤트를 빠르게 전달하고, 대상별 durable consumer와 재전달을 제공합니다. Auth나 Media가 모든 Region의 현재 주소와 연결 상태를 직접 관리하지 않아도 됩니다.

다만 JetStream이 원본 DB의 커밋과 Region DB의 커밋 사이를 자동으로 하나의 트랜잭션으로 만들어주지는 않습니다.

그 사이에는 outbox, 중복 처리, 버전 순서, recovery_generation, 변경 내역과 스냅샷이 필요합니다.

처음에는 메시지 브로커를 붙이면 비동기 문제 대부분이 해결될 것 같았습니다. 실제로 만들어보니 브로커가 잘하는 것은 빠르게 전달하는 일이었습니다. 무엇이 현재인지 결정하고, 전달 기록을 잃었을 때 다시 만드는 일은 원본 데이터베이스가 맡아야 했습니다.

앞 글에서 Region 내부 작업에서는 JetStream을 뺐고, 이번 글에서는 서비스 사이의 변경에 JetStream을 남겼습니다.

두 결정은 반대가 아닙니다. 같은 도구를 모든 곳에 쓰기보다, 각 도구가 잘하는 구간을 찾은 결과였습니다.

여기까지가 현재 공개한 V7 기술 연재의 1부입니다.

다음부터는 검색과 실시간 스트림, Media의 원본·rendition 구조, Sitelink의 전역 문서 연결, 계정 삭제와 모더레이션, 배포와 PITR 복구처럼 아직 제대로 풀지 못한 부분을 하나씩 이어가보려고 합니다.

시리즈 12 / 12
© 2026 levish