서비스를 만들다보면 외부 API를 호출할 일이 은근 많다.
결제 API
문자 발송 API
카카오 알림톡 API
다른 내부 서버 API
근데 만약 결제 API가 갑자기 느려지거나 죽어버리면 어떻게 될까?
우리 서버는 결제 API 응답을 기다리게 되고,
요청이 계속 쌓이고, PHP-FPM worker도 계속 잡혀있고,
결국 결제 기능만 문제가 아니라 우리 서버 전체가 느려질 수도 있다.
이런 상황에서 사용하는 패턴이 서킷 브레이커다.
이름 그대로 전기 차단기랑 비슷하다.
전기가 너무 많이 흐르면 차단기가 내려가듯이,
외부 API 호출이 계속 실패하면 잠깐 요청을 막아버린다.
서킷 브레이커가 없으면
예를 들어 외부 배송 API를 호출한다고 해보자.
우리 서버 -> 배송 API
배송 API가 죽었다.
그런데도 우리 서버는 요청이 올 때마다 계속 배송 API를 호출한다.
요청 1 -> timeout
요청 2 -> timeout
요청 3 -> timeout
요청 4 -> timeout
...
어차피 실패할 요청인데 매번 timeout까지 기다리게 된다.
이러면 사용자도 오래 기다리고,
우리 서버 리소스도 계속 낭비된다.
서킷 브레이커를 적용하면
일정 횟수 이상 실패하면 회로를 열어버린다.
요청 1 -> 실패
요청 2 -> 실패
요청 3 -> 실패
"외부 API 지금 상태 안 좋네"
회로 OPEN
-> 이후 요청은 외부 API를 호출하지 않고 바로 실패 응답
그리고 일정 시간이 지난 뒤, API가 살아났는지 한 번만 다시 확인한다.
잠시 후 요청 1건만 테스트
-> 성공하면 회로 다시 닫기
-> 실패하면 다시 열기
상태는 3개다
CLOSED
-> 정상 상태
-> 외부 API 호출 가능
OPEN
-> 장애 감지 상태
-> 외부 API 호출을 바로 막음
HALF_OPEN
-> 복구됐는지 테스트하는 상태
-> 일부 요청만 외부 API로 보냄
흐름으로 보면 이렇다.
CLOSED
-> 실패가 누적되면
OPEN
-> 일정 시간이 지나면
HALF_OPEN
-> 호출 성공
CLOSED
HALF_OPEN
-> 다시 실패
OPEN
PHP에서는 가볍게 어떻게 구현할 수 있을까?
Laravel을 쓴다는 가정으로, Cache를 이용해 간단히 구현해볼 수 있다.
일단 필요한 값은 이 정도다.
실패 횟수
회로가 열리는 시각
use Illuminate\Support\Facades\Cache;
class CircuitBreaker
{
private int $failureThreshold = 3;
private int $resetTimeoutSeconds = 30;
public function call(string $name, callable $callback)
{
$failureKey = "circuit-breaker:{$name}:failures";
$openKey = "circuit-breaker:{$name}:opened-at";
$openedAt = Cache::get($openKey);
// OPEN 상태인지 확인
if ($openedAt) {
$elapsed = now()->timestamp - $openedAt;
// 아직 대기 시간이 지나지 않았으면 외부 API 호출 자체를 막는다.
if ($elapsed < $this->resetTimeoutSeconds) {
throw new \RuntimeException(
"Circuit is open: {$name}"
);
}
// 대기 시간이 지났다면 HALF_OPEN처럼 한 번 호출을 허용한다.
Cache::forget($openKey);
}
try {
$result = $callback();
// 외부 API 호출 성공 시 실패 기록 초기화
Cache::forget($failureKey);
return $result;
} catch (\Throwable $e) {
$failures = Cache::increment($failureKey);
// 첫 실패라면 cache 만료 시간도 설정
if ($failures === 1) {
Cache::put($failureKey, 1, now()->addMinutes(1));
}
// 일정 횟수 이상 실패하면 회로 OPEN
if ($failures >= $this->failureThreshold) {
Cache::put($openKey, now()->timestamp, now()->addMinutes(1));
}
throw $e;
}
}
}
사용할 때는 외부 API 호출 부분을 감싸면 된다.
$circuitBreaker = new CircuitBreaker();
$result = $circuitBreaker->call('delivery-api', function () {
return Http::timeout(3)
->get('https://delivery.example.com/api/orders')
->throw()
->json();
});
배송 API가 3번 연속 실패하면,
Circuit is open: delivery-api
예외가 바로 발생한다.
즉 timeout 3초를 매번 기다리지 않고,
외부 API 호출 자체를 건너뛸 수 있다.
근데 모든 에러를 실패로 봐야 할까?
이건 조금 조심해야 한다.
예를 들어 우리가 요청을 잘못 보내서 외부 API가 400 Bad Request를 줬다고 하자.
이건 외부 API 장애가 아니라 우리 요청 문제다.
이걸 계속 실패 횟수로 올리면 멀쩡한 API를 괜히 차단하게 된다.
그래서 보통 이런 것만 실패로 본다.
connection timeout
connection refused
DNS 오류
외부 서버 5xx 응답
반대로 아래는 보통 서킷 브레이커 실패 횟수에 넣지 않는다.
400 Bad Request
401 Unauthorized
403 Forbidden
422 Validation Error
예를 들어 Laravel HTTP client에서는 대충 이렇게 나눌 수 있다.
$response = Http::timeout(3)
->get('https://delivery.example.com/api/orders');
if ($response->serverError()) {
throw new \RuntimeException('배송 API 서버 오류');
}
if ($response->clientError()) {
// 우리 요청이 잘못된 것일 가능성이 큼
// circuit breaker 실패 횟수에는 포함하지 않음
}
실서비스라면 Cache는 Redis를 쓰는 게 좋다
위 코드는 이해용으로는 충분한데, 서버가 여러 대라면 cache가 서버마다 따로 있으면 안 된다.
서버 A: 실패 횟수 3회 -> 회로 OPEN
서버 B: 실패 횟수 0회 -> 계속 외부 API 호출
이러면 서킷 브레이커가 제대로 동작하지 않는다.
그래서 여러 서버가 있는 환경이라면 Redis처럼 공유되는 저장소에 상태를 저장해야 한다.
그리고 HALF_OPEN 상태에서는 여러 요청이 한꺼번에 외부 API로 나가지 않게 Redis lock을 두는 게 좋다.
$lock = Cache::lock('circuit-breaker:delivery-api:half-open', 5);
if (!$lock->get()) {
throw new \RuntimeException('Circuit is half-open');
}
try {
// 복구 확인용 외부 API 호출
} finally {
$lock->release();
}
재시도랑은 뭐가 다를까?
둘은 같이 쓰이기도 하지만 역할이 다르다.
재시도
-> 이번 요청을 한두 번 더 해본다.
서킷 브레이커
-> 계속 실패 중이면 아예 외부 호출을 막는다.
예를 들어 외부 API가 일시적으로 흔들린다면,
1초 뒤 재시도
-> 그래도 실패
-> 3초 뒤 재시도
-> 그래도 실패
-> 실패가 계속 쌓이면 회로 OPEN
이런 식으로 사용할 수 있다.
다만 재시도를 너무 많이 하면 외부 API가 장애인 상황에서 요청을 더 많이 보내게 될 수 있다.
그래서 timeout, 제한된 재시도, 서킷 브레이커를 같이 생각해야 한다.
정리
서킷 브레이커는 외부 API가 죽었을 때 우리 서버까지 같이 느려지거나 죽는 걸 막기 위한 패턴이다.
외부 API 호출 실패가 누적됨
-> Circuit OPEN
-> 이후 요청은 즉시 실패 처리 또는 fallback 응답
-> 일정 시간 후 복구 확인
-> 성공하면 다시 CLOSED
내가 이해한 느낌으로는,
retry는 "한 번만 더 해볼까?"
circuit breaker는 "너 지금 상태 안 좋아 보이니까 잠깐 쉬어"
이 정도로 생각하면 편한 것 같다.
끗
'CS 정리' 카테고리의 다른 글
| 분산 트랜잭션이란? MSA에서 발생하는 문제와 Saga 패턴 (0) | 2026.08.24 |
|---|---|
| CQRS란? 읽기와 쓰기를 분리하는 이유 (0) | 2026.08.24 |
| Transactional Outbox에서 여러 워커가 이벤트를 안전하게 선점하는 방법 (0) | 2026.08.22 |
| Transactional Outbox Pattern - DB 트랜잭션과 이벤트 발행을 안전하게 연결하기 (0) | 2026.08.21 |
| SSE(Server-Sent Events) 딥다이브 - EventSource부터 재연결, Last-Event-ID까지 (2) | 2026.08.20 |