Published on

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

Authors
Binance registration banner

서비스 코드에서 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/인자 주입)은 설계의 핵심이므로, 팀의 아키텍처와 실행 모델(동기/비동기)에 맞춰 선택하세요.