leo.dev
backend

브로커 없이 만든 아웃박스

— SERIES 동시성 Part 02 / 02
  1. Part 01 동시성을 락으로 막을 때와 한 문장으로 접을 때
  2. Part 02 브로커 없이 만든 아웃박스

회원 가입에서 웰컴메일을 보내는 코드는 보통 이렇게 생겼다.

await this.companyRepo.save(company) // 가입 커밋
this.emailService.sendWelcomeEmail(company.email).catch(() => {}) // 가는 김에 메일

메일 발송을 await하지 않거나, 하더라도 실패를 catch로 삼킨다. 가입이라는 본 작업을 메일 때문에 실패시킬 순 없으니까.

그런데 이러면 메일이 안 가도 아무도 모른다. SES가 잠깐 응답하지 않았든 네트워크가 끊겼든 예외는 빈 catch로 사라지고 로그에도 안 남는다. 에러 수집을 붙이면서 말한 그 무음 실패다.

가입 트랜잭션은 커밋됐는데 메일은 그 밖에서 따로 날아간다. 두 작업은 원자적이지 않아서 가입은 됐는데 메일은 안 갔거나 드물게는 그 반대가 된다. 상태를 바꾸는 일과 그 뒤에 나가는 외부 발송이 따로 노는 이중 쓰기(dual write) 문제다.

이걸 고치면 워커가 하나 생기고, 워커가 생기면 다음 문제가 따라온다. 워커는 여러 인스턴스에서 동시에 돌고, 도중에 죽고, 죽은 잡을 치우는 장치가 또 죽는다.

보낼 일을 같은 트랜잭션에 적는 구조

트랜잭셔널 아웃박스는 두 저장소에 나눠 쓰던 것을 한 저장소의 로컬 트랜잭션 하나로 접는 패턴이다. 서로 다른 두 시스템은 원자적으로 갱신할 수 없으니, 원자적으로 갱신할 수 있는 곳까지 문제를 끌고 온다.

보통 Kafka·SQS 같은 브로커와 함께 설명되지만 브로커는 패턴의 필수 조건이 아니라 발송처의 한 종류다. 우리에겐 이미 MySQL이 있었고 테이블 하나로 충분했다.

외부로 바로 보내지 말고 “이걸 보내야 한다”는 사실을 본 작업과 같은 트랜잭션 안에서 DB에 적는다. outbox_messages 테이블(eventType·payload·status·attempts·nextAttemptAt)을 두고, enqueue가 호출자의 트랜잭션 매니저를 받아 그 트랜잭션에 참여한다.

await this.dataSource.transaction(async (em) => {
await em.save(subscription) // 구독 past_due로 전환
await this.outbox.enqueue("payment_failed", payload, em) // 같은 트랜잭션에 적재
})

이제 상태 전환과 발송 예약이 한 묶음이다. 트랜잭션이 커밋되면 둘 다 남고 롤백되면 둘 다 사라지므로, “구독을 past_due로 바꿨다”와 “결제 실패 안내를 보내기로 했다”가 원자적으로 묶인다. 실제 발송은 나중에 별도 워커가 이 테이블을 읽어 처리하고, 본 트랜잭션은 외부 I/O를 기다리지 않고 즉시 끝난다.

claim과 dispatch를 나눈 이유

발송 워커에서 제일 조심한 건 락 보유 범위다. 메시지 행을 잠그고 그 락을 쥔 채 SES 발송(외부 HTTP)을 기다리면, 느린 네트워크 동안 행 락이 묶여 경합과 타임아웃이 난다. 그래서 claim과 dispatch를 두 단계로 나눴다.

① claim (트랜잭션):
미발송 행을 pessimistic_write 락 → 아직 PROCESSING 아닌지 재확인
→ status=PROCESSING → 커밋(락 해제)
② dispatch (락 밖):
실제 발송 → 성공이면 SENT / 실패면 attempts++·nextAttemptAt 미루기(백오프)
→ 한도 초과면 FAILED

행을 PROCESSING으로 찜해 두는 순간 락을 놓고, 외부 발송은 아무 락도 없이 한다. 대신 워커가 1단계와 2단계 사이에 크래시하면 그 행은 PROCESSING인 채 영영 멈춘다. 그래서 5분 넘게 PROCESSING으로 남은 stale 행을 회수하는 경로를 뒀다.

회수 워커 자체가 멈췄을 때

회수 경로를 둔 것만으로는 부족했다. 회수해 줄 워커 자체가 죽으면 아무도 회수하지 않는다. 그 경우 PROCESSING 행이 조용히 쌓이는데, 각 행은 “처리 중”이라 에러도 안 남긴다. 이 글이 처음에 지목한 무음 실패가 아웃박스 안에서 다시 난다.

그래서 감시를 한 겹 더 얹었다. 회수 기준(5분)의 세 배인 15분이 지나도 PROCESSING으로 남아 있으면, 그건 늦은 게 아니라 회수할 주체가 없다는 신호로 본다. 대기 건수와 함께 그 수를 따로 센다.

이 감시를 다시 감시하는 층은 두지 않았다. 15분 초과 건수는 담당자가 읽는 대시보드에 올라가고 거기서 끊는다.

같은 메시지가 두 번 발송될 수 있다

이 구조는 at-least-once다. 정확히 한 번이 아니다. 워커가 발송에 성공하고 SENT로 마킹하기 직전에 죽으면 다음 틱이 같은 메시지를 또 보내므로, 같은 메시지가 두 번 발송될 수 있다.

웰컴메일이 두 번 가는 건 사소하다. 그래서 첫 consumer로 웰컴메일을 골라, 위험이 낮은 데서 메커니즘부터 확인했다.

하지만 결제처럼 중복이 치명적인 핸들러는 멱등 키가 필수다. 같은 이벤트를 두 번 받아도 한 번만 효과가 나도록 핸들러 쪽에서 이미 처리했는지를 키로 막아야 한다. 아웃박스가 “최소 한 번”을 보장하니 “많아야 한 번”은 받는 쪽이 책임진다. 핸들러가 실패하면 반드시 throw해야 재시도 대상이 된다는 규칙도 같이 따라오는데, 조용히 삼키면 아웃박스로 옮긴 의미가 없다.

크론 중복 실행을 막는 층

두 번 도는 게 아웃박스 워커만은 아니다. 모든 크론 잡이 그럴 수 있다. 크론이 도는 앱이 애초에 두 개다. 대부분은 워커 앱에 뒀지만, 아웃박스 적체 감시와 탈퇴 사용자 PII 익명화는 HTTP 앱 컨텍스트에서 돈다.

HTTP 앱은 prod에서 태스크 두 개로 띄운다. 롤링 교체 중에 healthy 타깃이 0이 되는 걸 막으려고 배포 워크플로우가 매번 강제하는 값이라, 자정에 도는 잡이 두 인스턴스에서 동시에 뜬다. 워커가 한 대뿐이어도 배포 중에는 구·신 컨테이너가 잠깐 겹쳐 같은 상황이 된다. 연차 적립 잡이 그렇게 두 번 뜨면 같은 직원에게 연차가 두 번 부여된다.

막는 장치를 세 층으로 뒀는데 층마다 작동 범위가 다르다.

무엇을 막나한계
프로세스 안 running 플래그이전 틱이 안 끝났는데 다음 틱이 겹치는 것인스턴스마다 하나씩이라 프로세스 사이엔 무력
MySQL named lock(GET_LOCK)여러 인스턴스가 같은 잡을 동시에 실행하는 것락이 안 풀리면 잡이 영구 정지
행 락·UNIQUE·멱등키위 두 층을 다 뚫고 두 번 실행됐을 때의 결과 중복자원마다 따로 설계해야 한다

아웃박스 발송 잡은 running 플래그와 행 락만 쓴다. 코드에 running 플래그가 있고 잡 락은 없는데, 주석이 그 플래그의 목적을 “불필요한 락 경합·중복 작업 방지”라고 적어둔 게 정확하다. 정확성은 플래그가 아니라 claim 단계의 행 락이 책임진다. 두 인스턴스가 동시에 폴링해도 같은 행을 양쪽이 가져갈 수 없고, 서로 다른 행을 나눠 처리하니 오히려 병렬이 이득이다.

반대로 연차 적립이나 반복 업무 생성은 잡 락을 감는다. 이 잡들은 “하루에 한 번”이라는 게 잡 전체의 성질이라 행 단위로 나눌 게 없다. 이전 회사에서 수수료 정산 배치도 같은 성질이었는데, 거기서는 잡 락 대신 그날 대상을 다시 계산하는 조회가 중복을 막았다. 거기 배치들은 파드를 하나로 둬서 두 인스턴스가 동시에 도는 상황 자체를 안 만들었다.

그래서 새 배치 잡을 만들 때 던지는 질문이 정해졌다. 두 인스턴스가 동시에 돌면 무엇이 중복되는가. 그 중복을 DB가 자체적으로 막을 수 있으면(행 락·UNIQUE·멱등키) 잡 락이 필요 없고, 없으면 감는다. 아웃박스는 전자이고 연차 적립은 후자다.

반납한 커넥션에 락이 남는 이유

잡 락을 감기로 했으니 그 락이 어떻게 풀리는지를 봤고, 거기서 전제 하나가 틀린 걸 찾았다.

이 프로젝트는 MySQL named lock(GET_LOCK/RELEASE_LOCK)을 두 곳에서 쓴다. 방금 말한 잡 락과, soft-delete 정책 때문에 UNIQUE를 못 건 자연키(users.email)의 동시 생성 직렬화다. 둘 다 임계구역을 감싸고 끝나면 락을 푸는 뻔한 구조인데, 함정은 finally에 있었다. 정리(cleanup)가 실패하면 무엇을 해야 하는가.

named-lock.util.ts (수정 전)
} finally {
try {
await qr.query('SELECT RELEASE_LOCK(?)', [key]);
} catch {
// 락 해제 실패는 무시 — 커넥션 반납 시 세션 종료로 자동 해제됨
}
await qr.release();
}

주석의 근거는 “커넥션 반납 = 세션 종료”다. 드라이버 소스를 따라가면 이게 사실이 아니다.

typeorm/driver/mysql/MysqlQueryRunner.js
release() {
this.isReleased = true;
if (this.databaseConnection) this.databaseConnection.release(); // mysql2 PoolConnection.release()
return Promise.resolve();
}
mysql2/lib/base/pool_connection.js
release() {
if (!this._pool || this._pool._closed) return;
this._pool.releaseConnection(this); // 풀에 반납할 뿐, 소켓도 세션도 살아있다
}

qr.release()는 커넥션을 풀에 돌려줄 뿐 MySQL 세션을 끝내지 않는다. 그런데 named lock은 세션에 묶인다.

MySQL 문서가 명시하는 해제 조건은 RELEASE_LOCK을 명시적으로 부르거나 세션이 끝나거나 둘뿐이다. 세션이 살아있으면 락도 살아있다.

같은 문서에 이 설계의 성격을 보여주는 문장이 하나 더 있다. GET_LOCK으로 잡은 락은 트랜잭션이 커밋되거나 롤백돼도 풀리지 않는다. 트랜잭션 경계와 무관하게 세션에만 묶인다는 뜻이라, 트랜잭션을 닫았으니 락도 정리됐겠지 하는 직관이 여기서는 통하지 않는다.

락이 안 풀렸을 때의 연쇄

RELEASE_LOCK이 실패한 채 커넥션이 풀로 돌아가면 이런 연쇄가 된다.

  1. 락이 그 세션에 그대로 남는다.
  2. 그 커넥션은 풀에서 계속 재사용되므로 세션도 안 끝나고, 락이 영영 안 풀린다.
  3. 다음 크론 실행이 다른 커넥션에서 GET_LOCK(key, 0)을 부르면 0을 받는다.
  4. 잡은 “다른 인스턴스가 이미 실행 중”으로 판단하고 조용히 건너뛴다. 로그는 warn 한 줄이다.

결과적으로 그 크론이 영구히 죽는다. 월별 청구 이력을 만드는 잡이면 그달 이력이 통째로 누락되고 아무도 모른다. 같은 전제가 이메일 락에도 있었는데 거기서 새면 그 이메일의 가입이 영구 차단된다.

락 누수를 막으려고 넣은 코드가 오히려 잡을 멈추는 코드였다. 앞의 stale 회수와 같은 모양이다. 발송 실패는 사용자가 메일을 못 받아 알게 되지만, 크론이 안 도는 건 아무도 안 물어보면 모른다.

destroy()로 세션 끊기

세션을 끊으면 MySQL이 락을 회수한다. mysql2의 PoolConnection.destroy()가 그 일을 한다.

mysql2/lib/base/pool_connection.js
destroy() {
this._removeFromPool(); // 풀에서 제거, 그만큼 새 커넥션을 만들 수 있다
super.destroy(); // 소켓 종료 → 세션 끝 → named lock 자동 회수
}
상황처방
RELEASE_LOCK 쿼리 실패커넥션 destroy()로 세션 종료, 락 회수
qr.release()(풀 반납) 실패커넥션 destroy()로 풀 슬롯 회수
destroy()마저 실패할 수 있는 게 없다. 그래도 반드시 로그와 에러 보고

destroy()는 내부에서 _pool을 null로 만들기 때문에 이후의 release()는 no-op이다. 이중 호출 순서를 신경 쓸 필요가 없다.

반납 실패도 같은 급의 사고다. qr.release()가 실패하면 커넥션이 풀로 돌아가지도 닫히지도 않은 채 유실되는데, 풀은 그걸 여전히 사용 중으로 센다. 반복되면 connectionLimit만큼 쌓여 풀이 고갈된다. 락 누수와 같은 종류의 조용한 사고라 처방도 같다.

부하보다 이 반납 실패가 풀을 더 비운다는 건 풀 기본값을 재보면서 확인했다.

RELEASE_LOCK 반환값을 확인하는 이유

RELEASE_LOCK의 반환값은 세 가지다.

의미
1이 세션이 잡은 락을 해제했다
0락은 있는데 이 세션 것이 아니다
NULL락이 존재하지 않는다

우리는 방금 GET_LOCK으로 이 락을 잡았고 임계구역 내내 들고 있어야 했다. 1이 아니라는 건 도중에 락을 잃었다는 뜻이고, 세션 끊김과 재수립·KILL·서버 재시작이 그런 일을 만든다. 그리고 락을 잃은 구간에는 다른 인스턴스가 같은 락을 잡고 동시에 실행했을 수 있다. 배치라면 중복 이력이, 이메일 락이라면 중복 유저가 이미 생겼을 수 있다는 뜻이라, warn으로 흘려보낼 사건이 아니라 error와 에러 리포트로 올려 담당자가 데이터를 확인하게 한다.

정리 실패를 어떻게 다룰지는 세 가지로 정했다.

  1. rethrow하지 않는다. finally에서 던지면 임계구역이 던진 원본 에러를 가린다.
  2. 빈 catch로 버리지도 않는다. rethrow 안 함과 조용히 버림은 다른 일이다.
  3. 회수하고 보고한다. destroy()로 리소스를 회수하고 에러 리포트로 흔적을 남긴다. 보고 형태는 한 함수로 통일한다. 네 지점(락 상실·해제 실패·반납 실패·파기 실패)이 제각각 찍으면 장애 때 읽을 수 없다.

평소에 안 도는 정리 경로 테스트

이 경로들은 평소에 안 돈다. 한 번도 안 도는 코드는 고쳐 쓰다 망가져도 알 방법이 없어서, 단위 테스트에서 mock으로 강제 실행시켰다.

  • RELEASE_LOCK 쿼리를 실패시키면 destroy()가 호출되는가.
  • qr.release()를 실패시키면 destroy()가 호출되는가.
  • 둘 다 임계구역의 원본 에러를 가리지 않는가.
  • RELEASE_LOCK0이나 NULL을 반환하면 에러 리포트가 올라가는가.
  • 드라이버가 문자열 '0'을 줘도 미획득으로 처리되는가. !'0'false라서 truthy 검사는 문자열 0을 획득으로 오판한다.

마지막 항목은 가설이 아니다. 이 유틸을 만들기 전에는 세 개의 잡에 GET_LOCK 코드가 다섯 블록 복붙돼 있었고, 그중 if (!acquired) 형태의 truthy 검사가 실제로 이 버그를 품고 있었다. 락이 정말 동시 실행을 막는지는 mock으로 못 잡으므로 Testcontainers 통합 테스트로 직렬화를 검증하고, 락을 빼면 그 테스트가 실패하는지까지 확인했다. 그 통합 테스트는 한동안 CI 게이트 밖에 있었는데, 지금은 전용 잡이 매 배포마다 돌린다.

아웃박스에 넣지 않은 알림

결제 도메인의 발송을 아웃박스로 옮기다 예상 못 한 데서 걸렸다. 결제 관련 발송에는 두 종류가 섞여 있었다. 고객사 어드민에게 가는 고객 안내(카드 재등록 유도)와, 우리 운영팀에게 가는 운영 알림(“결제는 됐는데 DB 업데이트가 실패했다” 같은 경보)이다.

고객 안내는 아웃박스로 옮겼는데 운영 알림을 같이 옮기려다 멈췄다. 아웃박스는 outbox_messages 테이블에 행을 남기므로 DB에 의존한다. 운영 알림의 상당수는 바로 그 “DB 쓰기가 실패했다”를 알리는 경보다. DB가 고장난 상황을 알리려고 같은 DB에 남기려 하면 그 쓰기 자체가 실패해서, 정작 가장 알려야 할 순간에 안 간다.

그래서 나눴다. DB 장애를 알리는 경보는 아웃박스로 보내지 않고 DB와 독립된 채널(직접 SES 발송)로 유지한다. 아웃박스는 “DB가 정상”이라는 전제 위에서만 동작한다. 정상 트랜잭션을 커밋한 뒤 이어지는 고객 안내를 안정적으로 보내는 게 아웃박스가 할 일이고, “DB가 죽었다”는 메시지는 그 전제가 성립하지 않는 경우라 다른 길로 가야 한다.

브로커 없이 도는 지금 구조

브로커 없이 테이블 하나로 아웃박스가 돌고, 상태 전환과 발송 예약이 같은 트랜잭션에 묶인다.

“무시해도 안전하다”는 주석을 쓸 일이 생기면 그 근거를 드라이버 소스에서 확인한 다음에 쓴다. 이번 건의 근거였던 “반납하면 세션이 끝난다”는 소스 어디에도 없었다.

↑↓ 이동 열기esc 닫기