Spring RestClient 에러 처리 심화

RestClient란?

Spring 6.1(Boot 3.2)에서 도입된 RestClientRestTemplate의 후계자다. WebClient의 플루언트 API를 동기 방식으로 제공하며, 블로킹 HTTP 클라이언트의 새로운 표준이다. Virtual Thread와 조합하면 WebClient의 논블로킹 없이도 높은 동시성을 달성할 수 있다.

기본 CRUD 요청

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient restClient(RestClient.Builder builder) {
        return builder
            .baseUrl("https://api.example.com/v1")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader("X-API-Key", "my-api-key")
            .requestFactory(new JdkClientHttpRequestFactory())  // JDK HttpClient 사용
            .build();
    }
}

@Service
@RequiredArgsConstructor
public class UserApiClient {

    private final RestClient restClient;

    // GET: 단건 조회
    public UserDto getUser(Long id) {
        return restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .body(UserDto.class);
    }

    // GET: 목록 조회 (ParameterizedTypeReference)
    public List<UserDto> getUsers(String status) {
        return restClient.get()
            .uri(uriBuilder -> uriBuilder
                .path("/users")
                .queryParam("status", status)
                .queryParam("limit", 100)
                .build())
            .retrieve()
            .body(new ParameterizedTypeReference<>() {});
    }

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

    // PUT: 수정
    public UserDto updateUser(Long id, UpdateUserRequest request) {
        return restClient.put()
            .uri("/users/{id}", id)
            .body(request)
            .retrieve()
            .body(UserDto.class);
    }

    // DELETE
    public void deleteUser(Long id) {
        restClient.delete()
            .uri("/users/{id}", id)
            .retrieve()
            .toBodilessEntity();
    }

    // 응답 전체 (헤더, 상태 코드 포함)
    public ResponseEntity<UserDto> getUserWithHeaders(Long id) {
        return restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .toEntity(UserDto.class);
    }
}

에러 처리: status handler

@Service
public class OrderApiClient {

    private final RestClient restClient;

    public OrderApiClient(RestClient.Builder builder) {
        this.restClient = builder
            .baseUrl("https://order-api.example.com")
            // 글로벌 에러 핸들러
            .defaultStatusHandler(HttpStatusCode::is5xxServerError, (request, response) -> {
                throw new ExternalServiceException(
                    "Order API 서버 에러: " + response.getStatusCode()
                );
            })
            .build();
    }

    public OrderDto getOrder(String orderId) {
        return restClient.get()
            .uri("/orders/{id}", orderId)
            .retrieve()
            // 요청별 에러 핸들러 (글로벌보다 우선)
            .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
                    throw new OrderNotFoundException(orderId);
                }
                if (response.getStatusCode() == HttpStatus.FORBIDDEN) {
                    throw new OrderAccessDeniedException(orderId);
                }
                // 응답 바디 읽기
                String body = new String(response.getBody().readAllBytes());
                throw new OrderApiException("주문 조회 실패: " + body);
            })
            .body(OrderDto.class);
    }

    // 에러 응답을 DTO로 파싱
    public OrderDto createOrder(CreateOrderRequest request) {
        return restClient.post()
            .uri("/orders")
            .body(request)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (req, response) -> {
                ObjectMapper mapper = new ObjectMapper();
                ErrorResponse error = mapper.readValue(
                    response.getBody(), ErrorResponse.class
                );
                throw new OrderValidationException(error.getErrors());
            })
            .body(OrderDto.class);
    }
}

onStatus()RestTemplateResponseErrorHandler보다 훨씬 직관적이다. 상태 코드별로 다른 예외를 던지고, 응답 바디를 에러 DTO로 파싱할 수 있다.

Interceptor: 로깅·인증·재시도

// 요청/응답 로깅 Interceptor
public class LoggingInterceptor implements ClientHttpRequestInterceptor {

    private static final Logger log = LoggerFactory.getLogger(LoggingInterceptor.class);

    @Override
    public ClientHttpResponse intercept(
            HttpRequest request, byte[] body,
            ClientHttpRequestExecution execution) throws IOException {

        long start = System.currentTimeMillis();
        log.info("→ {} {} body={}bytes", 
            request.getMethod(), request.getURI(), body.length);

        ClientHttpResponse response = execution.execute(request, body);

        log.info("← {} {}ms status={}", 
            request.getURI(), 
            System.currentTimeMillis() - start,
            response.getStatusCode());

        return response;
    }
}

// Bearer Token 자동 주입 Interceptor
public class BearerTokenInterceptor implements ClientHttpRequestInterceptor {

    private final TokenProvider tokenProvider;

    public BearerTokenInterceptor(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public ClientHttpResponse intercept(
            HttpRequest request, byte[] body,
            ClientHttpRequestExecution execution) throws IOException {

        String token = tokenProvider.getAccessToken();
        request.getHeaders().setBearerAuth(token);
        return execution.execute(request, body);
    }
}

// RestClient에 Interceptor 등록
@Bean
public RestClient restClient(RestClient.Builder builder, TokenProvider tokenProvider) {
    return builder
        .baseUrl("https://api.example.com")
        .requestInterceptor(new BearerTokenInterceptor(tokenProvider))
        .requestInterceptor(new LoggingInterceptor())
        .build();
}

Exchange: 저수준 응답 제어

// exchange()로 응답을 완전 제어
public <T> ApiResponse<T> callWithMetrics(String path, Class<T> type) {
    return restClient.get()
        .uri(path)
        .exchange((request, response) -> {
            HttpStatusCode status = response.getStatusCode();
            HttpHeaders headers = response.getHeaders();
            
            // 커스텀 헤더에서 메타데이터 추출
            String requestId = headers.getFirst("X-Request-Id");
            String rateLimit = headers.getFirst("X-RateLimit-Remaining");

            if (status.is2xxSuccessful()) {
                ObjectMapper mapper = new ObjectMapper();
                T body = mapper.readValue(response.getBody(), type);
                return new ApiResponse<>(body, requestId, 
                    Integer.parseInt(rateLimit));
            }

            if (status == HttpStatus.TOO_MANY_REQUESTS) {
                String retryAfter = headers.getFirst("Retry-After");
                throw new RateLimitExceededException(
                    Integer.parseInt(retryAfter));
            }

            throw new ApiException("Unexpected status: " + status);
        });
}

// 파일 다운로드: 스트리밍
public void downloadFile(String fileId, OutputStream out) {
    restClient.get()
        .uri("/files/{id}/download", fileId)
        .exchange((request, response) -> {
            response.getBody().transferTo(out);
            return null;
        });
}

exchange()retrieve()와 달리 자동 에러 처리가 적용되지 않는다. 모든 상태 코드를 직접 핸들링해야 하지만, 응답 헤더·스트리밍 등 세밀한 제어가 가능하다.

타임아웃과 커넥션 풀

@Bean
public RestClient restClient(RestClient.Builder builder) {
    // JDK HttpClient 기반 설정
    HttpClient httpClient = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(3))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

    JdkClientHttpRequestFactory requestFactory = 
        new JdkClientHttpRequestFactory(httpClient);
    requestFactory.setReadTimeout(Duration.ofSeconds(10));

    return builder
        .baseUrl("https://api.example.com")
        .requestFactory(requestFactory)
        .build();
}

// Apache HttpClient 5 기반 (커넥션 풀링)
@Bean
public RestClient restClientWithPool(RestClient.Builder builder) {
    PoolingHttpClientConnectionManager connManager = 
        PoolingHttpClientConnectionManagerBuilder.create()
            .setMaxConnTotal(100)           // 전체 최대 커넥션
            .setMaxConnPerRoute(20)         // 호스트당 최대 커넥션
            .setDefaultConnectionConfig(ConnectionConfig.custom()
                .setConnectTimeout(Timeout.ofSeconds(3))
                .setSocketTimeout(Timeout.ofSeconds(10))
                .build())
            .build();

    CloseableHttpClient httpClient = HttpClients.custom()
        .setConnectionManager(connManager)
        .setDefaultRequestConfig(RequestConfig.custom()
            .setResponseTimeout(Timeout.ofSeconds(10))
            .build())
        .build();

    return builder
        .baseUrl("https://api.example.com")
        .requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient))
        .build();
}

프로덕션에서는 Apache HttpClient 5의 커넥션 풀링을 사용한다. JDK HttpClient는 간단하지만 커넥션 풀 세부 제어가 제한적이다. HikariCP 커넥션 풀과 유사하게 maxConnTotalmaxConnPerRoute를 튜닝해야 한다. 관련 내용은 Spring Boot HikariCP 커넥션 풀 글을 참고하자.

테스트: MockRestServiceServer

@SpringBootTest
class UserApiClientTest {

    @Autowired
    private UserApiClient userApiClient;

    private MockRestServiceServer mockServer;

    @Autowired
    private RestClient.Builder restClientBuilder;

    @BeforeEach
    void setup() {
        // RestClient에 MockServer 바인딩
        MockRestServiceServer.MockRestServiceServerBuilder serverBuilder =
            MockRestServiceServer.bindTo(restClientBuilder);
        mockServer = serverBuilder.build();
    }

    @Test
    void getUser_성공() {
        mockServer.expect(requestTo("/users/1"))
            .andExpect(method(HttpMethod.GET))
            .andRespond(withSuccess("""
                {"id": 1, "name": "Alice", "email": "alice@test.com"}
                """, MediaType.APPLICATION_JSON));

        UserDto user = userApiClient.getUser(1L);

        assertThat(user.getName()).isEqualTo("Alice");
        mockServer.verify();
    }

    @Test
    void getUser_404_예외() {
        mockServer.expect(requestTo("/users/999"))
            .andRespond(withStatus(HttpStatus.NOT_FOUND));

        assertThrows(OrderNotFoundException.class, 
            () -> userApiClient.getUser(999L));
    }

    @Test
    void createUser_요청바디_검증() {
        mockServer.expect(requestTo("/users"))
            .andExpect(method(HttpMethod.POST))
            .andExpect(jsonPath("$.name").value("Bob"))
            .andExpect(jsonPath("$.email").value("bob@test.com"))
            .andRespond(withSuccess("""
                {"id": 2, "name": "Bob", "email": "bob@test.com"}
                """, MediaType.APPLICATION_JSON));

        CreateUserRequest request = new CreateUserRequest("Bob", "bob@test.com");
        UserDto result = userApiClient.createUser(request);

        assertThat(result.getId()).isEqualTo(2L);
        mockServer.verify();
    }
}

RestTemplate → RestClient 마이그레이션

// ❌ RestTemplate (레거시)
@Deprecated
public UserDto getUserOld(Long id) {
    return restTemplate.getForObject("/users/{id}", UserDto.class, id);
}

// ✅ RestClient (신규)
public UserDto getUser(Long id) {
    return restClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .body(UserDto.class);
}

// ❌ RestTemplate: exchange로 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<UserDto> response = restTemplate.exchange(
    "/users/{id}", HttpMethod.GET, entity, UserDto.class, id);

// ✅ RestClient: 체이닝으로 깔끔
ResponseEntity<UserDto> response = restClient.get()
    .uri("/users/{id}", id)
    .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
    .retrieve()
    .toEntity(UserDto.class);

// 기존 RestTemplate을 RestClient로 감싸기 (점진적 마이그레이션)
RestClient restClient = RestClient.create(existingRestTemplate);

RestClient.create(restTemplate)로 기존 RestTemplate의 설정(interceptor, error handler, request factory)을 그대로 가져올 수 있어, 점진적 마이그레이션이 가능하다. RestTemplate의 에러 처리 방식과 비교는 Spring WebClient·RestClient 심화 글에서 다루고 있다.

마무리

기능 RestTemplate RestClient WebClient
API 스타일 메서드 기반 플루언트 체이닝 플루언트 체이닝
실행 모델 동기 동기 비동기/리액티브
에러 처리 ResponseErrorHandler onStatus 체이닝 onStatus 체이닝
HTTP Interface
상태 유지보수 모드 권장 리액티브 전용

RestClient는 Spring의 동기 HTTP 클라이언트 표준이다. WebClient의 직관적 API를 블로킹 컨텍스트에서 사용할 수 있고, Virtual Thread와 조합하면 논블로킹 못지않은 성능을 달성한다. 신규 프로젝트에서는 RestTemplate 대신 RestClient를 선택하는 것이 정답이다.

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