그냥 예외처리만 잘했는데요?
좋아 그럼 너의 서버는 외부 api장애시 thread pool을 전부다 소모해버릴 것이다.
외부 Api 호출시 생길수 있는 문제는 다음과 같다.
- 타임아웃 미설정 - 외부 Api가 느려도 응답을 기다리며 스레드 점유
- 벌크헤드 없음 - 외부 Api호출이 하나의 공유 스레드풀, 리소스풀을 사용해 장애가 전파됨
- 서킷 브레이커 없음 - 실패가 연속해서 발생해도 계속 api를 호출함
- Retry 백오프 없음 - 일시적인 장애인데도 retry를 짧은 시간동안 계속 반복
- 멱등성 없음 - 재시도 요청이 중복처리됨
그럼 이제 이들의 자세한 원인 해결 법을 알아보자
타임 아웃 미설정
서블릿 서버의 경우 1요청 1스레드를 점유하게된다. 이때 wait time 설정이없어서 무한정 대기한다면 금방 스레드 풀이 소모될 것이다.
이를 예견할수 있는 방법이 있는데
- Cpu 사용량은 낮은데 서버 속도는 급격히 저하될때
- Thread Dump에 wait 혹은 timeout 다수가 있을때
즉이는 스레드 고갈로 이어지진다.
자신이사용하는 외부통신 라이브러리(Webclient, resttemplate 등에 timeout관련 설정이 있으니 이를 적극 사용하자)
벌크해드 미설정
가령 당신이 설정한 api에 트랜잭션이 걸려있어 DB 커넥션 풀이 연결된 상태에서 외부 api를 호출하고나서 트랜잭션이 완료된다면, 외부 api 통신장애가 트랜잭션 커밋을 안해 DB 커넥션을 점점 점유 -> DB에 Rock을 걸기 시작 -> 외부 api 장애로인한 전파가 DB까지 전파 으악
이를 장애전파라고하며 개발자라면 피해야하지 않겠는가?
물론이는 극단적인 예시고 트랜잭션안에 외부 api호출을 넣는 사람은 없을것이다(아마도?)
이번 장애도 1번과 마찬가지로 Cpu 사용량은 낮은데 커넥션풀 스레드풀은 사용량이 높다.
다행히 스프링은 다음과 같은 어노테이션을 제공한다.
@Bulkhead(name = "kakaoApi", type = THREADPOOL)
public String callKakaoApi() {
return webClient.get()
.uri("https://api.kakao.com/send")
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(2))
.block(); // block 위험하지만 격리되면 OK
}
이코드의 의미는 kakaoApi 스레드풀만 사용하라는 것이다. 여기에 10개를 설정했다면, 그리고 main에는 다른 스레드가 70개남았다면 장애가 커지진 않을 것이다.
하지만이는 배에 구멍 뚤렸을때 뚤린 방의 격벽만 막았을뿐 결국 구멍 수리를 해야한다. 즉 회복로직을 추가로 구현해야 한다.(직접 해결 하든지) 회복로직은 이 글을 다 읽어보면 어찌해야할지 느껴질 것이다
서킷 브레이커 없음, Retry 백오프 없음
집 가전제품 연결한 콘센트가 고장났는데 전기공급이 계속된다. 그럼 불이 나지 않겠는가? 다행이다. 당신의 전선엔 퓨즈가 었다. 자동으로 전기공급을 막았다.
백엔드도 똑같다. 외부 api에서 500에러가 계속 생기는데 서비스는 계속 호출을 한다. 이런 실패는 예외처리가 된다해도 이를처리하는 Cpu, 로그 적재로 인한 IO등 각종 리소스 사용량이 급격하게 증가한다.
다행히 감사하게도 스프링앤 중지해 주는 라이브러리가 존재한다.
Resilience4j의 @CircuitBreaker 어노테이션을 사용할 수 있다.
사용법이 궁금하면 접은 글을 펼처보자
@CircuitBreaker(name = "kakaoApi", fallbackMethod = "fallback")
public String callKakao() {
return webClient.get()
.uri("https://api.kakao.com/send")
.retrieve()
.bodyToMono(String.class)
.block(); // 외부 장애 시 여기서 예외 계속 남
}
resilience4j:
circuitbreaker:
instances:
kakaoApi:
slidingWindowSize: 10 # 최근 10개의 호출을 기준으로 실패율을 계산함
failureRateThreshold: 50 # 10개 중 5개 이상 실패하면 상태를 OPEN으로 전환
waitDurationInOpenState: 10s # OPEN 상태로 전환된 후 10초 동안 모든 요청을 차단
permittedNumberOfCallsInHalfOpenState: 2 # HALF-OPEN 상태에서 2개만 테스트 요청 시도
minimumNumberOfCalls: 10 # 실패율 계산을 시작하기 위한 최소 호출 수
automaticTransitionFromOpenToHalfOpenEnabled: true # 대기시간 후 자동으로 HALF-OPEN 전환
recordExceptions:
- java.io.IOException # IOException 발생 시 실패로 간주
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.example.IgnoredCustomException # 이 예외는 실패로 간주하지 않음
동작 순서
- 초기 상태 (CLOSED)
모둔 요청은 정상적으로 외부 API에 전달된다.
실패처리를 설정했다면 (접은글 참조) 그것에 해당하는 것만 실패로 간주로하고
설정을 안했다면 모든걸 실패처리를한다. - 실패율 계산 조건 충족시
설정한 값대로 요청이 10번(설정값) 이상 누적되면 실패율을 계산하기 시작한다.
그래서 이제 5번(설정값) 이상 에러나면 상태 전환 조건이 충족된다. - 상태 전환 (OPEN)
실패율이 50%가 넘으면 CirucuitBreaker가 OPEN 상태로 전환된다. 그래서 이후 10초(설정값) 동안 모든 요청을 보내지 않는다. 코딩상으로는 webClient.get() 가 내부실행이 안된다. - 10초 대기 후 (HALF_OPEN)
설정된 시간(10초) 가 지나면 자동으로 HALF_OPEN 상태로 진입한다.
이 상태에서는 permittedNumberOfCallsInHalfOpenState 값에 따라서 테스트 호출을 몇건만 허용한다.
이때 성공하면 CLOSED 상태로 복귀한다. (정상화)
여기서 특징은 테스트 호출이 1건이라도 실패하면 즉시 OPEN으로 전환해서 다시 차단한다.
멱등성 문제
멱등성이란 같은 요청을 여러번 보내도 결과가 한번 보낸 것과 같아야 한다는 성질이다.
여기서 생기는 문제 예시를 보자
- 같은 요청을 Retry같은 이유로 여러번 들어옴
- 서버가 이걸 매번 새 요청으로 인식하고 처리함
- 결제 2번, 쿠폰 2장 발급 같은 비지니스 논리 오류가 발생함
해결 방안 1 : Idempotency-Key 기반 요청 중복 제어
- 클라이언트가 Idempotency-key를 생성한다.
각 요청마다 UUID나 고유 키를 생성해서 HTTP Header에 포함한다. - 서버는 이 key값을 가지고 중복 요청인지 판단한다.
이때 중요한 문제가 있다. 외부 API를 사용할때 외부 API에서 Idempotency-Key 기반 멱등성을 지원해줘야한다. (Toss, KakaoPay 등에서 지원한다.)
근데 이를 지원하지 않는다면 클라이언트는 뭘할수 있을까?
해결 방안 2 : 클라이언트가 처리하기
결론부터 얘기하면 클라이언트가 처리할수 있는 방법이 제한적이다. 외부 API 서버에서 잘만들어 놨길 기도하는 방식이 가장 합리적이다.
- 외부 API 호출 전에 멱등성 key 기록
방식 : 클라이언트도 key를 저장하고 이를 저장
문제점 : key저장하고 에러가나면? 그럼 에러는 났는데 이미 처리된걸로 처리 기록이 호출보다 앞서면 안된다. - 외부 API 호출 후에 멱등성 key 기록
방식 : 실제 호출 후에 결과를 저장하고, 같은 키면 캐시된 응답 리턴
문제점 : 외부 호출이 성공했지만 응답 전에 서버 다운시? 즉 처리 성공 여부와 저장(기억)이 원자적이지 않다.
결국 외부 API가 멱등성을 지원하길 바래야하며 내가 백엔드 개발자라면 멱등성을 생각해야한다.
이를 보안하기 위한 요청 로그 + 오퍼레이셔널 워크플로우 기능도 있으니 한번 찾아보길 바란다.
마무리
지금까지 진행한 내역에 대한 예시코드와 설명이다.
외부 API 호출 보호 예시
@Service
public class KakaoApiService {
private final WebClient webClient;
private final RedisTemplate<String, String> redisTemplate;
public KakaoApiService(WebClient.Builder webClientBuilder, RedisTemplate<String, String> redisTemplate) {
this.webClient = webClientBuilder.baseUrl("https://api.kakao.com").build();
this.redisTemplate = redisTemplate;
}
/**
* 외부 API 호출 로직
* - Bulkhead: kakao 전용 스레드풀로 격리
* - CircuitBreaker: 실패율 누적 시 차단
* - Retry: 최대 3회 재시도 + 백오프 전략
* - 멱등성: Redis로 중복 요청 차단
*/
@Retry(name = "kakaoApi")
@CircuitBreaker(name = "kakaoApi", fallbackMethod = "fallback")
@Bulkhead(name = "kakaoApi", type = Bulkhead.Type.THREADPOOL)
public String callKakaoApi(String message, String idempotencyKey) {
// ❶ 멱등성: Redis에 이전 결과가 있으면 재사용
if (Boolean.TRUE.equals(redisTemplate.hasKey(idempotencyKey))) {
return redisTemplate.opsForValue().get(idempotencyKey);
}
// ❷ 타임아웃 + WebClient 호출
String result = webClient.post()
.uri("/send")
.header("Content-Type", "application/json")
.bodyValue("{ \"message\": \"" + message + "\" }")
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(2)) // ← 타임아웃 설정 (타임아웃 미설정 방지)
.block(); // ← 이 block이 위험하지만 벌크헤드 격리로 리스크 제어 가능
// ❸ 멱등성 응답 저장
redisTemplate.opsForValue().set(idempotencyKey, result, Duration.ofMinutes(10));
return result;
}
// ❹ fallback 처리
public String fallback(String message, String idempotencyKey, Throwable t) {
// 장애 대응 기본 응답 반환
return "카카오 메시지 전송 실패. 나중에 다시 시도해주세요.";
}
}
기능 | 적용 | 위치 설명 |
타임아웃 | .timeout(Duration.ofSeconds(2)) | 응답 지연으로 인한 스레드 고갈 방지 |
벌크헤드 | @Bulkhead(name = "kakaoApi", ...) | 외부 API 별로 스레드 격리 |
서킷브레이커 | @CircuitBreaker(...) + yaml 설정 | 실패 누적 시 차단 및 fallback |
Retry + 백오프 | @Retry(...) + yaml 설정 | 일시적 실패 자동 복구 |
멱등성 처리 | RedisTemplate.hasKey(idempotencyKey) 로 캐싱 | 재시도/중복 요청 방지 |
'Spring > Spring 기초' 카테고리의 다른 글
[Spring/기초] Valid 예외 처리 + 404, 405 (2) (0) | 2024.09.13 |
---|---|
[Spring/기초] api 공통 응답 포맷 + 예외 처리 합치기 (2) (0) | 2024.07.15 |
[Spring/기초] 전역 예외 처리 + Test Code (1) (0) | 2024.07.14 |
[Spring/기초] RestController 완벽 정리 (return type, 파라미터, 추가 개념 및 기능) (0) | 2024.06.23 |
[Spring/기초] 환경 변수 파일 사용하기 (env.properties) (0) | 2024.05.28 |