· 16분 읽기

알림을 한 번만, 그러나 잃지 않게 – 고객등급제 알림톡 발송을 outbox / inbox 로 다시 짠 기록


1. 전에는: 배치가 직접 보내고, 보낸 뒤에 적었다

고객등급제 알림 중 미션 독촉·등급 유지 Lock-in·이달의 별미·기념일 참여 대상은 처음에 배치가 직접 보냈다. 가상 스레드로 비즈뿌리오를 부르고 보낸 뒤 발송 내역(notification_histories)을 남겼다. 다음 실행은 "최근 내역이 있는 회원"을 빼서 중복을 피했다.

outbox 이전 SendMissionReminderUseCase KDoc: 보낸 뒤 발송 내역을 남기고 발송 직후 바로 남긴다
스냅샷 1 · outbox 이전 미션 독촉 잡. '발송 직후 바로 남긴다'를 강조한다.

이 구조의 약점은 보낸 순간과 적는 순간 사이다. 그 사이에 파드가 죽으면 다음 실행은 내역이 없다고 보고 다시 보낸다. 주석이 "바로 남긴다"를 강조하는 건 이 틈을 줄이려는 것이지만 틈이 닫히지는 않는다. 게다가 광고성 알림은 20:50 이후 보낼 수 없고 비즈뿌리오 호출 한도는 주문 알림과 나눠 써야 한다. 발송을 배치 안에 두면 재시도·마감·속도 조절을 잡마다 따로 구현해야 했다.

2. 구조: 쓰는 쪽과 보내는 쪽 사이에 테이블 두 개

바꾼 구조에서 배치는 "보낼 것"을 outbox 테이블에 적기만 한다. 중계 잡이 그 행을 RabbitMQ 로 넘기고 워커가 inbox(발송 내역의 message_id)로 같은 메시지를 한 번만 보낸다.

정상 흐름 애니메이션: 등급 잡이 outbox에 INSERT, 재실행은 0행, 중계 잡이 SKIP LOCKED로 차지, publish와 confirm, PUBLISHED, 워커가 inbox에 SENDING 선점, 비즈뿌리오 호출, SENT, ack
움직이는 그림 1 · 정상 흐름. 오른쪽 상태 칸에 outbox 행과 inbox 행이 어떻게 바뀌는지 보인다.
outbox: notification_outboxinbox: notification_histories.message_id
주인배치 (넣기·중계 상태 모두 배치만 쓴다)워커 (워커만 선점·결과를 쓴다)
목적잃지 않기: 보낼 것을 먼저 영속화두 번 보내지 않기: 같은 메시지 걸러내기
중복 키dedup_key UNIQUE (같은 사건 = 한 행)message_id 부분 UNIQUE (같은 메시지 = 한 행)
상태PENDING → PUBLISHING → PUBLISHED / SKIPPED / FAILED / EXPIRED(없음) → SENDING → SENT / FAILED / ERROR

두 테이블은 서로를 쓰지 않는다. 처음에는 워커가 배치 테이블의 sent_at 을 선점하는 구조였다. 그런데 두 서비스가 한 테이블을 쓰는 데다, 발행 잡이 하루 한 번만 돌아 워커가 선점을 풀어도 그날 다시 발행할 실행이 없었다. 그래서 소유를 나눴다. outbox 는 배치만 쓰고 중복 판별은 워커 자신의 테이블로 옮겼다.

2-1. outbox: 보낼 것을 먼저 남긴다

notification_outbox DDL 규칙과 테이블 정의: dedup_key UNIQUE, status는 중계 잡만, 워커는 쓰지 않음
스냅샷 2 · outbox DDL 과 운영 규칙. payload 에 보낼 메시지 전부를 사건 시점 값으로 담는다.

outbox 패턴의 교과서적 설명은 "업무 데이터 변경과 outbox INSERT 를 같은 트랜잭션에 묶는다"이다. 업무는 커밋됐는데 메시지는 못 보낸(또는 그 반대) 이중 쓰기 불일치를 막기 위해서다. 그런데 이 배치의 알림 잡들은 업무 데이터를 읽기만 하고 쓰는 것은 outbox INSERT 하나뿐이다(배치 코드의 규칙 문서에 잡마다 적혀 있다). 쓰기가 하나뿐이면 이중 쓰기 문제가 애초에 없다.

대신 필요한 건 재실행해도 같은 결과다. 잡이 중간에 죽으면 다시 돌리는데, 그때 이미 넣은 회원이 또 들어가면 안 된다. 그래서 dedup_key 를 업무 키로 결정적으로 만든다.

알림dedup_key
등급 안내GRADE_RENEWAL:{등급 이력 행 id}
월간 어워즈 당첨MONTHLY_AWARDS_WINNING:{당첨 행 id}
월간 어워즈 마감 임박MONTHLY_AWARDS_CLOSING:{당첨 행 id}:{기한 날짜}
미션 독촉MISSION_PARTICIPATION_REMINDER:{날짜}:{회원}
등급 유지 Lock-inGRADE_LOCK_IN:{월}:{회원}

예외가 하나 있다. 기념일 쿠폰은 쿠폰 발급(업무 쓰기) 뒤에 outbox 를 넣는다. 쿠폰은 나갔는데 outbox 를 못 넣으면 알림을 일부러 잃고 운영 경보를 보낸다. 코드 주석의 표현으로는 "알림을 잃는 쪽을 택했다(중복 발송보다 낫다)". 이 선택은 6장의 벤더 경계와 같은 원칙이다.

2-2. 중계: 짧게 차지하고, 트랜잭션 밖에서 발행한다

enqueue insertIgnore와 claim의 FOR UPDATE SKIP LOCKED 서브쿼리 UPDATE RETURNING
스냅샷 3 · 넣기(insertIgnore)와 차지. 차지는 한 문장으로 PENDING → PUBLISHING 을 바꾸고 바로 커밋한다.
  • 5분마다(08:00 ~ 20:55) 중계 잡이 돈다. 한 번에 50행을 차지하고 임대(claimed_until)는 15분이다.
  • SKIP LOCKED: 손으로 띄운 Job 은 CronJob 의 Forbid 정책이 막지 않아 중계 실행 두 개가 겹칠 수 있다. 겹쳐도 서로 다른 행을 가져간다.
  • 발행하는 동안 행 잠금을 쥐지 않는다: 차지는 커밋으로 끝나고 이후 상태 변경은 모두 WHERE status='PUBLISHING' AND claimed_by=? 조건으로만 한다. 임대를 잃은 실행의 늦은 쓰기는 0행이 된다.
  • 임대 만료 회수: 다음 실행이 시작할 때 임대가 지난 PUBLISHING 행을 PENDING 으로 되돌린다(CLAIM_EXPIRED).
  • 실패하면 백오프: 1·2·4·8·16분, 이후 30분. 다섯 번째 시도는 경보. 광고성은 20:40 이후 발행하지 않고 다음 날 08:00 으로 미룬다(워커 마감 20:50 보다 10분 앞).

2-3. 발행: 확인을 받고 나서야 PUBLISHED

RabbitNotificationPublisher.publish: CorrelationData, confirm 대기, nack와 returned 실패 처리, PERSISTENT, messageId 접두사
스냅샷 4 · 발행자. publisher confirm(5초)을 기다리고, nack·라우팅 실패는 실패로 올린다. 메시지는 persistent, 큐는 quorum 이다.
RelayOutboxNotificationUseCase: publish 성공 뒤 markPublished, 실패면 postpone
스냅샷 5 · 발행 → 확인 → PUBLISHED 순서. 실패하면 PENDING 으로 미루고 다시 시도한다.

순서가 핵심이다. 반대로 "PUBLISHED 를 찍고 나서 발행"하다 죽으면 메시지가 사라진다. 행은 끝난 것으로 보이는데 큐에는 아무것도 없다. 지금 순서는 그 대신 "확인은 받았는데 찍기 전에 죽으면 두 번 발행"을 남긴다. 잃는 것과 두 번 보내는 것 중 outbox 는 두 번 쪽을 고르고 그 중복은 inbox 에 맡긴다. 그래서 이 구간은 at-least-once 다.

메시지 id 는 notification-outbox-{outbox 행 id} 로 정해진다. 행이 같으면 몇 번을 다시 발행해도, 재시도 큐나 파킹 큐에서 되돌려도 같은 값이다. 상수 주석은 "워커가 중복을 거르는 키라 바꾸면 거르지 못한다"고 적어 두었다.

3. inbox: 같은 메시지를 한 번만 보낸다

notification_histories message_id DDL: SENDING 선점 흐름 설명과 부분 유니크 인덱스
스냅샷 6 · inbox 는 새 테이블이 아니라 발송 내역에 message_id 와 부분 유니크 인덱스를 더한 것이다. 인덱스는 운영 쓰기를 막지 않도록 CONCURRENTLY 로 만든다.

3-1. 왜 "보내기 직전"에 선점하나

워커의 첫 버전은 "내역이 있나 보고, 없으면 보내고, 보낸 뒤 적는다"였다. 이건 확인-후-실행(check-then-act) 경쟁에 약하다. 당시 커밋 메시지에 이렇게 적었다. "같은 메시지의 복사본(발행 확인 유실·임대 만료 재발행·연결 끊김 재전달)이 두 소비자에게 동시에 가면 둘 다 "내역 없음" 을 보고 두 번 발송할 수 있었다". 첫 처방은 큐를 단일 활성 소비자 · 동시 처리 1 로 바꿔 한 번에 하나씩 처리하는 것이었다. 하지만 소비자가 바뀌는 순간 같은 틈이 남는다. 그래서 같은 날 판단을 DB 의 유니크 인덱스에 넘기는 선점으로 바꿨고 단일 활성 소비자는 호출 한도 조절 용도로만 남았다.

BrandMessageNotificationFacade: 먼저 보기, ensureActive와 NonCancellable, claim INSERT, claimed false 처리, 벤더 호출 결과 분류
스냅샷 7 · 워커의 핵심. 먼저 보기(빨강 주석: 동시에 온 복사본은 여기를 함께 지날 수 있다) → 취소 경계 → 선점 → 선점한 쪽만 발송 → 결과 분류.
  1. 먼저 보기: 같은 message_id 행이 이미 있으면 바로 ack. 빠른 길일 뿐 막는 장치는 아니다.
  2. 사전 확인: 광고 시간(08:00~20:50), 회원 재조회, SMS 수신동의 재확인, 템플릿·대체 문자 조립. 막히면 행을 남기지 않고 파킹하거나 건너뛴다.
  3. 선점: INSERT status='SENDING' ON CONFLICT (message_id) DO NOTHING 을 커밋. 못 넣었으면 다른 사본이 이긴 것이니 보내지 않고 ack.
  4. 발송: 선점한 쪽만 비즈뿌리오를 부른다. refkey 에 message_id 를 싣는다.
  5. 결과: UPDATE … WHERE status='SENDING' 으로 SENT / FAILED / ERROR. 그 뒤에 ack.

선점부터 결과 쓰기까지는 NonCancellable 이다. 배포로 파드가 내려가며 코루틴이 취소돼도 끝까지 한다. 선점만 하고 끊기면 SENDING 행만 남아 그 메시지를 아무도 보내지 않게 되기 때문이다. 반대로 선점 전에 취소되면 아무것도 나가지 않았으니 그대로 되돌려(requeue) 다른 인스턴스가 보낸다.

3-2. 동시에 와도 하나만: 테스트로 재현

중계가 PUBLISHED를 찍기 전에 죽어 15분 뒤 같은 message_id로 재발행, 워커가 먼저 보기에서 SENT를 보고 ack, 동시 도착 시 INSERT 한쪽만 성공
움직이는 그림 2 · 중계가 발행 직후 죽으면. 15분 뒤 같은 id 로 다시 발행되고, inbox 가 두 번째를 막는다. 마지막 장면은 두 사본이 동시에 와 둘 다 먼저 보기를 통과한 경우다.
BrandMessageClaimIntegrationTest: 두 소비자가 먼저 보기를 함께 지난 뒤 선점을 다투게 하고 vendorCalls 1을 확인
스냅샷 8 · 두 사본을 먼저 보기까지 일부러 함께 통과시킨 뒤 선점을 다투게 한다. 실제 Postgres(Testcontainers)의 유니크 인덱스가 승자를 하나로 정해 벤더 호출은 1번이다.
RabbitMqConfig: quorum, deliveryLimit, at-least-once DLX, singleActiveConsumer와 중복 발송은 여기에 기대지 않는다는 주석
스냅샷 9 · 큐는 단일 활성 소비자 + 동시 처리 1 이다. 하지만 주석에 적었듯 이건 비즈뿌리오 호출 한도를 지키려는 것이고, 중복 방지는 여기에 기대지 않는다.

"소비자가 하나뿐이면 중복이 없지 않나"는 자주 하는 착각이다. 소비자가 교체되는 순간, ack 전에 연결이 끊긴 재배달, 임대 만료 재발행, 파킹 큐에서 사람이 되돌린 메시지 모두 같은 message_id 의 사본을 만든다. 순서 보장이나 소비자 수가 아니라 유니크 인덱스 한 줄이 마지막 방어선이다.

4. at-least-once 는 어디까지인가: 장애 지점별로 따져 보기

"이 구조는 at-least-once 다"라고 한 줄로 말하면 틀린다. 구간마다 보장이 다르다.

보장 구간: 등급 잡에서 outbox까지는 멱등 재실행, outbox에서 워커까지는 at-least-once, 워커에서 비즈뿌리오까지는 message_id당 최대 한 번
그림 1 · 구간별 보장. 합치면 정상 경로는 정확히 한 번, 장애가 나면 중복이 아니라 '에러'로 기운다.
#어디서 죽나무슨 일이 일어나나잃나두 번 가나
1잡이 outbox 커밋 전행 없음. 다시 돌리면 대상을 다시 계산해 넣는다아니오아니오 (dedup_key)
2커밋 뒤, 중계 전PENDING 으로 남아 5분 뒤 중계. 예정일이 지나면 EXPIRED + 경보아니오*아니오
3차지 뒤, 발행 전PUBLISHING 으로 남았다가 15분 뒤 PENDING 으로 회수 → 재발행아니오아니오
4발행했는데 확인이 시간 초과실패로 보고 백오프 재발행. 실제로는 들어갔다면 메시지가 둘아니오inbox 가 막음
5확인 받고 PUBLISHED 찍기 전15분 뒤 같은 id 로 재발행. 메시지가 둘(그림 2)아니오inbox 가 막음
6중계 실행 두 개가 겹침SKIP LOCKED 로 서로 다른 행아니오아니오
7워커가 선점 전에 죽음ack 안 됨 → 재배달. 선점 행이 없으니 다음 소비자가 보낸다아니오아니오
8선점 뒤, 벤더 호출 전 강제 종료SENDING 이 남아 재배달돼도 보내지 않음 → 10분 뒤 ERROR + 경보예아니오
9벤더 호출 중 시간 초과·5xxERROR 로 남기고 재시도하지 않음(그림 3)안 나갔으면 예아니오
10벤더 성공 뒤 결과 UPDATE 실패ERROR 로그·경보 후 ack. SENDING 이 남아 재배달을 막고 10분 뒤 ERROR아니오아니오
11결과 쓰고 ack 전에 죽음재배달 → 결과 행이 있어 skip아니오아니오
12확실히 안 나간 실패(토큰·연결·401/403/429)선점을 지우고 재시도(1초 → 10분 × 3 → 파킹)아니오아니오

* 광고성 알림이 20:40 이후로 밀리면 다음 날로 넘어가고 예정일이 지나면 EXPIRED 로 닫혀 경보가 나간다. 운영 문서 7절은 이 밖에 "선점 INSERT 는 커밋됐는데 응답을 못 받음", "재시도 큐 만료 이동 중 노드 장애" 같은 잃는 창도 숨기지 않고 적어 두었다.

표를 세로로 읽으면 결론이 보인다. "두 번 가나" 칸에 "예"는 없다. 메시지가 둘이 되는 곳(4·5)은 전부 outbox → 워커 구간이고 같은 message_id 를 달고 와 inbox 에서 걸린다. "잃나" 칸의 "예"는 선점 이후, 즉 벤더 경계에 몰려 있다. 그 밖에 잃을 수 있는 경우(* 표시의 EXPIRED 등)는 모두 경보로 드러난다.

5. 중복을 막는 장치들: 층별로

층장치무엇을 막나
넣기dedup_key UNIQUE + ON CONFLICT DO NOTHING, 업무 키로 만든 결정적 키잡 재실행·재시작으로 같은 사건이 두 행
중계FOR UPDATE SKIP LOCKED 차지 + claimed_by 조건부 전이겹친 중계 실행의 이중 발행
중계PENDING 만 차지, 확인 받은 행은 예외가 나도 즉시 되돌리지 않음(15분 임대에 맡김)이미 넘긴 행을 1분 만에 또 발행
메시지message_id = notification-outbox-{id}, 재시도·파킹 재발행에서도 보존모든 사본이 같은 키를 갖게 한다
워커선점: INSERT SENDING ON CONFLICT DO NOTHING (부분 유니크 인덱스)동시 사본. 결정적인 한 줄
워커결과·해제는 WHERE status='SENDING' 조건으로만결과 덮어쓰기, 결과 행 삭제
워커모르면 ERROR, 재시도하지 않음시간 초과·5xx 뒤 재전송
워커NonCancellable 구간 + 끝까지 돈 뒤에만 ack배포 중 "보냈는데 되돌림(requeue)"
워커선점 전 message_id 길이 검사(100자)내역을 못 남겨 중복을 못 막는 경우

6. 벤더 경계: 왜 여기서만 at-most-once 인가

비즈뿌리오 응답 시간 초과 시 ERROR로 남기고 ack, 재배달 시 skip, 운영자가 refkey로 대조, 확실히 안 나간 실패는 선점 삭제 후 재시도
움직이는 그림 3 · 응답이 끊기면 ERROR 로 남기고 다시 보내지 않는다. 확실히 안 나간 경우에만 선점을 지우고 다시 보낸다.
BizPpurioSendFailure: 확실히 안 나감과 다시 해 볼 만함의 정의, 그 밖은 모른다
스냅샷 10 · '확실히 안 나감'만 정의하고, 나머지는 전부 '모름'이다.

outbox → 워커 구간은 중복을 inbox 가 걸러 주니 마음껏 다시 보낼 수 있다. 하지만 워커 → 비즈뿌리오 구간에는 그런 장치가 없다. 비즈뿌리오 /v3/message 에는 멱등 키가 없어서 응답이 끊긴 요청을 다시 보내면 고객 휴대폰에 같은 문자가 두 번 갈 수 있다. 그래서 선택지는 둘뿐이다.

  • 다시 보낸다: 안 나갔던 건은 살리지만, 나갔던 건은 두 번 간다.
  • 다시 보내지 않는다: 나갔던 건은 한 번으로 끝나지만, 안 나갔던 건은 잃는다.

광고성 혜택 알림이 같은 사람에게 두 번 가면 클레임과 수신 거부로 이어지기 쉽다. 운영 문서에는 "두 번 나가느니 잃는 쪽을 택했다"고 적었다. 대신 잃은 건이 조용히 사라지지 않게 했다.

  • ERROR 행은 어드민 발송 현황에 "에러"로 보인다(행이 없으면 "미발송").
  • 결과를 못 남긴 건, 10분 넘은 SENDING, 선점을 못 푼 건은 Slack 경보로 간다(정리 스케줄러는 5분마다, ShedLock 으로 한 인스턴스만).
  • refkey 에 message_id 를 실어, 비즈뿌리오 결과 조회로 실제 발송 여부를 나중에 맞춰 볼 수 있다.
운영 문서 7절 남는 한계: 닫힌 창과 남는 창
스냅샷 11 · 운영 문서 7절. 이 구조로 닫힌 창(전에는 중복이던 경우)과 남는 창(모두 잃는 쪽)을 함께 적었다.

7. 그 밖의 장치

  • 파킹 큐: 다시 해도 같은 실패(템플릿 없음, 대체 문자를 만들 수 없음, 광고 시간 밖, 모르는 알림 종류 등)는 이유를 x-failure-reason 헤더에 실어 소비자 없는 파킹 큐로 보낸다. 사람이 고친 뒤 되돌리면 같은 message_id 라 선점이 중복을 막는다.
  • 재시도 경로: 리스너 안 2번(1초 간격) → 재시도 큐(TTL 10분) × 3 → 파킹. 재시도는 "확실히 안 나간" 건만 탄다.
  • SMS 동의 재확인: 예약 발송이라 넣은 뒤 동의를 거뒀을 수 있다. 중계와 워커가 각각 다시 본다.
  • 비운영 번호 차단 세 겹: 중계, 워커 사전 확인, 발송 직전. dev 에서 실제 고객에게 나가는 일을 막는다.
  • 대체 문자 2,000바이트(EUC-KR) 한도: 넘으면 비즈뿌리오가 요청째 거절하므로 보내기 전에 파킹한다.

8. 바뀐 순서

  1. 비운영 환경은 등록된 번호로만 발송하도록 먼저 막았다.
  2. outbox 가 처음 생겼다. sent_at 하나로 발송 여부를 보고 하루 한 번 발행했으며 워커가 그 sent_at 을 선점했다.
  3. 범용 중계 잡(5분 주기), outbox 상태 기계, 전용 quorum 큐를 만들고 중복 판별을 워커 소유 message_id 로 옮겼다.
  4. SKIP LOCKED 차지 + 15분 임대, 단일 활성 소비자, 재시도/파킹 분리, NonCancellable 을 넣었다.
  5. 벤더 호출 전 SENDING 선점과 정리 스케줄러를 넣고 결과를 모르는 건을 ERROR 로 남겨 어드민에 "에러"로 보이게 했다.
  6. 중계 도중 예외에도 차지를 바로 반환하게 하고 기념일 알림 유실 경보를 붙였다.
  7. 배치의 직접 발송을 모두 걷어냈다. 별미·기념일 참여·미션 독촉·Lock-in 모두 outbox 로 보내고 등급 산정 잡도 outbox 로 등급 안내를 보낸다.

9. 배운 것

  • outbox 는 "잃지 않기", inbox 는 "두 번 하지 않기"다. 하나만으로는 안 된다. 잃지 않으려고 다시 보내는 순간 중복이 생기고 그 중복은 받는 쪽이 걸러야 한다.
  • "같은 트랜잭션"이 outbox 의 본질은 아니다. 본질은 "보낼 것을 먼저 영속화하고, 확인될 때까지 다시 넘긴다"이다. 이 배치처럼 쓰기가 outbox 하나뿐이면 멱등한 INSERT 로 충분하다.
  • 중복 판별은 판단이 아니라 제약으로 한다. "있나 보고 없으면 보낸다"는 동시성 앞에서 깨진다. 유니크 인덱스 INSERT 하나가 승자를 정하게 하고 그 INSERT 를 부작용 직전에 둔다.
  • exactly-once 는 경계마다 다르게 고른 결과다. 멱등 키가 있는 구간은 at-least-once 로 밀어붙이고 멱등 키가 없는 외부 구간은 at-most-once 로 물러선다. 그리고 물러선 자리에서 잃은 것은 반드시 보이게 한다.
  • 남은 것: 벤더 경계의 "모름"은 지금 사람이 refkey 로 대조한다. 코드 주석이 말하는 "결과 수신(웹훅·조회)으로 나중에 맞춰 보기"를 자동화해 ERROR 를 SENT / FAILED 로 확정하는 것이 다음 단계로 보인다.

요약

  • 등급 안내·월간 어워즈·미션 독촉·이달의 별미 같은 고객등급제 알림을 배치가 비즈뿌리오에 직접 보내던 구조에서, 배치는 outbox 테이블에 적기만 하고 → 중계 잡이 RabbitMQ 로 넘기고 → 워커가 inbox(발송 내역의 message_id)로 한 번만 보내는 구조로 바꿨다.
  • outbox 는 메시지를 잃지 않으려고 둔다. 보낼 것을 먼저 DB 에 남기고 브로커가 받았다고 확인할 때까지 계속 다시 넘긴다. 그래서 outbox → 워커 구간은 at-least-once 다.
  • inbox 는 그 대가로 생기는 중복을 거른다. 워커가 비즈뿌리오를 부르기 직전에 INSERT … SENDING ON CONFLICT DO NOTHING 으로 선점하고 선점한 쪽만 보낸다.
  • 비즈뿌리오 발송 API 에는 멱등 키가 없어서 벤더 앞에서는 일부러 at-most-once 로 간다. 응답이 끊겨 나갔는지 모르면 다시 보내지 않고 ERROR 로 남긴다. 두 번 나가느니 잃는 쪽을 택했고 잃은 건은 화면과 경보로 드러난다.
  • 정상 경로에서는 정확히 한 번 나가고 장애가 나면 중복이 아니라 "에러"로 기운다.

댓글

댓글을 불러오는 중…