levish
levish
이전으로

NATS를 붙였다가 다시 빼게 된 이유

모든 비동기 작업을 JetStream에 넣었다가, Region 안에서 끝나는 일은 PostgreSQL 큐로 다시 옮긴 과정.

Sevenwiki — The Knowledge Infrastructure

August 31, 2026

개발SevenwikiPostgreSQLQueues

비동기 작업이면 일단 메시지 브로커 아닌가?

문서를 저장하고 나면 뒤에서 할 일이 꽤 많습니다.

SevenMark로 링크와 include, 파일 참조를 분석해야 하고, Meilisearch 색인도 갱신해야 합니다. 역링크 캐시를 비우고, 구독자 알림을 만들고, 더 이상 쓰지 않는 객체를 삭제하는 작업도 있습니다.

이런 일을 문서 저장 요청 안에서 전부 처리하면 응답이 느려집니다. 파서나 검색 엔진 하나가 잠깐 멈췄다고 문서 저장까지 실패할 수도 있습니다.

그래서 처음에는 자연스럽게 JetStream에 넣었습니다.

저는 Kafka와 RabbitMQ도 써봤고, V7의 서비스 간 이벤트에는 NATS JetStream을 사용하고 있었습니다. 이미 ACK와 재전달, 메시지 보존, 여러 consumer를 갖춘 브로커가 있는데 굳이 다른 작업 큐를 만들 이유가 없어 보였습니다.

문서 저장
  ↓
JetStream publish
  ↓
worker
  ↓
파싱 / 검색 / 캐시 갱신

구조도 익숙했고 워커를 늘리기도 쉬웠습니다.

그런데 Region 내부 작업까지 넣고 보니 한 바퀴를 너무 크게 돌고 있었습니다.

같은 DB에서 시작해 같은 DB로 돌아왔습니다

문서 분석 작업을 자세히 따라가보면 이렇습니다.

Region PostgreSQL에 문서 커밋
          ↓
JetStream에 작업 발행
          ↓
Region worker가 메시지 수신
          ↓
다시 같은 Region PostgreSQL에서 최신 문서 조회
          ↓
결과를 같은 Region PostgreSQL에 저장

원본도 같은 Region DB에 있고, 결과도 같은 Region DB에 있습니다. 브로커를 한 번 돌아서 다시 출발지로 돌아오는 셈입니다.

더 큰 문제는 문서 커밋과 메시지 발행 사이였습니다.

데이터베이스 커밋이 성공한 직후, JetStream에 메시지를 보내기 전에 프로세스가 종료되면 문서는 바뀌었지만 분석 작업은 없습니다.

이를 막으려면 결국 Region DB에 outbox를 만들고, 별도 relay가 JetStream에 발행해야 합니다.

문서 transaction
  └─ outbox 기록
         ↓
      relay
         ↓
   JetStream
         ↓
      worker
         ↓
같은 Region DB

한 데이터베이스 안에서 끝나는 작업을 위해 DB, outbox, relay, broker, consumer를 모두 거치고 있었습니다.

NATS가 중단되면 문서 참조 분석과 검색 색인도 같이 멈춥니다. Region마다 스트림과 consumer, DLQ도 관리해야 합니다.

비동기라는 이유만으로 같은 도구를 쓰는 것이 오히려 구조를 복잡하게 만들고 있었습니다.

기준을 기술이 아니라 데이터 위치로 바꿨습니다

그 뒤부터 비동기 작업을 두 종류로 나눴습니다.

한 Region 안에서 시작하고 끝나는 작업
  → PostgreSQL 큐

다른 서비스의 데이터를 바꿔야 하는 이벤트
  → Transactional Outbox + JetStream

문서의 현재 리비전이 바뀌었다는 사실과, 그 리비전을 분석해야 한다는 작업은 같은 Region DB에 있습니다. 한 트랜잭션에 함께 기록할 수 있습니다.

검색 색인도 Region DB에서 다시 만들 수 있는 조회용 데이터입니다. 역링크 캐시는 Region이 관리하는 Redis에 있고, 삭제할 리비전 객체의 키도 Region이 알고 있습니다.

반면 Auth의 세션 폐기를 Region에 알리거나, Media의 새 공개 버전을 각 사이트에 전달하는 일은 서로 다른 데이터베이스 사이를 건넙니다. 이 경우에는 원본 서비스의 outbox와 JetStream, 수신 측의 중복 처리와 복구 기능이 필요합니다.

워커가 별도 프로세스인지 아닌지는 기준이 아니었습니다. 원본 변경과 작업 등록을 같은 데이터베이스 커밋에 넣을 수 있는지가 기준이었습니다.

JetStream을 빼고 싶어서 PostgreSQL을 쓴 것이 아니라, JetStream이 필요한 경계와 필요하지 않은 경계를 다시 나눈 것입니다.

jobs(type, payload) 하나로 만들지는 않았습니다

PostgreSQL을 작업 큐로 쓰기로 하자 가장 먼저 떠오른 구조는 범용 테이블 하나였습니다.

jobs
  - id
  - type
  - payload jsonb
  - status

구현은 빠릅니다. 공통 워커가 type을 보고 분기하면 웬만한 작업을 전부 넣을 수 있습니다.

하지만 V7의 작업들은 완료의 의미가 서로 달랐습니다.

  • 문서 후처리는 문서마다 최신 리비전을 분석해야 합니다.
  • 역링크 캐시 무효화는 같은 (namespace, title) 요청을 합칠 수 있습니다.
  • 검색 색인은 문서, 토론, 사용자처럼 대상 종류가 다릅니다.
  • 객체 삭제는 storage key별로 성공 여부를 남겨야 합니다.
  • 검색 전체 재구축은 어디까지 훑었는지 cursor가 필요합니다.

결국 작업마다 별도 테이블을 만들었습니다.

document_post_processing_jobs
search_index_jobs
backlink_cache_invalidation_jobs
content_deletion_jobs
search_rebuild_operations

처음에는 테이블이 많아지는 것이 과한 설계처럼 보이기도 했습니다.

그래도 각 작업의 상태를 열과 CHECK 제약, 부분 인덱스로 표현할 수 있다는 장점이 컸습니다. 잘못된 payload를 런타임에서 뒤늦게 발견하지 않고, 데이터베이스가 불가능한 상태 자체를 거부하게 만들 수 있었습니다.

범용 큐의 편리함보다 각 작업이 언제 끝난 것으로 볼 수 있는지를 명확히 남겼습니다.

모든 중간 리비전을 처리할 필요는 없었습니다

한 문서가 짧은 시간에 열 번 저장됐다고 해보겠습니다.

검색과 링크 분석을 위해 열 개 리비전을 모두 순서대로 처리할 필요는 없습니다. 사용자에게 중요한 것은 마지막 현재 리비전의 결과가 정확한지입니다.

그래서 document_post_processing_jobs는 문서마다 행 하나를 사용합니다.

requested_generation
completed_generation
claimed_generation
requested_revision_id

문서가 다시 저장되면 새 작업 행을 계속 추가하지 않습니다. 처리해야 할 리비전 ID를 최신 값으로 바꾸고 requested_generation을 올립니다.

워커가 5번 세대를 처리하는 동안 6번이 요청되면, 5번을 끝낸 뒤에도 다음 조건이 남습니다.

completed_generation < requested_generation

워커는 같은 문서를 다시 가져와 최신 리비전을 처리합니다.

이 큐는 모든 변경을 보존하는 이벤트 로그가 아닙니다. 최신 상태로 수렴하기 위한 작업 요청입니다.

누가 어떤 리비전을 만들었는지는 immutable 리비전과 활동 로그에 이미 남아 있습니다. 모든 이력을 보존할 곳과 최신 결과만 필요한 곳을 구분했습니다.

중간 결과를 과감하게 합칠 수 있었던 이유도 원본 이력이 다른 곳에 안전하게 남아 있기 때문입니다.

작업을 가져간 워커도 영원히 믿지 않았습니다

여러 워커가 동시에 큐를 읽을 때는 같은 작업을 두 번 가져가지 않게 해야 합니다.

V7 워커는 FOR UPDATE SKIP LOCKED로 처리할 행을 고릅니다. 다른 워커가 잠근 행을 기다리지 않고 다음 대기 작업으로 넘어갑니다.

작업을 가져갈 때는 UUIDv7 claim_token, 처리할 세대와 claimed_until을 기록합니다.

claim_token
claimed_generation
claimed_until

문서 후처리 작업은 기본적으로 300초 동안 해당 워커에 할당됩니다. 실행 중에는 30초마다 만료 시각을 연장합니다.

워커가 중간에 죽으면 할당 시간이 지난 뒤 다른 워커가 같은 작업을 가져갈 수 있습니다.

완료나 실패를 기록할 때는 문서 ID만 사용하지 않습니다. 처음 받은 claim_token과 세대가 여전히 같은지 확인합니다.

할당 시간을 잃은 오래된 워커가 뒤늦게 돌아와 성공을 기록하면, 이미 새 워커가 처리한 결과를 덮을 수 있기 때문입니다.

분산 락처럼 영구적인 소유권을 주지 않고, 만료되는 임대와 토큰으로 그 순간의 작업 권한을 확인했습니다.

오래 걸린 분석 결과가 최신 문서를 덮으면 안 됐습니다

워커가 문서 본문을 R2에서 읽고 SevenMark로 분석하는 동안 사용자는 문서를 다시 저장할 수 있습니다.

예를 들어 워커가 revision 41을 분석하고 있는데 현재 문서는 이미 revision 42가 됐을 수 있습니다.

분석이 끝났다고 41의 링크와 파일 참조를 저장하면 최신 문서의 결과를 과거 값으로 덮게 됩니다.

그래서 결과를 쓰기 직전에 다음을 같은 트랜잭션에서 확인합니다.

  • claim_token과 세대가 여전히 같은가
  • 작업의 임대 시간이 아직 유효한가
  • 분석한 리비전이 아직 문서의 현재 리비전인가

현재 리비전이 달라졌다면 분석 결과를 버립니다. 요청 세대는 더 높게 남아 있으므로 워커가 최신 리비전을 다시 처리합니다.

revision 41 분석 시작
          ↓
사용자가 revision 42 저장
          ↓
41 분석 완료
          ↓
현재 리비전 확인
          ↓
41 결과 폐기, 42 다시 처리

비싼 작업을 했으니 아깝더라도, 과거 결과로 최신 상태를 덮는 것보다는 낫습니다.

Redis의 include 매니페스트 갱신은 best effort로 처리합니다. 실패해도 PostgreSQL에 저장한 문서 참조 결과까지 되돌리지는 않습니다. 캐시는 다시 만들 수 있지만 Region DB의 참조 데이터가 기준이기 때문입니다.

실패한 작업이 사라지지 않게 했습니다

백그라운드 워커는 사용자 화면에 잘 보이지 않습니다. 그래서 조용히 실패하기도 쉽습니다.

작업이 실패하면 시도 횟수와 마지막 오류, 다음 실행 시각을 남깁니다. 잠시 기다린 뒤 다시 시도하고, 현재 구현에서는 20회 실패하면 terminal 상태로 전환합니다.

무한히 재시도하면 영구 오류 하나가 계속 자원을 사용합니다. 반대로 일정 횟수 뒤 그냥 버리면 운영자가 문제가 있었는지조차 모를 수 있습니다.

그래서 Region DB에 큐 상태를 보는 뷰를 만들었습니다.

  • 대기 중인 작업 수
  • terminal 작업 수
  • 만료된 임대
  • 가장 오래 기다린 작업
  • 가장 높은 시도 횟수

terminal 작업을 다시 여는 함수도 작업별로 둡니다. 일반 애플리케이션 계정에는 권한을 주지 않고 운영용 역할만 실행할 수 있습니다.

재시도할 때 임의 payload를 새로 넣지는 않습니다. 현재 작업의 식별자와 세대는 유지한 채 실패 정보와 실행 가능 시각을 초기화합니다.

워커 프로세스가 살아 있다는 것과 실제 큐가 정상적으로 비워지고 있다는 것을 따로 볼 수 있게 했습니다.

PostgreSQL이 모든 큐의 답은 아닙니다

Region 내부 작업을 PostgreSQL로 옮기면서 구조는 분명해졌습니다.

문서 변경과 작업 등록을 한 트랜잭션에 넣을 수 있고, NATS 장애와 관계없이 파싱과 검색을 계속 처리할 수 있습니다. 최신 리비전만 남기는 세대 방식도 작업의 domain key로 직접 표현할 수 있습니다.

하지만 단점도 있습니다.

  • 주기적인 polling이 Region DB 부하에 포함됩니다.
  • 작업 종류마다 테이블과 마이그레이션, 워커 코드를 따로 만들어야 합니다.
  • 매우 높은 처리량의 범용 작업 큐를 대체하려는 구조는 아닙니다.
  • 현재 1초인 조회 간격도 실제 부하와 원하는 지연에 따라 조정해야 합니다.

PostgreSQL이 이미 있으니 모든 메시징을 없애자는 결론은 아니었습니다.

한 Region의 현재 데이터에서 다시 계산할 수 있고 결과도 같은 Region 안에 저장되는 작업에만 사용합니다.

그리고 다음 글에서 다룰 Auth, Media, Sitelink 이벤트처럼 실제로 서비스 경계를 건너는 변경에는 여전히 JetStream이 필요했습니다.

NATS를 붙였다가 뺀 것이 아니라, 처음에는 너무 넓게 붙였던 범위를 제자리로 줄인 셈입니다.

시리즈 11 / 12
© 2026 levish