
(2006, "MySQL server has gone away (BrokenPipeError(32, 'Broken pipe'))")
이 에러는 한 달에 한두 번, 주로 새벽이나 아침 첫 요청에서 떨어졌다. 재시도하면 사라졌고 다음 달 같은 시간대에 다시 나왔다. 한동안 우선순위에서 밀려 있다가, 빈도가 평소의 열 배로 뛴 어느 화요일에 끝까지 추적하기로 했다.
#결론과 해당 조건
쉽게 말하면, 요청이 끝나도 DB 연결을 돌려주지 않은 세션이 스레드에 남아 있다가, 밤사이 DB가 끊어 버린 그 연결로 아침 첫 쿼리를 보낸 것으로 본다. 연결을 검사하는 옵션은 풀에서 연결을 빌릴 때만 돌아서, 이미 쥐고 있던 연결은 검사하지 못했다.
FastAPI에서 scoped_session을 기본 설정으로 쓰면 동기 엔드포인트가 워커 스레드에서 실행되면서 세션이 요청 대신 그 스레드에 묶인다. 요청이 commit()이나 close() 없이 끝나 세션이 트랜잭션을 연 채 커넥션을 쥐고 있으면, 오래 쉰 스레드의 커넥션이 MySQL wait_timeout(쉬는 연결을 서버가 끊는 시간, 기본 8시간)으로 끊긴 뒤 다음 요청에서 Broken Pipe(2006)나 Lost connection(2013)이 난다.
해결은 세션을 스레드별로 찾던 것을 요청별로 찾게 바꾸고, 요청이 끝날 때마다 그 세션을 정리하는 것이었다. 구체적으로는 세션을 찾을 키를 정하는 scopefunc를 ContextVar의 요청 ID를 돌려주는 함수로 바꾸고 미들웨어에서 요청마다 remove()를 불렀고, 업무 코드는 고치지 않았다. 배포 뒤 한 달 동안 이 에러는 다시 나오지 않았다.
아래 조건이 모두 맞으면 같은 문제일 가능성이 높다.
scoped_session을scopefunc없이 쓴다.- 요청이
commit()이나close()없이 끝나 세션이 트랜잭션을 연 채 커넥션을 쥔다. def로 선언한 동기 엔드포인트가 워커 스레드 풀에서 실행된다.- MySQL이나 MariaDB처럼
wait_timeout으로 쉬는 커넥션을 끊는 DB를 쓴다. - 트래픽이 고르지 않아 일부 스레드가 오래 쉰다. DB를 쓰지 않는
def헬스체크가 계속 들어와도 해당한다.
조건을 하나씩 따지기 어렵다면 처리 중인 요청이 없는 시간에 풀 상태를 로그로 남겨 보면 된다. 요청도 백그라운드 작업도 없는데 풀에서 빌려 간 채 돌려주지 않은 커넥션(체크아웃된 커넥션)이 0개가 아니면 어딘가 반환이 빠졌다는 신호다. 워커 프로세스가 여럿이면 풀도 프로세스마다 따로 있으니 각각 본다. SQLAlchemy 2.1.5에서 커밋 없이 끝난 세션 둘을 스레드에 남겨 두자 아래처럼 찍혔다.
print(engine.pool.status())
# Pool size: 5 Connections in pool: 0 Current Overflow: -3 Current Checked out connections: 2
이 수치만으로는 누가 커넥션을 쥐었는지 모른다. 의심이 가면 create_engine(..., echo_pool="debug")로 체크아웃과 반환 로그를 켜고, 요청이 끝난 뒤에도 반환 로그가 없는 커넥션을 찾는다.
해결 코드는 미들웨어가 응답을 돌려줄 때 세션을 정리한다. 스트리밍 응답이나 백그라운드 작업에서도 같은 세션을 쓴다면 주의할 점의 제약을 먼저 보길 권한다. 한 요청 안에서 DB 작업을 병렬로 돌린다면 세션을 따로 열어야 한다.
#증상과 처음 의심한 것들
패턴은 분명했다.
- 트래픽이 한동안 없다가 다시 요청이 들어올 때 난다.
- MySQL
wait_timeout기본값 8시간과 맞물린다. - 특정 엔드포인트에 몰리지 않고 여러 API에 흩어져 나타난다.
pool_pre_ping=True가 이미 켜져 있는데도 사라지지 않는다.
가장 이상한 건 마지막이었다. 애플리케이션은 DB 커넥션을 매번 새로 열지 않고 커넥션 풀에 모아 두었다가 빌려 쓰는데, 풀에서 커넥션을 꺼내는 순간을 체크아웃이라고 한다. pool_pre_ping은 체크아웃할 때 커넥션이 살아 있는지 먼저 찔러 보고, 죽어 있으면 새것으로 바꿔 준다. SQLAlchemy 문서가 끊긴 연결에 대비하는 방법으로 소개하는 옵션이고, 우리는 운영 기본값으로 켜 둔 지 한참이었다.
먼저 MySQL 쪽을 의심했다. wait_timeout은 아무 쿼리도 오지 않는 커넥션을 서버가 끊기까지 기다리는 시간으로, 기본값은 28800초, 8시간이다. 우리 트래픽이 8시간이나 비는 일은 드물다고 생각했다. 당시에는 엔드포인트 단위로 공백을 셌는데, 새벽 4시에서 8시 사이에 그날 첫 요청이 들어오는 엔드포인트는 그 전 8시간 동안 요청을 받지 않았다. wait_timeout을 16시간으로 늘렸다. 다음 주에 같은 일이 다시 일어났다.
왜 늘린 뒤에도 재발했는지는 그때 확인하지 못했다. 지금 보면 설명이 둘 가능하다. MySQL은 앱처럼 비대화형으로 붙는 커넥션이 맺어질 때 전역 wait_timeout 값을 그 커넥션의 설정값으로 복사한다. 전역 값만 바꾸면 이미 열려 있던 커넥션은 8시간을 그대로 쓰고, 뒤에서 보듯 우리 세션은 그 오래된 커넥션을 계속 쥐고 있었다. 주말을 끼면 공백이 16시간을 넘는다는 점도 있다. 새 값을 받은 커넥션에서도 타임아웃을 늘리는 방법은 끊기는 시점을 미룰 뿐이었다.
pool_recycle=3600도 넣었다. 체크아웃할 때 만든 지 한 시간이 지난 커넥션을 새것으로 바꾸는 옵션이다. 개선은 미미했다. 두 옵션이 모두 체크아웃할 때만 일한다는 점이 그제야 눈에 들어왔다. 체크아웃 없이 다시 쓰이는 커넥션이 있다면 두 옵션 모두 손쓸 수 없다. 원인으로 가는 첫 단서가 거기 있었다.
나중에 확인해 보니 pool_pre_ping 자체는 고장 나지 않았다. 체크아웃이 일어나는 커넥션에서는 제대로 일했고, 에러가 난 것은 세션이 이미 쥐고 있던 커넥션을 새 체크아웃 없이 다시 쓴 경우였다. 그 화요일에 왜 빈도가 열 배로 뛰었는지는 끝내 확인하지 못했다.
#원인: 세션이 요청이 아니라 스레드에 묶인다
용어 셋을 먼저 정리해 둔다. 커넥션은 앱과 DB 사이에 실제로 열린 연결이다. 트랜잭션은 함께 확정하거나 함께 취소할 DB 작업의 묶음이고, 이 구성에서는 조회만 해도 시작된다. SQLAlchemy의 세션은 ORM 객체와 트랜잭션을 관리하는 객체다. 이 글처럼 엔진 하나에 묶인 세션은 DB에 처음 접근할 때 풀에서 커넥션을 체크아웃해 쥐고, 트랜잭션을 커밋이나 롤백으로 끝내거나 세션을 닫아야 그 커넥션을 풀에 돌려준다. 그래서 조회만 하는 GET 요청도 응답은 나갔는데 커넥션은 쥔 채 끝날 수 있다.
글에 자주 나오는 두 말도 짚어 둔다. 스레드는 프로그램 안에서 일을 하나씩 맡아 실행하는 단위이고, 미들웨어는 모든 요청의 앞뒤에서 공통으로 도는 코드다.
#scoped_session의 기본 스코프는 스레드다
scoped_session은 세션을 일일이 넘겨주지 않아도 어디서든 session_factory()를 부르면 지금 이 자리의 세션을 돌려주는 도구다. 기본 설정에서 이 자리는 스레드다. 스레드마다 사물함을 하나씩 주고, 처음 부르면 세션을 만들어 넣어 두고, 그다음부터는 같은 사물함에서 같은 세션을 꺼내 준다고 보면 된다. 내부에서는 threading.local() 기반의 ThreadLocalRegistry가 이 사물함 역할을 한다.
이 기본값은 WSGI 구성에 잘 맞는다. WSGI는 Flask, Django, Pyramid 같은 전통적인 Python 웹 프레임워크가 웹 서버와 맞물리는 규약이다. 전통적인 동기 구성에서는 요청 하나가 처음부터 끝까지 워커 스레드 하나 안에서 처리되니, 요청이 끝날 때 그 스레드에서 세션을 정리하면 된다. Flask-SQLAlchemy도 요청이 끝날 때 remove()를 부르는데, 3.x부터는 스레드 대신 앱 컨텍스트를 스코프로 쓴다. SQLAlchemy 문서도 이 전제를 적어 두었다.
“most Python web frameworks, with notable exceptions such as the asynchronous frameworks Twisted and Tornado, use threads in a simple way, such that a particular web request is received, processed, and completed within the scope of a single worker thread.”
“Twisted나 Tornado 같은 비동기 프레임워크를 빼면 대부분의 Python 웹 프레임워크는 스레드를 단순하게 씁니다. 특정 웹 요청이 단일 워커 스레드 범위 안에서 수신, 처리, 완료됩니다.”
스레드가 곧 요청이라면 세션을 스레드에 묶는 것이 곧 요청에 묶는 것이다. WSGI에서는 요청 끝에 remove()만 부르면 기본 설정으로 충분했다.
#ASGI에서는 요청 하나가 여러 스레드를 거친다
FastAPI와 Starlette는 ASGI 위에서 돈다. ASGI는 비동기 웹 서버와 앱이 맞물리는 규약으로, 이벤트 루프라는 창구 하나가 여러 요청을 번갈아 응대하고 오래 걸리는 동기 작업은 뒤편의 워커 스레드에 넘긴다. 미들웨어와 async def 엔드포인트는 이벤트 루프 스레드에서 돌고, def로 선언한 동기 엔드포인트와 동기 의존성은 anyio가 관리하는 워커 스레드 풀로 넘어간다. 요청의 앞뒤는 이벤트 루프 스레드에 있고 세션을 만드는 동기 엔드포인트는 워커 스레드에 있다. 동기 의존성과 동기 엔드포인트가 같은 워커 스레드에 배정된다는 보장도 없다.
아래의 워커 재사용과 정리는 FastAPI가 기본으로 쓰는 anyio asyncio 백엔드를 4.15.1 소스로 확인한 내용이다. 워커 스레드는 anyio의 CapacityLimiter가 동시 실행을 기본 40으로 제한한 풀에서 나오고, 같은 스레드가 여러 요청을 차례로 맡는다. anyio는 새 호출이 오면 가장 최근에 쉬기 시작한 워커부터 다시 쓰고, 10초 이상 쉰 나머지 워커는 그때 정리한다. threading.local()의 저장소는 스레드가 끝나야 가비지 컬렉션되므로, 워커가 살아 있는 동안 그 스레드의 세션도 남는다.
밤새 호출이 없으면 정리할 계기가 오지 않으니 아침 첫 요청은 마지막으로 일했던 워커에게 돌아간다. 프로세스가 완전히 비지 않아도 같은 일이 생긴다. DB를 쓰지 않는 def 헬스체크가 그 워커를 계속 다시 쓰면 워커는 살아 있고, 세션이 쥔 커넥션만 쉰다.
사고 당시 어느 워커가 얼마나 쉬었는지는 로그가 없어 확인하지 못했다. 이 경로는 anyio 구현을 읽고 재구성한 것이다.
정리를 빠뜨리면 세션을 쓴 워커 스레드마다 세션이 하나씩 남을 수 있다. 에러가 매일 나지 않은 것도 이 구조 때문으로 보인다. 공백 뒤 첫 요청을 받는 워커의 마지막 요청이 트랜잭션을 연 채 끝났을 때만 끊긴 커넥션을 다시 쓰게 되고, 커밋으로 끝났다면 커넥션은 이미 풀로 돌아가 다음 DB 접근에서 체크아웃을 거치고, 그때 pool_recycle이나 pool_pre_ping이 오래된 커넥션을 걸러 낸다.
결국 끊기는 것은 워커 스레드의 세션이 쥔 커넥션이고, 8시간을 세는 기준도 엔드포인트의 마지막 요청이 아닌 그 커넥션이 마지막으로 쓰인 시각이다.
같은 구조가 연결 에러 말고 남길 수 있는 증상도 하나 있다. 워커의 다음 요청이 이전 요청이 연 트랜잭션 안에서 이어 돌면, InnoDB 기본 격리 수준인 REPEATABLE READ의 일반 SELECT는 처음 읽은 시점의 스냅샷을 계속 써서 그사이 커밋된 변경을 못 볼 수 있다. 가끔 옛 데이터를 읽었다면 같은 원인을 의심해 볼 만하다.
#Broken Pipe까지의 흐름
우리 코드는 요청이 끝날 때 close()를 부르지 않았다. 세션은 트랜잭션을 연 채 커넥션을 쥐고 워커 스레드에 남았다.
세션이 커넥션을 직접 쥐고 있으니 풀에서 체크아웃이 일어나지 않는다. 체크아웃할 때만 일하는 pool_pre_ping에게는 끼어들 틈이 없다.
그림 끝의 두 번호는 이 흐름에서는 같은 원인이고, 끊긴 소켓이 어떤 상태인지에 따라 갈린다. FastAPI와 MySQL 8.4, PyMySQL로 재현해 보니, 서버가 연결을 닫은 지 얼마 안 돼 보낸 쿼리는 쓰기는 성공하고 응답을 읽는 단계에서 2013 Lost connection to MySQL server during query로 끝났다.
우리가 본 2006 BrokenPipeError는 TCP keepalive를 거친 경로로 설명할 수 있다. keepalive는 오래 조용한 연결에 운영체제가 보내는 생존 확인 신호이고, PyMySQL은 이것을 켜 둔다. 서버가 이미 닫아 버린 연결을 클라이언트가 그대로 쥐고 있으면, 이 확인에 서버 쪽이 “그런 연결은 없다”는 연결 재설정으로 답할 수 있다. 그 뒤에 쿼리를 쓰면 쓰기 자체가 실패해 2006이 난다. 리눅스에서 요청 사이를 80초 비우고 비교하자, keepalive 대기 시간이 기본값 7200초일 때는 2013이, 5초로 줄였을 때는 정확히 2006이 나왔다.
리눅스 기본 대기 시간은 서버가 닫으며 보낸 마지막 패킷부터 다시 2시간이라, 위 그림처럼 6시에 닫히고 9시에 첫 요청이 왔다면 이 경로에 들어맞는다. 운영의 실제 유휴 시간과 keepalive 설정은 확인하지 않았다.
재시도하면 사라진 이유는 확인하지 않았다. 연결 에러가 나면 SQLAlchemy는 그 커넥션을 무효화하고, 같은 세션으로 다시 DB에 접근하려면 rollback()이나 close()로 이전 트랜잭션을 먼저 정리해야 한다. 재시도가 같은 세션으로 갔다면 예외 처리 어딘가에서 그 정리가 일어났을 것이고, 다른 워커의 세션으로 갔을 수도 있다. 정리 없이 같은 세션으로 다시 접근하면 PendingRollbackError가 나니, 로그에서 함께 찾아볼 만하다.
#미들웨어의 remove()는 워커 스레드에 닿지 않는다
요청이 끝날 때 미들웨어에서 세션을 정리하면 될 것처럼 보인다. 이런 코드다.
class CleanupMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
try:
return await call_next(request)
finally:
database.session_factory.remove() # 이벤트 루프 스레드의 세션만 대상이 된다
이 코드는 워커 스레드의 세션을 지우지 못한다. 기본 레지스트리는 스레드별이라 remove()는 그것을 부른 스레드의 세션만 본다. 미들웨어는 이벤트 루프 스레드에서 돌고 세션은 워커 스레드에 있으니, remove()는 아무것도 지우지 않고 돌아온다. 같은 이유로 미들웨어에서 부르는 close()도 워커 스레드의 세션에 닿지 않는다. 이 글을 고치며 SQLAlchemy 2.1.5와 워커 스레드 하나짜리 풀로 다시 확인했다. 재현에서는 메인 스레드가 이벤트 루프 스레드 자리이고, 스레드별 레지스트리의 동작만 본 것이다.
default registry: ThreadLocalRegistry
after remove() from main thread, worker still has session: True
after remove() from worker thread, worker gets new session: True
FastAPI와 MySQL 8.4로 요청 경로 전체도 따로 재현했는데, 미들웨어에서 remove()를 불러도 체크아웃된 커넥션은 1로 남았고 다음 요청은 앞의 재현과 같은 2013으로 실패했다.
remove()는 세션을 만든 워커 스레드에서 불러야 듣는다. 어느 워커가 이 요청을 맡을지는 anyio가 정하고, 요청 쪽에서는 고를 수 없다.
#close()와 remove()는 하는 일이 다르다
“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 자체를 폐기합니다.”
close()는 ORM 객체와 트랜잭션 상태를 정리하고 커넥션을 풀에 돌려준다. 세션 객체는 레지스트리에 남아 다음 호출에 같은 객체가 돌아온다.remove()는close()를 실행한 뒤 레지스트리에서 세션을 지운다. 다음 호출에는 새 세션이 만들어진다.
엔드포인트나 Repository 안에서, 곧 세션을 만든 워커 스레드에서 close()를 매번 불렀다면 커넥션은 풀로 돌아가고 다음 요청은 체크아웃을 거쳤을 것이다. 그러면 pool_recycle과 pool_pre_ping이 일할 수 있어서, 요청을 넘어 쥔 커넥션을 검사 없이 다시 쓰는 경로는 막혔다. scoped_session을 쓰면 세션을 인자로 넘기지 않고도 지금 스코프의 세션을 얻을 수 있지만, 요청이 끝날 때의 정리는 직접 해야 한다. 우리 코드는 그 정리를 빠뜨렸다.
#해결: scopefunc를 ContextVar로 바꾸기
#아이디어
scoped_session에 직접 만든 scopefunc를 넘기면 ThreadLocalRegistry 대신 ScopedRegistry를 쓴다. 이 레지스트리는 키와 세션을 짝지어 보관하는 대응표, 곧 단순한 딕셔너리다.
# SQLAlchemy ScopedRegistry 내부 (단순화)
class ScopedRegistry:
def __init__(self, createfunc, scopefunc):
self.createfunc = createfunc
self.scopefunc = scopefunc
self.registry = {}
def __call__(self):
key = self.scopefunc() # 이 키가 무엇이냐가 전부
if key not in self.registry:
self.registry[key] = self.createfunc()
return self.registry[key]
키를 요청마다 고유한 값으로 잡으면, 같은 스레드에서 실행되더라도 요청마다 별도 세션을 쓴다. 요청마다 번호표를 발급하고 그 번호로 세션을 찾는 셈이다.
요청 ID는 ContextVar에 담는다. ContextVar는 실행 흐름마다 따로 값을 갖는 Python 표준 라이브러리의 컨텍스트 변수로, 요청마다 붙이는 번호표 역할을 한다. 미들웨어에서 붙인 번호표는 워커 스레드까지 따라간다.
#구현은 파일 셋, 핵심 변경은 한 줄
새 모듈과 새 미들웨어를 하나씩 만들고, 기존 Database 클래스에 scopefunc 한 줄을 더했다.
#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 클래스 (한 줄 변경)
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())
token = set_session_context(context_id)
try:
response = await call_next(request)
return response
finally:
# remove() → reset 순서가 중요:
# remove()는 내부적으로 scopefunc()을 호출하여 현재 컨텍스트의 세션을 찾는다.
# reset이 먼저 실행되면 scopefunc()이 LookupError를 내거나 다른 값을 돌려줘
# 이 요청의 세션이 정리되지 않는다.
database.session_factory.remove() # 현재 컨텍스트의 세션을 찾아 제거
reset_session_context(token) # 그 후 컨텍스트 리셋
app.add_middleware(ContextSessionMiddleware)
운영에 넣은 코드는 위와 같다. 지금 새로 쓴다면 remove()가 예외를 내도 컨텍스트는 복원되도록 finally를 한 겹 더 둘 것이다.
finally:
try:
database.session_factory.remove()
finally:
reset_session_context(token)
#왜 에러가 사라지나
앞의 CleanupMiddleware와 모양은 같지만 이번에는 remove()가 듣는다. 세션을 찾는 키가 이제 요청 ID이니, 이벤트 루프 스레드에서 불러도 이 요청의 세션을 찾는다.
remove()는 먼저 close()를 부르니 요청이 끝날 때마다 커넥션이 풀로 돌아간다. 다음 요청은 새 세션으로 시작해 DB에 처음 접근할 때 체크아웃을 거친다. 그 순간 pool_recycle=3600은 만든 지 한 시간이 지난 커넥션을 새것으로 바꾸고, pool_pre_ping은 다시 쓰는 커넥션이 끊겼는지 검사한다. 이번 수정은 두 옵션이 일할 기회를 되돌려 준 것이다.
같은 재현 환경에서 wait_timeout을 5초로 두고 요청 사이를 8초 비워 수정 전후를 비교했다. 기존 코드와 미들웨어에서 remove()만 부르는 코드는 요청이 끝난 뒤에도 체크아웃된 커넥션이 1개 남았고 다음 조회가 2013으로 실패했다. scopefunc를 바꾼 코드는 요청이 끝날 때마다 체크아웃이 0으로 돌아왔고, 같은 공백 뒤의 조회는 pool_pre_ping이 끊긴 커넥션을 새것으로 바꿔 성공했다. 동시 실행 한도를 기본값 40으로 둬도 결과는 같았다.
이 방식이 동작하는 데는 전제가 하나 있다. Starlette의 run_in_threadpool은 anyio의 to_thread.run_sync를 부르고, anyio는 호출 시점의 컨텍스트를 복사해 워커 스레드에서 실행한다. 요청에 붙인 번호표를 워커에 일을 넘길 때 함께 건네는 셈이라, 미들웨어에서 넣은 요청 ID가 엔드포인트 안의 session_factory()까지 같은 값으로 닿는다.
배포 다음 날부터 MySQL server has gone away는 나오지 않았고, 한 달이 지난 지금까지 한 번도 나오지 않았다. 다만 원래 한 달에 한두 번 나던 에러라, 한 달은 결론을 내리기에 긴 기간이 아니어서 계속 지켜보고 있다. 부하 시험은 따로 하지 않았고, 운영 대시보드의 메모리 사용량과 응답 시간 P99에서도 배포 전후 차이는 보이지 않았다.
#의존성 주입 패턴으로 바꾸지 않은 이유
FastAPI 공식 문서가 권하는 방식은 yield 의존성으로 요청마다 세션을 열고 닫는 패턴이다. 아래는 예전 SQL 튜토리얼에 실렸던 Depends(get_db) 예제이고, 지금 문서는 SQLModel 세션으로 같은 일을 한다.
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()
세션의 생명주기가 코드에 드러나고 요청마다 별도의 세션을 쓴다. 그런데 코드베이스가 의존성 주입 컨테이너로 scoped_session을 전역으로 주입하는 구조라면 전환 비용이 크다. 모든 UseCase와 Repository의 세션 전달 방식을 바꾸고 테스트 코드도 고쳐야 한다. 세션을 쓰는 곳마다 close()를 넣는 방법도 있지만, 세션을 곳곳에서 꺼내 쓰는 구조라 한 곳만 빠뜨려도 같은 일이 난다.
이 방식에도 보고된 문제가 있었다. FastAPI Discussion #6628에서는 스레드 풀이 커넥션 대기로 가득 차자 의존성의 finally 블록이 실행될 스레드를 얻지 못해 앱 전체가 멈췄고, 메인테이너는 2022년 9월 0.82.0의 수정으로 아마 해결됐을 것이라고 답했다.
scopefunc 교체는 기존 구조를 두고 문제가 되는 지점만 고친다. SQLAlchemy 문서는 가능하면 scoped_session 대신 웹 프레임워크의 통합 도구를 쓰라고 권하고, 그렇지 않더라도 세션을 현재 스레드보다 요청과 직접 연결하는 편이 낫다고 덧붙인다. 이 수정은 뒤쪽 권고를 따른 절충이다.
#다른 구현은 세션을 어디에 묶나
찾아보니 다른 구현도 같은 방식을 쓴다. 차이는 세션을 묶는 단위에 있다.
| 구현 | 스코프를 정하는 방식 | 묶는 단위 |
|---|---|---|
scoped_session 기본값 | threading.local() | 스레드 |
| Flask-SQLAlchemy | Flask 앱 컨텍스트 | 앱 컨텍스트 |
async_scoped_session 문서 예시 | current_task() | 비동기 태스크 |
| Galaxy Project | ContextVar, 폴백은 threading.get_ident | 요청 |
| 이 글의 해결책 | ContextVar.get() | 요청 |
대규모 생물정보학 플랫폼인 Galaxy Project는 FastAPI 위에서 같은 패턴을 쓴다. 요청 ID가 없으면 스레드 ID로 폴백한다.
#적용 시 주의할 점
#1. remove()를 빠뜨리면 메모리가 샌다
스레드별 레지스트리에서는 세션이 워커 스레드 수만큼만 생겼다. 요청 ID를 키로 쓰면 요청 수만큼 생길 수 있으니, remove()가 빠지면 ScopedRegistry의 딕셔너리가 끝없이 커진다. 미들웨어의 finally에서 반드시 불러야 한다.
#2. 요청 ID의 설정과 정리는 미들웨어 한곳에서 한다
이 예제의 BaseHTTPMiddleware는 하위 앱을 별도 태스크에서 실행하고, 동기 의존성으로 넘어갈 때는 anyio가 컨텍스트를 복사한다. 그래서 미들웨어에서 넣은 값은 의존성까지 내려가지만, 의존성에서 넣거나 바꾼 값은 미들웨어로 올라오지 않는다.
dependency-injector Discussion #493에도 미들웨어와 의존성이 같은 세션을 공유하지 못한 사례가 있고, 작성자는 컨텍스트가 나뉜 탓으로 추측했다.
FastAPI Discussion #8017에는 요청 ID는 미들웨어에서 설정하고 세션 정리는 의존성에서 하는 코드가 올라왔는데, 미들웨어가 컨텍스트 변수를 먼저 리셋해 의존성에서 부른 remove()가 이 요청의 세션을 찾지 못하고 결국 풀이 바닥난다는 지적이 뒤이어 달렸다. 당시의 보고라 지금 구성에 그대로 옮길 수는 없다. Python 태스크와 anyio 워커는 컨텍스트를 복사해 가므로, 부모가 나중에 리셋해도 복사된 요청 ID는 그대로다. 우리가 ContextVar 설정과 remove()를 미들웨어 한곳에 둔 것은 넣고 지우는 순서를 한눈에 관리하기 위해서다.
#3. BaseHTTPMiddleware는 응답 헤더가 나오면 정리한다
call_next()는 응답 헤더가 나오면 돌아오므로, 예제의 finally는 스트리밍 본문이나 백그라운드 작업이 끝나기 전에 실행될 수 있다. 본문을 만들거나 후속 작업을 하는 동안 같은 세션을 쓴다면 정리 시점을 따로 설계해야 한다. yield 의존성의 정리 코드도 응답 뒤에 돌 수 있으니 그 안에서 세션을 쓰는지 확인한다.
remove()는 동기 호출이라, 세션이 커넥션을 쥐고 있었다면 반환하면서 보내는 롤백이 끝날 때까지 이벤트 루프를 막는다. 운영에서 스트리밍 응답이 필요하다면 순수 ASGI 미들웨어로 옮겨 응답 본문이 끝난 뒤 정리하는 것을 고려할 만하다.
#4. pool_pre_ping과 pool_recycle은 그대로 둔다
이 둘은 이번 문제를 혼자 막지 못했지만, 수정 뒤에는 요청마다 체크아웃이 일어나니 풀 안에서 끊긴 커넥션을 걸러 내는 일을 다시 맡는다. 빼면 풀에서 끊긴 커넥션을 그대로 다시 써서 첫 DB 작업에서 연결 에러가 날 수 있다. 둘 다 체크아웃 시점의 검사라서 트랜잭션 도중에 끊기는 연결까지 막지는 못한다.
#5. 요청 밖에서 세션을 쓰는 코드는 따로 챙긴다
예제의 ContextVar에는 기본값이 없다. 미들웨어를 거치지 않는 앱 시작 훅, 배치, CLI, 테스트에서 session_factory()를 부르면 LookupError가 난다. 그런 경로에서는 set_session_context()로 ID를 직접 넣고 끝에서 remove()와 reset_session_context()를 부르거나, Galaxy처럼 스레드 ID로 폴백하게 만든다. 폴백 경로는 다시 스레드 단위가 되니 그 경로의 정리도 직접 해야 한다.
#6. 한 요청 안에서 세션을 동시에 쓰지 않는다
스레드 스코프에서는 워커 스레드마다 세션이 달랐지만, 요청 ID를 키로 쓰면 한 요청 안의 코드는 모두 같은 세션을 받는다. 한 요청에서 DB 작업 여러 개를 워커 스레드로 동시에 보내면 두 스레드가 세션 하나를 함께 쓰게 된다. anyio로 재현해 보니 워커 스레드 둘이 같은 세션 객체를 받았다. SQLAlchemy 세션은 동시 사용에 안전하지 않으니, 그런 작업에는 세션을 따로 열어 쓴다.
#전후 비교
| 항목 | 기존: 기본 스코프, 정리 누락 | scopefunc와 ContextVar |
|---|---|---|
| 세션 격리 단위 | 스레드 | 요청 |
요청 끝 정리 remove() | 세션을 만든 스레드에서만 유효 | 이벤트 루프 스레드에서도 유효 |
| 다음 요청의 체크아웃 | 세션이 쥔 커넥션을 재사용해 생략 | 첫 DB 접근 때 체크아웃, 풀 옵션 동작 |
Broken Pipe 위험 | 있음, 유휴 스레드 경로 | 유휴 스레드 경로 제거 |
| 한 요청 안의 동시 DB 작업 | 스레드마다 다른 세션 | 같은 세션 공유, 따로 열어야 함 |
| 추가한 코드 | 없음 | 모듈 1개, 미들웨어 1개, 한 줄 변경 |
UseCase와 Repository 수정 | 불필요 | 불필요 |
직접 원인은 요청이 끝나도 세션을 정리하지 않은 데 있었다. 미들웨어에 remove()를 넣는 것만으로 고칠 수 없었던 것은 “스레드 하나가 요청 하나”라는 WSGI 시대의 전제를 ASGI 환경에 그대로 들고 왔기 때문이다. 핵심 변경은 한 줄이었지만, 그 한 줄을 넣을 자리를 찾기까지는 며칠이 걸렸다.
FastAPI에서 scoped_session을 쓰고 있고 새벽에 errno 32나 2013 에러를 가끔 본다면, pool_pre_ping을 더하기 전에 세션이 무엇에 묶여 있는지부터 확인해 보길 권한다. scopefunc를 넘기지 않았다면 답은 스레드다.
#참고 자료
- SQLAlchemy 2.0 문서, 컨텍스트 세션:
scoped_session의 설계 전제,close()와remove()의 차이, 커스텀scopefunc를 설명한다. - MySQL 문서,
wait_timeout: 커넥션이 맺어질 때 전역 값으로 세션 값을 초기화한다고 설명한다. - fastapi/fastapi Discussion #8017:
scoped_session, 의존성 주입, 미들웨어 방식을 비교하고ContextVar와scopefunc로 푼 사례를 공유한다. - fastapi/fastapi Discussion #6628: 의존성 주입 방식 세션에서 스레드 풀이 막혀 생긴 데드락 보고다.
- sqlalchemy/sqlalchemy Discussion #8891: FastAPI가 얽힌 동시성 이슈가 늘었다는 zzzeek의 코멘트가 있다.
- ets-labs/python-dependency-injector Discussion #493: FastAPI의 미들웨어와 의존성이 같은 세션을 공유하지 못했다는 사례다.