이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Lock 경합과 deadlock은 어떻게 다르고 왜 발생하는가?
- 백엔드 코드의 어떤 패턴이 deadlock 가능성을 높이는가?
- 장애 중 lock 문제를 어떻게 확인하고 대응해야 하는가?
개요
Lock은 데이터 정합성을 지키는 장치다. 동시에 여러 트랜잭션이 같은 데이터를 읽고 쓰면 DB는 충돌을 조정해야 한다. 문제는 lock이 오래 잡히거나 서로 다른 순서로 잡히면 API 지연과 deadlock으로 이어진다는 점이다.
Lock 문제는 DB 내부 문제처럼 보이지만, 실제 원인은 백엔드 코드의 트랜잭션 경계, 쿼리 조건, 인덱스 부재, 배치 작업 순서에서 시작되는 경우가 많다.
원리
Lock wait는 한 트랜잭션이 다른 트랜잭션의 lock 해제를 기다리는 상태다. Deadlock은 두 개 이상의 트랜잭션이 서로가 가진 lock을 기다려 더 이상 진행할 수 없는 상태다.
Transaction A: order 1 lock 획득 -> order 2 lock 대기
Transaction B: order 2 lock 획득 -> order 1 lock 대기DB는 deadlock을 감지하면 보통 한 트랜잭션을 희생자로 골라 롤백한다. 애플리케이션은 이 실패를 안전하게 처리하거나 재시도해야 한다.
Lock wait는 기다리면 풀릴 수도 있지만, 사용자 API에서는 이미 timeout budget을 넘겼을 수 있다. 따라서 lock 문제는 DB 내부 이벤트이면서 동시에 API timeout, pool 고갈, 재시도 폭주로 이어지는 애플리케이션 문제다.
백엔드 코드에서 만드는 패턴
서로 다른 순서로 같은 자원을 잠그면 deadlock 가능성이 커진다.
@Transactional
public void transfer(Long fromAccountId, Long toAccountId, long amount) {
Account from = accountRepository.findByIdForUpdate(fromAccountId);
Account to = accountRepository.findByIdForUpdate(toAccountId);
from.withdraw(amount);
to.deposit(amount);
}동시에 transfer(1, 2)와 transfer(2, 1)가 실행되면 서로 다른 순서로 계좌를 잠글 수 있다. 해결은 잠금 순서를 고정하는 것이다.
@Transactional
public void transfer(Long fromAccountId, Long toAccountId, long amount) {
List<Long> ids = Stream.of(fromAccountId, toAccountId).sorted().toList();
Map<Long, Account> accounts = accountRepository.findAllByIdForUpdate(ids)
.stream()
.collect(Collectors.toMap(Account::id, Function.identity()));
Account from = accounts.get(fromAccountId);
Account to = accounts.get(toAccountId);
from.withdraw(amount);
to.deposit(amount);
}핵심은 비즈니스 방향과 상관없이 DB lock 획득 순서를 일정하게 만드는 것이다.
인덱스와 Lock 범위
조건에 맞는 인덱스가 없으면 DB가 더 넓은 범위를 읽거나 잠글 수 있다.
UPDATE coupon_issues
SET status = 'EXPIRED'
WHERE coupon_id = 10
AND status = 'ISSUED';이 쿼리가 자주 실행되고 대상 테이블이 크다면 다음 인덱스를 검토한다.
CREATE INDEX idx_coupon_issues_coupon_status
ON coupon_issues (coupon_id, status);인덱스는 조회 성능만이 아니라 잠금 범위에도 영향을 줄 수 있다. MySQL InnoDB의 gap lock, next-key lock은 특히 범위 조건과 인덱스 설계의 영향을 많이 받는다.
장애 중 확인
PostgreSQL에서는 pg_stat_activity와 lock 관련 view를 본다.
SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';오래 열린 트랜잭션도 확인한다.
SELECT pid, now() - xact_start AS tx_age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY tx_age DESC;MySQL에서는 Performance Schema, SHOW PROCESSLIST, SHOW ENGINE INNODB STATUS가 단서가 된다.
장애 중에는 blocking 관계를 확인한 뒤 완화한다.
1. 오래 열린 transaction 확인
2. blocking query와 blocked query 구분
3. 사용자 영향이 큰 endpoint 확인
4. batch나 admin 작업 중지
5. 필요한 경우 DBA와 query kill 판단
6. 재시도 폭주와 pool pending 확인query kill은 마지막에 가까운 선택이다. 롤백이 오래 걸리거나 비즈니스 작업을 실패시킬 수 있기 때문이다.
실전 팁
- 여러 행을 잠글 때는 항상 같은 순서로 잠근다.
- 트랜잭션 안의 외부 API 호출을 제거한다.
SELECT ... FOR UPDATE는 필요한 행만 정확히 잡도록 조건과 인덱스를 맞춘다.- deadlock은 재시도 가능 오류로 분류하되 멱등성을 확보한다.
- deadlock 빈도가 높으면 재시도보다 설계와 인덱스 문제를 먼저 본다.
- lock wait 알림은 API p99, pool pending, long transaction 지표와 함께 본다.
- 장애 중에는 batch/admin 작업을 먼저 멈춰 사용자 API와의 lock 충돌을 줄인다.
- deadlock 재시도는 bounded backoff를 두고 무한 재시도를 금지한다.
위험 신호!
- 트랜잭션 안에서 사용자 입력 대기나 외부 API 호출이 있다.
- 같은 테이블을 배치와 사용자 API가 다른 순서로 업데이트한다.
- deadlock 로그를 단순 일시 오류로만 처리한다.
FOR UPDATE가 넓은 조건이나 인덱스 없는 조건에 붙어 있다.- MySQL에서 gap lock을 모른 채 범위 update를 수행한다.
- deadlock을 재시도로 숨기기만 하고 lock 획득 순서와 인덱스를 고치지 않는다.
- 장애 중 blocking transaction을 확인하지 않고 무작정 DB 재시작을 요구한다.
확인 질문
- Lock wait와 deadlock의 차이는 무엇인가?
- Lock wait는 다른 트랜잭션의 lock 해제를 기다리는 상태이고, deadlock은 서로가 가진 lock을 기다려 진행 불가능한 순환 대기 상태다.
- Deadlock을 줄이기 위한 백엔드 코드 원칙은 무엇인가?
- 여러 자원을 잠글 때 항상 같은 순서로 접근하고, 트랜잭션을 짧게 유지하며, 조건에 맞는 인덱스를 둔다.
- Deadlock 재시도 전에 필요한 것은 무엇인가?
- 작업이 멱등적이거나 중복 실행돼도 안전하도록 Unique 제약, 상태 전이, 재시도 키를 설계해야 한다.
- Lock 장애 중 query kill을 신중히 해야 하는 이유는 무엇인가?
- 롤백 비용과 사용자 작업 실패 가능성이 있고, 잘못된 세션을 종료하면 장애 원인을 악화시킬 수 있기 때문이다.