본문 바로가기
Spring

외부 API 장애 대응을 위한 Circuit Breaker 구현(Feat.함수형 프로그래밍)

by ilyadelavie 2023. 7. 13.

기존 서버 구성


  • 블로그 검색을 위해 Open api 사용중
  • 카카오 API 하나의 벤더에만 의존
  • 장애 혹은 요청 실패 시 사용자가 여러 번 재시도 하거나 응답시간 길어지는 상황 발생

그래서?

일시적이고 단발성인 에러는 retry 패턴같은 전략을 사용하거나 타임아웃을 줘서 에러를 try-catch 하면 되지만 에러가 지속적으로 발생할 때는 응답을 받지 못한 요청이 타임아웃이 될 때까지 쓰레드풀을 잡고 있는다거나 메모리를 잡아 먹으면서 리소스가 부족해지는 등 다양한 bad 케이스가 발생할 수 있다.

 

위와 같은 이유로 외부 API 요청에서 장애 발생 시 작업이 실패했음을 받아들이고 타 벤더 API로 교체하기 위한 서킷 브레이커 패턴을 적용하여 교체 로직을 구현하려고 한다.

이 글에서는 서킷 브레이커 패턴에 대한 자세한 설명은 하지 않으니 대략적인 동작은 아래 자료를 참고하면 된다.

 

Circuit Breaker 동작 구조


  1. 외부 API 통신 시도 
  2. 외부 통신 실패, Retry 지속되어 임계치 초과 -> 서킷브레이커 Open
  3. Open과 동시에 외부 서버에 요청을 날리지 않고, Fail Fast로 빠른응답 리턴
  4. 서킷브레이커가 오픈하면 일정 시간 후에 반오픈(Half-Open) 상태
  5. 반오픈 상태에서 다시 외부 서비스를 호출해서 장애를 확인하면 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.

더보기

 

'Spring' 카테고리의 다른 글

[SpringBoot] MySQL 연동  (0) 2022.09.16
Mockito 사용하기  (1) 2022.09.14
Slice Test  (0) 2022.09.14