| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 쓰레드
- mysql
- 람다
- 도커 엔진
- Docker
- Thread
- 자료구조
- 데이터베이스
- 자바
- 스레드
- 알고리즘
- SQL
- 쿠버네티스
- container
- lambda
- 함수형 인터페이스
- 실전 자바 고급 1편
- 시작하세요 도커 & 쿠버네티스
- 컨테이너
- db
- RDB
- 동시성
- replicaset
- 자바 입출력 스트림
- 일프로
- 인프런
- 김영한
- Kubernetes
- 도커
- java
- Today
- Total
쌩로그
[TroubleShooting - 항해 플러스 Lite 백엔드 코스] 메서드 체이닝 mocking 시 테스트가 실패하는 케이스 본문
[TroubleShooting - 항해 플러스 Lite 백엔드 코스] 메서드 체이닝 mocking 시 테스트가 실패하는 케이스
.쌩수. 2026. 3. 27. 08:09목록
- 포스팅 개요
- 본론
2-1. 문제 상황
2-2. 문제 해결 - 요약
1. 포스팅 개요
해당 포스팅은 테스트 코드 작성을 하면서 메서드 체이닝에 대한 결과를 Mocking 할 때, 발생한 이슈를 해결한 기록이다.
남겨두면 좋을 케이스라서 이렇게 정리한다.
참고로 Claude를 통해서 해당 이슈를 해결했다.
2. 본론
2-1. 문제 상황
결제에 대한 로직을 아래와 같이 작성했다.
로직 순서는 다음과 같다.
- 중복 결제 방지(결제 버튼 2번 누른 것에 대한 방지)
- 결제 정보 저장
- 결제 id 조회
- outbox 저장
- 외부 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 처리하는 것도 괜찮을 거 같다.