- Published on
Python 데코레이터·컨텍스트로 DB 누수 0 만들기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
DB 커넥션 누수는 대부분 “닫아야 하는 자원”을 코드 곳곳에서 수동으로 관리할 때 발생합니다. 특히 Python에서는 예외가 발생했을 때 close() 호출이 건너뛰어지거나, 커넥션을 전역으로 보관한 뒤 반환을 잊는 형태로 누수가 생기기 쉽습니다.
이 글에서는 데코레이터와 컨텍스트 매니저를 조합해 커넥션/커서/트랜잭션의 생명주기를 한 곳에 고정하고, 애플리케이션 코드에서 실수할 여지를 제거하는 방법을 다룹니다. 운영 환경에서 커넥션 누수는 결국 장애로 이어지며, 장애가 나면 프로세스 재시작이나 리소스 압박(OOM 등)으로 증상이 확산되기도 합니다. 장애 원인 추적/완화 관점은 다음 글들도 함께 참고하면 좋습니다.
- systemd 서비스 자동 재시작 원인 추적 가이드
- EKS CrashLoopBackOff - OOMKilled·Exit 137 원인과 해결
- 리눅스 디스크 100%인데 삭제해도 용량이 안 늘 때
커넥션 누수의 전형적인 패턴
1) 예외 발생 시 close() 누락
import psycopg
def get_user(conn, user_id: int):
cur = conn.cursor()
cur.execute("SELECT id, name FROM users WHERE id=%s", (user_id,))
row = cur.fetchone()
cur.close() # 예외가 나면 여기까지 못 옴
return row
execute()에서 예외가 나면 cur.close()가 실행되지 않습니다. 커서가 닫히지 않으면 커넥션이 점유한 서버 리소스가 해제되지 않거나, 풀에서 반환이 지연되는 식으로 문제가 커집니다.
2) 커넥션을 “빌려오고” “반납”하지 않음
from psycopg_pool import ConnectionPool
pool = ConnectionPool("postgresql://app:pass@db:5432/app")
# 나쁜 예: 커넥션을 전역처럼 쥐고 있는 형태
conn = pool.getconn()
def handler():
with conn.cursor() as cur:
cur.execute("SELECT 1")
풀을 쓰더라도 “반납”을 하지 않으면 풀의 목적이 사라집니다. 트래픽이 올라가면 풀 고갈이 발생하고, 타임아웃/대기열 증가로 장애가 됩니다.
목표: 비즈니스 로직에서 자원 관리를 제거
핵심 목표는 간단합니다.
- 커넥션/커서/트랜잭션은 항상 정해진 규칙으로 열고 닫는다.
- 비즈니스 로직 함수는 SQL과 도메인 로직만 가진다.
- 예외가 나도 정리(
close,rollback, 풀 반환)가 보장된다.
이를 위해 Python의 두 가지 기능이 강력합니다.
with를 통한 컨텍스트 매니저- 공통 로직을 강제하는 데코레이터
컨텍스트 매니저로 “항상 닫히는” 커넥션 만들기
아래 예시는 psycopg 계열을 가정하지만, 원리는 sqlite3, pymysql, cx_Oracle 등에도 동일합니다.
1) 커넥션 풀에서 빌리고 반환하는 컨텍스트
from contextlib import contextmanager
from psycopg_pool import ConnectionPool
pool = ConnectionPool("postgresql://app:pass@db:5432/app", min_size=1, max_size=10)
@contextmanager
def get_conn():
conn = pool.getconn()
try:
yield conn
finally:
pool.putconn(conn)
try/finally로 반환을 강제합니다.- 호출자는
with get_conn() as conn:만 쓰면 됩니다.
2) 트랜잭션까지 포함한 컨텍스트
“커넥션 반환”과 “커밋/롤백”을 한 번에 묶는 게 누수 방지에 가장 효과적입니다.
from contextlib import contextmanager
@contextmanager
def transaction():
with get_conn() as conn:
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
이제 비즈니스 로직은 커밋/롤백을 신경 쓰지 않습니다.
def create_user(user_id: int, name: str):
with transaction() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO users(id, name) VALUES (%s, %s)",
(user_id, name),
)
이 패턴만으로도 “예외로 인한 미반납/미롤백”이 대부분 사라집니다.
데코레이터로 서비스 레이어에 일관성 강제
컨텍스트 매니저는 호출부가 with를 써야 합니다. 팀 규모가 커지면 누군가 실수로 with를 빼먹습니다. 서비스 레이어의 함수들에 공통적으로 트랜잭션을 적용하고 싶다면 데코레이터가 적합합니다.
1) conn을 주입하는 데코레이터
from functools import wraps
def with_transaction(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
with transaction() as conn:
return fn(*args, conn=conn, **kwargs)
return wrapper
사용 예:
@with_transaction
def transfer_money(from_id: int, to_id: int, amount: int, *, conn):
with conn.cursor() as cur:
cur.execute("UPDATE accounts SET balance = balance - %s WHERE id=%s", (amount, from_id))
cur.execute("UPDATE accounts SET balance = balance + %s WHERE id=%s", (amount, to_id))
포인트:
- 함수 시그니처에
conn을 키워드 전용 인자로 두면 호출자가 실수로 위치 인자로 넘기는 문제를 줄일 수 있습니다. - 모든 DB 접근 함수가 같은 방식으로 커넥션을 받게 되어 코드 리뷰가 쉬워집니다.
2) 읽기 전용 쿼리 최적화(옵션)
읽기 전용은 커밋이 필요 없고, 격리 수준이나 라우팅(리드 레플리카) 같은 정책을 별도로 둘 수 있습니다.
def with_conn(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
with get_conn() as conn:
return fn(*args, conn=conn, **kwargs)
return wrapper
@with_conn
def list_users(limit: int, *, conn):
with conn.cursor() as cur:
cur.execute("SELECT id, name FROM users ORDER BY id DESC LIMIT %s", (limit,))
return cur.fetchall()
이처럼 쓰기는 with_transaction, 읽기는 with_conn으로 나누면 불필요한 트랜잭션을 줄이면서도 누수 방지 구조는 유지할 수 있습니다.
실전에서 자주 놓치는 디테일
1) 커서도 컨텍스트로 닫기
드라이버가 커서를 컨텍스트 매니저로 지원한다면 with conn.cursor() as cur: 형태를 기본으로 두세요. 지원하지 않는 드라이버라면 커서용 컨텍스트를 하나 더 만들어도 됩니다.
from contextlib import contextmanager
@contextmanager
def cursor(conn):
cur = conn.cursor()
try:
yield cur
finally:
cur.close()
2) 예외를 “먹지 말고” 올리기
트랜잭션 컨텍스트에서 예외를 잡고 로그만 남긴 뒤 넘어가면, 상위 레이어는 성공으로 오해합니다. 데코레이터/컨텍스트에서는 원칙적으로 raise로 다시 올려 호출자가 실패를 인지하게 하세요.
3) 풀 설정과 타임아웃도 누수 방지의 일부
코드가 완벽해도 다음 상황에서 “누수처럼 보이는” 현상이 생깁니다.
- DB 서버가 느려져 쿼리가 오래 잡고 있음(커넥션 점유)
- 풀
max_size가 트래픽 대비 너무 작아 대기열이 증가 - 네트워크 이슈로 커넥션이 끊겼는데 재사용 시도
따라서 다음을 함께 설정하는 편이 좋습니다.
- 풀의
max_size,timeout(대기 제한) - 드라이버의 statement timeout(서버/클라이언트)
- 커넥션 헬스체크(드라이버/풀 기능 활용)
4) 테스트로 “반납 보장”을 검증하기
단위 테스트에서 커넥션 누수를 100% 잡아내긴 어렵지만, 최소한 “예외가 나도 풀 반환이 되는지”는 검증할 수 있습니다.
아래는 풀의 상태를 관찰할 수 있다는 가정의 의사 코드입니다(풀 구현에 따라 API가 다릅니다).
import pytest
@with_transaction
def will_fail(*, conn):
with conn.cursor() as cur:
cur.execute("SELECT 1")
raise RuntimeError("boom")
def test_connection_returned_on_exception():
before = pool.info() # 예: 사용 중/유휴 개수 등을 제공한다고 가정
with pytest.raises(RuntimeError):
will_fail()
after = pool.info()
assert after["checked_out"] == before["checked_out"]
풀의 내부 상태를 직접 못 보면, 최소한 “반복 호출에도 타임아웃/고갈이 발생하지 않는지”를 부하 테스트로 확인하세요.
FastAPI 같은 웹 프레임워크에 적용하기
요청 단위로 트랜잭션을 묶고 싶다면(핸들러 하나가 하나의 유스케이스라면) 컨텍스트를 라우트 함수 내부에서 쓰는 방식이 가장 단순합니다.
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.post("/users")
def create_user_api(user_id: int, name: str):
try:
with transaction() as conn:
with conn.cursor() as cur:
cur.execute("INSERT INTO users(id, name) VALUES (%s, %s)", (user_id, name))
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
서비스 레이어를 둔다면, 라우트는 서비스 함수를 호출만 하고 DB 관리는 데코레이터가 담당하도록 정리할 수 있습니다.
@with_transaction
def create_user_service(user_id: int, name: str, *, conn):
with conn.cursor() as cur:
cur.execute("INSERT INTO users(id, name) VALUES (%s, %s)", (user_id, name))
@app.post("/users2")
def create_user_api2(user_id: int, name: str):
create_user_service(user_id, name)
return {"ok": True}
체크리스트: “누수 0”에 가까워지는 규칙
- 커넥션은 반드시
with또는 데코레이터로만 획득한다(직접pool.getconn()금지). - 쓰기 작업은
commit/rollback을 컨텍스트 내부에서 강제한다. - 커서는 가능하면 컨텍스트로 닫는다.
- 예외를 컨텍스트에서 삼키지 말고
raise로 올린다. - 풀 크기/타임아웃/쿼리 타임아웃을 운영 트래픽에 맞춰 설정한다.
마무리
Python에서 DB 커넥션 누수는 “개발자의 실수”라기보다 “자원 생명주기를 분산시킨 설계”에서 발생합니다. 컨텍스트 매니저로 반납과 트랜잭션 경계를 강제하고, 데코레이터로 팀 전체 코드에 일관성을 부여하면 누수 가능성을 구조적으로 제거할 수 있습니다.
운영에서 커넥션 누수는 종종 재시작 루프나 리소스 압박으로 이어집니다. 장애가 났을 때는 애플리케이션 코드뿐 아니라 런타임/오케스트레이션 레벨 증상도 함께 관찰하면서 원인을 좁혀가세요.