자바 백엔드 입문 40편. 외부 API 호출용 Spring HTTP 클라이언트 WebClient·RestClient 표준 패턴을 우편배달부 비유로 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 40편이에요. 백엔드가 다른 백엔드를 호출하는 일 — 결제 API·푸시 알림 API·외부 데이터 조회 — 이게 HTTP 클라이언트. 이번 40편은 Spring 6 표준이 된 WebClient · RestClient 를 풀어 가요.
HTTP 클라이언트의 세대 변천
자바 백엔드의 외부 API 호출은 세 세대를 거쳤어요.
| 세대 | 도구 | 시점 | 상태 |
|---|---|---|---|
| 1세대 | RestTemplate | Spring 3.0+ | 유지보수 모드 (deprecated 아님, 신규 X) |
| 2세대 | WebClient | Spring 5+ (WebFlux) | 비동기·논블로킹 표준 |
| 3세대 | RestClient | Spring 6.1+ | 동기 표준, WebClient 스타일 |
한국 회사 신규 프로젝트 룰: - 동기 호출 = RestClient (Spring 6.1+) - 비동기·스트리밍 = WebClient - 레거시 프로젝트 = RestTemplate 유지하다 단계적 마이그레이션
이전 22편 RestTemplate 같은 자리에서 다룬 옛 패턴은 신규 프로젝트엔 X.
우편배달부 비유
HTTP 클라이언트 = "우편배달부". 봉투(요청)·받을 사람 주소(URL)·내용물(body)·도착했을 때 무엇 받을 건지(응답 타입) 설정하면 — 클라이언트가 알아서 우체국 거쳐 전달·답신 받기.
- RestTemplate = 오래된 우편 시스템 (느린 배달)
- WebClient = 빠른 퀵서비스 (논블로킹)
- RestClient = 모던 우편 (빠르고 깔끔)
RestClient — Spring 6.1+ 표준
의존성
spring-boot-starter-web 에 포함. 추가 의존성 X.
빌더 — 빈 등록 표준
@Configuration
public class HttpClientConfig {
@Bean
public RestClient paymentRestClient() {
return RestClient.builder()
.baseUrl("https://payment.example.com/api")
.defaultHeader("Authorization", "Bearer " + apiKey)
.defaultHeader("Content-Type", "application/json")
.build();
}
}
GET — 단건 조회
@Service
@RequiredArgsConstructor
public class PaymentClient {
private final RestClient paymentRestClient;
public Payment getPayment(String id) {
return paymentRestClient.get()
.uri("/payments/{id}", id)
.retrieve()
.body(Payment.class);
}
}
retrieve() = "응답을 받겠다", body(...) = "이 타입으로 역직렬화". 끝.
POST — 생성
public Payment createPayment(PaymentRequest req) {
return paymentRestClient.post()
.uri("/payments")
.body(req)
.retrieve()
.body(Payment.class);
}
List 응답
public List<Payment> listPayments(Long userId) {
return paymentRestClient.get()
.uri("/payments?userId={id}", userId)
.retrieve()
.body(new ParameterizedTypeReference<List<Payment>>() {});
}
6편 제네릭 의 타입 소거 영향 — List<Payment>.class 직접 못 써서 ParameterizedTypeReference 우회.
헤더·쿼리 파라미터
public Payment getPayment(String id, String traceId) {
return paymentRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/payments/{id}")
.queryParam("includeDetail", true)
.build(id))
.header("X-Trace-Id", traceId)
.retrieve()
.body(Payment.class);
}
응답 상태 코드 처리
public Payment getPayment(String id) {
return paymentRestClient.get()
.uri("/payments/{id}", id)
.retrieve()
.onStatus(status -> status.value() == 404, (req, res) -> {
throw new PaymentNotFoundException(id);
})
.onStatus(HttpStatusCode::is5xxServerError, (req, res) -> {
throw new PaymentServerException();
})
.body(Payment.class);
}
.onStatus(predicate, handler) 패턴 — HTTP 코드별 예외 매핑.
WebClient — 비동기·논블로킹
비동기 호출·스트리밍이 필요할 때.
의존성
implementation 'org.springframework.boot:spring-boot-starter-webflux'
WebFlux 의존성. 기존 MVC 프로젝트에 WebClient만 쓰는 것도 OK — WebFlux를 "클라이언트로만" 사용.
사용
@Configuration
public class WebClientConfig {
@Bean
public WebClient userWebClient() {
return WebClient.builder()
.baseUrl("https://user-api.example.com")
.defaultHeader("Authorization", "Bearer " + apiKey)
.build();
}
}
@Service
@RequiredArgsConstructor
public class UserClient {
private final WebClient userWebClient;
// 비동기 — Mono<T> 반환
public Mono<User> getUserAsync(Long id) {
return userWebClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User.class);
}
// 동기 — block() 호출
public User getUserSync(Long id) {
return userWebClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User.class)
.block(); // ← 동기 변환 (블로킹)
}
}
Mono<T> = "비동기 한 개", Flux<T> = "비동기 스트림" (WebFlux 의 핵심 타입). 39편 CompletableFuture 와 비슷한 비동기 결과 객체지만 — 더 풍부한 연산자.
RestClient vs WebClient — 어느 거?
| RestClient | WebClient | |
|---|---|---|
| 스타일 | 동기 | 비동기 (block 가능) |
| 필요 의존성 | spring-web | spring-webflux |
| 학습 곡선 | 낮음 (RestTemplate 사용자에게 친숙) | Mono·Flux 학습 필요 |
| 추천 시점 | 일반적인 동기 호출 | 스트리밍·논블로킹 필수 |
| Spring 버전 | 6.1+ | 5+ |
룰: 한국 회사 신규 프로젝트 동기 호출 99% = RestClient. 비동기·스트리밍·고성능 필수만 WebClient.
외부 API 병렬 호출 — RestClient + CompletableFuture
RestClient는 동기지만 — 39편 CompletableFuture 와 조합하면 병렬 호출.
@Service
@RequiredArgsConstructor
public class DashboardService {
private final UserClient userClient;
private final OrderClient orderClient;
private final PaymentClient paymentClient;
public Dashboard fetch(Long userId) {
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() ->
userClient.getUser(userId));
CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(() ->
orderClient.listOrders(userId));
CompletableFuture<List<Payment>> paymentFuture = CompletableFuture.supplyAsync(() ->
paymentClient.listPayments(userId));
CompletableFuture.allOf(userFuture, orderFuture, paymentFuture).join();
return new Dashboard(userFuture.join(), orderFuture.join(), paymentFuture.join());
}
}
세 외부 API를 병렬로 호출 — 응답 시간이 "가장 느린 API + α" 로 압축.
타임아웃 — 운영 필수
외부 API가 응답 안 하면 — 우리 백엔드 스레드가 영원히 대기. 타임아웃 반드시 설정.
@Bean
public RestClient paymentRestClient() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(3)); // 연결 3초
factory.setReadTimeout(Duration.ofSeconds(10)); // 응답 10초
return RestClient.builder()
.baseUrl("https://payment.example.com/api")
.requestFactory(factory)
.build();
}
WebClient 도 비슷 — Netty HttpClient 설정.
재시도 — Spring Retry
외부 API가 일시 실패할 때 — 자동 재시도.
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
@SpringBootApplication
@EnableRetry
public class Application { ... }
@Service
public class PaymentClient {
@Retryable(
retryFor = { ResourceAccessException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 500))
public Payment getPayment(String id) {
return paymentRestClient.get()...;
}
@Recover
public Payment recover(ResourceAccessException e, String id) {
log.error("결제 조회 최종 실패: {}", id, e);
return null;
}
}
@Retryable + @Recover — 최대 3회 시도, 500ms 간격. 다 실패하면 @Recover 메서드 호출.
함정 5가지
(1) RestTemplate 신규 사용
// ❌ Spring 6+ 신규 프로젝트엔 X
RestTemplate rt = new RestTemplate();
// ✅ RestClient 사용
RestClient rc = RestClient.create();
(2) String 응답 타입에 매번 body(String.class)
JSON 응답을 자주 String으로 받아 직접 파싱 — 안티패턴. DTO로 정의해서 Jackson에 맡기기.
(3) 타임아웃 없음
기본 타임아웃 = 무한대. 외부 API 죽으면 우리 백엔드도 같이 죽음. 반드시 설정.
(4) 동기 호출을 직렬로
세 API 직렬 호출 = 합쳐서 5초. 병렬로 호출하면 2초. 39편 패턴 활용.
(5) 4xx·5xx 무시
.retrieve() 는 4xx·5xx에 기본 예외 던짐 (RestClientResponseException). 단 — 비즈니스 의미 있는 코드 (404 = 자원 없음) 는 명시적으로 매핑.
Spring 6.1+ 신규 = RestClient. 비동기·스트리밍 = WebClient. RestTemplate은 신규 사용 X. 타임아웃·재시도·예외 매핑은 운영 필수.
한 줄 정리 — Spring 6.1+ HTTP 클라이언트 표준 = RestClient(동기) + WebClient(비동기). RestTemplate은 유지보수 모드. 타임아웃·재시도·외부 API 병렬 호출 패턴 필수.
시험 직전 한 번 더 — WebClient/RestClient 입문자가 매번 헷갈리는 것
- 세 세대 = RestTemplate(1세대) → WebClient(2세대) → RestClient(3세대)
- RestClient = Spring 6.1+ 동기 표준
- WebClient = Spring 5+ 비동기 표준 (Mono·Flux)
- RestTemplate = 유지보수 모드 (신규 X)
- 의존성 = RestClient는
spring-web, WebClient는spring-webflux - 빌더 패턴 =
RestClient.builder().baseUrl(...).defaultHeader(...).build() - 빈으로 등록해서 재사용 (생성 비용 큼)
retrieve()= 응답 받겠다body(T.class)= 역직렬화body(new ParameterizedTypeReference<List<T>>(){})= 제네릭 타입.onStatus(predicate, handler)= 코드별 예외 매핑.uri(builder -> ...)= 쿼리 파라미터- WebClient 동기 변환 =
.block()(블로킹 — 운영 신중) - Mono
= 비동기 한 개, Flux = 비동기 스트림 - 타임아웃 설정 필수 — 무한 대기 방지 (3초·10초 표준)
- 재시도 = Spring Retry
@Retryable + @Recover - 외부 API 병렬 호출 = RestClient + CompletableFuture.supplyAsync (한국 회사 표준)
- 4xx·5xx 자동 예외 (
RestClientResponseException) - 404 등 비즈니스 의미 코드 = 명시적 매핑
- DTO 응답 받기 — String 받아 직접 파싱 X
- 자바 백엔드 = 외부 API 매일 호출
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 35편 — 커스텀 Validator 만들기
- 36편 — Logback SLF4J 로깅
- 37편 — Spring Security 기초
- 38편 — Spring ApplicationEvent @EventListener
- 39편 — Spring @Async CompletableFuture 비동기
다음 글: