*/Spring

[Spring] OpenFeign ์‚ฌ์šฉํ•ด๋ณด๊ธฐ

sssbin 2024. 5. 22. 15:53

๐Ÿ’ก OpenFeign์ด๋ž€?

Netflix์— ์˜ํ•ด ์ฒ˜์Œ ๋งŒ๋“ค์–ด์ง„ Declarative(์„ ์–ธ์ ์ธ) HTTP Client ๋„๊ตฌ๋กœ์จ, ์™ธ๋ถ€ API ํ˜ธ์ถœ์„ ์‰ฝ๊ฒŒํ•  ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ค€๋‹ค. Open Feign์€ ์ธํ„ฐํŽ˜์ด์Šค์— ์–ด๋…ธํ…Œ์ด์…˜๋“ค๋งŒ ๋ถ™์—ฌ์ฃผ๋ฉด ๊ตฌํ˜„์ด ๋œ๋‹ค. ์ด๋Ÿฌํ•œ ๋ฐฉ์‹์€ Spring Data JPA์™€ ์œ ์‚ฌํ•˜๋ฉฐ, ์ƒ๋‹นํžˆ ํŽธ๋ฆฌํ•˜๊ฒŒ ๊ฐœ๋ฐœ์„ ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ค€๋‹ค.

๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ถ”๊ฐ€

ext {
    set('springCloudVersion', "2023.0.1")
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

dependencies {
    ...
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
    ...
}

** ์ฐธ๊ณ  https://spring.io/projects/spring-cloud#overview

ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๋ฒ„์ „์ด 3.2.5์—ฌ์„œ 2023.0.x ๋ฒ„์ „์„ ์‚ฌ์šฉํ–ˆ๊ณ , ๊ทธ ์ค‘์—์„œ๋„ ์ œ์ผ ์ตœ์‹  ๋ฒ„์ „์œผ๋กœ ์ž‘์„ฑํ•ด์คฌ๋‹ค!

OpenFeign ์„ค์ •

๋ฉ”์ธ ํด๋ž˜์Šค์— @EnableFeignClients ๋ฅผ ๋ถ™์—ฌ์ฃผ๋ฉด ๋œ๋‹ค.

์ด๋ ‡๊ฒŒ ํ•˜๋ฉด @FeignClient ๊ฐ€ ๋ถ™์€ ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์ž๋™์œผ๋กœ ์ฐพ๋Š”๋‹ค.

ํ•˜์ง€๋งŒ ๋‚˜๋Š” ๋ณ„๋„์˜ Config ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ์„ค์ •ํ•ด์คฌ๋‹ค.

package com.todayeat.backend._common.config;

import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableFeignClients("com.---.---")
public class OpenFeignConfig {

๋ณ„๋„์˜ ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด ์„ค์ •ํ•ด์ค„ ๋•Œ๋Š” ์ธํ„ฐํŽ˜์ด์Šค๋“ค์˜ ์œ„์น˜๋ฅผ ์ง€์ •ํ•ด์ค˜์•ผ ํ•œ๋‹ค.

๋‚˜๋Š” @EnableFeignClients ๋ฅผ ๋ถ™์—ฌ์„œ ํŒจํ‚ค์ง€๋กœ ์ง€์ •ํ•ด์คฌ๋‹ค.

ํด๋ผ์ด์–ธํŠธ ๊ตฌํ˜„

// ์˜ˆ์‹œ1

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;

@FeignClient(name = "KakaoRequestClient", url = "https://kapi.kakao.com")
public interface KakaoRequestClient {

    @PostMapping(value = "/v1/user/unlink", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    void unlink(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken);
}
// ์˜ˆ์‹œ2

import com.todayeat.backend.order.api.dto.request.CancelPaymentRequest;
import com.todayeat.backend.order.api.dto.response.CancelPaymentResponse;
import com.todayeat.backend.order.api.dto.response.GetPaymentResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

@FeignClient(name = "IamportRequestClient", url = "https://api.portone.io")
public interface IamportRequestClient {

    @GetMapping(value = "/payments/{paymentId}", consumes = MediaType.APPLICATION_JSON_VALUE)
    GetPaymentResponse getPayment(@RequestHeader(HttpHeaders.AUTHORIZATION) String apiSecret,
                                  @PathVariable String paymentId,
                                  @RequestParam String storeId);

    @PostMapping(value = "/payments/{paymentId}/cancel", consumes = MediaType.APPLICATION_JSON_VALUE)
    CancelPaymentResponse cancelPayment(@RequestHeader(HttpHeaders.AUTHORIZATION) String apiSecret,
                                        @PathVariable String paymentId,
                                        @RequestBody CancelPaymentRequest request);
}

API ํ˜ธ์ถœ์„ ์ˆ˜ํ–‰ํ•  ํด๋ผ์ด์–ธํŠธ๋Š” ์ธํ„ฐํŽ˜์ด์Šค๋กœ ์ž‘์„ฑํ•œ ํ›„, @FeignClient ๋ฅผ ๋ถ™์—ฌ์ฃผ๋ฉด ๋œ๋‹ค.

๊ธฐ์กด์˜ ์ปจํŠธ๋กค๋Ÿฌ์™€ ๊ต‰์žฅํžˆ ์œ ์‚ฌํ•˜๊ฒŒ ์ž‘์„ฑ์ด ๊ฐ€๋Šฅํ•˜๋‹ค!

์„œ๋น„์Šค์—์„œ ํ•ด๋‹น ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋˜๊ธฐ ๋•Œ๋ฌธ์— ํŽธ๋ฆฌํ•˜๊ฒŒ API ํ˜ธ์ถœ์„ ํ•  ์ˆ˜ ์žˆ๋‹ค.

ํ˜ธ์ถœ ์˜ˆ์‹œ

import com.todayeat.backend._common.oauth2.api.KakaoRequestClient;
import com.todayeat.backend._common.oauth2.dto.response.OAuth2Provider;
import com.todayeat.backend._common.oauth2.dto.response.OAuth2UserResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuth2UnlinkService {

    private final KakaoRequestClient kakaoRequestClient;

    public void kakaoUnlink(String accessToken) {

        log.info("[OAuth2UnlinkService.kakaoUnlink]");
        kakaoRequestClient.unlink("Bearer " + accessToken);
    }
}

์œ„์˜ ์˜ˆ์‹œ1์—์„œ ์ž‘์„ฑํ•œ ํด๋ผ์ด์–ธํŠธ์— ๋Œ€ํ•œ ํ˜ธ์ถœ ์˜ˆ์‹œ์ด๋‹ค.

์นด์นด์˜ค ํšŒ์› ํƒˆํ‡ด๋ฅผ ์ง„ํ–‰ํ•˜์˜€๊ณ , ๋ฐ˜ํ™˜๊ฐ’์€ ํ•ด๋‹น ๋กœ์ง์— ๋ถˆํ•„์š”ํ•œ ์ •๋ณด๋ผ๊ณ  ํŒ๋‹จํ•˜์—ฌ void๋กœ ๋ฐ›์•˜๋‹ค.

๋„ˆ๋ฌด๋„ˆ๋ฌด๋„ˆ๋ฌด ๊ฐ„๋‹จํ•˜๋‹ค. ๋!

์˜ˆ์™ธ ์ฒ˜๋ฆฌ

private void cancelPayment(String paymentId) {

    try {
        iamportRequestClient.cancelPayment(
                PORTONE_PREFIX + IAMPORT_API_SECRET_V2,
                paymentId,
                CancelPaymentRequest.of("invalid value"));
    } catch (Exception e) {
        log.error("[OrderService.cancelPayment] paymentId {} error : {}", paymentId, e.getMessage());
        throw new BusinessException(ORDER_CANCEL_FAIL);
    }
}

HTTP ์š”์ฒญ์ด ์„ฑ๊ณตํ•  ๋•Œ๋Š” ๊ฐ์ฒด๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋งคํ•‘๋˜์ง€๋งŒ,

๊ทธ ์™ธ์—๋Š” ๋ชจ๋“  ์š”์ฒญ์ด FeignException์œผ๋กœ ์ฒ˜๋ฆฌ๋œ๋‹ค.

// feign.FeignException#clientErrorStatus

switch (status) {
  case 400:
    return new BadRequest(message, request, body);
  case 401:
    return new Unauthorized(message, request, body);
  case 403:
    return new Forbidden(message, request, body);
  case 404:
    return new NotFound(message, request, body);
  case 405:
    return new MethodNotAllowed(message, request, body);
  case 406:
    return new NotAcceptable(message, request, body);
  case 409:
    return new Conflict(message, request, body);
  case 410:
    return new Gone(message, request, body);
  case 415:
    return new UnsupportedMediaType(message, request, body);
  case 429:
    return new TooManyRequests(message, request, body);
  case 422:
    return new UnprocessableEntity(message, request, body);
  default:
    return new FeignClientException(status, message, request, body);
}
// feign.FeignException#serverErrorStatus

switch (status) {
  case 500:
    return new InternalServerError(message, request, body);
  case 501:
    return new NotImplemented(message, request, body);
  case 502:
    return new BadGateway(message, request, body);
  case 503:
    return new ServiceUnavailable(message, request, body);
  case 504:
    return new GatewayTimeout(message, request, body);
  default:
    return new FeignServerException(status, message, request, body);
}

์ƒํƒœ ์ฝ”๋“œ๊ฐ€ 400๋ฒˆ๋Œ€์ผ ๋•Œ๋Š” FeignClientException์ด๊ณ , 500๋ฒˆ๋Œ€์ผ ๋•Œ๋Š” FeignServerException์ด๋‹ค.

๋”ฐ๋ผ์„œ ํ•ด๋‹น ์š”์ฒญ์˜ ์˜ˆ์™ธ๋„ ์ผ๋ฐ˜์ ์ธ ์˜ˆ์™ธ์™€ ๋˜‘๊ฐ™์ด ์ฒ˜๋ฆฌํ•ด์ฃผ๋ฉด ๋œ๋‹ค. ๋‚˜๋Š” try~catch ๋ฌธ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜์˜€๋‹ค.

๋ณดํ†ต์€ ErrorDecoder๋ฅผ ๋งŽ์ด ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ ๊ฐ™์•˜๋‹ค. ์ด ๋ถ€๋ถ„์€ ์กฐ๊ธˆ ๋” ์•Œ์•„๋ด์•ผ ํ•  ๊ฒƒ ๊ฐ™๋‹ค!