Published on

Python 데코레이터+컨텍스트 매니저로 자동 롤백

Authors

서비스 코드에서 DB 트랜잭션을 직접 다루다 보면 비슷한 코드가 반복됩니다. 예를 들어 try 블록에서 쿼리를 실행하고, 실패하면 롤백, 성공하면 커밋, 마지막에 커넥션 반환 같은 흐름이 매 함수마다 복붙됩니다. 이 패턴은 다음 문제를 낳습니다.

  • 커밋/롤백 누락: 예외 경로가 늘어날수록 빠뜨리기 쉽습니다.
  • 로깅/메트릭 불일치: 어떤 함수는 로깅하고 어떤 함수는 안 합니다.
  • 중첩 호출에서의 혼란: 상위 함수가 트랜잭션을 잡고 하위 함수도 커밋하려다 꼬입니다.

이 글에서는 컨텍스트 매니저로 트랜잭션 경계를 만들고, 데코레이터로 적용을 표준화해서 “예외 발생 시 자동 롤백”을 안정적으로 구현하는 방법을 다룹니다. 분산 환경에서는 SAGA 같은 보상 트랜잭션이 필요할 때도 있는데, 그 경우에도 “중복 실행 방지” 같은 설계 포인트가 있습니다. 관련해서는 MSA SAGA 보상 트랜잭션 중복 실행 막는 법도 함께 참고하면 좋습니다.

목표: 호출자는 비즈니스 로직만 쓰게 만들기

원하는 사용 경험은 아래처럼 “트랜잭션을 잊어도 되는” 형태입니다.

  • 성공하면 자동 커밋
  • 예외면 자동 롤백
  • 커넥션/세션 정리 자동
  • 필요한 경우에만 “읽기 전용”이나 “중첩 트랜잭션” 같은 옵션을 노출

이를 위해 아래 두 가지를 조합합니다.

  1. 컨텍스트 매니저: with 블록의 진입/탈출에 맞춰 커밋/롤백
  2. 데코레이터: 함수 실행을 with 블록으로 감싸 반복 제거

컨텍스트 매니저로 트랜잭션 스코프 만들기

파이썬 컨텍스트 매니저는 __enter__/__exit__ 또는 contextlib 기반으로 구현합니다. 여기서는 실무에서 가장 자주 쓰는 contextlib.contextmanager를 사용하겠습니다.

아래 예시는 DB-API 2.0 스타일 커넥션(예: psycopg2, pymysql)을 가정합니다. 핵심은 yield 앞에서 트랜잭션을 시작하고, yield 뒤에서 성공/실패에 따라 커밋/롤백을 수행한다는 점입니다.

from contextlib import contextmanager
from typing import Iterator, Optional


@contextmanager
def transaction(conn, *, read_only: bool = False) -> Iterator[object]:
    """커넥션 단위 트랜잭션 컨텍스트.

    - 성공: commit
    - 예외: rollback
    - 항상: 커서/리소스 정리(필요 시)

    read_only는 예시이며, 실제로는 DB별로 설정 방식이 다릅니다.
    """

    if read_only:
        # DB마다 read only 설정 방식이 다르므로 예시로만 둡니다.
        # 예: PostgreSQL은 BEGIN READ ONLY 같은 형태
        pass

    try:
        yield conn
        if not read_only:
            conn.commit()
        else:
            # 읽기 전용이면 커밋을 생략하거나, DB 정책에 맞게 처리
            conn.rollback()  # 일부 드라이버는 read-only라도 트랜잭션 종료가 필요
    except Exception:
        conn.rollback()
        raise

중요한 디테일: “예외를 삼키지 말 것”

__exit__에서 예외를 처리하고 True를 반환하면 예외가 숨겨집니다. 트랜잭션 컨텍스트는 보통 롤백만 하고 예외는 다시 던지는 게 안전합니다. 그래야 상위 레이어(예: API 핸들러)가 적절히 4xx/5xx를 만들고, 관측(로그/트레이싱)도 정상 동작합니다.

데코레이터로 트랜잭션 적용을 표준화

컨텍스트 매니저만으로도 충분하지만, 매번 with transaction(conn):을 쓰는 것조차 반복입니다. 특히 서비스 레이어 함수가 많을수록 “트랜잭션 적용 누락”이 발생합니다.

아래 데코레이터는 함수 실행을 트랜잭션 컨텍스트로 감쌉니다.

import functools
from typing import Callable, TypeVar, Any

R = TypeVar("R")


def transactional(get_conn: Callable[[], Any], *, read_only: bool = False):
    """함수 단위 트랜잭션 데코레이터.

    get_conn: 커넥션을 꺼내는 함수(예: pool.getconn, DI 컨테이너)
    """

    def decorator(fn: Callable[..., R]) -> Callable[..., R]:
        @functools.wraps(fn)
        def wrapper(*args, **kwargs) -> R:
            conn = get_conn()
            try:
                with transaction(conn, read_only=read_only):
                    return fn(*args, **kwargs)
            finally:
                # 풀을 쓴다면 반환 로직이 들어갑니다.
                # 예: pool.putconn(conn)
                pass

        return wrapper

    return decorator

이제 서비스 함수는 다음처럼 작성됩니다.

# 예: 전역 풀 또는 DI로 주입된 풀

def get_conn():
    # pool.getconn() 같은 로직
    return conn


@transactional(get_conn)
def transfer_money(from_user_id: int, to_user_id: int, amount: int) -> None:
    # 여기서는 conn을 직접 받지 않는 구조도 가능하지만,
    # 예제에서는 단순화를 위해 내부에서 전역/스레드로컬을 쓴다고 가정합니다.
    # 실제로는 conn을 인자로 받거나, repository가 conn을 받도록 구성하는 편이 안전합니다.

    # debit
    # credit
    # ledger insert
    # 중간에 예외가 나면 자동 rollback
    pass

“커넥션을 어디서 가져오나”가 설계의 핵심

데코레이터는 편하지만, 커넥션/세션을 어디서 가져오는지에 따라 안정성이 갈립니다.

  • 전역 변수: 테스트/동시성에서 취약
  • 스레드 로컬: 동기 웹서버에서는 그나마 괜찮지만, 비동기에서는 위험
  • 함수 인자 주입: 가장 명시적이지만 호출부가 번거로움
  • DI 컨테이너: 대규모 코드베이스에서 일관성 확보에 유리

실무에서는 Repository가 커넥션을 인자로 받게 하고, 서비스 함수는 conn을 명시적으로 받거나, 컨텍스트 변수(contextvars)로 세션을 전달하는 패턴을 많이 씁니다.

데코레이터+컨텍스트 매니저의 “중첩 트랜잭션” 문제

문제 상황은 이렇습니다.

  • A()@transactional로 트랜잭션을 시작
  • A() 내부에서 B()를 호출하는데 B()@transactional

이때 단순 구현은 B()가 별도 트랜잭션을 열고 커밋/롤백을 해버립니다. 같은 커넥션을 공유하지 않는다면 더 큰 문제(부분 커밋)가 생깁니다.

해결책은 보통 아래 중 하나입니다.

  1. 상위에서만 트랜잭션을 열고 하위는 참여(권장)
  2. DB의 세이브포인트(savepoint) 를 사용해 중첩을 흉내

여기서는 “상위 우선”을 구현하기 위해 contextvars로 현재 트랜잭션 상태를 추적하는 예시를 보겠습니다.

from contextvars import ContextVar
from contextlib import contextmanager

_in_tx: ContextVar[bool] = ContextVar("_in_tx", default=False)


@contextmanager
def transaction_scoped(conn):
    token = None
    if _in_tx.get():
        # 이미 트랜잭션 안이면 참여만 하고 커밋/롤백 책임은 상위에 둡니다.
        yield conn
        return

    try:
        token = _in_tx.set(True)
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        if token is not None:
            _in_tx.reset(token)

이제 데코레이터는 transaction_scoped를 사용하면 됩니다.

import functools


def transactional_scoped(get_conn):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            conn = get_conn()
            try:
                with transaction_scoped(conn):
                    return fn(*args, **kwargs)
            finally:
                pass

        return wrapper

    return decorator

이 방식은 “중첩 호출에서 커밋 책임이 한 곳에만 존재”하게 만들어, 부분 커밋을 예방합니다.

SQLAlchemy 세션으로 구현할 때의 실전 패턴

ORM을 쓴다면 커넥션보다 세션(Session) 을 트랜잭션 단위로 취급하는 편이 일반적입니다. SQLAlchemy 1.4+에서는 Session.begin()이 컨텍스트 매니저를 제공합니다.

from sqlalchemy.orm import sessionmaker

SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)


def with_session(fn):
    import functools

    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        session = SessionLocal()
        try:
            with session.begin():
                return fn(session, *args, **kwargs)
        except Exception:
            # begin 컨텍스트가 rollback을 수행하지만,
            # 여기서 로깅/추적을 추가할 수 있습니다.
            raise
        finally:
            session.close()

    return wrapper


@with_session
def create_user(session, email: str) -> int:
    user = User(email=email)
    session.add(user)
    session.flush()  # PK 필요 시
    return user.id

이 패턴의 장점은 session을 함수 인자로 명시적으로 전달하므로 테스트가 쉽고, 중첩 시에도 “같은 세션을 계속 전달”하는 방식으로 통제할 수 있다는 점입니다.

자동 롤백을 더 강하게 만드는 운영 팁

1) 예외 타입별 정책 분리

모든 예외를 롤백하는 건 맞지만, 로깅 레벨이나 알림은 다르게 가져갈 수 있습니다.

  • 비즈니스 예외(예: 재고 부족): warning
  • 시스템 예외(예: 네트워크, 데드락): error + 재시도

데드락/타임아웃 재시도는 DB별로 에러 코드가 달라서, 예외를 래핑해 정책을 통일하는 편이 좋습니다. gRPC 타임아웃처럼 “기한 초과”를 다루는 관점은 Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방도 유사한 운영 감각을 제공합니다.

2) 관측성: 트랜잭션 경계에 공통 로깅/메트릭

데코레이터는 공통 로깅을 넣기 좋은 지점입니다.

  • 실행 시간
  • 커밋 성공/롤백 발생 카운트
  • 예외 타입 분포

특히 로그 비용이 민감한 조직이라면 “트랜잭션 실패 로그 폭증”이 곧 비용으로 이어질 수 있어, 샘플링/구조화/레벨링이 중요합니다. 이 관점은 CloudWatch Logs 비용 폭증 원인과 절감 10가지와도 연결됩니다.

3) 테스트: 롤백이 정말 되었는지 검증

단위 테스트에서는 “예외가 나면 DB 상태가 원복되었는지”를 확인해야 합니다.

  • 트랜잭션 함수 실행
  • 의도적으로 예외 유발
  • 같은 커넥션/새 커넥션에서 레코드 존재 여부 확인

주의할 점은 격리 수준에 따라 같은 트랜잭션에서 보이는 데이터가 달라질 수 있다는 것입니다. 테스트는 가능하면 “새 세션/새 커넥션으로 조회”해 커밋 여부를 검증하세요.

흔한 함정 5가지

  1. 예외를 잡고 반환해버림: 롤백은 되었는데 호출자는 성공으로 오인합니다. 예외는 다시 던지거나 명시적으로 실패를 표현하세요.
  2. 중첩 트랜잭션에서 하위가 커밋: 상위 실패 시에도 일부가 커밋되는 최악의 버그가 됩니다. 스코프 참여 또는 세이브포인트를 쓰세요.
  3. 풀 반환 누락: finally에서 커넥션 반환을 강제하세요.
  4. 읽기 전용에서도 트랜잭션을 열어 잠금이 생김: DB/드라이버 설정에 따라 의도치 않은 잠금이 걸 수 있습니다. read-only 정책을 분리하세요.
  5. 비동기 환경에서 스레드 로컬 사용: asyncio에서는 contextvars가 더 안전합니다.

정리

컨텍스트 매니저는 “트랜잭션 경계에서 커밋/롤백을 자동화”하는 가장 파이썬다운 도구이고, 데코레이터는 그 경계를 “누락 없이 강제”하는 좋은 수단입니다. 두 가지를 조합하면 다음을 얻습니다.

  • 예외 시 자동 롤백이 일관되게 적용
  • 반복 코드 제거로 실수 감소
  • 로깅/메트릭/재시도 같은 운영 정책을 한 곳에 모음
  • 중첩 호출에서도 커밋 책임을 단일화 가능

단, 중첩 트랜잭션과 커넥션 전달 방식(전역/DI/인자 주입)은 설계의 핵심이므로, 팀의 아키텍처와 실행 모델(동기/비동기)에 맞춰 선택하세요.