알림을 한 번만, 그러나 잃지 않게 – 고객등급제 알림톡 발송을 outbox / inbox 로 다시 짠 기록
1. 전에는: 배치가 직접 보내고, 보낸 뒤에 적었다
고객등급제 알림 중 미션 독촉·등급 유지 Lock-in·이달의 별미·기념일 참여 대상은 처음에 배치가 직접 보냈다. 가상 스레드로 비즈뿌리오를 부르고 보낸 뒤 발송 내역(notification_histories)을 남겼다. 다음 실행은 "최근 내역이 있는 회원"을 빼서 중복을 피했다.

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

outbox: notification_outbox | inbox: 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: 보낼 것을 먼저 남긴다

outbox 패턴의 교과서적 설명은 "업무 데이터 변경과 outbox INSERT 를 같은 트랜잭션에 묶는다"이다. 업무는 커밋됐는데 메시지는 못 보낸(또는 그 반대) 이중 쓰기 불일치를 막기 위해서다. 그런데 이 배치의 알림 잡들은 업무 데이터를 읽기만 하고 쓰는 것은 outbox INSERT 하나뿐이다(배치 코드의 규칙 문서에 잡마다 적혀 있다). 쓰기가 하나뿐이면 이중 쓰기 문제가 애초에 없다.
대신 필요한 건 재실행해도 같은 결과다. 잡이 중간에 죽으면 다시 돌리는데, 그때 이미 넣은 회원이 또 들어가면 안 된다. 그래서 dedup_key 를 업무 키로 결정적으로 만든다.
| 알림 | dedup_key |
|---|---|
| 등급 안내 | GRADE_RENEWAL:{등급 이력 행 id} |
| 월간 어워즈 당첨 | MONTHLY_AWARDS_WINNING:{당첨 행 id} |
| 월간 어워즈 마감 임박 | MONTHLY_AWARDS_CLOSING:{당첨 행 id}:{기한 날짜} |
| 미션 독촉 | MISSION_PARTICIPATION_REMINDER:{날짜}:{회원} |
| 등급 유지 Lock-in | GRADE_LOCK_IN:{월}:{회원} |
예외가 하나 있다. 기념일 쿠폰은 쿠폰 발급(업무 쓰기) 뒤에 outbox 를 넣는다. 쿠폰은 나갔는데 outbox 를 못 넣으면 알림을 일부러 잃고 운영 경보를 보낸다. 코드 주석의 표현으로는 "알림을 잃는 쪽을 택했다(중복 발송보다 낫다)". 이 선택은 6장의 벤더 경계와 같은 원칙이다.
2-2. 중계: 짧게 차지하고, 트랜잭션 밖에서 발행한다

- 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


순서가 핵심이다. 반대로 "PUBLISHED 를 찍고 나서 발행"하다 죽으면 메시지가 사라진다. 행은 끝난 것으로 보이는데 큐에는 아무것도 없다. 지금 순서는 그 대신 "확인은 받았는데 찍기 전에 죽으면 두 번 발행"을 남긴다. 잃는 것과 두 번 보내는 것 중 outbox 는 두 번 쪽을 고르고 그 중복은 inbox 에 맡긴다. 그래서 이 구간은 at-least-once 다.
메시지 id 는 notification-outbox-{outbox 행 id} 로 정해진다. 행이 같으면 몇 번을 다시 발행해도, 재시도 큐나 파킹 큐에서 되돌려도 같은 값이다. 상수 주석은 "워커가 중복을 거르는 키라 바꾸면 거르지 못한다"고 적어 두었다.
3. inbox: 같은 메시지를 한 번만 보낸다

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

- 먼저 보기: 같은 message_id 행이 이미 있으면 바로 ack. 빠른 길일 뿐 막는 장치는 아니다.
- 사전 확인: 광고 시간(08:00~20:50), 회원 재조회, SMS 수신동의 재확인, 템플릿·대체 문자 조립. 막히면 행을 남기지 않고 파킹하거나 건너뛴다.
- 선점:
INSERT status='SENDING' ON CONFLICT (message_id) DO NOTHING을 커밋. 못 넣었으면 다른 사본이 이긴 것이니 보내지 않고 ack. - 발송: 선점한 쪽만 비즈뿌리오를 부른다.
refkey에 message_id 를 싣는다. - 결과:
UPDATE … WHERE status='SENDING'으로 SENT / FAILED / ERROR. 그 뒤에 ack.
선점부터 결과 쓰기까지는 NonCancellable 이다. 배포로 파드가 내려가며 코루틴이 취소돼도 끝까지 한다. 선점만 하고 끊기면 SENDING 행만 남아 그 메시지를 아무도 보내지 않게 되기 때문이다. 반대로 선점 전에 취소되면 아무것도 나가지 않았으니 그대로 되돌려(requeue) 다른 인스턴스가 보낸다.
3-2. 동시에 와도 하나만: 테스트로 재현



"소비자가 하나뿐이면 중복이 없지 않나"는 자주 하는 착각이다. 소비자가 교체되는 순간, ack 전에 연결이 끊긴 재배달, 임대 만료 재발행, 파킹 큐에서 사람이 되돌린 메시지 모두 같은 message_id 의 사본을 만든다. 순서 보장이나 소비자 수가 아니라 유니크 인덱스 한 줄이 마지막 방어선이다.
4. at-least-once 는 어디까지인가: 장애 지점별로 따져 보기
"이 구조는 at-least-once 다"라고 한 줄로 말하면 틀린다. 구간마다 보장이 다르다.

| # | 어디서 죽나 | 무슨 일이 일어나나 | 잃나 | 두 번 가나 |
|---|---|---|---|---|
| 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 | 벤더 호출 중 시간 초과·5xx | ERROR 로 남기고 재시도하지 않음(그림 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 인가


outbox → 워커 구간은 중복을 inbox 가 걸러 주니 마음껏 다시 보낼 수 있다. 하지만 워커 → 비즈뿌리오 구간에는 그런 장치가 없다. 비즈뿌리오 /v3/message 에는 멱등 키가 없어서 응답이 끊긴 요청을 다시 보내면 고객 휴대폰에 같은 문자가 두 번 갈 수 있다. 그래서 선택지는 둘뿐이다.
- 다시 보낸다: 안 나갔던 건은 살리지만, 나갔던 건은 두 번 간다.
- 다시 보내지 않는다: 나갔던 건은 한 번으로 끝나지만, 안 나갔던 건은 잃는다.
광고성 혜택 알림이 같은 사람에게 두 번 가면 클레임과 수신 거부로 이어지기 쉽다. 운영 문서에는 "두 번 나가느니 잃는 쪽을 택했다"고 적었다. 대신 잃은 건이 조용히 사라지지 않게 했다.
- ERROR 행은 어드민 발송 현황에 "에러"로 보인다(행이 없으면 "미발송").
- 결과를 못 남긴 건, 10분 넘은 SENDING, 선점을 못 푼 건은 Slack 경보로 간다(정리 스케줄러는 5분마다, ShedLock 으로 한 인스턴스만).
refkey에 message_id 를 실어, 비즈뿌리오 결과 조회로 실제 발송 여부를 나중에 맞춰 볼 수 있다.

7. 그 밖의 장치
- 파킹 큐: 다시 해도 같은 실패(템플릿 없음, 대체 문자를 만들 수 없음, 광고 시간 밖, 모르는 알림 종류 등)는 이유를
x-failure-reason헤더에 실어 소비자 없는 파킹 큐로 보낸다. 사람이 고친 뒤 되돌리면 같은 message_id 라 선점이 중복을 막는다. - 재시도 경로: 리스너 안 2번(1초 간격) → 재시도 큐(TTL 10분) × 3 → 파킹. 재시도는 "확실히 안 나간" 건만 탄다.
- SMS 동의 재확인: 예약 발송이라 넣은 뒤 동의를 거뒀을 수 있다. 중계와 워커가 각각 다시 본다.
- 비운영 번호 차단 세 겹: 중계, 워커 사전 확인, 발송 직전. dev 에서 실제 고객에게 나가는 일을 막는다.
- 대체 문자 2,000바이트(EUC-KR) 한도: 넘으면 비즈뿌리오가 요청째 거절하므로 보내기 전에 파킹한다.
8. 바뀐 순서
- 비운영 환경은 등록된 번호로만 발송하도록 먼저 막았다.
- outbox 가 처음 생겼다.
sent_at하나로 발송 여부를 보고 하루 한 번 발행했으며 워커가 그sent_at을 선점했다. - 범용 중계 잡(5분 주기), outbox 상태 기계, 전용 quorum 큐를 만들고 중복 판별을 워커 소유
message_id로 옮겼다. - SKIP LOCKED 차지 + 15분 임대, 단일 활성 소비자, 재시도/파킹 분리, NonCancellable 을 넣었다.
- 벤더 호출 전 SENDING 선점과 정리 스케줄러를 넣고 결과를 모르는 건을 ERROR 로 남겨 어드민에 "에러"로 보이게 했다.
- 중계 도중 예외에도 차지를 바로 반환하게 하고 기념일 알림 유실 경보를 붙였다.
- 배치의 직접 발송을 모두 걷어냈다. 별미·기념일 참여·미션 독촉·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 로 남긴다. 두 번 나가느니 잃는 쪽을 택했고 잃은 건은 화면과 경보로 드러난다.
- 정상 경로에서는 정확히 한 번 나가고 장애가 나면 중복이 아니라 "에러"로 기운다.
댓글
댓글을 불러오는 중…