먼저 ack 하던 알림 소비자 – 코루틴 launch 가 메시지를 잃는 이유와 처리 후 ack·멱등 처리
1. 배경: 알림이 소비되는 경로
설로인의 알림톡·문자·운영 슬랙 알림은 대부분 RabbitMQ 를 거친다. API 서버(가입·주문·인증번호)나 워커 자신(추천 처리 뒤 알림톡)이 알림 요청 토픽에 메시지를 넣으면 워커의 Consumer 가 큐에서 꺼내 비즈뿌리오·슬랙·Mongo 로 처리한다.

큐는 모두 durable(브로커가 재시작해도 남는다)이고 워커 여러 대가 같은 큐에 경쟁 소비자로 붙는다. 그래서 워커가 내려가도 메시지는 큐에 남아 다른 워커가 처리할 것으로 기대했다. 이 기대가 맞으려면 처리를 마친 뒤에 ack 해야 한다.
2. ack 의 동작
RabbitMQ 는 메시지를 소비자에게 배달했다고 바로 지우지 않는다. 소비자가 처리했다고 알려 줄 때(ack)까지 Unacked 상태로 두고 ack 를 받으면 지운다. ack 없이 채널이나 연결이 끊기면 메시지를 다시 Ready 로 돌려 다른 소비자에게 배달한다.

Spring AMQP 의 리스너 컨테이너가 이 ack 를 대신 보낸다. 기본 모드(AcknowledgeMode.AUTO)에서는 이렇게 동작한다.
- 리스너 메서드가 정상 반환하면 ack 한다.
- 예외를 던지면 nack 하고 다시 넣는다(requeue).
AmqpRejectAndDontRequeueException이면 버리고ImmediateRequeueAmqpException이면 무조건 다시 넣는다. - 컨테이너는 prefetch(기본 250)만큼 미리 받아 둔다. 처리 중인 것 말고도 수백 건이 이 소비자 몫으로 Unacked 상태에 있을 수 있다.
AUTO 모드에서는 메서드가 반환한 시점을 처리가 끝난 시점으로 본다. 이번 문제는 여기서 생겼다.
3. 코루틴 빌더: launch 와 runBlocking
워커의 서비스는 대부분 suspend 함수다. Mongo(리액티브)·HTTP(Ktor) 호출을 기다리는 동안 스레드를 잡지 않으려고 그렇게 작성했다. 그런데 @RabbitListener 메서드는 일반 함수라 안에서 suspend 함수를 부르려면 코루틴 빌더가 필요하다. 두 빌더는 이렇게 다르다.
launch | runBlocking | |
|---|---|---|
| 부른 쪽 | 기다리지 않는다. 코루틴을 띄우고 Job 을 받은 뒤 바로 다음 줄로 간다 | 끝날 때까지 기다린다. 부른 스레드를 막고 코루틴이 끝나야 반환한다 |
| 실행 위치 | 지정한 디스패처(Dispatchers.IO)의 다른 스레드 | 부른 스레드(이벤트 루프) |
| 예외 | 부른 쪽으로 오지 않고 스코프의 예외 처리기로 간다 | 부른 쪽으로 던져진다 |
| 쓰는 곳 | 결과를 기다릴 필요 없는 작업 | 일반 함수·main·테스트에서 suspend 함수를 부를 때 |
여기에 CoroutineScope(Dispatchers.IO) 를 그 자리에서 만들면 문제가 하나 더 생긴다. 구조화된 동시성(structured concurrency)은 자식 코루틴이 부모 스코프보다 먼저 끝나도록 보장한다. 그런데 즉석에서 만든 스코프에는 부모가 없다. 그 코루틴을 기다리는 쪽도, 애플리케이션 종료 시 취소하거나 완료를 기다려 주는 쪽도 없다. JVM 이 종료되면 하던 일과 함께 사라진다.

4. 문제
알림톡 리스너는 이렇게 되어 있었다.

2장과 3장을 합치면 이런 순서가 된다.
- 메시지가 배달된다.
- 리스너가
launch로 코루틴을 띄우고 바로 반환한다. - 컨테이너는 처리가 끝났다고 보고 ack 한다. 브로커는 메시지를 지운다.
- 컨테이너는 곧바로 다음 메시지를 받는다. prefetch 가 250 이라 메시지가 쌓여 있으면 수백 건이 금방 ack 되고 그만큼의 코루틴이 IO 스레드에서 대기한다.
- 이때 파드가 내려가면(배포·노드 교체·OOM) 대기하던 코루틴은 JVM 과 함께 사라진다. 큐에도 남아 있지 않다.

같은 문제가 전에도 있었다. 같은 파일의 주문·배송 알림 리스너는 노드 교체 때 알림이 사라진 뒤 이미 runBlocking 으로 바뀌어 있었다. 그때 같은 형태의 다른 리스너들은 바뀌지 않았다.

Consumer 에서 launch 를 쓰는 리스너는 일곱 개였다. 이번에는 그중 알림을 보내는 네 개(알림톡·인증번호·증정품·회원)를 고쳤다. 나머지 세 개(분석·쿠폰 발급·엑셀 다운로드)는 알림이 아니다. 엑셀 생성이나 대량 쿠폰 발급처럼 오래 걸리는 일이라 소비 스레드를 오래 잡는 문제를 따로 봐야 해서 남겨 두었다(8장).
5. 이렇게 된 이유
- 타입으로 막히지 않는다. 리스너 안에서 suspend 함수를 부르면 컴파일러가 코루틴이 필요하다고 알려 주지만
launch와runBlocking은 둘 다 컴파일된다. 둘은 언제 반환하느냐가 다른데 그 시점이 곧 ack 시점이다. - runBlocking 은 스레드를 막으니 피해야 한다는 인식이 있다. 하지만 리스너 컨테이너의 소비 스레드는 원래 메시지 하나를 처리하는 동안 막혀 있도록 만들어진 스레드다. 처리량이 더 필요하면 소비자 수(concurrency)를 늘리면 된다.
- 평소에는 드러나지 않는다. 파드가 정상이면 launch 든 runBlocking 이든 알림은 나간다. 차이는 종료 시점에만 생긴다. 사라진 알림은 로그도 남기지 않는다.
6. 바꾼 것
6-1. 처리 후 ack: runBlocking

runBlocking을 쓰면 발송이 끝나야 메서드가 반환되고 그때 ack 된다.- 업무 예외는 잡아서 ack 한다. 벤더 500 이나 템플릿 없음 같은 실패를 던지면 기본 정책상 같은 메시지가 계속 되돌아온다. 실패는
amqp.consumeERROR 로그에 payload 와 함께 남는다. 개선 전에도 실패는 로그만 남았으니 동작은 같다. - 취소(CancellationException)만 다시 던진다. 파드 종료로 끊긴 처리는 ack 하지 않아야 다시 배달된다.
- 동시 처리는 인스턴스당 소비자 4~8개(주문 알림 큐와 같은 값)로 두었다. launch 일 때는 사실상 제한이 없었다. 비즈뿌리오 호출이 한꺼번에 몰리지 않는 대신 순간 처리량에는 상한이 생긴다.
6-2. 새로 생긴 문제: 재배달되면 두 번 보낸다
처리 후 ack 로 바꾸면 유실은 없어지지만 같은 메시지를 두 번 처리할 수 있다. 처리 도중이나 처리를 끝내고 ack 하기 직전에 파드가 내려가면 브로커가 그 메시지를 다른 워커에게 다시 준다. 알림톡 메시지 하나에 여러 건이 실려 오기도 한다.

메시지 큐가 기본으로 보장하는 것은 at-least-once(적어도 한 번)다. 그래서 같은 메시지를 두 번 받아도 결과가 한 번 받은 것과 같도록(멱등) 소비자 쪽에서 처리해야 한다.
6-3. 수신 원장: 같은 메시지 알아보기
먼저 같은 메시지를 알아볼 키가 필요했다. 재배달된 메시지는 messageId 가 그대로다. 그런데 API 서버와 워커가 발행하는 메시지에는 messageId 가 없었다(아웃박스를 거쳐 발행하는 메시지만 outbox.id 를 실었다). 그래서 두 서버의 메시지 변환기에서 setCreateMessageIds(true) 를 켰다. messageId 가 비어 있을 때만 UUID 를 만들고 이미 있으면(아웃박스) 그대로 둔다. 이 동작은 spring-amqp AbstractMessageConverter 소스로 확인했다.
그다음 Mongo 에 수신 원장 컬렉션을 두었다. _id 를 큐:messageId(:순번) 으로 잡아서 별도 유니크 인덱스 없이도 insert 한 번으로 원자적으로 선점된다.


- 발송(runOnce): 알림톡·문자·슬랙은 되돌릴 수 없다. 앞선 처리가 끊긴 상태(PROCESSING)로 다시 오면 벤더가 그 요청을 받았는지 알 수 없다. 이 경우 다시 보내지 않고 WARN 로그를 남긴다. 중복 발송보다 그 한 건을 놓치는 쪽을 택했다. 혜택 알림 경로(발송 내역 선점)와 같은 판단이다.
- 업무 처리(runToCompletion): 마일리지·쿠폰 단계는 업무 키로 이미 한 단계를 알아볼 수 있다. 그래서 끊긴 건은 넘겨받아
resumed = true로 남은 단계를 처리한다. 다만 채널만 먼저 닫히고 원래 처리가 아직 돌고 있을 수도 있다. 그래서 시작한 지 2분(파드 종료 유예 30초보다 넉넉한 값)이 지나기 전에는 넘겨받지 않는다. 그동안 소비 스레드에서 5초마다 원장을 다시 확인하며 기다린다. 앞선 처리가 끝나면(DONE) 건너뛰고 2분이 지나면 넘겨받는다. 넘겨받기도 조건부 갱신이라 여러 인스턴스 중 한 곳만 성공한다. - 기다리는 이유: 처음에는 1초 쉬고 되돌리기(requeue)를 2분 동안 반복했다. 리뷰에서 두 가지가 지적됐다. 하나는 되돌릴 때마다 처리 예외 ERROR 로그가 1초에 한 줄씩 쌓인다는 점이다. 다른 하나는 quorum 큐의 배달 횟수 상한(RabbitMQ 4.x 기본 20회)이다. 이를 넘기면 DLX 가 없는 큐에서는 메시지가 버려진다. 이 대기는 앞선 처리를 하던 파드가 죽었을 때만 생기는 드문 경우라 소비 스레드 하나를 잠시 잡아 두는 편을 택했다.
- 원장이 실패해도 처리는 막지 않는다. Mongo 오류가 나면 원장 없이 처리한다. 원장 때문에 알림 전체가 멈추면 유실보다 더 나쁘다.

6-4. 업무 키 멱등: 친구추천 마일리지와 가입 혜택 쿠폰
회원 큐에는 알림톡 외에 친구추천 마일리지 적립과 가입 혜택 단계별 쿠폰이 함께 들어온다. 이 둘도 다시 실행했을 때 결과가 같아야 한다.
- 마일리지: 적립 원소에 이벤트 키(
recommend.new:{코드 오너}:{등록자})를 남긴다. 회원은 추천 코드를 한 번만 등록할 수 있어 이 쌍이 한 번의 이벤트다. "같은 키·같은 적립 유형이 없을 때만 추가"를 조건부 갱신 한 번으로 처리하므로 같은 이벤트가 동시에 와도 한 번만 적립된다. 지급 이력은 해당 적립 원소의 이력이 없을 때만 남긴다. 이력의_id를 적립 원소 id 로 두어서 동시에 두 번 써도 한 줄만 남는다. - 가입 혜택 쿠폰: 단계를 올릴 때 해당 메시지의 messageId 를
joinBenefit.appliedMessageIds(최근 10개)에 함께 저장한다. 목록에 있는 messageId 가 다시 오면 이미 적용한 것이므로 아무것도 하지 않는다. 마지막 하나만 기억하면 끊긴 메시지가 다시 오기 전에 다른 배송완료가 먼저 적용됐을 때 알아보지 못한다. 쿠폰 발급 후 단계 저장 전에 끊긴 건(resumed)은 이번 단계 쿠폰이 이미 있는지(발급 이력과 회원 쿠폰함 모두) 확인한다. 있으면 발급 없이 단계만 올린다. 처음 받은 메시지의 동작은 예전과 같다. - 추천인 목록: 문서를 읽어 통째로 저장하던 코드를
$addToSet으로 바꿨다. 없을 때만 추가되므로 같은 오너에게 친구 둘이 동시에 등록해도 서로 덮어쓰지 않는다.

7. 검증
부하 테스트는 하지 않았다. 대신 유실되지 않는지와 두 번 처리하지 않는지를 각각 확인하는 테스트를 층별로 만들었다. 그리고 코드를 작성하지 않은 리뷰어(별도 에이전트)에게 같은 메시지로 부작용이 두 번 나는 경로를 찾게 했다.
7-1. 리뷰에서 나온 것
가장 중요한 지적은 가입 혜택 쿠폰에서 나왔다. 처음 버전은 쿠폰은 있는데 단계가 그대로인 경우만 고려했다. 그런데 단계까지 저장한 뒤 원장에 DONE 을 쓰기 전에 끊기면 다시 받은 가입 메시지는 이미 joinBenefit 이 있으니 배송완료 분기로 처리된다. 그러면 배송하지 않았는데도 2단계 쿠폰이 나간다.
리뷰어는 appliedAt == requestDateTime 비교를 제안했다. 하지만 배송완료 메시지의 requestDateTime 은 배송 처리 시각이라 한 주문의 박스 두 개가 같은 초에 완료되면 정상 메시지까지 막힌다. 그래서 시각 대신 단계를 올린 메시지의 messageId 를 남기도록 고쳤다(6-4). 두 번째 리뷰에서는 마지막 하나만 기억하면 끊긴 메시지가 다시 오기 전에 다른 배송완료가 적용됐을 때 알아보지 못한다는 지적이 나왔다. 이 지적에 따라 최근 10개를 기억하도록 바꿨다.
그 밖에 반영한 것:
- 1초마다 되돌리던 루프를 기다리는 방식으로 변경(6-3)
- 동시에 두 번 실행될 때 생기던 지급 이력 중복
- 추천인 목록을 읽어 통째로 저장하던 코드
- 그 수정으로 생긴 회귀: 필드가 없는 예전 문서에 추천인을 넣지 않게 된 것. 예전 코드는 코틀린 기본값 덕분에 넣고 있었다.
반영하지 않은 것은 8장에 적었다.
7-2. 테스트
| 층 | 확인한 것 | 환경 |
|---|---|---|
| 리스너 | 느린 처리를 물려 두고 메서드가 반환한 시점에 처리가 끝났는지(= ack 시점). 업무 예외는 ack, 취소는 전파, 진행 중이면 requeue | 단위(mockk) |
| 원장 | 같은 메시지 20개 동시 수신 → 1번 처리, 끊긴 발송은 다시 안 함, 앞선 처리가 끝나면 기다렸다 건너뜀, 끊겼으면 기다렸다 한쪽만 넘겨받음, 쿼리 문자열 그대로 | 실제 Mongo 6.0 |
| 마일리지·추천 | 같은 이벤트 20개 동시 적립 → 1번, 다른 친구·기존 적립은 막지 않음, 같은 친구 20번 동시 등록 → 목록에 1번, 다른 친구 20명 동시 → 20명 모두, 이력 동시 저장 → 1줄 | 실제 Mongo 6.0 |
| 가입 혜택 쿠폰 | 단계까지 저장한 메시지가 다시 와도 아무것도 안 함(그 사이 다른 배송완료가 적용됐어도), 다음 배송완료는 그대로, 쿠폰함에만 들어간 채 끊긴 건은 재발급 안 함, 처음 받은 메시지는 예전과 같음 | 단위(mockk) |
| E2E | 실제 @RabbitListener 컨테이너에서 3건짜리 알림톡을 받다가 두 번째 건 발송 중 컨테이너를 멈춤 → 재배달 → 3건 모두 정확히 1번씩 | 실제 RabbitMQ 3.13 + Mongo |

- E2E 결과: 두 번째 건을 보내는 도중 컨테이너를 멈추자 메시지가 큐로 돌아왔다. 개선 전이었다면 이미 ack 되어 없었을 것이다. 다시 띄운 컨테이너는 0번(DONE)과 1번(PROCESSING)을 건너뛰고 2번만 보냈다. 원래 스레드가 늦게 끝나도 2번을 다시 보내지 않았다. 원장에는 1번이 PROCESSING 으로 남는다. 나갔는지 모르는 건이라는 표시다.
- 뮤테이션: 알림톡 리스너만
launch로 되돌리면 "반환 시점에 발송이 끝나 있다"와 "취소를 전파한다" 두 테스트가 실패한다. 테스트가 이 버그를 잡는다는 것을 확인했다. - 회귀: 워커 전체 1,056개(새 테스트 포함), API 서버 2,296개 모두 통과했다. 건너뛴 3개는 원래 수동 실행용이다.
8. 남은 것과 한계
- 끊긴 발송 한 건은 놓칠 수 있다. 벤더 호출 도중 파드가 죽은 건은 나갔는지 모르므로 다시 보내지 않는다. 그 건은 PROCESSING 원장 행과 WARN 로그로 찾을 수 있지만 아직 경보나 정리 스케줄러는 없다(혜택 알림의 오래된 선점 정리 스케줄러를 참고할 수 있다). 비즈뿌리오 API 에 멱등 키가 없어서 그 이상은 벤더 리포트와 대조해야 한다.
- 기준은 같은 메시지다. 원장은 같은 messageId 의 재배달을 막는다. 발행 쪽에서 같은 업무 이벤트를 다른 메시지로 두 번 발행하면 알림은 두 번 간다(마일리지는 이벤트 키라 막힌다). 지금 이 네 큐에 발행하는 곳은 API 서버와 워커뿐이고 둘 다 messageId 를 싣는다. 어드민과 배치가 이 큐에 발행하지 않는다는 것도 확인했다.
- 남은 launch 리스너 세 개(분석·쿠폰 발급·엑셀 다운로드)는 같은 유실 위험이 있다. 오래 걸리는 작업이라 runBlocking 으로 바꾸면 소비 스레드를 몇 분씩 잡는다. 브로커의 consumer timeout(기본 30분)도 고려해야 해서 따로 다룬다.
- 첫구매 추천 마일리지는 건드리지 않았다. 구매확정 배치가 같은 적립 함수를 직접 부르는 경로라 큐 재배달과 무관하다. 지급 전에 구매자의 지급 이력을 보고 이미 받은 고객을 거른다. 이벤트 키 없이 부르므로 동작도 예전과 같다.
- 기다리는 동안 소비 스레드를 잡는다. 파드 하나가 처리 도중 죽으면 그 파드가 쥐고 있던 회원 큐 메시지들이 다른 인스턴스에서 최대 2분씩 소비 스레드를 잡을 수 있다(인스턴스당 4~8개). 그동안 그 인스턴스의 회원 큐 처리가 느려진다. 드문 경우라 받아들였다.
- 넘겨받기는 2분 임대다. 업무 처리 하나가 2분을 넘기면 다른 소비자가 끊긴 것으로 보고 넘겨받을 수 있다. 지금 회원 큐의 처리는 밀리초 단위라 실제로 닿을 일은 없다. 다만 오래 걸리는 처리에 이 방식을 쓰려면 임대를 연장하는 장치가 필요하다.
- 운영 준비: 원장의 TTL 인덱스(
{ expireAt: 1 }, expireAfterSeconds: 0)는 운영 DB 에 직접 만든다. 없어도 동작은 같고 행이 지워지지 않을 뿐이다. - 부수 효과: 이제 모든 발행 메시지에 messageId 가 실리므로 주문 알림 큐의 아웃박스 중복 판별이 아웃박스가 아닌 메시지에도 조회(
_id기준)를 두 번 더 한다. 처리 결과는 이전과 같다.
요약
- 문제: 워커의 알림 리스너 4개(알림톡·인증번호 문자·증정품 슬랙·회원)가 메시지를 받자마자
CoroutineScope(Dispatchers.IO).launch { … }로 일을 넘기고 반환했다. Spring AMQP 는 리스너가 반환하면 ack 하므로 알림을 보내기도 전에 큐에서 메시지가 지워졌다. 그 사이 파드가 내려가면 durable 큐인데도 알림이 사라졌다. - 원인: 리스너 메서드는 일반 함수이고 안에서 부르는 서비스는 모두
suspend다. suspend 함수를 부르려면 코루틴이 필요한데 가장 쉬운 방법이launch였다. launch 는 띄워 놓고 기다리지 않는 빌더라 "메서드 반환 = 처리 완료"라는 ack 의 전제와 맞지 않는다. - 바꾼 것
- 리스너를
runBlocking으로 바꿔 처리가 끝난 뒤 ack 한다. - 그러면 처리 도중 끊긴 메시지가 다시 배달된다. 같은 알림이 두 번 가지 않도록 messageId 기준 수신 원장을 두었다. 발송은 나갔는지 모르면 다시 보내지 않는다. 업무 처리(마일리지·쿠폰 단계)는 업무 키로 이미 한 단계를 건너뛰고 남은 단계를 처리한다.
- 친구추천 마일리지는 이벤트 키로 한 번만 적립되도록 원자적 갱신으로 바꿨다.
- 리스너를
- 검증: 새 테스트 63개(실제 RabbitMQ·Mongo 를 띄운 통합·E2E 포함), 워커 전체 1,056개·API 서버 2,296개 모두 통과. launch 로 되돌리면 테스트가 실패하는 것(뮤테이션)도 확인했다.
부록: 테스트 실행
# 이번 변경의 테스트 (Docker 필요 — Testcontainers 가 Mongo·RabbitMQ 를 띄운다)
./gradlew :worker:test \
--tests "*ConsumerAckAfterProcessingTest" --tests "*NotificationAckRedeliveryE2ETest" \
--tests "*AmqpConsumeReceipt*" --tests "*PpurioMessageFacadeTest" \
--tests "*MileagePushIfEventAbsentIntegrationTest" --tests "*RecommendMileageIdempotencyTest" \
--tests "*JoinBenefitCouponResumeTest"
- 스냅샷: 실제 소스(개선 전·후)와 Gradle 테스트 결과 XML 로 화면을 만들고 Chrome headless 로 캡처했다.
댓글
댓글을 불러오는 중…