기존 서버 구성
- 블로그 검색을 위해 Open api 사용중
- 카카오 API 하나의 벤더에만 의존
- 장애 혹은 요청 실패 시 사용자가 여러 번 재시도 하거나 응답시간 길어지는 상황 발생
그래서?
일시적이고 단발성인 에러는 retry 패턴같은 전략을 사용하거나 타임아웃을 줘서 에러를 try-catch 하면 되지만 에러가 지속적으로 발생할 때는 응답을 받지 못한 요청이 타임아웃이 될 때까지 쓰레드풀을 잡고 있는다거나 메모리를 잡아 먹으면서 리소스가 부족해지는 등 다양한 bad 케이스가 발생할 수 있다.
위와 같은 이유로 외부 API 요청에서 장애 발생 시 작업이 실패했음을 받아들이고 타 벤더 API로 교체하기 위한 서킷 브레이커 패턴을 적용하여 교체 로직을 구현하려고 한다.
이 글에서는 서킷 브레이커 패턴에 대한 자세한 설명은 하지 않으니 대략적인 동작은 아래 자료를 참고하면 된다.
Circuit Breaker 동작 구조
- 외부 API 통신 시도
- 외부 통신 실패, Retry 지속되어 임계치 초과 -> 서킷브레이커 Open
- Open과 동시에 외부 서버에 요청을 날리지 않고, Fail Fast로 빠른응답 리턴
- 서킷브레이커가 오픈하면 일정 시간 후에 반오픈(Half-Open) 상태
- 반오픈 상태에서 다시 외부 서비스를 호출해서 장애를 확인하면 Open, 정상 응답이면 닫힘
일반적인 서킷브레이커 동작은 위와 같고 유사하게 Retry 임계치를 설정하여 초과 시 서킷브레이커를 열고 폴백 처리를 할 수 있도록 구현할 것이다.
- 카카오 API를 호출하고 실패한 경우 재시도 기능을 사용하여 지정 횟수만큼 요청 재시도
- 재시도 모두 실패한 경우 서킷브레이커를 활용해 카카오 API 호출을 차단(open)
- 이후 Fallback 호출하여 네이버 API 호출
- 일정 시간이 지나면 서킷브레이커로 회로를 점검하여 카카오 API 호출을 다시 허용(요청x, 상태 변경)
Spring cloud의 Resilience4j 라이브러리를 사용하면 서킷브레이커 패턴을 간단히 적용할 수 있지만 내 작고 소중한 미니 플젝은 MSA 환경도 아니니 직접 필요한 기능만 구현해보았다.
@Slf4j
public class CircuitBreaker {
private static final int MAX_RETRIES = 3;
private static final long RETRY_DELAY_MS = 1000;
private static final int FAILURE_THRESHOLD = 2;
private final KakaoBlogSearcher kakaoBlogSearcher;
private int retries;
private boolean circuitOpen;
public CircuitBreaker(KakaoBlogSearcher kakaoBlogSearcher) {
this.kakaoBlogSearcher = kakaoBlogSearcher;
this.retries = 0;
this.circuitOpen = false;
}
public Page<JsonNode> executeWithRetry(KeywordSearchRequest request){
while (retries < MAX_RETRIES) {
try {
if (!circuitOpen) {
return requestApi(request);
}
} catch (RuntimeException e) {
retries++;
if (retries >= MAX_RETRIES) {
handleRetryExhausted();
}
}
}
return null;
}
private Page<JsonNode> requestApi(KeywordSearchRequest request){
return blogSearcher.search();
}
private void handleRetryExhausted(){
log.warn("Retries exhausted, API replaced. failureTime = {}", System.currentTimeMillis());
if (retries >= FAILURE_THRESHOLD) {
log.info("CircuitBreaker changed state to OPEN");
circuitOpen = true;
}
}
}
서킷브레이커를 통해 디폴트 벤더인 카카오 api를 차단하는데 까지는 성공했다.
이후 애플리케이션 종료 없이 어떻게 api를 런타임에 동적으로 변경할 수 있을까?
우리는 운영중에 정책 변경에 따라 스펙을 변경하는게 아니기 때문에 DLL 같은 라이브러리 필요없이 그냥 fallback 메서드로 대체 로직을 구현해주면 된다ㅎㅎ;
public Page<JsonNode> executeWithRetry(KeywordSearchRequest request){
while (retries < MAX_RETRIES) {
try {
if (!circuitOpen) {
return requestApi(request);
}else {
return makeFallbackApiRequest(request);
}
} catch (RuntimeException e) {
retries++;
if (retries >= MAX_RETRIES) {
handleRetryExhausted();
}
}
}
return null;
}
private Page<JsonNode> requestApi(KeywordSearchRequest request){
return kakaoBlogSearcher.search();
}
private Page<JsonNode> makeFallbackApiRequest(KeywordSearchRequest request) {
return naverBlogSearcher.search(request);
}
private void handleRetryExhausted(){
log.warn("Retries exhausted, API replaced. failureTime = {}", System.currentTimeMillis());
if (retries >= FAILURE_THRESHOLD) {
log.info("CircuitBreaker changed state to OPEN");
circuitOpen = true;
}
}
}
그런데 이렇게 구현했을 때 문제점이 뭘까?
바로 서킷브레이커의 본연의 역할인 재시도,폴백 처리 로직과 api 로직 호출을 분리할 수 없다는 점이다. 라이브러리를 사용한다고 생각해보면 라이브러리 내부 로직에 내 서비스 로직이 의존되어 있는 것이다.
그럼 역할의 분리를 위해서 Supplier 인터페이스를 이용해 요청 메서드를 람다로 넣어주고 재시도 로직이나 폴백 로직에 걸리면 해당 처리를 할 수 있도록 해보자.(이게 제목에서 언급한 함수형 프로그래밍이다;)
호출부
public Page<JsonNode> search(KeywordSearchRequest request) {
return executeWithRetry(() -> kakaoBlogSearcher.search(request), () -> naverBlogSearcher.search(request));
}
CircuitBreaker
public T executeWithRetry(Supplier<T> supplier, Supplier<T> exchangeSupplier) {
while (retries < MAX_RETRIES) {
try {
if (!circuitOpen) {
return supplier.get();
}
} catch (RuntimeException e) {
retries++;
if (retries >= MAX_RETRIES) {
handleRetryExhausted();
return makeFallbackApiRequest(exchangeSupplier);
}
}
}
return null;
}
private T makeFallbackApiRequest(Supplier<T> exchangeSupplier) {
return exchangeSupplier.get();
}
private void handleRetryExhausted() {
log.warn("Retries exhausted, API replaced. failureTime = {}", System.currentTimeMillis());
if (retries >= FAILURE_THRESHOLD) {
log.info("CircuitBreaker changed state to OPEN");
circuitOpen = true;
reset();
}
}
private void reset() {
retries = 0;
log.info("CircuitBreaker changed state to CLOSE");
}
ref.
https://learn.microsoft.com/ko-kr/azure/architecture/patterns/circuit-breaker
회로 차단기 패턴 - Azure Architecture Center
원격 서비스 또는 리소스에 연결할 때 해결하는 데 걸리는 시간이 유동적인 오류를 처리합니다.
learn.microsoft.com
https://techblog.woowahan.com/6447/
외부 시스템 장애에 대처하는 우리의 자세 | 우아한형제들 기술블로그
공통기술전략팀 박주희입니다. ‘한 아이를 키우려면 온 마을이 필요하다’라는 아프리카 속담 들어보셨나요? 이 속담이 IT업계에도 딱 맞는다는 생각을 자주 하게 되는데, 하나의 서비스를 잘
techblog.woowahan.com
https://saramin.github.io/2020-12-18-post-api-with-circuit-breaker/
외부 API로 빚어진 장애대응 후일담 after 1years
클라이언트에 서킷브레이커를 입히다
saramin.github.io
'Spring' 카테고리의 다른 글
[SpringBoot] MySQL 연동 (0) | 2022.09.16 |
---|---|
Mockito 사용하기 (1) | 2022.09.14 |
Slice Test (0) | 2022.09.14 |