15 KiB
Lock 기준
1. 목적
이 문서는 PostgreSQL과 JPA/Hibernate 환경에서 명시적 락(explicit lock) 을 어떤 기준으로 사용할지 정의한다.
이 문서의 목표는 다음과 같다.
- 낙관적 락, 제약, 원자적 UPDATE로 해결 가능한 문제와 명시적 락이 필요한 문제를 구분한다
- PostgreSQL의 row-level lock과 table-level lock을 혼동하지 않게 한다
FOR UPDATE,FOR NO KEY UPDATE,FOR SHARE,FOR KEY SHARE,NOWAIT,SKIP LOCKED의 의미를 안전하게 해석한다- Spring Data JPA의
@Lock과 JPAPESSIMISTIC_*를 PostgreSQL 락 의미와 연결해서 사용한다
PostgreSQL은 explicit locking이 MVCC만으로 원하는 동작을 얻을 수 없을 때 애플리케이션이 직접 사용하는 도구라고 설명한다. 따라서 락은 기본값이 아니라 예외적 수단으로 보는 것이 맞다.
2. 근거 수준
- Official: PostgreSQL / Jakarta Persistence / Spring Data JPA 공식 문서에서 직접 확인되는 내용
- Official + Practice: 공식 동작 위에 일반적인 실무 best practice를 결합한 내용
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
이번 문서는 PostgreSQL의 explicit locking, SELECT locking clause, timeout 설정 문서와 Jakarta Persistence 3.2의 LockModeType, pessimistic lock timeout/scope, Spring Data JPA의 @Lock 문서를 근거로 한다.
3. 기본 원칙
3.1 명시적 락은 기본 해법이 아니다
PostgreSQL은 explicit locking을 “MVCC가 원하는 동작을 주지 않을 때” 사용하는 application-controlled locking이라고 설명한다. 따라서 프로젝트 기본 원칙은 다음 순서다. 먼저 UNIQUE/FK/CHECK 같은 제약, ON CONFLICT, 조건부 UPDATE, @Version 같은 더 직접적이고 덜 무거운 수단을 검토하고, 그래도 해결되지 않을 때만 명시적 락을 올린다.
3.2 기본 대상은 table lock이 아니라 row lock이다
PostgreSQL은 table-level lock과 row-level lock을 구분하며, 일반적인 비즈니스 동시성 제어에서는 row-level lock이 더 직접적이다. 또한 일반 SELECT는 ACCESS SHARE를 잡고, plain SELECT를 막는 유일한 table-level lock은 ACCESS EXCLUSIVE 라고 설명한다. 프로젝트 기본값은 LOCK TABLE이 아니라 필요한 row만 잠그는 것이다.
3.3 락은 가능한 짧게 잡는다
PostgreSQL은 row-level lock과 table-level lock이 보통 트랜잭션 종료 시점까지 유지되고, savepoint 이후 잡은 락은 savepoint rollback 시 해제된다고 설명한다. 또한 deadlock을 피하는 최선의 방어는 일관된 순서로 잠그는 것과 트랜잭션을 길게 잡지 않는 것이라고 설명한다. 프로젝트에서도 락을 잡은 뒤 외부 호출, 사용자 대기, 긴 계산을 넣지 않는다.
4. PostgreSQL 락 모델 기준
4.1 row-level lock은 일반 조회를 막지 않는다
PostgreSQL은 row-level lock이 plain read를 막지 않고, 같은 row에 대한 writer와 locker만 막는다고 설명한다. 따라서 row lock은 “모든 접근 차단”이 아니라 “같은 row의 경쟁 갱신/락 획득 제어”로 이해해야 한다. 이 점을 오해하면 불필요하게 table lock을 선택하게 된다.
4.2 join query의 락 범위는 명시적으로 제한한다
PostgreSQL SELECT ... FOR ... 문법은 FOR lock_strength [ OF from_reference ... ] 형태를 지원하고, table list를 생략하면 statement에 사용된 모든 테이블에 적용될 수 있다고 설명한다. 따라서 join query에서 특정 alias만 잠그려면 OF o처럼 대상을 명시하는 것이 더 안전하다. 프로젝트에서는 join query + row lock 조합에서 락 대상 alias 명시를 기본 검토한다.
4.3 LIMIT과 락을 함께 쓰면 충분한 row를 찾는 시점까지만 잠근다
PostgreSQL은 locking clause와 LIMIT를 함께 쓰면 필요한 수의 row를 반환할 만큼까지만 locking이 진행된다고 설명한다. 하지만 OFFSET으로 건너뛴 row도 잠길 수 있다고 명시한다. 따라서 queue claim 같은 락 기반 조회에서 OFFSET은 기본 금지다.
5. row-level lock 강도 선택 기준
5.1 FOR UPDATE
PostgreSQL은 FOR UPDATE가 선택된 row를 현재 트랜잭션 종료 시까지 잠그고, 다른 트랜잭션의 UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR NO KEY UPDATE, SELECT FOR SHARE, SELECT FOR KEY SHARE를 막는다고 설명한다. 가장 강한 row lock이므로, 삭제 또는 key 변경을 포함한 강한 배타 제어가 필요할 때 사용한다.
5.2 FOR NO KEY UPDATE
PostgreSQL은 FOR NO KEY UPDATE가 FOR UPDATE와 유사하지만 더 약하며, SELECT FOR KEY SHARE를 막지 않는다고 설명한다. 또한 key 변경을 동반하지 않는 일반 UPDATE는 이 수준의 잠금을 획득한다고 설명한다. 프로젝트에서는 row를 나중에 갱신할 예정이지만 key 보존이 전제인 경우, SQL 레벨에서는 FOR UPDATE보다 FOR NO KEY UPDATE를 먼저 검토한다.
5.3 FOR SHARE
PostgreSQL은 FOR SHARE가 shared row lock을 획득하고, 다른 트랜잭션의 UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR NO KEY UPDATE를 막지만, 다른 FOR SHARE와 FOR KEY SHARE는 허용한다고 설명한다. 프로젝트에서는 읽은 값을 트랜잭션 끝까지 변경/삭제되지 않게 보호하면서, 다른 shared reader는 허용하고 싶은 경우에 제한적으로 사용한다.
5.4 FOR KEY SHARE
PostgreSQL은 FOR KEY SHARE가 더 약한 shared row lock이며, DELETE나 key 값을 바꾸는 UPDATE는 막지만, 일반 non-key UPDATE와 다른 FOR KEY SHARE/FOR SHARE는 허용한다고 설명한다. 프로젝트에서는 부모 row가 삭제되거나 참조 key가 바뀌지 않도록 보호하는 수준이 필요할 때만 검토한다.
5.5 항상 가장 약한 충분 조건을 선택한다
PostgreSQL은 row-level lock mode마다 충돌 관계가 다르다. 따라서 프로젝트 기본 원칙은 “익숙하니 무조건 FOR UPDATE”가 아니라, 필요한 보호 수준을 충족하는 가장 약한 row lock을 고르는 것이다. 락 강도가 강할수록 대기와 deadlock 가능성도 커진다.
6. NOWAIT, SKIP LOCKED, timeout 기준
6.1 기본 wait 전략을 무심코 두지 않는다
PostgreSQL은 conflicting lock이 있으면 row/table lock 요청이 해제될 때까지 기다릴 수 있고, deadlock이 아니면 오래 대기할 수 있다고 설명한다. 또한 lock_timeout은 lock 획득을 기다리는 동안만 적용되는 별도 timeout이며, 전역 postgresql.conf에서 세션 전체에 거는 것은 권장하지 않는다고 설명한다. 프로젝트에서는 “무한 대기”를 기본값으로 방치하지 않고, API 성격에 따라 NOWAIT 또는 제한된 timeout을 검토한다.
6.2 NOWAIT는 fail-fast가 필요할 때 사용한다
PostgreSQL은 NOWAIT가 락을 즉시 잡지 못하면 기다리지 않고 오류를 반환한다고 설명한다. 따라서 사용자 요청 처리처럼 지금 선점할 수 없으면 즉시 충돌 응답을 주는 것이 맞는 작업에는 NOWAIT가 적합하다. 프로젝트에서는 “동시에 하나만 처리해야 하는데 기다리기보다 실패가 낫다”는 경우에만 사용한다.
6.3 SKIP LOCKED는 queue-like workload에만 제한한다
PostgreSQL은 SKIP LOCKED가 잠긴 row를 건너뛰며, 이 방식이 inconsistent view를 만들기 때문에 general-purpose work에는 적합하지 않지만 queue-like table을 여러 consumer가 처리할 때는 유용할 수 있다고 설명한다. 프로젝트에서도 SKIP LOCKED는 작업 큐 선점에만 허용하고, 일반 목록 조회나 관리자 화면에는 금지한다.
6.4 JPA jakarta.persistence.lock.timeout은 힌트일 뿐이다
Jakarta Persistence는 jakarta.persistence.lock.timeout을 pessimistic locking용 timeout 값(밀리초)으로 정의하지만, hint only라고 설명한다. 즉 provider가 관찰하려고 노력해야 하는 값이지, DB별로 완전히 같은 방식으로 강제된다고 가정하면 안 된다. 프로젝트에서는 JPA hint를 사용하더라도 DB/driver/provider 조합에서 실제 동작을 검증한다.
7. JPA / Spring Data JPA 기준
7.1 Spring Data JPA의 @Lock은 query method에 lock mode intent를 붙이는 수단이다
Spring Data JPA는 repository query method나 CRUD 재선언 메서드에 @Lock을 붙여 LockModeType을 적용할 수 있다고 설명한다. 따라서 JPA 계층에서 비관적 락을 사용할 때는 repository method에 의도적으로 선언하고, 일반 조회 메서드에 무심코 넓게 붙이지 않는다.
7.2 JPA의 pessimistic lock은 PESSIMISTIC_READ, PESSIMISTIC_WRITE, PESSIMISTIC_FORCE_INCREMENT를 기준으로 해석한다
Jakarta Persistence는 PESSIMISTIC_READ, PESSIMISTIC_WRITE, PESSIMISTIC_FORCE_INCREMENT가 즉시 장기 DB 락을 얻는 모드라고 설명한다. 또한 PESSIMISTIC_READ는 다른 트랜잭션의 read를 막지 않으면서 repeatable-read 성격의 보호를 제공하고, PESSIMISTIC_WRITE는 update 시도들 사이의 직렬화를 강제할 수 있다고 설명한다. 프로젝트에서는 JPA 레벨에서 기본은 PESSIMISTIC_WRITE와 PESSIMISTIC_READ만 제한적으로 사용하고, PESSIMISTIC_FORCE_INCREMENT는 버전 증가 의미가 분명한 경우에만 예외적으로 검토한다.
7.3 비관적 락 실패 예외는 두 종류로 나뉜다
Jakarta Persistence는 pessimistic locking 실패가 transaction-level rollback을 일으키면 PessimisticLockException을 던지고, statement-level rollback만 일으키면 LockTimeoutException을 던지며 현재 트랜잭션은 rollback 표시되지 않을 수 있다고 설명한다. 따라서 프로젝트에서는 두 예외를 같은 것으로 보지 않고, 특히 LockTimeoutException을 “statement 실패”로 다룰지 “유스케이스 실패”로 올릴지 application boundary에서 명확히 정한다.
7.4 PessimisticLockScope.EXTENDED는 기본값이 아니다
Jakarta Persistence는 pessimistic locking의 기본 scope가 NORMAL이고, EXTENDED를 쓰면 join table/collection table에 들어 있는 owned relationship과 element collection까지 lock 범위가 넓어진다고 설명한다. 하지만 이 경우에도 참조된 엔티티 상태 자체가 잠기는 것은 아니고, phantom은 여전히 가능하다고 설명한다. 프로젝트에서는 scope를 넓힌다고 “연관 엔티티 전체를 잠근다”고 오해하지 않으며, EXTENDED는 기본 금지다.
8. deadlock 기준
8.1 deadlock은 DB가 자동 감지하지만, 예방이 더 중요하다
PostgreSQL은 deadlock을 자동으로 감지하고, 관련 트랜잭션 중 하나를 abort해서 다른 쪽이 진행되게 만든다고 설명한다. 하지만 어느 쪽이 죽는지는 예측할 수 없고 의존해서는 안 된다. 따라서 프로젝트 기본 원칙은 항상 같은 순서로 잠그고, 처음부터 필요한 가장 강한 락을 잡는 것이다.
8.2 deadlock은 재시도 대상이지만, 부분 SQL만 재실행하지 않는다
PostgreSQL은 deadlock으로 abort된 트랜잭션은 on-the-fly retry로 처리할 수 있다고 설명한다. 프로젝트에서는 deadlock 재시도가 필요하면 유스케이스 전체를 새 트랜잭션에서 다시 수행하고, 중간 SQL 몇 줄만 재실행하는 방식은 금지한다.
9. 프로젝트 권장안
9.1 기본 선택 순서
프로젝트의 기본 선택 순서는 다음과 같다.
- 제약,
ON CONFLICT, 조건부 UPDATE,@Version으로 해결 가능한지 먼저 본다 - 그래도 “현재 존재하는 특정 row를 선점해야” 한다면 row-level pessimistic lock을 검토한다
- queue consumer라면
SKIP LOCKED를 검토하되, 일반 업무 조회에는 쓰지 않는다 - table lock은 migration/DDL/운영 작업 수준이 아니면 기본 금지다
이 순서는 PostgreSQL이 explicit locking을 MVCC의 보완 수단으로 설명하고, SKIP LOCKED도 queue-like table에 제한적으로 적합하다고 설명하는 점을 프로젝트 규칙으로 압축한 것이다.
9.2 일반 비즈니스 row 수정은 lock보다 원자적 update를 먼저 검토한다
단순 상태 전이, 카운터 차감, 중복 방지는 lock보다 단일 SQL 조건식이 더 작고 명확한 해결책인 경우가 많다. explicit lock은 “선택한 row를 이후 로직 동안 반드시 잡고 있어야 하는” 경우에만 올린다. 이는 PostgreSQL의 MVCC/explicit lock 구분과 SELECT FOR UPDATE의 대기 비용을 함께 고려한 best practice다.
9.3 join query에서는 OF 를 기본 검토한다
join query에 locking clause를 붙일 때 table list를 생략하면 더 넓은 범위가 잠길 수 있다. 프로젝트에서는 lock 대상이 하나의 aggregate root alias로 명확하면 FOR UPDATE OF o처럼 범위를 좁히는 것을 기본으로 한다.
9.4 queue claim query는 ORDER BY와 함께 설계한다
PostgreSQL은 LIMIT query는 ORDER BY가 없으면 예측 가능한 subset을 보장하지 않는다고 설명하고, SKIP LOCKED는 lock contention 회피용 queue-like case에 적합하다고 설명한다. 프로젝트에서는 queue claim query에 결정적 ORDER BY + LIMIT + SKIP LOCKED 를 함께 검토하고, OFFSET은 쓰지 않는다.
10. 문서 경계
이 문서는 명시적 락의 선택 기준과 사용 규칙을 다룬다.
다음 내용은 별도 문서에서 확장한다.
- optimistic locking과
@Version - isolation level 선택
- serialization failure / deadlock retry 정책
- queue/outbox/idempotency 전체 설계
- advisory lock / distributed lock
11. 금지 규칙
다음은 기본 금지다.
- 중복 생성, 단순 상태 전이, lost update 문제를 무조건 pessimistic lock으로 해결하는 것
LOCK TABLE을 일반 비즈니스 row 처리에 사용하는 것SKIP LOCKED를 일반 목록 API나 관리자 검색 화면에 사용하는 것OFFSET과 row lock을 함께 써서 queue claim 범위를 흐리게 만드는 것- join query에서 lock 대상 alias를 고려하지 않고 넓게 잠그는 것
- 락을 잡은 뒤 외부 API 호출, 긴 계산, 사용자 입력 대기를 넣는 것
PESSIMISTIC_WRITE를 대량 목록/페이지 조회에 거는 것PessimisticLockException과LockTimeoutException을 같은 의미로 처리하는 것PessimisticLockScope.EXTENDED를 연관 엔티티 전체 잠금으로 오해하는 것
이 금지 규칙은 PostgreSQL locking clause, explicit locking, timeout, deadlock 문서와 Jakarta Persistence의 pessimistic lock/timeout/scope 규정을 실무 운영 기준으로 요약한 것이다.
12. 체크리스트
다음 질문에 “예”로 답할 수 있어야 한다.
- 이 문제는 정말 explicit lock이 필요한가?
- row lock이면 충분한데 table lock으로 올리지 않았는가?
FOR UPDATE보다 약한 lock mode로 충분하지 않은가?- join query라면
OF <alias>로 lock 대상을 좁혔는가? - 사용자 요청이라면
NOWAIT또는 bounded wait가 더 맞지 않는가? SKIP LOCKED를 queue-like workload에서만 쓰고 있는가?- queue claim query에 결정적
ORDER BY가 있는가? OFFSET과 row lock을 함께 쓰고 있지 않은가?- JPA에서 lock failure 예외 종류를 구분하고 있는가?
- 락을 잡은 트랜잭션이 짧게 끝나는가?
- 여러 row/object를 잠글 때 항상 같은 순서를 지키는가?