Spring Cloud OpenFeign 실전 가이드

Spring Cloud OpenFeign은 인터페이스 한 줄로 외부 서비스 호출을 끝내 주는 도구지만, 실무 운영에 들어가는 순간 타임아웃·재시도·Fallback 3가지를 어떻게 설계했느냐가 서비스 안정성을 결정합니다. 도입 직후에는 깔끔해 보이던 코드가, 외부 서비스 한 곳이 잠시 멈추는 순간 우리 서비스까지 무너지는 경험은 거의 모든 팀이 한 번씩은 겪습니다.

이 글에서는 OpenFeign의 기본 동작을 빠르게 짚고, 운영에서 반드시 챙겨야 할 3가지(타임아웃·재시도·Fallback) 와 함께 자주 마주치는 5가지 함정까지 실전 코드 예시로 정리합니다. 외부 연동을 다수 운영 중이라면 한 번씩 점검해 보시기 바랍니다.


OpenFeign 기본 동작 다시 보기

OpenFeign은 선언적 HTTP 클라이언트입니다. 인터페이스를 정의하고 어노테이션만 붙이면, Spring Cloud가 런타임에 구현체를 만들어 빈으로 등록합니다.

@FeignClient(name = "customer-service", path = "/api/customers")
public interface CustomerClient {

    @GetMapping("/{id}")
    Customer findById(@PathVariable String id);

    @PostMapping
    Customer create(@RequestBody CustomerRequest request);
}

이 한 인터페이스로 다음이 자동 처리됩니다.

항목자동 처리 내용
서비스 디스커버리customer-service를 등록된 인스턴스로 매핑
로드 밸런싱Spring Cloud LoadBalancer가 라운드로빈 분배
JSON 직렬화/역직렬화Spring MVC와 같은 Jackson 설정 사용
HTTP 메서드 매핑@GetMapping·@PostMapping 그대로 인식

호출 측에서는 마치 로컬 메서드를 부르는 것처럼 사용할 수 있어, 외부 호출 코드가 비즈니스 로직을 가리지 않습니다. 단, 이 편리함 뒤에 숨은 기본값들이 운영 사고의 시작점이 됩니다.


타임아웃 기본값을 절대 믿지 말자

운영 사고 사례

외부 결제 서비스가 평소 100ms 응답에서 어느 날 5초까지 늘어졌습니다. 우리 측 OpenFeign 호출 코드는 타임아웃을 명시적으로 설정하지 않은 상태였습니다. 결과적으로 다음이 발생했습니다.

  • 결제 호출이 5초간 블로킹 → 스레드 1개 점유 지속
  • 동시 요청 50건이 모두 5초 대기 → 톰캣 스레드 풀(기본 200) 빠르게 소진
  • 결제 API가 아닌 다른 API까지 전부 응답 불가 → 전체 서비스 다운

이 사고의 본질은 결제사 장애가 아니라 타임아웃 미설정으로 인한 자원 전파 차단 실패였습니다. 외부 장애가 우리 서비스 전체를 잡아먹는 가장 흔한 시나리오입니다.

Connect Timeout과 Read Timeout

타임아웃은 두 단계로 나뉘어 있고, 둘 다 명시해야 합니다.

종류의미권장값
Connect TimeoutTCP 연결 수립까지 허용 시간1~3초
Read Timeout응답 수신 완료까지 허용 시간응답 SLA의 1.5배 (예: 평소 1초 → 1.5초)

너무 짧으면 정상 트래픽이 타임아웃에 걸리고, 너무 길면 위 사고처럼 자원 전파를 막지 못합니다. 외부 서비스의 평균·p99 응답 시간을 먼저 측정한 뒤 그 위에서 결정해야 합니다.

설정 방법

전체 OpenFeign 클라이언트에 공통 적용:

spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connect-timeout: 2000   # ms
            read-timeout: 5000

특정 클라이언트만 다르게:

spring:
  cloud:
    openfeign:
      client:
        config:
          customer-service:        # @FeignClient(name = "customer-service")
            connect-timeout: 1000
            read-timeout: 3000
          payment-service:
            connect-timeout: 3000
            read-timeout: 10000

코드 기반 설정이 필요하다면 feign.Request.Options 빈을 따로 등록할 수도 있지만, 운영 변경 용이성을 위해 application.yml 기반을 권장합니다.


재시도(Retry) 멱등성과 함께 설계하자

재시도는 일시적 장애를 흡수해 주는 강력한 도구지만, 잘못 적용하면 한 번에 그쳤을 사고를 N배로 키웁니다.

재시도가 위험한 이유

대표적 사고는 다음과 같습니다.

@PostMapping("/orders")
Order create(@RequestBody OrderRequest request);

이 POST 호출에 재시도 3회를 걸어 두면, 네트워크 일시 끊김으로 응답이 안 와도 주문이 3건 등록될 수 있습니다. 서버는 정상 처리했지만 응답 패킷이 유실된 경우입니다. 응답을 못 받았다고 서버가 처리 안 한 것은 아닙니다.

멱등성(Idempotency) 분류

재시도 적용 전 호출별 멱등성을 분류해야 합니다.

HTTP 메서드일반적 멱등성재시도 안전성
GET멱등안전
PUT멱등안전
DELETE멱등안전
POST비멱등위험
PATCH보통 비멱등위험

POST·PATCH에 재시도를 걸어야 한다면, 멱등성 키(idempotency key) 를 헤더로 전달해 서버가 중복 요청을 식별하도록 해야 합니다.

@PostMapping("/orders")
Order create(
    @RequestHeader("Idempotency-Key") String idempotencyKey,
    @RequestBody OrderRequest request
);

서버 측은 같은 키로 들어온 요청은 이전 응답을 그대로 반환하거나 신규 처리를 건너뜁니다.

Resilience4j 기반 재시도 (권장)

Spring Retry보다는 Resilience4j가 Circuit Breaker·Bulkhead·Rate Limiter와 함께 일관된 구성이 가능해 권장됩니다.

resilience4j:
  retry:
    instances:
      customerClient:
        max-attempts: 3
        wait-duration: 500ms
        exponential-backoff-multiplier: 2.0
        retry-exceptions:
          - feign.RetryableException
          - java.io.IOException
        ignore-exceptions:
          - com.example.BusinessException

Feign 인터페이스에 적용:

@FeignClient(name = "customer-service")
public interface CustomerClient {

    @Retry(name = "customerClient")
    @GetMapping("/api/customers/{id}")
    Customer findById(@PathVariable String id);
}

핵심은 세 가지입니다. GET 등 멱등 호출에만 재시도를 걸고, Exponential Backoff로 폭주 방지, 비즈니스 예외는 ignore-exceptions로 재시도에서 제외합니다.

재시도 대상 HTTP 코드

기본 원칙은 다음과 같습니다.

HTTP 상태재시도 여부
200~299재시도 불필요 (성공)
4xx (400~499)재시도 금지 — 클라이언트 잘못
429 (Too Many Requests)조건부 재시도 + Backoff 길게
5xx (500~599)재시도 가능 (일시 장애 가능성)
Connect/Read Timeout재시도 가능

400대를 재시도하면 같은 에러를 3번 받고 끝납니다. 4xx는 호출자 잘못이므로 재시도가 무의미합니다.


Fallback 외부 장애가 우리 서비스를 죽이지 않게

Fallback은 외부 호출이 실패했을 때 대체 응답을 반환하는 패턴입니다. Circuit Breaker와 결합해야 진가가 나옵니다.

Resilience4j와 결합한 Fallback

@FeignClient(
    name = "customer-service",
    fallback = CustomerClientFallback.class
)
public interface CustomerClient {

    @GetMapping("/api/customers/{id}")
    Customer findById(@PathVariable String id);
}

@Component
public class CustomerClientFallback implements CustomerClient {

    @Override
    public Customer findById(String id) {
        // 캐시 또는 기본값 반환
        return Customer.unknown(id);
    }
}

설정에서 Circuit Breaker를 활성화하고:

spring:
  cloud:
    openfeign:
      circuitbreaker:
        enabled: true

resilience4j:
  circuitbreaker:
    instances:
      customer-service:
        failure-rate-threshold: 50           # 50% 실패 시 차단
        wait-duration-in-open-state: 30s     # 30초 후 반열림 시도
        sliding-window-size: 20

이렇게 하면 실패율이 임계치를 넘는 순간 회로가 열려 외부 호출을 시도조차 안 하고 Fallback이 즉시 반환됩니다. 외부 장애 시 빠른 실패(fail-fast)가 가능해지고, 우리 서비스 자원이 소모되지 않습니다.

FallbackFactory — 예외 원인까지 전달

Fallback이 어떤 이유로 호출됐는지 알고 싶다면 FallbackFactory를 사용합니다.

@FeignClient(
    name = "customer-service",
    fallbackFactory = CustomerClientFallbackFactory.class
)
public interface CustomerClient { /* ... */ }

@Component
@Slf4j
public class CustomerClientFallbackFactory implements FallbackFactory<CustomerClient> {

    @Override
    public CustomerClient create(Throwable cause) {
        log.warn("[FALLBACK] customer-service cause={}", cause.toString());
        return new CustomerClient() {
            @Override
            public Customer findById(String id) {
                if (cause instanceof FeignException.NotFound) {
                    throw new BusinessException(ErrorCode.CUSTOMER_NOT_FOUND);
                }
                return Customer.unknown(id);
            }
        };
    }
}

이 구조는 장애 종류에 따라 다른 Fallback을 분기할 수 있어 실무에서 더 자주 쓰입니다.

Fallback에 비즈니스 로직을 넣지 마라

자주 보이는 실수는 Fallback 안에서 DB를 조회하거나 또 다른 외부 API를 호출하는 것입니다. Fallback은 즉시 반환되어야 하며, 그 안에서 또 실패하면 진짜 장애가 가려져 추적이 더 어려워집니다. Fallback에서는 로컬 캐시·기본값·정형화된 에러 응답만 반환합니다.


운영에서 마주치는 5가지 함정

함정 1: 기본 타임아웃이 무한대(또는 너무 김)

별도 설정 없이 그대로 두면 환경에 따라 매우 길거나 사실상 무한대일 수 있습니다. 모든 @FeignClient에 명시적 타임아웃을 적용하시기 바랍니다.

함정 2: POST에 재시도를 걸어 중복 처리 발생

가장 흔하면서 가장 치명적인 사고입니다. POST·PATCH는 기본적으로 재시도 대상에서 제외하고, 꼭 필요하다면 멱등성 키를 함께 설계합니다.

함정 3: Fallback에 비즈니스 로직을 넣어 진짜 장애를 가린다

Fallback이 또 실패하면서 원인이 가려지는 사고입니다. 로컬 캐시·기본값·정형화된 에러만 반환하는 원칙을 지킵니다.

함정 4: 인증·추적 헤더가 OpenFeign 호출에서 사라진다

외부 호출 시 traceId·인증 토큰·테넌트 ID 같은 헤더가 전파되지 않으면, 분산 추적과 권한 검증이 모두 끊깁니다. RequestInterceptor로 헤더를 일괄 전파합니다.

@Component
public class FeignHeaderInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        String traceId = MDC.get("traceId");
        if (traceId != null) {
            template.header("X-Trace-Id", traceId);
        }
        ServletRequestAttributes attrs =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attrs != null) {
            String auth = attrs.getRequest().getHeader("Authorization");
            if (auth != null) template.header("Authorization", auth);
        }
    }
}

함정 5: 로깅 레벨이 너무 자세하거나 너무 부족하다

OpenFeign의 로깅 레벨은 4단계입니다.

레벨내용권장 환경
NONE로깅 없음운영 (성능 우선)
BASIC요청 메서드·URL·응답 상태·소요시간운영 기본
HEADERSBASIC + 헤더개발·스테이징
FULL모든 정보 + 본문로컬 디버깅 전용

운영에 FULL을 켜 두면 민감정보 노출 + 디스크 폭증 위험이 있고, NONE은 장애 시 추적이 막힙니다. 운영은 BASIC이 표준입니다.

logging:
  level:
    com.example.client.CustomerClient: BASIC

실무 운영 체크리스트

신규 OpenFeign 클라이언트 도입 또는 점검 시 다음을 확인하시기 바랍니다.

첫째, 모든 @FeignClient에 Connect Timeout·Read Timeout이 명시되어 있는지 확인합니다.

둘째, 재시도는 멱등 호출(GET·PUT·DELETE)에만 적용되어 있고, POST·PATCH는 멱등성 키와 함께 설계되어 있는지 확인합니다.

셋째, 재시도 대상 예외와 HTTP 코드가 명확히 정의되어 있는지(retry-exceptions, ignore-exceptions) 확인합니다.

넷째, Circuit Breaker가 활성화되어 있고, Fallback이 즉시 반환되는 단순 응답인지 확인합니다.

다섯째, RequestInterceptor로 traceId·인증 헤더가 전파되는지 확인합니다.

여섯째, 로깅 레벨이 운영 환경에서 BASIC 으로 설정되어 있는지 확인합니다.

일곱째, Resilience4j 메트릭이 Prometheus 등에 노출되어 회로 차단 상태와 재시도 횟수를 모니터링할 수 있는지 확인합니다.

여덟째, 각 외부 서비스의 SLA·p99 응답 시간 기반으로 타임아웃 값을 재산정하고 분기별로 점검합니다.


자주 묻는 질문 (FAQ)

Q1. WebClient와 OpenFeign 중 어느 게 더 좋나요?

동기 코드 베이스라면 OpenFeign, 리액티브(WebFlux) 환경이라면 WebClient가 정답입니다. OpenFeign은 인터페이스 선언만으로 끝나는 간결함이 강점이고, WebClient는 비동기·스트리밍 처리가 강점입니다. 하나의 프로젝트에서 둘을 섞어도 무방하지만, 가급적 한 가지로 통일하는 것이 유지보수에 유리합니다.

Q2. Resilience4j와 Spring Retry 중 무엇이 좋나요?

신규 도입이라면 Resilience4j를 권장합니다. Circuit Breaker·Retry·Bulkhead·Rate Limiter·Time Limiter를 한 일관된 구성으로 다룰 수 있고, Spring Cloud Circuit Breaker 표준과도 호환됩니다. Spring Retry는 단순 재시도에는 충분하지만 회로 차단·격리 기능이 없습니다.

Q3. Feign 인터페이스는 어디에 두는 게 좋나요?

도메인별 패키지 안의 client 하위 패키지가 일반적입니다. 예를 들어 order.client.CustomerClient 처럼 둡니다. 모든 Feign 인터페이스를 하나의 client 패키지에 모으면 도메인 경계가 흐려지므로 피하시기 바랍니다.

Q4. 응답 JSON 매핑 실패는 어떻게 잡나요?

기본적으로 FeignException.errorStatus가 발생합니다. 매핑 실패와 5xx 에러를 구분하려면 ErrorDecoder를 커스텀해 상태 코드와 본문을 함께 분석한 뒤 의미 있는 비즈니스 예외로 변환하시기 바랍니다. 이 패턴은 이전 글의 @RestControllerAdvice 공통 에러 처리와 자연스럽게 연결됩니다.

Q5. 동기 호출이 답답합니다. 비동기로 바꿀 수 있나요?

OpenFeign 자체는 동기 호출 기반입니다. 비동기가 필요하다면 두 가지 선택지가 있습니다. @Async + OpenFeign 조합으로 호출 측에서 비동기화하거나, Reactive Feign(서드파티) 또는 WebClient로 전환합니다. 호출 측 비동기는 ThreadLocal 전파에 주의해야 합니다.


마무리

Spring Cloud OpenFeign 실전 가이드의 핵심은 도구의 화려함이 아니라 외부 장애가 우리 서비스로 번지지 않게 설계하는 능력입니다. 인터페이스 한 줄로 끝나는 듯한 OpenFeign도, 타임아웃·재시도·Fallback 3가지를 명시적으로 설계하지 않으면 외부 한 곳의 5초 지연이 우리 서비스 전체 다운으로 번집니다.

핵심을 다시 정리하면 다음과 같습니다. 타임아웃은 Connect·Read 모두 명시하고 외부 SLA 기반으로 정합니다. 재시도는 멱등 호출에만 적용하고 POST·PATCH는 멱등성 키와 함께 설계합니다. Fallback은 즉시 반환되는 단순 응답으로 두어 진짜 장애를 가리지 않게 합니다. 여기에 RequestInterceptor로 traceId·인증 전파, 운영 로깅 레벨 BASIC, Resilience4j 메트릭 모니터링까지 갖추면, 외부 연동이 많아도 안정적으로 운영되는 시스템이 완성됩니다.


핵심 요약

  • 타임아웃: Connect 1~3초·Read는 외부 SLA의 1.5배. 명시 없으면 외부 5초 지연이 우리 서비스 전체를 잡아먹습니다.
  • 재시도: 멱등 호출(GET·PUT·DELETE)에만 적용. POST·PATCH는 멱등성 키와 함께 설계합니다.
  • 재시도 대상: 5xx·타임아웃은 재시도, 4xx는 절대 재시도 금지(호출자 잘못).
  • Fallback: Circuit Breaker와 결합해 빠른 실패. 로컬 캐시·기본값·정형 에러만 반환합니다.
  • 헤더 전파: RequestInterceptor로 traceId·인증 토큰을 일괄 전파해 분산 추적과 권한을 유지합니다.
  • 운영 로깅: BASIC 표준. FULL은 민감정보 노출과 디스크 폭증 위험이 있습니다.

댓글 남기기