- Published on
Python contextmanager로 DB 트랜잭션 원자성 보장
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 “트랜잭션 원자성”은 기능 요구사항이 아니라 생존 요구사항에 가깝습니다. 결제 승인 후 주문 상태 업데이트가 실패하거나, 재고 차감과 로그 기록 중 하나만 반영되는 순간 데이터는 깨지고 복구 비용은 폭증합니다. 문제는 이런 버그가 보통 “예외 처리 누락”, “커밋 위치 실수”, “조기 return” 같은 사소한 형태로 숨어든다는 점입니다.
Python에서는 contextmanager(또는 클래스 기반 컨텍스트 매니저)를 이용해 트랜잭션 경계를 강제하고, 커밋/롤백/자원 반납을 한 곳에 모아 실수를 구조적으로 차단할 수 있습니다. 이 글에서는 DB 트랜잭션을 with 블록으로 감싸 원자성을 보장하는 패턴을, 드라이버 레벨과 ORM 레벨에서 각각 살펴봅니다.
참고로 원자성은 “한 트랜잭션 안의 변경이 전부 반영되거나 전부 반영되지 않는 것”이고, 이를 깨뜨리는 대표 원인은 트랜잭션 경계가 코드 곳곳에 흩어져 있는 구조입니다. 이벤트 소싱에서도 스냅샷과 이벤트 적용 경계가 흐려지면 비슷한 형태의 불일치가 발생합니다. 관련 디버깅 관점은 이벤트 소싱 스냅샷 불일치 버그 추적법도 함께 참고하면 좋습니다.
왜 contextmanager가 트랜잭션 버그를 줄이는가
트랜잭션 처리가 흩어져 있을 때 자주 나오는 안티패턴은 다음과 같습니다.
- 성공 경로에서만
commit()호출, 실패 경로에서rollback()누락 - 중간에
return/break로 빠져나가며 커밋/롤백이 스킵됨 - 예외를 잡아먹고 로깅만 한 뒤 계속 진행(부분 반영)
- 커넥션/커서 close 누락(커넥션 풀 고갈)
컨텍스트 매니저는 이 문제를 “제어 흐름이 어떻게 끝나든 항상 실행되는 정리 로직”으로 해결합니다.
- 블록이 정상 종료되면
commit() - 블록에서 예외가 발생하면
rollback() - 항상 커서/커넥션을 반납
- 예외를 삼킬지(억제) 그대로 전파할지(권장)를 중앙에서 통제
기본 패턴: contextlib.contextmanager로 트랜잭션 감싸기
가장 간단한 형태는 contextlib.contextmanager 데코레이터를 이용하는 것입니다.
from contextlib import contextmanager
@contextmanager
def transaction(conn):
"""DB-API 2.0 커넥션을 트랜잭션 단위로 다루는 헬퍼.
- 정상 종료: commit
- 예외 발생: rollback 후 예외 재전파
"""
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
사용자는 커밋/롤백을 직접 호출하지 않습니다.
def transfer_money(conn, from_id, to_id, amount):
with transaction(conn):
cur = conn.cursor()
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),
)
이렇게 하면 중간에 어떤 예외가 터져도 항상 롤백되며, 원자성이 보장됩니다.
커서까지 함께 관리하기
커서 close 누락도 흔합니다. 트랜잭션 컨텍스트 안에서 커서를 별도 컨텍스트로 제공하면 더 안전합니다.
from contextlib import contextmanager
@contextmanager
def transaction_cursor(conn):
try:
cur = conn.cursor()
yield cur
conn.commit()
except Exception:
conn.rollback()
raise
finally:
try:
cur.close()
except Exception:
pass
def create_order(conn, user_id, items):
with transaction_cursor(conn) as cur:
cur.execute(
"INSERT INTO orders(user_id, status) VALUES (%s, %s) RETURNING id",
(user_id, "CREATED"),
)
order_id = cur.fetchone()[0]
for sku, qty in items:
cur.execute(
"INSERT INTO order_items(order_id, sku, qty) VALUES (%s, %s, %s)",
(order_id, sku, qty),
)
return order_id
핵심은 “반드시 close해야 하는 자원”과 “반드시 commit/rollback해야 하는 경계”를 함께 묶어, 호출자가 실수할 여지를 없애는 것입니다.
예외를 잡아먹지 말고, 재전파 규칙을 명확히 하라
트랜잭션 컨텍스트에서 가장 위험한 실수는 예외를 내부에서 삼켜버리는 것입니다.
- 예외를 삼키면 호출자는 실패를 모릅니다.
- 실패를 모르니 재시도/보상 트랜잭션/알림이 동작하지 않습니다.
- 결과적으로 “조용히 깨진 데이터”가 남습니다.
따라서 위 예제처럼 except Exception: rollback(); raise가 기본입니다.
예외를 특정 타입만 변환하고 싶다면, 변환 후에도 실패 사실은 유지해야 합니다.
class DomainError(Exception):
pass
@contextmanager
def transaction(conn):
try:
yield conn
conn.commit()
except ValueError as e:
conn.rollback()
raise DomainError("invalid request") from e
except Exception:
conn.rollback()
raise
psycopg2(또는 DB-API)에서 주의할 점
오토커밋과 트랜잭션 시작
드라이버에 따라 “첫 쿼 실행 시 트랜잭션이 암묵적으로 시작”됩니다. 또한 오토커밋이 켜져 있으면 트랜잭션 자체가 무력화됩니다.
- 오토커밋이
True이면 각 쿼가 즉시 커밋되어 원자성이 깨집니다. - 트랜잭션 컨텍스트를 쓸 때는 오토커밋 정책을 명확히 해야 합니다.
예시(개념 코드):
# conn.autocommit = False # 드라이버별 설정
with transaction(conn):
...
운영 코드에서는 “커넥션 풀에서 빌려온 커넥션은 항상 동일한 autocommit 정책을 가진다”를 보장하는 편이 좋습니다. 그렇지 않으면 이전 사용자가 바꿔둔 설정이 다음 요청에 영향을 줍니다.
중첩 트랜잭션을 기대하지 말 것
with transaction(conn)를 중첩 호출한다고 해서 DB가 자동으로 세이브포인트를 만들어주지는 않습니다. 대부분의 DB-API 커넥션은 “커넥션 당 하나의 트랜잭션” 모델입니다.
중첩이 필요하다면 세이브포인트를 명시적으로 사용하거나, ORM이 제공하는 중첩 트랜잭션 기능을 사용해야 합니다.
SQLAlchemy에서의 권장 패턴
SQLAlchemy는 트랜잭션 관리가 더 체계적으로 제공됩니다. 특히 Session은 컨텍스트 매니저로 쓰기 좋습니다.
Session.begin() 컨텍스트로 원자성 보장
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("postgresql+psycopg2://...")
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def create_user(email: str):
with SessionLocal() as session:
with session.begin():
session.execute(
"INSERT INTO users(email) VALUES (:email)",
{"email": email},
)
# 여기서 예외가 나면 자동 rollback
# 정상 종료면 자동 commit
session.begin()블록이 트랜잭션 경계입니다.- 블록을 빠져나갈 때 SQLAlchemy가 커밋/롤백을 처리합니다.
with SessionLocal() as session:은 세션 close를 보장합니다.
서비스 레이어에서 트랜잭션 경계를 한 번만 잡기
실무에서 흔한 구조는 “서비스 함수가 여러 리포지토리를 호출”하는 형태입니다. 이때 리포지토리 내부에서 커밋해버리면 상위 유스케이스 원자성이 깨집니다.
권장:
- 트랜잭션 시작/종료는 유스케이스(서비스) 레이어에서 담당
- 리포지토리는 쿼리만 수행하고 커밋하지 않음
def place_order(session, user_id, items):
# repository 함수들은 session만 받아서 쿼리 수행
order_id = insert_order(session, user_id)
for sku, qty in items:
insert_order_item(session, order_id, sku, qty)
decrease_stock(session, sku, qty)
return order_id
def place_order_usecase(user_id, items):
with SessionLocal() as session:
with session.begin():
return place_order(session, user_id, items)
이 구조는 “원자성 보장 지점이 한 곳”이라 리뷰와 테스트가 쉬워집니다.
컨텍스트 매니저 설계 팁: 로깅, 재시도, 타임아웃을 어디에 둘까
트랜잭션 컨텍스트는 너무 많은 책임을 넣으면 오히려 위험해집니다. 하지만 다음 정도는 중앙화해도 좋습니다.
- 트랜잭션 실패 시 공통 로깅(쿼리 전체를 남기기보다는 요청 ID, 유스케이스, 예외 타입 중심)
- 데드락/직렬화 실패 같은 “재시도 가능한 예외”만 선별해 상위 레이어에서 재시도
예: 재시도는 컨텍스트 매니저 안이 아니라 바깥에서 도는 편이 예측 가능성이 높습니다.
import time
RETRYABLE_ERRORS = (Exception,) # 실제로는 드라이버 예외 타입을 좁혀야 함
def run_with_retry(fn, max_attempts=3, backoff_sec=0.2):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return fn()
except RETRYABLE_ERRORS as e:
last_exc = e
if attempt == max_attempts:
raise
time.sleep(backoff_sec * attempt)
raise last_exc
def create_order_with_retry(conn, user_id, items):
def _work():
with transaction_cursor(conn) as cur:
# ... 쿼리들
return "ok"
return run_with_retry(_work)
트랜잭션 경계와 재시도 정책을 분리하면, “어떤 실패를 재시도하는지”가 코드에서 더 명확해집니다.
테스트 전략: 원자성은 단위 테스트보다 통합 테스트로 잡힌다
원자성은 DB의 실제 동작(락, 제약조건, 격리수준)에 의존합니다. 따라서 다음을 추천합니다.
- 통합 테스트에서 의도적으로 예외를 발생시키고, DB 상태가 롤백되었는지 검증
- 유니크 제약 위반, FK 위반, 데드락 등 현실적인 실패 케이스를 포함
- “부분 반영”이 가장 치명적인 테이블(주문/결제/재고/정산)에 집중
이 관점은 캐시나 비동기 처리처럼 “상태가 기대와 다르게 남는 문제”를 다룰 때도 유사합니다. 프론트/백엔드 경계에서 상태가 갱신되지 않는 문제를 추적하는 방식은 Next.js App Router 캐시로 데이터가 안 갱신될 때 글의 접근도 참고할 만합니다.
자주 하는 실수 체크리스트
- 트랜잭션 블록 내부에서 예외를 잡고
return해버림(실패가 성공처럼 보임) - 리포지토리 함수 내부에서
commit()호출(상위 유스케이스 원자성 붕괴) - 오토커밋이 켜진 커넥션을 그대로 사용
- 중첩
with transaction(...)이 세이브포인트를 만들어줄 거라 착각 - 트랜잭션 안에서 외부 API 호출을 오래 수행(락 점유 시간 증가)
특히 “트랜잭션 안에서 외부 API 호출”은 성능과 교착을 동시에 악화시키는 전형적인 함정입니다. 외부 호출은 트랜잭션 밖으로 빼고, DB에는 상태 전이(예: PENDING 기록)만 원자적으로 남긴 뒤 비동기로 처리하는 식이 안전합니다.
결론: with로 트랜잭션을 강제하면 리뷰 비용이 줄어든다
contextmanager로 트랜잭션을 감싸면 원자성 보장 로직이 한 곳에 고정됩니다. 그 결과:
- 커밋/롤백 누락이 구조적으로 불가능해지고
- 예외 전파 정책이 일관되며
- 서비스 레이어에서 “이 유스케이스는 하나의 트랜잭션”이라는 의도가 코드로 드러납니다.
드라이버 레벨(DB-API)에서는 contextlib.contextmanager로 커서/커넥션을 함께 관리하고, ORM(SQLAlchemy)에서는 Session.begin()을 트랜잭션 경계로 삼는 것이 실전에서 가장 안전한 출발점입니다.