서비스마다 테스트는 통과했지만, 함께 돌려 본 적은 없었습니다

PeekCart 의 주문 하나는 네 서비스를 거쳐야 끝납니다. Order 가 주문을 만들고 Product 가 재고를 예약하고 Payment 가 결제를 승인하고 Notification 이 사용자에게 알립니다. 네 서비스는 서로를 직접 호출하지 않습니다. 각자 자기 DB 를 갖고 Kafka 토픽으로만 메시지를 주고받습니다.

결제 실패 경로에서는 Payment 가 payment.failed 를 발행하면 Order 는 주문을 CANCELLED 로 바꾸고 order.cancelled 를 발행합니다. Product 는 같은 payment.failed 를 받아 재고 예약을 RELEASED 로 되돌리고 재고를 원복합니다. Notification 은 실패 알림을 남깁니다. 이미 결제된 주문이 취소되면 환불까지 이어지는데, 이 경로는 24편에서 자세히 다뤘습니다. 이 흐름에 쓰이는 업무 토픽은 10종이고 토픽마다 .dlq 토픽이 하나씩 더 있어 모두 20종입니다.

주문 한 건이 지나가는 길

경로를 고르고 단계를 눌러 보세요. 네 서비스는 서로를 직접 호출하지 않고 Kafka 토픽으로만 이어집니다. 카드 아래 문구는 각 서비스 DB 에 남는 대표 상태입니다.

흐름을 단순화한 모형입니다. Outbox 와 poller, payment.requested 와 주문의 PAYMENT_REQUESTED 상태, product.updated, 재시도와 DLQ 는 그리지 않았습니다. 환불 경로에서는 Payment 자신의 감지와 이 시점의 결제 완료 알림 같은 일부 소비를 생략했고, 환불 원장과 fence 의 자세한 동작은 24편에서 다룹니다. 이벤트가 도착하는 순서와 속도는 설명용입니다.

서비스별 통합테스트는 많았습니다. Testcontainers 로 클래스마다 MySQL · Redis · Kafka 를 띄우고 실제 브로커를 거쳐 메시지를 소비하는 것까지 봤습니다. 하지만 그 컨테이너 안에는 자기 서비스만 있었습니다. 상대 서비스가 보낼 메시지는 테스트가 직접 만들어 넣었습니다. 아래는 Order 가 환불 회신을 받아 보상 원장(환불이 필요한 주문을 Order 가 적어 두는 테이블)의 행을 RESOLVED 로 바꾸는지 보는 테스트입니다.

@Test
@DisplayName("실제 Kafka 발행 → payment.refunded listener 가 배선되어 원장을 종결한다")
void refundReplyListener_isWiredToBroker() {
    Long orderId = seedOpenCompensation();

    kafkaTemplate.send("payment.refunded", orderId.toString(),
            refunded(orderId, "SUCCEEDED", null, LocalDateTime.now().withNano(0)));

    await().atMost(20, TimeUnit.SECONDS).untilAsserted(() ->
            assertThat(reload(orderId).getStatus()).isEqualTo(CompensationStatus.RESOLVED));
}

refunded(...) 는 필드를 Map 에 담고 envelope(eventId 와 payload 등을 담는 서비스 공통 메시지 형식)으로 감싸 문자열을 만드는 테스트 helper 입니다. 이 테스트가 증명하는 것은 “이런 구조의 메시지가 오면 Order 가 보상 원장을 처리한다”까지입니다. Payment 가 실제로 이 구조의 메시지를 발행하는지는 Payment 쪽 테스트가 따로 보고 두 테스트가 같은 구조를 가정하는지는 어느 쪽도 맞춰 보지 않습니다.

네 서비스를 한꺼번에 띄울 환경도 없었습니다. docker-compose.yml 에는 MySQL · Redis · Kafka 세 개만 있었고 컨테이너 이름과 호스트 포트도 고정돼 있었습니다. /actuator/health 를 기다리는 smoke 스크립트도 한 번에 한 서비스만 띄웠습니다. 여러 서비스를 동시에 띄워 하나의 saga 를 검증하는 하네스가 빠져 있었습니다.

saga 작업의 완료 조건 중 하나는 결제 실패에서 주문 취소와 재고 복구로 이어지는 Saga 보상 트랜잭션 흐름의 검증이었습니다. 이 하네스가 없으면 작업을 끝낼 수 없었습니다. 이 글에서는 작업을 나누고 E2E 계획을 고치기 전의 구상을 기존 계획이라고 부릅니다. 기존 계획은 이 검증을 이벤트 계약 수준의 검증으로 대신할 수 있다고 봤습니다. 하지만 메시지 DTO 나 consumer 단위의 계약 테스트로는 Kafka 전달 · Outbox · DB 트랜잭션 · 재시도가 함께 맞물려 도는지를 증명할 수 없어서 계획을 고치면서 계약 테스트로 대신하는 안을 뺐습니다.

검증 도구는 통과했는데 실제로 확인해야 할 것은 확인하지 못한 일(false-green)도 이 프로젝트에서 여러 번 겪었습니다. saga 작업에서는 “스케줄러가 존재한다”, “메트릭이 노출된다”, “listener 가 연결됐다” 같은 확인을 검증 완료로 치지 않기로 했습니다. 실패 경로를 실제로 주입하고 종료 상태에 도달했는지를 DB 상태로 조회하고 음성 대조까지 해야 검증으로 봤습니다.

이 규칙을 E2E 에도 적용하려면 이 질문에 먼저 답해야 합니다. 이벤트가 서비스 사이를 비동기로 오가는 체인에서 E2E 는 어디서 잘못 통과할 수 있고 그것을 어떻게 알아챌 수 있을까요?

결론부터 적습니다.

단계잘못 통과한 이유바꾼 방법
시작테스트가 첫 이벤트를 직접 넣어 운영 경로 일부를 건너뜀이미 있는 운영 HTTP 진입점으로 시작
대기대기 조건이 이 시나리오의 이벤트를 특정하지 않아 다른 행으로도 채워짐모든 대기를 이 실행이 만든 식별자에 묶음
판정실패했다는 사실만 보고 이유는 보지 않음음성 대조군은 그 앞 단계가 실제로 일어난 채 기대한 지점에서 실패할 때만 통과
검증 도구하네스의 준비 루프와 대조군 헬퍼도 같은 이유로 잘못 통과도구의 출력도 개수와 실패 이유로 대조

네 번째 행은 이 글 후반의 주제입니다.

어떻게 접근할 수 있을까요?

가장 자연스러운 첫 방법은 서비스별 통합테스트를 넓히는 것입니다. 네 서비스와 MySQL · Kafka 를 compose 로 함께 띄우고 saga 를 시작한 뒤, 각 서비스 DB 가 종료 상태에 도달할 때까지 기다렸다가 조회합니다. 테스트는 Gradle 멀티모듈 구성을 바꾸지 않으려고 Gradle 모듈 대신 셸 스크립트(scripts/saga-e2e-smoke.sh)로 두었습니다.

시나리오는 네 개이고 한 번 띄운 스택에서 차례로 돌립니다. 이 글에서는 아래 이름으로 부릅니다.

이름시나리오
A결제 실패 후 주문 취소 · 재고 복구 · 실패 알림
B재고 예약 실패 후 주문 취소
C이미 결제된 주문의 환불
D깨진 메시지의 DLQ 적재

비동기 체인을 검증하는 E2E 는 세 단계로 나눠 볼 수 있습니다. 첫 이벤트를 넣는 시작, 결과를 기다리는 대기, 통과 여부를 정하는 판정입니다. 이 구분은 과정을 정리하려고 나눈 것이고 기존 계획이 이렇게 나뉘어 있지는 않았습니다.

시작: 첫 이벤트를 어떻게 넣을까요?

choreography saga 에는 첫 이벤트를 보내 줄 orchestrator 가 없습니다. 어떤 서비스든 이벤트를 발행해야 체인이 시작됩니다. 기존 계획은 서비스별 통합테스트와 같은 방식을 택했습니다. 시나리오에 필요한 행을 DB 에 미리 넣고 시작 이벤트를 Kafka 에 직접 발행합니다. A 라면 테스트가 payment.failed 를 토픽에 직접 보냅니다. 외부 PG(Toss)는 테스트 중에 호출하면 안 되니 환불을 실행하는 스케줄러를 끄는 설정을 새로 두려 했습니다.

대기: 무엇을 기다릴까요?

비동기 체인에서는 “지금 이 값이다”라고 단언할 수 없으니 “제한 시간 안에 이 값이 된다”고 단언해야 합니다. E2E 의 단언은 대부분 대기 형태가 됩니다.

대기는 두 곳에 있습니다. 시작 전에는 스택이 준비됐는지 기다립니다. 기존 계획은 각 앱의 /actuator/health 가 200 을 돌려주면 준비된 것으로 봤습니다. 시작 후에는 시나리오마다 적어 둔 확인 쿼리가 기댓값을 돌려줄 때까지 반복해 조회합니다. A 라면 주문 상태, 예약 상태, 재고 수량, outbox 상태, 소비 기록 같은 것들입니다. 소비 기록은 processed_events 테이블로, 소비자가 처리한 이벤트를 (event_id, consumer_group) 쌍으로 남겨 같은 이벤트를 두 번 처리하지 않게 합니다.

판정: 무엇을 보고 통과라고 할까요?

제한 시간 안에 모든 쿼리가 기댓값에 도달하면 통과, 아니면 실패입니다.

여기에 E2E 가 앞으로도 계속 돌도록 강제하는 장치를 하나 더 두었습니다. “코드 경로 × 주입 장애 × 기대 종료 상태”를 기계가 읽을 수 있는 표(매트릭스)로 두고 표에 있는 경로에 테스트가 없거나 그 테스트가 실행되지 않으면 CI 를 실패시킵니다.

음성 대조도 계획에 있었습니다. product-service 를 멈추거나 재고를 충분히 넣으면 시나리오가 실패해야 한다는 검사입니다. 이 검사는 작업할 때 한 번 돌려 결과를 남기는 용도였습니다. CI 에서 매번 도는 검사는 정상 시나리오와 매트릭스뿐이었습니다.

서비스별 테스트를 그대로 넓힌 구조라 무리가 없어 보이지만 계획을 코드와 맞춰 보고 실제로 돌려 보자 세 단계 모두에서 잘못 통과할 수 있는 문제가 나왔습니다.

해 보려니 이런 문제가 있었습니다

계획의 전제가 코드와 달랐습니다

먼저 계획의 전제를 코드와 대조해 보니 다섯 가지가 맞지 않았습니다.

계획의 전제실제 코드바꾼 것
재고 수량은 inventories.quantity 에 있다컬럼 이름은 stock 이다. 환불 상태도 refund_status 하나가 없고 refund_result · refund_resolved_at · refund_failure_code 로 나뉜다확인 쿼리를 마이그레이션과 다시 대조했다
/actuator/health 가 200 이면 시나리오를 시작해도 된다health 는 토픽이 만들어졌는지, consumer 가 파티션을 할당받았는지 알려 주지 않는다준비 조건에 “consumer group 마다 활성 멤버 1 이상, 할당 파티션 1 이상”을 더했다
outbox 테이블에 행을 넣으면 발행 코드의 직렬화까지 검증된다poller 는 outbox 행의 payload 문자열을 그대로 Kafka 에 싣는다. 직렬화는 그 앞의 publisher 가 한다시작 방식을 다시 바꿨다
payment.failed 가 처리되면 네 서비스 모두에 소비 기록이 남는다소비자는 Order · Product · Notification 세 곳이다Payment 쪽 기대값은 outbox PUBLISHED 로 바꿨다
환불 스케줄러 하나를 끄면 테스트가 PG 를 호출하지 않는다Toss 주소는 코드에 하드코딩돼 있었고, 환불 조회 스케줄러도 같은 실행기로 Toss 를 호출한다PG stub 을 두고 주소를 설정으로 뺐다

이 다섯 가지는 코드를 열어 보면 알 수 있었습니다. 실제 스택에서 처음 돌렸을 때는 실제 입력과 기동 순서가 합쳐져야 나타나는 결함이 11건 더 나왔습니다. 운영 코드 결함은 0건이었고 11건 모두 테스트 하네스 쪽이었습니다.

  • 세 단계와 관련된 5건은 아래에서 다룹니다: envelope 필드 이름, DLQ 원장의 origin_topic 값(.dlq 가 붙지 않은 원본 토픽), 가격 캐시 대기, 결제 실패 응답 코드, PG 주소 설정
  • 나머지 6건은 seed SQL 의 컬럼 이름(2건), 장바구니 응답 코드, 재고 기준값을 읽는 시점, health 폴링의 예외 처리, 종료 명령의 인자처럼 하네스 자체의 단순한 결함이었습니다

시작: 우회하는 구간만 바뀌었습니다

테스트가 payment.failed 를 Kafka 에 직접 보내면 Payment 가 결제를 실패로 바꾸고 그 사실을 outbox 에 남기고 poller 가 발행하는 경로를 통째로 건너뜁니다. “네 서비스가 각자의 DB 와 Kafka 를 실제로 거친다”는 목표를 시작부터 지키지 못합니다.

시작 지점을 outbox 로 옮겨 소유 서비스의 outbox 테이블에 행을 넣기로 했습니다. 이러면 poller 는 거칩니다. 하지만 poller 가 하는 일은 이것뿐입니다.

private ProducerRecord<String, String> buildRecord(OutboxEvent event) {
    ProducerRecord<String, String> record = new ProducerRecord<>(
            event.getEventType(), null, event.getAggregateId(), event.getPayload());
    // ... trace 헤더
    return record;
}

event.getPayload() 는 이미 직렬화된 문자열입니다. envelope 을 만들고 ObjectMapper.writeValueAsString 으로 직렬화하는 일은 publisher 가 outbox 에 저장하기 전에 합니다. SQL 로 outbox 에 행을 넣으면 그 직렬화를 테스트가 손으로 대신합니다. 도입부의 refunded(...) helper 처럼 테스트가 메시지를 직접 조립하게 되고, 넣는 곳만 Kafka 에서 outbox 로 바뀝니다.

다음으로 앱 안에서 실제 publisher 를 호출하는 seed runner 를 두기로 했는데 이 runner 는 운영 이미지에 들어갑니다. @ConditionalOnProperty 로 켜지는 runner 는 인증과 업무 API 를 거치지 않고 saga 이벤트를 발행하는 스위치가 됩니다. 계획을 고쳐 이 코드를 운영 bootJar 에 넣지 않는 E2E 전용 source set 으로 옮기기로 했습니다.

구현을 시작하자 이 안도 쓸 수 없었습니다. E2E 컨테이너는 운영 bootJar 를 실행합니다. bootJar 에 없는 코드는 컨테이너 안에서 실행되지 않습니다. 운영 코드에 넣으면 보안 문제가 다시 생기고 빼면 실행되지 않습니다. seed runner 는 계획에만 있었고 한 번도 커밋되지 않았습니다.

첫 이벤트는 어디에서 들어올까요?

시작 방식을 고르면 같은 코드 구간 위에서 입력이 이동합니다. 흐린 구간은 테스트가 대신한 곳입니다.

Payment의 결제 실패 발행 경로만 단순화했습니다. 입력 패킷은 설명용이며 실제 로그가 아닙니다. seed runner는 구현되지 않은 계획입니다. HTTP는 기존 DUAL_ACCEPT 모드입니다. gateway 서명·SIGNED_ONLY와 환불 시나리오의 직접 발행 예외는 이 그림 밖입니다.

결제 실패도 실제 경로로 만들어야 했습니다

A 에서는 실패를 어떻게 만들지도 정해야 합니다. 결제 승인 API 가 200 이 아닌 응답을 돌려주는 경우는 두 가지입니다.

  • 승인 전 검사에서 거부되는 경우: 재고 예약이 아직 확정되지 않았으면 ensureConfirmable 이 PAY-008(409)을 던집니다. Toss 를 호출하지 않고 결제 상태도 바뀌지 않으며 payment.failed 도 발행되지 않습니다.
  • PG 가 실패하는 경우: 서비스가 결제를 FAILED 로 바꾸고 payment.failed 를 outbox 에 남긴 뒤, PAY-005(400)를 돌려줍니다.

처음 작성한 시나리오는 응답이 200 이 아닌지만 봤습니다. 그러면 예약이 확정되기 전에 승인을 호출해 PAY-008 을 받아도 “결제 실패를 만들었다”고 넘어갑니다. 코드 흐름대로라면 그 뒤의 대기는 오지 않을 payment.failed 를 기다리다 시간 초과로 끝나고 saga 가 실패를 처리하지 못한 것처럼 보입니다.

고친 시나리오는 결제 행이 승인 가능 상태(ready_for_payment = 1 AND status = 'PENDING')가 될 때까지 기다린 뒤 승인을 호출하고 응답 코드가 정확히 PAY-005 인지 단언합니다.

대기: 실제로는 기다리지 않은 대기

대기 단계의 문제는 원인이 모두 같았습니다. 대기 조건이 이 시나리오의 이벤트를 특정하지 않고 “그런 종류의 행”이 있는지만 봐서 다른 행이 조건을 채웠습니다. 시나리오 네 개가 한 스택을 공유하므로 대부분은 앞 시나리오가 남긴 행이었고, 알림처럼 같은 시나리오의 다른 행인 경우도 있었습니다.

가격 캐시 대기. 주문을 넣기 전에는 Order 가 상품 가격을 캐시했는지 기다려야 합니다. 그렇지 않으면 주문이 캐시 미스로 실패하고 그 실패가 saga 실패로 보입니다.

# 처음
"SELECT 1 AS ok FROM processed_events "
"WHERE consumer_group = 'order-svc-product-updated-group' LIMIT 1"

# 고친 뒤
"SELECT product_id FROM product_price_cache WHERE product_id = %s", (product_id,)

처음 조건은 “가격 갱신 이벤트를 소비한 기록이 하나라도 있는가”입니다. 첫 시나리오에서는 제대로 기다리지만 두 번째 시나리오부터는 앞 시나리오의 상품이 남긴 행 때문에 바로 통과합니다.

알림 대기. 결제 실패 알림을 기다리는 조건이 사용자 ID(A 는 100)만 봤습니다. 같은 시나리오에서 주문 생성 이벤트를 소비하며 생긴 ORDER_CREATED 알림 행이 이미 그 조건을 채우고 있었습니다. 알림 종류(type = 'PAYMENT_FAILED')까지 조건에 넣은 뒤에야 제대로 기다렸습니다.

소비 기록. 기존 계획은 processed_events 에서 event_id 만 조회해 어느 소비자가 처리했는지 알 수 없었습니다. 구현은 반대로 consumer_group 만 조회했고 이번에는 앞 시나리오의 행이 조건을 채웠습니다. 세 소비자가 아무 일도 하지 않아도 통과하는 상태였습니다.

DLQ 적재. D 는 origin_topic = 'payment.failed' 로만 행을 찾았습니다. A 가 같은 토픽에 실제 이벤트를 흘리므로 앞 시나리오의 소비가 실패했다면 그 행도 같은 조건을 채웁니다.

판정: 실패의 이유를 보지 않았습니다

판정 단계에서는 반대 방향의 오류도 나왔습니다. 시나리오가 outbox 의 order.cancelled 에서 취소 사유를 읽을 때, 처음 코드는 envelope 의 data 필드를 찾고 없으면 아무 경고 없이 바깥 객체를 썼습니다(.get("data", payload)). 실제 필드 이름은 payload 였습니다. 사유가 None 으로 나와 서비스가 계약을 어긴 것처럼 보였지만 실제로는 시나리오의 파서가 틀렸습니다. 파서는 키가 없으면 바로 실패하도록 고쳤습니다.

음성 대조도 실패했다는 사실만으로는 아무것도 증명하지 못했습니다. 기존 계획에는 “PG stub 을 내리면 환불 실행이 실패한다”로 외부 호출 차단을 확인하는 항목이 있었습니다. 이 검사는 네트워크 격리 설정(internal: true)을 지운 상태에서도 똑같이 실패합니다. stub 이 없으면 격리 여부와 상관없이 연결은 실패합니다. 이 검사로는 “환불 실행기가 stub 주소를 쓴다”는 것만 알 수 있었습니다.

해결하지 못한 지연이 하나 남았습니다

위 문제들을 고친 뒤 시나리오 네 개는 각각 통과했습니다. 네 개를 연속으로 돌리면 뒤쪽 시나리오가 시간 초과로 실패하는 일은 반복됐습니다. 브로커 하나에 consumer group 28개(listener 마다 group 을 따로 둡니다)가 붙은 상태에서 order.created · product.updated 소비가 수십 초에서 수 분까지 늦어졌습니다.

세 가지 완화책을 넣었습니다.

  • 3파티션 × 20토픽 × 28 group 의 메타데이터 부하를 의심해 자동 생성 토픽을 1파티션으로 고정했습니다.
  • 앱 네 개를 순차 기동으로 바꿨습니다. 동시에 띄우면 order-service 가 healthcheck 제한 시간 360초를 넘겼습니다.
  • 시나리오마다 상한 180초를 뒀습니다.

증상은 그대로였습니다. 다시 돌렸을 때도 C 다음의 D 가 시간 초과로 실패했습니다. D 만 단독으로 돌리면 통과했습니다. 하네스 작업은 이 증상을 해결하지 못한 채 마무리했고 지연은 이어진 작업에서 다시 봤습니다. 그 작업은 시나리오 격리와 실행 시간 예산을 다뤘습니다. 첫 번째 완화책이 틀린 전제에서 나왔다는 것은 뒤에서 다룹니다.

각 단계에서, 통과가 정말 이번 실행이 운영 경로를 지나간 결과라는 것을 어떻게 보장할 수 있을까요?

좀 더 적절한 해결책은 무엇일까요?

세 단계 모두 이번 실행이 운영 경로를 지나가지 않아도 통과할 수 있었습니다. 그래서 그 경로를 실제로 지나가지 않으면 통과할 수 없게 고쳤습니다.

시작: 운영 진입점을 그대로 씁니다

테스트용 진입점을 새로 만들지 않고 이미 있는 진입점을 쓰는 방법이 남아 있었습니다. 서비스들은 원래 gateway 가 서명한 내부 토큰(23편)만 신원으로 받습니다. gateway 를 도입하면서 둔 전환 기간 때문에 평문 X-User-Id · X-User-Role 같은 헤더도 함께 받는 DUAL_ACCEPT 모드가 운영 코드에 남아 있었습니다. E2E 는 네 서비스를 이 모드로 띄우고 테스트 runner 가 평문 헤더로 실제 컨트롤러를 호출합니다.

A 의 시작은 이렇게 바뀌었습니다.

  1. ADMIN 역할로 POST /api/v1/admin/products 를 호출해 상품을 만듭니다. Product 가 product.updated 를 발행합니다.
  2. Order 가 그 상품의 가격을 캐시할 때까지 기다립니다.
  3. 장바구니에 담고(POST /api/v1/cart/items, 201) 주문합니다(POST /api/v1/orders). Order 가 order.created 를 발행하고 Product 가 재고를 예약합니다.
  4. 결제가 승인 가능 상태가 되면 POST /api/v1/payments/confirm 을 호출합니다.

새 진입점도, 새 모듈도, 새 스위치도 없습니다. 그러면서 컨트롤러, 도메인 상태 변경, publisher 직렬화, outbox, poller 를 차례로 모두 거칩니다. 포기한 것도 있습니다. 평문 헤더를 써서 서명 토큰만 받는 SIGNED_ONLY 모드는 이 E2E 로 검증하지 않고 user-service 와 gateway 도 스택에 넣지 않았습니다.

이 선택에도 위험은 있습니다. seed runner 를 기각한 이유는 인증과 업무 API 를 건너뛰는 스위치가 운영 이미지에 생긴다는 것이었습니다. DUAL_ACCEPT 도 평문 헤더만으로 신원을 믿으므로 인증 쪽에서는 비슷한 약점이 있습니다. 차이는 두 가지입니다. 이 모드는 gateway 전환 기간에 이미 있던 것이라 E2E 가 새로 더한 스위치는 없고, 요청은 업무 API 와 도메인 로직을 그대로 지납니다. 그 대가로 E2E 는 전환용 모드에 기대게 됐습니다. 전환이 끝나 이 모드를 지우면 E2E 에도 서명 토큰을 만드는 경로가 필요합니다.

같은 경로를 HTTP에서 시작하면

시작 방식을 고르면 같은 코드 구간 위에서 입력이 이동합니다. 흐린 구간은 테스트가 대신한 곳입니다.

Payment의 결제 실패 발행 경로만 단순화했습니다. 입력 패킷은 설명용이며 실제 로그가 아닙니다. seed runner는 구현되지 않은 계획입니다. HTTP는 기존 DUAL_ACCEPT 모드입니다. gateway 서명·SIGNED_ONLY와 환불 시나리오의 직접 발행 예외는 이 그림 밖입니다.

결제 실패는 stub 스크립트로 만들었습니다. PG stub 은 paymentKey 의 접두사로 응답을 정합니다. e2e-ok- 로 시작하는 키는 승인 성공, e2e-cfail- 로 시작하는 키는 승인 500 입니다. stub 이 500 을 돌려주면 결제 서비스의 catch 블록이 실제로 결제를 FAILED 로 바꾸고 payment.failed 를 남깁니다. A 의 payment.failed 는 운영 코드가 직접 발행한 이벤트입니다.

스크립트에 없는 키에는 500 STUB_UNSCRIPTED_KEY 를 돌려줍니다. 승인 · 취소 · 조회 모두 같은 규칙입니다. 기본 응답을 성공으로 두면 키를 잘못 적은 시나리오도 성공해 버립니다.

외부 PG 호출은 네트워크에서 막았습니다. 환불 흐름 자체가 C 의 검증 대상이라 스케줄러를 끌 수 없었습니다. 대신 Toss 주소를 설정(toss.payments.base-url)으로 빼서 stub 을 가리키게 했습니다. 앱 · 인프라 · stub 은 외부와 연결되지 않는 compose 네트워크(internal: true) 하나에만 붙였습니다. 시작 진입점에는 운영 코드를 더하지 않았지만 이 주소 설정화는 운영 코드 수정이었습니다.

이 격리가 실제로 외부 호출을 막은 일도 있었습니다. 처음에는 주소를 TOSS_BASE_URL 환경변수로 넣었습니다. 이 변수는 기본 설정 파일의 placeholder 만 채우므로 로컬 프로파일 파일에 적힌 실제 주소보다 우선순위가 낮았습니다. 환불 실행기는 실제로 api.tosspayments.com 을 호출했다가 internal: true 네트워크에서 UnknownHostException 으로 실패했습니다. 주소는 프로퍼티 이름을 직접 덮어쓰는 TOSS_PAYMENTS_BASE_URL: http://pg-stub:8080/v1 로 바꿨습니다.

계획에 있던 스케줄러 끄기 설정은 만들지 않았습니다. 남겨 두면 환경변수 하나로 운영의 환불 스케줄러가 꺼질 수 있습니다. 그러면 health 는 정상인데 REQUESTED · CLAIMED · UNRESOLVED 상태의 환불이 끝나지 않고 쌓입니다.

C 는 예외로 남았습니다. 환불이 필요한 상황은 “이미 취소된 주문에 결제 완료가 도착하는” race condition 입니다. 승인 API 는 PENDING 이 아닌 결제를 거부하므로 이 race 를 HTTP 로 매번 같게 재현할 수 없었습니다. stub 이 승인 응답을 늦추는 동안 취소 API 를 부르는 식으로 race 를 만드는 방법은 따로 검토하지 않았습니다.

C 는 APPROVED 상태의 결제 행을 DB 에 넣고 환불 요청 토픽 두 개(stock.compensation.requested · order.compensation.requested)에 메시지를 직접 발행해 시작합니다. 이 E2E 가 보는 구간은 요청 토픽에서 payment.refunded outbox 가 PUBLISHED 가 될 때까지입니다. 그 사이에 중복 처리를 막는 fence, dispatcher, PG stub 을 지납니다. 감지와 요청 발행이 한 트랜잭션이라는 계약은 서비스별 통합테스트가 맡습니다.

대기: 이 실행의 이벤트에 묶습니다

대기와 단언의 조건에는 반드시 이 실행이 만든 식별자를 넣습니다.

대기묶는 식별자
가격 캐시이 시나리오가 만든 product_id
소비 기록이 시나리오의 outbox 에서 실제로 발행된 envelope 의 eventId + 확인하려는 consumer_group
알림시나리오마다 다른 사용자 ID(A 는 100, B 는 200) + 알림 종류(PAYMENT_FAILED 등)
DLQ 적재깨진 메시지 본문에 넣은 시나리오별 marker
DB 에 직접 넣는 주문 번호900000 + CRC32("sid|RUN_ID") % 90000(sid 는 시나리오 ID)으로 별도 번호 대역에 둠. 이 식만으로 모든 실행 사이의 충돌을 막지는 못함

실행 ID(RUN_ID)는 하네스가 실행마다 받는 값입니다. 상품 이름(e2e-<시나리오>-<RUN_ID>), DLQ marker, DB 에 직접 넣는 주문 번호에 들어가고, 이렇게 만든 상품 ID · 주문 ID · outbox 의 eventId 를 다시 대기 조건에 씁니다. 사용자 ID 는 시나리오마다 고정입니다. 대신 readiness 검사가 DB 에 남은 run marker 와 지금의 RUN_ID 를 대조해, 이전 실행의 데이터가 남은 볼륨이면 시나리오를 시작하지 않습니다. 그래서 다른 실행의 알림과 섞이지 않습니다.

시작 전 대기도 같은 방향으로 고쳤습니다. health 200 에 더해 시나리오가 쓰는 consumer group 마다 활성 멤버가 하나 이상이고 할당 파티션도 하나 이상인지 봅니다.

배경 스케줄러가 앞 시나리오의 행을 건드리지 않는다는 단언도 검토했지만 쓰지 않았습니다. PG 응답이 끝내 확정되지 않은 환불(UNRESOLVED)은 조회 스케줄러가 주기적으로 다시 처리합니다. 다음 시나리오가 도는 동안 그 행의 값이 바뀌는 것은 정상 동작입니다. 이것을 간섭으로 세면 운영 코드가 옳게 도는데도 E2E 가 실패합니다.

대신 끝나지 않는 잔여 데이터를 남기는 시나리오는 실행 순서의 맨 뒤에 두기로 했습니다. SCENARIO_ORDER = ["a", "b", "c", "d"] 의 끝에 LINGERING_SCENARIOS 가 놓였는지 스택을 띄우기 전에 검사합니다. 그런 시나리오(환불 시간 초과 분기)는 아직 없어서 지금 LINGERING_SCENARIOS 는 빈 목록이고, 이 검사가 확인하는 것도 지금은 없습니다. 그 시나리오가 생길 때를 대비해 둔 자리입니다.

판정: 실패해야 할 때, 그 이유로 실패하게 합니다

먼저 완료 기준을 바꿨습니다. 이 프로젝트가 반복해 겪은 false-green 하나는 시나리오를 실제로 한 번도 돌리지 않은 채 “E2E 를 세웠다”고 완료 처리하는 것이었습니다. 하네스 작업의 완료 조건에는 실제 스택 실행 결과를 넣었습니다. 앞에서 본 결함 11건은 이 조건 덕분에 드러났습니다.

그다음으로 음성 대조군(negative control)을 두었습니다. 결함을 일부러 넣은 스택에서 같은 검사를 돌려 그 검사가 실패하는지 봅니다. 정상 스택에서 통과한 시나리오만으로는 “saga 가 돈다”와 “단언이 아무것도 확인하지 않는다”를 구별할 수 없습니다. 한 번만 돌려서는 나중에 누군가 대기나 단언을 지워도 알 수 없어서 대조군을 CI 의 E2E 잡에서 매번 돌리게 했습니다. 아래 일곱 검사를 함께 돌립니다. 다섯은 결함을 넣고 실패를 기대하는 음성 대조군이고, 두 벌 기동과 canary 비교 두 개는 성공해야 하는 비교 검사입니다. canary 는 외부 연결이 되는지 보려고 둔 표적 컨테이너(egress-canary)입니다.

대조군넣는 결함기대 결과
poller 정지Order 의 outbox poller 정지A 실패
product 정지product-service 정지A 실패
재고 충분재고를 충분히 넣음B 실패
listener 부재업무 listener 하나 없음readiness 검사의 consumer group 확인 실패
두 벌 기동같은 compose 를 project 이름만 바꿔 두 벌 기동성공(이름 · 포트 충돌 없음)
canary 비교외부 네트워크의 비교용 컨테이너에서 canary 호출성공(연결됨)
canary 격리 (스크립트의 ⑥-음성)격리 네트워크의 payment-service 에서 canary 호출연결 실패

poller 정지 대조군이 특히 중요했습니다. 첫 이벤트가 실제 poller 를 거치는지 검출하는 대조군은 이것 하나뿐이었습니다. 기존 계획에서는 한 번만 돌리는 목록에 있었는데 CI 목록으로 옮겼습니다. 이 대조군은 뒤에서 보듯 다른 대조군을 망가뜨리는 문제도 안고 있었습니다. poller 를 멈출 때도 운영 코드에 스위치를 두지 않았습니다. poller 는 여러 인스턴스가 동시에 돌지 않도록 ShedLock(스케줄러 작업의 중복 실행을 막는 DB 락 라이브러리)을 씁니다. 대조군은 ShedLock 테이블 peekcart_order.shedlock 의 orderOutboxPollingJob 행을 lock_until = NOW() + INTERVAL 1 HOUR 로 INSERT/UPDATE 해서 락을 미리 차지합니다.

canary 는 양쪽에서 호출합니다. 격리된 앱에서 canary 로 연결되지 않는 것만 보면 canary 가 죽어 있어도 같은 결과가 나옵니다. 외부 네트워크의 비교용 컨테이너에서는 같은 canary 에 반드시 연결돼야 한다는 검사를 짝으로 뒀습니다. 격리 설정 자체는 compose 파일을 읽는 정적 검사로도 점검합니다. 모든 앱이 internal: true 네트워크에만 붙어 있어야 통과합니다.

실패 이유도 일부 대조군에서 확인했습니다. 다른 이유로 실패한 대조군은 그 결함을 잡았다는 증거가 되지 못합니다. listener 부재 대조군은 실패 이유가 consumer group 확인이어야 통과로 치고 재고 충분 대조군은 “주문 취소 대기의 시간 초과”만 통과로 칩니다. 실패 지점을 맞히는 것만으로 부족했던 사례는 뒤에서 다룹니다.

경로 목록도 같은 원리로 다뤘습니다. 경로 매트릭스가 “무엇을 검증해야 하는가”를 정하는 유일한 입력이면 행 하나를 지워도 위반으로 나오지 않고 검사 대상만 줄어듭니다. 반드시 있어야 하는 경로 ID 목록은 lint 코드 안에 따로 두고 매트릭스와 정확히 대조했습니다. 이 검사도 테스트가 있고 실행됐는지만 볼 뿐, 테스트 내용이 옳은지는 알 수 없습니다.

문제와 해결 방법

단계드러난 문제해결
시작Kafka 직접 발행 · outbox INSERT · seed runner 가 각각 다른 구간을 우회DUAL_ACCEPT 로 실제 HTTP 진입점 호출(새 진입점 0). C 는 요청 토픽부터 시작하는 예외로 명시
시작사전 검사 거부(PAY-008)를 결제 실패로 처리승인 가능 상태를 기다린 뒤 호출하고 PAY-005 를 단언. 실패는 stub 스크립트로 실제 catch 경로를 타게 함
시작테스트가 실제 PG 를 호출할 수 있음Toss 주소 설정화 + stub + internal: true. 스케줄러 끄기 설정은 만들지 않음
대기대기 조건이 같은 종류의 행을 찾아 다른 행으로 통과모든 대기 · 단언을 이 실행의 식별자에 묶음
대기health 200 을 준비 완료로 봄consumer group 마다 활성 멤버 · 할당 파티션 확인
대기배경 스케줄러가 공유 스택의 값을 바꿈”간섭 0” 단언은 쓰지 않음. 잔여 데이터를 남기는 시나리오를 맨 뒤에 두는 순서 검사
판정파서 fallback 때문에 파서 오류가 계약 위반처럼 보임키가 없으면 바로 실패
판정”stub 을 내리면 실패”가 격리 없이도 실패정적 격리 검사 + canary 음성 · 비교 검사
판정음성 대조를 한 번만 돌림대조 검사 7종(음성 5 · 비교 2)을 CI 에서 매번 실행

검증 도구도 잘못 통과했습니다

하네스를 만든 뒤에는 앞의 원칙을 CI 에서 실행되는 장치로 옮겼습니다. 그런데 이 장치들도 앞에서 본 것과 같은 이유로 잘못 통과했습니다.

대조군 판정이 다시 틀린 세 가지

도구가 없어서 난 실패를 격리의 증거로 읽었습니다. 외부 호출 차단을 확인하는 probe 는 처음에 격리된 payment-service 컨테이너 안에서 python3 로 연결을 시도했습니다. 연결은 실패했고 대조군은 통과했지만 payment-service 이미지에는 python3 가 없었습니다. 연결은 명령이 없어서 실패했고 네트워크 차단과는 상관이 없었습니다.

이 문제는 짝으로 둔 비교 검사에서 드러났습니다. 외부 네트워크의 비교용 컨테이너에서도 표적에 연결되지 않았습니다. 더 살펴보니 컨테이너 안에서 호스트 주소를 구하는 방법이 빈 값을 돌려줬습니다. Docker Desktop 에서는 브리지 게이트웨이가 VM 안의 주소라 격리되지 않은 컨테이너도 호스트 프로세스에 연결하지 못했습니다. 표적은 컨테이너(egress-canary)로 옮기고 결과를 종료 코드로 구분했습니다.

# 0 = 도달   7 = 연결 실패   28 = 타임아웃   127 = 명령 없음
neg_rc="$(probe_rc payment-service)"
if [[ "$neg_rc" == "7" || "$neg_rc" == "28" ]]; then
  echo "  ok   [⑥-음성 internal 전용 payment-service → 연결 실패(rc=${neg_rc})]"
else
  echo "  FAIL [⑥-음성 internal 전용 payment-service] curl rc=${neg_rc:-?} — 0 이면 격리가 깨졌고, 127 이면 도구가 없어 대조가 무의미하다"
  # 이후 실패 처리 생략
fi

0 이 아닌 종료 코드를 모두 통과로 처리하면 127 도 다시 통과합니다.

다른 검사가 먼저 실패한 것을 이 검사의 성공으로 읽었습니다. listener 부재 대조군은 앱 하나를 멈춘 뒤 readiness 검사가 실패하는지 봅니다. readiness 검사는 health 를 먼저 보고 consumer group 을 나중에 봅니다. 앱을 멈추면 health 에서 먼저 실패하니 확인하려던 group 검사는 실행되지도 않았습니다. 게다가 모든 예외를 성공으로 처리해서 Kafka 관리 도구나 DB 조회의 오류도 “감지 성공”이 됐습니다. 고친 뒤에는 skip_health=True 로 health 검사를 건너뛰고 "consumer group" in str(e) 로 실패 사유를 확인할 때만 통과합니다.

대조군의 실패 기록이 정상 실행의 기록을 덮어썼습니다. CI 는 정상 시나리오와 음성 대조군을 같은 실행 ID, 같은 출력 디렉터리로 돌렸습니다. 대조군은 A 를 일부러 실패시키므로 그 실패 기록이 앞서 만든 성공 기록을 덮어썼고 CI 가 “A 실패”로 떨어졌습니다. 로컬에서는 두 실행을 서로 다른 실행 ID 로 돌려서 재현되지 않았습니다. 대조군 결과는 하위 디렉터리(negative-control/)로 분리하고 CI 에서도 별도 실행 ID 를 줬습니다.

준비 단계의 루프가 맞는 가설을 가렸습니다

남아 있던 연속 실행 지연은 다음 작업에서 다시 봤습니다. 앞 작업에서는 D 가 시간 초과로 실패했지만 구간별 소요 시간을 남기자 B 하나만 눈에 띄게 느렸습니다. B 를 단독으로 돌리면 13초였는데 A 다음에 돌리면 180~220초였습니다(구간별 시간을 처음 남긴 측정). D 의 시간 초과와 B 의 지연이 어떻게 이어지는지는 따로 확인하지 않았습니다.

가설 세 개를 차례로 시험했습니다.

  1. 로컬 프로파일의 SQL 출력과 DEBUG 로깅이 소비를 늦춘다. 끄자 기동과 A 는 빨라졌지만 B 는 그대로였습니다.
  2. 토픽 파티션을 늦게 발견한다. consumer 가 토픽보다 먼저 구독하면 일부 파티션을 한동안 보지 못한다는 가설입니다. 앱을 띄우기 전에 토픽을 선언된 파티션 수로 미리 만드는 단계를 넣었지만 B 는 여전히 189초였습니다.
  3. 이벤트가 유실된다(운영 코드 버그). processed_events 에 해당 행 두 개가 남아 있었으므로 유실은 없었고 늦게 처리됐을 뿐이었습니다.

셋 다 원인이 아닌 것처럼 보였지만 둘째 가설을 시험할 때 이미 단서가 나와 있었습니다. 사전 생성 단계가 생성에 성공한 토픽을 1개로 출력했습니다. 그때 출력 원문은 남아 있지 않아 숫자만 지금 출력 형식으로 옮겨 적습니다.

토픽 20종 — 생성 성공 1 · 대조 실패 0

토픽 20개 중 1개만 만들어졌습니다. 사전 생성 스크립트의 루프 때문이었습니다. while read 루프는 표준 입력에서 한 줄씩 토픽을 읽습니다. 루프 안의 docker compose exec 도 표준 입력을 읽습니다. -T 는 TTY 할당만 끄므로 첫 반복의 exec 가 남은 토픽 목록을 모두 읽어 버렸고 루프는 한 번 돌고 끝났습니다. 아래는 고친 코드이고, 셋째 줄의 </dev/null 이 고친 부분입니다.

while IFS=$'\t' read -r topic parts; do
  dc exec -T kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:29092 \
       --create --if-not-exists --topic "$topic" --partitions "$parts" --replication-factor 1 \
       </dev/null >/dev/null 2>&1
  # ...
done < "$list"

재현 조건 Docker Compose v5.1.0, 포트를 열지 않은 alpine:3.20 컨테이너, 다섯 줄짜리 목록. exec 가 없는 루프는 다섯 번, docker compose exec -T box true 를 넣으면 한 번, 여기에 </dev/null 을 붙이면 다섯 번 돌았습니다. Kafka 명령으로 재현한 것은 아닙니다.

대조 실패 0 때문에 이 버그를 보지 못했고 앞에서 본 판정의 잘못된 통과가 하네스의 준비 단계에서 한 번 더 일어났습니다. 생성한 뒤 파티션 수를 검증하는 두 번째 루프도 같은 구조라 첫 토픽 하나만 대조했고 그 하나는 제대로 만들어져 있었습니다.

결국 둘째 가설은 맞았고 그 가설을 시험한 도구가 고장 나 있었습니다. 아래는 </dev/null 을 붙인 뒤의 결과입니다. B 의 첫 값 162~189초는 수정 전 몇 번의 실행 범위로, 시나리오 상한 180초를 넘겨 실패하거나 상한 바로 아래에서 겨우 통과한 값입니다. 화살표 뒤는 수정 후 세 번 연속 실행한 값입니다.

토픽 20종 — 생성 성공 20 · 대조 실패 0
scenario:b  162~189초(실패/경계) → 21s → 10s → 14s

토픽이 1파티션으로 먼저 생겼다가 나중에 3파티션으로 늘어나면 consumer 가 새 파티션을 바로 받지 못합니다. 아래 데모에서 먼저 확인할 수 있습니다.

파티션이 생겨도 곧바로 읽을 수 있을까요?

사전 생성 조건을 바꾸고 발행 단계를 눌러 보세요. 브로커의 파티션과 consumer가 할당받은 파티션을 따로 표시합니다.

단일 Kafka 브로커·콘솔 client·earliest 조건으로 한 번 돌린 실험을 단순화했습니다. 파티션 증가는 kafka-topics --alter로 재현했고, Spring 리스너·KafkaAdmin 조합은 그대로 재현하지 않았습니다. 8개와 22개는 실험값이고, 파티션 1·2 각각의 개수는 나눠 세지 않았습니다. 약 289초도 실험값이며 재생 시간은 줄였습니다. 실제 E2E에서 메시지가 어느 파티션에 들어갔는지는 확인하지 않았습니다. latest 조건에서는 늦게 할당된 파티션의 기존 메시지를 건너뛰었습니다.

앱은 순차적으로 기동합니다. 먼저 뜬 서비스의 consumer 가 아직 없는 토픽(예: 다른 서비스가 소유하는 order.created)을 구독하면 브로커가 그 토픽을 기본값인 1파티션으로 자동 생성합니다. 나중에 소유 서비스가 뜨면 Spring 의 KafkaAdmin 이 TopicBuilder 선언(3파티션)과 비교해 파티션을 3개로 늘립니다. 먼저 구독한 consumer 는 메타데이터를 다시 받을 때까지 파티션 0만 할당받은 상태로 남습니다. 그동안 키 해시로 파티션 1 · 2에 들어간 메시지는 소비되지 않습니다. 사용한 kafka-clients 3.9.2 에서 메타데이터 갱신 주기(metadata.max.age.ms) 기본값은 5분이고 allow.auto.create.topics 기본값은 true 입니다.

같은 이미지의 Kafka 컨테이너 하나로 이 과정을 재현했습니다.

조건결과
비교: 토픽을 3파티션으로 먼저 만들고 구독한 뒤 키 30개 발행30개 모두 즉시 소비, 할당 (0,1,2)
실험: 없는 토픽을 먼저 구독(1파티션 자동 생성)하고 3파티션으로 늘린 뒤 키 30개 발행파티션 0의 8개는 즉시 소비. 약 289초 뒤 할당이 (0,1,2)로 바뀌며 나머지 22개 소비

실험 조건 apache/kafka:3.8.1 브로커 1대, 콘솔 client, 서비스와 같은 auto.offset.reset=earliest. 호스트 포트를 열지 않은 일회용 네트워크와 볼륨에서 돌렸습니다. 파티션 증가는 kafka-topics --alter 로 재현했고 Spring @KafkaListener 와 KafkaAdmin 조합을 그대로 재현하지는 않았습니다. 289초는 한 번 잰 값입니다. 콘솔 기본값인 latest 로 돌렸을 때는 늦게 할당된 파티션이 이미 들어와 있던 메시지를 건너뛰어, 약 340초 동안 8개만 소비됐습니다.

두 번째 시나리오만 느렸던 이유는 따로 확인하지 않았습니다. A 와 B 의 주문 키가 어느 파티션에 들어갔느냐에 달린 것으로 보입니다.

같은 실험에서 첫 번째 완화책이 틀린 전제에서 나왔다는 것도 확인했습니다. KAFKA_NUM_PARTITIONS 만 빼고 같은 이미지를 띄워 보니 자동 생성 토픽은 원래 1파티션이었습니다. 1파티션 고정 설정은 토픽의 파티션 수를 바꾸지 않았고, 처음 관측한 product.updated 소비 3분 지연도 같은 원인이었을 가능성이 큽니다.

고친 뒤에는 같은 문제가 다시 생겨도 바로 드러나게 했습니다. 사전 생성은 “생성 성공 수 == 선언된 토픽 수”가 아니면 실패합니다. 생성할 토픽 목록과 파티션 수는 따로 적지 않고 각 서비스의 TopicBuilder 선언에서 뽑아냅니다(kafka-subscription-contract-lint.sh --emit-topics).

처음 통과한 CI 안에도 아무것도 확인하지 않은 대조군이 있었습니다

이 수정 뒤 GitHub Actions 에서 E2E 잡이 처음으로 끝까지 통과했습니다. 그런데 같은 실행 로그에서 poller 정지 대조군은 16:14:34 에, product 정지 대조군은 그 1.2초 뒤에 ok 를 찍었습니다.

두 대조군이 무엇을 확인했는지는 아래 데모에서 판정 규칙별로 먼저 비교해 볼 수 있습니다. 재고 충분 대조군의 두 경우도 함께 넣었습니다.

대조군의 실패를 무엇으로 판정할까요?

경우를 골라 시나리오가 어디서 멈추는지 보세요. 점선 테두리가 대조군이 기대한 실패 지점입니다. 같은 실패를 세 규칙이 어떻게 판정하는지 아래에서 비교합니다.

  • 아무 실패나 인정 expect_fail판정 전
  • 실패 지점 확인 expect_timeout_at판정 전
  • 앞 단계까지 확인 (원인 관측)판정 전

본문에 나온 세 경우를 단계 네 개로 줄인 모형입니다. 시나리오 A 의 결제 단계, 재시도, 실제 대기 시간은 생략했습니다. product 정지 대조군은 이후 상품을 먼저 만든 뒤 product-service 를 멈추도록 다시 구성했습니다. 판정 문구는 설명용이며 실제 로그가 아닙니다.

product 정지 대조군이 그렇게 빨랐던 이유는 한 달 뒤 대조군의 소요 시간을 살펴보다 알게 됐습니다. 두 대조군은 공용 헬퍼 expect_fail 로 “명령이 어떤 이유로든 실패하면 통과”로 판정했습니다. A 의 첫 줄은 상품 생성 API 호출이었고 이 호출이 멈춘 product-service 에서 바로 실패했습니다. 대조군이 확인하려던 예약 단계까지 가지도 못했습니다.

poller 정지 대조군에도 문제가 있었습니다. 대조군은 poller 의 ShedLock 행을 미래 시각으로 설정해 둔 뒤, 끝나면 그 행을 지워 락을 풀었습니다. 이 프로젝트가 쓰는 ShedLock 6.3.1 은 한 번 만든 락 행을 이후 UPDATE 로만 다룹니다. 행이 지워지자 poller 가 락을 다시 얻지 못했습니다. 이어서 돈 재고 충분 대조군은 예약도 취소도 일어나지 않았는데 “취소되지 않았으니 통과”가 됐습니다.

이 대조군은 기대한 지점(주문 취소 대기)에서 시간 초과가 났으니 실패 지점은 맞았습니다. 그런데도 잘못 통과했습니다. “일어나지 않았다”를 확인하는 대조군은 실패 지점만으로는 부족했고, 그 앞 단계가 실제로 일어났는지(이 경우 예약 성공)도 함께 봐야 했습니다.

두 결함은 한 달 뒤의 정리 작업에서 고쳤습니다. expect_fail 을 없애고 실패 지점을 Timeout.stage 로 특정했습니다. 재고 충분 대조군은 취소가 없는지 보기 전에 예약이 실제로 성공했는지를 먼저 조회합니다. “일어나지 않음”을 확인하는 대조군은 세 가지를 함께 보도록 정리했습니다.

  • 원인 관측: 결함이 실제로 들어갔는지 봅니다. poller 정지 대조군은 주문 이벤트가 outbox 에 PENDING 으로 남아 있는지 조회합니다.
  • 계산한 대기 시간: 결함이 없었다면 결과가 도착했을 만큼만 기다립니다. 예약 부재는 poller 주기 5초의 6배인 30초를 기다립니다.
  • 복원 확인: 결함을 거두면 같은 흐름이 다시 도는지 봅니다. 기다린 시간이 짧아서 못 본 것이 아님을 보장합니다.

락 해제도 행을 지우는 대신 lock_until 을 UPDATE 하도록 바꿨습니다.

같은 문제는 E2E 밖의 검사에도 있었습니다. 격리 lint 는 compose 의 앱 목록이 비어 있지 않은지만 봐서 payment-service 를 지운 fixture 도 통과시켰습니다. 스케줄러 테스트는 메서드를 직접 호출해서 @Scheduled 를 지워도 통과했습니다. 둘 다 있어야 하는 대상을 명시하고 실제 스케줄링이 실행되는지 기다리도록 고쳤습니다.

최종적으로 이렇게 됐습니다

처음 끝까지 통과한 GitHub Actions 실행에서 잰 구간별 시간입니다. 잡 전체 시간을 모두 나눈 것은 아닙니다.

구간소요
인프라 기동26초
토픽 사전 생성65초(20종 생성 · 검증 실패 0)
앱 기동앱마다 18초
readiness 검사2초
시나리오 A · B · C · D18 · 14 · 8 · 38초
대조군 준비인프라 12초 · readiness 검사 2초
대조 검사465초(7종 ok, 격리 probe 종료 코드 7)
E2E 잡 전체15분 41초(상한 45분)

로컬에서 상한 180초를 넘나들던 B 가 CI 에서도 14초였습니다.

더 좋은 방법은 없었을까요?

아래는 작업을 마친 뒤 따져 본 대안입니다.

최소 하네스를 먼저 돌려 봤다면

시작 방식은 계획 단계에서 Kafka 직접 발행, outbox INSERT, seed runner, E2E 전용 source set 순서로 여러 번 바뀌었습니다. 마지막 안은 “컨테이너는 운영 bootJar 를 실행한다”는 사실 하나 때문에 구현을 시작하자마자 쓸 수 없었습니다. 컨테이너를 한 번 띄워 보면 알 수 있는 사실이었습니다.

첫 실제 실행에서 나온 결함 11건도 마찬가지였습니다. envelope 필드 이름, 앞 시나리오의 행 때문에 바로 끝나는 대기, 로컬 프로파일보다 우선순위가 낮았던 PG 주소는 계획과 코드를 읽는 동안 놓쳤고 실행하면서 나왔습니다.

시나리오 하나(A)가 네 서비스를 거쳐 끝까지 통과하는 가장 작은 하네스를 먼저 세우고 그 위에서 나머지 계획을 다듬을 수 있었습니다. 그랬다면 시작 방식이 막힌다는 것을 계획을 여러 번 고치기 전에 알았을 것입니다. 다만 실제 PG 를 호출할 수 있다는 위험처럼 실행 전에 막아야 하는 것은 최소 하네스를 띄우기 전에 따로 확인해야 했습니다.

원칙을 헬퍼에 넣었다면

판정 원칙은 “실패해야 할 때, 그 이유로 실패하게 한다”입니다. 이 작업은 이 원칙을 대조군마다 따로 적용했습니다. listener 부재 대조군은 실패 메시지를, canary 대조군은 종료 코드를 확인했습니다. 하지만 poller 정지와 product 정지 대조군이 쓴 공용 헬퍼는 어떤 실패든 통과로 처리했습니다.

# 처음: 명령이 어떤 이유로든 실패하면 통과
expect_fail() {
  local name="$1"; shift
  if "$@" >/dev/null 2>&1; then
    echo "  FAIL [$name] 결함을 주입했는데 검사가 통과했다 — 그 단언은 아무것도 잡지 못한다"
    nc_fails=$((nc_fails + 1))
  else
    echo "  ok   [$name]"
  fi
}

나중에는 원칙을 헬퍼로 옮겼습니다. 대기가 시간 초과로 끝날 때 어느 대기였는지(stage)를 함께 남기고 대조군은 기대한 지점에서 실패했을 때만 통과합니다.

# 고친 뒤: 정확히 기대한 stage 의 Timeout 이어야 통과
def expect_timeout_at(stage, fn):
    try:
        fn()
    except Timeout as e:
        if e.stage != stage:
            raise AssertionError("기대한 지점(%s)이 아니라 %s 에서 실패했다 — %s" % (stage, e.stage, e))
        return
    raise AssertionError("결함을 주입했는데 %s 대기가 성립했다 — 그 단언은 이 결함을 잡지 못한다" % stage)

처음부터 대조 헬퍼가 기대하는 실패 지점을 인자로 받게 했다면, 그 인자 없이는 대조군을 작성할 수 없었을 것입니다. 다만 재고 충분 사례처럼 실패 지점이 맞아도 부족할 때가 있어서, 그 앞 단계가 실제로 일어났는지 보는 일은 지금도 대조군마다 따로 씁니다.

대기 쪽은 지금도 그대로입니다. 대기 헬퍼 wait_for 는 아무 조회 함수나 받고 “이 실행의 식별자에 묶는다”는 규칙은 관례로만 남아 있습니다. 이 관례는 이 작업에서 네 번 깨졌습니다(가격 캐시 · 알림 · 소비 기록 · DLQ). 헬퍼가 실행 ID 없이는 조회할 수 없게 만들었다면, 몇 번은 코드를 쓰는 시점에 막혔을 것입니다.

완화책도 효과를 확인해야 했습니다

1파티션 고정 설정은 “기본값이 3”이라는 전제에서 나왔는데 그 이미지의 기본값은 이미 1이었습니다. 설정을 바꿨으면 그 설정이 바꾸는 값을 한 번은 확인해야 합니다. 그랬다면 토픽의 파티션 수가 원래 1이었다는 것을 바로 알았을 것입니다. 효과 없는 완화책은 증상이 남아 있는 동안 “원인 하나는 처리했다”는 착각을 주고 다음 가설을 세우기 어렵게 만듭니다. 대조군에 요구했던 확인을 완화책에도 똑같이 요구할 수 있었습니다.

시나리오마다 새 스택을 띄웠다면

기존 계획은 시나리오마다 스택을 새로 띄우는 안을 가장 안전하다고 보면서도 실행 시간 때문에 쓰지 않았습니다. CI 에서 잰 값으로 보면 스택 하나를 준비하는 데 인프라 26초, 토픽 65초, 앱 네 개 72초, readiness 검사 2초로 약 165초가 듭니다. 추가 세 스택이면 약 8.3분이 더 듭니다(165초 × 3으로 계산한 값입니다).

이 안을 쓰면 앞 시나리오의 행이 뒤 시나리오의 대기를 채우는 문제는 구조적으로 없어집니다. 하지만 다른 문제는 막지 못합니다. 파티션을 늦게 발견하는 지연은 스택 하나 안에서 생기고 같은 시나리오의 ORDER_CREATED 알림이 잘못된 조건을 채우는 문제도 그대로입니다. 판정이 실패 이유를 보지 않는 문제도 스택 수와 상관이 없습니다. 실행 ID 에 묶는 방식은 스택 준비 시간을 늘리지 않고 같은 문제를 막았습니다.

정리하며

이 작업에서 잘못된 통과는 시작 · 대기 · 판정 세 단계에서 모두 나왔고 운영 진입점 · 실행 ID 에 묶은 대기 · 실패 지점을 확인하는 음성 대조군으로 대응했습니다.

그런데 가장 오래 남은 문제는 검증 도구 쪽에 있었습니다. 마지막 대응은 일부 대조군에만 제대로 적용됐습니다. product 정지 대조군은 확인하려던 예약 단계에 가기 전에 실패했고, 재고 충분 대조군은 poller 정지 대조군이 남긴 고장 때문에 잘못 통과한 채 한 달 동안 CI 를 통과했습니다. 이 사례에서 실패 지점이 맞아도 그 앞 단계가 실제로 일어났는지 확인해야 한다는 것도 알게 됐습니다. 하네스를 준비하는 루프 하나는 같은 이유로 잘못된 결과를 내서 맞는 가설을 틀린 것처럼 보이게 했습니다. 통과를 믿으려면 그 통과를 낸 도구의 판정도 같은 기준으로 확인해야 했습니다.

남은 과제

아직 남아 있는 것과 이후에 해결한 것을 함께 적습니다. 앞의 세 항목은 추적 목록에 올려 두었습니다.

  • 환불 결과 세 종류(SUCCEEDED · FAILED · UNRESOLVED) × 세 소비자의 아홉 조합은 서비스별 통합테스트로 고정했지만 E2E 로는 관측하지 않았습니다.
  • DLQ 는 원장 한 행과 식별자 여섯 개가 채워지는 것까지 확인합니다. 같은 메시지를 다시 소비할 때 새 행 없이 attempt_count = 2 가 되는지는 E2E 에서 보지 않습니다.
  • 실제 Toss 를 호출하는 승인 · 취소 · 조회 경로는 stub 으로만 확인했습니다.
  • E2E 는 평문 헤더 모드로 돌기 때문에 gateway 서명 토큰 경로는 이 E2E 밖에 있습니다. 그 경로는 23편의 클러스터 smoke 테스트가 따로 검증합니다.
  • canary 대조군은 다른 네트워크의 호스트에 연결할 수 없다는 것까지만 증명합니다. 실제 외부 PG 호출이 막힌 것을 실행에서 확인한 사례는 앞에서 본 UnknownHostException 한 건입니다.
  • 대기를 실행 ID 에 묶는 규칙은 아직 헬퍼가 강제하지 않는 관례입니다.
  • 실패한 메시지를 다시 흘려보내는 replay 는 이 작업 뒤에 별도 계약과 함께 만들었습니다.

썸네일: Traffic light in Thailand, green light 9 s left, Mattes, CC BY-SA 4.0 (16:9 로 잘라 사용)