transaction.on_commit으로 큐 enqueue를 감싸면 롤백 시 고아 잡을 막는다
·수정 1회
요약
- DB row 저장과 잡 enqueue를 한 트랜잭션에서 처리할 때, enqueue를 바로 실행하면 커밋 실패 시 row는 없는데 잡만 큐에 남는 "고아 잡(orphan job)"이 생긴다.
transaction.on_commit은 콜백을 커밋 성공 후에만 실행하도록 등록하므로, 롤백 시 enqueue 자체가 일어나지 않아 고아 잡을 막는다.
본문
고아 잡(orphan job)이란
큐에는 들어가 있는데 정작 그 잡이 참조해야 할 DB 데이터가 없는 잡. 예약 실행 잡이 record_id로 row를 찾으려 했는데 그 row가 롤백되어 존재하지 않는 상황. "부모 없는 자식"이라 orphan이라 부른다.
문제가 생기는 흐름
@transaction.atomic 블록 안에서는 커밋이 블록이 끝날 때까지 미뤄진다. 그런데 잡 enqueue를 그 안에서 즉시 실행하면:
@transaction.atomic
def save(...):
obj.save() # INSERT 실행 (아직 커밋 안 됨)
enqueue_job(obj.pk) # 🚨 커밋 전에 큐에 잡 발사
# ... return 이후 블록 종료 시 COMMIT
enqueue와 최종 커밋 사이에서 커밋이 실패하면(LogEntry 등 후속 DB 쓰기 실패, deadlock victim, 커넥션 단절 등) → DB는 롤백되어 row가 안 생기는데, 큐에 들어간 잡은 외부 시스템이라 롤백 대상이 아니다 → 고아 잡 발생.
on_commit이 닫는 부분
transaction.on_commit(
lambda: enqueue_job(obj.pk)
)
on_commit은 호출 시점엔 콜백을 리스트에 등록만 한다 (그래서lambda:로 지연 평가 필수).- 가장 바깥 트랜잭션이 실제 COMMIT에 성공한 직후에만 콜백을 실행한다.
- 롤백되면 콜백은 폐기 → enqueue 자체가 안 일어남.
- 결과적으로 row가 확정된 경우에만 잡이 큐에 들어간다. 즉시 실행 잡이라면 "워커가 커밋 전에 row를 조회해 실패"하는 race도 같이 해소된다.
남는 갭 (실패 창을 옮길 뿐)
on_commit은 "DB 커밋 ↔ enqueue"를 완전한 원자성으로 묶지 않는다. 실패 방향을 반대로 옮긴다:
- 커밋은 성공했는데
on_commit콜백 안의 enqueue가 실패 → 이번엔 "잃어버린 잡(lost job)": row는 있는데 실행 잡은 없음. - 즉 고아 잡(잘못 실행) 을 잃어버린 잡(미실행) 으로 트레이드한다. 재화 지급 같은 작업에선 "잘못 지급"보다 "미지급(운영자가 인지·재시도)"이 안전한 방향이라 합리적.
- 완전한 일관성이 필요하면 Transactional Outbox 패턴은 DB와 외부 시스템 간 일관성을 보장한다로 가야 한다.
on_commit은 그 경량 버전.
사례 — 관리자 재화 예약 지급 (SQS → Celery 전환)
- 기존: admin
save_related에서 SQS로 메시지를 즉시 발송 → 롤백 시 고아 메시지 가능. - 전환 후: 사내 비동기 큐 예약 잡으로 바꾸면서 enqueue를
on_commit으로 감쌈. - enqueue 시점(on_commit)과 별개로, 실행 UseCase는
select_for_update+executed_at가드로 중복 실행 시 중복 지급을 막는다. 두 장치가 서로 다른 레이어(등록 시점 / 실행 시점)를 담당. → SELECT FOR UPDATE는 행 수준 잠금을 획득한다
테스트에서의 함정
@pytest.mark.django_db(TestCase류)는 각 테스트를 트랜잭션으로 감싸 끝에 롤백한다 → 커밋이 안 일어나므로 on_commit 콜백도 실행되지 않는다. enqueue까지 검증하려면:
django_capture_on_commit_callbacksfixture로 콜백 강제 실행, 또는TransactionTestCase(transaction=True)로 실제 커밋 발생시키기.