쌩로그

[TroubleShooting - 항해 플러스 Lite 백엔드 코스] 메서드 체이닝 mocking 시 테스트가 실패하는 케이스 본문

TroubleShooting & 고민

[TroubleShooting - 항해 플러스 Lite 백엔드 코스] 메서드 체이닝 mocking 시 테스트가 실패하는 케이스

.쌩수. 2026. 3. 27. 08:09
반응형

목록

  1. 포스팅 개요
  2. 본론
     2-1. 문제 상황
     2-2. 문제 해결
  3. 요약

1. 포스팅 개요

해당 포스팅은 테스트 코드 작성을 하면서 메서드 체이닝에 대한 결과를 Mocking 할 때, 발생한 이슈를 해결한 기록이다.
남겨두면 좋을 케이스라서 이렇게 정리한다.

참고로 Claude를 통해서 해당 이슈를 해결했다.

2. 본론

2-1. 문제 상황

결제에 대한 로직을 아래와 같이 작성했다.
로직 순서는 다음과 같다.

  1. 중복 결제 방지(결제 버튼 2번 누른 것에 대한 방지)
  2. 결제 정보 저장
  3. 결제 id 조회
  4. outbox 저장
  5. 외부 APi로 결제 정보 전송 Event 발행(트랜잭션 후 처리)

아래는 코드다.

@Transactional
public PaymentResponse payment(PaymentServiceRequest request, String idempotencyKey) {
    // 1. 결제 상태 확인이 끝나면, 처리해야 할 결제이므로, 레디스로 중복 요청 방지
    if(verifyDuplicatePayment(idempotencyKey)) {
        throw new BusinessLogicRuntimeException(BusinessLogicMessage.ALREADY_PROCESSING_THIS_PAYMENT);
    }
    // 2. 결제 정보 저장
    PaymentResponse response = paymentUseCase.payment(request, idempotencyKey);

    // 3. 결제 id 조회
    Payment payment = retrievePaymentUseCase.retrievePayment(response.id());

    // 4. 같은 트랜잭션 내에서 outbox 저장
    registerOutboxUseCase.register(new Outbox(payment.getId(), payment.getOrderId(), payment.getTotalAmount(), payment.getPaymentState()));

    // 5. 외부 API 전송 Event 발행
    eventPublisher.publishEvent(new PaymentEvent(payment.getId(), payment.getOrderId()));

    return PaymentResponse.from(payment);
}

    private Boolean verifyDuplicatePayment(String idempotencyKey) {
        Boolean processing = stringRedisTemplate.opsForValue().setIfAbsent(
                "idempotencyKey:" + idempotencyKey,
                "PROCESSING",
                Duration.ofMinutes(30)
        );
        return !processing;
    }

그리고 해당 로직에 대한 테스트 코드를 작성했다.
테스트 코드를 작성하고 실행했더니,
stringRedisTemplate.opsForValue() 코드에서 null 이 발생했다고 한다.

먼저 작성한 테스트 코드를 살펴보자.
(#### 아래 부분 #### 에서 테스트 코드가 정상적으로 수행되지 않고 있다고 나왔다.)

@Test
@DisplayName("결제 로직 테스트")
void paymentTest() {
    // given
    Payment payment = Payment.create(1L, 30000L);
    payment.assignId(1L);
    payment.changeState(PaymentState.PAYMENT_COMPLETE);

    PaymentResponse expectedResponse = PaymentResponse.from(payment);

    // #### 아래 부분 ####
    given(stringRedisTemplate.opsForValue().setIfAbsent(any(), any(), any())).willReturn(Boolean.TRUE); 

    given(paymentUseCase.payment(any(), any())).willReturn(expectedResponse);
    given(retrievePaymentUseCase.retrievePayment(any())).willReturn(payment);

    Outbox outbox = new Outbox(payment.getId(), payment.getOrderId(), payment.getTotalAmount(), payment.getPaymentState());
    given(registerOutboxUseCase.register(any())).willReturn(outbox);

    // when
    PaymentResponse response = paymentFacade.payment(new PaymentServiceRequest(1L, 1L, 1L, null), UUID.randomUUID().toString());

    // then
    assertThat(response.id()).isEqualTo(payment.getId());
    assertThat(response.orderId()).isEqualTo(payment.getOrderId());
    assertThat(response.totalAmount()).isEqualTo(payment.getTotalAmount());
    assertThat(response.paymentState()).isEqualTo(PaymentState.PAYMENT_COMPLETE.toString());

    // then
    then(paymentUseCase).should(times(1)).payment(any(), any());
    then(retrievePaymentUseCase).should(times(1)).retrievePayment(any());
    then(registerOutboxUseCase).should(times(1)).register(any());
    then(eventPublisher).should(times(1)).publishEvent(any());
}

아래와 같은 로그가 발생했다.
로그는 다음과 같다.

Cannot invoke "org.springframework.data.redis.core.ValueOperations.setIfAbsent(Object, Object, java.time.Duration)" because the return value of "org.springframework.data.redis.core.StringRedisTemplate.opsForValue()" is null java.lang.NullPointerException: Cannot invoke "org.springframework.data.redis.core.ValueOperations.setIfAbsent(Object, Object, java.time.Duration)" because the return value of "org.springframework.data.redis.core.StringRedisTemplate.opsForValue()" is null at kr.hhplus.be.server.payment.application.service.PaymentServiceTest.paymentTest(PaymentServiceTest.java:72) at java.base/java.lang.reflect.Method.invoke(Method.java:568) at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

해당 문제는 stringRedisTemplate.opsForValue() 자체를 mocking하지 않아서 발생하는 문제였다.
Mockito는 메서드 체이닝을 직접적으로 지원하지 않기 때문에,
다음과 같이 단계별로 mocking 해야 한다.

문제 되는 코드를 살펴보자.

given(stringRedisTemplate.opsForValue().setIfAbsent(any(), any(), any())).willReturn(Boolean.TRUE); 

위와 같이 되어있는데,

stringRedisTemplate.opsForValue() 호출 후,
setIfAbsent(any(), any(), any()) 를 한 결과가 Boolean 타입의 TRUE 결과를 나오게 한다고 비즈니스 로직에 moking을 해놨다.

이러한 mocking을 한 번에 아니라, 단계별로 mocking 해야한다.

2-2. 문제 해결

이처럼 메서드 체이닝에 의해서 해당 이슈가 발생했기 때문에,
메서드 체이닝을 단계별로 mocking 하면 된다.

코드는 아래와 같이 수정했고, 테스트는 통과했다.

@Test
@DisplayName("결제 로직 테스트")
void paymentTest() {
    // given
    Payment payment = Payment.create(1L, 30000L);
    payment.assignId(1L);
    payment.changeState(PaymentState.PAYMENT_COMPLETE);

    PaymentResponse expectedResponse = PaymentResponse.from(payment);

    // 변경된 코드
    // ✅ ValueOperations mock 생성 및 설정
    ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
    given(stringRedisTemplate.opsForValue()).willReturn(valueOperations);
    given(valueOperations.setIfAbsent(any(), any(), any())).willReturn(Boolean.TRUE);

    ...

3. 요약

위처럼 메서드 체이닝을 mocking 했을 때에 대한 이슈를 살펴보고 단계적으로 mocking을 처리하면 된다는 것을 확인해보았다.

P.S)

그나저나 메서드 체이닝을 아예 메서드로 감쌀 순 없는지 생각을 해봤지만, private 메서드라서 애매하다.
public 으로 빼기에도 애매하고, 차라리 테스트 클래스에서 해당 코드를 메서드로 만들어서 moking 처리하는 것도 괜찮을 거 같다.

728x90
Comments