개요
여러 프로젝트를 진행하는 동안 외부 API를 사용하는 경우가 많았다. 가볍게는 소셜로그인부터 카카오톡 메세지 전송, 사내 앱 푸시 알림 등 운영중인 프로젝트에서도 사용하였다.
하지만 모든 경우에 장애가 발생한다는 것을 확인하지 않았다. 장애가 발생하면 바로 실패메세지가 뜨고, 따로 다시 시도를 한다거나, 얼만큼 기다렸다가 시도를 한다거나 하는 것 말이다.
수업에서 사용하는 도메인은 커머스로서, 사용자가 결제요청을 했고 모든 정보를 입력하였음에도 불구하고 외부 API가 불안정하다는 이유만으로 결제가 되지 않는다면 이는 회사의 실질적인 매출에 영향을 줄 것이다.
그래서 이번 주에는 외부 API를 의존하고, 이에 문제가 발생할 경우 어떻게 대처해야할 지에 대해 배우게 되었다.
RestTemplate 이용하기
외부 API를 호출하는 방식은 크게 동기와 비동기가 있다.
외부 API를 이용할 때에는 보통 비동기를 이용한다. 그래야 사용자가 보낸 API가 잘 요청이 되었는지 등을 1차적으로 확인하여 다음 작업을 할 수 있기 때문이다.
Spring에는 이러한 비동기를 지원하는 라이브러리가 여러 개가 있다. ///// 등이 있으며 이번에는 나한테 익숙한 RestTemplate을 이용해보고자 한다.
circuitBreaker 란 ?
circuitBreaker 란 연결의 성공/실패를 카운트하여 실패율이 임계치를 넘어섰을 때 자동으로 접속을 차단하는 시스템을 말한다. 다량의 오류가 발생하여 실패율이 임계치를 넘어가게 되면, 해당 시점에 발생하는 모든 호출들은 fallback method를 타게된다. 이렇게만 설명하면 이해가 잘 가지 않을 수 있으니 친구와 단둘이 여행을 간다는 상황에 빗대어 timeout, fallback, retry, 그리고 circuit breaker 에 대해 이야기해보도록 하겠다.
timeout
친구와 둘이 기차를 타고 여행을 간다. 그런데 기차를 타야하는데 친구가 오지 않는다.
이런 경우 내 뒤에 사람들이 많이 줄을 섰음에도 불구하고 열차 문 앞에서 친구가 올 때까지 기다리면 안될 것이다.
그래서 최소한 5초만 더 기다렸다가 안오면 그냥 타야겠다.. 라고 생각하게 될 것이다.
이러한 경우처럼 서비스가 다른 서비스로 요청을 보내는데 요청에 대한 응답이 오지 않는다고 무한정으로 기다리면 다음 응답들이 밀릴 것이다. 따라서 시스템적으로 특정 시간까지만 대기한다는 것을 설정해주는 것이 timeout 이다.
fallback
친구가 특정 시간까지 안와 결국 혼자 열차를 타게 되었다. 친구 자리까지 2자리를 예약 했지만, 친구가 오지 않아 혼자 앉게 되었다. 이 때 옆자리를 그대로 두면 돈 낭비일 것이다. 그래서 나는 옆자리 티켓을 취소하고 다른 사람이 앉도록 할 수 있을 것이다.
이처럼 실패한 경우 어떻게 할 것이다라는 대안을 fallback 이라고 한다.
retry
친구를 열차 앞에서 기다리는데, 친구가 오지 않는다고 무한정 가만히 있으면 안되고 친구한테도 기회를 주어야 한다.
이 때 친구한테 "나 진짜 너 버리고 열차탄다"라고 몇번이나 말해야할까?
이 횟수는 너무 많아서도, 너무 적어서도 안된다. 너무 많다면 친구한테는 공격처럼 느껴질 것이고 너무 적어도 친구의 긴장감이 없을 것이다.
예를 들어서 단순히 늦잠을 자서 늦은거라면 횟수를 정해 몇 번만 말하면 된다. 하지만 친구가 아파서 늦는거라면 너무 빨리 말해도 부담이 될 것이다. 그래서 이럴 땐 텀을 주고 말해야한다.
이처럼 시스템에서도 응답이 오지 않는다면 얼마나, 얼마 간이 간격을 두고 말해야하는 것을 정해야 한다. 이것을 retry라고 한다. retry를 설정할 때, 너무 많이 요청을 보낸다면 수신 시스템에서는 디도스 공격로 받아들일 수도 있으며 너무 적게 말한다면 문제가 해결되지 않을 수도 있다.
따라서 단순히 느리게 답을 해주었던 것이라면 일정 횟수정도 요청을 보내면 되고, 타서버가 장애가 발생했던 경우라면 일정 텀을 두고 요청을 보내는 것이 맞을 것이다.
circuit breaker
개발자가 위의 timeout, fallback, retry를 하나씩 모두 결정을 해주어야한다면 귀찮을 것이다. 그래서 자동 차단기 역할을 하는 것이 circuit-breaker 이다.
circuitBreaker 설정하기
java에서 circuitBreaker 는 Resilience4j를 이용하여 구현할 수 있다.
Resilience4j는 함수형 프로그래밍을 위해 설계된 가벼운 라이브러리이며, circuit breaker 뿐만 아니라 rate limiter, retry 또는 bulkhead를 사용하여 함수형 인터페이스, 람다식 또는 참조형 메서드를 향상시키는 데코레이터를 제공한다.
아래와 같은 형식으로 설정해주면, config 파일을 굳이 생성하지 않아도 설정이 가능하다. 저기서 pgRequest는 circuit-breaker 에서 사용할 이름이다.
아래의 설정은 멘토링 시간에 "이렇게 설정하면 좋다 ~" 정도의 설정이고, 현재 내가 진행중인 프로젝트에 맞는 설정이니 참고만 하면 좋을 것 같다.
resilience4j:
retry:
instances:
pgRequest:
max-attempts: 3 # 최대 3번 재시도 (총 4번 호출)
wait-duration: 1s # 재시도 간격
exponential-backoff-multiplier: 2 # 지수백오프 (1s, 2s, 4s)
retry-exceptions:
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.client.ResourceAccessException
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.client.HttpServerErrorException$InternalServerError
- java.util.concurrent.TimeoutException
ignore-exceptions:
- com.loopers.support.error.CoreException
circuitbreaker:
instances:
pgRequest:
sliding-window-size: 10 # 슬라이딩 윈도우 크기
sliding-window-type: count_based # 호출 횟수 기반
minimum-number-of-calls: 5 # 최소 호출 횟수
failure-rate-threshold: 50 # 실패율 임계값 (50%)
slow-call-rate-threshold: 50 # 느린 호출 비율 임계값
slow-call-duration-threshold: 3s # 느린 호출 기준 시간
wait-duration-in-open-state: 10s # OPEN 상태 유지 시간
permitted-number-of-calls-in-half-open-state: 3 # HALF_OPEN에서 허용할 호출 수
record-exceptions:
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.client.ResourceAccessException
# - org.springframework.web.client.HttpServerErrorException
# - org.springframework.web.client.HttpServerErrorException$InternalServerError
- java.util.concurrent.TimeoutException
ignore-exceptions:
- com.loopers.support.error.CoreException
위와 같이 설정을 해주었으면 간편하게 resttemplate을 사용하는 함수에 @CircuitBreaker 어노테이션을 붙여 사용해주면 된다.
이렇게 name에 위에서 설정한 instance 명을 설정해주면 설정해준 대로 실행 가능하다.
@Override
@Retry(name = "pgRequest", fallbackMethod = "pgFallback")
@CircuitBreaker(name = "pgRequest", fallbackMethod = "pgFallback")
public PgPaymentInfraV1Dto.PaymentResponse requestPayment(Long userId, String orderUUID, String cardType, String cardNo, Long amount) {
log.info("PG 결제 요청 시작 - userId: {}, orderUUID: {}", userId, orderUUID);
// 유효성 검증
validatePaymentRequest(userId, orderUUID, cardType, cardNo, amount);
String url = "http://localhost:8082/api/v1/payments";
String callbackUrl = "http://localhost:8080/api/v1/payments/callback";
HttpHeaders headers = new HttpHeaders();
headers.set("X-USER-ID", userId.toString());
PgPaymentInfraV1Dto.PgPaymentRequest paymentRequest = new PgPaymentInfraV1Dto.PgPaymentRequest(
orderUUID,
cardType,
cardNo,
amount.toString(),
callbackUrl
);
HttpEntity<PgPaymentInfraV1Dto.PgPaymentRequest> request = new HttpEntity<>(paymentRequest, headers);
ResponseEntity<PgPaymentInfraV1Dto.PgPaymentResponse> response = restTemplate.postForEntity(
url,
request,
PgPaymentInfraV1Dto.PgPaymentResponse.class
);
return PgPaymentInfraV1Dto.PaymentResponse.from(response.getBody());
}
fallback 메서드의 경우에는 아래와 같이 작성한다.
public PgPaymentInfraV1Dto.PaymentResponse pgFallback(Long userId, String orderUUID, String cardType, String cardNo, Long amount, Exception ex) {
log.error("PG 결제 요청 fallback 실행 - userId: {}, orderUUID: {}, error: {}",
userId, orderUUID, ex.getMessage(), ex);
return new PgPaymentInfraV1Dto.PaymentResponse(false, null, PaymentV1Dto.TransactionStatusResponse.FAILED, "PG 시스템에서 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
}
내가 fallbackMethod에 입력한 이름으로 함수명을 입력하고, 파라미터와 리턴값은 @CircuitBreaker 와 동일하게 하면 된다.
결론
이렇게 단순하게 외부 API 장애에 1차적으로 대응할 수 있게 되었다.
만약에 성공률이 30% 인 서비스가 있다면 여러번의 재시도를 진행하여 성공률을 높일 수 있을 것이다. 이는 서비스의 장애가 외부에 의존하지 않도록 해준다.
'loop:Pak L2 Backend(2025)' 카테고리의 다른 글
| [loop:Pak] Round7 - 이벤트 처리하기 (1) | 2025.08.28 |
|---|---|
| [loop:Pak] Round6 - WIL (0) | 2025.08.22 |
| [loop:Pak] Round-5 DB 구조 변경, 인덱스와 캐싱을 이용하여 조회 성능 늘리기 (7) | 2025.08.13 |
| [loop:Pak] Round4 - WIL (2) | 2025.08.09 |
| [loop:Pak] Round4 - 비관적 락, 낙관적 락 걸기 (4) | 2025.08.08 |