Django 교착 상태 대응: 잠금 순서와 SQLSTATE 기반 재시도 설계
A 계좌에서 B로 이체하는 요청과 B에서 A로 이체하는 요청이 동시에 들어온다. 각각 출금 계좌부터 잠그면 서로 상대가 가진 잠금을 기다릴 수 있다. 이 문제는 atomic()을 더 넓게 감싸서 해결되지 않는다. 잠금 순서와 실패 후 재실행 경계를 함께 정해야 한다.
기준: Django 5.2 · PostgreSQL 17 · psycopg 3. 아래 재시도 함수는 외부 트랜잭션이 없는 최상위 서비스 진입점용이다.
기다림과 교착은 다르다
하나의 트랜잭션이 다른 하나의 완료를 기다리는 것은 정상적인 잠금 경합일 수 있다. 교착은 대기 관계가 순환하는 경우다. PostgreSQL은 교착을 감지하면 참여 트랜잭션 중 하나를 중단한다. 어느 요청이 실패할지 업무 코드가 의존해서는 안 된다. PostgreSQL 잠금 문서
가장 먼저 모든 경로가 동일한 순서로 잠금을 획득하도록 맞춘다. 단순 두 계좌 예제에서는 PK 정렬을 사용할 수 있다.
from django.db import transaction
def lock_transfer_accounts(source_id, target_id):
if source_id == target_id:
raise ValueError("different accounts required")
with transaction.atomic():
accounts = {}
for account_id in sorted([source_id, target_id]):
accounts[account_id] = (
Account.objects.select_for_update().get(pk=account_id)
)
# 여기서 금액·통화·잔액 검사와 두 계좌 변경을 모두 수행한다.
apply_transfer(accounts[source_id], accounts[target_id])
이 코드는 잠금 규약을 보여 주는 골격이다. apply_transfer()가 다른 테이블도 수정한다면 그 테이블의 잠금 순서까지 일관되게 정해야 한다. FK 검사와 트리거, 별도 관리자 작업도 잠금을 획득할 수 있으므로 “정렬했으니 교착이 절대 없다”라고 결론 내릴 수는 없다.
재시도는 롤백이 끝난 뒤 시작한다
PostgreSQL의 40001은 직렬화 실패, 40P01은 교착 검출이다. 이런 오류는 실패한 SQL만 다시 보내는 것이 아니라 판단에 사용한 읽기를 포함한 전체 트랜잭션을 다시 실행해야 한다. 공식 재시도 지침
import random
import time
from django.db import OperationalError, connection, transaction
def retry_transaction(work, attempts=3):
if attempts < 1:
raise ValueError("attempts must be positive")
if connection.in_atomic_block:
raise RuntimeError("must be called outside an existing transaction")
for attempt in range(attempts):
try:
with transaction.atomic():
return work()
except OperationalError as exc:
cause = exc.__cause__
state = getattr(cause, "sqlstate", None)
if state not in {"40001", "40P01"} or attempt == attempts - 1:
raise
time.sleep(random.uniform(0, 0.05 * (2 ** attempt)))
except가 atomic 바깥에 있으므로 DB 롤백 후 다음 시도가 시작된다. psycopg 2에서는 오류 속성이 다를 수 있으니 실제 드라이버에 맞춰 확인한다. 연결 끊김이나 인증 오류, 모든 IntegrityError까지 무차별 재시도하지 않는다. 특히 커밋 성공 여부가 불명확한 네트워크 오류는 멱등성 기록을 통한 결과 확인이 필요하다.
재실행 가능한 함수에는 제약이 있다
work() 안에서 메일을 보내거나 외부 결제를 실행하면 첫 시도의 외부 효과가 롤백되지 않는다. DB 변경과 Outbox 기록까지만 처리하고 외부 전달은 별도 단계로 분리하는 편이 낫다.
재시도마다 모델을 다시 읽어야 한다. 바깥에서 읽어 둔 모델 인스턴스나 계산한 잔액을 클로저에 담으면 새 트랜잭션에서도 낡은 판단을 반복한다. Django는 DB가 롤백됐다고 Python 객체의 필드까지 원래 값으로 복원하지 않는다. Django 트랜잭션 문서
로그에는 오류보다 경쟁의 모양을 남긴다
업무 종류, 시도 번호, SQLSTATE, 트랜잭션 소요 시간, 관련 식별자의 안전한 표현을 기록한다. 실제 개인정보나 전체 SQL 파라미터를 무작정 남기지는 않는다. 재시도 후 성공률뿐 아니라 요청 지연의 상위 백분위와 잠금 대기 시간을 같이 본다.
검증에서는 두 연결이 반대 순서로 잠금을 잡도록 구성해 교착 오류를 재현한 다음, 순서 통일 후 같은 시나리오를 반복한다. 재시도 상한 도달 시 명확히 실패하고, 상위 요청의 제한 시간을 넘지 않는지도 확인한다. 재시도는 잠금 설계를 보완하는 장치이지, 무한 대기를 숨기는 장치가 아니다.
댓글
댓글 쓰기