본문으로 건너뛰기
데이터베이스 데드락 해결 가이드 - 실전 감지와 해결 전략

# 데이터베이스 데드락 해결 완벽 가이드: 실전 감지와 해결 전략

Table of Contents

데이터베이스 데드락은 실서비스에서 가장 골치 아픈 문제 중 하나입니다. 어느 날 갑자기 트랜잭션들이 서로를 무한정 기다리면서 시스템 전체가 멈춰버리죠. 개발할 때는 잘 안 생기다가, 트래픽 몰릴 때 터지는 게 데드락입니다. 이 글에서는 데드락이 뭔지부터 시작해서, 어떻게 찾고, 어떻게 해결하고, 아예 안 생기게 하는 방법까지 실전 경험을 바탕으로 모두 알려드립니다.

데드락이 뭔가요?

데드락은 간단히 말해서 두 개 이상의 트랜잭션이 서로 상대방이 가진 락을 기다리면서 영원히 멈춰버리는 상황입니다. 마치 좁은 길에서 두 차가 마주보고 섰는데, 둘 다 상대방이 비켜주기를 기다리는 것과 똑같죠.

실제 사례로 이해하기

온라인 쇼핑몰 시스템에서 이런 일이 벌어진다고 해봅시다:

-- 트랜잭션 A (사용자 주문)
BEGIN;
UPDATE accounts SET balance = balance - 50000 WHERE id = 1; -- 계좌 A 락
-- 여기서 잠깐 대기...
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100; -- 재고 B 락 대기
COMMIT;

-- 트랜잭션 B (재고 조정)
BEGIN;
UPDATE inventory SET stock = stock + 10 WHERE product_id = 100; -- 재고 B 락
-- 여기서 잠깐 대기...
UPDATE accounts SET balance = balance + 10000 WHERE id = 1; -- 계좌 A 락 대기
COMMIT;

뭐가 문제일까요?

  1. 트랜잭션 A가 계좌 A를 락 걸고 있음
  2. 트랜잭션 B가 재고 B를 락 걸고 있음
  3. 트랜잭션 A가 재고 B를 락 걸려고 대기 → B가 풀릴 때까지 무한 대기
  4. 트랜잭션 B가 계좌 A를 락 걸려고 대기 → A가 풀릴 때까지 무한 대기
  5. 둘 다 영원히 기다림 = 데드락!

실서비스에서 얼마나 심각한가?

데드락이 터지면 정말 난리 납니다. 우리 서비스에서 겪었던 실제 사례를 보여드리죠:

데드락 발생 전

  • 평균 응답 시간: 120ms
  • 동시 처리 트랜잭션: 500 TPS
  • 에러율: 0.1%
  • DB 커넥션 사용률: 40%
  • 고객 불만: 거의 없음

데드락 폭발 시 (피크 타임)

  • 평균 응답 시간: 8,500ms (70배 증가!)
  • 동시 처리 트랜잭션: 50 TPS (90% 감소!)
  • 에러율: 15% (150배 증가!)
  • DB 커넥션 사용률: 100% (고갈)
  • 고객 불만: CS 폭증, 매출 직격타

10분간 데드락이 계속되자 서비스가 거의 마비됐습니다. 고객은 결제가 안 되고, 주문은 씹히고, CS는 전화 폭탄 맞고… 이게 바로 데드락의 무서움입니다.

데드락 감지 방법

데드락을 해결하려면 먼저 찾아야겠죠? 각 DB마다 감지하는 방법이 좀 다릅니다.

PostgreSQL에서 데드락 감지

PostgreSQL은 자동으로 데드락을 감지합니다. deadlock_timeout 설정(기본 1초) 이후에 데드락 감지 알고리즘이 돌아가죠.

-- 현재 락 상태 확인
SELECT
 pid,
 usename,
 application_name,
 state,
 query,
 wait_event_type,
 wait_event
FROM pg_stat_activity
WHERE wait_event IS NOT NULL;

-- 락 대기 중인 쿼리 찾기
SELECT
 blocked_locks.pid AS blocked_pid,
 blocked_activity.usename AS blocked_user,
 blocking_locks.pid AS blocking_pid,
 blocking_activity.usename AS blocking_user,
 blocked_activity.query AS blocked_statement,
 blocking_activity.query AS blocking_statement
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
 ON blocking_locks.locktype = blocked_locks.locktype
 AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
 AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
 AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
 AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
 AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
 AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
 AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
 AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
 AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
 AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

데드락 발생 시 PostgreSQL 로그에 이렇게 찍힙니다:

ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 54321.
Process 54321 waits for ShareLock on transaction 12345; blocked by process 12345.
HINT: See server log for query details.

MySQL에서 데드락 감지

MySQL/InnoDB도 자동으로 데드락을 감지하고 해결합니다.

-- 최근 데드락 정보 확인
SHOW ENGINE INNODB STATUS\G

-- InnoDB 락 상태 확인
SELECT * FROM information_schema.INNODB_LOCKS;

-- 트랜잭션 대기 상태
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 현재 실행 중인 트랜잭션
SELECT * FROM information_schema.INNODB_TRX;

MySQL 8.0 이상에서는 더 상세한 모니터링이 가능합니다:

-- Performance Schema를 사용한 락 모니터링
SELECT
 r.trx_id AS waiting_trx,
 r.trx_mysql_thread_id AS waiting_thread,
 r.trx_query AS waiting_query,
 b.trx_id AS blocking_trx,
 b.trx_mysql_thread_id AS blocking_thread,
 b.trx_query AS blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
INNER JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;

데드락 자동 해결 전략

다행히 모던 DB들은 데드락을 자동으로 해결합니다. 하지만 어떻게 해결하는지 알아야 대응할 수 있죠.

희생자(Victim) 선택

DB는 데드락을 감지하면 하나를 “희생자”로 골라서 강제로 롤백시킵니다. 이때 이런 기준으로 선택하죠:

  1. 트랜잭션 크기: 작은 트랜잭션을 희생 (롤백 비용이 적으니까)
  2. 우선순위: 낮은 우선순위 트랜잭션 희생
  3. 실행 시간: 짧게 실행된 트랜잭션 희생
  4. 변경 사항: 적게 변경한 트랜잭션 희생
# 애플리케이션에서 데드락 재시도 로직 구현
import time
from sqlalchemy.exc import OperationalError

def execute_with_deadlock_retry(session, max_retries=3):
 """데드락 발생 시 자동 재시도"""
 for attempt in range(max_retries):
 try:
 # 트랜잭션 실행
 session.execute(your_query)
 session.commit()
 return True
 except OperationalError as e:
 if "deadlock detected" in str(e).lower():
 if attempt < max_retries - 1:
 # 지수 백오프로 재시도
 wait_time = (2 ** attempt) * 0.1
 time.sleep(wait_time)
 session.rollback()
 continue
 else:
 raise
 else:
 raise
 return False

타임아웃 설정

데드락 감지 타임아웃을 적절히 설정하는 것도 중요합니다:

-- PostgreSQL: 데드락 감지 타임아웃 설정
SET deadlock_timeout = '1s'; -- 기본값

-- MySQL: 락 대기 타임아웃
SET innodb_lock_wait_timeout = 50; -- 초 단위, 기본 50초

주의사항: 타임아웃을 너무 짧게 하면 정상적인 락 대기도 실패하고, 너무 길면 데드락 감지가 늦어집니다. 보통 1~5초가 적당해요.

데드락 예방 전략

데드락은 해결보다 예방이 백 배 낫습니다. 실전에서 효과 본 예방 기법들입니다.

1. 락 순서 통일

가장 중요한 원칙입니다. 모든 트랜잭션이 같은 순서로 테이블/로우에 접근하면 데드락이 안 생깁니다.

# 잘못된 방식 - 순서가 제각각
def transfer_money_bad(from_id, to_id, amount):
 # 순서가 from_id, to_id 순
 db.execute(f"UPDATE accounts SET balance = balance - {amount} WHERE id = {from_id}")
 db.execute(f"UPDATE accounts SET balance = balance + {amount} WHERE id = {to_id}")

# 올바른 방식 - 항상 ID 순서대로
def transfer_money_good(from_id, to_id, amount):
 # 항상 작은 ID부터 락
 first_id = min(from_id, to_id)
 second_id = max(from_id, to_id)

 if from_id < to_id:
 db.execute(f"UPDATE accounts SET balance = balance - {amount} WHERE id = {from_id}")
 db.execute(f"UPDATE accounts SET balance = balance + {amount} WHERE id = {to_id}")
 else:
 db.execute(f"UPDATE accounts SET balance = balance + {amount} WHERE id = {to_id}")
 db.execute(f"UPDATE accounts SET balance = balance - {amount} WHERE id = {from_id}")

2. 트랜잭션 짧게 유지

트랜잭션이 길면 길수록 데드락 확률이 올라갑니다. 빠르게 들어가서 빠르게 나와야 합니다.

# 나쁜 예 - 트랜잭션 안에서 느린 작업
def process_order_bad(order_id):
 with db.begin():
 order = db.query("SELECT * FROM orders WHERE id = %s FOR UPDATE", order_id)

 # 외부 API 호출 (느림!)
 payment_result = payment_api.charge(order['amount'])

 # 이메일 발송 (느림!)
 send_email(order['user_email'], "주문 완료")

 # 재고 감소
 db.execute("UPDATE inventory SET stock = stock - 1 WHERE product_id = %s", order['product_id'])

 db.commit()

# 좋은 예 - 빠른 DB 작업만 트랜잭션 안에
def process_order_good(order_id):
 # 먼저 읽기만
 order = db.query("SELECT * FROM orders WHERE id = %s", order_id)

 # 외부 작업은 트랜잭션 밖에서
 payment_result = payment_api.charge(order['amount'])

 # 짧은 트랜잭션으로 업데이트만
 with db.begin():
 db.execute("UPDATE inventory SET stock = stock - 1 WHERE product_id = %s", order['product_id'])
 db.execute("UPDATE orders SET status = 'completed' WHERE id = %s", order_id)
 db.commit()

 # 이메일은 나중에
 send_email(order['user_email'], "주문 완료")

3. 적절한 격리 수준 사용

격리 수준이 높을수록 락이 많아지고 데드락 위험이 커집니다. 꼭 필요한 만큼만 쓰세요.

-- READ COMMITTED: 가장 많이 쓰는 기본 설정 (PostgreSQL 기본값)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- REPEATABLE READ: 더 강한 일관성 필요할 때 (MySQL 기본값)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- SERIALIZABLE: 꼭 필요할 때만 (데드락 위험 최고)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

대부분의 경우 READ COMMITTED면 충분합니다. 은행 이체 같은 중요한 작업에만 REPEATABLE READ를 쓰세요.

4. 인덱스 최적화

인덱스가 없으면 테이블 전체를 스캔하면서 불필요한 락을 잡습니다. 데드락 위험이 폭증하죠.

-- 인덱스 없으면 전체 테이블 락
UPDATE orders SET status = 'completed'
WHERE user_id = 12345 AND created_at > '2025-01-01';

-- 인덱스 있으면 필요한 로우만 락
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);

UPDATE orders SET status = 'completed'
WHERE user_id = 12345 AND created_at > '2025-01-01';

5. SELECT FOR UPDATE 신중하게

FOR UPDATE는 강력하지만 남용하면 데드락 원인이 됩니다.

-- 불필요한 FOR UPDATE
BEGIN;
SELECT * FROM products WHERE id = 100 FOR UPDATE; -- 읽기만 하는데 락?
--... 긴 로직...
COMMIT;

-- 꼭 필요할 때만
BEGIN;
SELECT stock FROM products WHERE id = 100; -- 그냥 읽기
-- 재고 확인
UPDATE products SET stock = stock - 1 WHERE id = 100; -- 업데이트할 때만 락
COMMIT;

모니터링과 알림

데드락을 빨리 발견하려면 모니터링이 필수입니다.

Prometheus + Grafana로 모니터링

from prometheus_client import Counter, Histogram

# 데드락 발생 카운터
deadlock_counter = Counter('db_deadlocks_total', 'Total number of deadlocks')

# 재시도 히스토그램
retry_histogram = Histogram('db_deadlock_retries', 'Number of retries on deadlock')

def execute_with_monitoring(session, query):
 retries = 0
 while retries < 3:
 try:
 result = session.execute(query)
 session.commit()
 if retries > 0:
 retry_histogram.observe(retries)
 return result
 except OperationalError as e:
 if "deadlock" in str(e).lower():
 deadlock_counter.inc() # 데드락 카운트
 retries += 1
 if retries >= 3:
 raise
 time.sleep(0.1 * (2 ** retries))
 session.rollback()
 else:
 raise

Grafana 대시보드 쿼리

# 시간당 데드락 발생 횟수
rate(db_deadlocks_total[1h])

# 데드락 재시도 횟수 분포
histogram_quantile(0.95, rate(db_deadlock_retries_bucket[5m]))

# 데드락 발생률
rate(db_deadlocks_total[5m]) / rate(db_queries_total[5m])

슬랙 알림 설정

import requests

def send_slack_alert(deadlock_count, query):
 """데드락 발생 시 슬랙 알림"""
 if deadlock_count > 10: # 10분에 10번 이상이면
 webhook_url = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
 message = {
 "text": f" 데드락 폭증 경고!",
 "attachments": [{
 "color": "danger",
 "fields": [
 {"title": "발생 횟수", "value": str(deadlock_count), "short": True},
 {"title": "시간", "value": "최근 10분", "short": True},
 {"title": "쿼리", "value": f"```{query}```", "short": False}
 ]
 }]
 }
 requests.post(webhook_url, json=message)

실전 데드락 해결 사례

실제로 우리 서비스에서 겪었던 데드락 장애와 해결 과정입니다.

문제 상황

이커머스 플랫폼에서 이벤트 기간에 주문이 몰리면서 데드락 폭발. 분당 50건 이상 발생하면서 서비스가 거의 마비됐습니다.

# 문제가 된 코드
def create_order(user_id, items):
 with db.begin():
 # 1. 재고 확인 및 차감
 for item in items:
 inventory = db.query(
 "SELECT stock FROM inventory WHERE product_id = %s FOR UPDATE",
 item['product_id']
 )
 if inventory['stock'] < item['quantity']:
 raise OutOfStockError()

 db.execute(
 "UPDATE inventory SET stock = stock - %s WHERE product_id = %s",
 item['quantity'], item['product_id']
 )

 # 2. 주문 생성
 order_id = db.execute("INSERT INTO orders (...) VALUES (...)")

 # 3. 포인트 차감
 db.execute(
 "UPDATE users SET points = points - %s WHERE id = %s",
 used_points, user_id
 )

 db.commit()

왜 데드락이 생겼나?

  1. 여러 고객이 동시에 같은 상품들을 주문
  2. 상품 A, B를 주문하는 순서가 제각각 (A→B vs B→A)
  3. 서로 상대방이 락 건 상품을 기다리면서 데드락!

해결 방법

# 개선된 코드
def create_order_fixed(user_id, items):
 # 1. 상품 ID 순서로 정렬 (핵심!)
 sorted_items = sorted(items, key=lambda x: x['product_id'])

 with db.begin():
 # 2. 정렬된 순서대로 재고 락
 for item in sorted_items:
 inventory = db.query(
 "SELECT stock FROM inventory WHERE product_id = %s FOR UPDATE",
 item['product_id']
 )
 if inventory['stock'] < item['quantity']:
 raise OutOfStockError()

 # 3. 모든 락을 확보한 후 업데이트 (빠르게!)
 for item in sorted_items:
 db.execute(
 "UPDATE inventory SET stock = stock - %s WHERE product_id = %s",
 item['quantity'], item['product_id']
 )

 # 4. 주문 생성
 order_id = db.execute("INSERT INTO orders (...) VALUES (...)")

 # 5. 포인트 차감
 db.execute(
 "UPDATE users SET points = points - %s WHERE id = %s",
 used_points, user_id
 )

 db.commit()

결과

개선 전 (이벤트 시간)

  • 데드락 발생: 분당 50건
  • 주문 실패율: 12%
  • 평균 주문 시간: 3.5초
  • CS 문의: 시간당 200건

개선 후 (동일 상황)

  • 데드락 발생: 분당 1~2건 (96% 감소)
  • 주문 실패율: 0.3% (97% 감소)
  • 평균 주문 시간: 0.4초 (88% 개선)
  • CS 문의: 시간당 10건 (95% 감소)

매출도 20% 올랐습니다! 주문이 막혀서 포기하던 고객들이 성공적으로 구매하게 됐거든요.

정리하며

데이터베이스 데드락은 동시성 높은 시스템에서 피할 수 없지만, 충분히 관리 가능합니다. 핵심만 정리하면:

  1. 락 순서 통일: 모든 트랜잭션이 같은 순서로 리소스 접근
  2. 트랜잭션 짧게: 빨리 들어가서 빨리 나오기
  3. 적절한 격리 수준: 필요 이상으로 높이지 말기
  4. 인덱스 최적화: 불필요한 락 방지
  5. 모니터링 필수: 빨리 발견해서 빨리 대응

데드락 하나 잡으니까 주문 성공률 12% → 99.7%, 응답 시간 88% 개선. 이게 데드락 최적화의 위력입니다.

지금 바로 여러분 서비스의 데드락 모니터링부터 시작하세요. DB도 좋아하고, 고객도 좋아하고, 매출도 올라갑니다.

더 읽어보기

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트