본문 바로가기
Spring

Spring Cloud OpenFeign의 child context가 내 ErrorDecoder를 조용히 죽이고 있었다

by Ramos 2026. 4. 19.

Spring Cloud OpenFeign 4.x + Spring Boot 3.x 환경에서 Feign Client별 ErrorDecoder가 예고 없이 무력화되는 문제를 만났다. 원인은 child ApplicationContext의 빈 오버라이딩 정책이었고, 해결은 단순했지만 "왜 그런지"를 이해하는 데 시간이 걸렸다. 같은 길을 걷는 분들을 위해 기록을 남긴다.

  • Spring Cloud OpenFeign은 Feign Client마다 격리된 child ApplicationContext를 만든다.
  • Spring Boot 3.x부터 allowBeanDefinitionOverriding = false가 기본값이라, default 설정의 ErrorDecoder가 먼저 올라오면 per-client ErrorDecoder가 그걸 못 덮는다.
  • 심지어 충돌하지 않고 "default가 이겨서 도메인 ErrorDecoder가 조용히 무시" 되는 경우도 있다. 로그도 안 나온다.
  • 해답은 두 가지.
    1. defaultConfiguration으로 넘기는 클래스에는 @Configuration을 절대 붙이지 말 것.
    2. BaseFeignConfig에는 default ErrorDecoder를 두지 말 것. 도메인 예외 매핑이 필요한 client에만 per-client로 등록한다.
  • raw FeignException@ControllerAdvice에서 마지막 안전망으로 처리하면 충분하다.

1. 배경: 어느 날 도메인 예외가 사라졌다

사내 백엔드는 Spring Boot 3.x, Spring Cloud 2025.0.0, spring-cloud-starter-openfeign 4.x를 쓰고 있다. 여러 외부/내부 API를 Feign Client로 감싸두었고, 그중 Gitea와 Argo는 응답 body를 파싱해 도메인 예외로 올려주는 전용 ErrorDecoder를 가지고 있었다.

@FeignClient(
    name = "gitea",
    configuration = [GiteaFeignConfig::class]
)
interface GiteaFeignClient { /* ... */ }

그런데 어느 시점부터 Gitea가 404를 뱉어도 GiteaNotFoundException이 아니라 날것의 FeignException이 컨트롤러까지 올라왔다. 분명 GiteaErrorDecoder는 빈으로 등록되어 있었고, 테스트에서는 잘 동작했다. 운영 환경에서만 조용히 무력화되고 있었다.

2. 원인: child context와 빈 오버라이딩

Spring Cloud OpenFeign은 클라이언트마다 별도의 child ApplicationContext를 만든다. 공식 레퍼런스의 Feign Configuration 섹션에 작게 쓰여 있는 이 한 문장이 핵심이다.

A central concept in Spring Cloud's Feign support is that of the named client. Each feign client is part of an ensemble of components that work together to contact a remote server on demand, and the ensemble has a name ... Spring Cloud creates a new ensemble as an ApplicationContext on demand for each named client.

즉, @FeignClient(name = "gitea")"gitea"라는 이름의 child context를 하나 만들고, 그 안에서 Decoder, Encoder, ErrorDecoder, RequestInterceptor 등을 찾는다. 부모 context의 빈은 상속되지 않는다.

여기에 두 가지 함정이 겹친다.

함정 1) @EnableFeignClients(defaultConfiguration = ...)@Configuration을 붙이면 양쪽에서 등록된다

defaultConfiguration에 넘기는 클래스에 @Configuration을 붙이면, 그 클래스는 컴포넌트 스캔 대상이 되어 부모 context에도 등록되고, 동시에 모든 Feign Client의 child context에도 defaultConfiguration으로 적용된다. 부모와 자식에서 같은 이름의 빈이 중복 등록되는 셈이다.

함정 2) Spring Boot 3.x의 allowBeanDefinitionOverriding = false

Spring Boot 2.1부터 기본값이 false로 바뀌었고, 3.x에서도 그대로다. child context의 빈 등록 순서에 따라 먼저 올라온 default ErrorDecoder가 남고, 뒤이어 등록되려던 per-client ErrorDecoder가 조용히 무시된다. 오버라이딩이 금지된 상태에서 "같은 이름이면 이기는 쪽"이 구현/버전에 따라 갈리는데, 내 경우 default가 이겼다.

요약하면 이런 그림이다.

  • BaseFeignConfig@Configuration을 붙이지 않는다. defaultConfiguration으로만 주입되어 child context에서만 동작한다.
  • BaseFeignConfig에는 default ErrorDecoder를 두지 않는다. 이게 있으면 per-client decoder를 잡아먹는다.
  • 로깅 레벨, 공통 RequestInterceptor처럼 전 클라이언트 공통이면서 충돌 가능성이 없는 것만 base에 남긴다.

3. 공식 레퍼런스가 (작게) 경고하고 있는 것

Spring Cloud OpenFeign 4.x 레퍼런스의 "Feign Configuration" 섹션에 이런 문구가 있다.

As mentioned above, the configuration class passed to FeignClientsConfiguration should not be annotated with @Configuration. If it is, it will be a source of component scan for every application context in the application.

짧게 한 줄이지만 이번 문제의 본질을 그대로 담고 있다. @Configuration을 붙이면 default 설정이 부모 context에도 등록되어 Feign과 무관한 곳에서까지 살아나고, child context에서는 중복 등록으로 per-client 설정을 덮어쓸 수 있다.

여기에 Spring Boot 3.x의 기본값(allowBeanDefinitionOverriding = false)이 합쳐지면서, "덮어쓰기 에러"조차 나지 않고 그냥 먼저 등록된 쪽이 이기는 상태가 된다. 로그도 없고, 기동도 정상이고, 테스트는 통과한다. 디버깅 난이도가 은근히 높은 이유다.

4. 해결: "default ErrorDecoder를 두지 않는다"

한 줄짜리 원칙이 가장 효과적이었다.

BaseFeignConfig에는 ErrorDecoder를 정의하지 않는다. 도메인 예외 매핑이 필요한 client에만 per-client로 등록한다.

그리고 그 외의 raw FeignException은 글로벌 예외 핸들러가 마지막 안전망으로 받아낸다.

구분 예시 Client ErrorDecoder 동작
도메인 예외 매핑 필요 Gitea, Argo *ErrorDecoder per-client body 파싱 → 도메인 예외 throw
단순 전파 Harbor, Keycloak, Prometheus 등 없음 ErrorDecoder.Default → raw FeignException

"단순 전파" 그룹은 child context에 아무 ErrorDecoder도 없어서 Feign 내부 기본값(ErrorDecoder.Default)이 적용된다. 컨트롤러까지 raw FeignException이 올라오고, 거기서 @ControllerAdvice가 공통 포맷으로 변환한다.

핵심은 FeignException.commonResponseBodyOrNull() 같은 방어적인 확장함수다.

  • body가 사내 표준 응답 포맷일 때만 파싱해서 돌려준다.
  • 외부 API처럼 포맷이 다르면 null을 돌려 502 fallback으로 안전하게 빠진다.
  • JSON 파싱 예외, contentUTF8() 실패 같은 것도 모두 null로 흡수한다. 예외 처리 도중 또 예외가 터지는 상황을 피하려는 장치다.
fun FeignException.commonResponseBodyOrNull(): CommonResponse<*>? =
    runCatching {
        val raw = contentUTF8().takeIf { it.isNotBlank() } ?: return null
        objectMapper.readValue(raw, CommonResponse::class.java)
            .takeIf { it.header != null }
    }.getOrNull()

5. 덤: "살아있는 줄 알았던 dead code"

이번 정리 과정에서 BaseFeignErrorDecoder라는 클래스가 어떤 Feign Client의 configuration에도 연결되어 있지 않은 상태로 남아 있는 걸 발견했다. 실제로는 인스턴스화되지도 않는 dead code였는데, 같은 파일에 commonResponseBodyOrNull 확장함수가 top-level로 함께 있어서 "이 decoder가 살아있다"는 착각을 만들고 있었다.

  • BaseFeignErrorDecoder.kt 파일은 삭제.
  • 사용 중인 확장함수만 FeignExceptionExtensions.kt로 이사.
  • BaseFeignConfig의 KDoc에 남아 있던 [BaseFeignErrorDecoder] 링크 정리.

이렇게 해두면 다음 사람이 이 코드를 봤을 때 "default ErrorDecoder는 존재하지 않는다"는 사실이 파일 구조만으로도 드러난다. 주석이 아니라 구조로 의도를 드러내는 편이 언제나 낫다.

6. 새 Feign Client를 추가할 때의 체크리스트

  1. *FeignConfig 클래스에 @Configuration을 붙이지 않거나 컴포넌트 스캔 범위 밖 패키지에 둔다.
  2. 인증 헤더가 필요하면 @Bean RequestInterceptor로 주입한다. 인증 토큰을 client 인터페이스의 파라미터로 받지 않는다.
  3. 도메인 예외 매핑이 필요하면 해당 client에만 @Bean ErrorDecoder를 정의한다. 필요 없으면 생략.
  4. BaseFeignConfig에는 절대 ErrorDecoder를 추가하지 않는다.
  5. 외부 API(사내 표준 응답 포맷이 아닌 경우)를 호출한다면, 글로벌 핸들러가 502로 빠지는 경로가 정상임을 인지한다.
  6. feign.client.config.<name>.connectTimeout / readTimeout을 명시적으로 지정한다. HC5 기본값에 의존하지 않는다.
  7. 재시도, 서킷 브레이커가 필요하면 spring-cloud-starter-circuitbreaker-resilience4j로 분리한다. ErrorDecoder는 회복 정책이 아니다.

7. 운영/관측성 한 마디

  • 로깅: BaseFeignConfig에서 Logger.Level.BASIC + 커스텀 feignLoggingInterceptor를 걸어, body는 2000자까지만 남기고 Authorization***로 마스킹한다. 민감 헤더가 로그에 새는 사고는 대부분 "편의상 잠깐" 만든 디버그 로그에서 터진다.
  • traceId: raw FeignException은 글로벌 핸들러에서 log.error로 남는다. MDC에 traceId가 이미 들어가 있어서 알람 → 로그 → 분산 트레이싱으로 자연스럽게 이어진다.
  • 회복성: timeout / retry / circuit breaker를 ErrorDecoder로 구현하지 않는다. 역할이 다르다.

8. 마치며

이번 이슈의 재미있는 점은, 기술 문서가 이미 경고하고 있었지만 한 줄짜리 주의사항이었다는 것이다. 개발환경에서 뜬금없이 조용히 터지고 나서야 그 한 줄의 무게가 보였다. 교훈은 평소에 하는 말과 같다.

  • child ApplicationContext처럼 "격리"가 기본값인 구조에서는 "부모의 빈이 어떻게든 작동하겠지"라는 기대를 버리자.
  • @Configuration은 생각보다 넓게 작동하는 어노테이션이다. "컴포넌트 스캔에 포함된다"는 말을 가볍게 넘기지 말자.
  • 의도를 주석이 아니라 파일 구조와 빈 정의로 드러내자. dead code가 살아있는 코드를 흉내 낼 여지를 주면, 다음 사람(또는 6개월 뒤의 나)이 반드시 속는다.
  • 마지막 안전망은 @ControllerAdvice. per-client decoder가 실패하거나 존재하지 않아도 사용자에게 나가는 응답 포맷은 일관되어야 한다.

누군가 비슷한 증상("per-client ErrorDecoder를 분명히 등록했는데 안 탄다")을 겪고 있다면, 가장 먼저 의심해야 할 것은 default 설정 클래스의 @Configuration 어노테이션BaseFeignConfig에 혹시 숨어 있는 default ErrorDecoder다. 경험담이다.


참고

  • Spring Cloud OpenFeign Reference (4.x) — Feign Configuration / Creating Feign Clients
  • Spring Boot Reference — spring.main.allow-bean-definition-overriding의 기본값 변경 배경
  • feign-core — ErrorDecoder.Default의 동작