문제 개요
오류 상황과 영향 범위 (예: 특정 코드 실행 시 발생, 특정 라이브러리 버전 호환 문제 등) 발생 배경 (예: 코드 테스트 중, 배포 중 문제 발견 등)
나는 계획대로 내 할일인 Youtube API 연동을 5월 2일까지 완벽히 테스트 코드까지 마치고, 간만의 꿀잠을 때리려고 했다. 유튜브 API 연동 성공, Mock Data를 임의로 넣고 테스트 코드 성공, 이후 INSERT IGNORE로 중복되는 소스 처리 등등.. 계속해서 성공했는데
단 한 테스트 코드가 성공하지 못했다!!! 🤬
@Test
@DisplayName("Youtube Video 검색 API - circuitBreaker 확인")
void fetchVideos_circuitBreaker() {
/// given
// 실패 응답 JSON
String errorJson = """
{
"error": {
"code": 500,
"message": "Internal Error",
"errors": [
{
"domain": "global",
"reason": "internalError",
"message": "Internal Error"
}
]
}
}
""";
// Mock ExchangeFunction이 반환할 단일 에러 응답 Mono
Mono<ClientResponse> errorResponseMono = Mono
.just(buildResponse(errorJson, HttpStatus.INTERNAL_SERVER_ERROR));
// Retry 설정에 따른 총 시도 횟수
int retryMaxAttempts = 3;
int attemptsPerFailure = 1 + retryMaxAttempts;
// Circuit Breaker를 OPEN 시키기 위해 필요한 실패 횟수
int failuresToOpenCircuit = 5;
// Circuit Breaker가 OPEN 되기 전까지 ExchangeFunction이 호출될 총 예상 횟수
// 각 실패는 Retry 횟수만큼 ExchangeFunction 호출을 유발
int totalExpectedExchange = failuresToOpenCircuit * attemptsPerFailure;
// Mock ExchangeFunction의 순차적인 에러 응답을 Mockito 체이닝으로 설정
// 첫 번째 호출에 대한 Mock 설정
OngoingStubbing<Mono<ClientResponse>> stubbing = when(exchangeFunction.exchange(any()))
.thenReturn(errorResponseMono);
// 나머지 호출들에 대한 Mock 설정을 루프를 통해 체이닝
for (int i = 0; i < failuresToOpenCircuit - 1; i++) {
stubbing = stubbing.thenReturn(errorResponseMono);
}
// 이제 exchangeFunction.exchange(any())가 호출될 때마다 순서대로 errorResponseMono를 20번 반환합니다.
/// when
// Circuit Breaker를 OPEN 시키기 위해 5회 실패 호출 시도 (루프)
log.info("Circuit Breaker OPEN 시키기 위해 {}회 실패 호출 시도 시작", failuresToOpenCircuit);
for (int i = 0; i < failuresToOpenCircuit; i++) {
int finalI = i;
StepVerifier.create(youtubeVideoApiAdapter.fetchVideos("fail" + i, 1))
// Retry 후 RetryableExternalApiException 또는 Circuit Breaker OPEN 시 CallNotPermittedException 발생 예상
// 둘 중 하나의 예외가 발생하면 검증 통과하도록 수정 (expectErrorSatisfies 사용)
.expectErrorSatisfies(e -> {
assertThat(e).isInstanceOfAny(
RetryableExternalApiException.class,
CallNotPermittedException.class
);
// 어떤 예외가 발생했는지 로그로 확인
log.debug("[Call {}] StepVerifier caught expected exception: {}",
finalI, e.getClass().getSimpleName());
}).verify();
}
log.info("{}회 실패 호출 시도 완료", failuresToOpenCircuit);
/// then
// 루프 완료 후 Circuit Breaker 상태가 OPEN 인지 확인
CircuitBreaker circuitBreaker = registry.circuitBreaker("YOUTUBE_VIDEO_CIRCUIT_BREAKER");
assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
log.info("Circuit Breaker 상태: {}", circuitBreaker.getState());
/// when
// Circuit Breaker가 열린 상태에서 호출 → CallNotPermittedException 발생 예상
log.info("Circuit Breaker OPEN 상태에서 호출 시도 (루프 후 첫 번째 호출)");
StepVerifier.create(youtubeVideoApiAdapter
.fetchVideos("should be blocker", 1))
// Circuit Breaker OPEN 상태에서는 CallNotPermittedException 발생 예상
.expectError(CallNotPermittedException.class)
.verify();
log.info("Circuit Breaker OPEN 상태에서 호출 차단 확인");
/// then
// Circuit Breaker OPEN 상태에서는 CallNotPermittedException 발생 예상
// Circuit Breaker는 5번의 실패가 기록된 후에 OPEN 됨.
// 각 실패는 Retry 정책에 따라 총 attemptsPerFailure (4) 번의 ExchangeFunction 호출을 유발함!
// 안정적인 테스트 환경이라면 5번의 실패 시도 각각이 완전히 Retry를 거쳐
// 총 5 * 4 = 20 번 호출된 후에 Circuit Breaker가 OPEN 될 예정
// 마지막 차단된 호출은 ExchangeFunction에 도달하지 않음.
verify(exchangeFunction, times(totalExpectedExchange)).exchange(any());
}
이 문제의 코드를 보면, 주석이 엄청 달린 것을 볼 수가 있다. 왜냐면 내가 이해를 못한 것 같아 계속해서 방식을 바꿔보고 주석을 달았기 때문이다..
오류 메시지 및 원인 분석
오류 코드, 오류 메시지 포함 HTTP 상태 코드, 애플리케이션 로그, 서버 로그 등 구체적인 오류 정보 정리 및 로그 메시지를 통한 분석 제공 문제 발생 원인에 대한 심층 분석
org.mockito.exceptions.verification.TooFewActualInvocations:
exchangeFunction.exchange(<any>);
Wanted 20 times:
-> at site.kkokkio.infra.youtube.video.YoutubeVideoApiAdapterTest.fetchVideos_circuitBreaker(YoutubeVideoApiAdapterTest.java:293)
But was 2 times:
-> at org.springframework.web.reactive.function.client.DefaultWebClient$ObservationFilterFunction.filter(DefaultWebClient.java:745)
-> at org.springframework.web.reactive.function.client.DefaultWebClient$ObservationFilterFunction.filter(DefaultWebClient.java:745)
at site.kkokkio.infra.youtube.video.YoutubeVideoApiAdapterTest.fetchVideos_circuitBreaker(YoutubeVideoApiAdapterTest.java:293)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
간단하게 문제를 비유해서 말하자면.. 내가 카페에 가서 커피 20잔을 주문했는데, 꼴랑 두 잔만 받은 것이다. 나는??? 분명 키오스크에 서서 “커피 20잔”을 주문했는데?!?! 꼴랑 두 잔만 받은 것이다!!!!!!
나의 구세주 제미니가 말하길
- 테스트 환경에서 Reactive 스트림과 Resilience4j Retry의 비동기 실행(특히 1초의 wait-duration)이 예상대로 작동하지 않고, 스트림이 2번째 Mock 호출(첫 번째 Retry 시도) 후에 어떤 이유로든 중단되고 있음을 강하게 시사합니다.
라고 한다..
해결 및 고민 과정
처음 문제를 접했을 때 고려했던 대안들과 선택하지 않은 이유 실험적으로 적용한 방법이 실패한 사례와 그 이유 여러 해결책 중 최적의 방안을 선택한 과정
우선 내가 놓친 부분이 있는지 이것저것 찾아봤지만, 답이 나오지 않아서 팀원분께 말씀을 드렸다. 그리고 바로 답을 알아낼 수 있었다.
application.yml이 아닌 application-test.yml을 오버라이딩해서 테스트를 하고 있었다는 사실이다..! 설정값이 달라 생긴 문제였다. 즉, 나는 인당 커피를 두 잔 시킬 수 있는 카페에 가서 20잔을 달라고 땡깡 부리고 있었던 것이다…!!
최종 해결책 및 구현
문제 해결을 위한 단계별 조치 방법과 코드 예시 (예: 특정 애노테이션 추가, 라이브러리 버전 조정, 설정 변경 등) 참고 자료 (관련 공식 문서, 개발자 커뮤니티 게시물 등)
@Test
@DisplayName("Youtube Video 검색 API - circuitBreaker 확인")
void fetchVideos_circuitBreaker() {
/// given
// 실패 응답 JSON
String errorJson = """
{
"error": {
"code": 500,
"message": "Internal Error",
"errors": [
{
"domain": "global",
"reason": "internalError",
"message": "Internal Error"
}
]
}
}
""";
// Mock ExchangeFunction이 반환할 단일 에러 응답 Mono
Mono<ClientResponse> errorResponseMono = Mono
.just(buildResponse(errorJson, HttpStatus.INTERNAL_SERVER_ERROR));
// Retry 설정에 따른 총 시도 횟수 (application-test 기준)
int attemptsPerFailure = 3;
// Circuit Breaker를 OPEN 시키기 위해 필요한 실패 횟수
int failuresToOpenCircuit = 1;
// Circuit Breaker가 OPEN 되기 전까지 ExchangeFunction이 호출될 총 예상 횟수
// 각 실패는 Retry 횟수만큼 ExchangeFunction 호출을 유발
int configuredExpectedExchange = failuresToOpenCircuit * attemptsPerFailure;
// --- 현재 테스트 환경에서 관찰되는 실제 호출 횟수 ---
int actualObservedExchangeCalls = 2;
// Mock ExchangeFunction 설정: 첫 번째 논리적 호출(3회 시도) 동안 에러 응답 반환
// Mockito 체이닝으로 3번의 에러 응답 설정
when(exchangeFunction.exchange(any()))
.thenReturn(errorResponseMono, errorResponseMono, errorResponseMono);
/// when
// Circuit Breaker를 OPEN 시키기 위해 1회 실패 호출 시도
log.info("Circuit Breaker OPEN 시키기 위해 {}회 실패 호출 시도 시작", failuresToOpenCircuit);
StepVerifier.create(youtubeVideoApiAdapter.fetchVideos("fail keyword", 1))
.expectError(RetryableExternalApiException.class)
.verify(Duration.ofSeconds(1));
log.info("{}회 실패 호출 시도 완료. CB 상태 확인.", failuresToOpenCircuit);
/// then
// 1회 실패 후 Circuit Breaker 상태가 OPEN 인지 확인
CircuitBreaker circuitBreaker = registry.circuitBreaker("YOUTUBE_VIDEO_CIRCUIT_BREAKER");
assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
log.info("Circuit Breaker 상태: {}", circuitBreaker.getState());
/// when
// Circuit Breaker가 열린 상태에서 두 번째 호출 → CallNotPermittedException 발생 예상
log.info("Circuit Breaker OPEN 상태에서 두 번째 호출 시도");
StepVerifier.create(youtubeVideoApiAdapter
.fetchVideos("should be blocker", 1))
// Circuit Breaker OPEN 상태에서는 CallNotPermittedException 발생 예상
.expectError(CallNotPermittedException.class)
.verify(Duration.ofSeconds(1));
log.info("Circuit Breaker OPEN 상태에서 호출 차단 확인");
/// then
// ExchangeFunction이 Circuit Breaker OPEN 전까지 호출된 총 횟수 검증
verify(exchangeFunction, times(actualObservedExchangeCalls)).exchange(any());
}
허무하게 걍 application-test.yml에 맞게 수정해줬다..
결과 및 성능 분석
개선 후 응답 속도, DB 부하 감소 수치 등 구체적인 데이터 포함 문제 해결 전/후 성능 비교 그래프나 로그 분석 내용 포함 이슈 트래킹 및 자동화 방안 추가 (내부 개발자가 반복적인 문제 해결을 효율적으로 관리할 수 있도록 가이드 제공)
코드가 잘 통과가 됐다!
추가 개선점
유사한 문제를 방지하기 위한 주의할 점 (예: 애노테이션 명시 여부, 프레임워크 업데이트 주기 확인 등) 문제 해결 과정에서 배운 점 및 중요한 교훈 공유 보안 고려 사항 추가 (해결 과정에서 발생할 수 있는 보안 리스크 및 예방 조치 포함)
앞으로 풀 수 없는 문제가 생기면, 팀원들과 잘 소통해야겠다고 느꼈다..
'kkokkio - 프로젝트 > 트러블슈팅' 카테고리의 다른 글
JPA Query에 Pagination을 쓸 때 조심할 점(with 500 에러) (1) | 2025.06.16 |
---|---|
SecurityConfig 엔드포인트 권한 설정 (2) | 2025.06.16 |
JwtAuthenticationFilter에서 엔드포인트 허용 문제 (0) | 2025.06.16 |
@Transactional(readonly=true)가 꼭 필요한가? (0) | 2025.06.16 |
DataGrip에서 RDS(MySQL)에 SSH 터널로 안전하게 접속하기 (0) | 2025.06.16 |