200 OK, 텅 빈 body — Starlette Race Condition 장애 분석기
발생일: 2026-04-23 / 해결일: 2026-04-27
영향 범위: report-dev.machine365.ai 전체 API
들어가며
API가 200 OK를 반환하는데 body가 비어있다. 프론트엔드에는 아무것도 안 뜨고, Swagger UI(/docs)도 빈 화면. 그런데 로컬에서 돌리면 멀쩡하다.
배경은 이랬다. 미터링(Metering) 기능을 만들면서 API 호출 로그를 수집할 미들웨어를 작성했다. Spring Boot 백엔드에 먼저 적용하고, Python 프로젝트 세 곳(m365-weekly-report, m365-fastapi-backend, data-science-fastapi)으로 확장하는 작업이었다. 세 프로젝트 모두 동일한 미들웨어 코드, 동일한 Python 버전. 로컬 실행, 로컬 Docker 전부 정상. 그런데 개발 서버에 배포하는 순간, m365-weekly-report에서만 전체 API의 body가 증발했다.
같은 코드, 같은 환경, 로컬에서는 완벽하게 동작 — 배포만 하면 깨진다. 이 글은 그 원인을 추적하고 해결한 과정, 그리고 오픈소스에서 이 문제가 어떻게 수정되었는지까지 분석한 기술 로그다.
1. 증상 정리
- 모든 API 응답 상태 코드는 200 OK, 그러나 response body가 비어있음
- 프론트엔드에서 리포트 데이터 미출력
/docsSwagger UI 빈 화면- 로컬(
http://localhost:8000) 및 로컬 Docker에서는 정상 동작 - 서버 에러 로그:
starlette/middleware/base.py line 192 Exception in ASGI application
2. 근본 원인: FastAPI 버전에 따른 BaseHTTPMiddleware 버그
장애의 핵심은 FastAPI 버전 차이에 있었다. 사내 세 프로젝트의 FastAPI/Starlette 버전을 비교하면 명확해진다.
| 프로젝트 | FastAPI | Starlette | BaseHTTPMiddleware | 상태 |
|---|---|---|---|---|
| m365-weekly-report | 0.109.2 | 0.36.x | 버그 있음 | 장애 발생 |
| m365-fastapi-backend | 0.115.0 | 0.38.x | 정상 | 문제없음 |
| data-science-fastapi | 0.121.2 | 0.41.x | 정상 | 문제없음 |
FastAPI 0.109 → Starlette 0.36.x의 BaseHTTPMiddleware에서 call_next 이후 response body가 소비(consumed)되어 빈 응답이 반환되는 버그가 존재했으며, 이는 0.110+ 에서 수정되었다.
3. 기술적 원인 분석
버전을 올려서 해결한 뒤, 도대체 내부에서 무슨 일이 있었던 건지 궁금해서 Starlette의 이슈 트래커를 뒤져봤다. 찾아보니 우리만 겪은 문제가 아니었다. Issue #2516에는 같은 버전대에서 동일한 증상을 겪은 사람들의 리포트가 쌓여 있었고, 이 문제를 수정한 PR #2620의 작성자 adriangb가 근본 원인을 상세하게 설명해두었다. 아래 분석은 해당 PR과 이슈 스레드의 설명을 참고하여 정리한 것이다.
3.1 전체 요청 흐름 아키텍처
아래는 클라이언트 요청이 프록시를 거쳐 FastAPI 내부까지 도달하는 전체 흐름이다.

3.2 BaseHTTPMiddleware의 내부 동작
call_next() 내부에서는 다음 세 단계가 비동기로 동시에 수행된다:
- Controller가 response body를 생성
- BaseHTTPMiddleware가 body를
StreamingResponse로 감싸기 - 클라이언트에 body stream 전달
3.3 Race Condition의 발생
StreamingResponse.__call__은 anyio.create_task_group() 안에서 두 개의 task를 동시에 실행한다:
- listen_for_disconnect:
receive()를 polling하면서 클라이언트 연결 끊김을 감지 - stream_response: body chunk를
send()로 전송
문제는 BaseHTTPMiddleware 컨텍스트에서 listen_for_disconnect가 호출하는 receive()가 실제로는 inner ASGI 앱이 보낸 http.response.body 메시지까지 소비해 버린다는 점이다. disconnect를 기다리던 task가 본문 메시지를 가로채면, stream_response task가 다음 chunk를 꺼내려 할 때 anyio.EndOfStream이 발생하여 RuntimeError("No response returned") 또는 빈 body 200 OK가 반환된다.
아래 시퀀스 다이어그램은 **정상 흐름(로컬)**과 **장애 흐름(배포)**을 비교한 것이다.


3.4 왜 로컬에서는 재현되지 않았는가
이번 장애에서 가장 혼란스러웠던 부분이 바로 이것이다. 동일한 미들웨어 코드, 동일한 Python 버전, 로컬에서 완벽하게 동작 — 그런데 개발 서버에 배포하는 순간 모든 API가 빈 body를 반환했다.
미터링 미들웨어를 세 프로젝트에 동일하게 적용했는데, m365-weekly-report에서만 장애가 발생한 것도 같은 맥락이다. 코드의 문제가 아니라 FastAPI/Starlette 버전 차이 때문이었고, 이 버전 차이가 만드는 race condition은 네트워크 환경에 의존하여 발현된다.
로컬 환경 — 항상 정상:
- 네트워크 hop이 없어
send()가 거의 즉시 완료 - ASGI 서버와 앱이 같은 프로세스 내에서 동작하여
send()await가 짧게 종료 stream_responsetask가 빠르게 body 메시지를 모두 전송하고 task group이 정상 종료listen_for_disconnect가 본문 메시지를 가로챌 틈이 없음
프록시/배포 환경 — 장애 발생:
send()가 외부 소켓으로 나가야 하므로 TCP buffer, 프록시 buffering 등 backpressure 발생send()await가 길어지면서 event loop이 다른 task에 제어권을 넘김- 그 사이
listen_for_disconnect가 깨어나receive()호출 → inner ASGI app이 보낸http.response.body메시지를 가로챔 - 메모리 스트림이 닫히면서
EndOfStream→ 빈 body 200 OK
즉, "로컬에서 됐으니까 코드 문제는 아니다"라는 판단 자체는 맞았다. 진짜 문제는 코드가 아니라 그 코드가 올라탄 프레임워크 버전에 있었고, 그 버그는 배포 환경의 네트워크 지연이라는 조건이 충족되어야만 발현되는 것이었다.
4. 해결 방법
초기에는 원인이 특정되지 않은 상태에서 감으로 코드를 수정하고 배포하는 식으로 접근했다. 미들웨어 로직을 바꿔보고, response를 직접 복사하는 방식도 시도했다. 수정 후 로컬에서 테스트하면 당연히 잘 되고, 개발 서버에 배포하면 또 빈 body. 이런 사이클을 두어 번 반복하고 나서 깨달았다 — 원인도 모르고 감으로 코드를 고치는 건 시간 낭비다.
삽질이 길어지던 중 팀장님이 한마디 했다 — "버전 올려봐."
결과적으로 그 한마디가 정답이었다. FastAPI 버전을 올리자 문제가 깔끔하게 사라졌다.
- FastAPI: 0.109.2 → 0.115.0
- Starlette: 0.36.x → 0.46.x (BaseHTTPMiddleware 버그 해결 포함)
버전 업그레이드 후 개발 서버에 재배포했고, 모든 API가 정상적으로 body를 반환하는 것을 확인했다. 코드는 한 줄도 바꾸지 않았다.
문제는 해결됐지만, "왜 버전을 올리니까 되는 건데?"라는 궁금증이 남았다. 그래서 Starlette 오픈소스의 변경 이력을 추적해봤다.
5. 오픈소스에서의 수정 분석
버전을 올려서 해결된 건 확인했지만, 정확히 어떤 코드가 바뀌어서 이 문제가 사라진 건지 알고 싶었다. Starlette의 이슈와 PR을 추적한 결과, 이 race condition을 근본적으로 해결한 것은 PR #2620 (adriangb)이었다.
| 항목 | 정보 |
|---|---|
| 저장소 | encode/starlette |
| 파일 | starlette/middleware/base.py |
| 이슈 | #2516 — RuntimeError("No response returned") in BaseHTTPMiddleware |
| PR | #2620 — Don't poll for disconnects in BaseHTTPMiddleware via StreamingResponse |
| 릴리스 | Starlette 0.38.3 (2024-08) |
왜 단순 조건 분기로는 고칠 수 없었는가
PR을 분석하면서 가장 인상 깊었던 건, 이 버그가 조건문 하나로는 절대 고칠 수 없는 구조적 문제였다는 점이다.
# 수정 전 listen_for_disconnect 내부 (의사코드)
async def listen_for_disconnect(receive):
while True:
message = await receive() # 이 시점에 메시지는 이미 소비됨
if message["type"] == "http.disconnect":
break
# message가 http.response.body여도 이미 꺼내버렸다.
# anyio memory stream에는 "되돌리기(unget)"가 없다.
# → stream_response가 읽을 chunk가 사라진 상태
await receive()를 호출하는 순간 메시지는 스트림에서 빠져나온다. 타입을 확인해서 "이건 disconnect가 아니네?" 하고 돌려놓고 싶어도, anyio memory stream은 단방향이라 되돌리기가 불가능하다. 그래서 조건 분기가 아니라 receive() 호출 자체를 없애는 것이 유일한 해법이었고, adriangb도 정확히 그 방향으로 수정했다.
오픈소스 개발자의 해결 방법
수정의 핵심 아이디어는 "BaseHTTPMiddleware에서는 disconnect polling을 아예 하지 않는다" 는 것이다.
기존에는 Starlette의 범용 StreamingResponse를 그대로 사용했는데, 이 클래스의 __call__ 메서드가 anyio.create_task_group() 안에서 listen_for_disconnect와 stream_response를 동시에 실행하는 구조였다. adriangb는 starlette/middleware/base.py 내부에 _StreamingResponse라는 private 서브클래스를 새로 만들어 listen_for_disconnect task 자체를 제거했다.
# 수정 후 _StreamingResponse (PR #2620 핵심 변경)
# listen_for_disconnect task가 완전히 제거됨
# → receive()를 호출하는 경쟁자가 사라짐
# → body 메시지가 가로채일 가능성 자체가 없어짐
# stream_response만 단독 실행
# disconnect 감지는 ASGI 서버 레이어(Uvicorn 등)에 위임
이 변경이 우아한 이유는, disconnect 감지 기능을 제거하는 게 아니라 책임을 적절한 레이어로 옮긴 것이기 때문이다. 클라이언트 연결 끊김은 어차피 ASGI 서버(Uvicorn 등)가 소켓 레벨에서 감지할 수 있다. BaseHTTPMiddleware가 굳이 receive()로 직접 확인할 필요가 없었던 거다.

마치며
이 장애가 까다로웠던 건 모든 상식적인 디버깅 단서가 "코드 문제 아님"을 가리켰기 때문이다. 같은 코드, 같은 Python 버전, 로컬 정상, 다른 프로젝트 정상. 남은 변수는 프레임워크 버전뿐이었고, 그 버전 차이가 만드는 race condition은 배포 환경의 네트워크 지연이라는 조건이 충족되어야만 발현됐다.
이 경험에서 얻은 교훈은 네 가지다:
-
"로컬에서 됐다"는 보증이 아니다. 네트워크 지연, 프록시 버퍼링, TCP backpressure 같은 인프라 요소가 비동기 task의 실행 순서를 바꿀 수 있다. 배포 환경에서만 발현되는 버그는 실재하며, 로컬 통과를 배포 안전의 근거로 삼으면 안 된다.
-
같은 코드라도 올라탄 프레임워크 버전이 다르면 결과가 다르다. 세 프로젝트에 동일한 미들웨어를 적용했지만, FastAPI 버전이 가장 낮은 곳에서만 장애가 터졌다. 코드의 동일성이 안전을 보장하지 않는다.
-
미들웨어는 프레임워크에서 가장 민감한 레이어다. 모든 요청/응답을 가로채는 컴포넌트는 비동기 환경에서 예상 밖의 경쟁 조건을 만들 수 있다. 내부 동작 방식을 이해하고 있어야 장애 원인을 빠르게 좁힐 수 있다.
-
멀티 프로젝트 환경에서 의존성 버전 통일은 선택이 아니다. 공통 미들웨어나 모듈을 배포할 때는 각 프로젝트의 프레임워크 버전을 반드시 확인하고, 알려진 버그가 포함된 버전은 사전에 업그레이드해야 한다.
경력 5년 차지만, 돌이켜보면 1~2년 만에 배운 기술로 나머지 시간을 버텨온 것 같다. 이번 장애 하나만 봐도 프레임워크 내부 동작, 비동기 런타임의 스케줄링, 오픈소스 PR을 읽고 분석하는 능력까지 — 여전히 배울 것이 많다는 걸 다시 한번 느꼈다. 감으로 코드를 고치고 배포를 반복하던 자신을 돌아보면서, 다음에는 좀 더 나은 순서로 접근해야겠다고 생각했다.
이 글을 읽는 후배 개발자들은 정도를 걸었으면 한다. 원인을 모르면 코드를 고치지 말고, 원리를 먼저 파악하고, 오픈소스까지 추적해서 근본 원인을 이해한 뒤에 손을 대는 개발자가 되길 바란다. 느려 보여도 결국 그게 가장 확실한 길이다.