
MSA 기반 프로젝트를 진행하면서 외부 API 호출 시 서비스 연쇄 장애 문제에 대해 관심이 생겼다. 금융 업무와 관련된 대다수 API에서 외부 API를 호출하다보니 외부 API에 대한 의존성이 상당히 강했기 때문이다. 하나의 외부 서비스가 다운되면 이를 호출하는 모든 서비스가 함께 응답 불가 상태가 되는 장애 전파(Cascading Failure) 현상을 어떻게 해결할 수 있을까? 이러한 문제를 방지하기 위해 Circuit Breaker 패턴을 도입하게 되었다.
장애가 발생한 서비스로의 호출을 차단하여 시스템을 보호하는 메커니즘 (전기 회로의 차단기를 생각하면 된다)
CLOSED (정상)
OPEN (차단)
HALF_OPEN (반열림)
# bank-service-prod.yml
resilience4j:
circuitbreaker:
instances:
commonCircuit:
slidingWindowType: COUNT_BASED
registerHealthIndicator: true # Actuator를 통한 상태 모니터링
slidingWindowSize: 10 # 최근 10개 호출 기준으로 실패율 계산
failureRateThreshold: 50 # 실패율 50% 초과 시 OPEN
waitDurationInOpenState: 10s # OPEN 상태에서 10초 후 HALF_OPEN 전환
recordExceptions: # Circuit Breaker 트리거 예외 목록
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.server.ServerErrorException
permittedNumberOfCallsInHalfOpenState: 3 # HALF_OPEN에서 허용할 호출 수
retry:
instances:
commonRetry:
maxAttempts: 3 # 최대 3회 재시도
waitDuration: 2s # 재시도 간격 2초
retryExceptions:
- java.io.IOException
- org.springframework.web.client.ResourceAccessException
# bootstrap.yml
management:
endpoints:
web:
exposure:
include: refresh, beans, health, circuitbreakers, info
이게 있어야 Circuit Breaker 모니터링을 위한 엔드포인트를 외부에서 접근 가능하도록 노출시킬 수 있다.
@Service
@Slf4j
@RequiredArgsConstructor
public class ApiService {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final ApiRequestHelper apiRequestHelper;
// Fallback 메서드 정의
public void fallbackForVoid(String apiUrl, Map<String, Object> requestBody, Throwable t) {
log.error("CircuitBreaker 발동 - void method", t);
throw new BusinessException("서버 내 오류가 발생했습니다. 나중에 시도해주세요", "C-999");
}
public JsonNode fallbackForJson(String apiUrl, Map<String, Object> requestBody, Throwable t) {
log.error("CircuitBreaker 발동 - JsonNode method", t);
throw new BusinessException("서버 내 오류가 발생했습니다. 나중에 시도해주세요", "C-999");
}
/*
* 응답 데이터가 필요 없는 API 호출
*/
@Retry(name = "commonRetry")
@CircuitBreaker(name = "commonCircuit", fallbackMethod = "fallbackForVoid")
public void processApiRequest(String apiUrl, Map<String, Object> requestBody) {
HttpEntity<Map<String, Object>> entity = apiRequestHelper.createHttpEntity(requestBody);
restTemplate.postForEntity(apiUrl, entity, String.class);
}
/*
* 응답 데이터가 필요한 API 호출
*/
@Retry(name = "commonRetry")
@CircuitBreaker(name = "commonCircuit", fallbackMethod = "fallbackForJson")
public JsonNode processApiRequestRaw(String apiUrl, Map<String, Object> requestBody) {
HttpEntity<Map<String, Object>> entity = apiRequestHelper.createHttpEntity(requestBody);
try {
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, entity, String.class);
JsonNode responseJson = objectMapper.readTree(response.getBody());
if (responseJson.has("REC")) {
return responseJson.get("REC");
}
return objectMapper.createObjectNode()
.put("message", "REC 데이터가 없습니다.")
.put("code", "500");
} catch (JsonProcessingException e) {
log.error("JSON 파싱 오류 발생: ", e);
return objectMapper.createObjectNode()
.put("message", "JSON 응답을 처리하는 중 오류가 발생했습니다.")
.put("code", "500");
}
}
}
주의할 점은 Resilience4j의 fallbackMethod는 원래 메서드의 파라미터 + Throwable 을 인자로 받아야 한다는 점이다. 따라서 fallback() 메서드를 오버로드해서 정확히 맞는 시그니처로 두 개 만들어야 한다.
로컬에서는 문제가 없었는데 배포 환경에서 No fallback method match found 에러가 발생해서 찾아보니 이 부분이 원인이었다.
OPEN

HALF_OPEN

ERROR

CLOSED

플로우를 정리하면 아래와 같다.
장애 발생 단계
외부 API 10회 연속 실패
→ 실패율 100% (임계값 50% 초과)
→ Circuit Breaker OPEN
→ 이후 요청들은 즉시 차단 (notPermittedCalls) : 3번째 차단이 발생할 때 fallback 응답이 반환됨
복구 감지 단계
10초 대기 후 HALF_OPEN 전환
→ 첫 번째 테스트 호출 실패
→ 다시 OPEN으로 복귀
→ 10초 대기
→ 두 번째 테스트 호출 성공
→ CLOSED 상태로 완전 복구
상태가 3가지로 나뉘다보니 헷갈릴 수 있는데 실제로 테스트해보면 어떻게 상태가 전환되는지 이해할 수 있다!