WebClient는 Spring 5.0부터 도입된 비동기, 논블로킹 방식의 HTTP 클라이언트입니다. Spring WebFlux의 일부로 제공되며, 리액티브 프로그래밍을 지원합니다.
기존의 RestTemplate과 WebClient를 비교해보겠습니다.
| 구분 | RestTemplate | WebClient |
|---|---|---|
| 처리 방식 | 동기(Blocking) | 비동기(Non-blocking) |
| 성능 | 요청당 스레드 필요 | 적은 스레드로 많은 요청 처리 |
| Spring 지원 | 유지보수 모드 | 적극 개발 중 |
| 메모리 사용량 | 높음 | 낮음 |
| 학습 곡선 | 쉬움 | 보통 |
| 리액티브 지원 | ❌ | ✅ |
RestTemplate (동기 방식)
요청 1 → 스레드 1 → 대기 → 응답 처리
요청 2 → 스레드 2 → 대기 → 응답 처리
요청 3 → 스레드 3 → 대기 → 응답 처리
WebClient (비동기 방식)
요청 1, 2, 3 → 하나의 스레드 → 이벤트 루프로 처리
Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Gradle
implementation 'org.springframework.boot:spring-boot-starter-webflux'
import org.springframework.web.reactive.function.client.WebClient;
// 가장 기본적인 생성 방법
WebClient webClient = WebClient.create();
// Base URL을 지정한 생성
WebClient webClient = WebClient.create("https://api.example.com");
// Builder를 사용한 세밀한 설정
WebClient webClient = WebClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
// 단순 GET 요청
String result = webClient.get()
.uri("/api/users/1")
.retrieve()
.bodyToMono(String.class)
.block(); // 동기적으로 결과 대기
// 객체로 변환
UserDto user = webClient.get()
.uri("/api/users/1")
.retrieve()
.bodyToMono(UserDto.class)
.block();
// 리스트 형태로 받기
List<UserDto> users = webClient.get()
.uri("/api/users")
.retrieve()
.bodyToFlux(UserDto.class) // Flux 사용
.collectList() // List로 변환
.block();
// 객체를 JSON으로 전송
UserDto newUser = new UserDto("홍길동", "hong@example.com");
UserDto createdUser = webClient.post()
.uri("/api/users")
.bodyValue(newUser) // 요청 본문에 객체 설정
.retrieve()
.bodyToMono(UserDto.class)
.block();
// JSON 문자열로 전송
String jsonBody = "{\"name\":\"홍길동\",\"email\":\"hong@example.com\"}";
String result = webClient.post()
.uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(jsonBody)
.retrieve()
.bodyToMono(String.class)
.block();
// PUT 요청
UserDto updatedUser = webClient.put()
.uri("/api/users/1")
.bodyValue(userDto)
.retrieve()
.bodyToMono(UserDto.class)
.block();
// DELETE 요청
webClient.delete()
.uri("/api/users/1")
.retrieve()
.bodyToMono(Void.class)
.block();
// PATCH 요청
UserDto patchedUser = webClient.patch()
.uri("/api/users/1")
.bodyValue(partialUserDto)
.retrieve()
.bodyToMono(UserDto.class)
.block();
String result = webClient.get()
.uri("/api/protected-resource")
.header("Authorization", "Bearer " + accessToken)
.header("X-Custom-Header", "custom-value")
.retrieve()
.bodyToMono(String.class)
.block();
// 또는 헤더를 Consumer로 설정
String result = webClient.get()
.uri("/api/protected-resource")
.headers(headers -> {
headers.setBearerAuth(accessToken);
headers.set("X-Custom-Header", "custom-value");
})
.retrieve()
.bodyToMono(String.class)
.block();
// URI 템플릿 사용
String result = webClient.get()
.uri("/api/users/{id}/posts/{postId}", userId, postId)
.retrieve()
.bodyToMono(String.class)
.block();
// UriBuilder 사용
String result = webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/api/users")
.queryParam("page", 1)
.queryParam("size", 10)
.queryParam("sort", "name")
.build())
.retrieve()
.bodyToMono(String.class)
.block();
// Map으로 파라미터 전달
Map<String, String> params = Map.of(
"page", "1",
"size", "10",
"sort", "name"
);
String result = webClient.get()
.uri("/api/users?page={page}&size={size}&sort={sort}", params)
.retrieve()
.bodyToMono(String.class)
.block();
// 동기 방식 (block() 사용)
UserDto user = webClient.get()
.uri("/api/users/1")
.retrieve()
.bodyToMono(UserDto.class)
.block(); // 여기서 스레드가 대기
// 비동기 방식 (콜백 사용)
webClient.get()
.uri("/api/users/1")
.retrieve()
.bodyToMono(UserDto.class)
.subscribe(
user -> System.out.println("사용자: " + user.getName()), // 성공 시
error -> System.err.println("오류: " + error.getMessage()) // 실패 시
);
// CompletableFuture로 변환
CompletableFuture<UserDto> future = webClient.get()
.uri("/api/users/1")
.retrieve()
.bodyToMono(UserDto.class)
.toFuture();
// 여러 API를 병렬로 호출
Mono<UserDto> userMono = webClient.get()
.uri("/api/users/1")
.retrieve()
.bodyToMono(UserDto.class);
Mono<List<PostDto>> postsMono = webClient.get()
.uri("/api/users/1/posts")
.retrieve()
.bodyToFlux(PostDto.class)
.collectList();
// 두 요청의 결과를 조합
Mono<UserWithPostsDto> combinedMono = Mono.zip(userMono, postsMono)
.map(tuple -> new UserWithPostsDto(tuple.getT1(), tuple.getT2()));
UserWithPostsDto result = combinedMono.block();
@Configuration
public class WebClientConfig {
@Bean
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
@Bean
public WebClient apiWebClient(WebClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(1024 * 1024)) // 1MB
.build();
}
}
@Bean
public WebClient webClientWithTimeout() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 연결 타임아웃
.responseTimeout(Duration.ofSeconds(10)); // 응답 타임아웃
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
@Bean
public WebClient webClientWithLogging() {
HttpClient httpClient = HttpClient.create()
.wiretap(true); // 요청/응답 로깅 활성화
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
try {
String result = webClient.get()
.uri("/api/users/999") // 존재하지 않는 사용자
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException e) {
System.err.println("HTTP 상태: " + e.getStatusCode());
System.err.println("응답 본문: " + e.getResponseBodyAsString());
}
String result = webClient.get()
.uri("/api/users/999")
.retrieve()
.onStatus(HttpStatus::is4xxClientError, response -> {
return response.bodyToMono(String.class)
.flatMap(errorBody -> Mono.error(
new CustomException("클라이언트 오류: " + errorBody)
));
})
.onStatus(HttpStatus::is5xxServerError, response -> {
return Mono.error(new CustomException("서버 오류가 발생했습니다."));
})
.bodyToMono(String.class)
.doOnError(throwable -> log.error("API 호출 실패", throwable))
.onErrorReturn("기본값") // 오류 시 기본값 반환
.block();
@Service
@RequiredArgsConstructor
public class UserApiService {
private final WebClient webClient;
public Mono<UserDto> getUser(Long userId) {
return webClient.get()
.uri("/api/users/{id}", userId)
.retrieve()
.bodyToMono(UserDto.class);
}
public Mono<UserDto> createUser(CreateUserRequest request) {
return webClient.post()
.uri("/api/users")
.bodyValue(request)
.retrieve()
.bodyToMono(UserDto.class);
}
public Mono<List<UserDto>> getUsers(int page, int size) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/api/users")
.queryParam("page", page)
.queryParam("size", size)
.build())
.retrieve()
.bodyToFlux(UserDto.class)
.collectList();
}
}
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserApiService userApiService;
@GetMapping("/users/{id}")
public Mono<UserDto> getUser(@PathVariable Long id) {
return userApiService.getUser(id);
}
@PostMapping("/users")
public Mono<UserDto> createUser(@RequestBody CreateUserRequest request) {
return userApiService.createUser(request);
}
// 동기적으로 처리하고 싶다면
@GetMapping("/users/{id}/sync")
public UserDto getUserSync(@PathVariable Long id) {
return userApiService.getUser(id).block();
}
}
// ❌ 나쁜 예
public List<UserDto> getAllUsers() {
return webClient.get()
.uri("/api/users")
.retrieve()
.bodyToFlux(UserDto.class)
.collectList()
.block(); // 비동기의 장점을 상실
}
// ✅ 좋은 예
public Mono<List<UserDto>> getAllUsers() {
return webClient.get()
.uri("/api/users")
.retrieve()
.bodyToFlux(UserDto.class)
.collectList(); // Mono를 반환하여 비동기 유지
}
// WebClient는 자동으로 리소스를 정리하지만,
// 명시적으로 정리하고 싶다면
@PreDestroy
public void cleanup() {
// WebClient는 특별한 정리가 필요하지 않지만,
// 커스텀 HttpClient를 사용했다면 정리 필요
}
// 대용량 응답을 처리할 때는 버퍼 크기 조정
WebClient webClient = WebClient.builder()
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(10 * 1024 * 1024)) // 10MB
.build();
이번 포스팅에서는 WebClient의 기본적인 사용법을 알아봤습니다. 다음 포스팅에서는 실제 프로젝트에서 WebClient를 활용한 외부 API 연동에 대해 더 자세히 다뤄보겠습니다.