· 16분 읽기

먼저 ack 하던 알림 소비자 – 코루틴 launch 가 메시지를 잃는 이유와 처리 후 ack·멱등 처리


1. 배경: 알림이 소비되는 경로

설로인의 알림톡·문자·운영 슬랙 알림은 대부분 RabbitMQ 를 거친다. API 서버(가입·주문·인증번호)나 워커 자신(추천 처리 뒤 알림톡)이 알림 요청 토픽에 메시지를 넣으면 워커의 Consumer 가 큐에서 꺼내 비즈뿌리오·슬랙·Mongo 로 처리한다.

API 서버와 워커가 RabbitMQ 의 알림 요청 토픽에 발행하고, 워커의 Consumer 가 알림톡·인증번호·증정품·회원 큐를 소비해 비즈뿌리오·슬랙·Mongo 로 처리한다
그림 1 · 이번에 다룬 네 큐. 주문·배송 알림 큐와 결제 큐도 같은 Consumer 가 받는다.

큐는 모두 durable(브로커가 재시작해도 남는다)이고 워커 여러 대가 같은 큐에 경쟁 소비자로 붙는다. 그래서 워커가 내려가도 메시지는 큐에 남아 다른 워커가 처리할 것으로 기대했다. 이 기대가 맞으려면 처리를 마친 뒤에 ack 해야 한다.

2. ack 의 동작

RabbitMQ 는 메시지를 소비자에게 배달했다고 바로 지우지 않는다. 소비자가 처리했다고 알려 줄 때(ack)까지 Unacked 상태로 두고 ack 를 받으면 지운다. ack 없이 채널이나 연결이 끊기면 메시지를 다시 Ready 로 돌려 다른 소비자에게 배달한다.

메시지는 큐에서 Ready 로 기다리다 소비자에게 배달되면 Unacked 가 된다. ack 를 받으면 삭제되고, nack 이나 채널 종료면 다시 Ready 가 되어 재배달된다
그림 2 · 메시지 상태. 다시 배달된 메시지는 redelivered 플래그가 켜지고 messageId 는 그대로다.

Spring AMQP 의 리스너 컨테이너가 이 ack 를 대신 보낸다. 기본 모드(AcknowledgeMode.AUTO)에서는 이렇게 동작한다.

  • 리스너 메서드가 정상 반환하면 ack 한다.
  • 예외를 던지면 nack 하고 다시 넣는다(requeue). AmqpRejectAndDontRequeueException 이면 버리고 ImmediateRequeueAmqpException 이면 무조건 다시 넣는다.
  • 컨테이너는 prefetch(기본 250)만큼 미리 받아 둔다. 처리 중인 것 말고도 수백 건이 이 소비자 몫으로 Unacked 상태에 있을 수 있다.

AUTO 모드에서는 메서드가 반환한 시점을 처리가 끝난 시점으로 본다. 이번 문제는 여기서 생겼다.

3. 코루틴 빌더: launch 와 runBlocking

워커의 서비스는 대부분 suspend 함수다. Mongo(리액티브)·HTTP(Ktor) 호출을 기다리는 동안 스레드를 잡지 않으려고 그렇게 작성했다. 그런데 @RabbitListener 메서드는 일반 함수라 안에서 suspend 함수를 부르려면 코루틴 빌더가 필요하다. 두 빌더는 이렇게 다르다.

launchrunBlocking
부른 쪽기다리지 않는다. 코루틴을 띄우고 Job 을 받은 뒤 바로 다음 줄로 간다끝날 때까지 기다린다. 부른 스레드를 막고 코루틴이 끝나야 반환한다
실행 위치지정한 디스패처(Dispatchers.IO)의 다른 스레드부른 스레드(이벤트 루프)
예외부른 쪽으로 오지 않고 스코프의 예외 처리기로 간다부른 쪽으로 던져진다
쓰는 곳결과를 기다릴 필요 없는 작업일반 함수·main·테스트에서 suspend 함수를 부를 때

여기에 CoroutineScope(Dispatchers.IO) 를 그 자리에서 만들면 문제가 하나 더 생긴다. 구조화된 동시성(structured concurrency)은 자식 코루틴이 부모 스코프보다 먼저 끝나도록 보장한다. 그런데 즉석에서 만든 스코프에는 부모가 없다. 그 코루틴을 기다리는 쪽도, 애플리케이션 종료 시 취소하거나 완료를 기다려 주는 쪽도 없다. JVM 이 종료되면 하던 일과 함께 사라진다.

launch 는 소비 스레드가 코루틴을 띄우고 즉시 반환해 처리 전에 ack 된다. runBlocking 은 처리가 끝날 때까지 소비 스레드가 기다렸다가 반환하므로 처리 후 ack 된다
그림 3 · launch 는 리스너 메서드를 바로 끝내고 일을 다른 스레드로 넘긴다. runBlocking 은 일이 끝날 때까지 메서드를 끝내지 않는다.

4. 문제

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

개선 전 receivePpurioMessage: CoroutineScope(Dispatchers.IO).launch 로 sendMessages 를 띄우고 바로 반환한다
스냅샷 1 · 개선 전 알림톡 리스너. 인증번호·증정품·회원 리스너도 같은 형태였다.

2장과 3장을 합치면 이런 순서가 된다.

  1. 메시지가 배달된다.
  2. 리스너가 launch 로 코루틴을 띄우고 바로 반환한다.
  3. 컨테이너는 처리가 끝났다고 보고 ack 한다. 브로커는 메시지를 지운다.
  4. 컨테이너는 곧바로 다음 메시지를 받는다. prefetch 가 250 이라 메시지가 쌓여 있으면 수백 건이 금방 ack 되고 그만큼의 코루틴이 IO 스레드에서 대기한다.
  5. 이때 파드가 내려가면(배포·노드 교체·OOM) 대기하던 코루틴은 JVM 과 함께 사라진다. 큐에도 남아 있지 않다.
개선 전에는 배달 직후 ack 가 나가서, 파드가 종료되면 아직 보내지 못한 알림이 큐에도 워커에도 남지 않고 사라진다
그림 4 · 개선 전. ack 가 처리보다 먼저 나가므로 종료 시점에 진행 중이던 알림은 어디에도 남지 않는다.

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

개선 전에도 주문·배송 알림 리스너는 runBlocking 이었고, 주석에 노드 교체 사고가 적혀 있다
스냅샷 2 · 같은 파일에서 이미 고쳐져 있던 리스너. 주석에 같은 원인이 적혀 있다.

Consumer 에서 launch 를 쓰는 리스너는 일곱 개였다. 이번에는 그중 알림을 보내는 네 개(알림톡·인증번호·증정품·회원)를 고쳤다. 나머지 세 개(분석·쿠폰 발급·엑셀 다운로드)는 알림이 아니다. 엑셀 생성이나 대량 쿠폰 발급처럼 오래 걸리는 일이라 소비 스레드를 오래 잡는 문제를 따로 봐야 해서 남겨 두었다(8장).

5. 이렇게 된 이유

  • 타입으로 막히지 않는다. 리스너 안에서 suspend 함수를 부르면 컴파일러가 코루틴이 필요하다고 알려 주지만 launch 와 runBlocking 은 둘 다 컴파일된다. 둘은 언제 반환하느냐가 다른데 그 시점이 곧 ack 시점이다.
  • runBlocking 은 스레드를 막으니 피해야 한다는 인식이 있다. 하지만 리스너 컨테이너의 소비 스레드는 원래 메시지 하나를 처리하는 동안 막혀 있도록 만들어진 스레드다. 처리량이 더 필요하면 소비자 수(concurrency)를 늘리면 된다.
  • 평소에는 드러나지 않는다. 파드가 정상이면 launch 든 runBlocking 이든 알림은 나간다. 차이는 종료 시점에만 생긴다. 사라진 알림은 로그도 남기지 않는다.

6. 바꾼 것

6-1. 처리 후 ack: runBlocking

개선 후 receivePpurioMessage: runBlocking 안에서 발송하고, 업무 예외는 삼키고 취소만 다시 던진다
스냅샷 3 · 개선 후 알림톡 리스너. 네 리스너 모두 같은 구조다.
  • runBlocking 을 쓰면 발송이 끝나야 메서드가 반환되고 그때 ack 된다.
  • 업무 예외는 잡아서 ack 한다. 벤더 500 이나 템플릿 없음 같은 실패를 던지면 기본 정책상 같은 메시지가 계속 되돌아온다. 실패는 amqp.consume ERROR 로그에 payload 와 함께 남는다. 개선 전에도 실패는 로그만 남았으니 동작은 같다.
  • 취소(CancellationException)만 다시 던진다. 파드 종료로 끊긴 처리는 ack 하지 않아야 다시 배달된다.
  • 동시 처리는 인스턴스당 소비자 4~8개(주문 알림 큐와 같은 값)로 두었다. launch 일 때는 사실상 제한이 없었다. 비즈뿌리오 호출이 한꺼번에 몰리지 않는 대신 순간 처리량에는 상한이 생긴다.

6-2. 새로 생긴 문제: 재배달되면 두 번 보낸다

처리 후 ack 로 바꾸면 유실은 없어지지만 같은 메시지를 두 번 처리할 수 있다. 처리 도중이나 처리를 끝내고 ack 하기 직전에 파드가 내려가면 브로커가 그 메시지를 다른 워커에게 다시 준다. 알림톡 메시지 하나에 여러 건이 실려 오기도 한다.

처리 후 ack 로 바꾸면 처리 중에 종료된 메시지가 다른 워커로 다시 배달된다. 아무 장치가 없으면 이미 보낸 #0, #1 을 한 번 더 보낸다
그림 5 · 처리 후 ack 만 하면 이미 보낸 #0 과 보내던 #1 이 한 번 더 나간다.

메시지 큐가 기본으로 보장하는 것은 at-least-once(적어도 한 번)다. 그래서 같은 메시지를 두 번 받아도 결과가 한 번 받은 것과 같도록(멱등) 소비자 쪽에서 처리해야 한다.

6-3. 수신 원장: 같은 메시지 알아보기

먼저 같은 메시지를 알아볼 키가 필요했다. 재배달된 메시지는 messageId 가 그대로다. 그런데 API 서버와 워커가 발행하는 메시지에는 messageId 가 없었다(아웃박스를 거쳐 발행하는 메시지만 outbox.id 를 실었다). 그래서 두 서버의 메시지 변환기에서 setCreateMessageIds(true) 를 켰다. messageId 가 비어 있을 때만 UUID 를 만들고 이미 있으면(아웃박스) 그대로 둔다. 이 동작은 spring-amqp AbstractMessageConverter 소스로 확인했다.

그다음 Mongo 에 수신 원장 컬렉션을 두었다. _id 를 큐:messageId(:순번) 으로 잡아서 별도 유니크 인덱스 없이도 insert 한 번으로 원자적으로 선점된다.

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

6-4. 업무 키 멱등: 친구추천 마일리지와 가입 혜택 쿠폰

회원 큐에는 알림톡 외에 친구추천 마일리지 적립과 가입 혜택 단계별 쿠폰이 함께 들어온다. 이 둘도 다시 실행했을 때 결과가 같아야 한다.

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

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
새로 추가한 테스트 63개 모두 통과
스냅샷 7 · 이번에 추가한 테스트. E2E 는 연속 3번 실행해 모두 통과했다.
  • 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 로 캡처했다.

댓글

댓글을 불러오는 중…