
네, 시리즈의 4편입니다. 이번 주제는 실무에서 주니어뿐만 아니라 시니어 개발자들도 종종 겪는, "코드는 완벽한데 실행 타이밍 때문에 발생하는" 매우 까다로운 문제입니다. 로컬에서는 잘 되는데 트래픽이 있는 운영 환경에서만 간헐적으로 발생하는 유령 버그 를 잡아봅시다.
트랜잭션과 Celery의 미묘한 관계: "왜 알림이 안 가죠?"
1. 분명히 저장했는데 없대요
"회원가입을 했는데 환영 이메일이 안 와요."
"로그를 확인해보니 User matching query does not exist 에러가 떠 있습니다."
개발자는 억울합니다. 코드를 확인해봅니다. 분명히 User.objects.create()를 호출해서 유저를 만들었고, 그 바로 다음 줄에서 Celery 워커에게 "이 유저한테 이메일 보내"라고 명령(delay)을 내렸습니다.
논리적으로 유저가 없을 수가 없는 상황입니다. 그런데 Celery 워커는 유저를 못 찾겠답니다. 마치 귀신이 곡할 노릇이죠.
오늘은 Django의 트랜잭션(Transaction) 과 비동기 작업(Celery) 사이의 보이지 않는 경주(Race Condition) 때문에 발생하는 이 문제와, 이를 해결하는 transaction.on_commit에 대해 알아보겠습니다.
2. 흔한 실수 코드
우리는 보통 데이터 무결성을 위해 atomic을 사용하여 트랜잭션을 묶습니다. 그리고 무거운 작업(이메일 발송)은 사용자 응답 속도를 위해 Celery로 넘깁니다.
# views.py (문제가 발생하는 코드)
from django.db import transaction
from myapp.tasks import send_welcome_email
@transaction.atomic
def signup_user(data):
# 1. 유저 생성 (INSERT 쿼리 실행)
user = User.objects.create(
username=data['username'],
email=data['email']
)
# 2. Celery에 작업 요청 (비동기)
# user.id를 넘겨서 워커가 DB에서 조회하도록 함
send_welcome_email.delay(user.id)
# 3. 추가적인 로직 (예: 프로필 생성, 포인트 지급 등)
Profile.objects.create(user=user)
return user
# tasks.py (Celery 워커)
@shared_task
def send_welcome_email(user_id):
# 워커가 실행되는 시점
try:
user = User.objects.get(id=user_id) # 에러 발생 지점!
user.email_user("환영합니다!", "가입을 축하드려요.")
except User.DoesNotExist:
logger.error(f"User {user_id} not found!")
코드는 너무나 완벽해 보입니다. 생성하고, 보낸다. 무엇이 문제일까요?
3. 원인 분석: 데이터베이스의 격리 수준 (Isolation Level)
이 문제의 원인은 Django(웹 서버)의 트랜잭션이 커밋되는 시점과 Celery(워커)가 작업을 시작하는 시점의 미세한 차이(Race Condition) 때문입니다.
대부분의 RDBMS(PostgreSQL, MySQL)는 기본 격리 수준이 Read Committed입니다. 즉, "커밋이 완료된 데이터만 다른 트랜잭션에서 읽을 수 있다"는 뜻입니다.
상황을 타임라인으로 풀어보겠습니다.
- [Web]
signup_user함수 시작 (트랜잭션 시작) - [Web]
User.objects.create실행. (DB에는 데이터가 들어갔지만, 아직 Uncommitted 상태) - [Web]
send_welcome_email.delay(user.id)실행. Celery 브로커(Redis 등)에 메시지 전달. - [Worker] Celery 워커가 메시지를 낚아채서 즉시 작업 시작.
- [Worker]
User.objects.get(id=user.id)실행. (문제 발생!)- 이 시점에 웹 서버의 트랜잭션은 아직 3번 이후의 로직(
Profile생성 등)을 처리 중이거나 커밋 직전일 수 있습니다. - DB 입장에서는 웹 서버의 트랜잭션이 아직 커밋되지 않았으므로, 워커(다른 트랜잭션)에게 "그런 데이터 없는데?"라고 응답합니다.
- 이 시점에 웹 서버의 트랜잭션은 아직 3번 이후의 로직(
- [Worker]
User.DoesNotExist에러 발생 및 작업 실패. - [Web] 함수 종료, 트랜잭션 Commit. (이제야 데이터가 보임)
결국 Celery 워커가 너무 부지런해서 웹 서버가 일을 마치기도 전에 데이터를 찾으러 갔기 때문에 발생한 문제입니다.
4. 해결책: transaction.on_commit
Django 1.9부터 이 문제를 해결하기 위한 축복 같은 기능인 transaction.on_commit이 추가되었습니다.
이 함수는 "현재 트랜잭션이 성공적으로 커밋된 직후에 이 함수를 실행해줘"라고 Django에게 부탁하는 콜백(Callback) 레지스트리입니다.
코드 적용 (수정된 버전)
# views.py (개선된 코드)
from django.db import transaction
from myapp.tasks import send_welcome_email
@transaction.atomic
def signup_user(data):
user = User.objects.create(
username=data['username'],
email=data['email']
)
# 지금 바로 실행하지 말고, 트랜잭션이 "커밋되면" 실행해라!
transaction.on_commit(
lambda: send_welcome_email.delay(user.id)
)
Profile.objects.create(user=user)
return user
동작 원리
transaction.on_commit을 만나면 Django는 람다 함수를 메모리 리스트에 저장해둡니다. (아직 Celery에 요청 안 보냄)signup_user함수의 모든 로직이 끝나고,atomic블록이 끝나면서 DB에 Commit이 일어납니다.- Commit이 성공하는 순간, Django는 저장해둔 콜백 함수들을 실행합니다.
- 이제야
send_welcome_email.delay가 실행되고 Celery로 메시지가 갑니다. - 워커가 작업을 받아서 조회할 때는 이미 DB에 데이터가 확실히 존재합니다.
5. on_commit의 숨겨진 장점: 롤백 방어
transaction.on_commit을 사용해야 하는 또 다른 중요한 이유가 있습니다. 바로 트랜잭션 롤백 시나리오입니다.
기존 코드(즉시 delay 호출)에서는 이런 문제가 발생할 수 있습니다.
- 유저 생성 (
User.create) - 이메일 발송 요청 (
delay) -> 이메일 발송됨 - 프로필 생성 (
Profile.create) -> 에러 발생! - 트랜잭션 Rollback -> 유저 데이터 삭제됨
결과는? 가입은 실패했는데 가입 환영 이메일은 받은 상황 이 됩니다. 사용자는 "어? 가입됐네?" 하고 로그인하려는데 없는 계정이라고 나오죠. 서비스 신뢰도에 치명적입니다.
transaction.on_commit을 사용하면, 중간에 에러가 나서 트랜잭션이 롤백될 경우 콜백 함수가 아예 실행되지 않습니다. 즉, 데이터가 저장된 것이 확실할 때만 후속 작업을 수행하도록 강제할 수 있습니다.
6. 심화: 테스트 코드 작성 시 주의사항
transaction.on_commit을 적용하고 나면, 갑자기 테스트 코드가 실패할 수 있습니다. Django의 기본 TestCase는 각 테스트를 트랜잭션으로 감싸고, 테스트가 끝나면 롤백하는 방식으로 격리성을 보장하기 때문입니다.
TestCase 안에서는 실제 커밋(Real Commit)이 일어나지 않습니다. 따라서 on_commit에 등록된 콜백이 영원히 실행되지 않는 문제가 발생합니다.
해결 방법 1: TransactionTestCase 사용
TestCase 대신 TransactionTestCase를 상속받으면 실제 DB에 커밋을 수행하므로 on_commit이 동작합니다. 하지만 테스트 속도가 느려지고 DB에 데이터가 남을 수 있습니다.
해결 방법 2: django-capture-on-commit-callbacks (추천)
가장 우아한 방법은 django-capture-on-commit-callbacks 라이브러리를 사용하는 것입니다.
from django.test import TestCase
from django_capture_on_commit_callbacks import capture_on_commit_callbacks
class SignupTest(TestCase):
def test_signup_email(self):
with capture_on_commit_callbacks(execute=True) as callbacks:
signup_user(data)
# on_commit이 호출되었는지, 태스크가 실행되었는지 검증 가능
self.assertEqual(len(callbacks), 1)
이 라이브러리를 사용하면 TestCase의 속도를 유지하면서도 on_commit 로직을 강제로 실행시켜 테스트할 수 있습니다.
7. 마치며: 타이밍이 전부다
개발자가 짠 코드는 순차적으로 실행되지만, 시스템의 구성 요소들(Web, DB, Worker)은 각자의 시간선에서 움직입니다.
- 트랜잭션 안에서 Celery 태스크를 바로 호출(
delay)하면 Race Condition으로 인해DoesNotExist에러가 날 수 있다. - 트랜잭션이 롤백되어도 태스크는 실행되어버리는 부작용이 있다.
transaction.on_commit을 사용하면 트랜잭션이 성공적으로 끝난 후에만 태스크를 실행하므로, 데이터 정합성과 논리적 오류를 모두 잡을 수 있다.
이제 데이터 정합성(1편), 조회 성능(2편), 쓰기 성능(3편), 그리고 비동기 작업의 안정성(4편)까지 확보했습니다.
하지만 아직 2% 부족합니다. 아무리 쿼리를 줄이고 최적화해도, "데이터베이스가 데이터를 찾는 속도 자체"가 느리다면 소용이 없습니다. 수백만 건의 데이터 속에서 원하는 범인을 0.01초 만에 찾아내는 마법, 바로 인덱스(Index)입니다.
다음 포스팅 ["쿼리가 느리다면 답은 정해져 있다"] 에서는 개발자들이 자주 헷갈리는 복합 인덱스(Composite Index)의 컬럼 순서 비밀과 실행 계획(Explain) 분석법에 대해 다루겠습니다.