세 앱이 다운됐는데 증상은 다 멀쩡해 보였다

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

/health 엔드포인트는 프로세스가 살아있고 가장 얕은 코드 경로가 실행된다는 것만 증명한다 — 사용자에게 실제로 중요한 기능(이 경우 AI 생성)이 작동하는지, 트래픽을 처리하는 프로세스가 맞는지는 아무것도 말해주지 않는다.

배경

같은 인프라 위에서 몇 개의 소규모 소비자용 앱을 운영하고 있다 — 그중 둘을 App Nimbus(개인화된 일일 인사이트 앱)와 App Solace(멘탈 웰니스 추적 앱)라 부르겠다. 둘 다 프로세스 슈퍼바이저 아래에서 도는 FastAPI 백엔드다.

증상

"이 서비스들이 실제로 정상인지 확인해달라"는 정기 감사 요청 하나가, 버그 하나가 아니라 두 서비스에 걸쳐 쌓여있던 서로 무관한 네 개의 버그를 드러냈다 — 각각이 알림을 울릴 만큼은 아니게 우아하게 열화됐기 때문에 개별적으로는 눈에 띄지 않았다.

버그 1 — 4주 전부터 조용히 낡은 트래픽을 처리하던 고아 프로세스

App Nimbus의 슈퍼바이저 항목은 FATAL로 표시되며 계속 재시도하고 있었다. 실제 원인: 4주 전에 생긴 프로세스가 부모(원래의 부모 프로세스가 죽고 init에 재부모화됨)를 잃은 채 여전히 포트를 점유하고 있었다. 슈퍼바이저는 새 인스턴스를 계속 스폰하려 했지만 계속 바인딩에 실패했고, FATAL을 계속 로그로 남겼다 — 그러는 동안 한 달 된 고아 프로세스는 헬스체크에 조용히 계속 응답하고 있었다. App Solace도 약 1주일 전에 생긴 고아 프로세스로 정확히 같은 패턴을 보였다. 같은 호스트의 세 번째 앱은 영향받지 않았다.

# 재시작 시도할 때마다 실제로 벌어지고 있던 일
tail -100 app-nimbus.log
# "Address already in use" 15회 이상

pgrep -f "uvicorn.*app-nimbus"
# 마지막 배포보다 몇 주 전 시작된 PID가 나타남 — 고아 프로세스

조치: 고아 프로세스를 종료한 뒤 슈퍼바이저 아래에서 깨끗하게 시작하고, "슈퍼바이저가 RUNNING이라 한다"가 아니라 실제 /health 호출로 검증했다.

버그 2 — 만료된 AI API 인증키, `/health`가 AI를 호출하지 않아서 안 보였음

세 앱 전부의 프로세스 관리 설정이 만료된 인증정보를 가진 구버전 내부 AI 게이트웨이를 가리키고 있었다. AI를 사용하는 모든 엔드포인트는 내부적으로 401을 반환하고 있었지만, /health 자체는 AI 제공자를 호출하지 않기 때문에 계속 200을 반환했다. 앱들은 "떠 있는" 것처럼 보였다. 그저 유용하게 만드는 바로 그 기능 하나만 못 하고 있었을 뿐이다.

조치: 현재 유효한 인증정보를 (가정이 아니라) AI 제공자를 직접 호출해서 확인한 뒤, 세 서비스 설정을 모두 갱신하고 재시작했다.

버그 3 — 파이썬 버전 특정 문법이 모든 AI 스트리밍 요청을 조용히 죽이고 있었음

한 서비스의 인사이트 생성 코드가 async with asyncio.timeout(15):를 사용하고 있었는데 — 이건 Python 3.11 이상에서만 사용 가능한 문법이다. 서버는 Python 3.10.12로 운영 중이었다. 이 함수를 호출할 때마다 AttributeError: module 'asyncio' has no attribute 'timeout'이 발생했다. 호출하는 엔드포인트가 넓은 범위의 try/except로 감싸져 있어서 "기본 분석 결과입니다"라는 일반 메시지로 폴백했기 때문에, 사용자는 에러를 전혀 보지 못했다 — 그냥 매번 조용히 진짜 AI 생성 응답을 받지 못했을 뿐이고, 어떤 로그에도 이상하다고 표시되지 않았다(예외 자체는 로그에 남았지만 아무도 그걸 지켜보고 있지 않았다).

# 수정 전 — Python 3.11+ 전용, 3.10에서는 조용히 깨짐
async def generate_insight():
    async with asyncio.timeout(15):
        return await _run()

# 수정 후 — 3.8+ 호환
async def generate_insight():
    return await asyncio.wait_for(_run(), timeout=15)

조치: 버전 호환되는 동등한 구문으로 교체. 여기 담긴 교훈: 새로운 표준라이브러리 문법을 쓰기 전에 실제 배포된 인터프리터 버전 (python3 --version)을 항상 확인해야 한다 — 더 최신 파이썬으로 도는 로컬 개발 환경은 이런 유형의 버그를 절대 잡아내지 못한다.

버그 4 — 프레임워크 기본값이 서비스 자신의 API 계약을 조용히 위반

이 앱의 인증 의존성은 기본 HTTP Bearer 인증 클래스를 쓰고 있었는데, 이 클래스의 기본 동작은 Authorization 헤더가 아예 없을 때 403을 반환하는 것이었다. 이 서비스의 모든 테스트 스위트는 — 이전에, 다른 작업 단계에서 작성된 — "인증 없음 = 401"을 기대하고 있었다. 수정 전에 이미 테스트 10개가 정확히 이 계약 위반을 잡아내고 있었다. 그냥 아무도 들여다보지 않았을 뿐이다.

# 수정 전 — 헤더 누락 시 프레임워크 기본값이 403을 반환
security = HTTPBearer()

# 수정 후 — 명시된 API 계약에 맞게 오버라이드
class _Bearer401(HTTPBearer):
    async def __call__(self, request: Request):
        try:
            return await super().__call__(request)
        except HTTPException:
            raise HTTPException(status_code=401, detail="Not authenticated")

security = _Bearer401()

수정 중간에 발견한 작지만 진짜였던 함정: 오버라이드에서 Request 타입 힌트를 빼먹으면 프레임워크가 이 파라미터를 쿼리 파라미터로 오인해서 의도한 401 대신 422가 나온다 — 첫 번째 실수를 고치다가 저지르기 쉬운 두 번째 실수라서 짚어둘 가치가 있다.

검증

  • 고아 프로세스/인증정보 수정 후 세 헬스 엔드포인트 전부 200을 반환했다.
  • (헬스체크가 아니라) 실제 AI 기반 요청이 실제로 생성된 응답을 반환하는 것을 종단간으로 확인했으며, 상당한 분량의 생성 작업 기준 약 30여 초가 걸렸다 — 폴백 경로가 아니라 진짜 모델 지연시간과 일치했다.
  • 전체 테스트 스위트: 10건 실패에서 0건 실패로, 조용히 깨져 있던 영역 주변에 회귀 테스트 32건을 신규 추가했다.
얻은 교훈

이 네 버그 중 어느 것도 /health만 지켜봐서는 잡아낼 수 없었다. 헬스체크는 프로세스가 살아있고 가장 얕은 코드 경로가 실행된다는 것만 증명할 뿐 — 사용자에게 실제로 중요한 기능(이 경우엔 AI 생성)이 작동하는지, 트래픽을 처리하는 프로세스가 배포 도구가 배포했다고 믿는 그 프로세스가 맞는지, 인증 계약이 자신의 테스트가 기대하는 것과 조용히 어긋나 있지는 않은지에 대해서는 아무것도 말해주지 않는다. "대시보드가 초록색이다"와 "제품이 실제로 작동한다"는 서로 다른 주장이고, 그 간극이야말로 네 개의 독립된 버그가 몇 주 동안 발견되지 않은 채 숨어 있던 바로 그 자리였다.

Advertisement

첫 댓글을 남겨보세요