배경
사내 OpenAPI에 RateLimiter를 추가하면서 테스트를 강화하고 회귀 오류를 방지하기 위해 철저한 검증을 진행했습니다. 그럼에도 불구하고 예상치 못한 장애가 발생했습니다.
회귀 오류를 방지하기 위해서 테스트 코드를 강화했음에도 불구하고 테스트에서 장애가 검출되지 않는 이유는 무엇이었을까요?
결론부터 말씀을 드리자면, 사내에서는 OpenAPI 요청 서버와 인증 서버를 분리하여 운영하고 있는데, 테스트 환경에서 네트워크 I/O 발생 부분을 Mocking 처리한 것이 원인이었습니다.
그리고, 실제 디버깅을 하면서 문제를 추적하는 과정에서 FeignClient의 @SpringQueryMap을 사용한 특정 요청이 실패한다는 사실을 발견했습니다.
개발 환경
Java Version : 14
Spring Boot Version : 2.3.11.RELEASE
Spring Cloud Version : 2.2.9.RELEASE
이슈 분석
먼저, 테스트 환경을 동일하게 재현하기 위해 다음과 같이 구조를 정의하였습니다.
코드는 https://github.com/bombo-dev/Learning-code/tree/main/001_OPEN_FEIGN 에서 확인이 가능합니다.
Feign14 : 요청 서버, External : 인증 서버 로 매핑됩니다.

API 요청 시 Query Parameter를 이용해 특정 데이터를 검색하는 로직이 있었습니다.
디버그 모드에서 인증 서버로 요청이 도착했을 때, 데이터가 정상적으로 매핑되지 않는 문제가 발생했습니다.
이때, Query Parameter를 요청 메시지를 전달하기 위한 코드는 다음과 같이 구성이 되어있었습니다.
@FeignClient(name = "externalApi", url = "${apis.endpoint.external}")
public interface ExternalHttpClient {
@GetMapping("/api/v1/externals/find")
ExternalModel getExternals(@SpringQueryMap ExternalSearchCondition externalSearchCondition);
}
@Builder
public class ExternalSearchCondition {
private Long id;
private String name;
public ExternalSearchCondition(Long id, String name) {
this.id = id;
this.name = name;
}
}
원인 분석을 위해 두 개의 서버의 호출부, 응답부 에 Debugging point를 걸고 확인을 해봤습니다.
테스트를 위해 아래 요청을 진행해봅니다.
GET http://localhost:8080/api/v1/reals?name=name1

요청 서버의 요청에서 @ModelAttribute를 통한 Query Parameter 주입은 정상적으로 이루어지고 파라미터까지 정상적으로 전달되었습니다.

응답 서버에서 전달받은 요청 파라미터를 보니 기존에 전달하던 파라미터가 null로 요청받는 것을 확인 할 수 있었습니다.
보통, 대게 Java Beans API의 방식은 Getter Or Setter를 이용하여 데이터를 전달하는 경우가 많습니다.
@SpringQueryMap이 private 필드에 접근하지 못해 데이터를 읽어오지 못하는 것인지 확인하기 위해 Getter와 Setter를 번갈아 추가해 보았습니다.
Getter 추가
@Getter
@Builder
public class ExternalSearchCondition {
private Long id;
private String name;
public ExternalSearchCondition(Long id, String name) {
this.id = id;
this.name = name;
}
}

Getter를 추가해보니 정상적으로 Query Parameter가 매핑되는 것을 확인 할 수 있습니다.
Setter 추가
@Setter
@Builder
public class ExternalSearchCondition {
private Long id;
private String name;
public ExternalSearchCondition(Long id, String name) {
this.id = id;
this.name = name;
}
}

Setter를 추가한 경우에는 생성자만 존재했을 경우와 동일하게 Query Parameter가 매핑이 정상적으로 이루어지지 않네요.
@SpringQueryMap은 Getter를 바탕으로 property binding이 이루어지는 것으로 보입니다.
작은 결론
@SpringQueryMap을 사용 할 때에는 Getter를 추가해서 사용합시다! 끝!
이라고 하기엔 너무 찝찝한 부분이 많습니다. 수정하여 배포하기 이전에는 Getter 없이도 잘 도착하고 있었으니까요.
기존에는 Getter 없이도 잘 동작했는데 왜 갑자기 Getter가 필수가 되었을까요? 이 부분을 심층 분석해봤습니다.
이슈 딥다이브
@SpringQueryMap
먼저, @SpringQueryMap의 주석을 살펴보도록 하겠습니다.

Docs를 살펴보니 Spring MVC에서도 동작하도록 OpenFeign의 QueryMap이 동작할 수 있도록 만들어진 애노테이션으로 보입니다.
QueryMap에 동작의 핵심이 숨어있을 것으로 보이네요. QueryMap이 어떻게 처리되는지 한 번 QueryMap 애노테이션으로 가봅니다.

QueryMap을 찾아가니 이번엔 다시 Param.encoded를 살펴보라고 합니다. 한 번 다시 살펴봐야겠죠.
사실, 이때까지는 애노테이션의 사용처를 검색해봤으나 그렇다할 구현체가 보이지 않았습니다.

Headers, RequestLine(URL, Query Parameter) Body, POJO를 매핑해주는 애노테이션이라고 합니다!
더군다나, 여러가지 구현체도 보이는군요! 이중에서도 눈에 띄는 부분은 BeanQueryMapEncoder와 FieldQueryMapEncoder입니다. 두 개의 구현체를 살펴보기 전에 먼저, 두 구현체의 Interface인 QueryMapEncoder를 살펴보도록 합시다.
QueryMapEncoder

QueryMapEncoder에 encode 퍼블릭 메서드가 존재하고 기본 클래스로 구현체를 확장한 주석이 있는데요.
Default로는 FieldQueryMapEncoder가 사용된다는 걸까요?
default encoder uses reflection to inspect provided objects Fields to expand the objects values into a query string.
이 부분을 읽어보니 기존에 동작했던 방식과 동일합니다. 리플렉션 방식으로 필드를 주입한다. 가 명시가 되어있습니다.
하지만, 뒤에 Getter, Setter 방식으로 동작하는 BeanQueryMapEncoder를 사용해달라는 요청이 있습니다.
우선, 동작방식이 맞는지 코드로 한 번 살펴보도록 하겠습니다.
FieldQueryMapEncoder

설명에 명시되어있는 것처럼 분명하게 저희가 알고있는 리플렉션 방식의 필드 주입임을 확인 할 수 있습니다.
이어서, BeanQueryMapEncoder는 Getter 주입이 분명한지 살펴보도록 합시다.
BeanQueryMapEncoder

확실히, Getter 를 확인하여 주입하는 방식임을 확인 할 수 있었습니다.
이슈 원인
위와 같은 근거를 통해 어느정도 원인이 유추가 됩니다. 기존에 사용되던 FieldQueryMapEncoder에서 어떤 모종의 이유로 BeanQueryMapEncoder로 변경이 되었다. 때문에, Query Parameter가 정상적으로 매핑이 되지 않았다. 라는 결론을 내려 볼 수 있을 것 같습니다. 그럼 왜 갑자기 QueryMapEncoder 구현체가 변경이 된걸까요?
QueryMapEncoder 변경 원인 분석
위 두 개의 구현체에서 encode가 되는 것을 확인했으니 디버깅 포인트를 찍고 한 번 살펴보도록 하겠습니다.

실제로, 디버깅 포인트를 찍어보니 실제 실행되는 구현체는 BeanQueryMapEncoder를 사용하고 있는 PageableSpringQueryMapEncoder를 확인 할 수 있었습니다.

SpringFramework의 Pageable과 Sort를 처리 할 수 있다면 해당 query를 매핑할 수 있도록 도와주고
없다면 BeanQueryMapEncoder의 encoder를 사용하는 방식으로 되어있네요. 그럼 실제 사용되고 있는 구현체를 찾아냈으니 언제 PageableSpringQueryMapEncoder로 주입이 된 걸까요?

feign이 호출되는 가장 상위로직으로 올라가보겠습니다.

호출의 최상위로 올라오니 다음과 같이 ReflectiveFeign이 QueryMapEncoder를 의존하고 있는 것을 볼 수 있습니다. 어디선가 ReflectiveFeign에 QueryMapEncoder를 주입해주고 있다는 것인데요. 몇 개의 퍼블릭 메서드를 살펴보니 정적 생성자 메서드 패턴으로 이루어진 newInstance를 찾아볼 수 있었습니다. 해당 영역에 디버깅 포인트를 찍고 다시 한 번 살펴보도록 합시다.

디버깅을 확인해보니 ReflectiveFeign의 부모 객체인 Feign으로 부터 출발한 것을 확인 할 수 있었습니다.


다음과 같은 코드를 Feign Builder에서 확인해볼 수 있었습니다.
그럼 Builder의 QueryMapEncoder에는 무엇이 들어있던걸까요?

초기값이 FieldQueryMapEncoder로 보입니다. 하지만 build되는 과정에서 PageableSpringQueryMapEncoder로 변경된 부분을 한 번 더 살펴 볼 수 있습니다. queryMapEncoder를 주입해주는 부분에 디버깅을 찍고 더 들어가보도록 하겠습니다.

의존성이 변경되고 있음을 재차 확인 할 수 있었으며, 이제 호출부로 가보도록 하겠습니다.

getInheritiedAwareOptional 메서드에서 QueryMapEncoder 구현체를 찾아서 builder에 의존성을 주입해주는 것을 확인 할 수 있었습니다. getInheritiedAwareOptional에 비밀이 숨어있을 것으로 보입니다.

해당 메서드는 여러 클래스 타입에 의존성을 주입해주므로 단순히 디버깅을 하게 되면 너무나도 많은 클래스를 바라보게 됩니다.
따라서, 이런 상황에 인텔리제이는 특정 조건에서의 디버깅을 할 수 있도록 Conditional Debugging을 제공해주고 있는데요. 이를 활용하여 찾아보겠습니다.

다음과 같이 찾고자 하는 Class를 명시하고 디버깅포인트를 걸어주었습니다. 또한, 현재 디버깅하고 있는 클래스가 FeignClientFactoryBean인 만큼 빈 생성을 도와줄 것이라고 예상해볼 수 있습니다. 디버깅 포인트부터 출발하여 빈을 찾는 곳까지 Step Into를 통해서 추적해봤습니다.

위는 DefaultListableBeanFactory.resolveNamedBean() 메서드의 일부입니다.
실제로 현재, feignQueryMapEncoderPageable 이라는 이름으로 한 개의 빈이 존재하는 것으로 보이네요. 해당 빈에는 위에서 저희가 찾았던 PageableSpringQueryMapEncoder를 반환해줄 것이라고 유추해볼 수 있습니다. 해당 빈을 한 번 전체 프로젝트에서 검색해봤습니다.

찾았습니다! FeignClientsConfiguration에서 PageableSpringQueryMapEncoder를 주입해주고 있었네요! 그런데, 기본적인 주입이 아닌 스프링 부트에서 지원하는 @Conditional 이 있습니다. 여기서 명시 된 두 가지만 간단하게 소개하겠습니다.
@ConditionalOnClass
@Conditional that only matches when the specified classes are on the classpath
위는 @ConditionalOnClass 주석의 일부입니다. 명시된 것처럼 특정 클래스가 클래스패스에 있을때에만 빈을 등록하도록 하는 애노테이션입니다. 즉, 위의 예시에서는 org.springframework.data.domain.Pageable 클래스가 존재해야만 빈이 등록되는 것이지요.
@ConditionalOnMissingBean
다음으로는, ConditionalOnMissingBean 입니다.
@Conditional that only matches when no beans meeting the specified requirements are already contained in the BeanFactory
위는 @ConditionalOnMissingBean 주석의 일부입니다. 특정 조건을 만족하는 빈이 BeanFactory에 존재하지 않을 경우에만 적용되는 애노테이션입니다. 즉, 위에 @ConditionalOnClass를 만족하고, 그 어떠한 빈도 존재하지 않는다면 PageableSpringQueryMapEncoder가 주입되는 것입니다.
다시, 맨 처음의 배경으로 올라가보도록 하겠습니다.
사내 OpenAPI에 RateLimiter를 추가하면서 테스트를 강화하고 회귀 오류를 방지하기 위해 철저한 검증을 진행했습니다.
OpenAPI 서버가 분산 서버로 이루어져있기 때문에 RateLimiter를 도입하기 위해서 redis를 사용했습니다. 이를 위해서, 다음과 같은 의존성을 추가하였습니다.
implementation("org.springframework.boot:spring-boot-starter-data-redis")
그렇습니다. 이 의존성 주입이 원인이었습니다. 기존에는 org.springframework.data 이 의존 주입되는 패키지가 없었고 자연스럽게
org.springframework.data.domain.Pageable 도 존재하지 않았습니다. 그러나, 해당 라이브러리 의존성을 추가해주면서 생기게 되었고 Configuration Condition에 따라 의존성이 변경 된 것입니다.
장애 원인 정리
1️⃣ RateLimiter 적용을 위해 Redis 의존성을 추가
2️⃣ Spring Data Pageable이 함께 포함되면서 PageableSpringQueryMapEncoder가 주입
3️⃣ 기존의 FieldQueryMapEncoder → PageableSpringQueryMapEncoder로 변경됨
4️⃣ Getter 없는 필드는 Query Parameter로 변환되지 않음
이로 인해 기존에 문제없이 동작하던 코드가 장애를 일으키게 되었습니다.
해결 방법 및 회고
위와 같은 장애를 해소하기 위해서는 앞으로는 다음과 같이 세 가지 해결방안을 검토해 볼 수 있을 것 같습니다.
해결 방법
1️⃣ 해결방법 : 빈 주입 명시적으로 사용하기
@Configuration
public class FeignClientConfiguraiton {
@Bean
public QueryMapEncoder defaultQueryMapEncoder() {
return new PageableSpringQueryMapEncoder();
}
}
2️⃣ 해결방법 : @SpringQueryMap을 사용하는 Object에 Getter를 추가하기.
public class HttpRequest {
private String property;
public String getProperty() {
return this.property;
}
}
3️⃣ 해결방법 : E2E 테스트를 보강하기. 즉, 실제 HTTP 통신을 테스트 코드에 추가하기
회고
이번 원인 분석을 하면서, 어떻게 하면 이런 상황을 미리 방지할 수 있었을까? 여러 차례 시뮬레이션을 돌려보며 고민해봤습니다.
- 만약 QueryMapEncoder 인터페이스를 미리 확인했더라면?
- 실제 HTTP 통신을 통해 한 번이라도 E2E 테스트를 진행했다면?
하지만 다시 되돌아가더라도, 사전에 이 같은 상황이 발생할 것이라고 예측하는 건 쉽지 않았을 것 같습니다.
다만, 한 가지 예방할 수 있는 방법이 있었다면 개발 서버에 배포하여 실제 인증 절차를 거쳐보는 것이었겠죠.
이번 경험을 계기로 배포 전에는 더욱 신중하고 꼼꼼하게 점검해야 한다는 점을 다시 한번 되새기게 되었습니다.
원인을 파악하는 과정이 쉽지는 않았습니다. 결론에 도달했을 때는 너무 당황스러웠어요. "이런 방식으로도 장애가 발생할 수 있다니!"
라이브러리 의존성이란, 때로는 예상치 못한 방식으로 동작하며 예기치 않은 문제를 일으킬 수 있다는 점을 다시금 실감했습니다.
하지만, 이 과정에서 값진 경험을 얻을 수 있었습니다.
1️⃣ 디버깅 활용 능력
2️⃣ 스프링 빈 주입의 태초까지
만약 이번 장애가 없었다면, 이런 경험을 하기 어려웠을지도 모릅니다. 덕분에 한 단계 성장할 수 있었고, 앞으로 같은 실수를 반복하지 않을 자신감도 생겼습니다. 장애를 파악하는 과정이 쉽지 않았기에 현재 OpenFeign의 버전에서도 동일한 이슈가 있다면, QueryMapEncoder 인터페이스의 문서를 보완할 것을 건의해보고자 합니다.
마지막으로, 최근 지인이 들려준 말을 떠올리며 이 장애일지를 마무리합니다.
장애도 열심히 일하는 사람이 내는 것이다.
ps. 말 나온김에 바로 해버렸다.

비록, 문서를 수정하는 정도이지만... 오픈소스에 PR 올려보는 건 처음이라 가슴이 두근두근하다...!!!
'Backend > Spring' 카테고리의 다른 글
새어나가는 Data Transfer 비용을 잡자! (2) | 2025.01.19 |
---|---|
막내 개발자의 사내 분산 락 라이브러리 도입이야기 (1) (2) | 2024.11.10 |
Spring Security 5 -> Spring Security 6 에서의 Session 변경점 (0) | 2023.08.20 |
Spring Security OAuth2 주요 용어와 인증 방식 (0) | 2023.08.14 |
Invalid character found in method name 에러 (2) | 2023.05.07 |
배경
사내 OpenAPI에 RateLimiter를 추가하면서 테스트를 강화하고 회귀 오류를 방지하기 위해 철저한 검증을 진행했습니다. 그럼에도 불구하고 예상치 못한 장애가 발생했습니다.
회귀 오류를 방지하기 위해서 테스트 코드를 강화했음에도 불구하고 테스트에서 장애가 검출되지 않는 이유는 무엇이었을까요?
결론부터 말씀을 드리자면, 사내에서는 OpenAPI 요청 서버와 인증 서버를 분리하여 운영하고 있는데, 테스트 환경에서 네트워크 I/O 발생 부분을 Mocking 처리한 것이 원인이었습니다.
그리고, 실제 디버깅을 하면서 문제를 추적하는 과정에서 FeignClient의 @SpringQueryMap을 사용한 특정 요청이 실패한다는 사실을 발견했습니다.
개발 환경
Java Version : 14
Spring Boot Version : 2.3.11.RELEASE
Spring Cloud Version : 2.2.9.RELEASE
이슈 분석
먼저, 테스트 환경을 동일하게 재현하기 위해 다음과 같이 구조를 정의하였습니다.
코드는 https://github.com/bombo-dev/Learning-code/tree/main/001_OPEN_FEIGN 에서 확인이 가능합니다.
Feign14 : 요청 서버, External : 인증 서버 로 매핑됩니다.

API 요청 시 Query Parameter를 이용해 특정 데이터를 검색하는 로직이 있었습니다.
디버그 모드에서 인증 서버로 요청이 도착했을 때, 데이터가 정상적으로 매핑되지 않는 문제가 발생했습니다.
이때, Query Parameter를 요청 메시지를 전달하기 위한 코드는 다음과 같이 구성이 되어있었습니다.
@FeignClient(name = "externalApi", url = "${apis.endpoint.external}")
public interface ExternalHttpClient {
@GetMapping("/api/v1/externals/find")
ExternalModel getExternals(@SpringQueryMap ExternalSearchCondition externalSearchCondition);
}
@Builder
public class ExternalSearchCondition {
private Long id;
private String name;
public ExternalSearchCondition(Long id, String name) {
this.id = id;
this.name = name;
}
}
원인 분석을 위해 두 개의 서버의 호출부, 응답부 에 Debugging point를 걸고 확인을 해봤습니다.
테스트를 위해 아래 요청을 진행해봅니다.
GET http://localhost:8080/api/v1/reals?name=name1

요청 서버의 요청에서 @ModelAttribute를 통한 Query Parameter 주입은 정상적으로 이루어지고 파라미터까지 정상적으로 전달되었습니다.

응답 서버에서 전달받은 요청 파라미터를 보니 기존에 전달하던 파라미터가 null로 요청받는 것을 확인 할 수 있었습니다.
보통, 대게 Java Beans API의 방식은 Getter Or Setter를 이용하여 데이터를 전달하는 경우가 많습니다.
@SpringQueryMap이 private 필드에 접근하지 못해 데이터를 읽어오지 못하는 것인지 확인하기 위해 Getter와 Setter를 번갈아 추가해 보았습니다.
Getter 추가
@Getter
@Builder
public class ExternalSearchCondition {
private Long id;
private String name;
public ExternalSearchCondition(Long id, String name) {
this.id = id;
this.name = name;
}
}

Getter를 추가해보니 정상적으로 Query Parameter가 매핑되는 것을 확인 할 수 있습니다.
Setter 추가
@Setter
@Builder
public class ExternalSearchCondition {
private Long id;
private String name;
public ExternalSearchCondition(Long id, String name) {
this.id = id;
this.name = name;
}
}

Setter를 추가한 경우에는 생성자만 존재했을 경우와 동일하게 Query Parameter가 매핑이 정상적으로 이루어지지 않네요.
@SpringQueryMap은 Getter를 바탕으로 property binding이 이루어지는 것으로 보입니다.
작은 결론
@SpringQueryMap을 사용 할 때에는 Getter를 추가해서 사용합시다! 끝!
이라고 하기엔 너무 찝찝한 부분이 많습니다. 수정하여 배포하기 이전에는 Getter 없이도 잘 도착하고 있었으니까요.
기존에는 Getter 없이도 잘 동작했는데 왜 갑자기 Getter가 필수가 되었을까요? 이 부분을 심층 분석해봤습니다.
이슈 딥다이브
@SpringQueryMap
먼저, @SpringQueryMap의 주석을 살펴보도록 하겠습니다.

Docs를 살펴보니 Spring MVC에서도 동작하도록 OpenFeign의 QueryMap이 동작할 수 있도록 만들어진 애노테이션으로 보입니다.
QueryMap에 동작의 핵심이 숨어있을 것으로 보이네요. QueryMap이 어떻게 처리되는지 한 번 QueryMap 애노테이션으로 가봅니다.

QueryMap을 찾아가니 이번엔 다시 Param.encoded를 살펴보라고 합니다. 한 번 다시 살펴봐야겠죠.
사실, 이때까지는 애노테이션의 사용처를 검색해봤으나 그렇다할 구현체가 보이지 않았습니다.

Headers, RequestLine(URL, Query Parameter) Body, POJO를 매핑해주는 애노테이션이라고 합니다!
더군다나, 여러가지 구현체도 보이는군요! 이중에서도 눈에 띄는 부분은 BeanQueryMapEncoder와 FieldQueryMapEncoder입니다. 두 개의 구현체를 살펴보기 전에 먼저, 두 구현체의 Interface인 QueryMapEncoder를 살펴보도록 합시다.
QueryMapEncoder

QueryMapEncoder에 encode 퍼블릭 메서드가 존재하고 기본 클래스로 구현체를 확장한 주석이 있는데요.
Default로는 FieldQueryMapEncoder가 사용된다는 걸까요?
default encoder uses reflection to inspect provided objects Fields to expand the objects values into a query string.
이 부분을 읽어보니 기존에 동작했던 방식과 동일합니다. 리플렉션 방식으로 필드를 주입한다. 가 명시가 되어있습니다.
하지만, 뒤에 Getter, Setter 방식으로 동작하는 BeanQueryMapEncoder를 사용해달라는 요청이 있습니다.
우선, 동작방식이 맞는지 코드로 한 번 살펴보도록 하겠습니다.
FieldQueryMapEncoder

설명에 명시되어있는 것처럼 분명하게 저희가 알고있는 리플렉션 방식의 필드 주입임을 확인 할 수 있습니다.
이어서, BeanQueryMapEncoder는 Getter 주입이 분명한지 살펴보도록 합시다.
BeanQueryMapEncoder

확실히, Getter 를 확인하여 주입하는 방식임을 확인 할 수 있었습니다.
이슈 원인
위와 같은 근거를 통해 어느정도 원인이 유추가 됩니다. 기존에 사용되던 FieldQueryMapEncoder에서 어떤 모종의 이유로 BeanQueryMapEncoder로 변경이 되었다. 때문에, Query Parameter가 정상적으로 매핑이 되지 않았다. 라는 결론을 내려 볼 수 있을 것 같습니다. 그럼 왜 갑자기 QueryMapEncoder 구현체가 변경이 된걸까요?
QueryMapEncoder 변경 원인 분석
위 두 개의 구현체에서 encode가 되는 것을 확인했으니 디버깅 포인트를 찍고 한 번 살펴보도록 하겠습니다.

실제로, 디버깅 포인트를 찍어보니 실제 실행되는 구현체는 BeanQueryMapEncoder를 사용하고 있는 PageableSpringQueryMapEncoder를 확인 할 수 있었습니다.

SpringFramework의 Pageable과 Sort를 처리 할 수 있다면 해당 query를 매핑할 수 있도록 도와주고
없다면 BeanQueryMapEncoder의 encoder를 사용하는 방식으로 되어있네요. 그럼 실제 사용되고 있는 구현체를 찾아냈으니 언제 PageableSpringQueryMapEncoder로 주입이 된 걸까요?

feign이 호출되는 가장 상위로직으로 올라가보겠습니다.

호출의 최상위로 올라오니 다음과 같이 ReflectiveFeign이 QueryMapEncoder를 의존하고 있는 것을 볼 수 있습니다. 어디선가 ReflectiveFeign에 QueryMapEncoder를 주입해주고 있다는 것인데요. 몇 개의 퍼블릭 메서드를 살펴보니 정적 생성자 메서드 패턴으로 이루어진 newInstance를 찾아볼 수 있었습니다. 해당 영역에 디버깅 포인트를 찍고 다시 한 번 살펴보도록 합시다.

디버깅을 확인해보니 ReflectiveFeign의 부모 객체인 Feign으로 부터 출발한 것을 확인 할 수 있었습니다.


다음과 같은 코드를 Feign Builder에서 확인해볼 수 있었습니다.
그럼 Builder의 QueryMapEncoder에는 무엇이 들어있던걸까요?

초기값이 FieldQueryMapEncoder로 보입니다. 하지만 build되는 과정에서 PageableSpringQueryMapEncoder로 변경된 부분을 한 번 더 살펴 볼 수 있습니다. queryMapEncoder를 주입해주는 부분에 디버깅을 찍고 더 들어가보도록 하겠습니다.

의존성이 변경되고 있음을 재차 확인 할 수 있었으며, 이제 호출부로 가보도록 하겠습니다.

getInheritiedAwareOptional 메서드에서 QueryMapEncoder 구현체를 찾아서 builder에 의존성을 주입해주는 것을 확인 할 수 있었습니다. getInheritiedAwareOptional에 비밀이 숨어있을 것으로 보입니다.

해당 메서드는 여러 클래스 타입에 의존성을 주입해주므로 단순히 디버깅을 하게 되면 너무나도 많은 클래스를 바라보게 됩니다.
따라서, 이런 상황에 인텔리제이는 특정 조건에서의 디버깅을 할 수 있도록 Conditional Debugging을 제공해주고 있는데요. 이를 활용하여 찾아보겠습니다.

다음과 같이 찾고자 하는 Class를 명시하고 디버깅포인트를 걸어주었습니다. 또한, 현재 디버깅하고 있는 클래스가 FeignClientFactoryBean인 만큼 빈 생성을 도와줄 것이라고 예상해볼 수 있습니다. 디버깅 포인트부터 출발하여 빈을 찾는 곳까지 Step Into를 통해서 추적해봤습니다.

위는 DefaultListableBeanFactory.resolveNamedBean() 메서드의 일부입니다.
실제로 현재, feignQueryMapEncoderPageable 이라는 이름으로 한 개의 빈이 존재하는 것으로 보이네요. 해당 빈에는 위에서 저희가 찾았던 PageableSpringQueryMapEncoder를 반환해줄 것이라고 유추해볼 수 있습니다. 해당 빈을 한 번 전체 프로젝트에서 검색해봤습니다.

찾았습니다! FeignClientsConfiguration에서 PageableSpringQueryMapEncoder를 주입해주고 있었네요! 그런데, 기본적인 주입이 아닌 스프링 부트에서 지원하는 @Conditional 이 있습니다. 여기서 명시 된 두 가지만 간단하게 소개하겠습니다.
@ConditionalOnClass
@Conditional that only matches when the specified classes are on the classpath
위는 @ConditionalOnClass 주석의 일부입니다. 명시된 것처럼 특정 클래스가 클래스패스에 있을때에만 빈을 등록하도록 하는 애노테이션입니다. 즉, 위의 예시에서는 org.springframework.data.domain.Pageable 클래스가 존재해야만 빈이 등록되는 것이지요.
@ConditionalOnMissingBean
다음으로는, ConditionalOnMissingBean 입니다.
@Conditional that only matches when no beans meeting the specified requirements are already contained in the BeanFactory
위는 @ConditionalOnMissingBean 주석의 일부입니다. 특정 조건을 만족하는 빈이 BeanFactory에 존재하지 않을 경우에만 적용되는 애노테이션입니다. 즉, 위에 @ConditionalOnClass를 만족하고, 그 어떠한 빈도 존재하지 않는다면 PageableSpringQueryMapEncoder가 주입되는 것입니다.
다시, 맨 처음의 배경으로 올라가보도록 하겠습니다.
사내 OpenAPI에 RateLimiter를 추가하면서 테스트를 강화하고 회귀 오류를 방지하기 위해 철저한 검증을 진행했습니다.
OpenAPI 서버가 분산 서버로 이루어져있기 때문에 RateLimiter를 도입하기 위해서 redis를 사용했습니다. 이를 위해서, 다음과 같은 의존성을 추가하였습니다.
implementation("org.springframework.boot:spring-boot-starter-data-redis")
그렇습니다. 이 의존성 주입이 원인이었습니다. 기존에는 org.springframework.data 이 의존 주입되는 패키지가 없었고 자연스럽게
org.springframework.data.domain.Pageable 도 존재하지 않았습니다. 그러나, 해당 라이브러리 의존성을 추가해주면서 생기게 되었고 Configuration Condition에 따라 의존성이 변경 된 것입니다.
장애 원인 정리
1️⃣ RateLimiter 적용을 위해 Redis 의존성을 추가
2️⃣ Spring Data Pageable이 함께 포함되면서 PageableSpringQueryMapEncoder가 주입
3️⃣ 기존의 FieldQueryMapEncoder → PageableSpringQueryMapEncoder로 변경됨
4️⃣ Getter 없는 필드는 Query Parameter로 변환되지 않음
이로 인해 기존에 문제없이 동작하던 코드가 장애를 일으키게 되었습니다.
해결 방법 및 회고
위와 같은 장애를 해소하기 위해서는 앞으로는 다음과 같이 세 가지 해결방안을 검토해 볼 수 있을 것 같습니다.
해결 방법
1️⃣ 해결방법 : 빈 주입 명시적으로 사용하기
@Configuration
public class FeignClientConfiguraiton {
@Bean
public QueryMapEncoder defaultQueryMapEncoder() {
return new PageableSpringQueryMapEncoder();
}
}
2️⃣ 해결방법 : @SpringQueryMap을 사용하는 Object에 Getter를 추가하기.
public class HttpRequest {
private String property;
public String getProperty() {
return this.property;
}
}
3️⃣ 해결방법 : E2E 테스트를 보강하기. 즉, 실제 HTTP 통신을 테스트 코드에 추가하기
회고
이번 원인 분석을 하면서, 어떻게 하면 이런 상황을 미리 방지할 수 있었을까? 여러 차례 시뮬레이션을 돌려보며 고민해봤습니다.
- 만약 QueryMapEncoder 인터페이스를 미리 확인했더라면?
- 실제 HTTP 통신을 통해 한 번이라도 E2E 테스트를 진행했다면?
하지만 다시 되돌아가더라도, 사전에 이 같은 상황이 발생할 것이라고 예측하는 건 쉽지 않았을 것 같습니다.
다만, 한 가지 예방할 수 있는 방법이 있었다면 개발 서버에 배포하여 실제 인증 절차를 거쳐보는 것이었겠죠.
이번 경험을 계기로 배포 전에는 더욱 신중하고 꼼꼼하게 점검해야 한다는 점을 다시 한번 되새기게 되었습니다.
원인을 파악하는 과정이 쉽지는 않았습니다. 결론에 도달했을 때는 너무 당황스러웠어요. "이런 방식으로도 장애가 발생할 수 있다니!"
라이브러리 의존성이란, 때로는 예상치 못한 방식으로 동작하며 예기치 않은 문제를 일으킬 수 있다는 점을 다시금 실감했습니다.
하지만, 이 과정에서 값진 경험을 얻을 수 있었습니다.
1️⃣ 디버깅 활용 능력
2️⃣ 스프링 빈 주입의 태초까지
만약 이번 장애가 없었다면, 이런 경험을 하기 어려웠을지도 모릅니다. 덕분에 한 단계 성장할 수 있었고, 앞으로 같은 실수를 반복하지 않을 자신감도 생겼습니다. 장애를 파악하는 과정이 쉽지 않았기에 현재 OpenFeign의 버전에서도 동일한 이슈가 있다면, QueryMapEncoder 인터페이스의 문서를 보완할 것을 건의해보고자 합니다.
마지막으로, 최근 지인이 들려준 말을 떠올리며 이 장애일지를 마무리합니다.
장애도 열심히 일하는 사람이 내는 것이다.
ps. 말 나온김에 바로 해버렸다.

비록, 문서를 수정하는 정도이지만... 오픈소스에 PR 올려보는 건 처음이라 가슴이 두근두근하다...!!!
'Backend > Spring' 카테고리의 다른 글
새어나가는 Data Transfer 비용을 잡자! (2) | 2025.01.19 |
---|---|
막내 개발자의 사내 분산 락 라이브러리 도입이야기 (1) (2) | 2024.11.10 |
Spring Security 5 -> Spring Security 6 에서의 Session 변경점 (0) | 2023.08.20 |
Spring Security OAuth2 주요 용어와 인증 방식 (0) | 2023.08.14 |
Invalid character found in method name 에러 (2) | 2023.05.07 |