데드락(Deadlock)이란 무엇인가?
데이터베이스 시스템에서 개발자들이 자주 마주치는 성능 문제 중 하나는 바로 데드락(Deadlock)입니다.
데드락은 두 개 이상의 트랜잭션이 서로가 보유한 자원을 기다리며 무한정 대기하는 상태를 의미합니다.
간단히 말해, 트랜잭션 A가 자원 X를 잠그고 자원 Y를 기다리는 동안, 트랜잭션 B는 자원 Y를 잠그고 자원 X를 기다리는 상황에서 발생합니다. 이런 상황에서는 어느 트랜잭션도 진행할 수 없게 되어 시스템이 멈추게 됩니다.
데드락은 특히 높은 동시성(high concurrency)을 가진 시스템에서 빈번하게 발생할 수 있으며, 적절히 관리되지 않으면 애플리케이션의 성능 저하와 사용자 경험 악화로 이어질 수 있습니다.
데드락이 발생하는 네 가지 조건
데드락이 발생하기 위해서는 다음 네 가지 조건이 동시에 충족되어야 합니다:
- 상호 배제(Mutual Exclusion): 자원은 한 번에 하나의 트랜잭션만 사용할 수 있습니다.
- 점유와 대기(Hold and Wait): 트랜잭션이 이미 하나 이상의 자원을 보유하면서 다른 트랜잭션이 가진 자원을 추가로 요청합니다.
- 비선점(No Preemption): 자원은 트랜잭션이 완료될 때까지 강제로 해제될 수 없습니다.
- 순환 대기(Circular Wait): 트랜잭션들이 서로 자원을 기다리는 순환 구조가 형성되어야 합니다.
이러한 조건들 중 하나라도 제거하면 데드락은 발생하지 않습니다. 이것이 데드락 해결 전략의 기본 원리입니다.
실제 데드락 시나리오: 온라인 쇼핑몰 재고 관리 시스템
실제 비즈니스 시나리오를 통해 데드락이 어떻게 발생하는지 살펴보겠습니다. 온라인 쇼핑몰의 재고 관리 시스템을 예로 들어보겠습니다.
데이터베이스 스키마
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100),
stock INT,
last_updated TIMESTAMP
);
CREATE TABLE orders (
id INT PRIMARY KEY,
product_id INT,
quantity INT,
status VARCHAR(20),
FOREIGN KEY (product_id) REFERENCES products(id)
);
데드락 발생 시나리오
두 명의 고객이 동시에 같은 제품을 주문하는 상황을 가정해 봅시다:
트랜잭션 1 (고객 A의 주문 처리):
BEGIN TRANSACTION;
-- 1. 제품 정보 조회 및 재고 락 획득
UPDATE products SET stock = stock - 1 WHERE id = 101 AND stock > 0;
-- 2. 주문 테이블에 새 주문 추가
INSERT INTO orders (id, product_id, quantity, status) VALUES (1001, 101, 1, 'PENDING');
-- 3. 주문 상태 업데이트
UPDATE orders SET status = 'CONFIRMED' WHERE id = 1001;
COMMIT;
트랜잭션 2 (고객 B의 주문 처리):
BEGIN TRANSACTION;
-- 1. 주문 테이블에 새 주문 추가
INSERT INTO orders (id, product_id, quantity, status) VALUES (1002, 101, 1, 'PENDING');
-- 2. 제품 정보 조회 및 재고 락 획득
UPDATE products SET stock = stock - 1 WHERE id = 101 AND stock > 0;
-- 3. 주문 상태 업데이트
UPDATE orders SET status = 'CONFIRMED' WHERE id = 1002;
COMMIT;
위 시나리오에서 데드락이 발생하는 과정은 다음과 같습니다:
- 트랜잭션 1이 products 테이블의 행에 대한 락을 획득합니다.
- 동시에 트랜잭션 2가 orders 테이블에 새 행을 삽입하고 해당 행에 대한 락을 획득합니다.
- 트랜잭션 1이 orders 테이블에 새 행을 삽입하려 하지만, 트랜잭션 2가 이미 orders 테이블에 락을 걸고 있어서 대기합니다.
- 트랜잭션 2가 products 테이블의 재고를 업데이트하려고 하지만, 트랜잭션 1이 이미 products 테이블에 락을 걸고 있어서 대기합니다.
이렇게 두 트랜잭션이 서로 상대방이 보유한 자원을 기다리는 순환 대기 상태가 형성되어 데드락이 발생합니다.
데드락 탐지 및 진단 방법
MySQL에서 데드락 탐지하기
MySQL에서는 SHOW ENGINE INNODB STATUS
명령을 사용하여 최근 발생한 데드락에 대한 정보를 확인할 수 있습니다:
SHOW ENGINE INNODB STATUS\G
결과에서 "LATEST DETECTED DEADLOCK" 섹션을 찾으면 다음과 같은 정보를 확인할 수 있습니다:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-05-15 14:32:18 0x7f8a9c0e7700
*** (1) TRANSACTION:
TRANSACTION 8389, ACTIVE 6 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 23, OS thread handle 123456, query id 196 localhost root updating
UPDATE products SET stock = stock - 1 WHERE id = 101 AND stock > 0
*** (2) TRANSACTION:
TRANSACTION 8390, ACTIVE 4 sec inserting
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 24, OS thread handle 123457, query id 197 localhost root update
INSERT INTO orders (id, product_id, quantity, status) VALUES (1002, 101, 1, 'PENDING')
*** WE ROLL BACK TRANSACTION (1)
이 정보를 통해 어떤 트랜잭션들이 데드락에 관여했는지, 어떤 쿼리가 실행 중이었는지, 그리고 어떤 트랜잭션이 롤백되었는지 확인할 수 있습니다.
PostgreSQL에서 데드락 탐지하기
PostgreSQL에서는 pg_stat_activity
뷰를 조회하여 대기 중인 트랜잭션들을 확인할 수 있습니다:
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_locks.pid = blocked_activity.pid
JOIN pg_catalog.pg_locks blocking_locks ON blocked_locks.transactionid = blocking_locks.transactionid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_locks.pid = blocking_activity.pid
WHERE NOT blocked_locks.granted;
또한 PostgreSQL 로그 파일에서 "deadlock detected" 메시지를 확인할 수 있습니다:
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 5678.
Process 5678 waits for ShareLock on transaction 1234; blocked by process 1234.
HINT: See server log for query details.
데드락 해결 전략 및 방지 기법
데드락을 해결하고 방지하기 위한 여러 전략들을 살펴보겠습니다.
1. 트랜잭션 설계 개선
일관된 자원 접근 순서 유지하기:
데드락을 방지하는 가장 효과적인 방법 중 하나는 모든 트랜잭션이 동일한 순서로 자원에 접근하도록 설계하는 것입니다. 위의 예제에서는 다음과 같이 수정할 수 있습니다:
-- 트랜잭션 1과 2 모두 동일한 순서로 테이블에 접근
BEGIN TRANSACTION;
-- 1. 항상 먼저 products 테이블 접근
UPDATE products SET stock = stock - 1 WHERE id = 101 AND stock > 0;
-- 2. 그 다음 orders 테이블 접근
INSERT INTO orders (id, product_id, quantity, status) VALUES (1001, 101, 1, 'PENDING');
UPDATE orders SET status = 'CONFIRMED' WHERE id = 1001;
COMMIT;
이렇게 하면 두 트랜잭션이 동일한 순서로 테이블에 접근하므로 순환 대기 조건이 제거되어 데드락이 발생하지 않습니다.
2. 트랜잭션 분할하기
큰 트랜잭션을 여러 개의 작은 트랜잭션으로 분할하면 락 보유 시간을 줄일 수 있습니다:
-- 재고 확인 및 감소
BEGIN TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 101 AND stock > 0;
COMMIT;
-- 주문 생성
BEGIN TRANSACTION;
INSERT INTO orders (id, product_id, quantity, status) VALUES (1001, 101, 1, 'PENDING');
COMMIT;
-- 주문 상태 업데이트
BEGIN TRANSACTION;
UPDATE orders SET status = 'CONFIRMED' WHERE id = 1001;
COMMIT;
단, 이 방법은 트랜잭션의 원자성(Atomicity)을 희생할 수 있으므로 비즈니스 로직에 맞게 신중하게 적용해야 합니다.
3. 락 타임아웃 설정
대부분의 데이터베이스는 트랜잭션이 락을 기다리는 최대 시간을 설정할 수 있습니다:
MySQL:
SET innodb_lock_wait_timeout = 50; -- 50초 후 타임아웃
PostgreSQL:
SET lock_timeout = '5s'; -- 5초 후 타임아웃
이렇게 하면 데드락 상황에서 무한정 대기하는 것을 방지할 수 있습니다.
4. 낙관적 락(Optimistic Locking) 사용
낙관적 락은 실제로 DB 레벨에서 락을 걸지 않고, 버전 번호나 타임스탬프를 사용하여 충돌을 감지하는 방식입니다:
-- 테이블에 버전 컬럼 추가
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- 낙관적 락을 사용한 업데이트
BEGIN TRANSACTION;
-- 현재 버전 읽기
SELECT stock, version FROM products WHERE id = 101;
-- (애플리케이션에서 재고 확인 로직 수행)
-- 버전 확인하면서 업데이트
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 101 AND version = [현재_버전];
-- 영향받은 행이 없으면 다른 트랜잭션이 먼저 수정한 것이므로 재시도 필요
COMMIT;
이 방식은 동시성이 높지만 충돌이 적은 환경에서 효과적입니다.
5. 행 레벨 잠금 최소화하기
테이블 전체가 아닌 필요한 행만 잠그도록 쿼리를 최적화합니다:
-- 인덱스를 사용하여 필요한 행만 잠금
UPDATE products SET stock = stock - 1 WHERE id = 101 AND stock > 0;
-- 불필요한 행 잠금을 유발할 수 있는 쿼리 피하기
-- 피해야 할 예:
UPDATE products SET last_updated = NOW() WHERE category = 'electronics';
6. 데드락 자동 감지 및 해결 설정
대부분의 데이터베이스는 데드락을 자동으로 감지하고 해결하는 메커니즘을 제공합니다:
MySQL:
-- InnoDB 스토리지 엔진은 기본적으로 데드락 감지기가 활성화되어 있음
-- 데드락 발생 시 가장 비용이 적은 트랜잭션을 롤백함
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
PostgreSQL:
-- PostgreSQL은 기본적으로 데드락을 감지하고 해결함
-- deadlock_timeout 파라미터로 감지 주기 조정 가능
SHOW deadlock_timeout;
SET deadlock_timeout = '1s'; -- 더 빠른 감지를 위해 1초로 설정
데드락 처리를 위한 애플리케이션 레벨 전략
데이터베이스 레벨뿐만 아니라 애플리케이션 레벨에서도 데드락을 효과적으로 처리할 수 있는 전략이 있습니다.
1. 재시도 메커니즘 구현
데드락으로 인한 트랜잭션 실패 시 자동으로 재시도하는 로직을 구현합니다:
// Java 예제
int maxRetries = 3;
int retryCount = 0;
boolean success = false;
while (!success && retryCount < maxRetries) {
try {
// 트랜잭션 시작
connection.setAutoCommit(false);
// SQL 실행
PreparedStatement pstmt = connection.prepareStatement(
"UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0");
pstmt.setInt(1, productId);
int affected = pstmt.executeUpdate();
// 재고가 있는지 확인
if (affected > 0) {
// 주문 생성
// ...
// 트랜잭션 커밋
connection.commit();
success = true;
} else {
// 재고 부족
connection.rollback();
throw new OutOfStockException();
}
} catch (SQLException e) {
// 데드락 또는 다른 트랜잭션 오류 처리
connection.rollback();
// MySQL에서 데드락의 경우 SQLState는 "40001", 에러 코드는 1213
// PostgreSQL에서는 SQLState가 "40P01"
if (e.getSQLState().equals("40001") || e.getSQLState().equals("40P01")) {
// 데드락 발생, 잠시 대기 후 재시도
retryCount++;
Thread.sleep(100 * retryCount); // 지수 백오프
} else {
// 다른 SQL 오류
throw e;
}
} finally {
connection.setAutoCommit(true);
}
}
if (!success) {
// 최대 재시도 횟수 초과
throw new TransactionFailedException("Maximum retry attempts exceeded");
}
2. 지수 백오프(Exponential Backoff) 적용
재시도 간격을 점진적으로 늘려 시스템 부하를 분산시킵니다:
# Python 예제
import time
import random
import psycopg2
max_retries = 5
retry_count = 0
success = False
while not success and retry_count < max_retries:
try:
conn = psycopg2.connect("dbname=test user=postgres")
cur = conn.cursor()
# 트랜잭션 시작
conn.autocommit = False
# 재고 업데이트
cur.execute("UPDATE products SET stock = stock - 1 WHERE id = %s AND stock > 0",
(product_id,))
if cur.rowcount > 0:
# 주문 생성
cur.execute("INSERT INTO orders (product_id, quantity, status) VALUES (%s, %s, %s)",
(product_id, 1, 'PENDING'))
# 트랜잭션 커밋
conn.commit()
success = True
else:
conn.rollback()
raise Exception("Out of stock")
except psycopg2.Error as e:
conn.rollback()
# 데드락 오류 확인 (PostgreSQL)
if e.pgcode == '40P01': # 데드락 감지됨
retry_count += 1
# 지수 백오프 + 약간의 랜덤성 추가
sleep_time = (2 ** retry_count) * 0.1 + (random.random() * 0.1)
time.sleep(sleep_time)
else:
# 다른 데이터베이스 오류
raise
finally:
if conn:
conn.close()
if not success:
raise Exception("Transaction failed after maximum retries")
3. 트랜잭션 분리 및 비동기 처리
중요한 트랜잭션과 덜 중요한 작업을 분리하고, 덜 중요한 작업은 비동기적으로 처리합니다:
// Node.js 예제 (MySQL)
async function processOrder(productId, quantity, userId) {
const connection = await mysql.createConnection({/*...*/});
try {
await connection.beginTransaction();
// 핵심 트랜잭션: 재고 확인 및 감소
const [result] = await connection.execute(
'UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?',
[quantity, productId, quantity]
);
if (result.affectedRows === 0) {
await connection.rollback();
throw new Error('Out of stock');
}
// 핵심 트랜잭션: 주문 생성
const [orderResult] = await connection.execute(
'INSERT INTO orders (product_id, user_id, quantity, status) VALUES (?, ?, ?, ?)',
[productId, userId, quantity, 'CONFIRMED']
);
const orderId = orderResult.insertId;
await connection.commit();
// 비동기 작업: 이메일 알림, 로그 기록 등
queueBackgroundTask({
type: 'order_notification',
orderId: orderId,
userId: userId
});
return { success: true, orderId };
} catch (error) {
await connection.rollback();
if (error.errno === 1213) { // MySQL 데드락 에러 코드
// 로깅 및 모니터링
logDeadlockEvent(error);
throw new RetryableError('Database deadlock detected');
}
throw error;
} finally {
await connection.end();
}
}
데드락 모니터링 및 성능 튜닝
모니터링 도구 및 지표
데드락 발생을 모니터링하고 추적하기 위한 도구와 지표입니다:
- MySQL 모니터링:
-- 데드락 횟수 확인 SHOW GLOBAL STATUS LIKE 'innodb_deadlocks'; -- 대기 중인 트랜잭션 확인 SELECT * FROM information_schema.innodb_lock_waits;
- PostgreSQL 모니터링:
-- 데드락 관련 통계 확인 SELECT datname, deadlocks FROM pg_stat_database; -- 현재 대기 중인 락 확인 SELECT relation::regclass, mode, pid, granted FROM pg_locks WHERE NOT granted;
- Prometheus + Grafana: 데이터베이스 메트릭을 수집하고 시각화하여 데드락 패턴을 모니터링할 수 있습니다.
성능 튜닝 팁
- 인덱스 최적화:
적절한 인덱스를 설정하여 락 경합을 줄입니다: CREATE INDEX idx_products_id_stock ON products(id, stock);
- 트랜잭션 격리 수준 조정:
필요에 따라 트랜잭션 격리 수준을 조정합니다: -- MySQL SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- PostgreSQL SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
- 주기적인 데이터베이스 유지보수:
- 통계 정보 업데이트
- 불필요한 인덱스 제거
- 테이블 파티셔닝 고려
실전 데드락 사례 연구: 결제 시스템
실제 결제 시스템에서 발생할 수 있는 데드락 시나리오를 살펴보겠습니다.
시나리오 설명
온라인 결제 시스템에서는 사용자의 계좌에서 금액을 차감하고 수취인의 계좌에 입금하는 트랜잭션이 자주 발생합니다. 이때 두 계좌를 모두 업데이트해야 하므로 데드락이 발생할 수 있습니다.
-- 계좌 테이블
CREATE TABLE accounts (
id INT PRIMARY KEY,
user_id INT,
balance DECIMAL(10, 2),
last_updated TIMESTAMP
);
-- 트랜잭션 테이블
CREATE TABLE transactions (
id INT PRIMARY KEY,
from_account_id INT,
to_account_id INT,
amount DECIMAL(10, 2),
status VARCHAR(20),
created_at TIMESTAMP,
FOREIGN KEY (from_account_id) REFERENCES accounts(id),
FOREIGN KEY (to_account_id) REFERENCES accounts(id)
);
데드락 발생 상황
트랜잭션 1 (계좌 A에서 계좌 B로 송금):
BEGIN TRANSACTION;
-- 1. 출금 계좌(A) 잔액 확인 및 감소
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100;
-- 2. 입금 계좌(B) 잔액 증가
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 3. 트랜잭션 기록
INSERT INTO transactions (from_account_id, to_account_id, amount, status)
VALUES (1, 2, 100, 'COMPLETED');
COMMIT;
트랜잭션 2 (계좌 B에서 계좌 A로 송금):
BEGIN TRANSACTION;
-- 1. 출금 계좌(B) 잔액 확인 및 감소
UPDATE accounts SET balance = balance - 50 WHERE id = 2 AND balance >= 50;
-- 2. 입금 계좌(A) 잔액 증가
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- 3. 트랜잭션 기록
INSERT INTO transactions (from_account_id, to_account_id, amount, status)
VALUES (2, 1, 50, 'COMPLETED');
COMMIT;
여기서 트랜잭션 1이 계좌 A에 락을 걸고, 트랜잭션 2가 계좌 B에 락을 건 상태에서 서로 상대방 계좌에 접근하려고 하면 데드락이 발생합니다.
해결 방안: 계좌 ID 기준 순서 강제
가장 효과적인 해결책은 항상 낮은 ID의 계좌부터 접근하도록 트랜잭션을 설계하는 것입니다:
BEGIN TRANSACTION;
-- 항상 ID가 낮은 계좌부터 업데이트
IF from_account_id < to_account_id THEN
-- 출금 계좌의 ID가 더 작은 경우
UPDATE accounts SET balance = balance - amount WHERE id = from_account_id AND balance >= amount;
UPDATE accounts SET balance = balance + amount WHERE id = to_account_id;
ELSE
-- 입금 계좌의 ID가 더 작은 경우
UPDATE accounts SET balance = balance + amount WHERE id = to_account_id;
UPDATE accounts SET balance = balance - amount WHERE id = from_account_id AND balance >= amount;
END IF;
-- 트랜잭션 기록
INSERT INTO transactions (from_account_id, to_account_id, amount, status)
VALUES (from_account_id, to_account_id, amount, 'COMPLETED');
COMMIT;
이 방식을 사용하면 모든 트랜잭션이 동일한 순서로 계좌에 접근하므로 데드락이 발생하지 않습니다.
결론: 효과적인 데드락 관리를 위한 종합적 접근법
데드락은 동시성이 높은 데이터베이스 시스템에서 피할 수 없는 도전 과제입니다. 이 글에서 살펴본 바와 같이, 데드락은 상호 배제, 점유와 대기, 비선점, 순환 대기라는 네 가지 조건이 동시에 충족될 때 발생합니다. 이 중 하나만 제거해도 데드락을 방지할 수 있습니다.
가장 효과적인 데드락 방지 전략은 다음과 같습니다:
- 설계 단계에서의 예방: 일관된 자원 접근 순서를 적용하고, 트랜잭션을 최대한 작게 유지하며, 필요한 자원만 락하는 등의 설계 패턴을 적용합니다.
- 모니터링 및 감지: 데이터베이스의 데드락 감지 기능을 활용하고, 주기적으로 락 상황을 모니터링하여 데드락 발생 패턴을 파악합니다.
- 적절한 대응 전략: 데드락 발생 시 자동 재시도, 지수 백오프, 타임아웃 설정 등의 대응 메커니즘을 구현합니다.
- 성능 최적화: 인덱스 최적화, 트랜잭션 격리 수준 조정, 필요에 따라 낙관적 락 사용 등의 성능 튜닝을 적용합니다.
글로벌 서비스로 확장될수록 동시성 이슈와 데드락의 빈도가 증가하게 됩니다.
따라서 초기 설계 단계부터 데드락 예방 전략을 고려하고, 지속적인 모니터링과 대응 방안을 마련하는 것이 중요합니다.
실전 사례에서 살펴본 것처럼, 특히 금융 거래나 재고 관리 같은 중요한 비즈니스 로직에서는 데드락 방지 전략이 서비스 안정성에 직접적인 영향을 미칩니다. 데드락으로 인한 트랜잭션 실패는 사용자 경험을 저하시키고, 심각한 경우 비즈니스 손실로 이어질 수 있습니다.
마지막으로, 데드락 관리는 단순히 기술적인 문제를 넘어 비즈니스 요구사항과 시스템 아키텍처를 종합적으로 고려해야 하는 과제입니다. 트랜잭션 설계, 데이터베이스 튜닝, 애플리케이션 로직 개선을 포함한 다층적 접근을 통해 데드락 이슈를 효과적으로 관리할 수 있습니다.
앞으로 시스템을 설계할 때는 초기 단계부터 데드락 방지 전략을 고려하고, 정기적인 성능 테스트를 통해 데드락 상황을 미리 파악하여 안정적이고 확장 가능한 데이터베이스 애플리케이션을 구축해 나가시기 바랍니다.
'DB' 카테고리의 다른 글
트랜잭션 격리 수준 완벽 가이드: 실무에서 만나는 문제와 해결법 (1) | 2025.01.21 |
---|---|
데이터베이스 파티셔닝 전략 비교: MySQL vs PostgreSQL (0) | 2025.01.21 |
[PostgreSQL] PostgreSQL JSONB를 활용한 복잡한 데이터 처리 (0) | 2025.01.20 |
[Oracle] ORA-04036 에러 해결 및 SGA와 PGA 메모리 설정 가이드 (6) | 2025.01.17 |
[Oracle] Oracle Text 대량 텍스트 색인화 (feat. 상품검색기능) (7) | 2024.06.07 |