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 핵심 정리 시리즈의 여덟 번째 편입니다. 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 Cloud | Spring 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() | GET | T | 응답 본문을 객체로 반환 |
getForEntity() | GET | ResponseEntity | 응답 전체(헤더+본문) 반환 |
postForObject() | POST | T | 요청 본문 전송 후 응답 본문 반환 |
postForLocation() | POST | URI | 요청 전송 후 Location 헤더 반환 |
postForEntity() | POST | ResponseEntity | 응답 전체 반환 |
put() | PUT | void | 리소스 업데이트 |
delete() | DELETE | void | 리소스 삭제 |
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 아님 — 보안 패치 계속)- 신규 동기 코드는
RestClient6.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— 따라 적지 말기
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Spring Boot 입문
- 2편 — Spring MVC REST · MockMVC
- 3편 — Spring Data JPA · 검증
- 4편 — MySQL · Flyway · TestContainers
- 5편 — CSV 업로드 · 페이징 · 동적 쿼리
- 6편 — JPA 관계 매핑 심화
- 7편 — Spring Security · OAuth 2.0 · JWT
- 8편 — RestTemplate · RestClient (현재 글)
- 9편 — Reactive Programming · WebFlux 입문
- 10편 — WebFlux 심화 · MongoDB · WebClient
- 11편 — Cloud Gateway · Maven/Gradle · Buildpack
- 12편 — OpenAPI · Spring AI
- 13편 — Actuator · 관측성
- 14편 — Spring Cache · 이벤트
- 15편 — Docker · Compose · Kubernetes
- 16편 — 마이크로서비스 · Apache Kafka
- 17편 — Spring Professional · 베스트 프랙티스 (완)