
서비스를 개발하다 보면 이런 상황을 마주칩니다:
이 모든 문제의 핵심은 트랜잭션(Transaction) 관리입니다. 이 글에서는 Django의 트랜잭션 메커니즘을 코드 레벨에서 이해하고, 실무에서 발생하는 데이터 정합성 문제를 해결하는 방법을 다룹니다.
트랜잭션은 데이터베이스에서 "전부 성공하거나, 아니면 아무것도 안 한 것처럼 되돌리거나" 해야 하는 작업 단위입니다.
# 트랜잭션 없이 작업하면...
my_account.balance -= 10000 # 1. 내 계좌에서 1만 원 차감
my_account.save()
friend_account.balance += 10000 # 2. 친구 계좌에 1만 원 추가
friend_account.save() # ⚠️ 여기서 에러 발생하면?
만약 2번 작업에서 에러가 발생하면 내 돈만 사라지는 대참사가 발생합니다. 트랜잭션을 사용하면 2번이 실패했을 때 1번 작업도 자동으로 취소(Rollback)됩니다.
Django는 기본적으로 AUTOCOMMIT=True 상태로 동작합니다. 이는 각 쿼리가 실행되는 즉시 데이터베이스에 반영된다는 의미입니다.
user = User.objects.create(username='test') # 즉시 DB에 반영
user.profile.create(bio='Hello') # 즉시 DB에 반영
각 작업이 독립적으로 커밋되므로, 중간에 에러가 발생해도 이전 작업은 롤백되지 않습니다.
@transaction.atomic을 통해 원자성 보장하기@transaction.atomic은 Django에서 트랜잭션을 관리하는 핵심 도구입니다. "전부 아니면 전무(All or Nothing)" 원칙을 코드로 구현할 수 있습니다.
함수 전체를 하나의 트랜잭션으로 묶습니다.
from django.db import transaction
@transaction.atomic
def transfer_money(sender, receiver, amount):
# 1. 송신자 잔고 감소
sender.balance -= amount
sender.save()
# 2. 수신자 잔고 증가
receiver.balance += amount
receiver.save()
# 이 함수 안에서 에러가 발생하면 모든 작업이 취소됩니다
코드의 특정 부분만 트랜잭션으로 묶고 싶을 때 사용합니다.
from django.db import transaction
def update_user_profile(user, data):
# 트랜잭션 밖의 작업
user.last_login = now()
user.save()
try:
with transaction.atomic():
# 이 블록 안의 코드만 원자적으로 처리됨
user.profile.update(data)
user.settings.update(data)
except Exception as e:
# 에러 발생 시 profile/settings 수정은 취소됨
logger.error(f"프로필 업데이트 실패: {e}")
@transaction.atomic(using='replica', savepoint=True, durable=False)
결제는 무조건 성공해야 하는데, 로그 저장은 실패해도 상관없는 경우가 있을 수 있습니다. 이럴 때 중첩 트랜잭션을 활용할 수 있습니다.
결제는 반드시 성공해야 하지만, 알림 로그는 실패해도 괜찮은 경우
with transaction.atomic(): # 최외곽 트랜잭션
order.process_payment() # 핵심 로직
try:
with transaction.atomic(): # 내부 트랜잭션 (Savepoint)
send_notification_log() # 부차적 작업
except Exception:
pass # 로그 실패는 무시하고 결제는 유지
with transaction.atomic():
user = User.objects.create_user(username='test1')
with transaction.atomic():
UserProfile.objects.create(user=user, bio='Hello')

=== Case 1: 외부 성공 / 내부 성공 ===
--- Case 1 실행된 SQL 쿼리 ---
[1] (0.000s) BEGIN
[2] (0.005s) INSERT INTO "auth_user" ("password", "last_login", "is_superuser", "username", "first_name", "last_name", "email", "is_staff", "is_active", "date_joined") VALUES ('pbkdf2_sha256$1200000$4Yzf9eiHm77VB0QrUKhzDY$vFzW9w3wj2igghwi1kbgCYbdpHdh2WPzwKsAaREKka4=', NULL, false, 'testuser1', '', '', 'test1@example.com', false, true, '2026-01-04T12:38:54.568809+00:00'::timestamptz) RETURNING "auth_user"."id"
[3] (0.002s) SAVEPOINT "s8624328512_x1"
[4] (0.003s) INSERT INTO "transactions_userprofile" ("user_id", "bio", "phone", "created_at") VALUES (1, 'Test bio', '010-1234-5678', '2026-01-04T12:38:54.772432+00:00'::timestamptz) RETURNING "transactions_userprofile"."id"
[5] (0.001s) RELEASE SAVEPOINT "s8624328512_x1"
[6] (0.003s) COMMIT
[7] (0.001s) SELECT 1 AS "a" FROM "auth_user" WHERE "auth_user"."username" = 'testuser1' LIMIT 1
[8] (0.002s) SELECT 1 AS "a" FROM "transactions_userprofile" INNER JOIN "auth_user" ON ("transactions_userprofile"."user_id" = "auth_user"."id") WHERE "auth_user"."username" = 'testuser1' LIMIT 1
✓ Case 1 결과: User와 UserProfile 모두 정상 생성됨
결과: User와 UserProfile 모두 생성됨
try:
with transaction.atomic():
user = User.objects.create_user(username='test2')
with transaction.atomic():
UserProfile.objects.create(user=user)
raise ValueError("내부 실패!")
except ValueError:
pass

--- Case 2 실행된 SQL 쿼리 ---
[1] (0.000s) BEGIN
[2] (0.009s) INSERT INTO "auth_user" ("password", "last_login", "is_superuser", "username", "first_name", "last_name", "email", "is_staff", "is_active", "date_joined") VALUES ('pbkdf2_sha256$1200000$ow623hjDlUR5xD8BlIAaca$rMHRBbI2Rr0LNFAcjW/Z6LDCVmu3wWSKdgmsZTMXeP0=', NULL, false, 'testuser2', '', '', 'test2@example.com', false, true, '2026-01-04T12:38:54.892914+00:00'::timestamptz) RETURNING "auth_user"."id"
[3] (0.001s) SAVEPOINT "s8624328512_x2"
[4] (0.003s) INSERT INTO "transactions_userprofile" ("user_id", "bio", "phone", "created_at") VALUES (2, 'Test bio', '010-1234-5678', '2026-01-04T12:38:55.098904+00:00'::timestamptz) RETURNING "transactions_userprofile"."id"
[5] (0.001s) ROLLBACK TO SAVEPOINT "s8624328512_x2"
[6] (0.001s) RELEASE SAVEPOINT "s8624328512_x2"
[7] (0.001s) ROLLBACK
[8] (0.005s) SELECT 1 AS "a" FROM "auth_user" WHERE "auth_user"."username" = 'testuser2' LIMIT 1
[9] (0.003s) SELECT 1 AS "a" FROM "transactions_userprofile" INNER JOIN "auth_user" ON ("transactions_userprofile"."user_id" = "auth_user"."id") WHERE "auth_user"."username" = 'testuser2' LIMIT 1
✓ Case 2 결과: 내부 실패 시 전체 롤백됨 (User도 생성되지 않음)
결과: User와 UserProfile 모두 생성되지 않음 (전체 롤백)
with transaction.atomic():
user = User.objects.create_user(username='test3')
try:
with transaction.atomic():
UserProfile.objects.create(user=user)
raise ValueError("내부 실패!")
except ValueError:
pass # 예외를 여기서 처리

=== Case 3: 내부 실패 후 try-except로 에러 처리 ===
예외 처리됨: 프로필 생성 실패!
User 존재 여부: True
UserProfile 존재 여부: False
--- Case 3 실행된 SQL 쿼리 ---
[1] (0.000s) BEGIN
[2] (0.006s) INSERT INTO "auth_user" ("password", "last_login", "is_superuser", "username", "first_name", "last_name", "email", "is_staff", "is_active", "date_joined") VALUES ('pbkdf2_sha256$1200000$Wf7HTPk228iex60rpfC2O2$TvXANGH6+XOVe+GkN9T3FE/HLNMmRWXAuT8MktT9/wM=', NULL, false, 'testuser3', '', '', 'test3@example.com', false, true, '2026-01-04T12:38:55.218632+00:00'::timestamptz) RETURNING "auth_user"."id"
[3] (0.001s) SAVEPOINT "s8624328512_x3"
[4] (0.003s) INSERT INTO "transactions_userprofile" ("user_id", "bio", "phone", "created_at") VALUES (3, 'Test bio', '010-1234-5678', '2026-01-04T12:38:55.422083+00:00'::timestamptz) RETURNING "transactions_userprofile"."id"
[5] (0.001s) ROLLBACK TO SAVEPOINT "s8624328512_x3"
[6] (0.001s) RELEASE SAVEPOINT "s8624328512_x3"
[7] (0.001s) COMMIT
[8] (0.003s) SELECT 1 AS "a" FROM "auth_user" WHERE "auth_user"."username" = 'testuser3' LIMIT 1
[9] (0.002s) SELECT 1 AS "a" FROM "transactions_userprofile" INNER JOIN "auth_user" ON ("transactions_userprofile"."user_id" = "auth_user"."id") WHERE "auth_user"."username" = 'testuser3' LIMIT 1
✓ Case 3 결과: User는 생성되고, UserProfile만 롤백됨 (Savepoint 사용)
결과: User는 생성되고, UserProfile만 롤백됨 (부분 롤백)
Case 2는 내부 에러가 외부 atomic 블록까지 그대로 전달되어 Django가 트랜잭션 전체를 실패로 간주하고 유저 생성까지 모두 롤백합니다. 반면 Case 3는 try-except가 내부 에러를 중간에 가로채 소멸시켰기 때문에 외부 atomic 블록은 에러를 인지하지 못하고 유저 데이터를 정상적으로 커밋합니다.
결과적으로 예외가 외부 트랜잭션의 관리 범위에 도달했는지 여부에 따라 전체 취소와 부분 취소가 결정됩니다.
예외가 외부 트랜잭션까지 전파되는가?
트랜잭션 내에서 이메일 발송 같은 외부 서비스를 호출할 때 발생하는 문제와 해결 방법입니다.
@transaction.atomic
def register_user_naive(email, password):
user = User.objects.create_user(email=email, password=password)
send_mail('환영합니다!', '가입을 축하합니다.', [email])
raise ValueError("회원가입 실패!")
def test_naive_approach_with_exception(self):
"""
Given: register_user_naive() 함수가 트랜잭션 커밋 전에 이메일을 발송하도록 구현됨
When: 회원가입 중 예외가 발생하여 트랜잭션이 롤백됨
Then: DB는 롤백되었지만 이메일은 이미 발송됨 (정합성 문제)
"""
reset_queries()
print("\n=== 1단계: 동기 처리와 트랜잭션의 함정 ===")
# Given: 회원가입 시도
email = 'naive@example.com'
password = 'testpass123'
# When: register_user_naive() 호출 (함수 내부에서 예외 발생)
try:
register_user_naive(email, password)
except ValueError as e:
print(f"✗ 예외 발생: {e}")
# Then: 결과 확인
user_exists = User.objects.filter(email=email).exists()
email_sent = len(mail.outbox) > 0
# SQL 쿼리 출력
self._print_sql_queries("실행된 SQL 쿼리")
print(f"\n--- 결과 분석 ---")
print(f"DB 롤백 여부 (User 생성 안됨): {not user_exists}")
print(f"이메일 발송 여부: {email_sent}")
if not user_exists and email_sent:
print("\n문제 확인: DB는 롤백되었지만 이메일은 발송됨")
# 검증
self.assertFalse(user_exists, "User는 롤백되어야 함")
self.assertTrue(email_sent, "이메일은 발송됨 (문제!)")

DB는 롤백되지만 이메일은 이미 발송된 상태입니다.
@transaction.atomic
def register_user_on_commit(email, password):
user = User.objects.create_user(email=email, password=password)
transaction.on_commit(lambda: send_mail('환영합니다!', '...', [email]))

트랜잭션이 커밋된 후에만 이메일을 발송합니다. 롤백되면 이메일도 발송되지 않습니다.
단점: 이메일 발송이 끝날 때까지 응답이 지연됩니다.
@shared_task
def send_welcome_email_task(user_id):
user = User.objects.get(id=user_id)
send_mail('환영합니다!', '...', [user.email])
@transaction.atomic
def register_user_async(email, password):
user = User.objects.create_user(email=email, password=password)
transaction.on_commit(
lambda: send_welcome_email_task.delay(user.id)
)
사용자는 즉시 응답을 받고, 이메일은 백그라운드에서 처리됩니다.
주의: on_commit 없이 바로 delay() 호출 시 Celery worker가 DB 커밋보다 먼저 실행될 수 있습니다.
@dataclass
class UserRegistered:
user_id: int
email: str
class EventPublisher:
_listeners = {}
@classmethod
def subscribe(cls, event_type, listener):
if event_type not in cls._listeners:
cls._listeners[event_type] = []
cls._listeners[event_type].append(listener)
@classmethod
def publish(cls, event):
transaction.on_commit(
lambda: process_event_task.delay(event.event_type, asdict(event))
)
def register_user_event(email, password):
with transaction.atomic():
user = User.objects.create_user(email=email, password=password)
EventPublisher.publish(UserRegistered(user.id, user.email))
def on_user_registered(event):
send_welcome_email_task.delay(event.user_id)
create_marketing_log_task.delay(event.user_id)
EventPublisher.subscribe('user.registered', on_user_registered)
서비스 로직과 부가 작업이 완전히 분리됩니다. 새로운 작업 추가 시 기존 코드 수정이 불필요합니다.
실제 Production 환경에서는 Outbox Pattern을 사용해 이벤트를 DB에 트랜잭션과 함께 저장하나 해당 내용은 현재 포스팅에서 다루지 않습니다.
참고 자료: