데이터베이스 데드락 해결 완벽 가이드: 실전 감지와 해결 전략
데이터베이스 데드락은 실서비스에서 가장 골치 아픈 문제 중 하나입니다. 어느 날 갑자기 트랜잭션들이 서로를 무한정 기다리면서 시스템 전체가 멈춰버리죠. 개발할 때는 잘 안 생기다가, 트래픽 몰릴 때 터지는 게 데드락입니다. 이 글에서는 데드락이 뭔지부터 시작해서, 어떻게 찾고, 어떻게 해결하고, 아예 안 생기게 하는 방법까지 실전 경험을 바탕으로 모두 알려드립니다.
데드락이 뭔가요?
데드락은 간단히 말해서 두 개 이상의 트랜잭션이 서로 상대방이 가진 락을 기다리면서 영원히 멈춰버리는 상황입니다. 마치 좁은 길에서 두 차가 마주보고 섰는데, 둘 다 상대방이 비켜주기를 기다리는 것과 똑같죠.
실제 사례로 이해하기
온라인 쇼핑몰 시스템에서 이런 일이 벌어진다고 해봅시다:
-- 트랜잭션 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;뭐가 문제일까요?
- 트랜잭션 A가 계좌 A를 락 걸고 있음
- 트랜잭션 B가 재고 B를 락 걸고 있음
- 트랜잭션 A가 재고 B를 락 걸려고 대기 → B가 풀릴 때까지 무한 대기
- 트랜잭션 B가 계좌 A를 락 걸려고 대기 → A가 풀릴 때까지 무한 대기
- 둘 다 영원히 기다림 = 데드락!
실서비스에서 얼마나 심각한가?
데드락이 터지면 정말 난리 납니다. 우리 서비스에서 겪었던 실제 사례를 보여드리죠:
데드락 발생 전
- 평균 응답 시간: 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_eventFROM pg_stat_activityWHERE 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_statementFROM pg_catalog.pg_locks blocked_locksJOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pidJOIN 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.pidJOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pidWHERE NOT blocked_locks.granted;데드락 발생 시 PostgreSQL 로그에 이렇게 찍힙니다:
ERROR: deadlock detectedDETAIL: 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_queryFROM information_schema.INNODB_LOCK_WAITS wINNER JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_idINNER JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;데드락 자동 해결 전략
다행히 모던 DB들은 데드락을 자동으로 해결합니다. 하지만 어떻게 해결하는지 알아야 대응할 수 있죠.
희생자(Victim) 선택
DB는 데드락을 감지하면 하나를 “희생자”로 골라서 강제로 롤백시킵니다. 이때 이런 기준으로 선택하죠:
- 트랜잭션 크기: 작은 트랜잭션을 희생 (롤백 비용이 적으니까)
- 우선순위: 낮은 우선순위 트랜잭션 희생
- 실행 시간: 짧게 실행된 트랜잭션 희생
- 변경 사항: 적게 변경한 트랜잭션 희생
# 애플리케이션에서 데드락 재시도 로직 구현import timefrom 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 UPDATEBEGIN;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: raiseGrafana 대시보드 쿼리
# 시간당 데드락 발생 횟수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()왜 데드락이 생겼나?
- 여러 고객이 동시에 같은 상품들을 주문
- 상품 A, B를 주문하는 순서가 제각각 (A→B vs B→A)
- 서로 상대방이 락 건 상품을 기다리면서 데드락!
해결 방법
# 개선된 코드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% 올랐습니다! 주문이 막혀서 포기하던 고객들이 성공적으로 구매하게 됐거든요.
정리하며
데이터베이스 데드락은 동시성 높은 시스템에서 피할 수 없지만, 충분히 관리 가능합니다. 핵심만 정리하면:
- 락 순서 통일: 모든 트랜잭션이 같은 순서로 리소스 접근
- 트랜잭션 짧게: 빨리 들어가서 빨리 나오기
- 적절한 격리 수준: 필요 이상으로 높이지 말기
- 인덱스 최적화: 불필요한 락 방지
- 모니터링 필수: 빨리 발견해서 빨리 대응
데드락 하나 잡으니까 주문 성공률 12% → 99.7%, 응답 시간 88% 개선. 이게 데드락 최적화의 위력입니다.
지금 바로 여러분 서비스의 데드락 모니터링부터 시작하세요. DB도 좋아하고, 고객도 좋아하고, 매출도 올라갑니다.