데이터베이스를 죽이는 “Traffic Jam”(그리고 해결 방법)

발행: (2025년 12월 22일 오후 04:54 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

위에 제공된 소스 링크 외에 번역할 텍스트가 포함되어 있지 않습니다. 번역을 원하는 본문을 알려주시면 한국어로 번역해 드리겠습니다.

문제

완벽한 뱅킹 API를 작성했습니다. 로직은 탄탄하고, 수학은 완벽하며, 단위 테스트는 항상 통과합니다.
그런 다음 배포합니다.

트래픽이 급증합니다. 두 사용자가 서로에게 돈을 동시에 보내려고 합니다. 갑자기 로그에 빨간색 텍스트가 폭발합니다:

Deadlock detected.

데이터베이스가 수학 버그 때문에 충돌한 것이 아니라, 타이밍 버그 때문에 충돌했습니다.

데이터베이스 교착 상태는 이렇게 보인다

A deadlock is essentially a standoff in a western movie.

두 친구, 앨리스와 밥을 상상해 보세요.
그들은 정확히 같은 밀리초에 서로에게 $10을 보내기로 합니다.

DB 내부에서 일어나는 일

트랜잭션작업잠금
A (Alice → Bob)SELECT … FOR UPDATE on Alice’s rowAlice 잠금
Needs to lock Bob’s row to depositBob 대기
B (Bob → Alice)SELECT … FOR UPDATE on Bob’s rowBob 잠금
Needs to lock Alice’s row to depositAlice 대기
  • 트랜잭션 ABob을 기다립니다.
  • 트랜잭션 BAlice를 기다립니다.

어느 쪽도 진행할 수 없습니다 → 순환 대기 → 교착 상태.

최신 DB 엔진은 이 순환을 감지하고 트랜잭션 중 하나를 종료시켜, 사용자에게 일반적인 500 Internal Server Error를 반환합니다.

“안전하지 않은” 이체 엔드포인트

우리는 FastAPIpsycopg를 사용합니다. 안전하지 않은 버전에서는 보내는 사람을 먼저 잠그고, 그 다음 받는 사람을 잠급니다 – 논리적으로는 맞아 보이지만 위험합니다.

# unsafe_transfer.py
@app.post("/transfer/unsafe")
def transfer_unsafe(req: TransferRequest):
    with pool.connection() as conn:
        with conn.cursor() as cur:
            # 1️⃣ Lock the sender's account
            cur.execute(
                "SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
                (req.from_account,)
            )
            current_balance = cur.fetchone()[0]

            if current_balance  Bob
[3] ❌ DEADLOCK: Bob -> Alice
[2] ✅ Success: Alice -> Bob
[5] ❌ DEADLOCK: Bob -> Alice
[4] ✅ Success: Alice -> Bob
...
Success:   6
Deadlocks: 14

데이터베이스는 DeadlockDetected 예외를 발생시키고 트랜잭션을 롤백하며, 돈은 이동되지 않습니다. 사용자는 화가 납니다.

이 문제를 더 빠른 하드웨어로 해결할 수 없습니다.
SQL을 “최적화”해서도 해결할 수 없습니다.
해결 방법은 일관된 락 순서를 적용하는, 즉 기하학적인 접근입니다.

해결책: 전역 잠금 순서 강제

사이클을 방지하려면 모든 트랜잭션이 정확히 같은 순서로 잠금을 획득해야 합니다.
가장 간단한 규칙: 항상 ID가 더 작은 계정을 먼저 잠급니다.

왜 작동하는가

시나리오잠금 순서 (안전)
Alice (ID 1) → Bob (ID 2)Lock 1 → Lock 2
Bob (ID 2) → Alice (ID 1)Lock 1 → Lock 2

두 트랜잭션은 이제 같은 첫 번째 잠금(ID 1)을 두고 경쟁합니다. 하나는 획득하고, 다른 하나는 대기합니다. 순환 대기가 없으므로 데드락이 발생하지 않습니다.

안전한 이체 엔드포인트

# safe_transfer.py
@app.post("/transfer/safe")
def transfer_safe(req: TransferRequest):
    # Determine lock order: low ID first, high ID second
    first_lock_id  = min(req.from_account, req.to_account)
    second_lock_id = max(req.from_account, req.to_account)

    with pool.connection() as conn:
        with conn.cursor() as cur:
            # 1️⃣ Lock accounts in a fixed, consistent order
            cur.execute(
                "SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
                (first_lock_id,)
            )
            # Even with a delay, the second transaction is just WAITING for the first lock.
            time.sleep(0.1)

            cur.execute(
                "SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
                (second_lock_id,)
            )

            # 2️⃣ Now that we have both locks, we can safely check balances and transfer.
            # (Insert the same balance‑check & UPDATE logic as before)
            # …
            conn.commit()

유일한 변경점: SELECT … FOR UPDATE를 실행하기 전에 두 ID를 정렬하는 것. 나머지는 동일하게 유지됩니다.

Fix 적용 후 결과

/transfer/safe동일한 attack.py 스크립트를 실행하면 다음과 같은 결과가 나옵니다:

--- Starting Attack ---
Mode: SAFE (Fixed)
Users: 8
[1] ✅ Success: Alice -> Bob
[2] ✅ Success: Bob -> Alice
[3] ✅ Success: Alice -> Bob
[4] ✅ Success: Bob -> Alice
...
Success:  16
Deadlocks: 0

두 번째 트랜잭션이 첫 번째 락을 기다려야 하므로 지연 시간이 약간 늘어나지만, **신뢰성은 100 %**이며—데드락도 없고, 돈도 손실되지 않으며, 사용자가 화내는 일도 없습니다.

주요 내용

  1. 데드락은 논리 버그가 아니라 타이밍 버그입니다.
  2. FOR UPDATE는 강력하지만 락 순서가 일관되지 않으면 위험합니다.
  3. 한 줄 해결책: ID(또는 비교 가능한 리소스 식별자)를 정렬하고 항상 그 순서대로 락을 잡으세요.
  4. 동시성을 테스트하세요! attack.py와 같은 스크립트를 사용해 엔드포인트가 동시에 요청을 견디는지 확인하세요.

결정적인 락 순서를 강제함으로써 데드락을 일으키는 순환 의존성을 없앨 수 있으며, 여러분의 뱅킹 API는 실제 트래픽 급증 상황에서도 견고해집니다. 🚀

8

- ✅ Success: Bob → Alice  
- ✅ Success: Alice → Bob  
- ✅ Success: Bob → Alice  
- ✅ Success: Alice → Bob  
-

**Success:** 20  
**Deadlocks:** 0  

Zero crashes. Perfect consistency.  

Deadlocks aren’t random bad luck. They are predictable consequences of inconsistent locking.  
If you touch multiple rows in a single transaction, always touch them in the same order.  
Sort your IDs. Save your database.
Back to Blog

관련 글

더 보기 »