DB 와 검색 엔진이 어긋날 때 — 이중 쓰기 대신 아웃박스 패턴

DB 에는 있는 주문이 검색에서는 안 나오는데, 에러 로그엔 아무것도 없어요. 두 곳에 따로 쓰면 언젠가 어긋나거든요. 왜 조용히 어긋나는지, 한 곳에만 쓰고 나머지가 따라오게 하는 법을 정리했어요.

주문을 DB 에 저장하고 검색 엔진에도 넣어요. 두 곳에 다 있어야 목록도 나오고 검색도 되니까요.

대부분 잘 맞아요. 그런데 어느 날 확인해 보니 DB 에는 있는데 검색에는 없는 주문이 수백 건 있어요. 반대 경우도 있고요.

언제부터 어긋났는지 모르겠어요. 에러 로그에도 아무것도 없어요. 두 번의 쓰기 중 하나가 실패했는데 코드가 그걸 무시했거나, 아니면 그 사이에 프로세스가 죽었거나요.

두 곳에 쓰는 순간 둘이 어긋날 가능성이 생겨요. 그리고 그 어긋남은 아무 신호도 없이 쌓여요.

발견했을 때는 이미 언제부터 왜 그랬는지 추적할 수 없는 상태입니다.

왜 그런가

문제의 뿌리는 앞에서 본 것과 같아요. 두 시스템에 걸친 원자성을 코드 순서로 만들 수 없다는 것.

DB 에 쓰고 검색 엔진에 쓰는 코드를 생각해 볼게요. 첫 번째가 성공하고 두 번째가 실패하면 어긋나요. 두 번째가 성공했는데 응답을 못 받아 재시도하면 중복이 생길 수 있고요. 그리고 그 사이에 프로세스가 죽으면 첫 번째만 반영된 채로 끝나요.

순서를 바꿔도 마찬가지예요. 어느 쪽을 먼저 하든 그 사이에 실패 지점이 있어요.

트랜잭션으로 묶을 수도 없어요. 검색 엔진은 우리 DB 트랜잭션에 참여하지 않으니까요.

그리고 이 문제는 조용해요. 어긋난 순간에는 아무 일도 안 일어나요. 나중에 그 데이터를 조회할 때야 없다는 걸 알게 되고, 그때는 언제부터 왜 그랬는지 추적할 수 없어요.

동시성이 끼면 더 나빠져요. 같은 데이터를 두 요청이 거의 동시에 갱신하면, DB 에는 나중 값이 남고 검색 엔진에는 먼저 값이 남을 수 있어요. 두 쓰기의 순서가 시스템마다 다르게 정해지니까요. 각각은 성공했는데 결과가 다른 상태예요.

코드로 보면

한 곳에만 쓰고, 나머지는 따라오게:

코드
 db.save(order)
 search.index(order)     # 여기가 실패하면 조용히 어긋난다

· 아웃박스 — 한 트랜잭션에 사실만 기록
 BEGIN;
 INSERT INTO orders ...;
 INSERT INTO outbox (event) VALUES ('OrderCreated', ...);
 COMMIT;
 → 워커가 outbox 를 읽어 검색 엔진에 반영

· 또는 DB 변경 로그를 읽어 자동으로 흘려보낸다
 CDC → 검색 엔진 · 캐시 · 분석 저장소

· 어긋남을 발견하는 장치를 반드시 함께 둔다
 주기적으로 양쪽을 대조하고 차이를 알린다

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

해법의 핵심은 쓰기를 한 곳으로 모으는 것이에요.

한 곳을 진실의 원천으로 정하고, 나머지는 거기서 파생되게 만들어요. 그러면 두 번 쓰는 일이 없어지고, 파생이 실패해도 원천은 온전해요. 다시 흘려보내면 복구됩니다.

아웃박스가 그 구현이에요. 비즈니스 데이터와 "이 변경을 전파해야 한다"는 사실을 같은 트랜잭션에 기록하고, 워커가 그걸 읽어 다른 시스템에 반영해요. 앞서 본 이벤트 발행과 같은 구조예요.

변경 데이터 캡처도 같은 발상이에요. DB 의 변경 로그를 읽어 자동으로 흘려보내니, 애플리케이션 코드에 전파 로직이 아예 없어요. 코드가 깨끗해지고 빠뜨릴 여지도 없어요. 대신 별도 컴포넌트를 운영해야 합니다.

어느 쪽이든 결과적 일관성을 받아들여야 해요. 검색 엔진에 반영되기까지 몇 초가 걸려요. "방금 만든 게 검색에 안 나온다"를 UI 로 다뤄야 하고요. 대부분의 경우 이건 감당할 만한 대가예요 — 조용히 어긋나는 것보다 훨씬 낫습니다.

그리고 반드시 함께 있어야 하는 게 대조 장치예요. 어떤 방식을 쓰든 어긋남은 생겨요. 워커가 죽거나, 변환에 버그가 있거나, 수동으로 데이터를 고치면요. 그래서 주기적으로 양쪽을 비교하고 차이를 알리는 배치가 필요해요. 그리고 발견했을 때 다시 맞추는 절차도요.

여기서 순서 문제도 봐야 해요. 같은 데이터에 대한 여러 변경이 순서대로 반영되어야 해요. 아웃박스를 여러 워커가 병렬로 처리하면 순서가 섞일 수 있으니, 같은 키에 대해서는 직렬화하는 장치가 필요합니다.

처방

  1. 진실의 원천을 하나로 정하세요. 나머지는 거기서 파생되게 만드세요.
  2. 애플리케이션이 두 곳에 직접 쓰지 않게 하세요. 아웃박스나 변경 데이터 캡처로 흘려보내세요.
  3. 결과적 일관성을 UI 로 다루세요. "반영 중" 상태를 보여 주는 게 조용히 어긋나는 것보다 낫습니다.
  4. 대조 배치를 반드시 두세요. 어떤 방식을 써도 어긋남은 생깁니다.
  5. 어긋남을 발견했을 때 다시 맞추는 절차를 만들어 두세요. 발견만 하고 고칠 수 없으면 소용없어요.
  6. 같은 키에 대한 순서를 보장하세요. 병렬 처리하면 순서가 섞입니다.
  7. 파생 시스템을 처음부터 다시 만들 수 있게 하세요. 원천에서 전체를 다시 흘려보낼 수 있으면 복구가 쉽습니다.
  8. 어긋남의 수를 지표로 보세요. 0 이 아니라 추세를 보는 게 중요합니다.
  9. 운영 중 DB 를 직접 고치는 경로를 막거나, 고쳤을 때 다시 흘려보내는 절차를 두세요. 수동 수정은 파생 시스템이 알 수 없습니다.

지금 확인해 보세요

어긋남을 발견하는 장치:

코드
· 양쪽 개수 비교 — 가장 단순한 대조
 SELECT count(*) FROM orders WHERE created_at < :기준시각;
 검색 엔진의 같은 조건 문서 수
 기준 시각을 두지 않으면 정상 시차를 오인한다

· 표본 대조 — 내용까지 확인
 무작위 표본을 뽑아 양쪽 필드를 비교한다

· 아웃박스가 밀리고 있지 않은지
 SELECT count(*), max(now()-created_at) FROM outbox
 WHERE processed_at IS NULL;

· 파생 쪽 실패가 기록되고 있나
 검색 엔진이 문서를 거부했는데 성공으로 처리하면 조용히 빠진다

· 전체를 다시 흘려보낼 수 있는가
 이 절차가 있으면 복구가 절차가 되고, 없으면 사고가 된다

핵심 한 줄두 곳에 쓰면 언젠가 어긋난다. 한 곳에 쓰고 나머지는 따라오게 하라.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

장애·확장 글 23편 모두 보기 →