본문 바로가기
Spring Boot

Spring RestTemplate vs WebClient 가이드

by pswamazing 2025. 12. 8.

안녕하세요! 오늘은 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 서비스

 
 
java
@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 설정이 성능에 큰 영향을 미칩니다!

댓글