#TL;DR
FastAPI에서 scoped_session을 기본 설정으로 사용하면, 동기 엔드포인트가 ThreadPoolExecutor에서 실행되면서 세션이 스레드에 영구적으로 묶인다. 유휴 스레드의 커넥션이 MySQL wait_timeout으로 끊기면 Broken Pipe가 발생한다. scopefunc를 ContextVar 기반으로 교체하면 기존 코드 변경 없이 요청 단위 세션 격리를 달성할 수 있다.
#간헐적으로 발생하는 Broken Pipe 에러
(2006, "MySQL server has gone away (BrokenPipeError(32, 'Broken pipe'))")
이 에러는 한 달에 한두 번, 주로 새벽이나 아침 첫 요청에서 나타났다. 매번 retry로 사라졌고 다음 달 같은 시간대에 다시 등장했다. 한동안은 우선순위에서 밀려 있다가, 빈도가 평소의 열 배로 뛴 어느 화요일에 끝까지 추적해보기로 했다. 추적해보니 패턴은 분명했다.
- 트래픽이 한동안 없다가 다시 요청이 들어올 때 발생한다
- MySQL의
wait_timeout(기본 8시간)과 정확히 맞물린다 - 특정 엔드포인트가 아니라 다양한 API에서 간헐적으로 나타난다
pool_pre_ping=True가 이미 켜져 있는데도 사라지지 않는다
#처음 의심했던 것들
가장 의아했던 건 pool_pre_ping=True가 이미 켜져 있다는 사실이었다. SQLAlchemy 공식 권장 옵션이고, 죽은 커넥션을 체크아웃 시점에 검증한다. 우리는 이걸 production 기본값으로 켜둔 지 한참이었다. 그런데도 에러는 매달 빠지지 않고 돌아왔다.
먼저 MySQL wait_timeout을 봤다. 기본값은 28800초(8시간). 우리 트래픽이 8시간 비는 일은 흔하지 않다고 생각했는데, 새벽 4시에서 8시 사이 일부 endpoint가 정확히 그 길이의 침묵을 만들고 있었다. wait_timeout을 16시간으로 늘렸다. 다음 주, 같은 일이 다시 일어났다.
pool_recycle=3600도 추가했다. 미미한 개선. 그제야 체크아웃이라는 단어가 머릿속에 박혔다. pre_ping은 체크아웃하는 순간에만 동작한다. 만약 우리 시나리오에서 체크아웃 자체가 발생하지 않고 있다면? 그 질문이 진짜 원인으로 가는 첫 단서였다.
나중에 알았다. pool_pre_ping은 체크아웃이 발생하는 커넥션에는 정상 동작하고 있었다. 평소에 잡히던 에러들은 그쪽이었다. 사라지지 않은 절반은 체크아웃 자체가 일어나지 않는 커넥션이었다. 그게 진짜 문제였다.
#scoped_session의 설계 전제와 FastAPI의 충돌
#scoped_session은 WSGI 시대의 “1 요청 = 1 스레드” 모델을 전제로 설계됐다
scoped_session이 만들어진 배경을 이해해야 문제가 보인다. Flask, Django, Pyramid 등 전통적인 Python 웹 프레임워크는 WSGI(Web Server Gateway Interface) 기반이다. WSGI 서버(Gunicorn, uWSGI 등)는 각 HTTP 요청을 하나의 워커 스레드에 할당하고, 해당 스레드에서 요청 처리가 완료되면 스레드가 해제된다. 1 요청 = 1 스레드가 보장되는 모델이다.
SQLAlchemy 공식 문서는 이 전제를 명시하고 있다.
“the majority of Python web frameworks utilize threads in a simple way, such that a particular web request is received, processed and completed within the scope of a single worker thread.”
“대부분의 Python 웹 프레임워크는 단순한 방식으로 스레드를 사용합니다. 특정 웹 요청이 단일 워커 스레드 범위 내에서 수신, 처리, 완료됩니다.”
— SQLAlchemy Docs: Using Thread-Local Scope with Web Applications
이 전제 하에서는 “세션을 스레드에 연관시키면 곧 요청에 연관된다”는 논리가 성립한다. 그래서 scoped_session은 기본적으로 threading.local() 기반의 ThreadLocalRegistry를 사용하여 스레드당 하나의 세션을 유지한다. 커스텀 scopefunc을 전달하면 딕셔너리 기반의 ScopedRegistry로 전환되는데, 이 글의 해결책이 바로 이 메커니즘을 활용한다. WSGI 시대에는 기본 설정만으로 충분했다.
#왜 모던 프레임워크에서 문제가 되는가
FastAPI, Starlette 등 ASGI(Asynchronous Server Gateway Interface) 기반 프레임워크는 동시성 모델이 근본적으로 다르다.
- WSGI: 요청당 스레드 할당. 스레드와 요청이 1:1 대응.
- ASGI: 이벤트 루프 기반.
async def엔드포인트는 이벤트 루프에서 직접 실행되고,def엔드포인트는ThreadPoolExecutor의 스레드 풀에서 실행된다.
핵심 차이는 스레드 풀의 스레드가 재사용된다는 점이다. WSGI에서는 요청이 끝나면 스레드가 해제된다. FastAPI는 다르다. anyio의 CapacityLimiter가 동시 실행을 기본 40으로 제한하면서, 같은 스레드들이 여러 요청을 순차적으로 처리한다. 하나의 스레드가 요청 A를 처리한 뒤, 잠시 쉬었다가 요청 B를 처리하고, 다시 요청 C를 처리한다. 한 스레드가 여러 요청을 거쳐 가는 환경에서 threading.local()로 세션을 관리하면 세션이 스레드에 묶인 채로 남는다.
SQLAlchemy도 이 한계를 인정하고 있다.
“It is however strongly recommended that the integration tools provided with the web framework itself be used, if available, instead of scoped_session. In particular, while using a thread local can be convenient, it is preferable that the Session be associated directly with the request, rather than with the current thread.”
“가능하다면 scoped_session 대신 웹 프레임워크 자체에서 제공하는 통합 도구를 사용하는 것을 강력히 권장합니다. thread local을 사용하는 것이 편리할 수 있지만, Session을 현재 스레드보다는 요청과 직접 연관시키는 것이 바람직합니다.”
— SQLAlchemy Docs: Using Thread-Local Scope with Web Applications
graph TB
Client[Client Request] --> FastAPI[FastAPI<br/>async event loop]
FastAPI -->|"def endpoint<br/>(동기)"| TPE["ThreadPoolExecutor<br/>(스레드 풀)"]
FastAPI -->|"async endpoint<br/>(비동기)"| EL[Event Loop에서 직접 실행]
TPE --> T1[Thread-1]
TPE --> T2[Thread-2]
TPE --> TN[Thread-N]
T1 -->|"threading.local() 기반"| S1["Session-A<br/>(영구 바인딩)"]
T2 -->|"threading.local() 기반"| S2["Session-B<br/>(영구 바인딩)"]
TN -->|"threading.local() 기반"| SN["Session-N<br/>(영구 바인딩)"]
style S1 fill:#fee,stroke:#c00,color:#18181b
style S2 fill:#fee,stroke:#c00,color:#18181b
style SN fill:#fee,stroke:#c00,color:#18181b
스레드 풀 크기가 40이면, 최대 40개의 세션이 각 스레드에 하나씩 영구 바인딩된다.
#”close()를 호출하고 있는데 왜 Broken Pipe가 발생하나?”
이 문제를 처음 마주하면 의아하다. 분명히 session.close()를 호출하고 있는데 왜 커넥션이 정리되지 않는 건가? 원인은 close()와 remove()의 차이에 있다. scoped_session 환경에서 이 둘은 하는 일이 근본적으로 다르다.
SQLAlchemy 공식 문서에 따르면:
“The
scoped_session.remove()method first callsSession.close()on the current Session, which has the effect of releasing any connection/transactional resources owned by the Session first, then discarding the Session itself.”“
scoped_session.remove()메서드는 먼저 현재 Session에 대해Session.close()를 호출하여 커넥션/트랜잭션 리소스를 해제한 다음, Session 자체를 폐기합니다.”
정리하면:
graph LR
subgraph "session.close()"
direction TB
C1[Session-A] -->|"커넥션만 풀에 반환"| CP1[Connection Pool]
C1 -.->|"registry에 남아있음"| REG1["registry#91;thread_1#93; = Session-A ❌"]
end
subgraph "session_factory.remove()"
direction TB
C2[Session-A] -->|"커넥션 풀에 반환"| CP2[Connection Pool]
C2 -->|"registry에서 삭제"| REG2["registry#91;thread_1#93; 삭제 ✅"]
end
style REG1 fill:#fee,stroke:#c00,color:#18181b
style REG2 fill:#efe,stroke:#0a0,color:#18181b
close()= 커넥션 반환. 세션 객체는 registry에 남음. 다음 호출 시 같은 세션 반환.remove()= close() 실행 후, registry에서 세션 삭제. 다음 호출 시 새 세션 생성.
공식 문서의 설명과 같이, remove()는 close()를 실행한 뒤 세션 객체 자체를 registry에서 제거한다. 해당 세션에 쌓인 identity map이나 에러 상태까지 완전히 초기화되는 것이다.
그런데 scoped_session의 편의성은 바로 이러한 세션 관리를 명시적으로 하지 않아도 된다는 점에 있다. close()나 remove()를 호출하지 않아도 session_factory()만 호출하면 알아서 세션을 반환해준다. 이 편의성이 FastAPI에서는 독이 된다. 대부분의 코드에서 close()를 명시적으로 호출하지 않으므로, Session이 커넥션을 계속 보유한 채로 스레드에 남아있게 된다.
#Broken Pipe까지의 전체 흐름
sequenceDiagram
participant C as Client
participant T as Thread-3
participant S as Session-A<br/>(scoped)
participant M as MySQL
Note over C,M: 22:00 — 마지막 요청
C->>T: GET /api/data
T->>S: session_factory() → Session-A 반환
S->>M: Connection-X로 쿼리
M-->>C: Response 반환
Note over S: 요청 완료. close() 미호출<br/>Session-A가 Connection-X를 계속 보유
Note over C,M: ⏳ 8시간 경과 (Thread-3에 요청 없음)
Note over M: 06:00 — MySQL wait_timeout 만료
M-xS: Connection-X 서버 측 종료<br/>(클라이언트는 모름)
Note over C,M: 09:00 — 새로운 요청이 Thread-3에 배정됨
C->>T: GET /api/users
T->>S: session_factory() → 같은 Session-A 반환
S->>M: 이미 보유 중인 Connection-X로 쿼리 시도
M--xS: ❌ Broken Pipe!
Note over T: (2006, "MySQL server has gone away")
Session이 커넥션을 직접 보유하고 있으므로 커넥션 풀에서 체크아웃이 발생하지 않는다. pool_pre_ping은 체크아웃 시점에만 동작하기 때문에 이 시나리오에서는 무력화된다.
#같은 문제를 겪고 있다면 — 관련 이슈 모음
이 문제를 추적하면서 FastAPI + SQLAlchemy 조합에서 동일한 현상이 반복적으로 보고되고 있다는 걸 확인했다. 비슷한 증상을 겪고 있다면 아래 이슈들이 참고가 될 것이다.
FastAPI GitHub에서 관련 디스커션이 여러 건 확인된다:
- Discussion #8017 — scoped_session vs Dependency vs Middleware 비교, ContextVar + scopefunc 해결 사례 공유
- Discussion #6628 — DI 방식 세션의 데드락 문제. 스레드 풀이 커넥션 대기로 가득 차면 앱 전체가 멈춤
SQLAlchemy 쪽에서도 FastAPI 관련 동시성 이슈가 급증했다는 코멘트가 있다:
“we seem to be getting a flurry of concurrency issues involving FastAPI very suddenly. These errors, particularly the ‘lost connection’ error, are often the side effect of conditions that were ultimately caused by concurrency issues.”
“FastAPI와 관련된 동시성 이슈가 갑자기 쏟아지고 있다. 이러한 에러들, 특히 ‘lost connection’ 에러는 궁극적으로 동시성 문제에 의해 야기된 부작용인 경우가 많다.”
— zzzeek, sqlalchemy/sqlalchemy Discussion #8891
이 문제가 발생하는 전형적인 조건:
scoped_session을 scopefunc 지정 없이 사용 (기본값threading.local()기반)- 동기 엔드포인트(
def)로 ThreadPoolExecutor에서 실행 - MySQL/MariaDB 등
wait_timeout으로 유휴 커넥션을 끊는 DB 사용 - 트래픽이 일정하지 않아 일부 스레드가 장시간 유휴 상태에 놓임
#scopefunc를 ContextVar로 교체하여 해결
#핵심 아이디어
커스텀 scopefunc을 전달하면 scoped_session은 ThreadLocalRegistry 대신 ScopedRegistry를 사용한다. 이 구조는 단순한 딕셔너리다.
# SQLAlchemy ScopedRegistry 내부 (단순화)
class ScopedRegistry:
def __init__(self, createfunc, scopefunc):
self.registry = {}
def __call__(self):
key = self.scopefunc() # 이 키가 무엇이냐가 전부
if key not in self.registry:
self.registry[key] = self.createfunc()
return self.registry[key]
키를 요청별 고유 값으로 설정하면, 같은 스레드에서 실행되더라도 요청마다 별도의 세션을 사용하게 된다.
graph LR
subgraph "Before: 스레드 ID가 키"
direction TB
SF1["scopefunc()"] -->|"Thread-1 ID"| R1["registry#91;thread_1#93; = Session-A"]
SF1 -->|"Thread-2 ID"| R2["registry#91;thread_2#93; = Session-B"]
end
subgraph "After: 요청 ID가 키"
direction TB
SF2["scopefunc()"] -->|"req-a1b2"| RR1["registry#91;req-a1b2#93; = Session-X"]
SF2 -->|"req-e5f6"| RR2["registry#91;req-e5f6#93; = Session-Y"]
end
style R1 fill:#fee,stroke:#c00,color:#18181b
style R2 fill:#fee,stroke:#c00,color:#18181b
style RR1 fill:#efe,stroke:#0a0,color:#18181b
style RR2 fill:#efe,stroke:#0a0,color:#18181b
#구현: 3개 파일, 변경 지점 최소화
#ContextVar 모듈 (신규)
# context_session.py
from contextvars import ContextVar, Token
_session_context: ContextVar[str] = ContextVar("session_context")
def set_session_context(context_id: str) -> Token:
return _session_context.set(context_id)
def get_session_context() -> str:
return _session_context.get()
def reset_session_context(token: Token) -> None:
_session_context.reset(token)
#Database 클래스 (1줄 변경)
class Database:
def __init__(self, engine: Engine) -> None:
self.session_factory = orm.scoped_session(
orm.sessionmaker(autocommit=False, autoflush=False, bind=engine),
scopefunc=get_session_context # ← 이 한 줄이 핵심
)
#미들웨어 (신규)
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from uuid import uuid4
class ContextSessionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
context_id = str(uuid4())[:8]
token = set_session_context(context_id)
try:
response = await call_next(request)
return response
finally:
# remove() → reset 순서가 중요:
# remove()는 내부적으로 scopefunc()을 호출하여 현재 컨텍스트의 세션을 찾는다.
# reset이 먼저 실행되면 scopefunc()이 다른 값을 반환하여 엉뚱한 세션이 제거된다.
database.session_factory.remove() # 현재 컨텍스트의 세션을 찾아 제거
reset_session_context(token) # 그 후 컨텍스트 리셋
app.add_middleware(ContextSessionMiddleware)
UseCase, Repository, 엔드포인트 등 기존 코드는 수정하지 않는다.
#적용 후 요청 생명주기
sequenceDiagram
participant C as Client
participant MW as ContextSession<br/>Middleware
participant T as Thread-3
participant S as Session<br/>(요청별 생성)
participant M as MySQL
C->>MW: GET /api/data
MW->>MW: set_session_context(uuid)
MW->>T: call_next(request)
T->>S: session_factory()
Note over S: scopefunc() → uuid<br/>registry에 없음 → 새 Session 생성
S->>M: query 실행
M-->>S: 결과
S-->>T: 결과
T-->>MW: Response
MW->>MW: session_factory.remove()
Note over S: registry에서 삭제 ✅
MW->>MW: reset_session_context(token)
MW-->>C: Response
같은 스레드에 다른 요청이 배정되어도 context_id가 다르므로 별도의 세션이 생성되고, 요청이 끝나면 remove()로 완전히 정리된다.
이 패턴이 동작하는 핵심 전제: FastAPI/Starlette는 동기 엔드포인트를 anyio의 run_in_threadpool로 실행한다. anyio는 내부적으로 Python 표준 Context.run()을 사용하므로, 호출 시점의 ContextVar가 워커 스레드로 그대로 복사된다. 즉 미들웨어에서 set한 context_id가 endpoint 안에서 호출되는 session_factory()까지 같은 값으로 도달한다.
배포 다음 날부터 MySQL server has gone away 에러는 0이 됐다. 한 달이 지난 지금까지 0이다. 메모리 사용량 변화 없음 (remove()가 매 요청마다 정리). 응답 시간 P99 변화 없음 (ContextVar 오버헤드는 무시할 수준).
#DI 패턴으로 전환하지 않은 이유
FastAPI 공식 문서는 Depends(get_db) 패턴을 권장한다.
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/users")
def get_users(db: Session = Depends(get_db)):
return db.query(User).all()
세션 생명주기가 명시적이고 격리가 완벽하다. 하지만 기존 코드베이스가 DI 컨테이너를 통해 scoped_session을 전역적으로 주입하는 구조라면 전환 비용이 크다. 모든 UseCase와 Repository의 세션 전달 방식을 변경해야 하고, 테스트 코드도 전면 수정이 필요하다.
또한 DI 방식 자체도 FastAPI에서 문제가 있다. Discussion #6628에서 보고된 것처럼, 스레드 풀이 커넥션 대기로 가득 차면 dependency의 finally 블록이 실행될 스레드를 확보하지 못해 데드락이 발생할 수 있다.
scopefunc 교체는 기존 구조를 유지하면서 핵심 문제만 해결한다. SQLAlchemy가 권장하는 “Session을 요청과 직접 연관시키는” 방향과도 일치한다.
#다른 프레임워크도 같은 방식으로 해결하고 있다
모든 주요 프레임워크가 결국 같은 메커니즘 — scopefunc의 반환값을 키로 사용하는 registry — 에 도달한다. 차이는 키를 무엇으로 잡느냐뿐이다.
| 구현 | scopefunc | 키의 단위 |
|---|---|---|
scoped_session 기본값 | threading.local() | 스레드 |
| Flask-SQLAlchemy | Flask request context | Flask 요청 |
async_scoped_session | ContextVar.get() / current_task() | 비동기 태스크 |
| Galaxy Project | ContextVar (폴백: threading.get_ident) | 요청 |
| 이 글의 해결책 | ContextVar.get() | 요청 |
FastAPI Discussion #8017에서도 “요청마다 UUID를 생성해서 ContextVar에 저장하고, 그것을 scopefunc으로 사용하면 완벽히 동작한다”는 검증 사례가 공유됐다. Galaxy Project(대규모 생물정보학 플랫폼)도 FastAPI 기반에서 동일한 패턴을 프로덕션에 적용하고 있다.
SQLAlchemy 창시자 Mike Bayer(zzzeek)도 GitHub 디스커션에서 asyncio 환경에서는 threading.local() 대신 Python 표준 라이브러리의 contextvars를 사용할 것을 권장하고 있다.
#적용 시 주의할 점
#1. threading.local()은 스레드 종료 시에만 안전하다
SQLAlchemy 문서에 따르면, threading.local() 객체는 스레드가 종료되면 해당 저장소가 가비지 컬렉션된다. 스레드를 생성하고 폐기하는 환경에서는 remove() 없이도 안전하다. 하지만 FastAPI의 ThreadPoolExecutor는 스레드를 종료하지 않고 재사용한다. 이 환경에서는 remove() 호출이 필수인데, 호출을 보장하지 못한다면 scopefunc 자체를 교체하는 것이 더 확실하다.
#2. pool_pre_ping과 pool_recycle은 근본 해결이 아니다
engine = create_engine(
"mysql+pymysql://...",
pool_pre_ping=True,
pool_recycle=3600,
)
이 설정들은 커넥션 풀 레벨에서 죽은 커넥션을 탐지한다. 하지만 핵심적인 한계가 있다: pool_pre_ping은 커넥션이 풀에서 체크아웃되는 시점에만 동작한다.
scoped_session 패턴에서는 close()를 명시적으로 호출하지 않는 경우가 많다. 이때 Session이 커넥션을 직접 보유하고 있으므로 풀에서 체크아웃이 발생하지 않고, pool_pre_ping이 동작할 기회가 없다. close()를 호출하더라도 Session 자체는 registry에 남아 같은 Session이 반환되므로, 근본 문제(세션이 스레드에 묶이는 것)는 해결되지 않는다.
근본 해결은 scopefunc 교체이고, pool_pre_ping은 방어적 보조 설정으로 함께 사용하는 것이 좋다.
#3. remove() 누락은 메모리 누수를 일으킨다
미들웨어의 finally 블록에서 remove()를 반드시 호출해야 한다. 스레드 기반에서는 스레드 수(수십 개)만큼만 세션이 생겼지만, ContextVar 기반에서는 요청 수만큼 생길 수 있다. remove()가 빠지면 ScopedRegistry의 딕셔너리가 무한히 커진다.
#4. BaseHTTPMiddleware의 제약을 알고 쓰라
이 글의 예제는 BaseHTTPMiddleware를 사용한다. 구현이 간결하지만, Starlette에서 알려진 제약이 있다: downstream 앱이 별도 태스크에서 실행되므로 streaming response가 정상 동작하지 않을 수 있다. ContextVar를 미들웨어에서 set하고 downstream에서 read하는 이 패턴은 문제없이 동작하지만, 프로덕션에서 streaming이 필요하다면 pure ASGI middleware로 전환을 고려하라.
#5. 세션 생성과 정리를 모두 미들웨어에서 처리하라
FastAPI는 미들웨어와 dependency를 별도 컨텍스트에서 실행할 수 있다. ContextVar의 set/reset을 미들웨어에서 하고, remove()를 dependency에서 하면 컨텍스트가 이미 리셋된 후에 remove()가 호출될 수 있다. 이 문제는 dependency-injector Discussion #493에서도 보고된 바 있다. 세션의 전체 생명주기를 미들웨어 안에서 관리하는 것이 안전하다.
#정리
| 항목 | 기본 scoped_session | scopefunc + ContextVar |
|---|---|---|
| 세션 격리 단위 | 스레드 | 요청 |
| 세션 관리 | 암묵적 (threading.local) | 암묵적 (ContextVar) |
| Broken Pipe 위험 | 있음 (유휴 스레드) | 없음 (요청 종료 시 정리) |
| 기존 코드 변경 | 없음 | scopefunc 1줄 + 미들웨어 |
| UseCase/Repository 수정 | 불필요 | 불필요 |
| 디버깅 난이도 | 높음 (8시간 지연 발현) | 낮음 (즉시 발견) |
| greenlet/async 호환 | 제한적 | 호환 |
결국 우리가 잘못 묶어둔 것은 코드가 아니라 세션이 사는 공간이었다. “스레드 = 요청”이라는 WSGI 시대의 레거시를 ASGI 환경에 그대로 들고 온 것. 한 줄짜리 fix지만, 그 한 줄을 박을 자리를 찾기까지는 며칠이 걸렸다.
FastAPI에서 scoped_session을 쓰고 있고 새벽 errno 32를 가끔 본다면, pool_pre_ping을 추가하기 전에 먼저 물어볼 것 — 내 세션은 어디에 묶여 있는가?
#참고 자료
- SQLAlchemy 2.0 Docs — Contextual/Thread-local Sessions — scoped_session 설계 전제, close() vs remove(), 커스텀 scopefunc
- fastapi/fastapi Discussion #8017 — scoped_session vs Dependency vs Middleware 비교, ContextVar + scopefunc 해결 사례
- fastapi/fastapi Discussion #6628 — DI 방식 세션 데드락 보고
- sqlalchemy/sqlalchemy Discussion #8891 — FastAPI 동시성 이슈 급증, zzzeek 코멘트