결제가 실패하면 사용자에게 결제 실패 알림이 남아야 합니다. 알림 서비스에는 결제 실패 메시지를 처리하는 코드가 있습니다. 여기서 알림 타입 하나를 PAYMENT_FAILED 에서 결제 성공을 뜻하는 PAYMENT_COMPLETED 로 바꿔 보았습니다.

notificationCommandService.createNotification(userId, NotificationType.PAYMENT_COMPLETED, msg);

출처: 알림 서비스 NotificationConsumer 의 결제 실패 처리 코드에서 타입만 바꾼 실험용 변이입니다. 제품의 정상 코드가 아닙니다.

메시지 파싱, 중복 처리 방지, 호출 대상은 그대로 두고 타입만 틀리게 만든 결함입니다. 알림 서비스(notification 모듈)의 테스트 47건은 모두 통과했습니다. 주문 취소 알림을 확인하는 테스트는 있었지만 결제 실패 메시지가 올바른 타입의 알림을 만드는지 보는 테스트는 없었습니다. 같은 서비스를 대상으로 하는 테스트가 있다고 해서 이 결함을 검출한다고 말할 수는 없었습니다.

같은 알림은 여러 서비스를 실제로 띄운 스택에서 돌리는 e2e 도 확인합니다. 이 글은 겹쳐 보이는 두 검사를 줄일 수 있는지 살펴보다가 생긴 질문을 따라갑니다.

같은 최종 결과를 확인하는 e2e 와 JVM 테스트는 같은 결함을 검출할까?

글에 나오는 수치는 출처를 나눠 적었습니다. “로컬”은 macOS arm64 개발 머신의 Docker Desktop 에서 다시 돌린 실험이고 “CI”는 GitHub Actions 의 실행 기록입니다. 두 환경의 시간은 서로 비교하지 않습니다.

알림 하나를 확인하는 검사에는 무엇이 들어 있을까요?

PeekCart 는 주문, 결제, 상품, 알림 네 서비스가 Kafka 로 이벤트를 주고받는 쇼핑몰 예제입니다. 결제가 실패하면 결제 서비스가 payment.failed 이벤트를 자기 DB 의 outbox 테이블에 저장하고 주기적으로 도는 outbox poller 가 이를 Kafka 로 보냅니다. 이 토픽은 세 서비스가 각자의 consumer group 으로 읽습니다.

소비 서비스하는 일e2e 가 확인하는 결과
주문주문을 취소하고 order.cancelled 발행주문 CANCELLED
상품예약해 둔 재고를 되돌림재고 예약 RELEASED, 재고 복구
알림결제 실패 알림 저장PAYMENT_FAILED 알림

같은 토픽을 업무별 group 이 따로 읽으므로 주문 서비스가 메시지를 처리했다고 알림 서비스도 처리했다는 뜻은 되지 않습니다. e2e 는 서비스마다 남기는 소비 기록(애플리케이션의 중복 처리 방지용 테이블, Kafka 의 committed offset 과는 다른 값)을 group 별로 조회합니다.

실제 스택 e2e 의 결제 실패 시나리오(scenario_a)는 결제 실패, 주문 취소, 재고 복구, 소비 기록을 차례로 살핀 뒤 마지막으로 알림 DB 를 조회합니다.

lambda: nonzero(query("notification",
    "SELECT COUNT(*) AS c FROM notifications "
    "WHERE user_id = %s AND type = 'PAYMENT_FAILED'", (user_id,)))

출처: e2e 상태 확인 스크립트 saga_e2e.py 의 scenario_a() 발췌, 줄바꿈만 편집했습니다.

조건에는 사용자와 타입을 함께 겁니다. user_id 만 보면 같은 주문의 주문 생성 알림(ORDER_CREATED)이 이미 조건을 만족합니다.

이 행 하나가 생기려면 여러 조건이 함께 맞아야 합니다. 결제 서비스가 이벤트를 저장하고, poller 가 실제로 돌고, 알림 서비스가 맞는 토픽을 구독해 메시지를 읽고, 소비 함수가 맞는 사용자와 타입으로 저장해야 합니다. 이 글에서는 앞의 것들을 배선, 마지막 것을 로직이라고 부르겠습니다. 배선은 토픽 이름, consumer group, 직렬화 설정, 실행 이미지의 설정, 네트워크처럼 메시지가 실제로 지나가는 길을 이루는 요소입니다. 로직은 입력이 도착한 뒤 어떤 상태를 만드는지입니다.

알림 서비스의 단위 테스트는 메시지 파서와 중복 처리 방지 객체를 stub 으로 바꾸고 소비 메서드를 직접 호출합니다. 실제 토픽 구독, 서비스 이미지, 컨테이너 네트워크는 지나지 않습니다. 결제 실패 입력을 넣고 createNotification 이 PAYMENT_FAILED 로 호출되는지 보면 Kafka 없이도 타입 오류를 검출할 수 있습니다. 대신 이 테스트가 통과해도 실제 토픽 이름이나 이미지 설정이 맞는지는 알 수 없습니다.

JVM 이라는 실행 환경에서도 배선은 확인할 수 있습니다. notification 모듈의 테스트 47건에도 실제 Kafka 와 listener 를 띄우는 통합 테스트가 있습니다. 다만 이 통합 테스트는 order.created 와 payment.refunded 만 다루고 payment.failed 는 다루지 않습니다. 이 비교의 두 검사는 지나는 경로가 다릅니다. 단위 테스트는 소비 함수를 직접 부르고 e2e 는 여러 서비스 이미지를 실제로 연결한 스택을 지납니다.

아래 장면에서 같은 결제 실패 메시지에 결함을 넣어 보고 두 검사가 각각 어떻게 반응하는지 먼저 예상해 보세요.

같은 알림을 확인하는 두 검사는 같은 결함을 검출할까요?

위쪽은 결제 실패 메시지가 실제 Kafka 를 거쳐 알림 DB 까지 가는 e2e 경로이고, 아래쪽은 소비 함수를 직접 부르는 JVM 단위 테스트입니다. 넣을 결함과 JVM 검사의 보강 여부를 골라 보세요.

넣을 결함
JVM 검사
e2e 결제 실패 시나리오미측정 notification 모듈 JVM 테스트 전체미측정 결함을 검출한 검사미측정

    알림 타입 오류의 결과는 로컬에서 실행한 값입니다. e2e 는 결함을 넣은 알림 서비스 이미지로 결제 실패 시나리오를 돌린 한 번의 실행이고, JVM 수치는 notification 모듈 전체 테스트의 JUnit 결과(보강 전 47건, 보강 후 49건)이며, 이 모듈에는 실제 Kafka 를 쓰는 통합 테스트도 있지만 payment.failed 는 다루지 않습니다. CI 수치가 아닙니다. 구독 설정 오류는 알림 서비스의 payment.failed 구독 토픽만 틀린 경우를 코드 경로로 따라간 예시이며 실행하지 않았습니다. 이 스택의 e2e 스크립트는 기동 전에 listener 의 토픽과 group 을 선언 목록과 대조하므로 그 검사에서 실패할 것으로 예상하고, 장면의 메시지 흐름은 스택이 떴다고 가정한 그림입니다. JVM 결과도 예상입니다. 결제 서비스의 outbox poller, 다른 서비스의 consumer group, 재시도와 DLQ 는 그리지 않았습니다.

    알림 타입을 바꾼 알림 서비스 이미지를 따로 만들고 다른 세 서비스는 정상 이미지로 둔 채 결제 실패 시나리오를 로컬에서 돌렸습니다. 앞선 결제, 주문, 재고, 소비 기록 확인은 모두 지나갔고 마지막 알림 대기에서 실패했습니다.

    타임아웃(180s): PAYMENT_FAILED 알림 user=100

    출처: 로컬 실제 스택 실행 로그에서 timeout 줄만 발췌했습니다. 180초는 이 대기의 상한이며 처리 시간이 아닙니다.

    실패한 뒤 DB 를 따로 읽기 전용으로 조회하니 같은 사용자에게 ORDER_CREATED 1건과 PAYMENT_COMPLETED 1건이 있었고 알림 서비스 group 의 결제 실패 소비 기록도 1건 있었습니다. 기대한 PAYMENT_FAILED 알림은 없었습니다. 메시지는 실제로 도착했고 도착한 뒤 잘못된 타입으로 처리된 상태였습니다.

    데모의 구독 설정 오류는 실행하지 않은 예시입니다. 이 스택의 e2e 스크립트는 스택을 띄우기 전에 각 listener 의 토픽과 consumer group 을 선언된 목록과 대조합니다. 그래서 이런 결함은 시나리오까지 가기 전에 그 검사에서 걸릴 것으로 예상합니다. 어느 쪽이든 실제 스택 쪽 검사가 검출하고 소비 함수를 직접 부르는 단위 테스트는 구독 설정을 지나지 않습니다.

    알림 행이 없다는 결과에는 메시지가 도착하지 않은 경우와 도착했지만 잘못 처리된 경우가 모두 들어갑니다. 반대로 정상 스택에서 알림이 한 건 생겼다고 해서 모든 메시지 타입과 실패 처리 분기가 확인된 것도 아닙니다. 성공한 e2e 는 그 시나리오가 지난 배선과 그 시나리오가 확인한 결과까지만 보장합니다.

    결함을 넣고 실패하기만 하면 충분할까요?

    검사가 결함을 검출하는지 보려면 결함을 일부러 넣고 그 검사가 실패하는지 확인하면 됩니다. 이렇게 결함을 넣고 실패를 기대하는 검사를 음성 대조군(negative control)이라고 부릅니다. 정상 스택에서는 시나리오가 통과해야 하고 결함을 넣은 스택에서는 목표한 검사가 실패해야 합니다. 그 실패를 확인하면 대조군은 통과합니다.

    다만 대조군이 실패를 봤다고 해서 기대한 지점에서 실패했다는 보장은 없습니다. 상품 서비스를 멈추고 재고 예약이 일어나지 않는지 보는 대조군이 있었습니다. 이 대조군은 상품 서비스를 먼저 멈춘 뒤 정상 시나리오를 처음부터 실행했는데 시나리오의 첫 동작이 상품 생성 API 호출이었습니다. CI 실행 기록에서 멈춘 서비스의 상품 생성은 약 1초 만에 실패했고 예약을 기다리는 검사에는 가지도 못했습니다. 대조군의 실패 판정 코드가 어떤 실패든 통과로 처리했기 때문에 대조군은 통과했습니다. 이 사례와 해결 과정은 saga E2E 는 어디서 잘못 통과할까에서 자세히 다뤘으므로 여기서는 요점만 정리합니다.

    • 기대한 지점을 지정합니다. 대기가 시간 초과로 끝날 때 어느 대기였는지를 Timeout.stage 에 남기고 대조군은 그 값이 기대한 대기일 때만 통과합니다. 정상 시나리오와 대조군이 같은 예약 대기 함수를 씁니다.
    • 원인이 실제로 들어갔는지 봅니다. 주문이 예약 단계까지 가도록 상품과 단가 캐시를 먼저 준비한 뒤 상품 서비스를 멈추고 주문 이벤트가 발행됐는지와 상품 서비스가 응답하지 않는지를 확인하고 나서 예약 부재를 봅니다.
    • 결함을 빼면 같은 흐름이 다시 도는지 봅니다. 서비스를 다시 띄우고 같은 주문의 예약이 도착하는지 확인합니다.

    복원 확인은 결과가 오지 않은 원인이 주입이었다는 근거가 됩니다. poller 를 멈추는 대조군은 스케줄러 락 라이브러리 ShedLock 의 락 행을 미래 시각으로 잠가 주문 이벤트 발행을 막습니다. 처음에는 끝난 뒤 그 행을 지웠습니다. ShedLock 6.3.1 은 이미 만든 락 이름을 메모리에 기억하고 이후에는 INSERT 없이 UPDATE 만 시도하므로(StorageBasedLockProvider) 행을 지우면 갱신할 행이 없어 발행이 계속 멈춰 있을 수 있습니다. 이 문제를 고치던 작업 중 로컬에서 처음 돌린 대조군에서도 행 삭제 방식으로는 복원 확인이 실패했습니다. 해제는 행을 지우는 대신 시각을 과거로 바꾸도록 고쳤습니다.

    UPDATE peekcart_order.shedlock
    SET lock_until = NOW() - INTERVAL 1 DAY
    WHERE name = 'orderOutboxPollingJob'
      AND locked_by = 'e2e-negative-control';

    출처: 스택 실행 스크립트 saga-e2e-smoke.sh 의 대조군 복원 부분 발췌입니다. 실험용으로 격리한 스택에서만 실행하며 운영 복구 절차로 쓰는 코드가 아닙니다.

    대조군은 한 스택에서 차례로 돕니다. 그래서 발행이 멈춘 채로 다음 대조군인 “재고가 충분하면 주문이 취소되지 않는다”를 돌리면 예약도 취소도 일어나지 않은 상태에서 통과할 수 있습니다. 이처럼 확인하려던 흐름이 실행되지 않아도 성공하는 검사가 vacuous-green 입니다. 이 대조군은 예약이 성공한 것을 먼저 확인한 뒤 취소가 없는지 기다리도록 바꿨습니다. “일어나지 않았다”를 확인하는 검사에는 앞 단계가 실제로 일어났다는 증거가 함께 있어야 합니다.

    결함은 어디에서, 무엇으로 넣을까요?

    실제 스택에 결함을 넣을 때는 스택 바깥의 기존 수단만 썼습니다. 락 행의 시각을 바꾸고, 컨테이너를 멈추고, 외부 결제사 대역(pg-stub, 결제 키에 따라 정해진 응답을 돌려주는 컨테이너)의 응답을 정했습니다. 결제 실패 시나리오는 승인 거절을 뜻하는 4xx 응답을 씁니다. 결과를 알 수 없는 5xx 를 확정 실패와 섞지 않기 위해서입니다.

    상품 정지 대조군을 고치면서 주입 방법도 다시 비교했습니다.

    선택지얻는 것잃는 것
    고정 대기를 그대로 두고 검출 문제만 고치기변경이 가장 적음대조군의 고정 대기 405초(180초, 180초, 45초)가 그대로 남고, 상품 정지 대조군이 예약 대기까지 가도록 고치면 대기가 더 늘어남
    서비스에 테스트용 제어 엔드포인트나 빈 추가poller 와 시간을 직접 멈추고 진행시킬 수 있음운영 이미지에 제어 경로가 늘어남
    스택 바깥 수단으로 주입하고 원인과 복원을 확인운영 이미지가 그대로이고, 대기 시간을 원인 확인 기준으로 줄일 수 있음실제 주기를 기다려야 하고, 스택을 멈추고 되돌리는 비용이 들며, 시계 전체를 진행시킬 수 없음

    필요한 주입은 바깥 수단으로 할 수 있었고 운영 이미지에 제어 경로를 더할 만큼 이익이 크지 않다고 보아 세 번째를 골랐습니다. 대조군을 JVM 테스트로 옮기는 안도 있었지만 그렇게 하면 배선 검증을 무엇이 대신하는지부터 정해야 해서 이 단계에서는 보류했습니다. 배선 검증을 무엇이 맡을지는 다음 절에서 e2e 항목을 지울지 정하면서 다룹니다.

    바깥 수단으로 할 수 있는 일의 범위는 따로 봐야 합니다. 저장된 만료 시각을 바꾸면 특정 데이터가 만료 조건을 만족하게 만들 수 있지만 애플리케이션이 시간을 읽는 모든 곳을 제어하는 것과는 범위가 다릅니다. 외부 조작만으로 충분한지는 검증할 결함마다 다시 확인해야 합니다.

    “결과가 오지 않는다”를 확인하는 대기 시간은 대조군마다 정했습니다. poller 정지와 상품 서비스 정지는 poller 기본 주기 5초의 6배인 30초, listener 부재는 60초, 충분한 재고에서 취소가 없는지는 45초를 기다립니다. 앞 단계가 실제로 진행됐는지, 원인이 들어갔는지, 복원 뒤 다시 도는지를 함께 확인한다는 조건에서 고른 값입니다. 모든 부하에서 정상 흐름의 최대 지연을 증명한 값은 아니므로 이보다 느린 경로는 검출하지 못할 수 있습니다.

    어떤 조작으로 멈췄는지가 결과에 영향을 줍니다. docker stop 은 주 프로세스에 SIGTERM 을 보내고 유예 시간이 지나면 SIGKILL 을 보내므로 애플리케이션이 정상 종료 절차를 밟을 수 있습니다. docker pause 는 Linux 에서 freezer cgroup 으로 프로세스를 멈추며 프로세스는 멈췄다는 사실을 알지 못합니다. 정상 종료한 consumer 가 group 에서 빠지는 시점과 응답 없는 consumer 의 세션이 만료되는 시점이 같다고 볼 근거는 없습니다. 상품 서비스 대조군은 stop 을 썼으므로 그 결과는 실제로 사용한 조작과 대기 시간 범위에서만 설명할 수 있습니다.

    바꾼 뒤 CI 에서 대조군 내부 소요는 461초에서 234초가 됐습니다. 461초는 고치기 전 PR 실행, 234초는 고친 뒤 main 실행의 대조군 구간(잡 전체가 아닌 내부 단계 합계)이며 같은 조건에서 반복한 비교는 아닙니다.

    대조군도 대조해 볼 수 있을까요?

    대조군은 검사를 검사하는 도구이므로 대조군 자체도 같은 방법으로 확인할 수 있습니다. 결함을 넣지 않았거나 엉뚱한 곳에 넣었을 때 대조군이 실패하는지 보는 것입니다.

    이 글을 위해 비교 시점의 코드를 로컬에서 다시 띄워 돌려 보았습니다. 정상 시나리오 4종이 먼저 통과했고 그 위에서 음성 대조군 6종과 각 대조군의 준비, 복원 확인이 모두 통과했습니다. 6종은 앞에서 본 poller 정지, 상품 서비스 정지, 충분한 재고에 더해, 소비 서비스 하나를 멈췄을 때 consumer group 확인에서 실패하는지 보는 listener 부재와 그 밖의 격리 확인 2종입니다.

    이어서 알림 타입 결함 이미지를 넣은 스택에서 poller 를 멈추지 않은 경우, 상품 서비스를 멈추지 않은 경우, listener 를 그대로 둔 경우, 상품 서비스 대신 결제 서비스를 멈춘 경우를 돌렸습니다. 네 경우 모두 대조군이 실패했습니다. poller 무주입은 목표한 예약 대기가 그대로 진행돼 실패했고 나머지 셋은 주입이 실제로 들어갔는지 보는 사전 확인에서 멈췄습니다. 엉뚱한 서비스를 멈춰 본 것은 상품 서비스 대조군 하나입니다. 이 결과는 로컬 한 번의 실행이며 대조군이 다룬 결함 밖의 장애까지 대신하지는 않습니다.

    겹쳐 보이는 검사는 지워도 될까요?

    e2e 의 확인 항목을 하나씩 배선과 로직으로 나눠 보았습니다. 배선을 지나는 항목은 e2e 에 남겨야 합니다. 로직 쪽은 JVM 테스트가 이미 검출하는지 결함을 넣어 확인했습니다. 사람이 고른 로직 결함 23건을 코드에 하나씩 넣고 해당 JVM 테스트를 돌렸더니 20건은 지정한 테스트가 실패했고 3건은 모듈 전체 테스트가 통과했습니다. 23건은 모든 장애를 나열한 목록이 아니므로 이 숫자를 검출률로 읽을 수는 없습니다.

    통과한 3건은 다음과 같습니다.

    잘못 바꾼 동작기존 검사가 놓친 이유추가한 검사
    승인 실패 응답 코드를 PAY-005 대신 PAY-001 로 반환HTTP 400 만 확인했고 두 코드 모두 400응답의 code 가 PAY-005 인지 확인
    결제 실패 알림을 PAYMENT_COMPLETED 로 생성결제 실패 소비 함수의 타입 검사가 없음PAYMENT_FAILED 타입으로 알림 생성을 호출하는지 확인
    재고 예약 실패(RESERVATION_FAILED)로 취소된 주문의 알림을 생략다른 취소 사유는 검사했지만 이 값은 없음예약 실패 취소가 ORDER_CANCELLED 알림을 만드는지 확인

    첫 행은 결제 승인 API 가 동기 방식이던 시점의 결과입니다. 지금은 승인 요청을 비동기로 접수하고 응답하도록 바뀌었으므로 이 행을 현재 응답 규약으로 읽으면 안 됩니다.

    세 결함 모두 “에러가 나면 실패”하는 검사는 통과하고 “정확한 결과가 다르면 실패”하는 검사에서만 드러납니다. 응답 코드 결함에서는 400 이라는 상태 코드가, 알림 타입 결함에서는 알림이 생겼다는 사실이 그대로였습니다. 취소 알림 결함은 다른 취소 사유만 검사하는 테스트를 그대로 통과했습니다.

    무엇을 고르고 무엇을 남겼을까요?

    선택지얻는 것고르지 않은 이유
    JVM 에 같은 로직 검사가 있는 e2e 항목 제거역할 구분이 단순해 보임배선을 지나는 항목은 지울 수 없고, 배선 없이 서비스 DB 의 기록 값을 한 번 더 조회하는 나머지 항목은 대부분 대기 없는 조회라 줄일 시간이 작다고 봄
    Pact 같은 consumer-driven contract test 도입서비스 사이 메시지 호환성을 독립적으로 확인이벤트 DTO 를 공통 모듈로 공유하고 같은 커밋의 이미지로 e2e 를 돌리므로 추가 필요가 작다고 봄. 이 전제는 아래에서 일부 틀린 것으로 확인
    세 로직 결함의 JVM 검사만 보강하고 e2e 는 유지로직 결함을 빠르고 좁은 테스트에서 확인고른 안

    세 번째를 골랐습니다. 제품 코드는 바꾸지 않고 기존 테스트 클래스 두 개에 검사를 더했습니다. 하나는 기존 테스트 메서드에 응답 코드 확인을 추가한 것이라 전체 테스트 수는 1253건에서 1255건으로 2건 늘었습니다. e2e 에서 지운 항목은 없습니다. 알림 타입을 보는 JVM 테스트는 실제 토픽 구독과 이미지 설정까지 대신 확인하지 않기 때문입니다.

    첫 번째 안을 고르지 않은 이유 중 “줄일 시간이 작다”는 개별 조회의 제거 전후 시간을 재지 않은 추정입니다. 절약 시간이 정확히 0초라고 말할 근거는 없습니다.

    두 번째 안의 전제도 시험해 보았습니다. 공유 DTO 의 Java record 필드 userId 에 @JsonProperty("recipientId") 만 붙이면 생성자와 userId() 접근자가 그대로라 결제와 알림 두 모듈 모두 컴파일됩니다. 그런데 알림 소비자는 공유 DTO 로 역직렬화하지 않고 JSON 에서 userId 라는 이름으로 필드를 읽습니다. 실제 ObjectMapper 로 직렬화하면 JSON 에는 recipientId 만 있어서 소비자가 찾는 필드가 없습니다. 같은 일회성 탐침 테스트가 정상 코드에서는 통과하고 이 변이에서는 실패했습니다. 실제 Kafka 를 거치거나 소비 함수 전체를 호출한 실험은 아닙니다. 공통 타입을 공유하면 컴파일이 메시지 필드 불일치를 막는다는 전제에는 반례가 있다는 데까지만 보여 주며 contract test 도구가 필요하다는 결론까지 가지는 않습니다. 같은 커밋 이미지로 도는 e2e 가 이 변이를 검출하는지는 돌려 보지 않았습니다. 서비스별로 다른 버전이 함께 배포되는 상황도 여전히 검증 범위 밖입니다.

    보강한 검사는 실제로 결함을 검출할까요?

    검사를 추가해도 그 검사가 결함을 검출하는지는 따로 봐야 합니다. 그래서 세 결함을 다시 넣고 검사 추가 전후를 모두 재현했습니다. 제품 코드는 같고 테스트 파일만 추가 전과 후로 바꿨으며 해당 모듈 전체 테스트를 같은 testSeed(310018)로 돌렸습니다.

    조건정상 코드결함 1 (응답 코드)결함 2 (알림 타입)결함 3 (취소 알림 생략)
    검사 추가 전이번 로컬 실행 없음payment 239건 모두 통과notification 47건 모두 통과notification 47건 모두 통과
    검사 추가 후payment 239건, notification 49건 모두 통과239건 중 1건 실패49건 중 1건 실패49건 중 1건 실패

    조건: 로컬 macOS arm64, Java 17, 해당 모듈 전체 테스트를 --rerun 으로 한 번씩 실행했습니다. 검사 추가 전의 정상 코드 조건은 이번에 돌리지 않았습니다. 실패한 1건은 세 경우 모두 새로 추가하거나 보강한 검사였습니다.

    선택한 세 결함은 이제 더 좁은 테스트에서 실패를 볼 수 있습니다. 다만 이번에 다시 돌린 것은 세 결함의 해당 모듈 전체이고 저장소 전체 1255건은 아니며, 다른 seed 나 다른 결함은 확인하지 않았습니다. e2e 쪽 증거도 결함마다 범위가 다릅니다. 알림 타입 결함은 결함을 넣은 이미지로 e2e 를 직접 돌려 마지막 알림 대기에서 실패하는 것을 확인했습니다. 응답 코드와 취소 알림 결함은 e2e 의 해당 확인 항목과 코드 경로를 맞춰 보기만 했고 23건 전체를 e2e 로 돌리지도 않았습니다.

    e2e 를 지우지 않았으므로 이 결정으로 CI 시간이 줄지는 않습니다. 한 main 실행의 CI 시나리오 잡 321초 중 시나리오 4종은 합계 82초, 인프라와 토픽, 앱 기동은 합계 157초였습니다. 이 분해를 보고 기동 시간을 따로 살펴보게 됐습니다.

    같은 구분은 다른 검사에도 필요했을까요?

    결과 하나에 여러 책임이 섞여 있다는 문제는 시간 상한과 운영 alert 에서도 나왔습니다.

    무응답 Redis 의 시간 상한과 캐시 경로를 나눴습니다

    상품 상세 조회는 상품 캐시와 재고 캐시를 씁니다. Redis 가 응답하지 않으면 두 캐시의 get 과 put 이 각각 command timeout 을 기다린 뒤 DB 조회로 넘어갑니다. 설정값 500ms 가 네 번 걸리므로 이 경로의 timeout 예산은 2초입니다. 테스트는 정상 조회로 초기화 비용을 먼저 치른 뒤, Toxiproxy 의 timeout toxic 을 timeout 0 으로 걸어 Redis 의 응답을 전달하지 않게 하고 조회 구간의 시간을 잽니다. timeout 0 은 연결을 닫지 않은 채 toxic 을 제거할 때까지 데이터를 버리는 조건이라 Redis 프로세스가 멈추거나 연결이 거부되는 것과는 다른 장애입니다.

    이 테스트가 기존 상한 2.6초를 넘겨 2.764079209초로 실패한 적이 있습니다. 같은 상품 키의 실패 로그는 get 과 put 을 합쳐 네 번으로, timeout 횟수는 예산 계산과 같았습니다. 상품 캐시의 get 실패 로그와 put 실패 로그 사이 1.071초에서 put timeout 500ms 를 빼면 약 571ms 가 남습니다. DB 조회 등이 포함된 구간이라 처음 분석은 이 추가 시간을 부하에 따른 잡음으로 보았지만 이 값만으로 원인을 CPU 부하로 확정할 수는 없습니다.

    원인을 확정할 수 없었으므로 시간 상한 하나에 원인 판정까지 맡기지 않고 시간과 경로를 나눴습니다. 상한은 3.5초로 늘리고 캐시 경로를 지났는지는 fallback 카운터(캐시 get, put 이 실패해 DB 로 넘어간 횟수)의 증가분으로 봅니다. 누적값만 보면 앞선 테스트가 남긴 실패 횟수 때문에 이번 요청이 해당 경로를 지나지 않아도 통과할 수 있습니다. 테스트는 측정 전에 캐시별 카운터 값을 기준값으로 저장해 두고 빼는데, 이 기준값 목록에 재고 캐시가 빠져 있어서 추가했습니다.

    시간 상한이 반응하는 변화를 보려고 로컬에서 조건마다 테스트 파일을 바꿔 돌렸습니다. timeout 변경은 테스트의 설정 속성(@TestPropertySource)을 바꿨고 지연은 측정 구간 안에 sleep(parkNanos)을 넣었습니다.

    조건측정 구간결과
    정상 (timeout 500ms)상한 3.5초 안통과
    timeout 을 1초로 변경4.1995065초실패
    측정 구간에 1000ms 지연 추가상한 3.5초 안통과
    측정 구간에 1800ms 지연 추가4.85348825초실패

    조건: 로컬 실행 1회씩. 네 조건 모두 timeout 예외는 4회였고 예외 메시지에 찍힌 timeout 은 정상과 지연 조건이 500ms, 변경 조건이 1s 였습니다. 지연 추가는 상한의 여유를 보려고 넣은 탐침이며 부하를 재현한 조건은 아닙니다.

    예산 2초에 상한 3.5초이면 계산상 여유는 1.5초입니다. 1초 추가 지연에서는 통과했고 1.8초에서는 실패했지만, 1.8초 실행에서도 지연을 뺀 시간이 약 3.05초로 예산보다 1초 넘게 길었습니다. 호스트 부하가 높았던 로컬 실행이라 기본 시간이 실행마다 크게 흔들렸고, 이 결과로 여유가 정확히 1.5초라고 말할 수는 없습니다. 시간 상한은 timeout 이 크게 늘거나 빠지는 변화에 반응하지만 설정이 그대로여도 다른 이유로 1.5초 넘게 늦어지면 똑같이 실패합니다. 시간을 넘겼다면 timeout 설정이 틀렸을 수도 있고 다른 이유로 늦어졌을 수도 있습니다. 둘을 구분하려면 카운터와 실제 적용된 설정, 실패 로그를 함께 봐야 합니다. 지금 테스트의 실패 메시지는 원인을 timeout 설정 변경으로 단정하고 있어서 고칠 항목으로 남겼습니다.

    경로 검사를 카운터로 옮겨도 wall-clock 으로 재는 상한에는 부하의 영향이 남습니다. 상한을 늘릴수록 잡음에는 덜 흔들리지만 작은 timeout 변화는 놓치게 됩니다.

    발행 실패와 소비 실패는 어디에서 볼까요?

    Outbox FAILED 는 Kafka 로 보내는 재시도를 다 쓴 발행 실패이고 DLQ 는 소비 처리에 실패한 메시지를 별도 토픽으로 옮긴 것입니다. PeekCart 의 dlq.backlog 는 DLQ 토픽의 메시지를 모아 DB 에 저장한 원장의 적재량이며 Kafka 토픽의 전체 메시지 수와 같은 값이 아닙니다.

    처음에는 DLQ 적재량 메트릭이 없고 두 실패 알림이 같은 Slack 채널에 섞인다고 생각했습니다. 코드를 보니 DLQ gauge 와 alert 는 이미 있었습니다. 직접 Slack 을 보내는 구현은 알림 서비스에만 있었고 나머지 세 서비스는 로그만 남기는 구현을 쓰고 있었습니다. 빠져 있던 것은 Outbox FAILED 를 평가하는 alert 였습니다.

    sum by (application)(outbox_backlog{application=~"notification-service|order-service|payment-service|product-service", status="failed"})

    출처: Grafana alert 규칙 파일 grafana-alerts.yml 의 peekcart-outbox-failed 쿼리 발췌입니다. 이 쿼리 결과가 0보다 큰 상태가 5분 이어지면 발화하도록 조건을 더했습니다.

    Outbox 와 DLQ 규칙에는 각각 signal="outbox-publish", signal="dlq" 라벨을 붙여 외부 채널로 나눠 보낼 기준을 만들었습니다. 다만 실제 contact point 와 notification policy 는 아직 설정하지 않았으므로 라벨이 있다는 것과 채널이 나뉘어 전달된다는 것은 다른 상태입니다.

    복구 경로는 실패 종류마다 다릅니다. 발행 실패라면 브로커와 발행 경로를 조사하고 재발행 조건을 확인해야 합니다. 소비 실패라면 실패 원인을 고친 뒤 재처리해도 되는지 따져 봐야 합니다. 어느 쪽이든 원래 실패한 작업과 멱등성 조건을 확인해야 복구가 끝납니다. 라벨만으로 복구가 정해지지는 않습니다. Outbox FAILED 의 재발행 절차는 아직 운영 절차로 만들지 않았습니다.

    alert 는 쿼리 문자열이 맞는지와 규칙이 실제로 발화하는지를 따로 봐야 했습니다. 규칙 파일의 정적 검사기는 쿼리 식을 확인합니다. Outbox 규칙의 signal 을 dlq 로 바꾸거나, 규칙을 일시정지하거나, 발화 조건을 $A > 999 로 바꾸거나, 지속 시간을 500분으로 바꾼 네 변이를 넣어 보니 모두 통과했습니다. 쿼리 A 가 그대로이기 때문입니다. 실제 발화는 로컬 Prometheus 와 Grafana 에 테스트용 시계열을 넣어 따로 확인했습니다. 처음 실행은 Grafana 가 초기 평가를 마치기 전에 확인을 시작해 NoData 로 끝났습니다. 이 NoData 는 확인 도구의 준비 문제여서 규칙이 실패한 결과로 세지 않았습니다. 준비 단계를 고친 확인 도구로 다시 돌린 실행에서 Outbox, DLQ, compensation, cache get, cache evict 규칙 다섯 개가 원래 지속 시간과 1분 평가 간격으로 발화한 뒤 정상으로 돌아오는 것을 보았습니다. 이 확인에는 테스트용 시계열만 썼고 실제 앱이 낸 메트릭과 외부 채널 전달은 포함하지 않았습니다.

    다시 설계한다면 무엇을 더 확인할까요?

    여기부터는 결과를 바탕으로 한 사후 분석입니다.

    기대하는 실패 위치를 검사 설계에 함께 둡니다. 정상 결과와 “이 결함을 넣으면 어느 검사에서 실패해야 하는가”를 한 쌍으로 두면 준비 단계의 실패가 목표 검사의 성공으로 처리되는 일을 일찍 발견할 수 있습니다. 모든 결함을 나열할 수는 없고 실제 실행 경로와 다른 모형에 결함을 넣으면 실제 경로의 검출 여부를 보여 주지 못한다는 한계는 남습니다.

    시간 상한은 검사할 변화의 크기와 환경 잡음의 여유를 나눠 설명합니다. 경로를 지났는지는 증가분으로, 무응답 의존이 오래 기다리게 만드는 변화는 시간으로 확인합니다. 유한한 상한에는 부하 영향이 남으므로 실패를 원인으로 바로 읽지 않고 로그를 보존합니다.

    alert 의 쿼리 검사와 발화 검사를 나눕니다. 쿼리 문자열을 고정해도 임계값, 지속 시간, 일시정지 여부, 라우팅 라벨은 달라질 수 있습니다. 실제 시계열을 넣어 발화와 복귀를 보는 검사와, 설정 변이를 빠르게 검출하는 정적 검사가 서로 보완합니다.

    정리하며

    처음 질문으로 돌아가면, 같은 최종 결과를 확인해도 두 검사가 검출하는 결함은 달랐습니다. 결제 실패 알림 하나를 확인하는 e2e 에는 실제 메시지 전달과 알림 생성 로직이 함께 들어 있었고, 소비 함수를 직접 부르는 JVM 테스트는 그중 로직만, 그것도 해당 검사가 있을 때만 볼 수 있었습니다. 알림 타입 결함은 보강 전 JVM 테스트를 모두 통과하고 e2e 에서 실패했습니다. 배선 결함은 이 글에서 JVM 테스트와 나란히 돌려 보지 않았지만, poller 정지와 listener 부재처럼 메시지가 지나는 길을 끊는 대조군은 실제 스택에서 검출됐습니다.

    확인한 것범위
    알림 타입 결함은 기존 JVM 테스트를 모두 통과하고 e2e 의 마지막 알림 대기에서 실패로컬, 결함을 넣은 이미지로 한 번 실행
    세 로직 결함은 검사 추가 전 모듈 전체 통과, 추가 후 새 검사 1건씩 실패, 추가 후 정상 코드는 통과로컬, 세 결함의 해당 모듈 전체
    poller 정지와 listener 부재를 포함한 대조군 6종은 결함을 넣었을 때 통과, 세 대조군은 주입을 빼거나 엉뚱한 서비스를 멈췄을 때 실패로컬, 한 번 실행 (엉뚱한 서비스 정지는 상품 대조군만)
    시간 상한은 1초 지연에서 통과하고 1.8초 지연과 timeout 1초 변경에서 실패로컬, 조건별 한 번 실행
    alert 정적 검사는 라우팅 라벨, 일시정지, 임계값, 지속 시간 변이를 통과로컬, Outbox 규칙의 네 변이

    그래서 세 로직 결함의 검사는 JVM 테스트에 보강했고 실제 연결을 지나는 e2e 는 하나도 지우지 않았습니다. 이 보강으로 고른 결함은 더 좁은 테스트에서 확인할 수 있게 됐습니다. 검증 시간이 줄거나 모든 결함이 보장되지는 않습니다. 정상 상태의 통과, 결함을 넣었을 때 기대한 지점의 실패, 결함을 뺀 뒤의 복원은 각각 다른 증거였습니다.

    아직 확인하지 않은 범위는 서비스별 버전이 섞인 배포(여러 서비스 이미지 중 일부만 새 버전으로 게시되는 경우 포함), 실제 외부 결제사의 동작, alert 의 외부 채널 전달과 Outbox 재발행 절차, 그리고 정해 둔 대기 시간보다 느린 경로입니다.