RestClient — RestTemplate 후속과 OAuth2

2026-05-02AWS SAA-C03 스터디

Spring Boot 3 핵심 정리 시리즈 8편. Spring HTTP 클라이언트가 처음엔 왜 헷갈리는지부터 호텔 예약 대행자 비유로 풀어가며 — RestTemplate vs RestClient vs WebClient vs FeignClient 4가지 비교, RestTemplateBuilder 주입 패턴, getForObject·exchange·postForLocation 메서드 정리, UriComponentsBuilder로 안전한 URL 구성, MockRestServiceServer와 @RestClientTest로 외부 서버 없이 테스트, 7편에서 만든 OAuth2 토큰을 ClientHttpRequestInterceptor로 자동 주입까지 처음 다루는 분도 따라올 수 있게 친절하게 풀어쓴 8편.

📚 Spring Boot 3 핵심 정리 · 8편 / 14편 — RestTemplate 후속과 OAuth2

이 글은 Spring Boot 3 핵심 정리 시리즈의 여덟 번째 편입니다. 7편까지 따라오셨다면 이제 우리 서비스에 OAuth2 + JWT 자물쇠가 채워져 있어요. 그런데 마이크로서비스 아키텍처에서는 — 한 서비스가 다른 서비스를 호출하는 일이 매일 일어납니다. 결제 서비스가 회원 서비스에, 주문 서비스가 재고 서비스에 — HTTP로 자기들끼리 대화해요. 이때 클라이언트 역할을 하는 도구가 8편의 주제예요.

이번 편에서는 RestClient 와 그 선배인 RestTemplate 을 중심으로 — RestTemplateBuilder 주입 패턴, 주요 메서드(getForObject·exchange·postForLocation), MockRestServiceServer로 외부 서버 없이 테스트, 그리고 7편에서 만든 OAuth2 토큰을 자동으로 박아 보내는 인터셉터까지 풀어 갑니다.

왜 HTTP 클라이언트가 처음엔 헷갈릴까요

이유는 네 가지예요.

첫째, 클라이언트 종류가 너무 많아요. RestTemplate·WebClient·RestClient·FeignClient — 이름만 봐서는 어느 게 어느 시점에 도입됐고, 지금 어떤 게 권장되는지 안 잡혀요. 검색하면 시대마다 다른 답이 나오니 더 헷갈립니다.

둘째, 메서드 이름이 비슷비슷합니다. getForObject·getForEntity·postForObject·postForEntity·postForLocation·exchange·execute — 아홉 개 가까이 되는 메서드 중 어떤 걸 언제 써야 할지 한 번에 안 잡혀요.

셋째, 테스트가 까다로워요. 클라이언트 코드는 실제 HTTP 서버에 의존하는데, 단위 테스트할 때는 그 서버를 띄울 수 없죠. MockRestServiceServer·RestTemplateBuilder Mock·@RestClientTest 같은 조합을 알아야 격리된 테스트가 가능해요.

넷째, OAuth2 토큰을 매 요청마다 박는 코드가 지저분해요. 호출할 때마다 토큰 헤더를 직접 박으면 보일러플레이트가 폭발합니다. 이걸 깔끔하게 자동화하는 인터셉터 패턴을 알아야 해요.

해결법은 한 가지예요. HTTP 클라이언트를 "호텔 예약 대행자" 로 잡고 풀면 갑자기 명확해집니다. 우리 서비스는 손님이고, RestTemplate은 호텔(외부 API)에 직접 전화 걸어 객실 예약·취소·조회를 대행하는 직원이에요. 이 비유로 풀어 갑니다.

Spring HTTP 클라이언트 — 4가지 비교

먼저 큰 그림부터 잡고 갈게요. Spring 생태계에는 시대별로 등장한 4가지 HTTP 클라이언트가 있어요.

클라이언트방식도입 버전상태특징
RestTemplate동기/블로킹Spring 3.0유지보수 모드안정적, 광범위한 사용
WebClient비동기/리액티브Spring 5.0활성 개발 중논블로킹, 스트리밍 지원
RestClient동기/블로킹Spring 6.1신규RestTemplate의 현대적 대안, Fluent API
FeignClient동기/블로킹Spring CloudSpring Cloud선언적 HTTP 클라이언트

룰을 한 줄로 — 레거시 코드는 RestTemplate, 신규 동기 코드는 RestClient, 리액티브 스택은 WebClient. RestTemplate이 deprecated는 아니지만 "유지보수 모드"라서 새 기능은 RestClient에 들어갑니다. 이번 글의 학습용으로는 RestTemplate 패턴을 먼저 배우면 — RestClient·WebClient로 옮겨 가는 게 자연스러워요. 메서드 이름과 흐름이 비슷하거든요.

여기서 시험 함정이 하나 있어요. RestTemplate이 deprecated가 아닙니다. 인터넷에 "RestTemplate은 곧 사라진다"는 표현이 있는데, 정식 입장은 "유지보수 모드"예요. 새 기능 추가는 멈췄지만 보안 패치는 계속됩니다. 기존 코드는 그대로 써도 되고, 신규 코드만 RestClient로 가는 게 안전한 선택이에요.

RestTemplate 기본 — RestTemplateBuilder 주입 패턴

RestTemplate은 동기식 블로킹 HTTP 클라이언트예요. HTTP 메서드(GET·POST·PUT·DELETE)에 대응하는 메서드를 제공하고, JSON/XML 직렬화를 자동 처리합니다.

회사 비유로 — RestTemplate"외부 호텔에 전화 걸어 객실을 예약·취소하는 비서" 같은 거예요. "이 URL로 GET 요청 보내고 결과를 자바 객체로 받아 줘"라고 한 줄 지시하면 — JSON 파싱, HTTP 헤더, 인코딩까지 다 알아서 처리해 줍니다.

다만 RestTemplate을 직접 new RestTemplate()으로 만들지 말고, RestTemplateBuilder를 주입받아 한 번에 빌드하는 패턴이 Spring Boot 환경에서 권장돼요.

@Component
public class ProductClientImpl implements ProductClient {

    public static final String PRODUCT_PATH = "/api/v1/product";
    public static final String PRODUCT_PATH_ID = PRODUCT_PATH + "/{productId}";

    private final RestTemplate restTemplate;

    // RestTemplateBuilder를 주입받아 RestTemplate 생성
    // 빌더에 박힌 공통 설정(인터셉터·타임아웃 등)이 자동 적용됨
    public ProductClientImpl(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    @Override
    public Page<ProductDTO> listProducts() {
        return listProducts(null, null, null, null, null);
    }

    @Override
    public Page<ProductDTO> listProducts(String productName, String category,
                                         Boolean showInventory, Integer pageNumber,
                                         Integer pageSize) {
        // UriComponentsBuilder로 쿼리 파라미터를 포함한 URL 안전하게 구성
        UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromPath(PRODUCT_PATH);

        if (productName != null) {
            uriComponentsBuilder.queryParam("productName", productName);
        }
        if (category != null) {
            uriComponentsBuilder.queryParam("category", category);
        }
        if (showInventory != null) {
            uriComponentsBuilder.queryParam("showInventory", showInventory);
        }
        if (pageNumber != null) {
            uriComponentsBuilder.queryParam("pageNumber", pageNumber);
        }
        if (pageSize != null) {
            uriComponentsBuilder.queryParam("pageSize", pageSize);
        }

        // exchange는 ResponseEntity를 반환하여 헤더 등 메타데이터도 접근 가능
        ResponseEntity<ProductDTOPageImpl> response = restTemplate.exchange(
                uriComponentsBuilder.toUriString(),
                HttpMethod.GET,
                null,
                ProductDTOPageImpl.class);

        return response.getBody();
    }
}

여기서 시험 함정이 하나 있어요. RestTemplateBuilder.build()는 생성자에서 한 번만 호출해야 합니다. 메서드 안에서 매번 build()를 부르면 새 인스턴스가 만들어져 — 빌더에 박힌 공통 설정이 다음 챕터에서 보여 드릴 MockRestServiceServer와 바인딩이 안 돼요. 테스트가 깨집니다.

RestTemplate 주요 메서드

자주 쓰는 메서드 9개를 한 표로 정리해 둘게요.

메서드HTTP 메서드반환 타입설명
getForObject()GETT응답 본문을 객체로 반환
getForEntity()GETResponseEntity응답 전체(헤더+본문) 반환
postForObject()POSTT요청 본문 전송 후 응답 본문 반환
postForLocation()POSTURI요청 전송 후 Location 헤더 반환
postForEntity()POSTResponseEntity응답 전체 반환
put()PUTvoid리소스 업데이트
delete()DELETEvoid리소스 삭제
exchange()모두ResponseEntity임의 HTTP 메서드, 헤더 설정 가능
execute()모두T최고 수준의 유연성

기본 사용 패턴을 코드로 묶어 보겠습니다.

public class ProductClientImpl implements ProductClient {

    // GET — 단건 조회
    @Override
    public Optional<ProductDTO> getProductById(UUID productId) {
        return Optional.ofNullable(
                restTemplate.getForObject(PRODUCT_PATH_ID, ProductDTO.class, productId)
        );
    }

    // POST — 생성
    // POST 요청 후 서버는 Location 헤더에 새로 생성된 리소스 URL을 반환
    // 그 URL로 다시 GET 요청하여 생성된 리소스를 반환
    @Override
    public ProductDTO createProduct(ProductDTO newProduct) {
        URI location = restTemplate.postForLocation(PRODUCT_PATH, newProduct);

        // Location 헤더의 URL로 생성된 상품 조회
        return restTemplate.getForObject(location.getPath(), ProductDTO.class);
    }

    // PUT — 전체 수정
    @Override
    public ProductDTO updateProduct(UUID productId, ProductDTO product) {
        restTemplate.put(PRODUCT_PATH_ID, product, productId);
        return getProductById(productId).orElseThrow();
    }

    // PATCH — 부분 수정 (RestTemplate에는 patchForObject가 없어 exchange 사용)
    @Override
    public ProductDTO patchProduct(UUID productId, ProductDTO product) {
        restTemplate.exchange(
                PRODUCT_PATH_ID,
                HttpMethod.PATCH,
                new HttpEntity<>(product),
                Void.class,
                productId);
        return getProductById(productId).orElseThrow();
    }

    // DELETE — 삭제
    @Override
    public void deleteProduct(UUID productId) {
        restTemplate.delete(PRODUCT_PATH_ID, productId);
    }
}

여기서 시험 함정이 하나 있어요. RestTemplate에는 patchForObject가 없습니다. PATCH 요청은 exchange(HttpMethod.PATCH, ...)로 처리해야 해요. 가끔 인터넷에서 "왜 patchForObject가 안 되지"라고 묻는 글이 있는데 — 메서드 자체가 없는 거예요.

또 하나 — POST 응답에서 ID를 받아 오는 패턴입니다. 새 리소스를 만들면 서버가 Location: /api/v1/product/{id} 헤더에 새 ID URL을 박아 줘요. 클라이언트는 그 URL로 다시 GET을 날려 생성된 리소스를 받습니다. postForLocation이 그래서 따로 있는 거예요.

URL 구성 — UriComponentsBuilder가 정답

URL을 만들 때 문자열 연결로 짜면 함정이 줄지어 따라옵니다. 한국어·공백·특수 문자가 인코딩 안 돼서 깨지거나, 슬래시가 두 번 붙거나, 쿼리 파라미터 순서가 뒤섞여요. 이걸 다 해결해 주는 게 UriComponentsBuilder 입니다.

// 잘못된 예 — 문자열 연결 (특수 문자 인코딩 문제)
String url = "http://localhost:8080/api/v1/product?productName=" + productName;

// 올바른 예 — UriComponentsBuilder
URI uri = UriComponentsBuilder
        .fromUriString("http://localhost:8080")
        .path("/api/v1/product/{productId}")
        .queryParam("productName", productName)
        .queryParam("pageNumber", pageNumber)
        .buildAndExpand(productId)
        .toUri();

// 경로 변수만 있는 경우
URI uri = UriComponentsBuilder
        .fromPath(PRODUCT_PATH + "/{productId}")
        .build(productId);

여기서 시험 함정이 하나 있어요. 경로 변수에 UUID를 String.format으로 박는 패턴도 자주 보이는데 — 이것도 인코딩 문제가 발생할 수 있어요. RestTemplate이 자체적으로 경로 변수를 안전하게 처리해 주니, 마지막 인자로 변수를 그대로 넘기는 패턴을 씁니다.

// 잘못된 예 — 문자열 포맷
String url = String.format("/api/v1/product/%s", productId.toString());

// 올바른 예 — RestTemplate이 안전하게 처리
restTemplate.getForObject("/api/v1/product/{productId}", ProductDTO.class, productId);

JSON 직렬화 — Page 함정

Page 같은 제네릭 타입을 받으면 — Jackson이 직렬화/역직렬화 시 타입 정보를 잃는 문제가 있어요. 이걸 해결하려면 Page 인터페이스를 구현하는 구체 클래스를 따로 만듭니다.

// Page<ProductDTO> 역직렬화를 위한 커스텀 구현
@JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"})
public class ProductDTOPageImpl extends PageImpl<ProductDTO> {

    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public ProductDTOPageImpl(
            @JsonProperty("content") List<ProductDTO> content,
            @JsonProperty("number") int page,
            @JsonProperty("size") int size,
            @JsonProperty("totalElements") long total) {
        super(content, PageRequest.of(page, size), total);
    }
}

@JsonIgnoreProperties(ignoreUnknown = true) — 응답 JSON에 우리가 모르는 필드가 있어도 무시. @JsonCreator — 생성자에 직접 매핑. 이 두 가지로 페이징 응답을 깔끔하게 받을 수 있어요.

RestTemplate 테스트 — MockRestServiceServer

RestTemplate 코드를 단위 테스트할 때 — 실제 HTTP 서버에 의존하면 테스트가 느리고 불안정해져요. MockRestServiceServer 가 가짜 서버 역할을 해 줍니다.

가장 쉬운 패턴은 @RestClientTest 어노테이션이에요. 테스트할 클라이언트 클래스만 콘텍스트에 올려서 — MockRestServiceServer까지 자동 구성됩니다.

@RestClientTest(ProductClientImpl.class)  // 테스트할 클라이언트 클래스 지정
public class ProductClientMockTest {

    @Autowired
    ProductClient productClient;

    @Autowired
    MockRestServiceServer server;  // 가짜 HTTP 서버

    @Autowired
    ObjectMapper objectMapper;     // JSON 변환기

    @Test
    void testListProducts() throws JsonProcessingException {
        // 1. 예상 응답 데이터 준비
        ProductDTO dto = getProductDTO();
        String payload = objectMapper.writeValueAsString(
                createPage(List.of(dto)));

        // 2. 가짜 서버 동작 설정
        server.expect(method(HttpMethod.GET))
              .andExpect(requestTo("http://localhost:8080" + ProductClientImpl.PRODUCT_PATH))
              .andRespond(withSuccess(payload, MediaType.APPLICATION_JSON));

        // 3. 실제 테스트 실행
        Page<ProductDTO> dtos = productClient.listProducts();

        // 4. 검증
        assertThat(dtos.getContent().size()).isGreaterThan(0);
        server.verify();  // 예상했던 요청이 실제로 발생했는지 확인
    }

    private ProductDTO getProductDTO() {
        return ProductDTO.builder()
                .id(UUID.randomUUID())
                .productName("Test Product")
                .category("Books")
                .upc("12345")
                .price(new BigDecimal("10.00"))
                .build();
    }
}

흐름은 간단해요. (1) 응답 페이로드를 JSON 문자열로 만들고 → (2) 가짜 서버가 어떤 요청에 어떻게 응답할지 미리 등록(server.expect(...).andRespond(...)) → (3) 실제 클라이언트 호출 → (4) server.verify()로 예상 요청이 실제로 갔는지 확인.

여기서 정말 중요한 시험 함정 — server.verify()를 호출하지 않으면 예상했던 요청이 안 갔어도 테스트가 통과합니다. "내가 등록해 놓은 요청은 실제로 갔는가"를 검증하는 게 verify()예요. 빠뜨리면 — 클라이언트 메서드를 잘못 만들어 호출이 아예 안 가도 테스트가 그린이 됩니다. 반드시 끝에 박는 습관을 들이세요.

여러 HTTP 호출 — POST + GET 조합

POST 후 Location 헤더로 GET 하는 createProduct 같은 메서드는 — Mock 서버에 두 번의 호출을 순서대로 등록해야 해요.

@Test
void testCreateProduct() throws JsonProcessingException {
    ProductDTO dto = getProductDTO();

    // POST 요청에 대한 응답 설정 — 새 리소스의 URL을 Location 헤더로 반환
    URI uri = UriComponentsBuilder
            .fromPath(ProductClientImpl.PRODUCT_PATH + "/{productId}")
            .build(dto.getId());

    server.expect(method(HttpMethod.POST))
          .andExpect(requestTo("http://localhost:8080" + ProductClientImpl.PRODUCT_PATH))
          .andRespond(withAcceptedLocation(uri));  // 202 Accepted + Location 헤더

    // 이어지는 GET 요청에 대한 응답 설정
    String payload = objectMapper.writeValueAsString(dto);
    server.expect(method(HttpMethod.GET))
          .andExpect(requestTo("http://localhost:8080" + uri.getPath()))
          .andRespond(withSuccess(payload, MediaType.APPLICATION_JSON));

    // 테스트 실행
    ProductDTO result = productClient.createProduct(dto);

    // 검증
    assertThat(result.getId()).isEqualTo(dto.getId());
}

여기서 시험 함정이 하나 있어요. POST 요청 Mock 설정에 ID를 박지 마세요. 리소스를 생성하는 시점에는 ID가 아직 없어요. 서버가 응답으로 ID를 부여해 Location 헤더에 박아 줍니다. 그러니 POST URL은 /api/v1/product (ID 없음)이고, Location 헤더에 ID가 들어 있는 패턴입니다.

// 잘못된 예 — POST URL에 ID 포함
server.expect(method(HttpMethod.POST))
      .andExpect(requestToUriTemplate(PRODUCT_PATH + "/{productId}", dto.getId()))  // 잘못!

// 올바른 예 — ID 없는 기본 경로
server.expect(method(HttpMethod.POST))
      .andExpect(requestTo(PRODUCT_PATH))
      .andRespond(withAcceptedLocation(uri));  // Location 헤더에 생성된 ID

Mock 응답 메서드 — 자주 쓰는 6가지

메서드HTTP 상태설명
withSuccess(body, mediaType)200 OK성공 응답과 본문
withNoContent()204 No Content본문 없는 성공
withAcceptedLocation(uri)202 Accepted + Location생성 요청 응답
withBadRequest()400 Bad Request잘못된 요청
withUnauthorizedRequest()401 Unauthorized인증 실패
withStatus(HttpStatus)임의임의 상태 코드

@BeforeEach로 공통 설정 묶기

여러 테스트에서 같은 ProductDTO·JSON 페이로드를 만드는 코드가 반복되면 — DRY 원칙 위반이에요. @BeforeEach로 한 번에 묶습니다.

@RestClientTest(ProductClientImpl.class)
public class ProductClientMockTest {

    @Autowired ProductClient productClient;
    @Autowired MockRestServiceServer server;
    @Autowired ObjectMapper objectMapper;

    ProductDTO dto;
    String dtoJson;

    @BeforeEach  // 각 테스트 실행 전에 초기화
    void setUp() throws JsonProcessingException {
        dto = ProductDTO.builder()
                .id(UUID.randomUUID())
                .productName("Test Product")
                .category("Books")
                .upc("12345")
                .price(new BigDecimal("10.00"))
                .quantityOnHand(100)
                .build();

        dtoJson = objectMapper.writeValueAsString(dto);
    }

    @Test
    void testGetProductById() {
        server.expect(method(HttpMethod.GET))
              .andExpect(requestToUriTemplate(ProductClientImpl.PRODUCT_PATH_ID, dto.getId()))
              .andRespond(withSuccess(dtoJson, MediaType.APPLICATION_JSON));

        Optional<ProductDTO> result = productClient.getProductById(dto.getId());

        assertThat(result).isPresent();
        assertThat(result.get().getProductName()).isEqualTo("Test Product");
    }

    @Test
    void testDeleteProduct() {
        server.expect(method(HttpMethod.DELETE))
              .andExpect(requestToUriTemplate(ProductClientImpl.PRODUCT_PATH_ID, dto.getId()))
              .andRespond(withNoContent());

        productClient.deleteProduct(dto.getId());

        server.verify();
    }
}

OAuth2 토큰 자동 주입 — 인터셉터 패턴

7편에서 만든 OAuth2 인증 서버에서 토큰을 받았다고 칠 때 — 매 호출마다 Authorization: Bearer {token} 헤더를 직접 박으면 보일러플레이트가 폭발해요. 클라이언트 메서드 10개면 10번 박아야 합니다. 이걸 깔끔하게 자동화하는 게 ClientHttpRequestInterceptor 입니다.

회사 비유로 — 인터셉터는 "매 외부 호출 직전에 출입증을 자동으로 챙겨 주는 비서" 예요. 비서가 한 번 자리잡으면, 우리는 그냥 호텔에 전화 걸고 비서가 알아서 출입증 챙겨 줍니다.

OAuth2 클라이언트 의존성 + 설정

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
# OAuth2 클라이언트 등록 정보 — 'springauth'는 사용자 정의 provider 이름
spring.security.oauth2.client.registration.springauth.client-id=messaging-client
spring.security.oauth2.client.registration.springauth.client-secret=secret
spring.security.oauth2.client.registration.springauth.scope=message.read
spring.security.oauth2.client.registration.springauth.authorization-grant-type=client_credentials

# Provider 설정 — 토큰 발급 URL
spring.security.oauth2.client.provider.springauth.token-uri=http://localhost:9000/oauth2/token

AuthorizedClientManager 설정

AuthorizedClientManager는 OAuth2 클라이언트 라이브러리의 핵심으로 — 인증 서버에서 토큰을 자동으로 획득하고 캐싱·갱신합니다.

@Configuration
public class RestTemplateBuilderConfig {

    @Bean
    OAuth2AuthorizedClientManager auth2AuthorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientService oAuth2AuthorizedClientService) {

        // Client Credentials 흐름 설정
        OAuth2AuthorizedClientProvider authorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .clientCredentials()
                        .build();

        AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
                new AuthorizedClientServiceOAuth2AuthorizedClientManager(
                        clientRegistrationRepository,
                        oAuth2AuthorizedClientService);

        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
        return authorizedClientManager;
    }

    // HTTP 인터셉터를 RestTemplateBuilder에 등록 — 모든 요청에 Bearer 토큰 자동 추가
    @Bean
    RestTemplateBuilder restTemplateBuilder(
            OAuth2AuthorizedClientManager authorizedClientManager) {

        OAuthRequestInterceptor requestInterceptor =
                new OAuthRequestInterceptor(authorizedClientManager);

        return new RestTemplateBuilder()
                .additionalInterceptors(requestInterceptor);
    }
}

OAuth2 HTTP 인터셉터 구현

public class OAuthRequestInterceptor implements ClientHttpRequestInterceptor {

    private final OAuth2AuthorizedClientManager authorizedClientManager;

    public OAuthRequestInterceptor(OAuth2AuthorizedClientManager authorizedClientManager) {
        this.authorizedClientManager = authorizedClientManager;
    }

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

        // 인증 요청 객체 생성
        OAuth2AuthorizeRequest authorizeRequest =
                OAuth2AuthorizeRequest.withClientRegistrationId("springauth")
                        .principal(new AnonymousAuthenticationToken(
                                "anonymous",
                                "anonymous",
                                AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")))
                        .build();

        // AuthorizedClientManager가 토큰을 가져옴 (캐시 없으면 자동 발급)
        OAuth2AuthorizedClient authorizedClient =
                authorizedClientManager.authorize(authorizeRequest);

        if (authorizedClient == null) {
            throw new IllegalStateException("missing credentials");
        }

        // Authorization 헤더에 Bearer 토큰 추가
        request.getHeaders().add(
                HttpHeaders.AUTHORIZATION,
                "Bearer " + authorizedClient.getAccessToken().getTokenValue());

        return execution.execute(request, body);
    }
}

이제 ProductClient.listProducts()를 호출하면 — 인터셉터가 매 요청 직전에 토큰을 자동으로 챙겨 헤더에 박아 줍니다. 클라이언트 코드는 토큰의 존재를 몰라도 됩니다.

여기서 시험 함정이 하나 있어요. 인터셉터를 구현만 하고 RestTemplateBuilder에 등록하지 않으면 실제로 실행되지 않습니다. additionalInterceptors(...)를 빠뜨리는 게 흔한 실수예요.

// 잘못된 예 — 인터셉터 구현만 하고 등록 누락
@Bean
RestTemplateBuilder restTemplateBuilder(...) {
    return new RestTemplateBuilder();  // 인터셉터 등록 안 됨!
}

// 올바른 예 — additionalInterceptors로 등록
@Bean
RestTemplateBuilder restTemplateBuilder(
        OAuth2AuthorizedClientManager authorizedClientManager) {
    return new RestTemplateBuilder()
            .additionalInterceptors(
                    new OAuthRequestInterceptor(authorizedClientManager)
            );
}

또 하나 — Spring Security OAuth(별도 프로젝트)는 deprecated 됐어요. 인터넷 예제 중 옛날 자료는 그쪽 라이브러리를 쓰는 경우가 많은데 — 현재 권장 방식과 다릅니다. Spring 6 기준 공식 문서나 최신 예제를 따라가세요.

RestTemplate 흔한 실수 — 타임아웃 미설정

마지막으로 운영 사고로 자주 이어지는 함정 하나. RestTemplate의 기본 타임아웃은 무한이에요. 외부 서비스가 응답하지 않으면 — 우리 스레드가 영원히 대기합니다. 그러다 스레드 풀이 다 묶이면 우리 서비스도 죽어요.

@Bean
RestTemplateBuilder restTemplateBuilder() {
    return new RestTemplateBuilder()
            .connectTimeout(Duration.ofSeconds(5))   // 연결 타임아웃
            .readTimeout(Duration.ofSeconds(10));    // 읽기 타임아웃
}

connectTimeout = 호스트에 연결 시도하는 시간 / readTimeout = 응답 데이터 받기 시작한 후 다음 데이터까지 기다리는 시간. 둘 다 명시적으로 박아 두는 게 운영 환경의 기본입니다.

또 하나 — getForObject()가 null을 반환할 수 있어요. 응답 본문이 비어 있을 때 그렇습니다. 그대로 메서드 호출하면 NPE — Optional.ofNullable(...)로 감싸 두는 게 안전한 패턴이에요.

// 잘못된 예 — NPE 위험
ProductDTO product = restTemplate.getForObject(url, ProductDTO.class);
return product.getProductName(); // null이면 NPE!

// 올바른 예 — Optional로 감싸기
return Optional.ofNullable(
        restTemplate.getForObject(url, ProductDTO.class, productId)
);

더 자세히 — 공식 문서

RestTemplate·RestClient·WebClient의 자세한 사양과 구성 옵션은 Spring REST Clients 공식 가이드에서 확인할 수 있어요. 최신 RestClient의 Fluent API, 인터셉터 체이닝, 메시지 변환기 커스터마이즈까지 친절하게 정리돼 있습니다.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 8편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • Spring HTTP 클라이언트 4종RestTemplate(레거시) / RestClient(신규 동기) / WebClient(리액티브) / FeignClient(선언적)
  • RestTemplate = 유지보수 모드 (deprecated 아님 — 보안 패치 계속)
  • 신규 동기 코드는 RestClient 6.1+ 권장
  • RestTemplateBuilder 주입 후 생성자에서 .build() 한 번만
  • 메서드 안에서 매번 build() = MockServer 바인딩 깨짐
  • getForObject() = 응답 본문 / exchange() = 응답 전체(헤더+본문)
  • POST 후 ID 받기 = postForLocation() → Location URL로 다시 GET
  • PATCH = exchange(HttpMethod.PATCH, ...) (patchForObject 없음!)
  • URL 구성 = UriComponentsBuilder (문자열 연결 금지)
  • 경로 변수 = .buildAndExpand(id) 또는 마지막 인자로 전달
  • 쿼리 파라미터 = .queryParam("key", value) (자동 URL 인코딩)
  • 한국어·공백·특수 문자는 반드시 URL 인코딩 필요
  • Page 역직렬화 = 커스텀 PageImpl 구현체 + @JsonCreator + @JsonIgnoreProperties
  • @RestClientTest = 특정 클라이언트만 콘텍스트에 올림 + MockRestServiceServer 자동 구성
  • RestTemplateBuilder를 내부에서 빌드하는 클라이언트 = Mockito + MockServerRestTemplateCustomizer 필요
  • server.verify() 항상 호출 — 빠뜨리면 호출 안 가도 테스트 그린!
  • POST Mock URL에 ID 박지 말기 — POST 시점에는 ID 없음 (Location 헤더로 받음)
  • withSuccess / withNoContent / withAcceptedLocation / withBadRequest 응답 자주 사용
  • 공통 설정은 @BeforeEach 로 추출 (DRY)
  • OAuth2 클라이언트 = spring-boot-starter-oauth2-client + application.properties 등록 정보
  • AuthorizedClientManager = 토큰 자동 발급·캐싱·갱신
  • ClientHttpRequestInterceptor = 매 요청에 Bearer {token} 헤더 자동 추가
  • 인터셉터 구현만 하고 additionalInterceptors(...) 빠뜨리면 실행 안 됨
  • 타임아웃 명시 필수connectTimeout + readTimeout 안 박으면 무한 대기
  • 운영 사고 1순위 = 외부 서비스 응답 없음 + 타임아웃 무한 → 스레드 풀 고갈
  • getForObject() null 처리Optional.ofNullable(...) 감싸 NPE 방어
  • Spring Security OAuth(별도 프로젝트) = deprecated — Spring 6 OAuth2 모듈 사용
  • 옛날 인터넷 예제 = WebSecurityConfigurerAdapter·Spring Security OAuth — 따라 적지 말기

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!