인프라 탓을 먼저 하지 말라고 가르쳐준 버그 2건

8월 26, 2026 · 노이반
현장의 기록: AI Engineer / Forward Deployed Engineer — 11편
한 줄 요약

'게이트웨이를 바꿔야 한다', '제공자 쪽에 문제가 있다' 같은 인프라 탓 가설은 대개 비싸고 느린 길이다 — 실패 지점에서 특정 환경변수나 엔드포인트 값이 실제로 읽히고 있는지 확인하면 대부분의 게이트웨이 버그는 몇 분 안에 풀린다.

배경

여러 앱과 도구를 공유 내부 AI 게이트웨이에 대고 운영하고 있다. 몇 달 간격을 두고 벌어진 두 개의 별개 사건이 똑같은 형태를 하고 있었다: 뭔가 인프라의 한계처럼 보였는데, 두 번 다 실제 버그는 내가 통제하는 코드 안에 있었다 — 다만 처음에 보고 있던 그 파일이 아니었을 뿐.

사건 1 — "왜 게이트웨이를 바꿔야 하죠?"라는 질문이 나를 멈춰 세웠다

App Nimbus의 AI 해석 서비스가 더 길고 상세한 요청에서 실패하기 시작했다. 내 첫 대응은 다른 게이트웨이 엔드포인트로 전환하자는 것이었다 — 현재 게이트웨이가 더 무거운 프롬프트를 감당하기엔 너무 느리거나 제약이 많다는 가설이었다.

이런 질문을 직접 받았다: "왜 게이트웨이를 바꿔야 하는 거죠? 지금 엔드포인트나 모델에 실제로 뭐가 문제인 건가요?" — 확실한 증거 없이 내린 결론에 대한 정당한 반문이었다.

가정을 방어하는 대신 다시 확인해보니: 이 서비스에는 AI를 호출하는 모듈이 두 개 있었다. 하나(더 가벼운 기능에 쓰이는)는 목표 엔드포인트를 환경 변수에서 올바르게 읽어와서 현재 배포 설정과 일치했다. 다른 하나 — 실제로 실패하고 있던 그것 — 는 엔드포인트가 클라이언트 생성자에 그대로 하드코딩되어 있어서, 환경 변수를 완전히 건너뛰고 몇 달째 아무도 갱신하지 않은 더 오래된 내부 엔드포인트를 가리키고 있었다.

# 수정 전 — 같은 서비스 안의 한 모듈, 하드코딩됨
def _get_client():
    return Anthropic(base_url="https://old-internal-gateway.example/v3")

# 같은 코드베이스의 자매 모듈은 이미 이렇게 올바르게 하고 있었음:
def _get_client():
    return Anthropic(
        base_url=os.getenv("ANTHROPIC_BASE_URL", "https://current-internal-gateway.example/v3")
    )

그 오래된 엔드포인트는 서버 측에서 3분짜리 하드 타임아웃을 강제한다. 실패하던 기능의 가장 긴 프롬프트 모드는 자연스럽게 완결되기까지 통상 4~6분이 필요했다. 어느 게이트웨이나 모델을 가리키든 상관없이, 모든 긴 요청은 시작하기도 전에 실패가 예정돼 있었다 — "게이트웨이가 너무 느리다"는 프레임 자체가 애초에 틀린 질문이었다. 이미 배포 설정에 올바르게 설정돼 있었지만 이 모듈 하나만 읽지 않고 있던 현재 엔드포인트에는 그런 제한이 없었다.

# 수정 후 — 자매 모듈의 패턴과 일치
def _get_client():
    return Anthropic(
        base_url=os.environ.get("ANTHROPIC_BASE_URL", "https://current-internal-gateway.example/v3")
    )

이걸 끝났다고 간주하기 전에, 실제 엔드포인트를 대상으로 세 가지 응답 길이 구간에서 매번 자연스러운 완결(잘림 없이)을 확인했다. 게이트웨이 전환은 전혀 필요하지 않았다 — 수정은 이미 존재하던 설정을 우회하지 않게 하는 네 줄짜리 변경이었다.

사건 2 — 모델과는 아무 상관 없던 "빈 스트림" 오류

별개로, 폴백 제공자 경로를 통한 특정 두 모델 호출이 "빈 스트림, 종료 사유 없음"이라는 오류로 실패하기 시작했다 — 이국적인 원인(레이트 리밋, 모델 폐기, 제공자 장애)을 추측하고 싶게 만드는 종류의 모호한 오류였다.

실제로는 진짜 버그 두 개가 겹쳐 있었다.

첫 번째: 폴백 설정이 실제로는 아무데도 정의되지 않은 인증정보 환경변수를 참조하고 있었다 — 조용히 빈 문자열로 치환됐고, 그래서 이 경로를 통한 모든 호출이 모델 로직에 도달하기도 전에 기본 인증에서 실패하고 있었다. 발견하고 나면 단순했다: 참조된 모든 인증정보 이름을 실제로 설정된 것과 대조 확인하고, 참조돼 있다고 해서 변수가 존재한다고 가정하지 말 것.

두 번째, 더 미묘한 버그: 인증정보를 고친 뒤에도 특정 파라미터 조합은 여전히 HTTP 400으로 실패했다. 클라이언트 라이브러리의 호출을 가로채서 실제로 나가는 요청 본문을 캡처해보니, tools와 함께 reasoning_effort 필드가 전송되고 있었다 — 그런데 이 특정 게이트웨이는 이 두 모델에 대해 정확히 이 조합을 명시적으로 거부하는 반면, 같은 범용 제공자 통합을 통해 서빙되는 다른 reasoning 지원 엔드포인트들은 바로 그 필드를 기대하고 요구한다. 통합 코드는 우연히 같은 범용 "custom provider" 라벨 아래 등록된 여러 다른 백엔드 종류를 서빙하도록 만들어진 하나의 범용 코드 경로를 갖고 있었고 — 한 백엔드 계열의 요구사항(항상 reasoning_effort를 붙임)을 전부에 보편적으로 적용하고 있었다. 같은 범용 라벨을 공유하는 다른 백엔드 계열이 tool 호출과의 조합에서 이걸 명시적으로 금지한다는 걸 모른 채로.

# 수정 후 — 이 조합을 거부하는 호스트에 대한 명시적 예외 목록
_REASONING_EFFORT_INCOMPATIBLE_WITH_TOOLS_HOSTS = (
    "old-internal-gateway.example",
)

def build_request_extras(self, *, reasoning_config=None, base_url=None, **_):
    if any(h in (base_url or "").lower()
           for h in self._REASONING_EFFORT_INCOMPATIBLE_WITH_TOOLS_HOSTS):
        return {}, {}   # 이 백엔드에 대해서는 reasoning_effort를 완전히 생략
    # ...이걸 요구하는 백엔드를 위한 기존 로직은 그대로 유지

검증

  • 사건 1: 실제 엔드포인트에서 세 가지 응답 길이 구간 전부 잘림 없이 자연스럽게 완결됐고, 전체 회귀 스위트도 실패 없음.
  • 사건 2: 정확히 실패하던 호출 패턴을 재실행 — 평범한 쿼리, 대체 접근 경로를 통한 쿼리, 실제 tool 호출을 명시적으로 유발하는 쿼리 — 세 가지 전부 성공해서, 이 수정이 테스트하기 가장 쉬운 케이스뿐 아니라 단순 케이스와 tool 트리거 케이스 둘 다 커버한다는 걸 확인했다.
얻은 교훈

두 사건 모두 그럴싸한, 인프라 탓을 하는 가설("게이트웨이를 바꿔야 한다", "모델/제공자에 문제가 있다")로 시작했고, 바로 그 가설대로 행동했다면 비싸고 느리게 고치는 방향으로 갔을 것이다. 둘 다 누군가 "여기서 환경변수가 실제로 읽히고 있다는 걸 확인했나?", "우리가 보내는 요청의 실제 바이트를 직접 들여다봤나?"를 오류 메시지의 표면적인 암시 대신 물어본 순간 몇 줄 짜리 코드 수정으로 해결됐다. 지켜갈 가치가 있는 습관: 외부 인프라가 문제라고 결론 내리기 전에, 내 코드가 자기 설정을 어떻게 읽는지부터 grep해보고, 그걸로도 결론이 안 나면 실제로 나가는 요청을 가로채서 직접 읽어봐야 한다.

Advertisement

첫 댓글을 남겨보세요