- Published on
Python 데코레이터+컨텍스트 매니저로 자동 롤백
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서비스 코드에서 DB 트랜잭션을 직접 다루다 보면 비슷한 코드가 반복됩니다. 예를 들어 try 블록에서 쿼리를 실행하고, 실패하면 롤백, 성공하면 커밋, 마지막에 커넥션 반환 같은 흐름이 매 함수마다 복붙됩니다. 이 패턴은 다음 문제를 낳습니다.
- 커밋/롤백 누락: 예외 경로가 늘어날수록 빠뜨리기 쉽습니다.
- 로깅/메트릭 불일치: 어떤 함수는 로깅하고 어떤 함수는 안 합니다.
- 중첩 호출에서의 혼란: 상위 함수가 트랜잭션을 잡고 하위 함수도 커밋하려다 꼬입니다.
이 글에서는 컨텍스트 매니저로 트랜잭션 경계를 만들고, 데코레이터로 적용을 표준화해서 “예외 발생 시 자동 롤백”을 안정적으로 구현하는 방법을 다룹니다. 분산 환경에서는 SAGA 같은 보상 트랜잭션이 필요할 때도 있는데, 그 경우에도 “중복 실행 방지” 같은 설계 포인트가 있습니다. 관련해서는 MSA SAGA 보상 트랜잭션 중복 실행 막는 법도 함께 참고하면 좋습니다.
목표: 호출자는 비즈니스 로직만 쓰게 만들기
원하는 사용 경험은 아래처럼 “트랜잭션을 잊어도 되는” 형태입니다.
- 성공하면 자동 커밋
- 예외면 자동 롤백
- 커넥션/세션 정리 자동
- 필요한 경우에만 “읽기 전용”이나 “중첩 트랜잭션” 같은 옵션을 노출
이를 위해 아래 두 가지를 조합합니다.
- 컨텍스트 매니저:
with블록의 진입/탈출에 맞춰 커밋/롤백 - 데코레이터: 함수 실행을
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()가 별도 트랜잭션을 열고 커밋/롤백을 해버립니다. 같은 커넥션을 공유하지 않는다면 더 큰 문제(부분 커밋)가 생깁니다.
해결책은 보통 아래 중 하나입니다.
- 상위에서만 트랜잭션을 열고 하위는 참여(권장)
- 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가지
- 예외를 잡고 반환해버림: 롤백은 되었는데 호출자는 성공으로 오인합니다. 예외는 다시 던지거나 명시적으로 실패를 표현하세요.
- 중첩 트랜잭션에서 하위가 커밋: 상위 실패 시에도 일부가 커밋되는 최악의 버그가 됩니다. 스코프 참여 또는 세이브포인트를 쓰세요.
- 풀 반환 누락:
finally에서 커넥션 반환을 강제하세요. - 읽기 전용에서도 트랜잭션을 열어 잠금이 생김: DB/드라이버 설정에 따라 의도치 않은 잠금이 걸 수 있습니다. read-only 정책을 분리하세요.
- 비동기 환경에서 스레드 로컬 사용:
asyncio에서는contextvars가 더 안전합니다.
정리
컨텍스트 매니저는 “트랜잭션 경계에서 커밋/롤백을 자동화”하는 가장 파이썬다운 도구이고, 데코레이터는 그 경계를 “누락 없이 강제”하는 좋은 수단입니다. 두 가지를 조합하면 다음을 얻습니다.
- 예외 시 자동 롤백이 일관되게 적용
- 반복 코드 제거로 실수 감소
- 로깅/메트릭/재시도 같은 운영 정책을 한 곳에 모음
- 중첩 호출에서도 커밋 책임을 단일화 가능
단, 중첩 트랜잭션과 커넥션 전달 방식(전역/DI/인자 주입)은 설계의 핵심이므로, 팀의 아키텍처와 실행 모델(동기/비동기)에 맞춰 선택하세요.