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가 더 적합한 상황도 있다. 결국 어떤 요구사항이냐에 따라 적절한 도구를 선택하는 게 맞다는 걸 이번에 배웠다.