Published on

Python 데코레이터·컨텍스트로 DB 누수 0 만들기

Authors

DB 커넥션 누수는 대부분 “닫아야 하는 자원”을 코드 곳곳에서 수동으로 관리할 때 발생합니다. 특히 Python에서는 예외가 발생했을 때 close() 호출이 건너뛰어지거나, 커넥션을 전역으로 보관한 뒤 반환을 잊는 형태로 누수가 생기기 쉽습니다.

이 글에서는 데코레이터컨텍스트 매니저를 조합해 커넥션/커서/트랜잭션의 생명주기를 한 곳에 고정하고, 애플리케이션 코드에서 실수할 여지를 제거하는 방법을 다룹니다. 운영 환경에서 커넥션 누수는 결국 장애로 이어지며, 장애가 나면 프로세스 재시작이나 리소스 압박(OOM 등)으로 증상이 확산되기도 합니다. 장애 원인 추적/완화 관점은 다음 글들도 함께 참고하면 좋습니다.

커넥션 누수의 전형적인 패턴

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 커넥션 누수는 “개발자의 실수”라기보다 “자원 생명주기를 분산시킨 설계”에서 발생합니다. 컨텍스트 매니저로 반납과 트랜잭션 경계를 강제하고, 데코레이터로 팀 전체 코드에 일관성을 부여하면 누수 가능성을 구조적으로 제거할 수 있습니다.

운영에서 커넥션 누수는 종종 재시작 루프나 리소스 압박으로 이어집니다. 장애가 났을 때는 애플리케이션 코드뿐 아니라 런타임/오케스트레이션 레벨 증상도 함께 관찰하면서 원인을 좁혀가세요.