데드락(Deadlock) 완전 정복 — MikroORM/PostgreSQL 실전 예제로 이해하기
데드락은 이론적으로는 단순하지만, 실무에서는 예상치 못한 곳에서 터진다. 이 글에서는 데드락의 발생 조건을 정리하고, MikroORM + PostgreSQL 환경에서 실제로 데드락이 발생하는 3가지 패턴을 쿼리 레벨까지 추적하며, 각 해결 방법의 트레이드오프를 다룬다.
데드락이란
두 개 이상의 트랜잭션이 서로가 잡고 있는 리소스를 기다리면서 영원히 진행하지 못하는 상태다. 둘 다 상대방이 먼저 놓아주길 기다리지만, 아무도 양보하지 않는다.
데드락 발생의 4가지 조건
데드락은 아래 4가지가 동시에 성립해야 발생한다. 하나라도 깨뜨리면 데드락은 발생하지 않는다.
1. 상호 배제 (Mutual Exclusion) — 리소스를 하나의 트랜잭션만 점유할 수 있다. SELECT ... FOR UPDATE로 Row Lock을 걸면 다른 트랜잭션은 해당 Row에 접근하지 못한다.
2. 점유 대기 (Hold and Wait) — 하나의 리소스를 잡고 있으면서 다른 리소스도 요청한다. Product A의 Lock을 보유한 채로 Product B의 Lock을 요청하는 상황이다.
3. 비선점 (No Preemption) — 다른 트랜잭션이 잡고 있는 Lock을 강제로 뺏을 수 없다. Lock은 해당 트랜잭션이 COMMIT 또는 ROLLBACK할 때만 해제된다.
4. 순환 대기 (Circular Wait) — TX1이 TX2를 기다리고, TX2가 TX1을 기다리는 순환 구조가 형성된다.
케이스 1: 교차 업데이트 — 가장 흔한 데드락 패턴
상황
두 개의 API가 거의 동시에 호출된다. 하나는 주문 처리로 Product A → B 순서로 재고를 차감하고, 다른 하나는 재고 이동으로 Product B → A 순서로 재고를 변경한다.
// API 1: 주문 처리 — Product A 먼저, Product B 다음
@Post('/order')
async createOrder() {
const em = this.orm.em.fork();
await em.transactional(async (em) => {
// Step 1: Product A에 Row Lock
const productA = await em.findOneOrFail(
Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
);
productA.stock -= 1;
await someSlowBusinessLogic(); // 복잡한 비즈니스 로직
// Step 2: Product B에 Row Lock 필요
// 💀 TX2가 Product B를 잡고 있어서 대기
const productB = await em.findOneOrFail(
Product, { id: 2 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
);
productB.stock -= 1;
});
}
// API 2: 재고 이동 — Product B 먼저, Product A 다음
@Post('/transfer')
async transferStock() {
const em = this.orm.em.fork();
await em.transactional(async (em) => {
// Step 1: Product B에 Row Lock
const productB = await em.findOneOrFail(
Product, { id: 2 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
);
productB.stock -= 5;
await someSlowBusinessLogic();
// Step 2: Product A에 Row Lock 필요
// 💀 TX1이 Product A를 잡고 있어서 대기
const productA = await em.findOneOrFail(
Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
);
productA.stock += 5;
});
}
쿼리 레벨에서 무슨 일이 벌어지나
시간 순서대로 실제 SQL을 추적하면 데드락이 어떻게 형성되는지 보인다.
-- TX1 (주문 처리) TX2 (재고 이동)
-- ────────────── ──────────────
BEGIN; BEGIN;
SELECT * FROM product
WHERE id = 1
FOR UPDATE;
-- ✅ Product 1 Lock 획득
SELECT * FROM product
WHERE id = 2
FOR UPDATE;
-- ✅ Product 2 Lock 획득
SELECT * FROM product
WHERE id = 2
FOR UPDATE;
-- ⏳ 대기... TX2가 Product 2 잡고 있음
SELECT * FROM product
WHERE id = 1
FOR UPDATE;
-- ⏳ 대기... TX1이 Product 1 잡고 있음
-- 💀 DEADLOCK 발생
순환 대기 구조가 만들어진다.
TX1 ─── Product 1 (Lock 보유) ───→ Product 2 (Lock 대기)
↑ │
│ ↓
TX2 ─── Product 2 (Lock 보유) ───→ Product 1 (Lock 대기)PostgreSQL이 데드락을 감지하면 한쪽 트랜잭션을 강제 롤백시킨다.
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678;
blocked by process 5679.
Process 5679 waits for ShareLock on transaction 5677;
blocked by process 1234.케이스 2: 암묵적 데드락 — FOR UPDATE 없이도 발생한다
명시적으로 PESSIMISTIC_WRITE를 쓰지 않아도 데드락이 발생할 수 있다. UPDATE 문 자체가 Row Lock을 건다는 사실을 모르면 당한다.
// TX1
await em.transactional(async (em) => {
// UPDATE는 자동으로 Row Lock을 건다
await em.nativeUpdate(Product, { id: 1 }, { stock: raw('stock - 1') });
// → Product 1에 Lock
await someSlowBusinessLogic();
await em.nativeUpdate(Product, { id: 2 }, { stock: raw('stock - 1') });
// → Product 2에 Lock 필요 → 💀 대기
});
// TX2 (동시 실행)
await em.transactional(async (em) => {
await em.nativeUpdate(Product, { id: 2 }, { stock: raw('stock + 5') });
// → Product 2에 Lock
await someSlowBusinessLogic();
await em.nativeUpdate(Product, { id: 1 }, { stock: raw('stock + 5') });
// → Product 1에 Lock 필요 → 💀 대기
});
실제 쿼리를 보면 SELECT ... FOR UPDATE가 없는데도 동일한 구조다.
-- TX1 TX2
BEGIN; BEGIN;
UPDATE product SET stock = stock - 1
WHERE id = 1;
-- Row Lock on id=1
UPDATE product SET stock = stock + 5
WHERE id = 2;
-- Row Lock on id=2
UPDATE product SET stock = stock - 1
WHERE id = 2;
-- ⏳ 대기...
UPDATE product SET stock = stock + 5
WHERE id = 1;
-- ⏳ 대기...
-- 💀 DEADLOCK
SELECT ... FOR UPDATE는 명시적으로 Lock을 거는 것이고, UPDATE는 암묵적으로 Lock을 거는 것이다. 결과는 동일하다. MikroORM의 em.flush()도 내부적으로 UPDATE를 실행하므로, 변경된 엔티티의 flush 순서에 따라 암묵적 데드락이 발생할 수 있다.
케이스 3: 범위 업데이트와 인덱스 락 — 실무에서 자주 당하는 패턴
단일 Row가 아니라 범위 쿼리로 여러 Row를 업데이트할 때, Lock이 걸리는 순서가 인덱스 스캔 방향에 따라 달라질 수 있다.
// TX1: 카테고리 A 상품 가격 일괄 인상
await em.transactional(async (em) => {
await em.nativeUpdate(
Product,
{ category: 'A', price: { $lt: 10000 } },
{ price: raw('price * 1.1') }
);
// → WHERE category = 'A' AND price < 10000
// → 조건에 해당하는 여러 Row에 Lock
});
// TX2: 가격 범위로 할인율 업데이트
await em.transactional(async (em) => {
await em.nativeUpdate(
Product,
{ price: { $gte: 5000, $lte: 15000 } },
{ discount: 10 }
);
// → WHERE price >= 5000 AND price <= 15000
// → 범위가 겹치는 Row에서 데드락 가능
});
-- 범위 쿼리의 함정:
-- PostgreSQL은 인덱스 스캔 순서대로 Row Lock을 건다
-- 두 쿼리가 다른 인덱스를 사용하면 Lock 순서가 달라질 수 있다
-- TX1: row 101 Lock → row 102 Lock → row 103 Lock → ...
-- TX2: row 103 Lock → row 102 Lock → row 101 Lock → ...
-- (다른 인덱스 또는 역방향 스캔)
-- 💀 DEADLOCK
이 패턴이 특히 위험한 이유는 코드만 봐서는 데드락이 예상되지 않기 때문이다. 단일 쿼리 안에서 여러 Row에 Lock이 걸리고, 그 순서를 개발자가 제어하기 어렵다.
해결 방법
1. Lock 순서 통일 — 순환 대기 조건을 깨뜨린다
데드락의 4가지 조건 중 "순환 대기"를 원천 차단하는 가장 근본적인 해결책이다. 여러 리소스에 Lock을 걸어야 할 때, 항상 같은 순서(예: ID 오름차순)로 Lock을 건다.
async updateProducts(ids: number[], updates: Partial<Product>[]) {
const sortedIds = [...ids].sort((a, b) => a - b); // 항상 ID 오름차순
await em.transactional(async (em) => {
for (const id of sortedIds) {
const product = await em.findOneOrFail(
Product, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE }
);
// 비즈니스 로직 적용
}
});
}
TX1이 Product 1 → 2 순서로 Lock을 걸고, TX2도 Product 1 → 2 순서로 Lock을 건다면 순환이 발생하지 않는다. TX2는 Product 1을 기다리고, TX1이 끝나면 TX2가 진행된다.
2. Lock Timeout — 무한 대기를 방지한다
await em.transactional(async (em) => {
// PostgreSQL — 5초 대기 후 포기
await em.getConnection().execute('SET lock_timeout = 5000');
try {
const product = await em.findOneOrFail(
Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
);
product.stock -= 1;
} catch (e) {
// ERROR: canceling statement due to lock timeout
// 재시도 로직 또는 에러 처리
}
});
데드락 자체를 예방하지는 않지만, 데드락이 발생했을 때 빠르게 실패하도록 한다. PostgreSQL이 데드락을 감지하기까지 기본적으로 1초(deadlock_timeout)가 걸리는데, lock_timeout을 설정하면 그 전에 먼저 포기할 수 있다.
3. 낙관적 락 (Optimistic Lock) — Lock을 걸지 않는다
Lock 자체를 걸지 않으므로 데드락이 구조적으로 발생하지 않는다. 대신 커밋 시점에 충돌을 감지한다.
@Entity()
export class Product {
@PrimaryKey()
id!: number;
@Property()
stock!: number;
@Property({ version: true })
version!: number; // MikroORM이 자동 관리
}
// 사용
await em.transactional(async (em) => {
const product = await em.findOneOrFail(Product, { id: 1 });
product.stock -= 1;
// flush 시점에 version 체크
// UPDATE product SET stock = ?, version = version + 1
// WHERE id = 1 AND version = 3
// → version이 바뀌었으면 OptimisticLockError 발생
});
-- 낙관적 락의 실제 쿼리
-- Lock 없이 읽고:
SELECT * FROM product WHERE id = 1;
-- version=3, stock=100
-- 커밋 시 version 조건으로 업데이트:
UPDATE product
SET stock = 99, version = 4
WHERE id = 1 AND version = 3;
-- affected rows = 0이면 → 다른 트랜잭션이 먼저 수정한 것
-- → OptimisticLockError 발생 → 재시도
동시 충돌이 적은 환경(대부분의 웹 애플리케이션)에서 적합하다. 충돌이 빈번하면 재시도가 많아져서 오히려 비효율적이다.
4. 트랜잭션 범위 최소화 — 항상 적용해야 하는 원칙
// ❌ 트랜잭션 안에서 너무 많은 일을 한다
await em.transactional(async (em) => {
const product = await em.findOneOrFail(
Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
);
// Lock 잡은 상태에서...
await sendEmail(user); // 외부 API 호출 (느림)
await generateInvoice(order); // 파일 생성 (느림)
await notifySlack(channel); // 또 외부 호출 (느림)
product.stock -= 1;
});
// ✅ Lock 보유 시간을 최소화한다
await sendEmail(user);
await generateInvoice(order);
await notifySlack(channel);
// 트랜잭션은 DB 작업만
await em.transactional(async (em) => {
const product = await em.findOneOrFail(
Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
);
product.stock -= 1;
// 바로 커밋 → Lock 즉시 해제
});
Lock을 잡고 있는 시간이 짧으면 다른 트랜잭션이 같은 리소스를 기다리는 시간도 짧아지고, 데드락이 발생할 확률이 급격히 줄어든다.
해결 방법 비교
| 해결 방법 | 원리 | 적합한 경우 | 주의점 |
|---|---|---|---|
| Lock 순서 통일 | 순환 대기 조건 제거 | 어떤 리소스를 Lock할지 미리 아는 경우 | 범위 쿼리에서는 적용 어려움 |
| Lock Timeout | 무한 대기 방지 | 범용적 안전장치 | 데드락 예방이 아닌 빠른 실패 |
| 낙관적 락 | Lock 자체를 안 걸음 | 충돌이 적은 환경 | 충돌 빈번 시 재시도 비용 |
| 트랜잭션 범위 최소화 | Lock 보유 시간 단축 | 항상 적용 가능 | 비즈니스 로직 분리 설계 필요 |
핵심 정리
- UPDATE 문은 암묵적으로 Row Lock을 건다.
FOR UPDATE만 Lock이 아니다. - 데드락은 Lock 순서가 다를 때 발생한다. 항상 같은 순서로 Lock을 걸면 순환 대기를 차단할 수 있다.
- 범위 쿼리는 Lock 순서를 예측하기 어렵다. 인덱스 스캔 방향에 따라 Row Lock 순서가 달라질 수 있다.
- 트랜잭션 안에서 외부 API 호출, 파일 I/O 등 느린 작업을 하지 않는다. Lock 보유 시간이 길어지면 데드락 확률이 높아진다.
- PostgreSQL은 데드락을 감지하면 한쪽을 강제 롤백한다. 애플리케이션에서 이를 잡아서 재시도하는 로직이 필요하다.
'DB' 카테고리의 다른 글
| 파티셔닝의 정의와 예시, 고려해야 하는 시점 (2) | 2025.08.02 |
|---|---|
| 스플릿 브레인의 정의와 발생 시나리오 정리 (0) | 2025.08.02 |
| [데이터 중심 어플리케이션 설계하기] 2과. 데이터 모델과 질의 언어 (0) | 2025.07.13 |
| [데이터 중심 어플리케이션 설계하기] 1과. 신뢰 할 수 있고 확장 가능 하며 유지 보수 하기 쉬운 애플리케이션 (1) | 2025.07.13 |
| 6/26 DB Connection Pool 고갈 장애 분석 및 해결 방안 리포트 (0) | 2025.06.27 |
댓글