- Published on
Context Manager로 DB 트랜잭션 자동 롤백 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서비스 코드에서 DB 트랜잭션을 직접 begin/commit/rollback으로 다루기 시작하면, 예외 분기와 조기 return이 늘어날수록 “커밋 누락”, “롤백 누락”, “커넥션 반환 누락” 같은 사고가 잦아집니다. 특히 다음 상황이 겹치면 더 위험합니다.
- 여러 DAO 호출 사이에서 예외가 발생
- 중간에 검증 실패로 조기 종료
- 로깅/메트릭 전송 중 예외
- 재시도 로직이 섞이며 롤백 타이밍이 꼬임
이럴 때 가장 강력한 해법 중 하나가 Context Manager로 트랜잭션 경계를 강제하는 패턴입니다. 핵심은 간단합니다.
with블록이 정상 종료되면commit- 블록 내부에서 예외가 발생하면
rollback - 어떤 경우든 커넥션은 풀로 반환
이 글에서는 Python을 기준으로 구현 패턴을 설명하지만, 개념 자체는 Java의 트랜잭션 경계(예: 선언적 트랜잭션)와 동일합니다. Spring을 쓰는 경우에도 “트랜잭션이 왜 먹통인지” 같은 문제를 겪는다면 원리를 이해하는 데 도움이 됩니다. 관련해서는 Spring Boot 3에서 @Transactional 먹통 원인 7가지도 함께 보면 좋습니다.
왜 Context Manager가 실무에서 강한가
1) 트랜잭션 경계를 코드 구조로 고정한다
트랜잭션은 “함수 호출 흐름”이 아니라 “업무 단위”에 맞춰야 합니다. Context Manager는 블록 단위로 경계를 고정하므로, 팀원이 코드를 수정해도 실수로 commit/rollback 위치를 바꾸기 어렵습니다.
2) 예외 안전성(exception safety)
예외는 생각보다 자주 발생합니다. 네트워크 오류, 타임아웃, 유니크 제약 위반, 직렬화 실패 등. Context Manager는 try/except/finally를 매번 반복하지 않게 해주고, “항상 정리(cleanup)가 수행된다”는 보장을 줍니다.
3) 커넥션 풀 누수 방지
트랜잭션 롤백만큼이나 중요한 게 커넥션 반환입니다. 풀을 쓰는 환경에서 커넥션 누수는 곧 장애로 이어집니다. Linux에서 디스크가 꽉 찼는데 공간이 안 줄어드는 deleted-but-open 문제처럼, 자원은 “반납을 안 하면” 언젠가 터집니다. 이런 자원 누수 관점은 리눅스 디스크 100%인데 용량이 안 줄 때 - deleted-but-open(lsof)에서 다룬 방식과도 닮아 있습니다.
가장 단순한 형태: DB-API 2.0 기반
Python의 많은 드라이버는 DB-API 2.0 스타일을 따릅니다. 이 경우 connection.commit()과 connection.rollback()을 직접 호출할 수 있습니다.
아래는 커넥션 풀에서 커넥션을 빌려 트랜잭션을 실행하고, 예외가 나면 자동 롤백하는 기본 패턴입니다.
from contextlib import contextmanager
@contextmanager
def transaction(pool):
conn = pool.getconn()
try:
# 필요 시 autocommit 끄기
conn.autocommit = False
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
pool.putconn(conn)
사용 예시는 다음처럼 단순해집니다.
def create_order(pool, user_id, items):
with transaction(pool) as conn:
cur = conn.cursor()
cur.execute(
"insert into orders(user_id) values(%s) returning id",
(user_id,),
)
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
이 패턴의 장점은 다음과 같습니다.
- 예외가 어디서 터져도
rollback이 보장 - 정상 종료 시에만
commit - 커넥션 반환이
finally로 보장
실무 디테일 1: 어떤 예외를 롤백해야 하나
대부분의 경우 “예외면 롤백”이 맞습니다. 다만 예외의 성격에 따라 대응이 달라질 수 있습니다.
- 비즈니스 검증 실패: 예외 대신 결과 타입으로 처리하면 롤백 없는 조기 종료가 가능
- DB 제약 위반: 롤백 후 상위로 전파, 혹은 사용자 메시지로 변환
- 네트워크/타임아웃: 롤백 후 재시도 대상일 수 있음
중요한 포인트는 롤백 여부를 호출자에게 맡기지 말고 트랜잭션 경계에서 결정하는 것입니다. 호출자가 롤백을 “잊어버릴” 가능성을 없애야 합니다.
실무 디테일 2: 중첩 트랜잭션과 세이브포인트
서비스 코드가 커지면 “이미 트랜잭션 안에서 또 트랜잭션을 열고 싶은” 요구가 생깁니다. 이때 단순히 또 with transaction(...)을 열면 같은 커넥션을 재사용할지, 새 커넥션을 빌릴지 애매해집니다.
일반적으로는 다음 중 하나를 선택합니다.
- 정책 A: 트랜잭션은 최상위에서만 열고, 하위 함수는 커넥션을 인자로 받는다
- 정책 B: 중첩은 세이브포인트로 처리한다
PostgreSQL 기준으로 세이브포인트를 이용한 패턴은 다음과 같습니다.
import uuid
from contextlib import contextmanager
@contextmanager
def savepoint(conn):
sp_name = "sp_" + uuid.uuid4().hex
cur = conn.cursor()
cur.execute(f"savepoint {sp_name}")
try:
yield
cur.execute(f"release savepoint {sp_name}")
except Exception:
cur.execute(f"rollback to savepoint {sp_name}")
raise
사용 예시는 다음과 같습니다.
def update_profile_with_optional_log(conn, user_id, payload):
cur = conn.cursor()
cur.execute("update users set name=%s where id=%s", (payload["name"], user_id))
# 로그 적재 실패는 전체 트랜잭션을 깨지 않게 하고 싶다
try:
with savepoint(conn):
cur.execute(
"insert into audit_logs(user_id, action) values(%s, %s)",
(user_id, "profile_update"),
)
except Exception:
# 감사 로그 실패는 삼키고 본 작업은 진행
pass
이 패턴은 “부분 실패를 허용”해야 하는 경우에 유용하지만, 남용하면 데이터 일관성 규칙이 흐려질 수 있습니다. 어떤 작업이 원자적으로 묶여야 하는지부터 합의하는 게 우선입니다.
실무 디테일 3: 재시도와 트랜잭션의 결합
serialization_failure 같은 오류는 재시도하면 성공할 수 있습니다. 다만 재시도는 반드시 트랜잭션 단위로 해야 합니다. 즉, “중간까지 쓴 것”을 이어서 하는 게 아니라, 롤백 후 처음부터 다시 실행해야 합니다.
import time
TRANSIENT_ERRORS = ("40001", "40P01") # 예: serialization_failure, deadlock_detected
def run_with_retry(pool, fn, max_attempts=3, base_sleep=0.05):
for attempt in range(1, max_attempts + 1):
try:
with transaction(pool) as conn:
return fn(conn)
except Exception as e:
# 드라이버별로 SQLSTATE 접근 방식이 다르므로 프로젝트에 맞게 수정
sqlstate = getattr(e, "pgcode", None)
if sqlstate in TRANSIENT_ERRORS and attempt < max_attempts:
time.sleep(base_sleep * (2 ** (attempt - 1)))
continue
raise
여기서 중요한 점은 fn(conn)이 멱등성(idempotency) 을 만족하도록 설계하는 것입니다. 예를 들어 insert를 재시도하면 중복이 생길 수 있으니, 유니크 키로 방지하거나 upsert를 고려해야 합니다.
SQLAlchemy로 구현하는 정석 패턴
SQLAlchemy는 이미 트랜잭션 컨텍스트 매니저를 제공합니다. 그럼에도 “팀 표준 형태”로 감싸두면 일관성이 좋아집니다.
Engine과 Session 기반
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("postgresql+psycopg2://user:pass@host/db")
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
@contextmanager
def session_scope():
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
사용:
def create_user(email: str):
with session_scope() as s:
s.execute(
"insert into users(email) values(:email)",
{"email": email},
)
SQLAlchemy 2.0 스타일을 쓰면 engine.begin() 자체가 컨텍스트 매니저로 동작합니다.
from sqlalchemy import text
with engine.begin() as conn:
conn.execute(text("update counters set v = v + 1"))
# 정상 종료 시 commit, 예외 시 rollback
운영 관점: 롤백이 자주 일어나면 무엇을 봐야 하나
자동 롤백 패턴을 적용하면 “실패가 조용히 처리되는” 게 아니라, 실패가 더 명확히 드러납니다. 그 다음은 운영 지표를 붙이는 일입니다.
- 롤백 횟수, 원인 예외 타입 분포
- 재시도 횟수
- 트랜잭션 평균 시간, p95/p99
- 데드락/직렬화 실패 비율
PostgreSQL을 쓰는 경우, 트랜잭션이 길어지면 bloat나 vacuum 지연으로 이어질 수 있습니다. 트랜잭션 패턴을 정리한 뒤에는 DB 상태도 함께 점검하는 편이 좋습니다. 관련해서는 PostgreSQL VACUUM 안먹힘? bloat 진단·해결에서 진단 포인트를 참고할 수 있습니다.
흔한 실수 체크리스트
1) except에서 예외를 삼키고 커밋해버림
다음처럼 작성하면, 내부에서 예외가 발생했는데도 바깥에서 정상 종료로 인식되어 커밋될 수 있습니다.
def bad(pool):
with transaction(pool) as conn:
try:
conn.cursor().execute("insert into t(v) values(1)")
raise RuntimeError("boom")
except Exception:
# 여기서 삼키면 with 블록은 정상 종료로 간주
pass
이 경우는 블록 내부에서 예외를 삼키지 말고, 정말로 부분 실패를 허용해야 한다면 앞서 설명한 세이브포인트 같은 명시적 구조로 바꾸는 게 안전합니다.
2) 트랜잭션 안에서 외부 API 호출
트랜잭션을 길게 잡으면 락이 오래 유지되고 충돌이 늘어납니다. 외부 API 호출은 지연/실패 가능성이 높아 트랜잭션과 결합하면 위험합니다.
권장 구조:
- 외부 API 호출은 트랜잭션 밖에서 수행
- DB에는 “요청 기록”을 남기고 비동기 처리(아웃박스 패턴 등)
3) 커넥션을 전역으로 들고 있음
컨텍스트 매니저는 “빌리고, 쓰고, 반납”을 강제하기 위한 장치입니다. 커넥션을 전역 싱글톤처럼 들고 있으면 이 장점이 사라집니다.
결론: 트랜잭션은 제어 흐름이 아니라 자원이다
Context Manager로 DB 트랜잭션을 감싸는 자동 롤백 패턴은 단순히 코드가 깔끔해지는 수준이 아니라, 다음을 동시에 해결합니다.
- 예외 안전성 확보
- 커밋/롤백 규칙의 표준화
- 커넥션 풀 누수 방지
- 재시도 및 세이브포인트 같은 고급 패턴 확장 용이
팀 코드베이스에서 트랜잭션 처리가 제각각이라면, 우선 “표준 컨텍스트 매니저”를 하나 만들고 모든 쓰기 작업을 그 경계 안으로 끌어들이는 것부터 시작하는 것이 가장 비용 대비 효과가 큽니다.