동시성 제어
두 편집기가 같은 글의 버전 0을 읽었다. A가 먼저 저장했는데 B가 옛 본문을 그대로 저장하면 A의 수정이 사라질 수 있다. 동시성 제어는 이처럼 겹쳐 진행되는 작업이 어떤 순서와 조건으로 공유 데이터를 바꿀지 정한다. 여기서는 먼저 작업할 권리를 확보하는 잠금과, 저장할 때 읽어둔 버전을 검사하는 방법을 비교한다.
잠금의 규칙과 낙관적 검증의 역사
Eswaran·Gray·Lorie·Traiger의 1976년 논문은 트랜잭션·일관성·실행 순서를 형식화하고, 잠금 프로토콜과 논리적 조건으로 지정한 데이터 집합의 보호를 다뤘다. 1974년 IBM 연구 보고서 선행본도 있으므로 1976년을 잠금이나 트랜잭션의 최초 발명년으로 단정할 수는 없다.1
Kung과 Robinson은 1981년 논문에서 지역 복사본을 다루는 읽기 단계, 충돌을 확인하는 검증 단계, 변경을 반영하는 쓰기 단계를 설명했다. 읽기·쓰기 집합으로 트랜잭션의 충돌을 검증하는 알고리즘이다. 웹 API에서 자주 쓰는 한 행의 버전 검사는 이 생각을 작은 범위에 적용하지만, 논문의 전체 검증 알고리즘을 구현한 것과는 구별한다.2
두 방식의 차이는 “잠금이 있느냐 없느냐”로만 설명하기 어렵다. 낙관적 조건부 갱신도 DB 내부에서는 쓰기 잠금을 사용할 수 있고, 원논문 역시 검증과 쓰기의 임계구역을 다룬다. 애플리케이션이 충돌을 언제 발견하고 사용자에게 어떤 선택을 돌려주는지가 중요한 차이다.
비교와 쓰기를 한 문장에 넣기
다음 PostgreSQL 예제는 한 글을 버전 0에서 1로 바꾼 뒤, 버전 0을 들고 온 다른 수정 요청을 거절한다. 순서가 정해진 두 쓰기로 오래된 요청의 처리 계약을 확인한다.
CREATE TEMP TABLE posts (
id integer PRIMARY KEY,
body text NOT NULL,
version bigint NOT NULL CHECK (version >= 0)
);
INSERT INTO posts VALUES (1, 'original', 0);
UPDATE posts
SET body = 'edited by A', version = version + 1
WHERE id = 1 AND version = 0
RETURNING body, version;
UPDATE posts
SET body = 'edited by B', version = version + 1
WHERE id = 1 AND version = 0
RETURNING body, version;
SELECT body, version FROM posts WHERE id = 1;
첫 갱신은 edited by A, 1을 반환하고 두 번째는 0행을 반환한다. 최종 내용은 A의 수정이다. 호출자는 0행을 성공으로 처리하지 말고 “없거나, 읽어둔 버전과 맞지 않는다”는 사전조건 실패로 알려야 한다. 두 경우를 따로 구별하려면 추가 조회의 시점과 정보 노출 정책도 정한다.
버전을 먼저 조회한 뒤 조건 없는 UPDATE를 보내면 두 문장 사이에 다른 요청이 끼어들 수 있다. 위처럼 저장 문장의 WHERE에 조건을 넣어 DB가 비교와 변경을 함께 처리하도록 한다. HTTP의 ETag와 If-Match도 읽은 표현이 여전히 일치할 때만 변경한다는 조건을 전달하는 방법이며, 서버는 그 조건을 실제 저장까지 연결해야 한다.3
먼저 잠그고 작업한다면
PostgreSQL에서 트랜잭션 안의 SELECT ... FOR UPDATE는 선택한 행을 잠가 충돌하는 갱신이나 잠금 획득을 기다리게 할 수 있다. 잠금은 트랜잭션이 끝날 때 해제된다. 일반 스냅샷 SELECT까지 모두 막는 잠금은 아니므로, 무엇이 기다리는지 구별해서 읽어야 한다.4
이 방식은 짧은 트랜잭션 안에서 같은 대상을 검사하고 바꿀 때 유용하다. 사람이 편집하는 몇 분 동안 DB 잠금을 계속 잡는 대신, 편집 완료 시 버전을 검사해 충돌을 보여주는 방식이 더 어울리는 상황도 있다. 이는 사용자 대기 시간과 충돌 처리 방식을 고르는 문제이며, 어느 방식이 항상 빠르다는 결론은 데이터와 부하 측정 없이 낼 수 없다.
한 행 밖의 규칙은 누가 지킬까
자원 7의 예약이 아직 없다면 WHERE resource = 7 FOR UPDATE는 잠글 기존 행도 찾지 못한다. 두 트랜잭션이 모두 0행을 읽고 각각 예약을 넣을 수 있으므로, PostgreSQL의 행 잠금을 “앞으로 생길 행까지 잠근다”고 이해하면 규칙에 빈틈이 생긴다. 자원당 예약 하나가 정확한 규칙이라면 UNIQUE(resource)가 이를 직접 표현한다. 여러 행의 합계나 조회 조건이 규칙이라면 대표 행의 잠금, 적합한 제약, 직렬가능한 실행 등 그 규칙에 맞는 보호가 필요하다.14
버전 검사에도 수명 조건이 있다. 글을 삭제하고 같은 ID·버전 0으로 다시 만들면, 예전 버전 0 요청이 새 글에 일치할 수 있다. 모든 쓰기가 버전을 올리는지, ID를 다시 쓰는지, 다른 행까지 읽은 판단을 무엇이 검증하는지 함께 확인한다. 멱등성이 같은 요청의 반복 효과를 다룬다면, 버전 검사는 그 요청이 어떤 상태를 기준으로 했는지 검사한다.
예제 환경: PostgreSQL 18. 한 행의 조건부 갱신을 보여주며 여러 행 전체의 직렬가능성을 증명하는 코드는 아니다.
각주
- K. P. Eswaran, J. N. Gray, R. A. Lorie, I. L. Traiger, The Notions of Consistency and Predicate Locks in a Database System, CACM 19(11), 1976, pp.624-633, §2-3. 저자 자료. ↩
- H. T. Kung, John T. Robinson, On Optimistic Methods for Concurrency Control, ACM TODS 6(2), 1981, pp.213-226, 그림 1 및 §3-4. 저자 제공 논문. ↩
- RFC 9110, §13.1.1 “If-Match”. 표준. ↩
- PostgreSQL 18, §13.3 “Explicit Locking”, 특히 행 잠금과 교착상태. 공식 문서. ↩