본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

Lock: 동시 접근하는 트랜잭션 사이에서 데이터 정합성을 지키는 직렬화 장치

락(Lock)은 여러 트랜잭션이 같은 데이터에 동시에 접근할 때 충돌을 막기 위해 접근 권한을 제어하는 DBMS의 동시성 제어 메커니즘이다. 공유 락(S)과 배타 락(X)의 호환성, 행 단위 락, 비관적/낙관적 접근 방식, 데드락(Deadlock)의 원리를 PostgreSQL 기준으로 설명한다. SELECT FOR UPDATE, 낙관적 락, 데드락 회피 패

By 송민성

1. 개념

락(Lock)은 여러 트랜잭션(Transaction)이 같은 자원(행, 테이블 등)에 동시에 접근할 때, 접근 순서와 범위를 제어하는 DBMS의 동시성 제어(Concurrency Control) 장치다. 트랜잭션은 자원을 읽거나 쓰기 전에 락을 획득하고, 커밋(COMMIT) 또는 롤백(ROLLBACK) 시점에 해제한다.

기본이 되는 락 모드는 두 가지다.

모드이름의미
S공유 락(Shared Lock)읽기용. 여러 트랜잭션이 동시에 보유 가능
X배타 락(Exclusive Lock)쓰기용. 한 트랜잭션만 보유 가능

호환성(Compatibility)은 다음과 같다.

보유 \ 요청SX
S가능대기
X대기대기

2. 왜 사용하는가

락이 없으면 동시에 실행되는 트랜잭션이 서로의 중간 결과를 덮어써서 다음과 같은 이상 현상(Anomaly)이 생긴다.

  • 갱신 손실(Lost Update): 두 트랜잭션이 같은 값을 읽고 각자 수정해 저장하면 한쪽 결과가 사라진다.
  • 더티 라이트(Dirty Write): 커밋되지 않은 다른 트랜잭션의 변경 위에 덮어쓴다.

재고 차감, 계좌 이체, 좌석 예약처럼 "읽고 → 계산하고 → 쓰는" 흐름에서 락은 이 구간을 보호해 정합성(Consistency)을 지킨다.

3. 동작 원리

3.1 락의 단위(Granularity)

락은 걸리는 범위에 따라 나뉜다. 범위가 넓을수록 관리 비용은 낮지만 동시성이 떨어진다.

  • 테이블 락: 테이블 전체를 잠근다. 동시성이 낮다.
  • 행 락(Row Lock): 특정 행만 잠근다. PostgreSQL의 UPDATE, DELETE, SELECT ... FOR UPDATE가 사용한다.

3.2 2단계 락킹(2PL, Two-Phase Locking)

트랜잭션을 락 획득만 하는 확장 단계(Growing Phase)와 해제만 하는 수축 단계(Shrinking Phase)로 나누는 규약이다. 한 번 락을 해제한 뒤에는 새 락을 얻지 않는다. 이 규약을 지키면 직렬 가능성(Serializability)이 보장된다. 실제 DBMS는 대개 엄격한 2PL(Strict 2PL)처럼 배타 락을 커밋/롤백 때까지 유지한다.

3.3 PostgreSQL의 MVCC와 락

PostgreSQL은 다중 버전 동시성 제어(MVCC)를 사용하므로 일반 SELECT는 행 락을 잡지 않는다. 읽기가 쓰기를 막지 않고, 쓰기도 읽기를 막지 않는다. 락은 주로 쓰기-쓰기 충돌이나 명시적 잠금(FOR UPDATE 등)에서 작동한다.

3.4 비관적 락과 낙관적 락

  • 비관적 락(Pessimistic Lock): 충돌이 날 것으로 보고 먼저 락을 건다. SELECT ... FOR UPDATE가 대표적이다.
  • 낙관적 락(Optimistic Lock): 충돌이 드물다고 보고 락 없이 진행한 뒤, 갱신 시점에 버전 번호로 충돌을 검사한다.

3.5 데드락(Deadlock)

두 트랜잭션이 서로가 가진 락을 기다리면 영원히 진행되지 못한다. PostgreSQL은 대기 시간이 deadlock_timeout(기본 1초)을 넘으면 대기 그래프에서 순환을 검사하고, 순환이 있으면 한 트랜잭션을 강제 중단시킨다.

4. 코드 예제

4.1 비관적 락: SELECT FOR UPDATE

sql
CREATE TABLE accounts ( id INT PRIMARY KEY, balance INT NOT NULL CHECK (balance >= 0), version INT NOT NULL DEFAULT 0 ); INSERT INTO accounts (id, balance) VALUES (1, 1000), (2, 500); -- 세션 A BEGIN; SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 행 락 획득 UPDATE accounts SET balance = balance - 300 WHERE id = 1; -- 아직 COMMIT 하지 않음 -- 세션 B (동시에 실행) BEGIN; SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- A가 끝날 때까지 대기 -- A가 COMMIT 하면 갱신된 값(700)을 읽고 진행 COMMIT; -- 세션 A COMMIT;

4.2 낙관적 락: 버전 컬럼

sql
-- 1) 현재 버전을 읽는다 (예: balance=1000, version=0) SELECT balance, version FROM accounts WHERE id = 1; -- 2) 읽었던 버전이 그대로일 때만 갱신한다 UPDATE accounts SET balance = balance - 300, version = version + 1 WHERE id = 1 AND version = 0; -- 영향받은 행이 0개면 다른 트랜잭션이 먼저 수정한 것이므로 재시도

4.3 대기하지 않는 락: NOWAIT, SKIP LOCKED

sql
-- 이미 잠겨 있으면 대기 대신 즉시 에러 SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT; -- 잠긴 행은 건너뛰고 잠기지 않은 행만 가져옴 (작업 큐 패턴) SELECT * FROM accounts ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;

4.4 데드락 재현

sql
-- 세션 A BEGIN; UPDATE accounts SET balance = balance - 10 WHERE id = 1; -- 세션 B BEGIN; UPDATE accounts SET balance = balance - 10 WHERE id = 2; -- 세션 A UPDATE accounts SET balance = balance + 10 WHERE id = 2; -- B를 기다림 -- 세션 B UPDATE accounts SET balance = balance + 10 WHERE id = 1; -- A를 기다림 → 데드락 -- ERROR: deadlock detected (한쪽이 자동 롤백됨)

4.5 현재 락 상태 확인

sql
SELECT pid, locktype, mode, granted, relation::regclass AS relation FROM pg_locks WHERE relation IS NOT NULL; -- 어떤 세션이 누구를 막고 있는지 SELECT pid, pg_blocking_pids(pid) AS blocked_by, state, query FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;

5. 시간 복잡도 또는 성능 특성

락은 연산 복잡도보다 대기 시간과 동시 처리량(Throughput)이 성능을 좌우한다.

  • 락 보유 시간이 길수록 대기 트랜잭션이 쌓여 처리량이 떨어진다. 락은 커밋/롤백까지 유지되므로 트랜잭션 길이가 곧 락 보유 시간이다.
  • 락 범위가 좁을수록(행 락) 동시성이 높다. 반대로 테이블 락은 같은 테이블의 쓰기를 직렬화한다.
  • 비관적 락은 충돌이 잦은 환경에서 유리하다. 낙관적 락은 충돌이 드문 환경에서 락 대기가 없어 유리하지만, 충돌이 잦으면 재시도 비용이 커진다.
  • 인기 상품 재고처럼 한 행에 쓰기가 몰리는 핫 로우(Hot Row)는 락 경합의 병목이 된다.

6. 실무 사용 사례

  • 재고·포인트 차감: SELECT ... FOR UPDATE로 행을 잠근 뒤 차감하거나, UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty > 0처럼 조건부 원자적 갱신을 쓴다.
  • 작업 큐: FOR UPDATE SKIP LOCKED로 여러 워커가 같은 작업을 중복 처리하지 않고 나눠 가져간다.
  • 게시글 수정 충돌 방지: 사용자가 편집하는 동안 오래 락을 잡을 수 없으므로 version 컬럼 기반 낙관적 락을 쓴다.
  • 스키마 변경: ALTER TABLE 같은 DDL은 강한 테이블 락을 요구하므로 트래픽이 적은 시간에 수행하고 lock_timeout을 설정해 장시간 대기를 막는다.
sql
SET lock_timeout = '3s'; ALTER TABLE accounts ADD COLUMN note TEXT;

7. 주의할 점

  • 트랜잭션 안에서 외부 호출 금지: HTTP 호출처럼 느린 작업을 락을 쥔 채 수행하면 다른 트랜잭션이 모두 대기한다.
  • 락 획득 순서 통일: 여러 행을 잠글 때 항상 같은 순서(예: id 오름차순)로 접근하면 데드락 확률이 크게 줄어든다.
sql
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
  • 데드락 재시도 로직: 데드락이나 직렬화 실패로 중단된 트랜잭션은 애플리케이션에서 재시도할 수 있게 설계한다.
  • 트랜잭션을 열어둔 채 방치 금지: idle in transaction 상태의 세션은 락을 계속 쥐고 있다. idle_in_transaction_session_timeout 설정을 고려한다.
  • 격리 수준(Isolation Level)과의 관계: 기본 격리 수준 READ COMMITTED에서는 락 없이 읽고 쓰는 로직이 갱신 손실을 일으킬 수 있다. "읽고 → 계산 → 쓰기"는 FOR UPDATE, 조건부 UPDATE, 낙관적 락 중 하나로 보호해야 한다.
  • 낙관적 락의 영향 행 수 확인: 갱신된 행 수가 0인지 반드시 검사해야 충돌을 감지한다.

8. 핵심 정리

  • 락은 동시 트랜잭션의 접근을 제어해 갱신 손실 같은 이상 현상을 막는다.
  • 공유 락(S)끼리는 호환되고, 배타 락(X)은 모든 락과 충돌한다.
  • PostgreSQL은 MVCC 덕분에 일반 SELECT가 행 락을 잡지 않으며, 쓰기 충돌과 명시적 잠금에서 락이 작동한다.
  • 비관적 락은 먼저 잠그고, 낙관적 락은 버전으로 충돌을 사후에 감지한다.
  • 데드락은 락 획득 순서 통일, 짧은 트랜잭션, 재시도 로직으로 대응한다.
  • 성능은 락 범위와 보유 시간에 좌우되므로 트랜잭션은 짧게, 락 범위는 좁게 유지한다.