View

728x90
반응형

이 질문을 검색해서 들어온 사람은 보통 둘 중 하나다. MySQL InnoDB만 다루다가 Postgres로 넘어왔거나, 아니면 락 메커니즘을 깊게 파보지 않아서 헷갈리는 경우다. 결론부터 말하면 Postgres는 행(Row) 단위 락도 당연히 지원한다. 그것도 아주 잘 지원한다.

 

다만 사람들이 헷갈리는 이유가 있다. (1) Postgres 락 모드가 테이블 락만 8단계, 행 락 4단계라 처음 보면 머리가 복잡하다. (2) LOCK TABLE이라는 명령어가 눈에 잘 띄어서 "Postgres는 테이블 단위로만 잠그는 것인가?"라는 의문이 든다. (3) 한국어 자료 중에 락 종류를 한 페이지에 깔끔히 정리한 글이 드물다.

 

이 글을 다 읽으면 (1) postgres 행 단위 락 종류를 머릿속에 표로 그릴 수 있다, (2) SELECT FOR UPDATE, SKIP LOCKED 같은 실전 SQL을 바로 쓸 수 있다, (3) pg_locks 뷰로 락 디버깅까지 가능하다. 5분이면 충분하다.

 

30초 컷 결론: Postgres 행 단위 락은 당연히 가능하다

 

TL;DR

 

- Postgres는 행 락, 테이블 락, 페이지 락, 어드바이저리 락을 모두 지원한다.

 

- 일반 UPDATE/DELETE는 자동으로 행 단위 락(FOR UPDATE 수준)을 획득한다.

 

- 일반 SELECT는 MVCC 덕분에 락을 거의 잡지 않는다.

 

- LOCK TABLE을 직접 써야 할 일은 마이그레이션 같은 특수 상황뿐이다.

 

"Postgres는 테이블 락밖에 안 된다"는 말은 명백히 틀린 정보다. 오히려 Postgres는 MVCC(Multi-Version Concurrency Control) 구조 때문에 락을 최소한으로만 잡으려는 DB이다. UPDATE customers SET email = 'x' WHERE id = 1; 같은 평범한 쿼리도 내부적으로는 그 한 행에만 배타 락을 걸고 끝난다. 다른 트랜잭션이 같은 테이블의 다른 행을 동시에 수정해도 막히지 않는다.

 

LOCK TABLE 명령어는 존재하지만, 그것은 옵션 중 하나일 뿐이다. 명시적으로 호출하지 않으면 거의 잡히지 않는다. 따라서 이 글의 첫 문장만 기억해도 충분하다: Postgres 행 락은 디폴트로 들어가 있는 기능이다.

 

큰 그림으로 본 Postgres 락 모드

 

 

출처: xata.io

 

Postgres 락은 크게 4가지 단위로 나뉜다. 각각 언제 쓰는지 한 줄로 요약하면 다음과 같다.

 

락 단위 4가지 분류

 

단위 명령 예시 언제 잡힘
테이블 락 (Table-level) `LOCK TABLE`, DDL DDL 자동 + 명시적 호출
행 락 (Row-level) `UPDATE`, `SELECT FOR UPDATE` DML 자동 + 명시적 호출
페이지 락 (Page-level) 내부 처리 Postgres 내부에서 짧게 잡고 풀음
어드바이저리 락 (Advisory) `pg_advisory_lock()` 애플리케이션이 직접 호출

 

페이지 락은 사실상 신경 쓸 일이 없다. Postgres가 내부적으로 인덱스 페이지 같은 것을 잠깐 잡았다가 푸는 용도이다. SQL로 직접 다룰 일은 없다.

 

테이블 락 모드 8단계 충돌 매트릭스

 

테이블 락이 8단계라고 하면 처음 듣는 사람은 "왜 이렇게 많은가?"라는 의문이 들 것이다. 다만 실제로 모두 외울 필요는 없고, 어떤 명령이 어떤 락을 자동으로 잡는지만 알면 된다.

 

락 모드 자동으로 잡는 명령 충돌하는 락
ACCESS SHARE `SELECT` ACCESS EXCLUSIVE만 충돌
ROW SHARE `SELECT FOR UPDATE/SHARE` EXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVE `UPDATE`, `DELETE`, `INSERT` SHARE 이상
SHARE UPDATE EXCLUSIVE `VACUUM`, `ANALYZE`, `CREATE INDEX CONCURRENTLY` 자기 자신 + SHARE 이상
SHARE `CREATE INDEX` (CONCURRENTLY 없이) ROW EXCLUSIVE 이상
SHARE ROW EXCLUSIVE `CREATE TRIGGER`, 일부 `ALTER TABLE` 거의 다 충돌
EXCLUSIVE `REFRESH MATERIALIZED VIEW CONCURRENTLY` ACCESS SHARE 빼고 다 충돌
ACCESS EXCLUSIVE `DROP TABLE`, `TRUNCATE`, 대부분 `ALTER TABLE` 모든 락과 충돌

 

핵심은 표 맨 아래 두 줄이다. ACCESS EXCLUSIVE는 모든 락과 충돌한다. 이것이 잡히면 해당 테이블에 SELECT조차 들어갈 수 없다. 운영 중인 테이블에 무심코 ALTER TABLE을 날렸다가 서비스가 전부 멈춰본 사람이 한국에 의외로 많다. 이 부분은 뒤에서 따로 다룬다.

 

행 단위 락 4가지 종류

 

postgres 행 단위 락도 사실 4가지로 나뉘어 있다. 9.3 버전 이전에는 FOR UPDATE와 FOR SHARE 두 가지만 존재했으나, 외래키 동시성 이슈 때문에 9.3에서 두 종류가 더 추가되었다.

 

행 락 모드 강도 용도
FOR KEY SHARE 가장 약함 외래키 무결성 보호용
FOR SHARE 약함 "내가 읽는 동안 바뀌면 안 됨"
FOR NO KEY UPDATE 중간 키 컬럼 안 건드리는 UPDATE
FOR UPDATE 가장 강함 명시적 행 락, 다른 트랜잭션 다 막음

 

쉽게 말하면 위로 갈수록 약하고 아래로 갈수록 강하다. 일반 UPDATE는 키 컬럼(PK 등)을 건드리지 않으면 자동으로 FOR NO KEY UPDATE 수준의 락을 잡고, PK를 건드리면 FOR UPDATE 수준으로 잡는다. 외래키로 참조 중인 행에 대해서는 자동으로 FOR KEY SHARE가 걸려 부모 행이 사라지는 것을 막아준다.

 

대부분의 실무 시나리오에서는 SELECT ... FOR UPDATE 하나만 쓰면 충분하다. 나머지 3개는 "왜 존재하는가" 정도만 알아도 충분하다.

 

행 단위 락이 자동으로 잡히는 시점

 

 

출처: cybrosys.com

 

이론은 끝났고, 이제 실전이다. 행 단위 락이 언제 자동으로 잡히고, 언제 명시적으로 걸어야 하는지 SQL로 살펴본다.

 

UPDATE/DELETE는 묻지도 따지지도 않고 행 락을 잡는다

 

-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
-- 여기서 id=42 행에 자동으로 행 단위 배타 락이 잡힌다
-- COMMIT 전까지 다른 트랜잭션은 같은 행을 건드릴 수 없다

-- 트랜잭션 B (동시 실행)
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
-- A가 COMMIT 또는 ROLLBACK 할 때까지 대기 (블로킹)

 

다만 다른 행(예: id = 99)을 건드리는 트랜잭션은 막히지 않는다. 이것이 행 단위 락의 핵심이다. 테이블 전체가 잠기는 것이 아니라 정확히 해당 행만 잠긴다.

 

일반 SELECT는 락을 잡지 않는다

 

Postgres의 MVCC 구조 덕분에 SELECT는 행 락도, 테이블 배타 락도 잡지 않는다. ACCESS SHARE라는 가장 약한 테이블 락만 잡으며, 이는 ACCESS EXCLUSIVE를 제외하면 어떤 락과도 충돌하지 않는다.

 

-- 트랜잭션 A: UPDATE 중 (행 락 잡힌 상태)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 42;

-- 트랜잭션 B: 같은 행 SELECT
SELECT * FROM accounts WHERE id = 42;
-- 막히지 않는다! 이전 버전(스냅샷)을 읽는다

 

이것이 MySQL InnoDB와의 가장 큰 차이다. InnoDB는 격리수준에 따라 SELECT도 락을 잡을 수 있으나, Postgres는 디폴트로 잡지 않는다. "읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다"가 Postgres MVCC의 기본 철학이다.

 

명시적 행 락이 필요한 케이스

 

자동 락은 한 SQL 문장 단위로만 잡힌다. 다만 실무에서는 "조회한 다음 그 값으로 계산해서 업데이트하기까지" 사이에 다른 트랜잭션이 끼어들면 안 되는 경우가 많다. 대표적으로 재고 차감, 좌석 예약, 잔액 갱신 같은 작업이다.

 

-- 잘못된 예: race condition 발생 가능
BEGIN;
SELECT stock FROM products WHERE id = 1;  -- stock = 5
-- 여기서 다른 트랜잭션이 끼어들어 stock을 0으로 만들 수 있다
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

 

이를 막으려면 SELECT FOR UPDATE로 미리 행 락을 선점해야 한다.

 

-- 올바른 예: SELECT FOR UPDATE로 행 락 선점
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 이 행은 COMMIT/ROLLBACK 전까지 다른 트랜잭션이 건드릴 수 없다
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

 

이것이 postgres select for update의 가장 흔한 사용 패턴이다. 동시성 이슈의 90%는 이 하나로 해결된다.

 

LOCK TABLE은 언제 써야 하는가

 

솔직히 말해 대부분의 백엔드 개발자는 LOCK TABLE을 직접 쓸 일이 거의 없다. Postgres가 자동으로 적절한 레벨의 락을 잡아주기 때문이다. 다만 명시적으로 테이블 락이 필요한 시나리오가 몇 가지 있다.

 

-- 테이블 전체에 대한 일관된 스냅샷이 필요할 때
BEGIN;
LOCK TABLE accounts IN SHARE MODE;
-- 이제 누구도 accounts에 INSERT/UPDATE/DELETE를 할 수 없다 (SELECT는 가능)
SELECT SUM(balance) FROM accounts;  -- 정확한 합계
COMMIT;

-- 마이그레이션 중 데이터 일관성 보장
BEGIN;
LOCK TABLE old_table IN ACCESS EXCLUSIVE MODE;
INSERT INTO new_table SELECT * FROM old_table;
DROP TABLE old_table;
ALTER TABLE new_table RENAME TO old_table;
COMMIT;

 

다만 운영 중인 서비스에서 ACCESS EXCLUSIVE를 잡으면 그 시점부터 모든 쿼리가 멈춘다. 트래픽이 있는 시간대에는 절대 해서는 안 되는 작업이다.

 

실무에서 진짜 자주 쓰는 락 패턴 4가지

 

 

출처: Medium (129KB)

 

여기부터가 진짜 알아야 할 부분이다. 공식 문서에 친절하게 적혀 있지 않은 것들 위주로 정리한다.

 

SELECT FOR UPDATE SKIP LOCKED로 큐 만들기

 

이 패턴은 한번 익히면 정말 자주 쓰게 된다. 메시지 큐, 작업 큐, 잡 워커 같은 것을 직접 만들 때 핵심이 되는 패턴이다.

 

-- 워커 N개가 동시에 돌면서 작업을 가져갈 때
BEGIN;
SELECT id, payload
FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;  -- 다른 워커가 이미 잡은 행은 건너뛴다

-- 이 워커가 잡은 행만 업데이트
UPDATE job_queue SET status = 'processing' WHERE id = $1;
COMMIT;

 

SKIP LOCKED가 없으면 워커들이 같은 행을 두고 줄을 서서 대기하게 된다. SKIP LOCKED를 붙이면 "이 행은 이미 누가 잡았으니 다음 행으로" 하고 넘어간다. RabbitMQ 같은 별도의 큐 솔루션을 띄우지 않고 Postgres 한 대로 큐 시스템을 만들 때 쓰는 마법 같은 옵션이다.

 

NOWAIT으로 락 대기 안 하고 바로 실패시키기

 

배치 작업이나 리포트 같은 곳에서 "락이 잡혀 있으면 즉시 실패시키고 나중에 다시 시도" 하고 싶을 때 사용한다.

 

BEGIN;
SELECT * FROM accounts WHERE id = 42 FOR UPDATE NOWAIT;
-- 락이 이미 잡혀 있으면 즉시 ERROR: could not obtain lock 발생
-- 기다리지 않는다

 

이를 쓰지 않고 그냥 FOR UPDATE만 쓰면 다른 트랜잭션이 끝날 때까지 무한정 대기한다. 운영 환경에서 락 대기로 커넥션 풀이 모두 차면 서비스 다운으로 이어진다. NOWAIT 또는 lock_timeout 설정으로 가드를 거는 것이 안전하다.

 

어드바이저리 락으로 분산 락 흉내내기

 

어드바이저리 락(advisory lock)은 Postgres가 제공하는 "애플리케이션이 의미를 정의해서 쓰는 락"이다. 특정 행이나 테이블과 무관하게, 단순히 64비트 정수 키로 락을 잡고 풀 수 있다.

 

-- "주문 처리"라는 작업을 한 번에 하나만 돌고 싶을 때
SELECT pg_advisory_lock(12345);
-- 다른 세션이 같은 키로 lock을 잡으려 하면 대기한다
-- ... 작업 수행 ...
SELECT pg_advisory_unlock(12345);

-- non-blocking 버전
SELECT pg_try_advisory_lock(12345);
-- true가 반환되면 락 획득 성공, false면 실패 (대기하지 않는다)

 

크론 잡 중복 실행 방지, 마이그레이션 락, 분산 시스템에서의 leader election 같은 곳에 자주 사용한다. Redis Redlock 같은 것을 따로 띄우지 않고도 분산 락을 흉내 낼 수 있다.

 

데드락이 났을 때 잡는 법

 

pg_locks와 pg_stat_activity를 조인하면 누가 누구를 막고 있는지 모두 확인할 수 있다.

 

SELECT
  blocked.pid AS blocked_pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query,
  blocking.usename AS blocking_user,
  blocked.wait_event_type
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';

 

이 쿼리 하나만 알아두면 "왜 쿼리가 끝나지 않는가?" 싶을 때 90%는 답이 나온다. 더 깊게 보고 싶으면 pg_locks 뷰를 직접 조회하면 락 모드, 객체 종류, 트랜잭션 ID까지 모두 확인할 수 있다.

 

-- 현재 잡혀 있는 모든 락 보기
SELECT locktype, relation::regclass, mode, granted, pid
FROM pg_locks
ORDER BY granted, pid;

 

MySQL InnoDB와 무엇이 다른가

 

 

출처: enterprisedb.com

 

MySQL을 다루다가 넘어온 사람이 헷갈리는 포인트가 몇 가지 있다. 한 번에 정리한다.

 

항목 MySQL InnoDB PostgreSQL
행 락 단위 인덱스 레코드 락 튜플(tuple) 락
갭 락 / 넥스트 키 락 있음 (REPEATABLE READ에서 활성) 없음
일반 SELECT 격리수준에 따라 락 잡을 수 있음 락 안 잡음 (MVCC)
디폴트 격리수준 REPEATABLE READ READ COMMITTED
외래키 락 자동으로 강한 락 FOR KEY SHARE로 약하게
데드락 감지 자동 감지 + rollback 자동 감지 + rollback
명시적 행 락 `SELECT ... FOR UPDATE` `SELECT ... FOR UPDATE`

 

가장 큰 차이는 갭 락이 없다는 점이다. MySQL은 WHERE id BETWEEN 10 AND 20 같은 범위 쿼리에 FOR UPDATE를 걸면 그 범위 사이의 빈 공간(gap)까지 락을 잡아 새로운 INSERT를 막는다. Postgres는 그렇지 않다. 이미 존재하는 행만 잠근다. 그래서 같은 시나리오에서 MySQL은 phantom read를 막아주지만 Postgres는 막아주지 않는다(직렬화 격리수준 SERIALIZABLE에서만 보장된다).

 

또 하나의 큰 차이는 일반 SELECT의 락 동작이다. Postgres는 거의 모든 케이스에서 SELECT가 다른 트랜잭션을 막지 않는다. MySQL은 격리수준에 따라 다르다. 그래서 Postgres가 "동시성에 더 친화적"이라는 평가를 받는다.

 

운영 함정: 무심코 ACCESS EXCLUSIVE 잡는 DDL

 

 

출처: geeksforgeeks.org

 

이 섹션이 이 글에서 가장 중요할 수도 있다. 입문 글에서는 잘 다루지 않지만, 운영하다 보면 한 번씩 사고가 나는 부분이다.

 

ALTER TABLE ADD COLUMN의 함정

 

-- 9.x ~ 10 버전: 디폴트 값 있는 컬럼 추가
ALTER TABLE huge_table ADD COLUMN created_at TIMESTAMP DEFAULT NOW();
-- ACCESS EXCLUSIVE를 잡고 모든 행을 다시 쓴다
-- 행이 1억 개면 몇 시간 동안 테이블 전체가 잠긴다

 

11버전부터는 이것이 메타데이터 변경만으로 끝난다. 실제 행을 다시 쓰지 않고, 카탈로그에만 "이 컬럼의 디폴트는 X"라고 기록한다. 그래서 거의 즉시 종료된다. 버전이 11 이상인지 반드시 확인하고, 그 이하라면 트래픽이 적은 시간에 수행하거나 아예 디폴트 없이 컬럼을 추가한 뒤 별도로 백필해야 한다.

 

CREATE INDEX의 함정

 

-- 평범한 인덱스 생성: ROW EXCLUSIVE 이상 충돌 (INSERT/UPDATE/DELETE를 모두 막는다)
CREATE INDEX idx_email ON users(email);

-- 운영 환경에서는 반드시 이것을 써야 한다
CREATE INDEX CONCURRENTLY idx_email ON users(email);
-- SHARE UPDATE EXCLUSIVE만 잡고, DML을 막지 않는다
-- 단점: 트랜잭션 안에서 쓸 수 없고, 실패 시 invalid 상태로 남을 수 있다

 

CONCURRENTLY를 빠뜨리고 운영 DB에 인덱스를 만들었다가 서비스가 멈춰본 사람이 정말 많다. 운영 마이그레이션 스크립트에는 반드시 포함되어야 한다.

 

Postgres 마이그레이션 도구

 

MySQL에는 gh-ost나 pt-online-schema-change 같은 무중단 스키마 변경 도구가 있다. Postgres에는 같은 컨셉의 pg_repack이나 pg_squeeze 같은 익스텐션이 있다. 큰 테이블을 정리하거나 재작성할 때 락을 잡지 않고 작업할 수 있다. 운영 규모가 커지면 한 번씩 살펴보는 것이 좋다.

 

Postgres 행 단위 락 핵심 요약과 다음 학습 단계

 

여기까지 왔다면 postgres 행 단위 락에 대해서는 거의 다 파악한 것이다. 핵심만 다시 정리하면 다음과 같다.

 

  • Postgres는 행 락을 당연히 지원한다. 일반 UPDATE/DELETE는 자동으로 행 락을 잡는다.
  • 일반 SELECT는 락을 잡지 않는다. MVCC 덕분에 읽기/쓰기가 서로 막지 않는다.
  • 명시적 행 락은 SELECT ... FOR UPDATE 하나로 90%가 해결된다.
  • SKIP LOCKED로 큐 패턴, NOWAIT으로 락 대기 회피, pg_advisory_lock으로 분산 락 구현이 가능하다.
  • 운영에서는 ACCESS EXCLUSIVE를 잡는 DDL을 조심해야 한다. 인덱스는 반드시 CONCURRENTLY를 붙여야 한다.

 

다음에 더 깊이 파볼 만한 주제는 다음과 같다: Postgres MVCC가 내부적으로 어떻게 동작하는지(튜플 버전 관리, vacuum), 트랜잭션 격리수준 4단계(READ UNCOMMITTED ~ SERIALIZABLE)별 동작 차이, pg_stat_activity로 슬로우 쿼리를 잡는 디버깅 패턴이다. 락 이슈로 운영 장애를 한 번 겪고 나면 이 주제들이 진짜 와닿게 된다.

 

참고할 만한 외부 자료는 PostgreSQL 공식 문서의 Explicit Locking 챕터pg_locks 카탈로그 뷰 페이지이다. 영어지만 정답지에 가까우니, 이 글을 읽고 한 번 훑어보면 머릿속에 그림이 완전히 자리잡는다.

 

락 때문에 운영 장애가 났던 경험이나, "이 패턴은 이렇게 하면 더 좋더라" 같은 노하우가 있다면 댓글로 공유해주면 좋겠다. 다른 사람에게도 도움이 될 것이다.

728x90
반응형
Share Link
reply
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31