[실전 Django - database 정복기 4부] 트랜잭션과 Celery의 미묘한 관계: "왜 알림이 안 가죠?"

2025. 12. 12. 17:38·ServerDev/DJango

 

 

 

 

네, 시리즈의 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입니다. 즉, "커밋이 완료된 데이터만 다른 트랜잭션에서 읽을 수 있다"는 뜻입니다.

상황을 타임라인으로 풀어보겠습니다.

  1. [Web] signup_user 함수 시작 (트랜잭션 시작)
  2. [Web] User.objects.create 실행. (DB에는 데이터가 들어갔지만, 아직 Uncommitted 상태)
  3. [Web] send_welcome_email.delay(user.id) 실행. Celery 브로커(Redis 등)에 메시지 전달.
  4. [Worker] Celery 워커가 메시지를 낚아채서 즉시 작업 시작.
  5. [Worker] User.objects.get(id=user.id) 실행. (문제 발생!)
    • 이 시점에 웹 서버의 트랜잭션은 아직 3번 이후의 로직(Profile 생성 등)을 처리 중이거나 커밋 직전일 수 있습니다.
    • DB 입장에서는 웹 서버의 트랜잭션이 아직 커밋되지 않았으므로, 워커(다른 트랜잭션)에게 "그런 데이터 없는데?"라고 응답합니다.
  6. [Worker] User.DoesNotExist 에러 발생 및 작업 실패.
  7. [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

동작 원리

  1. transaction.on_commit을 만나면 Django는 람다 함수를 메모리 리스트에 저장해둡니다. (아직 Celery에 요청 안 보냄)
  2. signup_user 함수의 모든 로직이 끝나고, atomic 블록이 끝나면서 DB에 Commit이 일어납니다.
  3. Commit이 성공하는 순간, Django는 저장해둔 콜백 함수들을 실행합니다.
  4. 이제야 send_welcome_email.delay가 실행되고 Celery로 메시지가 갑니다.
  5. 워커가 작업을 받아서 조회할 때는 이미 DB에 데이터가 확실히 존재합니다.

5. on_commit의 숨겨진 장점: 롤백 방어

transaction.on_commit을 사용해야 하는 또 다른 중요한 이유가 있습니다. 바로 트랜잭션 롤백 시나리오입니다.

기존 코드(즉시 delay 호출)에서는 이런 문제가 발생할 수 있습니다.

  1. 유저 생성 (User.create)
  2. 이메일 발송 요청 (delay) -> 이메일 발송됨
  3. 프로필 생성 (Profile.create) -> 에러 발생!
  4. 트랜잭션 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)은 각자의 시간선에서 움직입니다.

 

 

  1. 트랜잭션 안에서 Celery 태스크를 바로 호출(delay)하면 Race Condition으로 인해 DoesNotExist 에러가 날 수 있다.
  2. 트랜잭션이 롤백되어도 태스크는 실행되어버리는 부작용이 있다.
  3. transaction.on_commit을 사용하면 트랜잭션이 성공적으로 끝난 후에만 태스크를 실행하므로, 데이터 정합성과 논리적 오류를 모두 잡을 수 있다.

이제 데이터 정합성(1편), 조회 성능(2편), 쓰기 성능(3편), 그리고 비동기 작업의 안정성(4편)까지 확보했습니다.

하지만 아직 2% 부족합니다. 아무리 쿼리를 줄이고 최적화해도, "데이터베이스가 데이터를 찾는 속도 자체"가 느리다면 소용이 없습니다. 수백만 건의 데이터 속에서 원하는 범인을 0.01초 만에 찾아내는 마법, 바로 인덱스(Index)입니다.

다음 포스팅 ["쿼리가 느리다면 답은 정해져 있다"] 에서는 개발자들이 자주 헷갈리는 복합 인덱스(Composite Index)의 컬럼 순서 비밀과 실행 계획(Explain) 분석법에 대해 다루겠습니다.

 

'ServerDev > DJango' 카테고리의 다른 글

[실전 Django - database 정복기 5부] 인덱스(Index) 설계: "쿼리가 느리다면 답은 정해져 있다"  (0) 2025.12.12
[실전 Django - database 정복기 3부] 대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"  (0) 2025.12.12
[실전 Django - database 정복기 2부] 기능은 똑같은데 왜 쿼리가 100방이나 나가죠? (N+1 문제 해결)  (0) 2025.12.12
[실전 Django - database 정복기 1부 ] ORM의 배신, 그리고 select_for_update로 해결한 동시성 이슈  (0) 2025.12.12
'ServerDev/DJango' 카테고리의 다른 글
  • [실전 Django - database 정복기 5부] 인덱스(Index) 설계: "쿼리가 느리다면 답은 정해져 있다"
  • [실전 Django - database 정복기 3부] 대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"
  • [실전 Django - database 정복기 2부] 기능은 똑같은데 왜 쿼리가 100방이나 나가죠? (N+1 문제 해결)
  • [실전 Django - database 정복기 1부 ] ORM의 배신, 그리고 select_for_update로 해결한 동시성 이슈
yeseul-kim01
yeseul-kim01
  • yeseul-kim01
    슬 개발일지
    yeseul-kim01
  • 전체
    오늘
    어제
    • 분류 전체보기 (79)
      • 자격증 (1)
        • 정보보안기사 (0)
      • DevOps (17)
        • Docker (6)
        • Kubernetes (1)
        • GitHub Actions (0)
        • AWS (4)
        • Monitoring (1)
        • Nginx (1)
        • GCP (3)
      • ServerDev (34)
        • SpringBoot (13)
        • DJango (5)
        • FastAPI (14)
        • Next (0)
        • Flask (0)
        • Database (2)
      • Algorithm (2)
        • BFS (0)
        • DFS (1)
        • 다익스트라 (0)
      • CS (8)
      • Data Engineering (1)
      • AI&MLOps (2)
      • Architecture (6)
      • Software Engineering (0)
        • Library Packaging (0)
      • Project (5)
        • docx-generator (0)
        • speak-note (2)
        • ms-serving (1)
        • keyshield (2)
      • ProgrammingLanguages (3)
        • Python (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    KeyShield
    프로젝트기록-speaknote
    SpringBoot
    실시간시스템
    하이브리드아키텍처
    FastAPI
    depends
    KServe
    비동기처리
    멀티모듈
    rag
    아키텍처
    MLops
    NLP부트캠프
    동시성제어
    multipartfile
    STT
    백엔드
    아키텍처설계
    Kubernetes
    di
    프로젝트기록-KeyShield
    실무일기-인프라편
    Django
    실무일기-백엔드편
    SpeakNote
    docker
    FastAPI - CORS 마스터
    트러블슈팅
    grpc
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
yeseul-kim01
[실전 Django - database 정복기 4부] 트랜잭션과 Celery의 미묘한 관계: "왜 알림이 안 가죠?"
상단으로

티스토리툴바