현대의 웹 애플리케이션은 수많은 사용자가 동시에 데이터에 접근하고 수정합니다. 이러한 환경에서 여러 사용자가 동시에 같은 데이터를 수정하려고 할 때, "동시성 문제"가 발생할 수 있습니다.
가장 대표적인 문제는 "Lost Update" 현상입니다. 두 사용자 A와 B가 동시에 같은 데이터를 읽고, 각자 수정한 후 저장하면, 나중에 저장한 사용자 B의 변경사항이 사용자 A의 변경사항을 덮어써버리는 현상이 발생합니다.
이러한 문제를 해결하기 위해 데이터베이스와 ORM 시스템은 크게 두 가지 접근 방식을 제공합니다
낙관적 잠금(Optimistic Locking)? 비관적 잠금(Pessimistic Locking)?
- 낙관적 잠금(Optimistic Locking): 충돌이 거의 발생하지 않을 것이라 낙관적으로 가정하고, 문제가 발생했을 때 처리
- 비관적 잠금(Pessimistic Locking): 충돌이 발생할 것이라 비관적으로 가정하고, 미리 데이터를 잠금
말로만 들어선 잘 이해가 안될 수 있습니다. 실생활에서 이해하기 쉽도록 한번 풀어볼게요!
낙관적 잠금은 도서관에서 책을 빌리는 상황과 비슷합니다
- 가족 모임을 계획하기 위해 공유 스프레드시트가 있습니다. 여기에 각자 가능한 날짜와 선호하는 메뉴를 적습니다.
- 가족 구성원들은 같은 스프레드시트에 자유롭게 접근하여 정보를 읽고 수정합니다.
- 각 구성원이 수정을 시작할 때, 문서의 "마지막 수정일시"를 확인합니다.
- 수정을 완료하고 저장하기 전에, 스프레드시트는 시작할 때 확인한 "마지막 수정일시"와 현재의 값을 비교합니다.
- 만약 그 사이에 다른 가족 구성원이 문서를 수정했다면(마지막 수정일시가 변경됨), "다른 사람이 이미 문서를 변경했습니다. 최신 버전을 확인하고 변경사항을 다시 적용해주세요"라는 메시지가 표시됩니다.
- 이 방식은 모든 가족이 언제든 문서에 접근할 수 있지만, 충돌이 발생했을 때만 해결 과정이 필요합니다.
비관적 잠금은 중요 문서를 편집하는 회의실과 비슷합니다
- 회사의 중요 계약서를 여러 부서가 검토하고 수정해야 합니다.
- 누군가 문서를 검토하기 위해 회의실에 들어가면, 그 사람이 나올 때까지 문에 "회의 중" 팻말을 걸어둡니다(읽기/쓰기 잠금).
- 다른 사람들은 팻말을 보고 회의가 끝날 때까지 기다려야 합니다.
- 회의실에 들어간 사람은 방해 없이 문서를 독점적으로 수정할 수 있습니다.
아래 설명할 내용은 아래 Git 주소에서 확인 할 수 있습니다.
트랜잭션과 잠금의 기본
먼저 관련 핵심 개념을 이해하는 것이 중요합니다
트랜잭션(Transaction): 데이터베이스에서 하나의 논리적 작업 단위를 의미합니다. ACID(원자성, 일관성, 격리성, 지속성) 특성을 갖습니다.
잠금(Lock): 데이터에 대한 동시 접근을 제어하는 메커니즘입니다.
격리 수준(Isolation Level): 트랜잭션이 다른 트랜잭션에게 어느 정도 영향을 받는지 결정하는 수준입니다.
낙관적 잠금(Optimistic Locking)
낙관적 잠금은 이름 그대로 "낙관적"인 접근 방식입니다. 기본 가정은 "충돌이 드물게 발생할 것! 에이~ 설마 동시에 수정하려고 하겠어?!"이라는 것입니다. 데이터를 읽을 때 잠금을 하지 않고, 수정 시점에 해당 데이터가 다른 사용자에 의해 변경되었는지 확인합니다. 마치 Google Docs, Google Spreadsheets와 유사하죠??
이제 예시 코드와 함께 이해해보도록해요
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=100)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.PositiveIntegerField()
version = models.PositiveIntegerField(default=0) # 버전 필드
def save(self, *args, **kwargs):
# 새 객체가 아니라면 버전 증가
if self.pk is not None:
self.version += 1
super().save(*args, **kwargs)
낙관적 잠금을 구현하기 위해 주로 "version" 필드를 사용합니다. 모델에 버전 필드를 추가하고, 데이터가 수정될 때마다 이 값을 증가시킵니다.
# optimistic_shell.py
from django.db import transaction
from django.db.models import F
from optimistic.models import Product
class OptimisticLockException(Exception):
pass
def update_product(product_id, user_data, current_version):
try:
with transaction.atomic():
# 버전을 조건으로 업데이트 시도
rows_updated = Product.objects.filter(
id=product_id,
version=current_version
).update(
name=user_data['name'],
price=user_data['price'],
stock=user_data['stock'],
version=F('version') + 1 # 버전 증가
)
if rows_updated == 0:
# 업데이트된 행이 없으면 충돌이 발생했다는 의미
raise OptimisticLockException("다른 사용자가 이미 데이터를 수정했습니다.")
return Product.objects.get(id=product_id)
except OptimisticLockException as e:
# 충돌 발생 시 처리 로직
# 예: 최신 데이터 가져와서 사용자에게 재시도 유도
latest_product = Product.objects.get(id=product_id)
print(f"충돌 발생: {e}")
print(f"최신 버전: {latest_product.version}")
return latest_product
# 테스트용 제품 생성
product = Product.objects.create(name="테스트 상품", price=10000, stock=100)
# 정상 케이스 테스트
user_data = {
'name': '업데이트된 상품',
'price': 12000,
'stock': 90
}
result = update_product(product.id, user_data, product.version)
print(f"업데이트 성공: {result.name}, 가격: {result.price}, 재고: {result.stock}, 버전: {result.version}")
# 충돌 케이스 테스트 (다른 사용자가 이미 수정한 상황 가정)
# 이전 버전을 사용해 업데이트 시도
old_version = 0 # 이전 버전
user_data = {
'name': '다른 업데이트',
'price': 15000,
'stock': 80
}
result = update_product(product.id, user_data, old_version)
print(f"충돌 후 최신 상태: {result.name}, 가격: {result.price}, 재고: {result.stock}, 버전: {result.version}")
사진과 같이 충돌 당시의 로그를 확인 할 수 있습니다. 즉, 낙관적 잠금의 핵심은 업데이트 시 조건을 추가하여 충돌을 감지하는 것입니다.
그럼 과연 낙관적 잠금의 장단점은 뭘까요??
장점
- 동시 요청이 많아도 잠금으로 인한 대기 시간이 없어 높은 처리량을 제공
- 데드락(deadlock) 상황이 발생하지 않음
- 읽기 작업에 제약을 두지 않아 읽기 성능이 좋음
Version 필드를 활용하여 현재 데이터가 최신인지 아닌지를 판단하기 때문에 DB레벨에서의 잠금으로 인해 다른 트랜잭션이 대기하지 않게되고, 이를 통해 대기 시간이 없다는게 가장 큰 장점이라 생각되요!
단점
- 충돌 발생 시 재시도 로직 구현이 필요(ex. retry,,,로직)
- 동일 데이터에 대한 수정이 많은 경우 충돌 빈도가 높아 성능 저하 가능
비관적 잠금(Pessimistic Locking)
비관적 잠금은 "충돌이 발생할 것"이라고 비관적으로 가정하고, 데이터를 읽을 때부터 잠금을 획득하는 방식입니다. 이는 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 방지합니다.
실제 비즈니스 사례에는 어떤 경우가 있을까요??
뱅킹 시스템의 계좌 거래
- 사용자가 출금, 송금 등의 거래를 시작하면 계좌에 즉시 잠금이 설정됩니다.
- 거래가 완료될 때까지 다른 거래는 대기 상태가 됩니다.
- 이를 통해 두 개의 출금이 동시에 처리되어 잔액이 마이너스가 되는 상황을 방지합니다.
- 모든 금융 거래가 순차적으로 처리되어 데이터 일관성이 보장됩니다.
아래 예시 코드로 한번 확인해볼까요?
# pessimistic_shell.py
from django.db import transaction
from pessimistic.models import Product
def update_product_stock(product_id, quantity):
with transaction.atomic():
# SQL문 예시: SELECT ... FOR UPDATE 구문으로 해당 레코드에 잠금 획득
product = Product.objects.select_for_update().get(id=product_id)
# Lock을 걸어두고 product에 접근
if product.stock >= quantity:
product.stock -= quantity
product.save()
return True
else:
# 재고 부족
return False
기본적으로 select_for_update()는 잠금이 해제될 때까지 기다립니다. 그러나 무한정 기다리는 것은 위험할 수 있으므로, 타임아웃을 설정할 수 있습니다 -> 저는 단순 예제라서 제외했습니다.
그럼 비관적 잠금의 장단점은 뭘까요??
장점
- 충돌을 사전에 방지하여 데이터 일관성 보장
- 재시도 로직이 필요 없음 -> 다른 트랜잭션이 대기 후 잠금이 풀리면 update를 진행하기 때문이죠!
- 트랜잭션 격리 수준이 높아 데이터 무결성 보장
단점
- 잠금으로 인한 대기 시간 발생으로 처리량 저하 가능
- 부적절한 사용 시 데드락 발생 가능
- 데이터베이스 리소스 사용량 증가
마무리
성능 비교
일반적인 경우, 낙관적 잠금은 잠금 획득/해제 오버헤드가 없어 높은 동시성 환경에서 더 좋은 성능을 보입니다. 하지만 충돌이 많이 발생하는 환경에서는 재시도로 인한 부담이 커질 수 있습니다.
비관적 잠금은 적은 수의 사용자가 높은 빈도로 동일 데이터에 접근할 때 더 효율적일 수 있으며, 데이터 일관성이 가장 중요한 경우에 적합합니다.
실제로 두 방식 모두 데이터의 정합성을 유지하는 방식인데 왜! 왜?! 비관적 잠금의 방식이 일관성 유지에 더 적합하다고 할까요??
이유는 비관적 잠금이 더 엄격한 방식으로 정합성을 보장하기 때문입니다.
비관적 잠금은 데이터베이스 강제성을 통해 일관성을 보장하지만, 낙관적 잠금은 특정 시점에서만 확인합니다. 즉, 낙관적 잠금은 코드가 일관성을 확인하고 관리해야 합니다. 처음 낙관적 잠금의 코드를 보면 읽기 요청은 언제나 가능하지만, 쓰기 요청에서 version을 확인하는 프로세스로 데이터를 업데이트했죠? 반면에 비관적 잠금은 데이터베이스 레벨에서 잠금을 걸기 때문에 충돌자체를 방지합니다. 보장 시점의 차이죠!
'프로그래밍 > Django' 카테고리의 다른 글
Django ORM 성능 최적화: F() 표현식과 Q() 객체 완벽 가이드 (0) | 2025.04.15 |
---|---|
Django Multi-Tenancy: SaaS 서비스에서 기업별 데이터 분리하기 (0) | 2025.04.15 |
Django ORM 완벽 가이드: 기본부터 심화까지 Django-ORM CheatSheet!! (0) | 2025.04.14 |
Django에서 비즈니스 로직 분리하기: 유지보수 가능한 코드의 비결 (1) | 2025.04.13 |
🚀 Django REST Framework (DRF) - Serializer가 Response를 만들기까지 Serializer Core에 대하여 (1) | 2025.02.27 |