외부 뉴스 소스 수십 개를 수집하는 파이프라인을 만든 적이 있다.
RSS, YouTube, 공시, 검색 API 같은 외부 소스에서 뉴스를 긁어모으는 구조였다.
여기서 가장 빨리 배운 건 알고리즘이 아니었다.
외부 소스는 반드시, 그리고 제멋대로 실패한다는 사실이었다.
어떤 RSS는 어제까지 멀쩡하다 오늘 502를 뱉고 어떤 피드는 형식이 깨진 채로 응답한다.
문제는 소스 하나의 실패가 파이프라인 전체를 끌어내린다는 점이었다.
이 글은 그 실패를 서킷브레이커와 소스 health 으로 한 소스 안에 가두는 법에 대한 기록이다.

난리난 리포트…
1. 순진한 수집 루프의 함정
처음 코드는 이랬다.
소스를 돌면서 긁고, 실패하면 로그 찍고 넘어가는 식.
for feed in feeds:
articles = collect(feed) # 하나만 터져도...
save(articles)
문제가 두 가지였다.
하나, 죽은 소스를 매 사이클마다 또 때린다.
이미 5번 연속 죽은 피드인데 다음 사이클에도 똑같이 호출하고 똑같이 타임아웃을 기다린다.
둘, 어떤 소스가 며칠째 죽어 있는지 한눈에 안 보인다.
“전체 수집량이 줄었다”는 보이는데, 어느 소스가 범인인지가 안 보였다.
결국 필요한 건 소스 단위의 상태였다.

가장 쉽고 가장 간단한 로그 찍기
코드 몇 줄에 온갖 로그가 다 깔려 있었다.
2. 실패를 세는 가장 작은 단위 – SourceHealth
그래서 소스마다 health를 따로 들고 다니기로 했다.
복잡할 것 없이 dataclass 하나다.
@dataclass
class SourceHealth:
consecutive_failures: int = 0
last_failure: Optional[datetime] = None
last_success: Optional[datetime] = None
is_active: bool = True
total_failures: int = 0
total_successes: int = 0
핵심은 consecutive_failures(연속 실패)와 total_failures(누적 실패)를 따로 센다는 점이다.
연속 실패는 “지금 끊어야 하나”를 판단하는 값이고, 누적 실패는 “이 소스가 원래 못 미덥나”를 보는 값이다.
둘을 합쳐버리면 일시적 장애와 만성적 불량을 구분할 수 없다.

3. 연속 5회 실패하면 소스를 끊는다 – 서킷브레이커
서킷브레이커의 본체는 단순하다.
성공하면 연속 카운터를 0으로 리셋하고, 실패하면 올린다.
def record_success(self, source_name: str):
source = self._get_source(source_name)
source.consecutive_failures = 0
source.last_success = datetime.utcnow()
source.total_successes += 1
if not source.is_active:
source.is_active = True # 죽었다 살아난 경우
def record_failure(self, source_name: str, error: str = ""):
source = self._get_source(source_name)
source.consecutive_failures += 1
source.last_failure = datetime.utcnow()
source.total_failures += 1
if source.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES:
source.is_active = False # 회로 차단
MAX_CONSECUTIVE_FAILURES = 5.
연속 5번 실패하면 그 소스는 is_active = False가 되어 회로가 열린다(끊긴다).
숫자 5에 큰 의미는 없다.
일시적 네트워크 떨림(1~2회)은 견디되 진짜 죽은 소스는 빨리 손절하는 타협점이었다.
중요한 건 성공 한 번이 연속 카운터를 리셋한다는 규칙이다.
서킷브레이커로 가끔 실패하는 소스는 끊기지 않고 진짜 연속으로 죽는 소스만 끊긴다.
4. 24시간 뒤 알아서 다시 열어본다
서킷브레이커를 이용해 끊는 것만큼 중요한 게 다시 여는 것이다.
영구히 끊어버리면 잠깐 장애였던 소스를 영영 잃는다.
그래서 복구 창을 뒀다.
def is_source_active(self, source_name: str) -> bool:
source = self._get_source(source_name)
# 끊겼더라도 복구 창이 지났으면 한 번 더 시도
if not source.is_active and source.last_failure:
recovery_cutoff = datetime.utcnow() - timedelta(
hours=self.RECOVERY_WINDOW_HOURS
)
if source.last_failure < recovery_cutoff:
return True
return source.is_active
RECOVERY_WINDOW_HOURS = 24.
끊긴 소스도 마지막 실패로부터 24시간이 지나면 is_source_active가 다시 True를 돌려준다.
그 시도가 성공하면 record_success가 is_active를 살리고 또 실패하면 다시 끊긴다.
사람이 손으로 죽은 소스를 되살릴 필요가 없다.
끊고, 식히고, 알아서 다시 두드려본다.
다시 두드려 보는 것이 많은 실수와 내가 만지던가 하는 비용을 줄여주었다.
5. 수집 루프는 딱 두 줄이 바뀐다
이 모든 게 실제 수집 코드에선 두 줄로 들어간다.
for feed_key, feed_config in feeds.items():
source_name = feed_config["name"]
if not self.circuit_breaker.is_source_active(source_name):
continue # 끊긴 소스는 건너뛴다
try:
saved = self._collect_single_feed(feed_key, feed_config)
self.circuit_breaker.record_success(source_name)
except Exception as e:
self.circuit_breaker.record_failure(source_name, str(e))
수집 루프 맨 앞에서 is_source_active로 거르고 try/except 양 갈래에서 성공·실패를 기록한다.
비즈니스 로직(_collect_single_feed)은 서킷브레이커를 전혀 몰라도 된다.
실패 판정 자체도 명확히 했다.
RSS는 깨진 응답을 200으로 주는 경우가 많아서 파싱 결과가 비면 예외로 올렸다.
if parsed.bozo and not parsed.entries:
raise RuntimeError(f"Feed parse error: {parsed.bozo_exception}")
“형식은 깨졌지만 HTTP는 성공”인 회색지대를 명시적 실패로 끌어내린 것이다.
6. 끊긴 걸 모르면 의미가 없다 – health 관측
서킷브레이커를 깔아도 그 상태가 안 보이면 반쪽이다.
내가 눈으로 보이지 않으면 잘 되고 있는지 제대로 체감하기가 어려웠다.
그래서 CLI에 소스별 health를 한 화면에 띄웠다.
if age_hours < 6:
status = "[green]OK[/green]"
elif age_hours < 24:
status = "[yellow]STALE[/yellow]"
else:
status = "[red]DOWN[/red]"
소스별로 마지막 수집 시각, 경과 시간, 24시간 수집량, 상태를 표로 본다.
6시간 안에 새 데이터가 있으면 OK, 24시간 넘으면 DOWN.
여기에 파이프라인 끝단의 건강 지표도 하나 붙였다.
rate = matched / total * 100
rate_status = "GOOD" if rate >= 10 else "LOW" if rate >= 5 else "POOR"
수집한 이벤트 중 실제로 관심 키워드에 매칭된 비율.
소스가 다 살아있어도 이 비율이 무너지면 상류 어딘가가 망가진 거다.
“수집은 되는데 쓸 게 없는” 조용한 실패를 잡는 지표였다.

마무리하며 – LLM 공급자 API도 결국 외부 소스다
앞에서 살펴본 패턴을 RSS 수집기에서 배웠지만 적용 대상은 훨씬 넓다.
특히 요즘 내가 매일 호출하는 외부 소스가 하나 있다.
이제는 모두가 친숙한 gpt, claude같은 LLM 공급자 API다.
OpenAI든 Anthropic이든 5xx를 뱉고, rate limit을 걸고, 가끔 타임아웃이 난다.
RSS 피드와 본질이 똑같은 “100% 통제할 수 없는 외부 의존성”이다.
그래서 Agent가 여러 모델·여러 Tool을 호출하는 구조라면 같은 질문을 던지게 된다.
- 한 공급자가 죽었을 때, 그 호출을 매번 또 때리고 있지는 않은가?
- 연속 실패와 누적 실패를 따로 세고 있는가?
- 끊은 의존성을 일정 시간 뒤 알아서 다시 시도하는가?
- 어떤 의존성이 지금 죽어 있는지 한 화면에서 보이는가?
서킷브레이커는 거창한 인프라가 아니다.
dataclass 하나와 카운터 두 개, “성공이 리셋한다”는 규칙 하나면 시작된다.
실패를 없앨 수는 없다.
다만 실패를 한 소스 안에 가둘 수는 있다.
내가 만든 작업이 잘 되어가고 있는지 어디가 문제인지 어떻게 관리할 것인지 알아보는 것,
그것이 개발의 깊이라고 생각한다.
함께 읽으면 좋은 글
- Agent를 관측하는 3가지 도구 살펴보기 ft. LLM Token
- 프롬프트 캐싱을 실제 워크로드에 붙이기 – 핀테크 거래 분류기 설계 일지
- 처음 만들어 본 MCP 서버 – ‘읽기 전용’을 설계의 출발점으로 둔 이유
- 같은 뉴스가 30번 들어올 때 — 중복 제거를 3단계로 짠 이유
글쓴이 · Plutojoshua
핀테크에서 LLM과 AI 에이전트를 실제 서비스에 적용하는 개발자다. 카카오 AI 앰배서더로 Kanana 모델을 앱에 직접 연동해 검증하고, 비전공자로 시작해 실제 코드와 측정값을 근거로 “왜 그렇게 설계했는지”를 1인칭으로 기록한다. 운영자 소개는 About, 코드는 GitHub에서 볼 수 있다.
“못 미더운 외부 소스를 가두는 법 2가지 – 서킷브레이커와 소스 health 관측”에 대한 4개의 생각