Spring Boot 3.4에서 안정화된 RestClient는 기존 RestTemplate의 후속 동기 HTTP 클라이언트입니다. WebClient의 직관적인 빌더 패턴 API를 동기 환경에서 그대로 사용할 수 있어, 리액티브 스택이 필요 없는 프로젝트에서 훨씬 깔끔한 HTTP 통신 코드를 작성할 수 있습니다.

RestClient vs RestTemplate vs WebClient

특성RestTemplateRestClientWebClient
방식동기동기비동기/동기
API 스타일메서드 기반빌더 패턴빌더 패턴
상태유지보수 모드권장리액티브 환경 권장
Spring Boot 버전전 버전3.2+전 버전

RestClient 생성

import org.springframework.web.client.RestClient;

@Configuration
public class HttpClientConfig {

    @Bean
    public RestClient restClient(RestClient.Builder builder) {
        return builder
            .baseUrl("https://api.example.com")
            .defaultHeader("Accept", "application/json")
            .defaultHeader("User-Agent", "MyApp/1.0")
            .requestInterceptor((request, body, execution) -> {
                log.debug("요청: {} {}", request.getMethod(), request.getURI());
                var response = execution.execute(request, body);
                log.debug("응답: {}", response.getStatusCode());
                return response;
            })
            .build();
    }
}

기본 CRUD 작업

GET 요청

// 단일 객체 조회
User user = restClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .body(User.class);

// 리스트 조회 (ParameterizedTypeReference 사용)
List<User> users = restClient.get()
    .uri("/users?page={page}&size={size}", 0, 20)
    .retrieve()
    .body(new ParameterizedTypeReference<List<User>>() {});

// 쿼리 파라미터 빌더 사용
List<User> filtered = restClient.get()
    .uri(uriBuilder -> uriBuilder
        .path("/users")
        .queryParam("status", "active")
        .queryParam("role", "admin")
        .queryParam("sort", "name,asc")
        .build())
    .retrieve()
    .body(new ParameterizedTypeReference<>() {});

POST 요청

// JSON 바디 전송
User newUser = new User("홍길동", "hong@example.com");

User created = restClient.post()
    .uri("/users")
    .contentType(MediaType.APPLICATION_JSON)
    .body(newUser)
    .retrieve()
    .body(User.class);

// ResponseEntity로 헤더 포함 응답 받기
ResponseEntity<User> response = restClient.post()
    .uri("/users")
    .body(newUser)
    .retrieve()
    .toEntity(User.class);

URI location = response.getHeaders().getLocation();
HttpStatusCode status = response.getStatusCode();

PUT / PATCH / DELETE

// PUT - 전체 수정
restClient.put()
    .uri("/users/{id}", userId)
    .body(updatedUser)
    .retrieve()
    .toBodilessEntity();

// PATCH - 부분 수정
restClient.patch()
    .uri("/users/{id}", userId)
    .body(Map.of("email", "new@example.com"))
    .retrieve()
    .body(User.class);

// DELETE
restClient.delete()
    .uri("/users/{id}", userId)
    .retrieve()
    .toBodilessEntity();

에러 처리

User user = restClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
        if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
            throw new UserNotFoundException("사용자를 찾을 수 없습니다: " + userId);
        }
        throw new ClientException("클라이언트 오류: " + response.getStatusCode());
    })
    .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
        throw new ServerException("서버 오류 발생");
    })
    .body(User.class);

// 전역 에러 핸들러 설정
@Bean
public RestClient restClient(RestClient.Builder builder) {
    return builder
        .baseUrl("https://api.example.com")
        .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
            String body = new String(response.getBody().readAllBytes());
            log.error("API 오류 - {} {}: {}",
                response.getStatusCode(), request.getURI(), body);
            throw new ApiException(response.getStatusCode(), body);
        })
        .build();
}

Exchange 메서드로 세밀한 제어

// exchange로 응답을 직접 처리
Optional<User> user = restClient.get()
    .uri("/users/{id}", userId)
    .exchange((request, response) -> {
        if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
            return Optional.empty();
        }
        if (response.getStatusCode().is2xxSuccessful()) {
            ObjectMapper mapper = new ObjectMapper();
            User u = mapper.readValue(response.getBody(), User.class);
            return Optional.of(u);
        }
        throw new ApiException("예상치 못한 응답: " + response.getStatusCode());
    });

파일 업로드

MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", new FileSystemResource("/path/to/file.pdf"));
body.add("description", "첨부 문서");

UploadResult result = restClient.post()
    .uri("/files/upload")
    .contentType(MediaType.MULTIPART_FORM_DATA)
    .body(body)
    .retrieve()
    .body(UploadResult.class);

인증 헤더 자동 주입

@Bean
public RestClient restClient(RestClient.Builder builder, TokenProvider tokenProvider) {
    return builder
        .baseUrl("https://api.example.com")
        .requestInitializer(request -> {
            String token = tokenProvider.getAccessToken();
            request.getHeaders().setBearerAuth(token);
        })
        .build();
}

HTTP Interface와 결합

Spring 6의 HTTP Interface를 사용하면 선언적 방식으로 API를 정의할 수 있습니다.

// 인터페이스 정의
public interface UserApi {

    @GetExchange("/users/{id}")
    User getUser(@PathVariable Long id);

    @GetExchange("/users")
    List<User> listUsers(@RequestParam String status);

    @PostExchange("/users")
    User createUser(@RequestBody User user);

    @DeleteExchange("/users/{id}")
    void deleteUser(@PathVariable Long id);
}

// 프록시 생성
@Bean
public UserApi userApi(RestClient restClient) {
    RestClientAdapter adapter = RestClientAdapter.create(restClient);
    HttpServiceProxyFactory factory = HttpServiceProxyFactory
        .builderFor(adapter)
        .build();
    return factory.createClient(UserApi.class);
}

// 사용
@Service
public class UserService {
    private final UserApi userApi;

    public User getUser(Long id) {
        return userApi.getUser(id); // 인터페이스 메서드 호출
    }
}

테스트 지원

@SpringBootTest
class UserServiceTest {

    @Autowired
    private UserService userService;

    private MockRestServiceServer mockServer;

    @BeforeEach
    void setup(@Autowired RestClient.Builder builder) {
        mockServer = MockRestServiceServer.bindTo(builder).build();
    }

    @Test
    void shouldGetUser() {
        mockServer.expect(requestTo("/users/1"))
            .andRespond(withSuccess(
                "{\"id\":1,\"name\":\"홍길동\"}",
                MediaType.APPLICATION_JSON));

        User user = userService.getUser(1L);
        assertThat(user.getName()).isEqualTo("홍길동");
        mockServer.verify();
    }
}

RestClient는 Spring Boot 3.4 이상의 프로젝트에서 동기 HTTP 통신의 표준으로 자리 잡았습니다. RestTemplate에서 마이그레이션할 때 API 스타일만 바꾸면 되므로 전환 비용도 낮습니다.