Post

06 스프링과 외부 API 호출

06 스프링과 외부 API 호출

1. Note

1. Note

  • 요령있게 구현가능한 환경이나 상태에 따라서 조절 필요할듯.
  • 급하게 바꿀 필요는 없는 것 같음.
  • feign(페인) 같은 경우도 보면
    • 의존성 버전 설정하는 Bom파일 같은 경우도 2023버전(2026.04.)
    • 기술이 발달하는 속도보다
    • 구축 환경이 해결할 수 있는 역량이 못 따라감
    • 그것 또한 비용!

2. 스프링에서 외부 API 호출 방식 종류

1. RestTemplate (레거시)

  • 호출 방식
    • 의존성: spring-boot-starter-web
    • 필수: Bean 등록
  • 소스
    1
    2
    3
    4
    5
    
    // 동기 방식 (blocking)
    // 현재는 deprecated (사용 비추천)
    
    RestTemplate restTemplate = new RestTemplate();
    String result = restTemplate.getForObject(url, String.class);
    

2. WebClient (Spring 공식 권장)

  • 호출 방식
    • 의존성: spring-boot-starter-webflux
    • 필수: 없음
  • 소스
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    // 비동기 / 논블로킹 (Reactive)
    // Spring WebFlux 기반
      
    WebClient webClient = WebClient.create();
      
    String result = webClient.get()
    .uri(url)
    .retrieve()
    .bodyToMono(String.class)
    .block();
    

3. Feign (OpenFeign)

  • 호출방식
    • 의존성: spring-cloud-starter-openfeign
    • 필수: @EnableFeignClients
    • 기본 사용: @FeignClient 인터페이스
  • 소스
    1
    2
    3
    4
    5
    6
    7
    8
    
    // 인터페이스 기반 선언형 HTTP Client
    // 별도로 Config 필요
    @FeignClient(name = "example", url = "https://api.example.com")
    public interface ExampleClient {
      
        @GetMapping("/users")
        List<User> getUsers();
    }
    

4. HttpClient (Apache, OkHttp 등)

  • 호출방식
    • 의존성: httpclient 또는 okhttp
    • 필수: 없음 (직접 생성)
  • 소스
    1
    2
    3
    
    //저수준 HTTP 클라이언트
    CloseableHttpClient client = HttpClients.createDefault();
    HttpGet request = new HttpGet(url);
    

2. 문제가 발생할 수 있는 상황들

1. 네트워크 오류

  • 문제
    • 네트워크 장애나 타임아웃으로 인해 API 호출이 실패하면, 데이터베이스에 이미 적용된 작업은 롤백되지 않음
    • 외부 API와 데이터베이스 간 상태 불일치가 발생할 가능성이 큼
  • 해결 방법
    • 네트워크 장애를 예상하고, API 호출 전후의 데이터베이스 상태를 관리하는 로직을 추가함
    • API 호출 실패 시 데이터베이스 작업을 취소하거나,
    • API 호출 성공 시 데이터베이스 작업을 수행하도록 순서를 조정

2. 외부 시스템 장애

  • 문제
    • 외부 시스템(예: 결제 게이트웨이, 메시지 큐)이 다운되면, 트랜잭션 상태와 외부 작업 결과가 불일치할 수 있음
    • 예: 결제 성공 후 주문 저장 중 실패하거나, 반대로 주문 저장 성공 후 결제 API 호출이 실패.
  • 해결 방법:
    • 보상 트랜잭션(Compensating Transaction)
      • API 호출 성공 이후 데이터베이스 작업이 실패하면, API 호출을 취소하는 로직을 추가
      • 예: 결제가 성공한 경우, 주문 저장 실패 시 결제를 취소하는 API 호출 수행.(기록이 남아서 위험)
    • 트랜잭션 내 API 호출 순서 조정
      • 데이터베이스 작업 후 외부 API를 호출하도록 설계하여, 데이터 정합성을 우선 보장
      • 예: 주문 저장 후 결제 API 호출.

3. 타임아웃 및 예외 처리

  • 문제
    • API 호출이 무한 대기 상태에 빠지거나, 응답 시간이 과도하게 길어질 경우 트랜잭션이 불필요하게 오래 유지됨
    • 이는 시스템 성능에 악영향을 미침
  • 해결 방법
    • API 호출 시 타임아웃을 설정하여, 일정 시간이 지나면 작업을 강제로 중단
    • 타임아웃 발생 시 트랜잭션 롤백 또는 재시도 로직을 추가

4. 일시적인 호출 실패

  • 문제
    • 외부 API 호출 실패가 일시적인 네트워크 장애나 과부하로 발생하는 경우가 많음
    • 즉각적인 실패 처리 대신 재시도를 통해 성공률을 높일 수 있음
  • 해결 방법
    • Spring의 @Retryable 어노테이션이나 Resilience4j 라이브러리를 활용하여 재시도 로직을 구현
    • 일정 횟수 재시도 후에도 실패하면 사용자 정의 예외를 던지고, 트랜잭션을 롤백

3. Retry 기법 활용 (재시도 전략)

1. 셋팅

  • 의존성
    1
    
    implementation 'org.springframework.retry:spring-retry'
    
  • Main에 어노테이션
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    @EnableRetry // 추가 필요
    @EnableAsync
    @EnableFeignClients
    @SpringBootApplication
    public class ExamplePart3Application {
      public static void main(String[] args) {
        SpringApplication.run(ExamplePart3Application.class, args);
      }
    }
    

2. 적용

1
2
3
4
5
6
7
8
9
10
11
12
@Transactional
@Retryable(value = ServiceException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void save() {
  // ... 비즈니스 로직
  throw new ServiceException(ServiceExceptionCode.NOT_FOUND_PRODUCT); // retry 실행
}
// `value` => 재시도 대상 예외를 지정
// `maxAttempts` => 재시도를 몇 번 수행할지 설정
// `@Backoff` => 각 재시도 사이의 대기 시간을 설정
    => 1000이면 1초정도
    => 바로 바로 실행해버리면 반복 장애가 발생할 수도 있음 
// 최대 재시도 횟수 내에서도 실패하면 최종적으로 `RemoteException`이 호출자에게 전달

5. 타임아웃 설정

1. 타임아웃 설정

  • 외부 API 호출이 무한 대기 상태에 빠지거나, 응답 시간이 지나치게 길어질 경우 전체 시스템의 성능이 저하됨
  • 이를 방지하기 위해 타임아웃 설정을 통해 일정 시간이 지나면 요청을 강제로 종료하는 방법을 사용
  • OpenFeign을 활용해서 타임아웃을 구현하는 것을 권장

2. OpenFeign

  • REST API를
    • 직접 HttpURLConnection, RestTemplate, WebClient로 호출하는 대신
    • 자바 인터페이스로 선언만 하면 내부에서 HTTP 호출을 대신 해주는 도구
  • 구분
    • OpenFeign → 오픈소스 프로젝트(Git)
    • Spring Cloud OpenFeign → 스프링에서 통합 지원

3. FeignClient를 활용한 타임아웃 설정

  • 의존성
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    dependencyManagement {
      imports {
          mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.1"
      }
    }
        
    dependencies {
      implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
    }
    
  • Configuration
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    @Configuration
    public class OpenFeignConfig {
      
      @Bean
      public Request.Options feignOptions() {
        return new Request.Options(
            10000, TimeUnit.MILLISECONDS, -- 연결시도 시간 제한 10초
            60000, TimeUnit.MILLISECONDS, -- 연결이후 응답 대기 시간 60초
            true -- HTTP 리다이렉트(3xx)를 자동으로 따라갈지 여부
        );
      }
      
      @Bean
      public Retryer feignRetryer() {
        return Retryer.NEVER_RETRY; -- 요청 실패 시 절대 재시도하지 않는다 (딱 1번만 호출)
      }
    }
    
  • Main
    1
    2
    3
    4
    5
    6
    
    @EnableFeignClients
    public class ExamplePart3Application {
      public static void main(String[] args) {
        SpringApplication.run(ExamplePart3Application.class, args);
      }
    }
    

3. 서비스단

  • 매핑설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@FeignClient(
    name = "external-product",
    url = "${external.external-shop.url}",
    configuration = OpenFeignConfig.class
)
public interface ExternalShopClient {

  @GetMapping("/products?page={page}&size={size}") // url설정
  ExternalProductResponse getProducts(@RequestParam("page") Integer page,
      @RequestParam("size") Integer size);
      
  // 매핑은 컨트롤러랑 동일하게 사용가능함
  // 리턴타입은 요청후 받는 리스폰 되는 것이고 외부API와 맞춰야함.
  // 파라미터로 넣은 것들은 URL로 잘들어감
  // RequestBody도 잘들어감!    
      
}
  • 서비스단
1
2
3
4
5
6
7
8
9
10
11
12
private final ExternalShopClient externalShopClient;
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;

@Transactional
@Retryable(value = DomainException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void save() {
  try {
    // 요청받는 메소드
    ExternalProductResponse responses = externalShopClient.getProducts(1, 10); 
    
  ~~~~~
This post is licensed under CC BY 4.0 by the author.