Spring HTTP Interface 선언적 클라이언트

Spring HTTP Interface란

Spring Framework 6.0(Spring Boot 3.0)부터 도입된 HTTP Interface는 Java 인터페이스에 어노테이션을 선언하는 것만으로 HTTP 클라이언트를 생성하는 기능입니다. OpenFeign과 유사한 선언적 방식이지만, Spring 네이티브로 RestClient, WebClient, RestTemplate을 백엔드로 사용할 수 있어 별도 라이브러리 의존성이 필요 없습니다.

기존 WebClientRestClient의 명령형 코드를 선언적 인터페이스로 대체하면 코드량이 크게 줄고, 비즈니스 로직과 HTTP 통신 세부사항이 완전히 분리됩니다.

기본 설정

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    // WebClient 백엔드 사용 시
    implementation("org.springframework.boot:spring-boot-starter-webflux")
}

인터페이스 정의

HTTP 엔드포인트를 인터페이스 메서드로 매핑합니다:

public interface UserApiClient {

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

    @GetExchange("/users")
    List<UserResponse> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size
    );

    @PostExchange("/users")
    UserResponse createUser(@RequestBody CreateUserRequest request);

    @PutExchange("/users/{id}")
    UserResponse updateUser(
        @PathVariable Long id,
        @RequestBody UpdateUserRequest request
    );

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

    @GetExchange("/users/{id}")
    ResponseEntity<UserResponse> getUserWithHeaders(@PathVariable Long id);
}
어노테이션 HTTP 메서드 비고
@GetExchange GET 조회
@PostExchange POST 생성
@PutExchange PUT 전체 수정
@PatchExchange PATCH 부분 수정
@DeleteExchange DELETE 삭제
@HttpExchange 커스텀 클래스 레벨 기본 경로 설정

프록시 빈 등록: RestClient 백엔드

Spring Boot 3.2+에서는 RestClient를 백엔드로 사용하는 것이 권장됩니다:

@Configuration
public class HttpClientConfig {

    @Bean
    public UserApiClient userApiClient(RestClient.Builder builder) {
        RestClient restClient = builder
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE,
                MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader("X-API-Key", "my-api-key")
            .requestInterceptor(new LoggingInterceptor())
            .build();

        HttpServiceProxyFactory factory = HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(restClient))
            .build();

        return factory.createClient(UserApiClient.class);
    }
}

WebClient 백엔드(리액티브)를 사용하려면:

@Bean
public UserApiClient userApiClientReactive(WebClient.Builder builder) {
    WebClient webClient = builder
        .baseUrl("https://api.example.com")
        .defaultHeader(HttpHeaders.CONTENT_TYPE,
            MediaType.APPLICATION_JSON_VALUE)
        .build();

    HttpServiceProxyFactory factory = HttpServiceProxyFactory
        .builderFor(WebClientAdapter.create(webClient))
        .build();

    return factory.createClient(UserApiClient.class);
}

리액티브 반환 타입

WebClient 백엔드 사용 시 Mono/Flux 반환이 가능합니다:

public interface OrderApiClient {

    @GetExchange("/orders/{id}")
    Mono<OrderResponse> getOrder(@PathVariable String id);

    @GetExchange("/orders")
    Flux<OrderResponse> streamOrders(
        @RequestParam String status
    );

    @PostExchange("/orders")
    Mono<ResponseEntity<OrderResponse>> createOrder(
        @RequestBody CreateOrderRequest request
    );
}

커스텀 헤더와 인증

동적 헤더와 Bearer 토큰 인증을 처리합니다:

@HttpExchange(url = "/api/v2", contentType = "application/json")
public interface PaymentApiClient {

    @PostExchange("/payments")
    PaymentResponse processPayment(
        @RequestBody PaymentRequest request,
        @RequestHeader("Authorization") String bearerToken,
        @RequestHeader("Idempotency-Key") String idempotencyKey
    );

    @GetExchange("/payments/{id}")
    PaymentResponse getPayment(
        @PathVariable String id,
        @RequestHeader("Authorization") String bearerToken
    );
}

매번 토큰을 전달하는 것이 번거롭다면, ClientHttpRequestInterceptor로 자동 주입합니다:

@Component
@RequiredArgsConstructor
public class BearerTokenInterceptor implements ClientHttpRequestInterceptor {

    private final TokenProvider tokenProvider;

    @Override
    public ClientHttpResponse intercept(
            HttpRequest request,
            byte[] body,
            ClientHttpRequestExecution execution) throws IOException {

        String token = tokenProvider.getAccessToken();
        request.getHeaders().setBearerAuth(token);
        return execution.execute(request, body);
    }
}

@Bean
public PaymentApiClient paymentApiClient(
        RestClient.Builder builder,
        BearerTokenInterceptor tokenInterceptor) {

    RestClient restClient = builder
        .baseUrl("https://payment.example.com")
        .requestInterceptor(tokenInterceptor)
        .build();

    return HttpServiceProxyFactory
        .builderFor(RestClientAdapter.create(restClient))
        .build()
        .createClient(PaymentApiClient.class);
}

에러 핸들링

HTTP 에러 응답을 도메인 예외로 변환합니다:

@Bean
public UserApiClient userApiClient(RestClient.Builder builder) {
    RestClient restClient = builder
        .baseUrl("https://api.example.com")
        .defaultStatusHandler(
            HttpStatusCode::is4xxClientError,
            (request, response) -> {
                String body = new String(
                    response.getBody().readAllBytes());

                if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
                    throw new ResourceNotFoundException(
                        "리소스를 찾을 수 없습니다: " + request.getURI());
                }
                if (response.getStatusCode() == HttpStatus.CONFLICT) {
                    throw new ConflictException("충돌: " + body);
                }
                throw new ClientException(
                    response.getStatusCode().value(), body);
            }
        )
        .defaultStatusHandler(
            HttpStatusCode::is5xxServerError,
            (request, response) -> {
                throw new ExternalServiceException(
                    "외부 서비스 오류: " + response.getStatusCode());
            }
        )
        .build();

    return HttpServiceProxyFactory
        .builderFor(RestClientAdapter.create(restClient))
        .build()
        .createClient(UserApiClient.class);
}

타임아웃과 재시도

RestClient에 타임아웃을 설정하고, 구조화 로깅과 함께 재시도 로직을 추가합니다:

@Bean
public RestClient restClient(RestClient.Builder builder) {
    // 타임아웃 설정
    HttpComponentsClientHttpRequestFactory requestFactory =
        new HttpComponentsClientHttpRequestFactory();
    requestFactory.setConnectTimeout(Duration.ofSeconds(3));
    requestFactory.setReadTimeout(Duration.ofSeconds(10));

    return builder
        .baseUrl("https://api.example.com")
        .requestFactory(requestFactory)
        .requestInterceptor((request, body, execution) -> {
            long start = System.currentTimeMillis();
            ClientHttpResponse response = execution.execute(request, body);
            long elapsed = System.currentTimeMillis() - start;

            log.info("HTTP {} {} → {} ({}ms)",
                request.getMethod(), request.getURI(),
                response.getStatusCode(), elapsed);

            return response;
        })
        .build();
}

Spring Retry와 조합하여 일시적 오류에 대한 자동 재시도:

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserApiClient userApiClient;

    @Retryable(
        retryFor = ExternalServiceException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public UserResponse getUser(Long id) {
        return userApiClient.getUser(id);
    }

    @Recover
    public UserResponse recoverGetUser(
            ExternalServiceException ex, Long id) {
        log.error("사용자 조회 최종 실패: id={}", id, ex);
        throw new ServiceUnavailableException("사용자 서비스 일시 불가");
    }
}

멀티 클라이언트 팩토리

여러 외부 API를 관리할 때 팩토리 패턴으로 중복을 줄입니다:

@Configuration
public class ApiClientConfig {

    private <T> T createClient(
            RestClient.Builder builder,
            String baseUrl,
            Class<T> clientClass,
            ClientHttpRequestInterceptor... interceptors) {

        RestClient.Builder configured = builder.clone()
            .baseUrl(baseUrl)
            .defaultHeader(HttpHeaders.ACCEPT,
                MediaType.APPLICATION_JSON_VALUE);

        for (var interceptor : interceptors) {
            configured.requestInterceptor(interceptor);
        }

        return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(configured.build()))
            .build()
            .createClient(clientClass);
    }

    @Bean
    public UserApiClient userApiClient(RestClient.Builder builder) {
        return createClient(builder,
            "https://user-service:8080", UserApiClient.class);
    }

    @Bean
    public PaymentApiClient paymentApiClient(
            RestClient.Builder builder,
            BearerTokenInterceptor auth) {
        return createClient(builder,
            "https://payment-service:8080",
            PaymentApiClient.class, auth);
    }

    @Bean
    public InventoryApiClient inventoryApiClient(RestClient.Builder builder) {
        return createClient(builder,
            "https://inventory-service:8080",
            InventoryApiClient.class);
    }
}

테스트: MockRestServiceServer

@SpringBootTest
class UserApiClientTest {

    @Autowired
    private UserApiClient userApiClient;

    private MockRestServiceServer mockServer;

    @Autowired
    private RestClient.Builder restClientBuilder;

    @BeforeEach
    void setup() {
        MockRestServiceServer.bindTo(restClientBuilder);
    }

    @Test
    void shouldGetUser() {
        mockServer.expect(requestTo("/users/1"))
            .andExpect(method(HttpMethod.GET))
            .andRespond(withSuccess(
                """
                {"id": 1, "name": "홍길동", "email": "hong@example.com"}
                """,
                MediaType.APPLICATION_JSON
            ));

        UserResponse user = userApiClient.getUser(1L);

        assertThat(user.name()).isEqualTo("홍길동");
        mockServer.verify();
    }
}

정리

Spring HTTP Interface는 OpenFeign을 대체하는 Spring 네이티브 선언적 HTTP 클라이언트입니다. 인터페이스와 어노테이션만으로 HTTP 통신을 정의하고, RestClient/WebClient를 백엔드로 유연하게 선택하며, 인터셉터로 인증·로깅·재시도를 횡단 관심사로 분리합니다. Spring Boot 3.2+ 프로젝트에서 외부 API 통합의 표준 패턴으로 자리잡고 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux