안녕하세요! 오늘은 Spring에서 HTTP 통신을 위해 사용하는 RestTemplate과 WebClient를 알아보겠습니다. 메모리 누수 없이 안전하게 재사용하는 방법까지 완벽하게 정리해드립니다!
1. RestTemplate vs WebClient 비교
RestTemplate
동기 방식 - 요청을 보내고 응답을 받을 때까지 스레드가 블로킹됩니다.
Spring 3.0부터 사용 - 오래된 API이지만 안정적입니다.
Deprecated 예정 - Spring 5.0부터 유지보수 모드이며, WebClient 사용을 권장합니다.
간단한 사용법 - 동기 방식이라 코드가 직관적입니다.
WebClient
비동기/논블로킹 - Reactive 방식으로 동작하며, 높은 처리량을 제공합니다.
Spring 5.0부터 추가 - WebFlux의 일부로 제공됩니다.
권장 방식 - 새로운 프로젝트에서는 WebClient 사용을 권장합니다.
함수형 스타일 - Fluent API로 체이닝 방식의 코드 작성이 가능합니다.
2. Maven 의존성 추가
RestTemplate 의존성
RestTemplate은 spring-web에 포함되어 있어 별도 의존성이 필요 없습니다.
<!-- Spring Boot Web Starter에 포함됨 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
WebClient 의존성
WebClient는 WebFlux를 사용합니다.
<!-- WebClient 사용을 위한 WebFlux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
3. RestTemplate Bean 등록 (재사용 가능)
RestTemplate은 thread-safe하므로 싱글톤으로 등록하여 재사용해야 합니다. 매번 새로 생성하면 메모리 낭비와 성능 저하가 발생합니다.
기본 RestTemplate 설정
package com.example.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.setConnectTimeout(Duration.ofSeconds(5)) // 연결 타임아웃 5초
.setReadTimeout(Duration.ofSeconds(10)) // 읽기 타임아웃 10초
.build();
}
}
Connection Pool 설정 (권장)
Apache HttpClient를 사용하여 Connection Pool을 구성하면 성능이 크게 향상됩니다.
Maven 의존성 추가
<!-- Apache HttpClient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
Connection Pool 적용
package com.example.config;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
// Connection Pool 설정
PoolingHttpClientConnectionManager connectionManager =
new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(100); // 전체 최대 연결 수
connectionManager.setDefaultMaxPerRoute(20); // 라우트당 최대 연결 수
// HttpClient 생성
HttpClient httpClient = HttpClientBuilder.create()
.setConnectionManager(connectionManager)
.build();
// RequestFactory 설정
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory(httpClient);
factory.setConnectTimeout(5000); // 연결 타임아웃 5초
factory.setReadTimeout(10000); // 읽기 타임아웃 10초
return new RestTemplate(factory);
}
}
로깅 인터셉터 추가
요청/응답 로그를 확인하고 싶다면 인터셉터를 추가합니다.
package com.example.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Slf4j
@Component
public class LoggingInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
// 요청 로깅
log.info("=== Request ===");
log.info("URI: {}", request.getURI());
log.info("Method: {}", request.getMethod());
log.info("Headers: {}", request.getHeaders());
log.info("Body: {}", new String(body, StandardCharsets.UTF_8));
// 요청 실행
ClientHttpResponse response = execution.execute(request, body);
// 응답 로깅
log.info("=== Response ===");
log.info("Status: {}", response.getStatusCode());
log.info("Headers: {}", response.getHeaders());
return response;
}
}
인터셉터를 RestTemplate에 등록:
@Configuration
public class RestTemplateConfig {
@Autowired
private LoggingInterceptor loggingInterceptor;
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate(factory);
// 인터셉터 추가
restTemplate.setInterceptors(Collections.singletonList(loggingInterceptor));
return restTemplate;
}
}
4. RestTemplate 사용법
GET 요청
@Service
@RequiredArgsConstructor
public class ApiService {
private final RestTemplate restTemplate;
// 단순 GET 요청
public String getSimple() {
String url = "https://api.example.com/data";
return restTemplate.getForObject(url, String.class);
}
// 객체로 변환
public UserDto getUser(Long userId) {
String url = "https://api.example.com/users/" + userId;
return restTemplate.getForObject(url, UserDto.class);
}
// URI 파라미터 사용
public UserDto getUserWithParam(Long userId) {
String url = "https://api.example.com/users/{id}";
return restTemplate.getForObject(url, UserDto.class, userId);
}
// Query Parameter 사용
public List<UserDto> searchUsers(String name, Integer age) {
String url = "https://api.example.com/users?name={name}&age={age}";
Map<String, Object> params = new HashMap<>();
params.put("name", name);
params.put("age", age);
UserDto[] users = restTemplate.getForObject(url, UserDto[].class, params);
return Arrays.asList(users);
}
// ResponseEntity로 받기 (헤더, 상태코드 확인)
public UserDto getUserWithResponse(Long userId) {
String url = "https://api.example.com/users/{id}";
ResponseEntity<UserDto> response =
restTemplate.getForEntity(url, UserDto.class, userId);
System.out.println("Status: " + response.getStatusCode());
System.out.println("Headers: " + response.getHeaders());
return response.getBody();
}
}
POST 요청
@Service
@RequiredArgsConstructor
public class ApiService {
private final RestTemplate restTemplate;
// 단순 POST 요청
public UserDto createUser(UserDto userDto) {
String url = "https://api.example.com/users";
return restTemplate.postForObject(url, userDto, UserDto.class);
}
// ResponseEntity로 받기
public UserDto createUserWithResponse(UserDto userDto) {
String url = "https://api.example.com/users";
ResponseEntity<UserDto> response =
restTemplate.postForEntity(url, userDto, UserDto.class);
return response.getBody();
}
// 헤더 추가
public UserDto createUserWithHeaders(UserDto userDto) {
String url = "https://api.example.com/users";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer YOUR_TOKEN");
HttpEntity<UserDto> request = new HttpEntity<>(userDto, headers);
return restTemplate.postForObject(url, request, UserDto.class);
}
}
PUT, DELETE 요청
@Service
@RequiredArgsConstructor
public class ApiService {
private final RestTemplate restTemplate;
// PUT 요청
public void updateUser(Long userId, UserDto userDto) {
String url = "https://api.example.com/users/{id}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<UserDto> request = new HttpEntity<>(userDto, headers);
restTemplate.put(url, request, userId);
}
// DELETE 요청
public void deleteUser(Long userId) {
String url = "https://api.example.com/users/{id}";
restTemplate.delete(url, userId);
}
// exchange 메서드로 통합 처리
public UserDto updateUserWithExchange(Long userId, UserDto userDto) {
String url = "https://api.example.com/users/{id}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<UserDto> request = new HttpEntity<>(userDto, headers);
ResponseEntity<UserDto> response = restTemplate.exchange(
url,
HttpMethod.PUT,
request,
UserDto.class,
userId
);
return response.getBody();
}
}
5. WebClient Bean 등록 (재사용 가능)
WebClient도 재사용 가능하도록 싱글톤으로 등록합니다.
기본 WebClient 설정
package com.example.config;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
return WebClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader("Content-Type", "application/json")
.build();
}
}
Connection Pool 설정 (권장)
메모리 누수를 방지하고 성능을 최적화하는 Connection Pool 설정입니다.
package com.example.config;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
// Connection Pool 설정
ConnectionProvider connectionProvider = ConnectionProvider.builder("custom")
.maxConnections(100) // 최대 연결 수
.maxIdleTime(Duration.ofSeconds(20)) // 유휴 연결 유지 시간
.maxLifeTime(Duration.ofSeconds(60)) // 연결 최대 수명
.pendingAcquireTimeout(Duration.ofSeconds(60)) // 연결 대기 타임아웃
.evictInBackground(Duration.ofSeconds(120)) // 백그라운드 정리 주기
.build();
// HttpClient 설정
HttpClient httpClient = HttpClient.create(connectionProvider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 연결 타임아웃
.responseTimeout(Duration.ofSeconds(10)) // 응답 타임아웃
.doOnConnected(conn ->
conn.addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
.addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS))
);
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.baseUrl("https://api.example.com")
.defaultHeader("Content-Type", "application/json")
.filter(logRequest()) // 요청 로깅
.filter(logResponse()) // 응답 로깅
.build();
}
// 요청 로깅 필터
private ExchangeFilterFunction logRequest() {
return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
System.out.println("Request: " + clientRequest.method() + " " + clientRequest.url());
clientRequest.headers().forEach((name, values) ->
values.forEach(value -> System.out.println(name + ": " + value)));
return Mono.just(clientRequest);
});
}
// 응답 로깅 필터
private ExchangeFilterFunction logResponse() {
return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
System.out.println("Response Status: " + clientResponse.statusCode());
return Mono.just(clientResponse);
});
}
}
여러 개의 WebClient 사용
API마다 다른 설정이 필요한 경우 여러 개의 WebClient를 등록할 수 있습니다.
@Configuration
public class WebClientConfig {
@Bean
@Qualifier("internalApiClient")
public WebClient internalApiClient() {
return WebClient.builder()
.baseUrl("https://internal-api.example.com")
.defaultHeader("Authorization", "Bearer INTERNAL_TOKEN")
.build();
}
@Bean
@Qualifier("externalApiClient")
public WebClient externalApiClient() {
return WebClient.builder()
.baseUrl("https://external-api.example.com")
.defaultHeader("API-Key", "YOUR_API_KEY")
.build();
}
}
사용 시:
@Service
public class ApiService {
private final WebClient internalApiClient;
private final WebClient externalApiClient;
public ApiService(@Qualifier("internalApiClient") WebClient internalApiClient,
@Qualifier("externalApiClient") WebClient externalApiClient) {
this.internalApiClient = internalApiClient;
this.externalApiClient = externalApiClient;
}
}
6. WebClient 사용법
GET 요청
@Service
@RequiredArgsConstructor
public class ApiService {
private final WebClient webClient;
// 동기 방식 (block 사용)
public UserDto getUserSync(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserDto.class)
.block(); // 동기로 변환
}
// 비동기 방식 (Mono 반환)
public Mono<UserDto> getUserAsync(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserDto.class);
}
// 리스트 조회 (Flux 사용)
public Flux<UserDto> getAllUsers() {
return webClient.get()
.uri("/users")
.retrieve()
.bodyToFlux(UserDto.class);
}
// Query Parameter 사용
public Mono<List<UserDto>> searchUsers(String name, Integer age) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/users")
.queryParam("name", name)
.queryParam("age", age)
.build())
.retrieve()
.bodyToFlux(UserDto.class)
.collectList();
}
// ResponseEntity로 받기
public Mono<UserDto> getUserWithResponse(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.toEntity(UserDto.class)
.map(response -> {
System.out.println("Status: " + response.getStatusCode());
System.out.println("Headers: " + response.getHeaders());
return response.getBody();
});
}
}
POST 요청
@Service
@RequiredArgsConstructor
public class ApiService {
private final WebClient webClient;
// 단순 POST 요청
public Mono<UserDto> createUser(UserDto userDto) {
return webClient.post()
.uri("/users")
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class);
}
// 헤더 추가
public Mono<UserDto> createUserWithHeaders(UserDto userDto) {
return webClient.post()
.uri("/users")
.header("Authorization", "Bearer YOUR_TOKEN")
.header("Custom-Header", "value")
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class);
}
// 동기 방식
public UserDto createUserSync(UserDto userDto) {
return webClient.post()
.uri("/users")
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class)
.block();
}
}
PUT, DELETE 요청
@Service
@RequiredArgsConstructor
public class ApiService {
private final WebClient webClient;
// PUT 요청
public Mono<UserDto> updateUser(Long userId, UserDto userDto) {
return webClient.put()
.uri("/users/{id}", userId)
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class);
}
// DELETE 요청
public Mono<Void> deleteUser(Long userId) {
return webClient.delete()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(Void.class);
}
// PATCH 요청
public Mono<UserDto> patchUser(Long userId, Map<String, Object> updates) {
return webClient.patch()
.uri("/users/{id}", userId)
.bodyValue(updates)
.retrieve()
.bodyToMono(UserDto.class);
}
}
7. 에러 처리
RestTemplate 에러 처리
@Service
@RequiredArgsConstructor
public class ApiService {
private final RestTemplate restTemplate;
public UserDto getUserSafely(Long userId) {
String url = "https://api.example.com/users/{id}";
try {
return restTemplate.getForObject(url, UserDto.class, userId);
} catch (HttpClientErrorException e) {
// 4xx 에러
System.out.println("Client Error: " + e.getStatusCode());
System.out.println("Response Body: " + e.getResponseBodyAsString());
throw new RuntimeException("사용자 조회 실패", e);
} catch (HttpServerErrorException e) {
// 5xx 에러
System.out.println("Server Error: " + e.getStatusCode());
throw new RuntimeException("서버 에러", e);
} catch (ResourceAccessException e) {
// 타임아웃, 연결 실패
System.out.println("Connection Error: " + e.getMessage());
throw new RuntimeException("연결 실패", e);
}
}
}
WebClient 에러 처리
@Service
@RequiredArgsConstructor
public class ApiService {
private final WebClient webClient;
public Mono<UserDto> getUserSafely(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.onStatus(HttpStatus::is4xxClientError, response -> {
return response.bodyToMono(String.class)
.flatMap(body -> {
System.out.println("Client Error: " + body);
return Mono.error(new RuntimeException("사용자 조회 실패"));
});
})
.onStatus(HttpStatus::is5xxServerError, response -> {
return Mono.error(new RuntimeException("서버 에러"));
})
.bodyToMono(UserDto.class)
.onErrorResume(WebClientException.class, e -> {
System.out.println("Connection Error: " + e.getMessage());
return Mono.error(new RuntimeException("연결 실패", e));
});
}
// 기본값 반환
public Mono<UserDto> getUserWithDefault(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserDto.class)
.onErrorReturn(new UserDto()); // 에러 시 기본값 반환
}
// 재시도
public Mono<UserDto> getUserWithRetry(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserDto.class)
.retry(3); // 3번 재시도
}
}
8. 실전 예제: 외부 API 연동 서비스
DTO 클래스
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {
private Long id;
private String name;
private String email;
private Integer age;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private int code;
private String message;
private T data;
}
RestTemplate 서비스
@Service
@RequiredArgsConstructor
@Slf4j
public class UserRestTemplateService {
private final RestTemplate restTemplate;
private static final String BASE_URL = "https://api.example.com";
public UserDto getUser(Long userId) {
String url = BASE_URL + "/users/{id}";
try {
ResponseEntity<UserDto> response =
restTemplate.getForEntity(url, UserDto.class, userId);
log.info("사용자 조회 성공: {}", userId);
return response.getBody();
} catch (HttpClientErrorException.NotFound e) {
log.error("사용자를 찾을 수 없음: {}", userId);
throw new RuntimeException("사용자를 찾을 수 없습니다");
} catch (Exception e) {
log.error("사용자 조회 중 에러 발생", e);
throw new RuntimeException("사용자 조회 실패", e);
}
}
public List<UserDto> searchUsers(String keyword) {
String url = BASE_URL + "/users/search?keyword={keyword}";
UserDto[] users = restTemplate.getForObject(url, UserDto[].class, keyword);
return Arrays.asList(users);
}
public UserDto createUser(UserDto userDto) {
String url = BASE_URL + "/users";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<UserDto> request = new HttpEntity<>(userDto, headers);
return restTemplate.postForObject(url, request, UserDto.class);
}
public UserDto updateUser(Long userId, UserDto userDto) {
String url = BASE_URL + "/users/{id}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<UserDto> request = new HttpEntity<>(userDto, headers);
ResponseEntity<UserDto> response = restTemplate.exchange(
url, HttpMethod.PUT, request, UserDto.class, userId
);
return response.getBody();
}
public void deleteUser(Long userId) {
String url = BASE_URL + "/users/{id}";
restTemplate.delete(url, userId);
}
}
WebClient 서비스
@Service
@RequiredArgsConstructor
@Slf4j
public class UserWebClientService {
private final WebClient webClient;
public Mono<UserDto> getUser(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.onStatus(HttpStatus::is4xxClientError, response -> {
log.error("사용자를 찾을 수 없음: {}", userId);
return Mono.error(new RuntimeException("사용자를 찾을 수 없습니다"));
})
.bodyToMono(UserDto.class)
.doOnSuccess(user -> log.info("사용자 조회 성공: {}", userId))
.doOnError(e -> log.error("사용자 조회 중 에러 발생", e));
}
public Flux<UserDto> searchUsers(String keyword) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/users/search")
.queryParam("keyword", keyword)
.build())
.retrieve()
.bodyToFlux(UserDto.class);
}
public Mono<UserDto> createUser(UserDto userDto) {
return webClient.post()
.uri("/users")
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class)
.doOnSuccess(user -> log.info("사용자 생성 성공: {}", user.getId()));
}
public Mono<UserDto> updateUser(Long userId, UserDto userDto) {
return webClient.put()
.uri("/users/{id}", userId)
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class)
.doOnSuccess(user -> log.info("사용자 업데이트 성공: {}", userId));
}
public Mono<Void> deleteUser(Long userId) {
return webClient.delete()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(Void.class)
.doOnSuccess(v -> log.info("사용자 삭제 성공: {}", userId));
}
// 동기 방식으로 사용하기
public UserDto getUserSync(Long userId) {
return getUser(userId).block();
}
// 여러 요청 병렬 처리
public Mono<Map<String, Object>> getUserWithDetails(Long userId) {
Mono<UserDto> userMono = getUser(userId);
Mono<List<String>> friendsMono = getUserFriends(userId);
Mono<List<String>> postsMono = getUserPosts(userId);
return Mono.zip(userMono, friendsMono, postsMono)
.map(tuple -> {
Map<String, Object> result = new HashMap<>();
result.put("user", tuple.getT1());
result.put("friends", tuple.getT2());
result.put("posts", tuple.getT3());
return result;
});
}
private Mono<List<String>> getUserFriends(Long userId) {
return webClient.get()
.uri("/users/{id}/friends", userId)
.retrieve()
.bodyToFlux(String.class)
.collectList();
}
private Mono<List<String>> getUserPosts(Long userId) {
return webClient.get()
.uri("/users/{id}/posts", userId)
.retrieve()
.bodyToFlux(String.class)
.collectList();
}
}
9. 메모리 누수 방지 체크리스트
RestTemplate
Bean으로 등록하여 재사용 - 매번 new RestTemplate()로 생성하지 마세요.
Connection Pool 사용 - Apache HttpClient의 PoolingHttpClientConnectionManager를 사용하세요.
타임아웃 설정 필수 - 무한 대기를 방지하기 위해 반드시 타임아웃을 설정하세요.
리소스 정리 - try-with-resources나 finally에서 리소스를 정리하세요.
WebClient
Bean으로 등록하여 재사용 - 매번 WebClient.create()로 생성하지 마세요.
Connection Pool 설정 - ConnectionProvider를 통해 Connection Pool을 구성하세요.
타임아웃 설정 필수 - responseTimeout, connectTimeout을 반드시 설정하세요.
Subscribe 필수 - Mono/Flux는 subscribe하지 않으면 실행되지 않습니다.
Block 사용 주의 - block()은 동기화되므로 필요한 경우에만 사용하세요.
10. 성능 비교
동기 환경 (일반적인 Spring MVC)
RestTemplate이 더 적합합니다. 코드가 간단하고 직관적이며, 동기 환경에서는 성능 차이가 크지 않습니다.
고부하 환경 (많은 외부 API 호출)
WebClient가 월등히 우수합니다. 비동기/논블로킹 방식으로 스레드를 효율적으로 사용합니다.
성능 테스트 결과 예시
동시 요청 100개 처리 시:
- RestTemplate: 약 10초 (스레드 블로킹)
- WebClient: 약 2초 (논블로킹)
메모리 사용량:
- RestTemplate: 스레드당 메모리 사용
- WebClient: 이벤트 루프 방식으로 적은 메모리 사용
11. 언제 무엇을 사용할까?
RestTemplate 사용 권장
레거시 프로젝트 - 이미 RestTemplate을 사용 중이고 문제가 없다면 굳이 변경할 필요 없음
간단한 API 호출 - 단순한 CRUD 작업이고 호출 빈도가 낮은 경우
동기 방식 선호 - 코드 가독성과 디버깅이 중요한 경우
팀원이 익숙함 - 팀원들이 RestTemplate에 익숙하고 Reactive 경험이 없는 경우
WebClient 사용 권장
신규 프로젝트 - Spring 5.0 이상의 새 프로젝트는 WebClient 사용
고성능 필요 - 많은 외부 API 호출이 필요한 경우
비동기 처리 - 여러 API를 병렬로 호출해야 하는 경우
Reactive 스택 - Spring WebFlux를 사용하는 프로젝트
스트리밍 데이터 - SSE(Server-Sent Events)나 스트리밍 응답을 처리해야 하는 경우
13. 실무 팁
공통 에러 핸들러
@Component
public class WebClientErrorHandler {
public <T> Mono<T> handleError(ClientResponse response, Class<T> clazz) {
if (response.statusCode().is4xxClientError()) {
return response.bodyToMono(String.class)
.flatMap(body -> {
System.out.println("Client Error: " + body);
return Mono.error(new ApiException("API 요청 오류"));
});
}
if (response.statusCode().is5xxServerError()) {
return Mono.error(new ApiException("서버 오류"));
}
return response.bodyToMono(clazz);
}
}
// 사용 예시
@Service
@RequiredArgsConstructor
public class UserService {
private final WebClient webClient;
private final WebClientErrorHandler errorHandler;
public Mono<UserDto> getUser(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.exchangeToMono(response -> errorHandler.handleError(response, UserDto.class));
}
}
재시도 로직
@Service
@RequiredArgsConstructor
public class UserService {
private final WebClient webClient;
public Mono<UserDto> getUserWithRetry(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserDto.class)
.retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) // 3번 재시도, 2초 간격
.filter(throwable -> throwable instanceof WebClientException)
.onRetryExhaustedThrow((retrySpec, retrySignal) ->
new RuntimeException("재시도 횟수 초과")));
}
}
타임아웃 설정
@Service
@RequiredArgsConstructor
public class UserService {
private final WebClient webClient;
public Mono<UserDto> getUserWithTimeout(Long userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserDto.class)
.timeout(Duration.ofSeconds(5)) // 5초 타임아웃
.onErrorResume(TimeoutException.class, e -> {
System.out.println("요청 타임아웃");
return Mono.empty();
});
}
}
응답 캐싱
@Service
public class CachedUserService {
private final WebClient webClient;
private final Map<Long, UserDto> cache = new ConcurrentHashMap<>();
public Mono<UserDto> getUserCached(Long userId) {
// 캐시 확인
if (cache.containsKey(userId)) {
System.out.println("캐시에서 조회");
return Mono.just(cache.get(userId));
}
// API 호출 후 캐시 저장
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserDto.class)
.doOnSuccess(user -> cache.put(userId, user));
}
}
14. 테스트 코드 작성
RestTemplate 테스트
@SpringBootTest
@AutoConfigureMockRestServiceServer
class UserRestTemplateServiceTest {
@Autowired
private UserRestTemplateService userService;
@Autowired
private MockRestServiceServer mockServer;
@Test
void getUserTest() {
// Given
Long userId = 1L;
UserDto expectedUser = new UserDto(1L, "홍길동", "hong@example.com", 30);
mockServer.expect(requestTo("https://api.example.com/users/1"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body(new ObjectMapper().writeValueAsString(expectedUser)));
// When
UserDto result = userService.getUser(userId);
// Then
assertNotNull(result);
assertEquals("홍길동", result.getName());
mockServer.verify();
}
}
WebClient 테스트
@SpringBootTest
class UserWebClientServiceTest {
@Autowired
private UserWebClientService userService;
private MockWebServer mockWebServer;
@BeforeEach
void setUp() throws IOException {
mockWebServer = new MockWebServer();
mockWebServer.start();
}
@AfterEach
void tearDown() throws IOException {
mockWebServer.shutdown();
}
@Test
void getUserTest() {
// Given
Long userId = 1L;
UserDto expectedUser = new UserDto(1L, "홍길동", "hong@example.com", 30);
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(new ObjectMapper().writeValueAsString(expectedUser)));
// When
UserDto result = userService.getUser(userId).block();
// Then
assertNotNull(result);
assertEquals("홍길동", result.getName());
}
}
15. 주의사항 정리
RestTemplate 주의사항
매번 생성 금지 - RestTemplate은 thread-safe하므로 싱글톤으로 재사용하세요.
// ❌ 잘못된 방법
public UserDto getUser(Long userId) {
RestTemplate restTemplate = new RestTemplate(); // 매번 생성 - 메모리 낭비
return restTemplate.getForObject(url, UserDto.class);
}
// ✅ 올바른 방법
@Service
@RequiredArgsConstructor
public class UserService {
private final RestTemplate restTemplate; // Bean 주입
}
Connection Pool 필수 - 고부하 환경에서는 반드시 Connection Pool을 설정하세요.
타임아웃 설정 - 무한 대기를 방지하기 위해 connectTimeout, readTimeout을 설정하세요.
예외 처리 - HttpClientErrorException, HttpServerErrorException을 적절히 처리하세요.
WebClient 주의사항
Block 사용 주의 - 비동기의 장점을 잃으므로 꼭 필요한 경우에만 사용하세요.
// ❌ WebFlux 환경에서 block() 사용 금지
@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(UserDto.class)
.block(); // WebFlux에서는 블로킹 금지!
}
// ✅ Mono를 그대로 반환
@GetMapping("/users/{id}")
public Mono<UserDto> getUser(@PathVariable Long id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(UserDto.class);
}
Subscribe 필수 - Mono/Flux는 subscribe하지 않으면 실행되지 않습니다.
// ❌ 실행되지 않음
public void updateUser(Long userId, UserDto userDto) {
webClient.put()
.uri("/users/{id}", userId)
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class);
// subscribe가 없어서 실제로 실행되지 않음!
}
// ✅ subscribe 추가
public void updateUser(Long userId, UserDto userDto) {
webClient.put()
.uri("/users/{id}", userId)
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class)
.subscribe();
}
Connection Pool 설정 - ConnectionProvider를 통해 Connection Pool을 구성하세요.
메모리 누수 방지 - 사용하지 않는 Subscription은 dispose()로 정리하세요.
마무리
이제 RestTemplate과 WebClient를 메모리 누수 없이 안전하게 사용할 수 있게 되었습니다!
정리하면:
1단계 - Maven 의존성 추가 (RestTemplate은 기본 포함, WebClient는 WebFlux 추가)
2단계 - Configuration에서 Connection Pool 설정과 함께 Bean 등록
3단계 - 서비스에서 Bean 주입받아 재사용
4단계 - 적절한 타임아웃, 에러 처리, 재시도 로직 구현
선택 가이드:
- 간단한 프로젝트 or 동기 방식 선호 → RestTemplate
- 신규 프로젝트 or 고성능 필요 → WebClient
정말 간단하죠? 외부 API 연동이 많은 프로젝트에서는 Connection Pool 설정이 성능에 큰 영향을 미칩니다!
'Spring Boot' 카테고리의 다른 글
| Spring Boot 3.x + Security 6.x JWT 인증 구현 가이드 (0) | 2025.12.23 |
|---|---|
| SockJS란? 실시간 통신을 위한 WebSocket 대안 (0) | 2025.12.11 |
| Spring에서 Caffeine 캐시 완벽 가이드 (0) | 2025.12.05 |
| Spring Tag 라이브러리로 JSP include시 id 겹침 방지 (0) | 2025.01.03 |
| 개발 환경에서 서버 2개이상 돌릴 때 세션 유지 방법 (0) | 2023.12.19 |
댓글