Spring WebClient·RestClient 심화

Spring HTTP 클라이언트의 진화

Spring에서 외부 API를 호출하는 방식은 RestTemplateWebClientRestClient + HTTP Interface로 진화해 왔습니다. Spring Boot 3.2+에서는 RestClient(동기)와 WebClient(비동기/리액티브), 그리고 선언적 HTTP Interface를 상황에 맞게 선택할 수 있습니다.

이 글에서는 각 클라이언트의 특성, 실전 설정, 에러 핸들링, 재시도/타임아웃 전략, HTTP Interface의 선언적 패턴까지 다룹니다.

WebClient: 비동기/리액티브 HTTP 클라이언트

기본 설정

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient(WebClient.Builder builder) {
        return builder
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            // 커넥션 풀 및 타임아웃 설정
            .clientConnector(new ReactorClientHttpConnector(
                HttpClient.create()
                    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
                    .responseTimeout(Duration.ofSeconds(5))
                    .doOnConnected(conn -> conn
                        .addHandlerLast(new ReadTimeoutHandler(5))
                        .addHandlerLast(new WriteTimeoutHandler(5)))
            ))
            // 응답 버퍼 크기 제한 (기본 256KB → 1MB)
            .codecs(configurer -> configurer
                .defaultCodecs()
                .maxInMemorySize(1024 * 1024))
            .build();
    }
}

CRUD 요청 패턴

@Service
@RequiredArgsConstructor
public class UserApiClient {

    private final WebClient webClient;

    // GET — Mono (단일)
    public Mono<UserDto> getUser(Long id) {
        return webClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .bodyToMono(UserDto.class);
    }

    // GET — Flux (목록)
    public Flux<UserDto> getAllUsers() {
        return webClient.get()
            .uri("/users")
            .retrieve()
            .bodyToFlux(UserDto.class);
    }

    // POST
    public Mono<UserDto> createUser(CreateUserRequest request) {
        return webClient.post()
            .uri("/users")
            .bodyValue(request)
            .retrieve()
            .bodyToMono(UserDto.class);
    }

    // PUT with header
    public Mono<UserDto> updateUser(Long id, UpdateUserRequest request, String token) {
        return webClient.put()
            .uri("/users/{id}", id)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .bodyValue(request)
            .retrieve()
            .bodyToMono(UserDto.class);
    }

    // DELETE — 응답 바디 없음
    public Mono<Void> deleteUser(Long id) {
        return webClient.delete()
            .uri("/users/{id}", id)
            .retrieve()
            .bodyToMono(Void.class);
    }
}

에러 핸들링

public Mono<UserDto> getUserWithErrorHandling(Long id) {
    return webClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        // 상태 코드별 에러 처리
        .onStatus(HttpStatusCode::is4xxClientError, response -> {
            if (response.statusCode() == HttpStatus.NOT_FOUND) {
                return Mono.error(new UserNotFoundException(id));
            }
            return response.bodyToMono(ErrorResponse.class)
                .flatMap(body -> Mono.error(
                    new ApiClientException(response.statusCode(), body.getMessage())));
        })
        .onStatus(HttpStatusCode::is5xxServerError, response ->
            Mono.error(new ApiServerException("외부 API 서버 오류")))
        .bodyToMono(UserDto.class)
        // 타임아웃
        .timeout(Duration.ofSeconds(3))
        // 폴백
        .onErrorResume(TimeoutException.class, ex ->
            Mono.error(new ApiTimeoutException("API 응답 시간 초과")));
}

RestClient: Spring 6.1+ 동기 클라이언트

WebClient의 fluent API를 동기 환경에서 사용하고 싶을 때 RestClient가 정답입니다. RestTemplate의 후계자로, 블로킹 방식이지만 API가 훨씬 직관적입니다.

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient restClient(RestClient.Builder builder) {
        return builder
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .requestInterceptor((request, body, execution) -> {
                // 공통 로깅 인터셉터
                log.debug("→ {} {}", request.getMethod(), request.getURI());
                ClientHttpResponse response = execution.execute(request, body);
                log.debug("← {}", response.getStatusCode());
                return response;
            })
            .build();
    }
}

@Service
@RequiredArgsConstructor
public class UserApiClient {

    private final RestClient restClient;

    public UserDto getUser(Long id) {
        return restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                throw new ApiClientException(response.getStatusCode());
            })
            .body(UserDto.class);
    }

    public List<UserDto> getAllUsers() {
        return restClient.get()
            .uri("/users")
            .retrieve()
            .body(new ParameterizedTypeReference<List<UserDto>>() {});
    }

    public UserDto createUser(CreateUserRequest request) {
        return restClient.post()
            .uri("/users")
            .body(request)
            .retrieve()
            .body(UserDto.class);
    }
}

HTTP Interface: 선언적 API 클라이언트

Spring 6에서 도입된 HTTP Interface는 Feign처럼 인터페이스 선언만으로 API 클라이언트를 생성합니다. 별도 라이브러리 의존 없이 Spring 네이티브로 동작합니다.

// 인터페이스 선언
public interface UserApi {

    @GetExchange("/users/{id}")
    UserDto getUser(@PathVariable Long id);

    @GetExchange("/users")
    List<UserDto> getAllUsers();

    @GetExchange("/users")
    List<UserDto> searchUsers(@RequestParam String name,
                               @RequestParam(required = false) String email);

    @PostExchange("/users")
    UserDto createUser(@RequestBody CreateUserRequest request);

    @PutExchange("/users/{id}")
    UserDto updateUser(@PathVariable Long id,
                       @RequestBody UpdateUserRequest request);

    @DeleteExchange("/users/{id}")
    void deleteUser(@PathVariable Long id);

    // 리액티브도 지원
    @GetExchange("/users/{id}")
    Mono<UserDto> getUserReactive(@PathVariable Long id);
}
@Configuration
public class HttpInterfaceConfig {

    // RestClient 기반 (동기)
    @Bean
    public UserApi userApi(RestClient restClient) {
        RestClientAdapter adapter = RestClientAdapter.create(restClient);
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
            .builderFor(adapter)
            .build();
        return factory.createClient(UserApi.class);
    }

    // WebClient 기반 (리액티브)
    @Bean
    public UserApi userApiReactive(WebClient webClient) {
        WebClientAdapter adapter = WebClientAdapter.create(webClient);
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
            .builderFor(adapter)
            .build();
        return factory.createClient(UserApi.class);
    }
}

이제 UserApi를 주입받아 일반 메서드 호출처럼 사용할 수 있습니다. 구현체는 Spring이 프록시로 자동 생성합니다.

재시도 전략: Resilience4j 연동

Spring Cloud Gateway처럼 외부 API 호출에도 재시도와 서킷브레이커가 필수입니다.

@Configuration
public class ResilienceConfig {

    @Bean
    public WebClient resilientWebClient(WebClient.Builder builder) {
        // Retry 설정
        Retry retry = Retry.backoff(3, Duration.ofMillis(500))
            .maxBackoff(Duration.ofSeconds(3))
            .filter(throwable ->
                throwable instanceof WebClientResponseException.ServiceUnavailable
                || throwable instanceof TimeoutException)
            .doBeforeRetry(signal ->
                log.warn("재시도 #{}: {}", signal.totalRetries(), signal.failure().getMessage()));

        WebClient webClient = builder
            .baseUrl("https://api.example.com")
            .build();

        // WebClient에 retry 적용하는 유틸
        return webClient.mutate()
            .filter((request, next) ->
                next.exchange(request)
                    .retryWhen(retry))
            .build();
    }
}

선택 가이드

  • RestClient: Spring MVC 동기 앱에서 외부 API 호출 → 기본 선택
  • WebClient: WebFlux 리액티브 앱, 또는 동기 앱에서 비동기 호출이 필요한 경우
  • HTTP Interface: API가 여러 개이고 선언적 패턴 선호 시 → RestClient/WebClient 위에 구축
  • RestTemplate: 레거시. 신규 프로젝트에서는 사용하지 않음

테스트: MockWebServer

@SpringBootTest
class UserApiClientTest {

    private MockWebServer mockServer;
    private UserApiClient client;

    @BeforeEach
    void setUp() throws IOException {
        mockServer = new MockWebServer();
        mockServer.start();

        RestClient restClient = RestClient.builder()
            .baseUrl(mockServer.url("/").toString())
            .build();
        client = new UserApiClient(restClient);
    }

    @Test
    void getUser_정상응답() {
        mockServer.enqueue(new MockResponse()
            .setBody("""
                {"id": 1, "name": "홍길동", "email": "hong@example.com"}
                """)
            .setHeader("Content-Type", "application/json"));

        UserDto user = client.getUser(1L);

        assertThat(user.getName()).isEqualTo("홍길동");
        RecordedRequest request = mockServer.takeRequest();
        assertThat(request.getPath()).isEqualTo("/users/1");
    }

    @Test
    void getUser_404_예외발생() {
        mockServer.enqueue(new MockResponse().setResponseCode(404));
        assertThrows(UserNotFoundException.class, () -> client.getUser(999L));
    }

    @AfterEach
    void tearDown() throws IOException {
        mockServer.shutdown();
    }
}

마무리

Spring Boot 3.2+에서는 RestClient가 동기 HTTP 클라이언트의 표준, WebClient가 비동기/리액티브의 표준입니다. 그 위에 HTTP Interface를 얹으면 Feign 없이도 선언적 API 클라이언트를 구축할 수 있습니다. Method Security와 결합하여 API 호출 권한을 제어하고, Resilience4j로 재시도/서킷브레이커를 적용하면 안정적인 외부 API 통합이 완성됩니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux