[실전 Django - database 정복기 1부 ] ORM의 배신, 그리고 select_for_update로 해결한 동시성 이슈

2025. 12. 12. 16:48·ServerDev/DJango

 

Django는 ORM이 너무 편해서 개발자가 SQL이나 데이터베이스의 락(Lock) 개념을 잊어버리기 쉬운데, 서비스 규모가 커지면 이 부분에서 반드시 사고가 터집니다.

 

실무에서 Django를 쓰다 보면, 로컬 혼자 개발할 땐 절대 안 터지는데 사용자가 몰리는 운영 환경에서만 유령처럼 나타나는 버그들이 있습니다. 그중 가장 대표적이고 치명적인 것이 바로 동시성(Concurrency) 이슈입니다.

 

오늘부터 [실전 Django]라는 주제로, 교과서에는 잘 나오지 않지만 실무에서 반드시 마주치게 되는 Django의 매운맛 이슈들을 하나씩 정리해 보려 합니다.

그 첫 번째 이야기는, 포인트 결제 시스템을 만들며 겪었던 '데이터 덮어쓰기(Race Condition)' 문제와 이를 select_for_update로 해결한 과정입니다.

 

"포인트 결제 시스템에서 발생한 따닥(Race Condition) 문제 해결기"를 주제로 잡아보겠습니다. 실제로 제가 겪었던 고민의 흐름대로 아주 길고 자세하게 작성했습니다.

 


 

Django ORM의 배신, 그리고 select_for_update로 해결한 동시성 이슈

처음 Django를 접했을 때 가장 감탄했던 것은 ORM이었습니다. SQL 한 줄 작성하지 않고도 파이썬 객체 다루듯이 데이터베이스를 조작할 수 있다는 건 마법 같았으니까요. 하지만 서비스 트래픽이 늘어나고, 특히 돈이나 포인트가 오가는 중요한 로직을 다루게 되면서 이 편리함 뒤에 숨겨진 거대한 함정을 마주하게 되었습니다. 바로 동시성 문제입니다.

오늘은 로컬 환경에서는 절대 발생하지 않다가, 운영 환경에서만 간헐적으로 데이터가 틀어지는 유령 같은 버그를 잡기 위해 제가 고민했던 과정과, Django가 제공하는 강력한 락(Lock) 매커니즘인 select_for_update에 대해 깊이 있게 이야기해보려 합니다.

 

평화로워 보였던 코드의 함정

상황은 간단했습니다. 사용자가 보유한 포인트를 차감하는 로직이었습니다. 초기 코드는 정말 단순했습니다. 사용자 모델을 가져오고, 현재

포인트를 확인한 뒤, 차감할 금액보다 많으면 값을 줄이고 저장한다. 비즈니스 로직상 아무런 문제가 없어 보입니다.

 

def deduct_point(user_id, amount):
    user = User.objects.get(id=user_id)

    if user.point >= amount:
        user.point -= amount
        user.save()
        return True
    else:
        raise ValueError("잔액이 부족합니다.")

 

 

이 코드는 테스트 코드에서도 완벽하게 통과했습니다. 하지만 마케팅 이벤트로 트래픽이 몰리던 날, 이상한 제보가 들어왔습니다. 분명 1만 포인트밖에 없던 사용자가 동시에 두 개의 상품을 구매해서 잔액이 마이너스가 되거나, 혹은 차감이 한 번만 이루어진 것입니다.

이른바 경쟁 상태(Race Condition)가 발생한 것입니다.

서버가 요청을 하나씩 순서대로 처리해주면 좋겠지만, 실제 운영 환경은 멀티 스레드 혹은 멀티 프로세스 환경입니다. 두 개의 요청이 거의 동시에 들어왔을 때, A 요청이 데이터를 읽고 아직 저장하지 않은 찰나의 순간에 B 요청이 데이터를 읽어갑니다.

A 요청: "현재 포인트 10000원. 5000원 깎아서 5000원으로 저장."
B 요청: "현재 포인트 10000원(A가 아직 저장 안 함). 5000원 깎아서 5000원으로 저장."

결과적으로 사용자는 10000원을 썼지만, DB에는 5000원이 남게 됩니다. 회사의 손실로 이어지는 치명적인 버그입니다.

 

 

첫 번째 시도: F() 객체를 이용한 원자적 연산

 

가장 먼저 떠오른 해결책은 Django의 F() 객체였습니다. 파이썬 메모리로 데이터를 가져와서 연산하는 것이 아니라, 데이터베이스 레벨에서 연산을 수행하도록 쿼리를 날리는 방식입니다.

from django.db.models import F

def deduct_point(user_id, amount):
    user = User.objects.get(id=user_id)

    if user.point >= amount:
        user.point = F('point') - amount
        user.save()

 

 

F() 객체를 사용하면 UPDATE user SET point = point - amount ... 형태의 SQL이 실행되므로, 데이터베이스가 직접 값을 수정합니다. 동시성 문제의 상당 부분을 해결할 수 있는 가볍고 좋은 방법입니다.

하지만 이 방식에는 한계가 있었습니다. 바로 '비즈니스 로직 검증'이 어렵다는 점입니다. 위의 코드에서 if user.point >= amount 부분은 여전히 파이썬 메모리에 있는 값을 기준으로 합니다. 업데이트 쿼리가 날아가는 시점의 실제 DB 값은 이미 변했을 수도 있습니다.

물론 update() 메서드와 필터링을 조합해서 User.objects.filter(id=user_id, point__gte=amount).update(point=F('point') - amount) 처럼 작성할 수도 있습니다. 하지만 이 방식은 업데이트 후 변경된 정확한 잔액을 알기 위해 다시 조회를 해야 하거나, 잔액 부족 시 명시적인 예외를 발생시키기보다는 단순히 업데이트된 행(row)의 개수가 0개라는 것만 알 수 있어 로직 처리가 매끄럽지 않았습니다.

 

두 번째 시도: 비관적 락(Pessimistic Locking)과 select_for_update

 

 

결국 데이터의 정합성을 완벽하게 보장하기 위해서는 트랜잭션이 시작되고 데이터를 읽는 시점부터, 작업이 끝나고 저장하는 시점까지 다른 트랜잭션이 이 데이터를 건드리지 못하게 막아야 했습니다. 이를 비관적 락이라고 합니다. "누군가 데이터를 건드릴 것이다"라고 비관적으로 가정하고 아예 문을 잠가버리는 것이죠.

Django는 이를 위해 select_for_update()라는 메서드를 제공합니다.

 

 

from django.db import transaction

@transaction.atomic
def deduct_point(user_id, amount):
    # 여기서 락을 겁니다. 트랜잭션이 끝날 때까지 유효합니다.
    try:
        user = User.objects.select_for_update().get(id=user_id)
    except User.DoesNotExist:
        raise ValueError("사용자가 없습니다.")

    if user.point >= amount:
        user.point -= amount
        user.save()
    else:
        raise ValueError("잔액이 부족합니다.")

 

 

이 코드가 실행되면 Django는 SQL 쿼리 끝에 FOR UPDATE 구문을 붙여서 전송합니다. 데이터베이스는 해당 행(Row)에 락을 걸고, 이 트랜잭션이 커밋되거나 롤백되어 끝날 때까지 다른 트랜잭션이 이 행을 읽거나 수정하지 못하게 대기시킵니다.

이제 A 요청이 작업을 마칠 때까지 B 요청은 get() 단계에서 대기하게 되므로, 데이터 무결성이 완벽하게 보장됩니다.

 

select_for_update 사용 시 주의해야 할 옵션들

 

 

select_for_update를 사용할 때 고민해야 할 지점은 "락이 걸려있을 때 어떻게 할 것인가"입니다. 기본적으로는 무한정 대기하지만, 상황에 따라 다른 옵션이 필요할 수 있습니다.

 

nowait=True
이미 락이 걸려있다면 대기하지 않고 즉시 DatabaseError를 발생시킵니다. 사용자에게 "현재 처리 중입니다. 잠시 후 다시 시도해주세요"라고 빠르게 응답해야 하는 경우 유용했습니다.

 

skip_locked=True
락이 걸린 행은 결과에서 제외하고 나머지 행만 가져옵니다. 포인트 차감 같은 로직보다는, 여러 워커가 작업 큐 테이블에서 할 일을 하나씩 가져갈 때 유용합니다. 남이 하고 있는 건 건너뛰고 내가 할 것만 가져오면 되니까요.

 

of=(...)
여러 테이블을 조인해서 가져올 때, 특정 테이블의 행에만 락을 걸고 싶을 때 사용합니다. select_related와 함께 사용할 때 불필요하게 연관된 테이블까지 모두 잠그는 것을 방지하여 성능 저하를 막을 수 있었습니다.

 

데드락(Deadlock)이라는 또 다른 악몽

 

 

락을 도입해서 데이터 정합성 문제는 해결했지만, 개발자의 숙명처럼 또 다른 문제가 찾아왔습니다. 바로 교착 상태, 데드락입니다.

서로 다른 두 트랜잭션이 서로가 놓아주기를 기다리며 영원히 멈춰버리는 현상입니다.

트랜잭션 1: 유저 A 잠금 -> 유저 B 잠금 시도 (대기)
트랜잭션 2: 유저 B 잠금 -> 유저 A 잠금 시도 (대기)

이 문제는 포인트 선물하기 기능처럼 두 개의 레코드를 동시에 잠가야 할 때 발생했습니다. 이를 해결하기 위해 제가 선택한 방법은 '잠그는 순서의 통일'이었습니다. 항상 ID가 작은 순서대로, 혹은 알파벳 순서대로 락을 걸도록 로직을 강제함으로써 사이클이 생기는 것을 방지했습니다.

# ID 순서로 정렬하여 락을 거는 방식으로 데드락 예방
users = User.objects.select_for_update().filter(id__in=[id1, id2]).order_by('id')

마치며

사실 동시성 문제를 해결하는 방법에는 비관적 락만 있는 것은 아닙니다. 데이터에 버전(version) 필드를 두고 저장할 때 버전이 맞는지 확인하는 낙관적 락(Optimistic Locking) 방식도 있습니다. 충돌이 자주 발생하지 않는다면 낙관적 락이 성능상 유리할 수 있습니다.

하지만 결제나 포인트처럼 충돌 빈도는 낮더라도 단 한 번의 실패가 데이터 무결성에 치명적인 영향을 주는 도메인에서는, 다소 성능 비용이 들더라도 select_for_update를 통한 강력한 제어가 정답에 가깝다고 생각합니다.

 

Django ORM은 편리하지만, 그 편리함이 데이터베이스의 동작 원리를 몰라도 된다는 뜻은 아닙니다. 오히려 ORM이 생성하는 쿼리가 DB 락에 어떤 영향을 미칠지 상상할 수 있어야, 트래픽이 몰아치는 운영 환경에서도 튼튼하게 버티는 서비스를 만들 수 있다는 것을 그때 뼈저리게 느꼈습니다.

 

동시성 제어로 데이터의 무결성을 지켰다면, 다음은 속도를 챙길 차례입니다. Django를 쓰다 보면 기능은 문제없는데 API가 이유 없이 느려지는 경우가 많습니다. 범인은 대부분 쿼리가 수백 번 날아가는 N+1 문제죠.

 

다음 포스팅 [[실전 Django - database 정복기 2부] "제 API가 왜 느린가 했더니 쿼리가 100방 나가요" (N+1 문제 해결)] 에서는 select_related와 prefetch_related를 제대로 구분해서 쓰는 법에 대해 다뤄보겠습니다.

 

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

[실전 Django - database 정복기 5부] 인덱스(Index) 설계: "쿼리가 느리다면 답은 정해져 있다"  (0) 2025.12.12
[실전 Django - database 정복기 4부] 트랜잭션과 Celery의 미묘한 관계: "왜 알림이 안 가죠?"  (0) 2025.12.12
[실전 Django - database 정복기 3부] 대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"  (0) 2025.12.12
[실전 Django - database 정복기 2부] 기능은 똑같은데 왜 쿼리가 100방이나 나가죠? (N+1 문제 해결)  (0) 2025.12.12
'ServerDev/DJango' 카테고리의 다른 글
  • [실전 Django - database 정복기 5부] 인덱스(Index) 설계: "쿼리가 느리다면 답은 정해져 있다"
  • [실전 Django - database 정복기 4부] 트랜잭션과 Celery의 미묘한 관계: "왜 알림이 안 가죠?"
  • [실전 Django - database 정복기 3부] 대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"
  • [실전 Django - database 정복기 2부] 기능은 똑같은데 왜 쿼리가 100방이나 나가죠? (N+1 문제 해결)
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)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
yeseul-kim01
[실전 Django - database 정복기 1부 ] ORM의 배신, 그리고 select_for_update로 해결한 동시성 이슈
상단으로

티스토리툴바