
새벽 첫 요청만 DB 오류가 뜨는데 도대체 왜 그런 걸까요? 답은 거의 항상 같습니다. MySQL 같은 DB 서버가 밤새 가만히 있던 연결을 서버 쪽에서 먼저 끊어버렸는데, SQLAlchemy 커넥션 풀은 그 사실을 모른 채 죽은 연결을 그대로 꺼내 쓰기 때문입니다. 이 글에서는 pool_recycle 값을 얼마로 잡아야 하는지, pool_pre_ping을 언제 같이 써야 하는지를 구체적인 숫자와 코드로 정리합니다.
새벽에만 DB 오류가 나는 진짜 원인
트래픽이 뜸한 새벽 시간대에는 애플리케이션이 몇 시간씩 DB에 쿼리를 한 번도 안 보내는 경우가 흔합니다. 이 사이 MySQL 서버는 설정된 wait_timeout 시간이 지나면 그 연결의 소켓을 서버 쪽에서 먼저 닫아버립니다. 기본값은 28800초, 즉 8시간입니다.
문제는 애플리케이션 쪽 커넥션 풀이 이 사실을 전혀 모른다는 점입니다. 풀은 그 연결을 여전히 “체크인된 유효한 연결”로만 기억하고 있습니다. 아침에 첫 요청이 들어오면 풀은 이 연결을 그대로 꺼내 쿼리를 던지고, 서버는 이미 소켓을 닫았으니 아래 같은 에러가 돌아옵니다.
sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError) (2006, "MySQL server has gone away")
[SQL: SELECT ...]
두 번째 요청부터는 풀이 죽은 연결을 버리고 새 연결을 맺기 때문에 거짓말처럼 정상으로 돌아갑니다. 그래서 로그에는 “새벽 첫 요청만” 실패하는 패턴이 매일 비슷한 시각에 남습니다.
pool_recycle, 얼마로 잡아야 할까요?

pool_recycle은 지정한 초 이상 “나이 든” 연결을 체크아웃 시점에 강제로 버리고 새 연결로 교체하는 옵션입니다. 기본값은 -1이라서, 설정하지 않으면 SQLAlchemy는 아무 것도 재활용하지 않습니다.
값을 정하기 전에 먼저 실제 wait_timeout을 확인해야 합니다. 관리형 DB(RDS, Cloud SQL 등)는 기본값을 바꿔두는 경우가 있어서 짐작으로 넣으면 틀릴 수 있습니다.
SHOW VARIABLES LIKE 'wait_timeout';
-- +---------------+-------+
-- | Variable_name | Value |
-- +---------------+-------+
-- | wait_timeout | 28800 |
-- +---------------+-------+
wait_timeout이 28800초라면 pool_recycle은 그보다 넉넉히 짧은 값, 예를 들어 1800초(30분)나 3600초(1시간) 정도로 잡는 것이 일반적인 안전 범위입니다. 로드밸런서나 프록시가 DB보다 먼저 연결을 끊는 환경이라면 이보다 더 짧게 잡아야 여유가 생깁니다.
pool_pre_ping이 잡아주는 범위와 놓치는 지점
pool_pre_ping을 True로 켜면, 풀이 연결을 내어주기 직전에 가벼운 SELECT 1을 날려 생존을 확인합니다. 죽어 있으면 조용히 버리고 새 연결로 교체한 뒤 호출자에게는 정상 연결을 넘겨줍니다. 기본값은 False이기 때문에 켜지 않으면 이 검사 자체가 아예 일어나지 않습니다.
이 방식의 장점은 원인을 가리지 않는다는 점입니다. wait_timeout 만료든, DB 재시작이든, 로드밸런서가 더 짧은 유휴 타임아웃으로 끊은 경우든 체크아웃 시점에 모두 걸러집니다.
대신 체크아웃마다 왕복 쿼리 한 번이 추가됩니다. DB와 애플리케이션이 같은 리전·같은 네트워크 안에 있으면 보통 1ms 안쪽이라 체감되지 않지만, 초당 체크아웃이 매우 많은 서비스나 DB와 물리적으로 먼 환경이라면 누적 지연이 쌓일 수 있습니다. 또한 연결 자체는 살아 있지만 쿼리 문법 오류나 max_allowed_packet 초과로 실패하는 상황은 pre_ping이 잡아주는 영역이 아닙니다.
두 옵션을 같이 설정해 보세요
pool_recycle만 쓰면 recycle 주기 사이에 예상보다 짧은 타임아웃으로 연결이 먼저 끊길 경우 여전히 에러가 날 수 있습니다. pool_pre_ping만 쓰면 모든 체크아웃에 검사 비용이 붙고, 아주 오래된 연결도 서버가 아직 안 끊었다면 계속 재사용된다는 점이 남습니다. 두 옵션은 겹치는 구간이 다르기 때문에 같이 쓰는 쪽이 안전합니다.
| 항목 | pool_recycle | pool_pre_ping |
|---|---|---|
| 동작 시점 | 체크아웃 시, 지정 시간 초과 연결을 선제적으로 폐기 | 체크아웃 시, SELECT 1로 생존을 즉시 확인 |
| 잡아내는 범위 | 예측 가능한 idle timeout (wait_timeout 등) | 원인 불문, 이미 죽은 모든 연결 |
| 오버헤드 | 교체 주기마다 재연결 비용만 | 매 체크아웃마다 쿼리 1회 |
| 기본값 | -1 (비활성) | False (비활성) |
from sqlalchemy import create_engine
engine = create_engine(
"mysql+pymysql://user:pass@host/dbname",
pool_recycle=1800, # wait_timeout(28800초)보다 넉넉히 짧게
pool_pre_ping=True, # 체크아웃 전 SELECT 1로 생존 확인
)
이렇게 두 값을 같이 지정하면 recycle이 대부분의 경우를 미리 걸러내고, pre_ping이 recycle 주기 사이에 발생한 예외적인 단절까지 잡아줍니다.
지금 설정을 점검하는 순서
새벽 첫 요청만 DB 오류가 나는 문제는 거의 항상 “서버는 끊었는데 풀은 모른다”는 한 가지 구조에서 시작됩니다. 다음 순서로 점검하면 원인을 숫자로 확인하고 고칠 수 있습니다.
SHOW VARIABLES LIKE 'wait_timeout';으로 실제 DB의 유휴 타임아웃 값을 확인합니다.- 중간에 로드밸런서나 프록시(RDS Proxy, PgBouncer 등)가 있다면 그쪽의 idle timeout도 함께 확인합니다.
- pool_recycle을 둘 중 더 짧은 값보다 여유 있게 작게 잡습니다.
- pool_pre_ping=True를 추가해 recycle 주기 사이의 예외 상황을 보완합니다.
- 배포 후 며칠간 새벽 시간대 에러 로그를 지켜보며 재현 여부를 확인합니다.
숫자만 복사해서 붙여 넣기보다, 본인 서비스의 wait_timeout과 네트워크 구간별 타임아웃을 직접 확인하는 쪽이 재발을 막는 데 더 확실합니다.
