소프트웨어 개발/데이터·통계

DB Lock과 데드락 | 낙관적 락·비관적 락, SELECT FOR UPDATE, SKIP LOCKED

boradora 2026. 8. 10. 23:41

 

작업일2026. 07

Lock Deadlock FOR UPDATE SKIP LOCKED Advisory Lock pg_locks

재고가 1개 남았는데 두 사람이 같은 순간에 주문 버튼을 누릅니다. 둘 다 재고를 1로 읽고, 둘 다 0으로 고칩니다. 재고는 0이 되었는데 주문은 두 건 들어옵니다. Lock은 이 상황을 막기 위한 장치입니다.

같은 행을 동시에 고치려는 트랜잭션이 둘이면 어떻게 될까요?
충돌을 미리 막을지, 일어난 뒤에 잡아낼지부터 정해야 합니다.

MVCC 덕분에 일반적인 읽기와 쓰기는 서로를 막지 않습니다. 일반 SELECT는 다른 트랜잭션의 UPDATE를 기다리지 않고 자신의 스냅샷을 읽습니다. 하지만 쓰기끼리의 충돌은 여전히 발생할 수 있습니다. 같은 행을 두 트랜잭션이 동시에 수정하려 하면, 한쪽은 다른 쪽의 작업이 끝날 때까지 기다리게 됩니다.

Lock을 안 걸면 생기는 일

먼저 무엇을 막으려는 것인지부터 보겠습니다. 아래 순서로 실행되면 두 주문이 처리됐는데 재고는 1만 줄어듭니다.

시각
Session 1
Session 2
t1
stock 읽음 → 1
 
t2
 
stock 읽음 → 1
t3
stock = 0 으로 저장
 
t4
 
stock = 0 으로 저장 (t3을 덮어씀)
주문은 2건인데 재고는 1만 줄었습니다. 이것을 Lost Update 라고 합니다

여기서 UPDATE items SET stock = stock - 1 처럼 읽은 값을 쓰지 않고 데이터베이스가 계산하게 하면 이 특정 사례는 막힙니다. UPDATE는 수정하는 행에 대한 잠금을 획득하기 때문입니다. 문제는 읽고 → 판단하고 → 쓰는 흐름입니다. 재고를 확인해서 0보다 클 때만 차감하는 로직은 애플리케이션에 있고, 그 사이에 다른 트랜잭션이 끼어들 수 있습니다.

Lock의 층위

PostgreSQL의 애플리케이션 관점에서 볼 수 있는 잠금은 크게 세 가지입니다.

Row-Level Lock
행 하나를 잠급니다. UPDATE, DELETE, SELECT ... FOR UPDATE 가 여기에 해당합니다. 일반적으로 같은 행에 대한 충돌을 제어할 때 사용하며, 다른 행에 대한 작업에는 직접적인 영향을 주지 않습니다.
Table-Level Lock
테이블 전체를 잠급니다. ACCESS SHARE부터 ACCESS EXCLUSIVE까지 등급이 있고, ALTER TABLE 처럼 강한 테이블 잠금이 필요한 DDL은 ACCESS EXCLUSIVE 같은 강한 잠금을 획득합니다.
Advisory Lock
테이블이나 행이 아니라 내가 정한 숫자를 잠급니다. 데이터베이스는 그 숫자의 뜻을 모르고, 의미는 애플리케이션이 정합니다.
Table-Level Lock은 DDL에서 조용히 걸립니다. 운영 중에 ALTER TABLE을 실행하면 ACCESS EXCLUSIVE를 잡는데, 이 잠금은 해당 테이블에 대한 다른 대부분의 동시 접근과 충돌할 수 있습니다. 앞선 트랜잭션이 그 테이블을 쥐고 있으면 ALTER가 대기하고, 그 뒤로 들어온 조회들이 ALTER 뒤에 줄줄이 밀립니다. 요청이 연쇄적으로 대기하면서 서비스 지연으로 이어질 수 있는 경로입니다.

낙관적 Lock과 비관적 Lock

충돌을 다루는 대표적인 방식은 크게 두 가지입니다. 충돌이 일어나기 전에 막을지, 일어난 뒤에 감지할지입니다.

구분 낙관적 Lock (Optimistic) 비관적 Lock (Pessimistic)
전제 충돌이 드물다 충돌이 잦다
시점 저장할 때 충돌을 감지하고 재시도 읽을 때 미리 잠그고 다른 쪽은 대기
구현 version 이나 updated_at 컬럼을 비교 SELECT ... FOR UPDATE
DB 잠금 걸지 않습니다. 데이터베이스 입장에서는 평범한 UPDATE입니다 실제로 행 잠금을 잡습니다
맞는 곳 읽기가 많고 갱신이 드문 곳. 블로그 글 수정, 설정 변경, 사용자가 오래 붙잡는 편집 화면 은행 이체, 좌석 예매, 재고 차감처럼 짧은 트랜잭션 안에서 충돌을 즉시 제어해야 하는 작업
대가 충돌을 감지하면 최신 데이터를 다시 가져오거나 사용자에게 충돌을 알리는 등 재처리 설계가 필요합니다. 대기가 생기고 데드락 가능성이 열립니다

낙관적 Lock은 데이터베이스 기능이 아니라 설계 패턴입니다. 읽을 때 본 버전과 저장할 때의 버전이 같은지 WHERE로 확인하는 것이 전부입니다.

-- 읽을 때 version = 3 이었다면
UPDATE posts
   SET title = '수정한 제목',
       version = version + 1
 WHERE id = 10
   AND version = 3;

-- 갱신된 행이 0개면 그 사이 누군가 먼저 고친 것 → 사용자에게 알리고 재시도

사용자가 편집 화면을 10분간 열어 두는 상황에서 비관적 Lock을 쓰면 그 10분 동안 다른 사람이 손도 못 댑니다. 긴 사용자 세션에 낙관적 Lock을 쓰는 이유입니다.

FOR UPDATE의 동작 옵션

비관적 Lock은 SELECT ... FOR UPDATE로 겁니다. 이미 잠긴 행을 만났을 때 어떻게 할지에 따라 세 갈래로 나뉘고, 이 선택에 따라 대기 방식과 동시 처리 특성이 달라집니다.

옵션 잠긴 행을 만나면 쓰는 곳
FOR UPDATE 풀릴 때까지 대기 순서를 지켜야 하는 이체, 재고 차감
FOR UPDATE NOWAIT 기다리지 않고 즉시 에러 대기 자체가 문제인 화면. 사용자에게 바로 알려야 할 때
FOR UPDATE SKIP LOCKED 그 행을 건너뛰고 다음 행으로 작업 큐. 워커 여러 개가 서로 다른 작업을 집어 갑니다
BEGIN;
SELECT * FROM items WHERE id = 1 FOR UPDATE NOWAIT;
-- 다른 세션이 잡고 있으면 즉시 실패합니다
-- ERROR: could not obtain lock on row in relation "items"

SKIP LOCKED로 만드는 작업 큐

SKIP LOCKED는 큐 서버를 따로 두지 않고 데이터베이스만으로 분산 처리를 만들 때 씁니다. 여러 워커가 같은 큐 테이블을 조회해도 이미 다른 워커가 잠근 행은 건너뛰므로 같은 행을 동시에 가져가는 경쟁을 피할 수 있습니다.

WITH picked AS (
  SELECT id
    FROM job_queue
   WHERE status = 'READY'
   ORDER BY id
     FOR UPDATE SKIP LOCKED    -- 다른 워커가 잡은 행은 건너뜁니다
   LIMIT 10
)
UPDATE job_queue
   SET status = 'RUNNING', started_at = now()
  FROM picked
 WHERE job_queue.id = picked.id
 RETURNING job_queue.*;

SKIP LOCKED가 없으면 여러 워커가 같은 잠금 대상 행을 선택할 경우, 한 워커가 처리하는 동안 다른 워커들이 그 잠금이 풀릴 때까지 대기할 수 있습니다. 하나가 처리하는 동안 나머지 넷은 대기하므로, 워커를 늘려도 처리량이 늘지 않습니다.

Lock이 풀리는 시점

일반적인 트랜잭션 수준의 잠금은 COMMIT이나 ROLLBACK 시 자동으로 해제됩니다. PostgreSQL의 Advisory Lock처럼 세션 수준에서 유지되거나 별도의 해제 함수를 사용하는 잠금도 있습니다.

트랜잭션이 길수록 잠금을 쥐고 있는 시간이 길어지고, 충돌 확률도 같이 올라갑니다.
HTTP 호출, 파일 입출력, 외부 결제 API 호출은 트랜잭션 밖에서 처리합니다. 트랜잭션 안에는 가능한 한 필요한 데이터베이스 작업만 짧게 둡니다.

외부 API를 트랜잭션 안에서 부르면, 그 API가 3초 걸릴 때 잠금도 3초를 잡고 있습니다. 초당 100건의 요청이 들어오는 구간이라면 그 3초 동안 같은 자원을 기다리는 요청이 대량으로 쌓일 수 있습니다.

데드락

데드락은 여러 트랜잭션이 서로가 보유한 자원을 기다리면서 어느 쪽도 진행하지 못하는 상태입니다. 서로가 필요로 하는 잠금을 보유한 채 상대방의 잠금을 기다리는 순환 대기가 생길 때 발생합니다.

Session 1
Session 2
 
 
① row 1 잠금 획득
② row 2 잠금 획득
 
③ row 2 를 요청 → 대기
 
④ row 1 을 요청 → 대기
Session 1
Session 2
순환 대기. 기다리는 관계가 원을 그립니다

어떻게 감지하는가

데드락은 서로가 서로를 기다리는 순환 대기 상태이므로, 단순히 잠금 대기가 오래 지속된다는 사실만으로는 데드락인지 판단할 수 없습니다. 그래서 데이터베이스는 대기 관계를 그래프로 만들어 순환이 있는지 확인합니다.

매번 확인하면 비용이 크기 때문에 일정 시간 기다린 뒤에 검사합니다. PostgreSQL은 기본적으로 1초 동안 잠금을 기다린 뒤 데드락 검사를 시작하며, 이 시간은 deadlock_timeout으로 조정할 수 있습니다. 일반적인 잠금 대기마다 데드락 검사를 수행하지 않고, 설정된 시간이 지나면 검사를 수행하는 방식입니다.

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

데드락이 감지되면 관련 트랜잭션 중 하나를 중단해 순환 대기를 끊습니다. 따라서 데드락이 영원히 지속되지는 않지만, 희생된 트랜잭션은 오류를 반환하므로 애플리케이션에서 재시도 등의 처리가 필요합니다. 데드락은 발생 가능성을 줄이면서, 발생했을 때 재시도할 수 있도록 대비해야 합니다.

데드락을 줄이는 네 가지

① 잠그는 순서를 통일합니다

가장 기본적이고 효과적인 방법입니다. 여러 행을 건드릴 때 항상 같은 기준으로 정렬해서 잠그면 순환이 생기지 않습니다. 한 트랜잭션은 1번 → 2번 순서로 잠그고 다른 트랜잭션은 2번 → 1번 순서로 잠그면, 서로 하나씩 잠근 뒤 상대방의 잠금을 기다리는 순환 대기가 발생할 수 있기 때문입니다.

BEGIN;
WITH targets AS (
  SELECT id FROM items
   WHERE id = ANY (ARRAY[1, 2])
   ORDER BY id            -- 항상 id 오름차순으로 잠급니다
     FOR UPDATE
)
UPDATE items SET stock = stock - 1
  FROM targets WHERE items.id = targets.id;
COMMIT;

② 트랜잭션을 짧게 유지합니다

잠금을 쥐는 시간이 짧아지면 겹칠 확률 자체가 내려갑니다. 비즈니스 로직과 외부 호출을 트랜잭션 밖으로 빼는 것이 가장 효과가 큽니다.

③ Advisory Lock으로 업무 단위를 직렬화합니다

행이나 테이블이 아니라 업무 개념을 잠그고 싶을 때 씁니다. "42번 사용자에 대한 정산 작업은 한 번에 하나만”처럼, 여러 테이블에 걸친 특정 업무 단위를 하나의 논리적 잠금으로 직렬화할 수 있습니다.

BEGIN;
SELECT pg_advisory_xact_lock(42);        -- user_id=42 에 대한 논리적 잠금

UPDATE users    SET balance = balance - 1000 WHERE id = 42;
INSERT INTO payments (user_id, amount) VALUES (42, 1000);

COMMIT;                                   -- 트랜잭션이 끝나면 자동 해제

pg_advisory_xact_lock은 트랜잭션이 끝날 때 자동으로 풀립니다. xactpg_advisory_lock처럼 세션 수준의 Advisory Lock은 명시적으로 해제하거나 세션이 종료될 때까지 유지됩니다. 따라서 관리에 주의해야 합니다. 트랜잭션 범위에서만 잠금이 필요하다면 pg_advisory_xact_lock을 사용하는 편이 관리하기 쉽습니다.

④ 재시도를 넣어 둡니다

위 셋을 다 해도 데드락은 완전히 없어지지 않습니다. 데드락 에러를 잡아 재시도하되, 재시도 사이에 무작위 지연 을 두면 여러 요청이 동시에 재시도하면서 다시 충돌하는 상황을 줄일 수 있습니다.

try:
    execute_tx()
except psycopg2.errors.DeadlockDetected:      # PostgreSQL 에러코드 40P01
    time.sleep(random.uniform(0.1, 0.5))      # 무작위 지연 (jitter)
    retry()

지금 무엇이 막고 있는지

운영 중에 요청이 밀리기 시작하면 누가 무엇을 잠그고 있는지부터 확인합니다. 대기 중인 세션과 그 세션을 막고 있는 세션을 함께 볼 수 있습니다.

SELECT bl.pid   AS waiting_pid,
       wl.pid   AS blocking_pid,
       bl.query AS waiting_query,
       wl.query AS blocking_query,
       now() - bl.query_start AS waiting_for
  FROM pg_locks l1
  JOIN pg_stat_activity bl ON bl.pid = l1.pid
  JOIN pg_locks l2
    ON l1.locktype = l2.locktype
   AND l1.relation IS NOT DISTINCT FROM l2.relation
   AND l1.pid <> l2.pid
  JOIN pg_stat_activity wl ON wl.pid = l2.pid
 WHERE NOT l1.granted AND l2.granted
 ORDER BY waiting_for DESC;

오래 실행 중이거나 대기 중인 세션만 확인하려면 실행 시간을 기준으로 필터링할 수 있습니다.

SELECT pid, state, wait_event, now() - query_start AS duration, query
  FROM pg_stat_activity
 WHERE state != 'idle'
   AND now() - query_start > INTERVAL '30 seconds';

원인이 확인되면 필요한 경우 해당 세션의 쿼리를 취소하거나 세션을 종료합니다. 먼저 실행 중인 쿼리를 취소하고, 필요한 경우 세션 자체를 종료하는 순서로 대응할 수 있습니다.

SELECT pg_cancel_backend(pid);      -- 실행 중인 쿼리만 취소
SELECT pg_terminate_backend(pid);   -- 연결 자체를 종료

설정 파일에서 log_lock_waits = on을 켜두면 잠금 대기가 로그로 남습니다. 장애가 지나간 뒤에 원인을 찾으려면 이 값이 미리 켜져 있어야 합니다.

DBMS별 차이

주요 DBMS는 데드락을 감지하고 해소하는 기능을 제공합니다. 갈리는 것은 무엇으로 예방하고, 어디서 확인하고, 어떻게 끊느냐입니다.

DBMS 자동 감지 예방 · 제어 세션 종료 확인하는 곳
PostgreSQL O
deadlock_timeout (기본 1초)
순서 통일
짧은 트랜잭션
Advisory Lock
pg_terminate_backend(pid) pg_locks
pg_stat_activity
MySQL
InnoDB
O
대기 그래프 즉시 탐지
순서 통일
Row-Level Lock 범위 좁히기
KILL <process_id> SHOW ENGINE INNODB STATUS
Oracle O 순서 통일
짧은 트랜잭션
ALTER SYSTEM KILL SESSION V$LOCK
V$SESSION
SQL Server O
Deadlock Monitor 스레드
DEADLOCK_PRIORITY 로 희생될 쪽 지정 KILL <spid> sys.dm_tran_locks
XML 데드락 그래프
분산 · Cloud O
분산 Lock 그래프 탐지
중앙 Lock 서버 콘솔 / API 제공 콘솔의 Lock Graph

차이가 나는 지점이 두 곳입니다.

  • SQL Server는 데드락 발생 시 희생될 세션의 우선순위를 지정할 수 있습니다. DEADLOCK_PRIORITY를 올려 두면 그 세션은 데드락이 나도 살아남고 상대가 롤백됩니다. 밤에 도는 중요한 배치가 사용자 요청 때문에 죽지 않게 할 때 씁니다.
  • PostgreSQL은 잠금 대기가 발생할 때마다 즉시 데드락 검사를 수행하지 않습니다. deadlock_timeout만큼 기다린 뒤에야 그래프를 확인하기 때문에, 데드락이 나도 최소 1초는 잡혀 있습니다. 짧은 트랜잭션이 많은 서비스라면 이 값을 낮춰 감지를 앞당길 수 있지만, 그만큼 검사가 자주 돌아 부하가 늘어납니다.

정리하면

  • MVCC는 일반적인 읽기와 쓰기가 서로 대기하지 않도록 합니다. 쓰기끼리의 충돌은 Lock으로 다뤄야 합니다.
  • 충돌이 드물면 낙관적, 잦으면 비관적입니다. 낙관적 Lock은 DB 기능이 아니라 version 컬럼을 비교하는 설계 패턴입니다.
  • 잠긴 행을 만났을 때 무엇을 할지 정합니다. 대기(FOR UPDATE), 즉시 실패(NOWAIT), 건너뛰기(SKIP LOCKED).
  • 일반적인 트랜잭션 잠금은 커밋이나 롤백에서 자동으로 해제됩니다. 외부 API 호출을 트랜잭션 안에 두면 그 시간만큼 잠금이 유지됩니다.
  • 데드락은 순환 대기 상태이며, PostgreSQL은 deadlock_timeout 이후 데드락 검사를 수행합니다. deadlock_timeout 뒤에 그래프를 확인해 한쪽을 롤백시킵니다.
  • 줄이는 방법은 순서 통일, 짧은 트랜잭션, Advisory Lock, 재시도 네 가지입니다. 없앨 수는 없습니다.
한 줄 정리: 데드락을 줄이는 핵심은 잠그는 순서를 통일하고 트랜잭션을 짧게 유지하는 것입니다. 잠금 순서를 통일하고 트랜잭션을 짧게 유지하면 데드락 발생 가능성을 크게 줄일 수 있습니다.

참고