goodchuck 블로그

QueuePool limit of size 5 overflow 10 reached — FastAPI + SQLite API 가 20분 멈춘 이유와 NullPool 로 고친 기록

8분 읽기

운영 중인 FastAPI 서버가 어느 밤 모든 요청에 응답하지 않았어요. 로그에는 이 줄만 계속 찍혔어요.

QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00

22시 26분에 멈췄고, 22시 47분에 컨테이너를 재시작하고서야 돌아왔어요. 그 20분 동안 알림은 하나도 오지 않았어요. 이 글은 이 에러가 무슨 뜻인지, 왜 풀이 비지 않았는지, 그리고 SQLite 에서는 왜 풀을 아예 없앴는지를 정리한 기록이에요.

이 에러는 무슨 뜻인가 — 연결 15개가 다 쓰였고 30초를 기다려도 안 돌아왔다

SQLAlchemy 는 create_engine 에 아무것도 안 주면 연결 풀(QueuePool) 을 만들어요. 기본값은 이래요.

설정기본값뜻
pool_size5평소 들고 있는 연결 수
max_overflow10바쁠 때 더 만들 수 있는 연결 수
pool_timeout30연결이 다 쓰였을 때 기다리는 초

그러니까 에러 문장은 "연결 15개가 전부 누군가에게 빌려 가 있고, 30초를 기다려도 하나도 반납되지 않았다"는 뜻이에요. 요청은 연결을 못 받으면 30초 뒤 실패하고, 그 사이 들어온 요청도 줄을 서요. 요청이 계속 들어오는 한 서버는 사실상 멈춰요.

증상도 그대로였어요. 웹 쪽은 캐시해 둔 페이지는 잘 열렸는데, API 를 불러야 하는 페이지는 60초가 넘도록 응답이 끝나지 않았어요.

이 에러를 만났다면 먼저 볼 것 — 대부분은 연결을 돌려주지 않는 코드다

이 에러의 흔한 원인은 아래 셋이에요. 제 경우는 세 번째였지만, 검색해서 왔다면 첫 번째부터 확인해 보세요.

  1. 세션을 닫지 않는다. SessionLocal() 로 만든 세션을 close() 하지 않으면 연결이 풀로 돌아가지 않아요. FastAPI 라면 의존성에서 try: yield db / finally: db.close() 가 빠지지 않았는지, 백그라운드 작업에서 만든 세션도 닫는지 보세요.
  2. 연결을 쥔 채 오래 걸리는 일을 한다. 트랜잭션 안에서 외부 API 를 부르거나 큰 계산을 하면, 그동안 연결 하나가 묶여 있어요. DB 작업만 짧게 끝내고 나머지는 연결 밖에서 하세요.
  3. 연결을 쥔 채 다른 것을 기다린다. 잠금 · 큐 · 다른 요청의 결과를 기다리는 동안 연결을 놓지 않으면, 기다리는 쪽과 연결이 필요한 쪽이 서로 막혀요. 아래에서 자세히 다뤄요.

지금 풀 상태는 engine.pool.status() 로 볼 수 있어요. 빌려 간 연결 수(Checked out connections)가 요청이 없을 때도 줄지 않으면 어딘가에서 연결이 새고 있는 거예요.

왜 풀이 비지 않았나 — 잠금을 기다리는 요청들이 연결을 쥔 채 서 있었다

먼저 밝혀 둘 게 있어요. 멈춘 순간의 스레드 덤프는 재시작하면서 사라져서, 아래 원인은 코드와 로그로 추정한 것이에요.

이 서버에는 무거운 조회 결과를 잠깐 들고 재사용하는 응답 캐시가 있어요. 같은 순간 같은 요청이 여러 개 와도 계산은 한 번만 하려고, 키마다 잠금을 걸어요. 줄이면 이런 모양이에요.

def get(self, key, compute):
    if (hit := self._fresh(key)) is not None:
        return hit
    with self._lock_for(key):        # 같은 키는 한 요청만 계산
        if (hit := self._fresh(key)) is not None:
            return hit               # 기다리는 사이 앞 요청이 채웠으면 그걸 쓴다
        result = compute()           # ← 여기서 DB 연결이 필요하다
        self._store(key, result)
        return result

캐시만 보면 문제없어요. 문제는 DB 연결 풀과 같이 놓였을 때예요.

  1. 요청 여러 개가 이미 DB 연결을 하나씩 쥔 상태로 같은 키의 잠금 앞에 줄을 선다
  2. 잠금을 쥔 요청은 compute() 에서 연결을 하나 더 받아야 하는데, 풀이 비어 있다
  3. 잠금을 쥔 요청은 30초 기다리다 실패하고, 다음 요청이 잠금을 받아 똑같이 기다린다
  4. 그 사이 새 요청이 계속 들어와 연결을 쥔 채 줄 끝에 선다

서로가 서로를 기다리는 교착에 가까운 구조라, 요청이 끊기지 않는 한 풀이 비지 않아요. 계기는 그 시각 제가 돌린 헤드리스 브라우저 점검이 쿼리가 붙은 주소를 한꺼번에 열어 API 요청을 몰아 준 것으로 보고 있어요.

pool_size 를 늘리는 건 답이 아니었어요. 한도가 15에서 50이 되면 멈추는 시점이 늦어질 뿐, 같은 구조에서 요청이 더 몰리면 똑같이 막혀요.

왜 NullPool 인가 — SQLite 연결은 파일을 여는 것뿐이다

PostgreSQL 같은 서버형 DB 는 연결 하나를 만들 때 네트워크 왕복과 인증이 들어서 풀이 꼭 필요해요. 그런데 SQLite 의 연결은 같은 프로세스에서 파일을 여는 것이라 비용이 거의 없어요. 풀로 아낄 게 별로 없는데, 풀 때문에 기다리는 일만 생긴 거예요.

그래서 파일 SQLite 는 NullPool 로 바꿨어요. 풀 없이 요청마다 연결을 열고, 끝나면 바로 닫아요. 연결을 기다리는 일 자체가 없어져요.

from sqlalchemy import create_engine
from sqlalchemy.engine import make_url
from sqlalchemy.pool import NullPool

def build_engine(url: str):
    database = make_url(url).database
    # 메모리 DB 는 연결마다 새 DB 가 되므로 풀을 그대로 둔다(테스트용)
    file_db = bool(database) and database != ":memory:"
    return create_engine(
        url,
        connect_args={"check_same_thread": False},
        **({"poolclass": NullPool} if file_db else {}),
    )

신경 쓴 점은 세 가지예요.

  • 메모리 DB(:memory:)는 예외. SQLite 메모리 DB 는 연결마다 다른 DB 라서, 풀 없이 열면 연결할 때마다 테이블이 비어 있어요. 테스트에서 메모리 DB 를 쓰고 있어서 그쪽은 그대로 뒀어요.
  • 동시 요청 수는 FastAPI 가 막는다. def 로 만든 엔드포인트는 FastAPI(정확히는 AnyIO)의 스레드풀에서 돌고, 기본 한도가 40이에요. 풀이 없어도 연결이 무한정 열리지는 않아요.
  • 연결할 때마다 PRAGMA 가 다시 돈다. WAL · 외래키 같은 PRAGMA 를 connect 이벤트에서 걸고 있어서 요청마다 실행되는데, 파일을 여는 것과 마찬가지로 비용이 아주 작아요.

PostgreSQL 로 옮기게 되면 이 설정은 다시 풀로 돌아가야 해요. 그쪽은 연결이 비싸서 풀이 맞아요.

다시 안 생기게 — 연결 20개를 쥔 채 새 연결이 나오는지 테스트

예전 한도(5 + 10)보다 많은 연결을 쥔 상태에서도 새 연결이 바로 나오는지 확인하는 테스트를 붙였어요.

def test_sqlite_has_no_pool_limit(tmp_path):
    eng = build_engine(f"sqlite:///{tmp_path / 'p.db'}")
    assert isinstance(eng.pool, NullPool)
    held = [eng.connect() for _ in range(20)]  # 예전 한도(5 + 10)보다 많이
    try:
        with eng.connect() as c:
            assert c.execute(text("select 1")).scalar() == 1
    finally:
        for c in held:
            c.close()

누가 나중에 create_engine 설정을 정리하다 풀을 되살리면 이 테스트가 바로 잡아 줘요.

20분 동안 알림이 없었다 — 헬스체크를 붙였더니 HEAD 에 405

더 아팠던 건 멈춘 걸 제가 직접 보기 전까지 아무도 알려 주지 않았다는 점이에요. 그래서 외부에서 몇 분마다 /health 를 두드리는 UptimeRobot 을 붙였는데, 붙이자마자 서버가 '다운'으로 떴어요.

nginx 로그를 보니 UptimeRobot 이 GET 이 아니라 HEAD /health 로 묻고 있었고, 서버는 405 Method Not Allowed 를 돌려주고 있었어요. FastAPI 의 @app.get 은 GET 만 받고 HEAD 는 자동으로 받아 주지 않거든요.

# 전: HEAD 요청에 405
@app.get("/health")

# 후: GET · HEAD 둘 다
@app.api_route("/health", methods=["GET", "HEAD"])

헬스체크 서비스는 응답 본문이 필요 없어서 HEAD 를 쓰는 경우가 많아요. 헬스체크 엔드포인트는 처음부터 HEAD 도 받게 두는 게 좋아요.

정리

무엇내용
에러QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00
뜻연결 15개(5 + 10)가 다 빌려 가 있고 30초 안에 반납이 없었다
원인(추정)캐시 잠금을 기다리는 요청들이 연결을 쥔 채 대기 → 계산하는 요청이 연결을 못 받음
안 된 답pool_size 늘리기 — 멈추는 시점만 늦춘다
고침파일 SQLite 는 NullPool, 메모리 DB 는 그대로, 동시성은 FastAPI 스레드풀(40)이 막음
재발 방지연결 20개를 쥔 채 새 연결이 나오는지 테스트
알림외부 헬스체크 + /health 가 HEAD 도 받게

SQLite 를 쓰는 작은 서비스라면 SQLAlchemy 기본 풀을 그대로 두지 말고, 풀이 정말 필요한지부터 따져 보세요. 연결이 공짜인 DB 에서는 풀이 아끼는 것보다 막히는 일이 더 클 수 있어요.