Spring Boot 4 HTTP 클라이언트 완전 가이드 - RestClient, WebClient, @HttpExchange
Spring Boot 4에서 외부 API를 호출하는 방법은 세 가지다. RestClient, WebClient, @HttpExchange. 각각 설계 철학이 다르고, 적합한 상황이 다르다. 이 글에서는 세 가지를 설정부터 에러 핸들링, 테스트, 프로덕션 운영까지 전부 다룬다.
세 가지 방식 한눈에 비교
| RestClient | WebClient | @HttpExchange | |
|---|---|---|---|
| 방식 | 동기 (블로킹) | 비동기 (논블로킹) | 선언적 (인터페이스) |
| 도입 시점 | Spring Boot 3.2 | Spring Boot 2.0 | Spring Boot 3.0 |
| 위치 | Spring Boot 4 기본 권장 | 리액티브 스택 | RestClient 또는 WebClient 위에서 동작 |
| 의존성 | spring-boot-starter-web |
spring-boot-starter-webflux |
별도 의존성 불필요 |
| 코드 스타일 | 메서드 체이닝 | Mono/Flux 리액티브 체이닝 | 인터페이스 + 어노테이션 |
| 학습 난이도 | 낮음 | 높음 (리액티브 개념 필요) | 낮음 |
| 디버깅 | 쉬움 (스택트레이스 직관적) | 어려움 (리액티브 스택트레이스) | 쉬움 |
| 적합한 상황 | 일반적인 API 호출 | 스트리밍, SSE, 동시 다건 호출 | 외부 API가 여러 개일 때 |
선택 기준
리액티브 스택(WebFlux)을 사용하는가?
└─ Yes → WebClient
└─ No → 외부 API 엔드포인트가 많은가?
└─ Yes → @HttpExchange (인터페이스로 깔끔하게 정리)
└─ No → RestClient (가장 단순)
Virtual Threads 환경에서의 선택: Java 25 + Virtual Threads를 사용하면 동기 코드도 I/O 대기 시 플랫폼 스레드를 반환한다. “비동기 성능이 필요해서” WebClient를 선택할 이유가 대부분 사라진다. 스트리밍이나 SSE 같은 특수한 요구사항이 아니라면 RestClient가 코드도 단순하고 디버깅도 쉽다.
같은 API를 세 가지 방식으로 호출하면
외부 사용자 API(GET /users/{id})를 호출하는 코드를 비교해보자.
// 1. RestClient - 가장 직관적
fun getUser(id: Long): User {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(User::class.java)!!
}
// 2. WebClient - 리액티브 타입 반환
fun getUser(id: Long): Mono<User> {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User::class.java)
}
// 3. @HttpExchange - 인터페이스만 정의
@GetExchange("/users/{id}")
fun getUser(@PathVariable id: Long): User
RestClient와 @HttpExchange는 반환 타입이 User다. WebClient만 Mono<User>를 반환한다. 이 차이가 코드 전체에 영향을 미친다. Service에서 Mono를 다뤄야 하고, 에러 핸들링도 리액티브 방식이어야 한다.
RestClient
왜 RestTemplate 대신 RestClient인가
Spring Boot 4에서 RestTemplate은 더 이상 권장하지 않는다. 동작은 하지만 새로운 기능이 추가되지 않는다.
// RestTemplate (레거시) - 응답을 꺼내려면 ResponseEntity를 경유
val response: ResponseEntity<User> = restTemplate.getForEntity("/users/1", User::class.java)
val user = response.body
// RestTemplate (레거시) - 에러 핸들링이 번거롭다
try {
restTemplate.getForObject("/users/999", User::class.java)
} catch (e: HttpClientErrorException.NotFound) {
// 404 처리
}
// RestClient - 체이닝으로 깔끔하게
val user = restClient.get()
.uri("/users/{id}", 1)
.retrieve()
.body(User::class.java)
// RestClient - 에러 핸들링도 체이닝
val user = restClient.get()
.uri("/users/{id}", 999)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError) { _, response ->
throw UserNotFoundException(999)
}
.body(User::class.java)
설정
@Configuration
class RestClientConfig {
@Bean
fun restClient(builder: RestClient.Builder): RestClient {
return builder
.baseUrl("https://api.external.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.requestFactory(clientHttpRequestFactory())
.build()
}
private fun clientHttpRequestFactory(): ClientHttpRequestFactory {
return JdkClientHttpRequestFactory().apply {
setReadTimeout(Duration.ofSeconds(5))
}
}
}
RestClient.Builder를 주입받아 사용하는 것이 중요하다. Spring Boot가 Auto-Configuration으로 제공하는 Builder에는 이미 Jackson ObjectMapper, 인터셉터 등이 설정되어 있다. RestClient.create()로 직접 생성하면 이런 혜택을 받지 못한다.
CRUD 전체 예시
@Service
class UserApiClient(
private val restClient: RestClient
) {
// GET - 단건 조회
fun getUser(id: Long): User {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(User::class.java)!!
}
// GET - 목록 조회 (제네릭 타입)
fun getUsers(page: Int, size: Int): List<User> {
return restClient.get()
.uri("/users?page={page}&size={size}", page, size)
.retrieve()
.body(object : ParameterizedTypeReference<List<User>>() {})!!
}
// POST - 생성
fun createUser(request: CreateUserRequest): User {
return restClient.post()
.uri("/users")
.body(request)
.retrieve()
.body(User::class.java)!!
}
// PUT - 수정
fun updateUser(id: Long, request: UpdateUserRequest): User {
return restClient.put()
.uri("/users/{id}", id)
.body(request)
.retrieve()
.body(User::class.java)!!
}
// DELETE - 삭제
fun deleteUser(id: Long) {
restClient.delete()
.uri("/users/{id}", id)
.retrieve()
.toBodilessEntity()
}
// 응답 헤더가 필요할 때
fun getUserWithHeaders(id: Long): ResponseEntity<User> {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.toEntity(User::class.java)
}
}
List<User> 같은 제네릭 타입은 ParameterizedTypeReference를 사용해야 한다. Java의 타입 소거(Type Erasure) 때문에 List::class.java로는 내부 타입 정보를 전달할 수 없다.
에러 핸들링
fun getUser(id: Long): User? {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError) { request, response ->
when (response.statusCode) {
HttpStatus.NOT_FOUND -> throw UserNotFoundException(id)
HttpStatus.FORBIDDEN -> throw AccessDeniedException("사용자 $id 접근 권한 없음")
else -> throw ExternalApiException("클라이언트 에러: ${response.statusCode}")
}
}
.onStatus(HttpStatusCode::is5xxServerError) { _, response ->
throw ExternalApiException("외부 API 서버 에러: ${response.statusCode}")
}
.body(User::class.java)
}
onStatus를 설정하지 않으면 4xx/5xx 응답 시 RestClientResponseException이 발생한다. 이것을 글로벌 예외 핸들러에서 잡아도 되지만, 클라이언트별로 다른 처리가 필요하면 onStatus로 변환하는 것이 명확하다.
인터셉터 - 공통 로직 주입
모든 요청에 인증 토큰을 넣거나, 로깅을 하거나, 요청/응답을 가공해야 할 때 사용한다.
@Bean
fun restClient(builder: RestClient.Builder): RestClient {
return builder
.baseUrl("https://api.external.com")
.requestInterceptor(loggingInterceptor())
.requestInterceptor(authInterceptor())
.requestFactory(clientHttpRequestFactory())
.build()
}
// 요청/응답 로깅
private fun loggingInterceptor() = ClientHttpRequestInterceptor { request, body, execution ->
val log = LoggerFactory.getLogger("HttpClient")
log.debug(">>> {} {}", request.method, request.uri)
val response = execution.execute(request, body)
log.debug("<<< {} ({}ms)", response.statusCode, /* elapsed */)
response
}
// 인증 토큰 주입
private fun authInterceptor() = ClientHttpRequestInterceptor { request, body, execution ->
request.headers.setBearerAuth(tokenProvider.getAccessToken())
execution.execute(request, body)
}
인터셉터는 등록 순서대로 실행된다. 로깅 → 인증 순서로 등록하면, 로깅에 인증 헤더까지 포함된 요청이 기록된다.
타임아웃 설정
타임아웃은 두 가지를 구분해야 한다.
[Connection Timeout]
클라이언트 ──── TCP 연결 시도 ────→ 서버
↑
이 단계에서 대기하는 최대 시간
(서버가 아예 응답하지 않는 경우)
[Read Timeout]
클라이언트 ←──── 응답 데이터 수신 ──── 서버
↑
데이터가 오기까지 대기하는 최대 시간
(연결은 됐지만 응답이 늦는 경우)
private fun clientHttpRequestFactory(): ClientHttpRequestFactory {
// JDK HttpClient 사용 (Java 25 권장)
val httpClient = java.net.http.HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3)) // Connection Timeout
.build()
return JdkClientHttpRequestFactory(httpClient).apply {
setReadTimeout(Duration.ofSeconds(5)) // Read Timeout
}
}
Connection Timeout 3초: 3초 안에 TCP 연결이 안 되면 서버가 죽었거나 네트워크 문제다. 더 기다릴 이유가 없다.
Read Timeout 5초: API 응답이 5초를 넘기면 타임아웃. 외부 API의 SLA에 따라 조정한다. 결제 API처럼 느릴 수 있는 경우 10~15초로 늘릴 수 있지만, 기본값은 5초가 적당하다.
WebClient
언제 WebClient를 써야 하는가
Virtual Threads 시대에 WebClient를 선택해야 하는 명확한 이유가 있는 경우만 사용한다.
[WebClient가 필요한 경우]
1. 여러 외부 API를 동시에 호출하고 결과를 합쳐야 할 때
2. SSE(Server-Sent Events) 스트리밍 수신
3. 이미 리액티브 스택(WebFlux)을 사용하는 프로젝트
4. 응답을 스트림으로 처리해야 할 때 (대용량 JSON 등)
[WebClient가 필요하지 않은 경우]
1. 일반적인 REST API 호출 → RestClient
2. "비동기라서 성능이 좋을 것 같아서" → Virtual Threads가 해결
3. 단순히 최신 기술을 쓰고 싶어서 → 복잡도만 증가
설정
@Configuration
class WebClientConfig {
@Bean
fun webClient(builder: WebClient.Builder): WebClient {
return builder
.baseUrl("https://api.external.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.clientConnector(
ReactorClientHttpConnector(
HttpClient.create()
.responseTimeout(Duration.ofSeconds(5))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
)
)
.codecs { configurer ->
configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024) // 2MB
}
.build()
}
}
maxInMemorySize는 응답 본문의 최대 버퍼 크기다. 기본값은 256KB인데, 큰 JSON 응답을 받으면 DataBufferLimitException이 발생한다. 필요에 따라 늘리되, 무제한(-1)은 메모리 폭주 위험이 있으므로 피한다.
동시 호출 - WebClient의 진짜 강점
3개의 외부 API를 호출해야 하는 상황:
// RestClient - 순차 실행 (총 650ms)
fun getUserDashboard(userId: Long): DashboardDto {
val user = restClient.get().uri("/users/{id}", userId) // 200ms
.retrieve().body(User::class.java)!!
val orders = restClient.get().uri("/users/{id}/orders", userId) // 300ms
.retrieve().body(object : ParameterizedTypeReference<List<Order>>() {})!!
val points = restClient.get().uri("/users/{id}/points", userId) // 150ms
.retrieve().body(Points::class.java)!!
return DashboardDto(user, orders, points)
// 총: 200 + 300 + 150 = 650ms (순차)
}
// WebClient - 동시 실행 (총 300ms)
fun getUserDashboard(userId: Long): DashboardDto {
val userMono = webClient.get().uri("/users/{id}", userId)
.retrieve().bodyToMono(User::class.java) // 200ms ─┐
val ordersMono = webClient.get().uri("/users/{id}/orders", userId) // │
.retrieve().bodyToFlux(Order::class.java).collectList() // 300ms ─┤ 동시
val pointsMono = webClient.get().uri("/users/{id}/points", userId) // │
.retrieve().bodyToMono(Points::class.java) // 150ms ─┘
return Mono.zip(userMono, ordersMono, pointsMono)
.map { (user, orders, points) -> DashboardDto(user, orders, points) }
.block()!!
// 총: max(200, 300, 150) = 300ms (동시)
}
[순차 실행 - RestClient]
user |████████████████████| 200ms
orders |██████████████████████████████| 300ms
points |██████████| 150ms
총 650ms
[동시 실행 - WebClient]
user |████████████████████| 200ms
orders |██████████████████████████████| 300ms
points |██████████| 150ms
총 300ms (가장 느린 요청 기준)
650ms와 300ms. 차이가 크다. 외부 API 호출이 3~5개 이상이면 WebClient의 동시 호출이 체감될 정도의 차이를 만든다.
참고: Virtual Threads + RestClient 조합에서도
StructuredTaskScope를 사용하면 동시 호출이 가능하다. 하지만 아직 preview 기능이고, WebClient의Mono.zip이 더 성숙한 방식이다.
에러 핸들링
fun getUser(id: Long): User {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.onStatus({ it.is4xxClientError }) { response ->
response.bodyToMono(String::class.java)
.flatMap { body ->
Mono.error(ExternalApiException("4xx 에러: $body"))
}
}
.onStatus({ it.is5xxServerError }) { response ->
Mono.error(ExternalApiException("외부 서버 에러: ${response.statusCode()}"))
}
.bodyToMono(User::class.java)
.timeout(Duration.ofSeconds(5))
.retryWhen(
Retry.backoff(3, Duration.ofMillis(500))
.filter { it is WebClientResponseException.ServiceUnavailable }
.onRetryExhaustedThrow { _, signal ->
ExternalApiException("3회 재시도 후에도 실패", signal.failure())
}
)
.block()!!
}
WebClient의 에러 핸들링은 리액티브 체이닝으로 구성된다. .timeout(), .retryWhen() 같은 연산자를 체이닝으로 붙일 수 있는 것이 장점이다.
스트리밍 수신 (SSE)
WebClient만 가능한 기능이다.
// Server-Sent Events 수신
fun subscribeToEvents(): Flux<ServerEvent> {
return webClient.get()
.uri("/events/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(ServerEvent::class.java)
}
// 사용
subscribeToEvents()
.doOnNext { event -> log.info("이벤트 수신: {}", event) }
.doOnError { error -> log.error("스트림 에러", error) }
.subscribe()
@HttpExchange
왜 인터페이스 기반인가
외부 API가 10개, 20개의 엔드포인트를 가지고 있으면 RestClient로 일일이 작성하는 것은 반복 작업이다.
// RestClient 방식 - 엔드포인트마다 비슷한 코드 반복
@Service
class UserApiClient(private val restClient: RestClient) {
fun getUser(id: Long): User = restClient.get().uri("/users/{id}", id).retrieve().body(User::class.java)!!
fun createUser(req: CreateUserRequest): User = restClient.post().uri("/users").body(req).retrieve().body(User::class.java)!!
fun updateUser(id: Long, req: UpdateUserRequest): User = restClient.put().uri("/users/{id}", id).body(req).retrieve().body(User::class.java)!!
fun deleteUser(id: Long) = restClient.delete().uri("/users/{id}", id).retrieve().toBodilessEntity()
fun getUsers(page: Int): List<User> = restClient.get().uri("/users?page={page}", page).retrieve().body(object : ParameterizedTypeReference<List<User>>() {})!!
}
// @HttpExchange 방식 - 인터페이스만 정의하면 끝
@HttpExchange("/users")
interface UserClient {
@GetExchange("/{id}")
fun getUser(@PathVariable id: Long): User
@PostExchange
fun createUser(@RequestBody request: CreateUserRequest): User
@PutExchange("/{id}")
fun updateUser(@PathVariable id: Long, @RequestBody request: UpdateUserRequest): User
@DeleteExchange("/{id}")
fun deleteUser(@PathVariable id: Long)
@GetExchange
fun getUsers(@RequestParam page: Int): List<User>
}
@HttpExchange의 장점:
- 코드가 적다: URI 조합, retrieve, body 변환 코드가 전부 사라진다
- 계약이 명확하다: 인터페이스만 보면 어떤 API를 호출하는지 한눈에 파악된다
- Spring MVC와 대칭:
@GetMapping↔@GetExchange,@PostMapping↔@PostExchange - 테스트가 쉽다: 인터페이스이므로 Mock 구현이 간단하다
설정
@Configuration
class HttpServiceConfig {
@Bean
fun userClient(restClientBuilder: RestClient.Builder): UserClient {
val restClient = restClientBuilder
.baseUrl("https://api.user-service.com")
.requestFactory(clientHttpRequestFactory())
.build()
val factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
return factory.createClient(UserClient::class.java)
}
@Bean
fun paymentClient(restClientBuilder: RestClient.Builder): PaymentClient {
val restClient = restClientBuilder
.baseUrl("https://api.payment-service.com")
.requestFactory(clientHttpRequestFactory())
.build()
val factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
return factory.createClient(PaymentClient::class.java)
}
private fun clientHttpRequestFactory(): ClientHttpRequestFactory {
return JdkClientHttpRequestFactory().apply {
setReadTimeout(Duration.ofSeconds(5))
}
}
}
외부 서비스마다 다른 baseUrl과 타임아웃을 설정할 수 있다. 결제 서비스는 10초, 일반 API는 5초 같은 차등 설정이 가능하다.
Spring Boot 4: @ImportHttpServices
Spring Boot 4에서는 위의 수동 설정을 자동화할 수 있다.
@Configuration
@ImportHttpServices(
group = "user-service",
types = [UserClient::class]
)
@ImportHttpServices(
group = "payment-service",
types = [PaymentClient::class]
)
class HttpServiceConfig
spring:
http:
client:
user-service:
url: https://api.user-service.com
connect-timeout: 3s
read-timeout: 5s
payment-service:
url: https://api.payment-service.com
connect-timeout: 3s
read-timeout: 10s
HTTP Service Registry가 그룹별로 RestClient를 자동 구성한다. Java 코드에는 URL이나 타임아웃이 없고, YAML에서 관리한다. 환경별(local/prod)로 URL을 다르게 설정하기도 쉽다.
어노테이션 상세
@HttpExchange("/orders")
interface OrderClient {
// 기본 GET
@GetExchange("/{id}")
fun getOrder(@PathVariable id: Long): Order
// 쿼리 파라미터
@GetExchange
fun searchOrders(
@RequestParam status: String,
@RequestParam(required = false) from: LocalDate?
): List<Order>
// 헤더 추가
@GetExchange("/{id}")
fun getOrderWithAuth(
@PathVariable id: Long,
@RequestHeader("X-Api-Key") apiKey: String
): Order
// POST with body
@PostExchange
fun createOrder(@RequestBody request: CreateOrderRequest): Order
// 응답 전체 (헤더 포함)
@GetExchange("/{id}")
fun getOrderEntity(@PathVariable id: Long): ResponseEntity<Order>
// PATCH
@PatchExchange("/{id}")
fun patchOrder(
@PathVariable id: Long,
@RequestBody patch: Map<String, Any>
): Order
}
공통: 에러 핸들링 전략
외부 API 전용 예외 클래스
// 외부 API 호출 실패를 표현하는 예외
class ExternalApiException(
val serviceName: String,
val statusCode: HttpStatusCode?,
override val message: String,
override val cause: Throwable? = null
) : RuntimeException(message, cause)
// 글로벌 예외 핸들러에 추가
@ExceptionHandler(ExternalApiException::class)
fun handleExternalApiException(
e: ExternalApiException,
request: HttpServletRequest
): ResponseEntity<ApiErrorResponse> {
log.error("외부 API 호출 실패: service={}, status={}", e.serviceName, e.statusCode, e)
return ResponseEntity
.status(HttpStatus.BAD_GATEWAY) // 502
.body(
ApiErrorResponse(
traceId = MDC.get("traceId"),
status = 502,
code = "EXTERNAL_API_ERROR",
message = "외부 서비스 연동 중 오류가 발생했습니다",
path = request.requestURI
)
)
}
외부 API 장애를 클라이언트에게 그대로 전달하면 안 된다. “결제 서비스에서 Connection refused” 같은 메시지를 사용자에게 보여주는 것은 보안상으로도, UX상으로도 좋지 않다. 502 Bad Gateway로 래핑하고, 내부 로그에만 상세 정보를 남긴다.
재시도 (Retry)
일시적인 네트워크 문제로 실패하는 경우가 있다. 한 번 더 시도하면 성공하는 경우가 많다.
// RestClient - Spring Retry 사용
@Retryable(
retryFor = [ResourceAccessException::class],
maxAttempts = 3,
backoff = Backoff(delay = 500, multiplier = 2.0)
)
fun getUser(id: Long): User {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(User::class.java)!!
}
재시도 흐름 (exponential backoff):
1차 시도 → 실패 → 500ms 대기
2차 시도 → 실패 → 1000ms 대기 (500 × 2.0)
3차 시도 → 성공 ✓ (또는 최종 실패)
재시도하면 안 되는 경우:
| 상태 코드 | 재시도 | 이유 |
|---|---|---|
| 400 Bad Request | X | 요청이 잘못된 것이므로 다시 보내도 같은 결과 |
| 401 Unauthorized | X | 인증 문제이므로 재시도 무의미 |
| 403 Forbidden | X | 권한 문제 |
| 404 Not Found | X | 리소스가 없는 것 |
| 409 Conflict | X | 비즈니스 충돌 |
| 429 Too Many Requests | O | 잠시 후 재시도하면 성공 가능 |
| 500 Internal Server Error | △ | 서버 버그일 수도, 일시 장애일 수도 |
| 502 Bad Gateway | O | 일시적인 네트워크 문제일 가능성 |
| 503 Service Unavailable | O | 서버 과부하, 잠시 후 복구 가능 |
| Connection Timeout | O | 네트워크 일시 장애 |
멱등성(Idempotency)도 고려해야 한다: GET은 몇 번 재시도해도 안전하다. 하지만 POST(주문 생성, 결제)를 재시도하면 중복 처리될 수 있다. POST 재시도가 필요하면 Idempotency Key를 함께 전송해야 한다.
// 멱등성 키를 사용하는 결제 API 호출
fun processPayment(request: PaymentRequest): PaymentResponse {
val idempotencyKey = UUID.randomUUID().toString()
return restClient.post()
.uri("/payments")
.header("Idempotency-Key", idempotencyKey)
.body(request)
.retrieve()
.body(PaymentResponse::class.java)!!
}
공통: 테스트
RestClient 테스트 - MockRestServiceServer
@RestClientTest(UserApiClient::class)
class UserApiClientTest {
@Autowired
private lateinit var client: UserApiClient
@Autowired
private lateinit var server: MockRestServiceServer
@Autowired
private lateinit var objectMapper: ObjectMapper
@Test
fun `사용자 조회 성공`() {
val expected = User(id = 1, name = "홍길동", email = "hong@test.com")
server.expect(requestTo("/users/1"))
.andExpect(method(HttpMethod.GET))
.andRespond(
withSuccess(
objectMapper.writeValueAsString(expected),
MediaType.APPLICATION_JSON
)
)
val result = client.getUser(1)
assertThat(result.name).isEqualTo("홍길동")
server.verify() // 기대한 요청이 실제로 발생했는지 검증
}
@Test
fun `사용자 조회 404`() {
server.expect(requestTo("/users/999"))
.andRespond(withResourceNotFound())
assertThrows<UserNotFoundException> {
client.getUser(999)
}
}
@Test
fun `서버 에러 시 502 반환`() {
server.expect(requestTo("/users/1"))
.andRespond(withServerError())
assertThrows<ExternalApiException> {
client.getUser(1)
}
}
}
MockRestServiceServer는 실제 HTTP 통신 없이 RestClient의 요청/응답을 가로채서 테스트한다. 외부 API가 없어도 다양한 시나리오(성공, 404, 500, 타임아웃)를 검증할 수 있다.
@HttpExchange 테스트 - Mock 인터페이스
@WebMvcTest(OrderController::class)
class OrderControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@MockitoBean
private lateinit var orderClient: OrderClient // 인터페이스라 Mock이 쉽다
@Test
fun `주문 조회`() {
val order = Order(id = 1, status = "PAID", amount = 50000)
given(orderClient.getOrder(1)).willReturn(order)
mockMvc.perform(get("/api/orders/1"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.status").value("PAID"))
}
}
@HttpExchange의 테스트 장점이 여기서 드러난다. 인터페이스이므로 @MockitoBean으로 바로 Mock을 만들 수 있다. RestClient를 직접 사용하면 MockRestServiceServer 설정이 필요하지만, @HttpExchange는 일반 서비스 Mock과 동일하다.
통합 테스트 - WireMock
실제 HTTP 통신까지 검증하려면 WireMock을 사용한다.
@SpringBootTest
@WireMockTest(httpPort = 8089)
class UserApiClientIntegrationTest {
@Autowired
private lateinit var client: UserApiClient
@Test
fun `외부 API 타임아웃 시 예외 발생`(wireMock: WireMockRuntimeInfo) {
stubFor(
get("/users/1")
.willReturn(ok().withFixedDelay(10_000)) // 10초 지연
)
assertThrows<ResourceAccessException> {
client.getUser(1) // Read Timeout 5초 → 타임아웃 예외
}
}
@Test
fun `외부 API 재시도 동작 확인`(wireMock: WireMockRuntimeInfo) {
stubFor(
get("/users/1")
.inScenario("retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(serverError()) // 1차: 500 에러
.willSetStateTo("second-attempt")
)
stubFor(
get("/users/1")
.inScenario("retry")
.whenScenarioStateIs("second-attempt")
.willReturn(okJson("""{"id":1,"name":"홍길동"}""")) // 2차: 성공
)
val result = client.getUser(1)
assertThat(result.name).isEqualTo("홍길동")
verify(2, getRequestedFor(urlEqualTo("/users/1"))) // 2번 호출됨
}
}
WireMock은 실제 HTTP 서버를 띄워서 요청을 수신하고 미리 정의한 응답을 반환한다. 타임아웃, 재시도, 네트워크 지연 같은 시나리오를 현실적으로 테스트할 수 있다.
프로덕션 체크리스트
여러 외부 서비스별 설정 분리
@Configuration
class HttpClientConfig {
// 일반 API - 빠른 타임아웃
@Bean
fun defaultRestClient(builder: RestClient.Builder): RestClient {
return builder
.baseUrl("https://api.internal.com")
.requestFactory(requestFactory(connectTimeout = 2, readTimeout = 5))
.build()
}
// 결제 API - 느린 타임아웃 허용
@Bean("paymentRestClient")
fun paymentRestClient(builder: RestClient.Builder): RestClient {
return builder
.baseUrl("https://api.payment.com")
.requestFactory(requestFactory(connectTimeout = 3, readTimeout = 15))
.build()
}
// 파일 업로드 API - 매우 느린 타임아웃
@Bean("fileRestClient")
fun fileRestClient(builder: RestClient.Builder): RestClient {
return builder
.baseUrl("https://api.storage.com")
.requestFactory(requestFactory(connectTimeout = 5, readTimeout = 60))
.build()
}
private fun requestFactory(connectTimeout: Long, readTimeout: Long): ClientHttpRequestFactory {
val httpClient = java.net.http.HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(connectTimeout))
.build()
return JdkClientHttpRequestFactory(httpClient).apply {
setReadTimeout(Duration.ofSeconds(readTimeout))
}
}
}
모든 외부 API에 동일한 타임아웃을 설정하면 안 된다. 결제 API는 카드사 응답을 기다려야 하므로 15초가 필요할 수 있지만, 내부 API는 5초면 충분하다.
로깅 - 무엇을 남겨야 하는가
private fun loggingInterceptor() = ClientHttpRequestInterceptor { request, body, execution ->
val log = LoggerFactory.getLogger("ExternalApi")
val startTime = System.currentTimeMillis()
log.info(">>> {} {} (body: {} bytes)", request.method, request.uri, body.size)
val response = execution.execute(request, body)
val elapsed = System.currentTimeMillis() - startTime
log.info(
"<<< {} {} ({}ms, body: {} bytes)",
response.statusCode, request.uri, elapsed, response.headers.contentLength
)
// 에러 응답이면 본문도 로깅
if (response.statusCode.isError) {
// 주의: 응답 본문을 읽으면 스트림이 소비된다.
// BufferingClientHttpResponseWrapper로 감싸야 재사용 가능
log.error("에러 응답 본문: {}", response.bodyAsString())
}
response
}
로깅할 것: HTTP 메서드, URI, 상태 코드, 소요 시간, 에러 응답 본문 로깅하지 말 것: 요청 본문에 포함된 개인정보(비밀번호, 카드번호, 주민번호), 인증 토큰
커넥션 풀 관리
RestClient의 기본 JdkClientHttpRequestFactory는 JDK의 HttpClient를 사용하고, 이것은 내부적으로 커넥션 풀을 관리한다. 하지만 Apache HttpClient 5를 사용하면 커넥션 풀을 더 세밀하게 제어할 수 있다.
// Apache HttpClient 5 사용 시
private fun apacheRequestFactory(): ClientHttpRequestFactory {
val connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(100) // 전체 커넥션 풀 크기
.setMaxConnPerRoute(20) // 호스트별 최대 커넥션
.build()
val httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.build()
return HttpComponentsClientHttpRequestFactory(httpClient).apply {
setConnectTimeout(Duration.ofSeconds(3))
setReadTimeout(Duration.ofSeconds(5))
}
}
MaxConnPerRoute가 중요하다. 기본값이 5인데, 특정 외부 API에 동시 요청이 많으면 커넥션 대기가 발생한다. 외부 서비스별 예상 동시 호출 수에 맞춰 설정한다.
정리: 실무에서 자주 하는 실수
| 실수 | 결과 | 해결 |
|---|---|---|
| 타임아웃 미설정 | 외부 API 장애 시 자기 서버도 먹통 | 반드시 connect/read timeout 설정 |
| 모든 에러에 재시도 | POST 중복 처리, 불필요한 부하 | 멱등한 요청만 재시도 |
| 에러 응답 그대로 전달 | 내부 정보 노출, 사용자 혼란 | 502로 래핑, 내부 로그에만 상세 기록 |
| 단일 타임아웃 | 결제 API 타임아웃 or 내부 API 무한 대기 | 서비스별 타임아웃 분리 |
| 로그에 토큰 노출 | 보안 사고 | 민감 헤더 마스킹 |
| 커넥션 풀 미설정 | 동시 요청 시 커넥션 대기 | MaxConnPerRoute 조정 |
댓글