[스프링/Spring] WebClient 사용법 정리

dongbrown·2025년 6월 2일

Spring

목록 보기
18/23

🤔 WebClient란 무엇인가?

WebClient는 Spring 5.0부터 도입된 비동기, 논블로킹 방식의 HTTP 클라이언트입니다. Spring WebFlux의 일부로 제공되며, 리액티브 프로그래밍을 지원합니다.

WebClient의 핵심 특징

  • 비동기 처리: 요청을 보내고 응답을 기다리는 동안 다른 작업 수행 가능
  • 논블로킹: 스레드가 블로킹되지 않아 더 많은 동시 요청 처리 가능
  • 체이닝 API: 메서드 체이닝을 통한 직관적인 코드 작성
  • 유연한 설정: 다양한 커스터마이징 옵션 제공

🆚 RestTemplate vs WebClient

기존의 RestTemplate과 WebClient를 비교해보겠습니다.

구분RestTemplateWebClient
처리 방식동기(Blocking)비동기(Non-blocking)
성능요청당 스레드 필요적은 스레드로 많은 요청 처리
Spring 지원유지보수 모드적극 개발 중
메모리 사용량높음낮음
학습 곡선쉬움보통
리액티브 지원

성능 차이가 나는 이유

RestTemplate (동기 방식)

요청 1 → 스레드 1 → 대기 → 응답 처리
요청 2 → 스레드 2 → 대기 → 응답 처리
요청 3 → 스레드 3 → 대기 → 응답 처리

WebClient (비동기 방식)

요청 1, 2, 3 → 하나의 스레드 → 이벤트 루프로 처리

🛠️ WebClient 시작하기

1. 의존성 추가

Maven

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Gradle

implementation 'org.springframework.boot:spring-boot-starter-webflux'

2. 기본 WebClient 생성

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();

📋 WebClient 기본 사용법

GET 요청

// 단순 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();

POST 요청

// 객체를 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/PATCH/DELETE 요청

// 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();

URL 파라미터 설정

// 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();

⚡ 비동기 처리 방법

동기 vs 비동기

// 동기 방식 (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();

🔧 WebClient 설정 및 커스터마이징

Configuration 클래스로 Bean 등록

@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();
    }
}

💡 WebClient 사용 시 주의사항

1. 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를 반환하여 비동기 유지
}

2. 리소스 정리

// WebClient는 자동으로 리소스를 정리하지만, 
// 명시적으로 정리하고 싶다면
@PreDestroy
public void cleanup() {
    // WebClient는 특별한 정리가 필요하지 않지만,
    // 커스텀 HttpClient를 사용했다면 정리 필요
}

3. 메모리 사용량 고려

// 대용량 응답을 처리할 때는 버퍼 크기 조정
WebClient webClient = WebClient.builder()
    .codecs(configurer -> configurer
        .defaultCodecs()
        .maxInMemorySize(10 * 1024 * 1024)) // 10MB
    .build();

🚀 다음 단계

이번 포스팅에서는 WebClient의 기본적인 사용법을 알아봤습니다. 다음 포스팅에서는 실제 프로젝트에서 WebClient를 활용한 외부 API 연동에 대해 더 자세히 다뤄보겠습니다.

  • 토큰 기반 인증 처리
  • 세션 공유 방법
  • 대용량 데이터 처리
  • 에러 핸들링 고급 기법
  • 실제 운영 환경에서의 설정

0개의 댓글