Skip to main content

FeignClient으로 PG연동하기

TL;DR — FeignClient는 HTTP 클라이언트 코드를 인터페이스 선언만으로 끝낼 수 있는 Spring Cloud 라이브러리다. 처음엔 RestTemplate이랑 뭐가 다른가 싶었는데, 직접 PG 연동에 써보니 확실히 달랐다.


계기

이번에 외부 PG(Payment Gateway) 시스템과 연동하는 결제 기능을 구현했다. PG에 HTTP 요청을 보내야 했는데, 어떤 클라이언트를 쓸지 고민하다 FeignClient를 처음 써봤다.

솔직히 처음엔 "그냥 RestTemplate 쓰면 되는 거 아닌가?" 싶었다. 그런데 써보고 나서 왜 많이들 쓰는지 이해가 됐다.


FeignClient란?

Spring Cloud에서 제공하는 선언형 HTTP 클라이언트다.

"선언형"이라는 게 핵심이다. 어떻게 호출할지 코드로 구현하는 게 아니라, 무엇을 호출할지 인터페이스로 선언하면 Spring이 런타임에 구현체를 자동으로 만들어준다.


RestTemplate과의 차이점

기존에 자주 쓰던 RestTemplate 라이브러리와 비교하여 같은 PG 결제 요청을 RestTemplate과 FeignClient로 구현하면 차이가 명확하다.

// 선언부
@FeignClient(name = "pg-client", url = "${pg.url}", configuration = PgFeignConfig.class)
public interface PgClient {

@PostMapping("/api/v1/payments")
PgPaymentResponse requestPayment(
@RequestHeader("X-USER-ID") String userId,
@RequestBody PgPaymentRequest request
);

@GetMapping("/api/v1/payments/{transactionKey}")
PgTransactionDetailResponse getPaymentStatus(
@RequestHeader("X-USER-ID") String userId,
@PathVariable("transactionKey") String transactionKey
);

@GetMapping("/api/v1/payments")
PgTransactionDetailResponse getPaymentByOrderId(
@RequestHeader("X-USER-ID") String userId,
@RequestParam("orderId") String orderId
);
}

// 호출부
PgPaymentResponse response = pgClient.requestPayment(userId, request);

FeignClient는 인터페이스 선언만 하면 끝이다. 호출하는 코드는 메서드 하나.

@PostMapping, @RequestHeader, @RequestBody 같은 Spring MVC 어노테이션을 그대로 쓸 수 있어서 따로 배울 것도 없다.


동작 방식

FeignClient는 AOP 기반의 프록시 패턴이다.

@EnableFeignClients를 선언하면 Spring이 애플리케이션 시작 시 PgClient 인터페이스를 읽고, 실제 HTTP 호출 코드가 담긴 구현체를 자동으로 생성해서 Bean으로 등록한다.

@EnableFeignClients
@EnableScheduling
@SpringBootApplication
public class CommerceApiApplication {
public static void main(String[] args) {
SpringApplication.run(CommerceApiApplication.class, args);
}
}

개발자가 작성하는 건 인터페이스뿐이고 실제 동작은 Spring이 만든 프록시가 처리한다.

내부 흐름

pgClient.requestPayment() 를 호출하면 내부적으로 이런 순서로 동작한다.

  1. 인터페이스 메서드 호출 — 개발자가 작성한 pgClient.requestPayment(userId, request) 호출
  2. 프록시 인터셉트 — Spring이 생성한 ReflectiveFeign 프록시가 호출을 가로챔
  3. 어노테이션 파싱@PostMapping, @RequestHeader, @RequestBody 등을 읽어 HTTP 요청 객체로 변환
  4. 인코딩RequestBody를 Jackson 등 등록된 Encoder로 직렬화
  5. HTTP 실행 — 기본값은 HttpURLConnection, OkHttp/Apache HttpClient로 교체 가능
  6. 응답 디코딩 — 응답 바디를 PgPaymentResponse로 역직렬화
  7. 에러 처리 — 4xx/5xx 응답은 ErrorDecoder로 위임 (아래 섹션 참고)

이 흐름 전체가 인터페이스 선언 하나로 추상화되어 있기 때문에, 비즈니스 로직에서는 메서드 호출 한 줄만 보이게 된다.


타임아웃 설정

FeignClient는 application.yml에서 타임아웃을 설정할 수 있다.

feign:
client:
config:
pg-client:
connect-timeout: 1000 # 연결 타임아웃 1초
read-timeout: 3000 # 응답 대기 타임아웃 3초

PG 시뮬레이터가 100~500ms 지연이 있고 간혹 더 오래 걸릴 수 있어서, 3초 안에 응답이 없으면 실패로 처리하도록 설정했다.


에러 처리 / ErrorDecoder

FeignClient는 HTTP 응답이 4xx/5xx이면 기본적으로 FeignException을 던진다. 문제는 PG 시스템마다 에러 응답 포맷이 제각각이라는 것이다. 기본 예외만으로는 PG가 왜 실패했는지 파악하기 어렵다.

이를 해결하는 게 ErrorDecoder다. PG의 에러 응답을 직접 파싱해서 의미 있는 예외로 변환할 수 있다.

@Slf4j
public class PgErrorDecoder implements ErrorDecoder {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public Exception decode(String methodKey, Response response) {
try {
String body = Util.toString(response.body().asReader(StandardCharsets.UTF_8));
PgErrorResponse error = objectMapper.readValue(body, PgErrorResponse.class);

return switch (response.status()) {
case 400 -> new PgBadRequestException(error.message());
case 401 -> new PgUnauthorizedException("PG 인증 실패");
case 422 -> new PgPaymentRejectedException(error.message()); // 카드 한도 초과 등
case 503 -> new PgUnavailableException("PG 서비스 점검 중");
default -> new PgException("PG 알 수 없는 오류: " + response.status());
};
} catch (IOException e) {
log.error("PG 에러 응답 파싱 실패", e);
return new PgException("PG 에러 응답을 읽을 수 없음");
}
}
}

ErrorDecoder를 FeignClient 설정에 등록하면 된다.

@Configuration
public class PgFeignConfig {

@Bean
public ErrorDecoder pgErrorDecoder() {
return new PgErrorDecoder();
}
}

이렇게 하면 PG에서 422가 오면 PgPaymentRejectedException이 던져지고, 상위 레이어에서 카드 한도 초과 / 잔액 부족을 명확하게 구분해서 처리할 수 있다.

tip

ErrorDecoder에서 RetryableException을 반환하면 Feign 내장 Retryer가 자동으로 재시도한다.
단, Resilience4j @Retry와 함께 쓰는 경우 재시도가 중복될 수 있으니 둘 중 하나만 쓰는 게 낫다.


Resilience4j와 함께 쓰기

FeignClient를 선택한 이유 중 하나가 Resilience4j와의 조합이었다. 사실 Resilience4j는 AOP 기반이라 어떤 HTTP 클라이언트와도 조합할 수 있다. 다만 FeignClient를 쓰면 코드가 깔끔해서 어노테이션 위치가 명확해진다.

@Transactional
@CircuitBreaker(name = "pgCircuitBreaker", fallbackMethod = "requestPaymentFallback")
@Retry(name = "pgRetry")
public PaymentInfo requestPayment(Long userId, Long orderId, CardType cardType, String cardNo) {
// ...
PgPaymentResponse pgResponse = pgClient.requestPayment(String.valueOf(userId), pgRequest);
// ...
}

pgClient.requestPayment() 한 줄이 실제 PG 호출의 전부다. @CircuitBreaker@Retry는 이 메서드를 감싸는 형태로 동작한다.

resilience4j:
circuitbreaker:
instances:
pgCircuitBreaker:
sliding-window-size: 10
failure-rate-threshold: 50 # 10회 중 5회 실패 시 Open
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
retry:
instances:
pgRetry:
max-attempts: 3
wait-duration: 500ms

PG 시뮬레이터의 실패율이 40%였는데, 이 설정 덕분에 PG가 불안정해도 내부 서비스는 정상적으로 응답할 수 있었다.


장단점 정리

장점

  • 코드가 간결하다 — 인터페이스 선언만으로 HTTP 클라이언트 완성
  • Spring MVC 어노테이션 그대로 사용 — 러닝커브가 거의 없음
  • 설정 기반 타임아웃/로깅 — yml로 관리 가능
  • Resilience4j, 로드밸런서 등 Spring Cloud 생태계와 자연스러운 통합

단점

  • Spring Cloud 의존성 필요 — 순수 Spring Boot 프로젝트에 추가하려면 BOM 설정 필요
  • 동기(블로킹) 방식 — WebClient처럼 논블로킹이 아님
  • 동적 URL 처리가 불편 — 런타임에 URL이 바뀌는 경우 대응하기 어려움
  • 파일 업로드/다운로드가 번거로움

어떤 상황에서 쓰면 좋을까?

상황추천
외부 REST API 연동 (PG, 카카오, 네이버 등)✅ FeignClient
MSA 환경에서 서비스 간 HTTP 통신✅ FeignClient
대용량 트래픽, 논블로킹이 필요한 경우WebClient
레거시 코드 유지보수RestTemplate (현상 유지)
바이너리 파일 전송RestTemplate / WebClient

이번처럼 외부 PG 시스템에 동기 요청을 보내고 결과를 기다리는 케이스는 FeignClient가 딱 맞는 상황이었다.


마치며

처음엔 RestTemplate이랑 뭐가 다른가 싶었는데, 직접 써보니 확실히 달랐다. 외부 API 연동 코드가 인터페이스 하나로 깔끔하게 정리되니까 비즈니스 로직에 더 집중할 수 있었다.

다만 "FeignClient가 무조건 좋다"는 건 아니다. RestTemplate도 충분히 동작하고, WebClient가 더 적합한 상황도 있다. 결국 어떤 요구사항이냐에 따라 적절한 도구를 선택하는 게 맞다는 걸 이번에 배웠다.