← 모든 글
Testing

금융 서비스에서 테스트 코드를 어디까지 작성해야 할까?

승인·실패·중복·취소·부분취소·DB 오류·외부 PG Timeout을 기준으로 금융 서비스 테스트 범위를 정한 기록이다.

결제 코드에 테스트를 추가하면서 가장 어려웠던 질문은 몇 개를 작성할지가 아니었다. 어디까지 자동으로 증명해야 배포할 수 있는지가 문제였다. 성공 케이스만 통과해서는 운영에서 생기는 중복과 타임아웃을 설명할 수 없었다.

JUnit5와 Mockito를 사용했지만 도구보다 먼저 결제 상태가 깨지는 경로를 목록으로 만들었다.

결제의 결과를 상태 전이로 적었다

승인 성공, 승인 실패, 중복 승인, 취소, 부분취소를 서로 다른 API 테스트로만 보지 않았다. 어떤 상태에서 어떤 명령을 받을 수 있고, 성공 뒤 금액과 이력이 어떻게 남아야 하는지 적었다.

READY → APPROVED → PARTIALLY_CANCELLED → CANCELLED
   └────────────── 실패 시 기존 상태 유지 ──────────────┘

테스트 이름에는 구현 메서드보다 상황과 결과를 적었다. 리팩토링으로 메서드가 바뀌어도 결제 규칙은 그대로 읽히게 했다.

단위 테스트와 통합 테스트의 경계를 나눴다

금액 계산, 상태 전이, 중복 판단은 빠른 단위 테스트로 확인했다. 데이터베이스 제약, 트랜잭션 롤백, 실제 매핑은 통합 테스트로 확인했다. 모든 의존성을 Mock으로 만들면 데이터베이스에서만 생기는 문제를 놓쳤다.

범위주로 확인한 것
단위 테스트상태 전이, 금액 계산, 분기
통합 테스트트랜잭션, 제약조건, 데이터 매핑
외부 연동 테스트Timeout, 오류 응답, 결과 조회

실패 뒤 무엇이 남는지 확인했다

DB 저장에 실패했을 때 승인 이력이 일부만 남지 않는지 확인했다. 외부 PG Timeout에서는 실패로 단정하지 않고 결과를 알 수 없는 상태를 처리하는지 봤다. 네트워크 오류가 곧 결제 실패라는 가정은 위험했다.

중복 승인 테스트에서는 같은 요청이 두 번 들어와도 외부 결제와 내부 금액 반영이 한 번만 일어나야 했다. 취소와 부분취소는 누적 금액이 승인 금액을 넘지 않는지를 최종 불변식으로 뒀다.

시나리오 표를 먼저 만들었다

테스트 클래스를 열고 생각나는 케이스부터 추가하면 성공 케이스만 자세해지고 실패 케이스는 빠지기 쉬웠다. 입력 상태, 외부 결과, 내부 저장 결과, 재시도 가능 여부를 축으로 시나리오 표를 만들었다.

상황외부 결과내부에서 확인할 것
승인 성공승인 식별자 반환APPROVED 상태와 금액 이력
승인 거절명확한 실패성공 상태가 남지 않음
승인 Timeout결과 불명확UNKNOWN 상태와 조회 대상
중복 승인같은 요청 재입력외부 부작용과 내부 반영이 한 번
부분취소일부 금액 취소누적 취소액과 남은 가능 금액
DB 실패외부 성공 가능보정·대사 대상이 추적 가능

각 행은 테스트 하나라는 뜻이 아니었다. 같은 상황에서도 시작 상태와 명령 순서가 다르면 결과가 달라졌다. 표는 누락된 위험을 찾는 지도였고, 실제 테스트는 한 가지 이유로만 실패하도록 더 작게 나눴다.

상태 전이를 도메인 안에서 거절하게 했다

서비스 메서드가 현재 상태를 확인해도 다른 호출 경로가 엔티티 상태를 직접 바꾸면 규칙이 깨졌다. 승인과 취소 규칙을 상태를 소유한 객체에 두고 허용되지 않은 전이를 즉시 거절하게 했다.

Payment payment = Payment.ready(amount);

payment.approve(approvalKey);
assertThat(payment.status()).isEqualTo(APPROVED);

assertThatThrownBy(payment::approve)
    .isInstanceOf(InvalidPaymentState.class);

예시는 구조를 단순화했다. 테스트에서 중요한 것은 메서드 이름이 아니라 두 번째 승인 명령이 상태와 금액을 바꾸지 않는다는 점이었다. 예외만 확인하지 않고 예외 뒤 객체 상태가 그대로인지도 확인했다.

부분취소는 경계값을 많이 만들었다. 0원, 승인 금액과 같은 금액, 남은 금액보다 큰 값, 여러 번 나눠 취소한 누적값을 확인했다. 계산을 여러 서비스에 복사하지 않고 한 규칙으로 모으니 테스트도 같은 기준을 사용했다.

표현만 다른 입력은 매개변수 테스트로 묶었다

오류 코드마다 테스트 메서드를 복사하면 무엇이 같은 규칙이고 무엇이 다른 결과인지 흐려졌다. 같은 처리 결과를 가져야 하는 입력은 매개변수 테스트로 묶고, 상태가 달라지는 케이스는 별도 테스트로 남겼다.

@ParameterizedTest
@MethodSource("declinedResponses")
void 명확한_거절은_실패로_종료하고_승인상태를_남기지_않는다(
    GatewayResponse response
) {
    gateway.willReturn(response);

    PaymentResult result = paymentService.approve(command);

    assertThat(result.isDeclined()).isTrue();
    assertThat(repository.find(command.key())).isEmpty();
}

테스트 이름은 구현 세부사항 대신 상황과 결과를 적었다. 실패했을 때 이름만 읽어도 어떤 비즈니스 규칙이 깨졌는지 알 수 있었다. 공통 준비 코드가 너무 커지면 테스트가 무엇을 검증하는지 숨겼기 때문에 필요한 값만 드러냈다.

중복 요청은 순차 호출과 경쟁 호출을 나눠 봤다

같은 요청을 한 테스트 안에서 두 번 호출하는 것만으로는 동시성 문제를 잡지 못했다. 순차 재요청은 기존 결과를 반환하는지 확인했고, 경쟁 요청은 둘이 사전 조회를 함께 통과해도 저장소 제약이 최종 결과를 하나로 만드는지 확인했다.

요청 A ── 조회: 없음 ──┐
                       ├─ 유일성 제약 ── 하나만 반영
요청 B ── 조회: 없음 ──┘

테스트에서 스레드 수를 늘리는 것보다 경쟁 지점을 통제하는 것이 중요했다. 두 실행이 같은 지점에 도착하도록 맞춘 뒤 저장을 진행했다. 한 요청이 성공하고 다른 요청이 이미 처리된 결과로 정리되는지 확인했다.

외부 결제사가 멱등 키를 지원하는 경우 같은 키가 전달되는지도 확인했다. 내부 데이터만 하나이고 외부 승인이 두 번이면 테스트는 실패였다. 시스템 경계마다 중복 방지 책임을 따로 검증했다.

Timeout은 실패가 아니라 미확정 결과로 테스트했다

외부 호출에서 Timeout이 났다고 해서 결제사가 요청을 처리하지 않았다는 뜻은 아니었다. 응답만 돌아오지 않았을 수 있었다. 이 경우 자동 재시도가 같은 승인을 다시 만들 수 있으므로 단순 실패 분기로 보내지 않았다.

외부 호출 Timeout
  → 상태 UNKNOWN 저장
  → 같은 키로 상태 조회
      ├─ 승인 확인: APPROVED로 확정
      ├─ 실패 확인: DECLINED로 확정
      └─ 계속 불명확: 대사 대상으로 유지

Mock은 Timeout을 던지는 데 사용했고, 서비스가 UNKNOWN 상태와 조회에 필요한 안전한 식별자를 남기는지 확인했다. 이후 상태 조회 결과에 따라 한 방향으로만 확정되는지도 별도 테스트로 작성했다.

트랜잭션 테스트는 실제 저장소로 확인했다

Mockito로 repository가 예외를 던지게 하면 분기 처리는 확인할 수 있지만 실제 롤백과 제약조건은 알 수 없었다. 트랜잭션 경계가 중요한 케이스는 실제 데이터베이스 매핑을 사용하는 통합 테스트로 옮겼다.

결제 상태와 이력이 함께 저장돼야 하는데 중간 저장이 실패했을 때 둘 다 롤백되는지 확인했다. 유니크 제약 충돌이 애플리케이션에서 예상한 예외로 변환되는지도 봤다. 테스트가 끝날 때 자동 롤백된다는 사실에 기대지 않고, 서비스 호출 직후의 데이터 상태를 조회했다.

테스트 데이터는 의미가 보이게 만들었다

필드가 많은 결제 객체를 매번 생성하면 기본값이 무엇인지 알기 어려웠다. 정상 결제 생성기를 두고 각 테스트는 바꾸려는 조건만 명시했다. 다만 모든 값을 숨기는 거대한 fixture는 만들지 않았다.

PaymentCommand command = aPayment()
    .withAmount(validAmount)
    .withRequestKey(requestKey)
    .build();

날짜와 시간은 시스템 시계를 직접 읽지 않고 테스트에서 고정할 수 있게 했다. 당일 중복 판단, 취소 가능 기간, 정산 기준일 같은 규칙이 실행 시각에 따라 흔들리지 않게 했다.

Mock은 실패 조건을 만드는 데 사용했다

Mockito는 테스트를 빠르게 만들기 위한 목적보다 외부 PG Timeout, 특정 오류 코드, 재시도 같은 조건을 재현하는 데 사용했다. 호출 횟수만 확인하지 않고 결과 상태와 저장된 데이터를 함께 봤다.

어디까지 작성할지 정한 기준

금액이 달라지거나 복구 방법이 달라지는 분기는 반드시 테스트했다. 단순한 getter나 프레임워크 동작을 반복해서 증명하지는 않았다. 장애가 났을 때 가장 먼저 확인할 규칙이 테스트 이름으로 남아 있어야 했다.

CI에서는 빠른 단위 테스트를 먼저 실행하고 저장소와 외부 계약을 확인하는 테스트를 뒤에 뒀다. 느린 테스트를 없애기보다 실패를 빨리 알려주는 순서를 만들었다. 배포 전에는 금액 불변식과 주요 실패 시나리오가 반드시 통과해야 했다.

테스트로 모든 운영 장애를 예측할 수는 없었다. 외부 결제사의 실제 지연과 네트워크 분리는 Mock과 로컬 데이터베이스만으로 완전히 재현되지 않았다. 그래서 계약 테스트, 모니터링, 대사를 테스트 코드의 대체물이 아니라 다음 방어선으로 함께 뒀다.

금융 서비스의 테스트 범위는 코드 줄 수로 정할 수 없었다. 실패했을 때 돈의 상태를 설명하고 다시 실행해도 같은 결과를 만들 수 있는지, 그 질문에 답하는 시나리오까지 작성했다.