외부 API 연동 패턴 — 타임아웃, 재시도, 서킷 브레이커

·2026년 2월 7일

CS

목록 보기
4/5

LLM은 내 서버가 아니다

이 프로젝트에서 LLM은 외부 API다. 내가 제어할 수 없는 영역이다. 언제 응답이 올지, 얼마나 걸릴지, 실패할지 성공할지 모두 내 손 밖이다.

세마포어와 Rate Limiter로 RPM 초과를 막고, 트랜잭션을 쪼개서 커넥션 점유 시간을 줄였다. 그런데 그것만으로는 외부 API 호출의 불안정성 자체를 해결한 게 아니다. LLM이 5초 만에 응답할 수도, 30초가 걸릴 수도, 아예 응답이 안 올 수도 있다.

이 글에서는 외부 API를 호출할 때 반드시 고려해야 하는 것들을 정리한다.


타임아웃 — 무한정 기다리면 안 된다

외부 API를 호출할 때 가장 기본적으로 해야 할 것이 타임아웃 설정이다. 설정 안 하면 어떻게 될까? LLM 서버가 응답을 안 보내면 내 서버는 무한정 기다린다. 스레드가 물려버리는 거다.

타임아웃은 두 종류가 있다.

Connection Timeout

서버와 TCP 연결을 유지하는 데 걸리는 최대 시간이다. 상대 서버가 아예 응답을 안 하거나, 네트워크에 문제가 있을 때 걸린다.

일반적으로 3~5초 정도가 적절하다. 연결 자체가 5초 이상 걸린다면 상대 서버에 문제가 있는 거니까 기다려봤자 의미가 없다.

Read Timeout

연결은 됐는데, 응답 데이터를 다 받는 데 걸리는 최대 시간이다. LLM 호출의 경우 이게 중요하다. LLM은 응답 생성에 수 초에서 수십 초가 걸릴 수 있으니까.

내 프로젝트 기준으로 LLM 응답이 보통 3~10초 사이였다. 그래서 Read Timeout을 너무 짧게 잡으면 정상 응답도 끊어버리고, 너무 길게 잡으면 스레드가 오래 물린다. 적절한 균형점을 찾아야 한다.

// RestTemplate 예시
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);  // 5초
factory.setReadTimeout(15000);    // 15초

이걸 설정 안 하는 것과 하는 것의 차이는 엄청나다. 설정 안 하면 스레드가 영원히 락업될 수 있다. 설정하면 최소한 "포기하고 다음 요청을 처리할 수 있다."


재시도(Retry) 전략

타임아웃이 발생하거나 LLM이 에러를 리턴하면, 다시 시도해야 한다. 근데 재시도를 어떻게 하느냐에 따라 결과가 크게 달라진다.

단순 재시도 (Immediate Retry)

실패하면 즉시 다시 시도한다.

실패 → 재시도 → 실패 → 재시도 → 실패 → 포기

문제는 서버가 과부하 상태에서 실패한 건데, 즉시 다시 때리면 과부하를 악화시킨다는 것이다. 특히 429 에러(RPM 초과)는 "너무 많이 보냈다"는 의미인데, 바로 다시 보내면 또 429가 나올 것이다.

지수 백오프 (Exponential Backoff)

재시도 간격을 점점 늘리는 전략이다.

1차 시도: 실패 → 1초 대기
2차 시도: 실패 → 2초 대기
3차 시도: 실패 → 4초 대기
4차 시도: 실패 → 8초 대기
...
최대 재시도 횟수 도달 → 포기

서버에 회복할 시간을 주는 거다. AWS, Google Cloud, 대부분의 API 서비스들이 이 방식을 권장한다.

지수 백오프 + Jitter

여기에 랜덤 지연(jitter)을 추가한다.

1차: 1초 + random(0~500ms)
2차: 2초 + random(0~500ms)
3차: 4초 + random(0~500ms)

왜 jitter가 필요하냐면, 여러 클라이언트가 동시에 실패하면 동시에 재시도한다. 지수 백오프만 쓰면 동시에 1초 뒤에, 동시에 2초 뒤에 때린다. 이걸 Thundering Herd 라고 한다. jitter를 추가하면 재시도 시점이 분산된다.

내 프로젝트에서의 선택

RPM이 4인 환경이라 Thundering Herd가 발생할 규모는 아니었다. 하지만 단순 재시도는 429 에러를 악화시키니까 지수 백오프는 적용했다. jitter까지 가진 않았던 건, 동시 사용자 수가 적어서 재시도 충돌 가능성이 낮았기 때문이다. 스케일이 커지면 jitter도 추가해야 한다.


HTTP 상태 코드 — 에러마다 대응이 다르다

LLM API에서 돌아오는 에러는 종류가 다양하다. 각각 대응이 다르다.

429 Too Many Requests

RPM 초과. 재시도 가능하지만, 바로 재시도하면 또 429가 나온다. 지수 백오프로 간격을 두고 재시도해야 한다. 내 프로젝트에서 가장 많이 만난 에러다.

408 Request Timeout

서버가 요청을 처리하는 데 너무 오래 걸림. 재시도 가능하다.

500 Internal Server Error

서버 내부 오류. 재시도해볼 수 있지만, 서버 자체의 문제라 성공 보장이 없다.

503 Service Unavailable

서버가 일시적으로 이용 불가. 재시도 가능하지만 간격을 두어야 한다.

400 Bad Request

내가 보낸 요청이 잘못된 것. 재시도해도 의미 없다. 요청을 고쳐야 한다.

401/403 Unauthorized/Forbidden

인증 문제. 재시도 의미 없다. API 키를 확인해야 한다.

정리하면 이렇다:

상태 코드재시도 가능대응
429O (간격 필요)지수 백오프
408, 503O지수 백오프
500△ (제한적)1~2회 재시도 후 포기
400X요청 수정
401/403X인증 확인

모든 에러를 똑같이 재시도하면 안 된다. 400 에러를 재시도하면 계속 400이 나오는 거니까 의미 없는 호출만 쌓인다. 에러 종류에 따라 대응을 분기해야 한다.


서킷 브레이커 패턴 — 끊을 줄도 알아야 한다

재시도만으로는 해결 안 되는 상황이 있다. LLM API가 지속적으로 실패하는 경우다. 이럴 때 계속 호출하면 내 서버 리소스만 낭비한다.

이럴 때 쓰는 게 서킷 브레이커(Circuit Breaker) 패턴이다. 전기 회로의 차단기에서 따온 개념이다.

동작 원리

세 가지 상태가 있다:

CLOSED (정상)

요청이 정상적으로 통과한다. 실패가 누적되면 OPEN으로 전환된다.

OPEN (차단)

요청을 아예 보내지 않는다. 즉시 실패 응답을 리턴한다. 일정 시간 후 HALF_OPEN으로 전환된다.

HALF_OPEN (테스트)

제한된 수의 요청만 통과시켜서 테스트한다. 성공하면 CLOSED로, 실패하면 다시 OPEN으로.

[CLOSED] ── 실패 누적 ──▶ [OPEN] ── 시간 경과 ──▶ [HALF_OPEN]
    ▲                                                    │
    └─────── 성공 ────────────────────────────┘
                                              │
                  [OPEN] ◀── 실패 ─────┘

라이브러리

Java에서는 Resilience4j가 대표적이다.

CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)        // 실패률 50% 이상이면 OPEN
    .waitDurationInOpenState(Duration.ofSeconds(30))  // 30초 후 HALF_OPEN
    .slidingWindowSize(10)           // 최근 10개 요청 기준
    .build();

CircuitBreaker circuitBreaker = CircuitBreaker.of("llmApi", config);

String result = circuitBreaker.executeSupplier(() -> callLLMApi());

Netflix Hystrix도 유명하지만 이제 더 이상 유지보수되지 않는다. Resilience4j가 현재 표준이다.

내 프로젝트에서는

서킷 브레이커를 직접 적용하지는 않았다. 이유는 두 가지다.

하나, 이미 세마포어 + Rate Limiter로 429 에러를 10% 이내로 떨궜다. 지속적 실패가 발생할 상황이 많지 않았다.

둘, 프로젝트 규모상 라이브러리를 하나 더 도입하는 복잡도가 얻는 이익대비 크다고 판단했다.

다만 어떤 것이고 왜 필요한지는 알고 있다. 프리티어를 쓰지 않게 되거나 트래픽이 늘어나면 그때는 도입해야 한다. 특히 LLM API가 장시간 장애를 일으키는 상황에서는 서킷 브레이커 없이는 사용자 경험이 극도로 나빠진다. 요청할 때마다 느리게 실패하는 것보다, 빠르게 "지금은 안 된다"고 알려주는 게 낫다.


전체 방어 구조 정리

내 프로젝트에서 외부 API 호출에 대한 방어를 정리하면 이렇다:

요청 들어옴
  │
  ├─ [1차 방어] Semaphore: 동시 4개 제한
  │
  ├─ [2차 방어] Rate Limiter: 분당 4회 속도 제한
  │
  ├─ [3차 방어] Timeout: 연결 5초 / 응답 15초
  │
  ├─ [4차 방어] Retry: 지수 백오프 (최대 3회)
  │
  └─ [미적용/추후 고려] Circuit Breaker

각 계층이 다른 문제를 해결한다.

세마포어는 동시 접근 폭탄을 막고, Rate Limiter는 API 할당량 초과를 막고, Timeout은 무한 대기를 막고, Retry는 일시적 실패를 복구하고, Circuit Breaker는 지속적 장애에서 서버를 보호한다.

이 중 현재 적용한 것과 아직 적용하지 않은 것이 있다. 중요한 건 각각이 왜 필요하고, 언제 도입해야 하는지를 이해하고 있는 것이다. 무조건 다 적용하는 게 아니라, 프로젝트 규모와 상황에 맞게 판단하는 거다.

다음 글에서는 스레드와 부하 테스트 이야기를 다룬다. 스레드 4개짜리 환경의 한계와, k6로 어떻게 검증했는지를 정리한다.

0개의 댓글