동시성 제어 (MVCC/Concurrency)

동시성 제어 (MVCC/Concurrency)

출처: PostgreSQL 공식 문서 18, Chapter 13. Concurrency Control 원문 URL: https://www.postgresql.org/docs/current/mvcc.html (본문은 공식 문서의 동시성 제어 장을 한국어로 풀어 쓴 해설입니다. 기술 용어와 동작 규칙은 원문을 그대로 따릅니다.)


13.1. 소개

두 세션 이상이 동시에 같은 데이터를 접근하려고 할 때, PostgreSQL이 어떤 식으로 행동하는지가 이 장의 핵심이에요. 이 상황에서 목표는 단순히 "모두 빠르게"가 아니라, 모든 세션이 효율적으로 접근하면서도 데이터 무결성을 철저하게 지키는 것이에요. 데이터베이스 애플리케이션을 만드는 개발자라면 이 장에서 다루는 내용은 한 번쯤 익숙해질 필요가 있어요.


13.2. 트랜잭션 격리 수준 (Transaction Isolation)

SQL 표준은 네 가지 트랜잭션 격리 수준을 정의해요. 가장 엄격한 것은 Serializable인데, 표준의 정의를 보면 "일련의 Serializable 트랜잭션을 동시에 실행해도, 그 트랜잭션들을 어떤 순서로 하나씩 차례로 실행한 것과 같은 효과가 나온다"는 보장을 해요. 나머지 세 격리 수준은 '현상(phenomena)'이라는 개념으로 정의돼요. 이 현상들은 동시 트랜잭션들이 서로 간섭하면서 생기는 것들인데, 각 수준에서 "이런 현상은 일어나면 안 된다"고 금지하는 식이에요. 그런데 Serializable의 정의 자체가 "하나씩 돌린 것과 결과가 같아야 한다"는 것이니, 그 수준에서는 아래 나오는 현상들이 아예 불가능한 게 당연해요.

각 수준에서 금지되는 현상들은 이런 것들이에요.

dirty read (오염 읽기)

한 트랜잭션이, 아직 커밋되지 않은 동시 트랜잭션의 데이터를 읽는 것을 말해요.

nonrepeatable read (반복 불가 읽기)

한 트랜잭션이 전에 읽었던 데이터를 다시 읽었는데, 그 사이에 다른 트랜잭션(최초 읽기 이후 커밋된)이 그 데이터를 바꿔놓은 것을 발견하는 현상이에요.

phantom read (유령 읽기)

한 트랜잭션이 특정 검색 조건을 만족하는 행 집합을 반환하는 쿼리를 다시 실행했을 때, 그 사이에 새로 커밋된 트랜잭션 때문에 조건을 만족하는 행 집합이 바뀌어 있는 현상이에요.

serialization anomaly (직렬화 이상)

한 무리의 트랜잭션들이 성공적으로 커밋했는데, 그 결과가 그 트랜잭션들을 하나씩 어떤 순서로 돌렸을 때의 모든 가능한 결과와 일치하지 않는 경우를 말해요.

SQL 표준과 PostgreSQL이 실제로 구현한 격리 수준의 대응은 아래 표와 같아요.

격리 수준 오염 읽기 반복 불가 읽기 유령 읽기 직렬화 이상
Read Uncommitted 허용 (PG에선 아님) 가능 가능 가능
Read Committed 불가능 가능 가능 가능
Repeatable Read 불가능 불가능 허용 (PG에선 아님) 가능
Serializable 불가능 불가능 불가능 불가능

PostgreSQL에서는 네 가지 표준 격리 수준을 전부 요청할 수는 있어요. 하지만 내부적으로 구현된 건 세 가지만이에요. 즉, PostgreSQL의 Read Uncommitted 모드는 Read Committed처럼 동작해요. 표준 격리 수준을 PostgreSQL의 멀티버전 동시성 제어(MVCC) 구조에 매핑하는 가장 합리적인 방법이 이거기 때문이에요.

표에서 또 눈여겨볼 점이 있어요. PostgreSQL의 Repeatable Read 구현은 유령 읽기(phantom read)를 허용하지 않아요. 이것도 SQL 표준에 어긋나지 않아요. 표준은 각 격리 수준에서 "이 현상이 일어나면 안 된다"는 최소 보장만 규정할 뿐, 더 강한 보장을 하는 것 자체는 허용하기 때문이에요.

트랜잭션의 격리 수준을 설정하려면 SET TRANSACTION 명령을 쓰면 돼요.

중요: PostgreSQL의 일부 데이터 타입과 함수는 트랜잭션 동작에 관한 특별한 규칙이 있어요. 특히 sequence(그리고 거기서 파생된 serial 컬럼의 카운터)의 변경은 그 즉시 모든 다른 트랜잭션에 보이게 되고, 변경한 트랜잭션이 중단(abort)되더라도 롤백되지 않아요.

13.2.1. Read Committed 격리 수준

Read Committed는 PostgreSQL의 기본 격리 수준이에요. 이 수준의 트랜잭션에서 SELECT 쿼리(FOR UPDATE/SHARE 없는)는 쿼리가 시작되기 전에 커밋된 데이터만 보게 돼요. 아직 커밋되지 않은 데이터나, 쿼리 실행 중에 다른 트랜잭션이 커밋한 변경은 절대 못 봐요. 쉽게 말해 SELECT는 쿼리가 시작되는 순간의 데이터베이스 스냅샷을 보는 셈이에요.

그런데 한 가지 주의할 점이 있어요. SELECT는 자기 자신의 트랜잭션 안에서 이전에 실행한 UPDATE의 효과는 아직 커밋되지 않았더라도 볼 수 있어요. 그리고 같은 트랜잭션 안에 있더라도, 두 개의 연속된 SELECT는 다른 데이터를 볼 수 있어요. 첫 SELECT가 시작된 후 두 번째 SELECT가 시작되기 전에 다른 트랜잭션이 커밋했다면 그렇죠.

UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR SHARE 명령도 대상 행을 찾는 방식은 SELECT와 같아요. 즉, 명령 시작 시점에 커밋된 대상 행만 찾아요. 하지만 그런 대상 행이, 우리가 그것을 찾는 시점에 이미 다른 동시 트랜잭션에 의해 갱신(또는 삭제, 잠금)되어 있을 수 있어요. 이 경우 갱신하려던 쪽은, 먼저 갱신한 트랜잭션이 커밋되거나 롤백될 때까지 기다려요. 먼저 갱신한 트랜잭션이 롤백되면 그 효과가 무효화되므로, 두 번째 갱신자는 원래 찾은 행으로 진행할 수 있어요. 만약 먼저 갱신한 트랜잭션이 커밋했다면, 두 번째 갱신자는 행이 삭제됐으면 그 행을 무시하고, 그렇지 않으면 갱신된 버전의 행에 자기 작업을 적용하려고 해요. 이때 명령의 검색 조건(WHERE 절)을 다시 평가해서, 갱신된 버전의 행이 여전히 그 조건을 만족하는지 확인해요. 만족하면 두 번째 갱신자는 그 행의 갱신 버전을 사용해서 작업을 진행해요. SELECT FOR UPDATESELECT FOR SHARE의 경우에는, 갱신된 버전의 행이 잠기고 클라이언트에 반환되는 것이에요.

INSERTON CONFLICT DO UPDATE 절도 비슷하게 동작해요. Read Committed 모드에서 삽입하려는 각 행은 삽입되거나 갱신되는데, 관련 없는 오류가 없다면 그 둘 중 하나의 결과는 반드시 보장돼요. 만약 충돌이 INSERT 스냅샷에서 아직 보이지 않는 다른 트랜잭션의 효과에서 비롯됐다면, 그 행의 어떤 버전도 이 명령에 관례적으로 보이지 않을 수 있는데도 UPDATE 절이 그 행에 적용돼요.

ON CONFLICT DO NOTHING 절도, INSERT 스냅샷에서 보이지 않는 다른 트랜잭션의 결과 때문에 어떤 행의 삽입이 진행되지 않을 수 있어요. 이것도 Read Committed 모드에서만 해당하는 얘기예요.

MERGEINSERT, UPDATE, DELETE 하위 명령의 다양한 조합을 지정하게 해줘요. INSERTUPDATE를 모두 가진 MERGEON CONFLICT DO UPDATE가 있는 INSERT와 비슷해 보이지만, INSERT 또는 UPDATE 중 하나가 반드시 일어난다는 보장은 없어요. MERGEUPDATEDELETE를 시도했는데 행이 동시에 갱신됐지만 조인 조건이 여전히 현재 대상·원본 튜플에 대해 통과한다면, MERGE는 그 UPDATE/DELETE 명령들처럼 행의 갱신된 버전에 작업을 수행해요. 하지만 MERGE는 여러 동작을 지정하고 그것들이 조건부일 수 있기 때문에, 각 동작의 조건이 갱신된 행 버전에서 재평가되며, 원래 매칭된 동작이 동작 목록에서 나중에 나타나더라도 첫 동작부터 차례로 재평가돼요. 반면 행이 동시에 갱신되어 조인 조건이 실패하면, MERGE는 명령의 NOT MATCHED BY SOURCENOT MATCHED [BY TARGET] 동작을 다음에 평가하고, 각 종류에서 성공하는 첫 동작을 실행해요. 행이 동시에 삭제되면 MERGENOT MATCHED [BY TARGET] 동작을 평가하고 성공하는 첫 번째 것을 실행해요. MERGEINSERT를 시도하는데 유니크 인덱스가 있고 중복 행이 동시에 삽입되면 유니크 위반 오류가 발생해요. MERGEMATCHED 조건의 평가를 다시 시작하는 식으로 그런 오류를 피하려고 하지 않아요.

이런 규칙 때문에, 갱신 명령이 불일치한 스냅샷을 볼 수가 있어요. 즉, 자기 자신이 갱신하려는 같은 행에 대한 동시 갱신 명령의 효과는 볼 수 있지만, 데이터베이스의 다른 행에 대한 그 명령들의 효과는 보지 못해요. 이 성질은 복잡한 검색 조건을 쓰는 명령에 Read Committed가 부적합하게 만들어요. 하지만 단순한 경우에는 딱 맞아요. 예를 들어 한 계좌에서 다른 계좌로 $100을 이체하는 경우를 생각해볼게요.

BEGIN;
UPDATE accounts SET balance = balance + 100.00 WHERE acctnum = 12345;
UPDATE accounts SET balance = balance - 100.00 WHERE acctnum = 7534;
COMMIT;

만약 다른 트랜잭션이 동시에 계좌 7534의 잔액을 바꾸려 한다면, 두 번째 문장이 그 계좌 행의 갱신된 버전으로 시작하기를 분명히 원할 거예요. 각 명령이 미리 정해진 행 하나만 건드리기 때문에, 갱신된 버전의 행을 보게 해도 문제있는 불일치는 생기지 않아요.

반면 더 복잡한 사용은 Read Committed에서 바람직하지 않은 결과를 낼 수 있어요. 예를 들어 한 명령이 다른 명령에 의해 그 제한 기준에 더해지고 제거되고 있는 데이터를 대상으로 하는 DELETE를 생각해볼게요. website가 두 행짜리 테이블이고 hits가 9와 10이라고 가정해요.

BEGIN;
UPDATE website SET hits = hits + 1;
-- 다른 세션에서 실행:  DELETE FROM website WHERE hits = 10;
COMMIT;

UPDATE 전후에 hits = 10인 행이 있는데도 DELETE는 아무 효과가 없어요. 갱신 전 행 값 9가 건너뛰어지고, UPDATE가 끝나고 DELETE가 잠금을 얻었을 때 새 행 값은 더 이상 10이 아니라 11이라서 조건을 만족하지 않기 때문이에요.

Read Committed 모드는 각 명령을 그 순간까지 커밋된 모든 트랜잭션을 포함하는 새 스냅샷으로 시작하기 때문에, 같은 트랜잭션의 이후 명령들은 어쨌든 커밋된 동시 트랜잭션의 효과를 보게 돼요. 위에서 문제가 된 지점은, 하나의 명령이 데이터베이스의 완전히 일관된 뷰를 보는지 여부예요.

Read Committed가 주는 부분적인 트랜잭션 격리는 많은 애플리케이션에 충분하고, 이 모드는 빠르고 쓰기도 간단해요. 하지만 모든 경우에 충분하진 않아요. 복잡한 쿼리와 갱신을 하는 애플리케이션은 Read Committed가 주는 것보다 더 엄격하게 일관된 데이터베이스 뷰를 요구할 수 있어요.

13.2.2. Repeatable Read 격리 수준

Repeatable Read 격리 수준은 트랜잭션이 시작되기 전에 커밋된 데이터만 봐요. 트랜잭션 실행 중 동시 트랜잭션이 커밋한 변경이나, 아직 커밋되지 않은 데이터는 절대 보지 못해요. (다만 각 쿼리는 자기 트랜잭션 안에서 이전에 실행한 UPDATE의 효과는 아직 커밋되지 않았더라도 볼 수 있어요.) 이건 SQL 표준이 이 격리 수준에 요구하는 것보다 더 강한 보장이고, 위 표에 있는 현상 중 직렬화 이상(serialization anomaly)을 제외한 모든 현상을 막아요. 앞서 말했듯 표준은 각 격리 수준이 반드시 제공해야 할 최소 보호만 규정하므로, 더 강한 보장은 명시적으로 허용돼요.

이 수준이 Read Committed와 다른 점은, repeatable read 트랜잭션 속의 쿼리가 트랜잭션 안의 첫 번째 비 트랜잭션-제어 문장이 시작된 시점의 스냅샷을 본다는 거예요. Read Committed처럼 현재 문장 시작 시점이 아니라요. 그래서 한 트랜잭션 안의 연속된 SELECT는 같은 데이터를 봐요. 즉, 자기 트랜잭션이 시작된 뒤에 커밋된 다른 트랜잭션의 변경은 보지 못해요.

이 수준을 쓰는 애플리케이션은 직렬화 실패로 인한 트랜잭션 재시도에 대비해야 해요.

UPDATE, DELETE, MERGE, SELECT FOR UPDATE, SELECT FOR SHARE 명령도 대상 행을 찾는 방식은 SELECT와 같아요. 트랜잭션 시작 시점에 커밋된 대상 행만 찾아요. 하지만 그런 대상 행이 발견될 때쯤 이미 다른 동시 트랜잭션에 의해 갱신(또는 삭제, 잠금)되어 있을 수 있어요. 이 경우 repeatable read 트랜잭션은 첫 번째 갱신 트랜잭션이 커밋되거나 롤백될 때까지 기다려요. 첫 번째 갱신자가 롤백되면 그 효과가 무효화되고, repeatable read 트랜잭션은 원래 찾은 행으로 진행할 수 있어요. 하지만 첫 번째 갱신자가 커밋했고(그리고 행을 실제로 갱신·삭제했고, 단지 잠그기만 한 게 아니라면), repeatable read 트랜잭션은 이런 메시지와 함께 롤백돼요.

ERROR:  could not serialize access due to concurrent update

repeatable read 트랜잭션은 시작된 이후 바뀐 행을 수정하거나 잠글 수 없기 때문이에요.

이 오류 메시지를 받으면 애플리케이션은 현재 트랜잭션을 중단하고 트랜잭션 전체를 처음부터 재시도해야 해요. 두 번째 시도에서 트랜잭션은 이미 커밋된 변경을 초기 뷰의 일부로 보게 되므로, 새 버전의 행을 새 트랜잭션 갱신의 시작점으로 쓰는 데 논리적 충돌이 없어요.

주의할 점이 있어요. 갱신하는 트랜잭션만 재시도가 필요할 수 있어요. 읽기 전용 트랜잭션은 직렬화 충돌이 절대 없어요.

Repeatable Read 모드는 각 트랜잭션이 데이터베이스의 완전히 안정적인 뷰를 본다는 엄격한 보장을 줘요. 하지만 그 뷰가 항상 동시 트랜잭션들의 어떤 직렬(하나씩) 실행과 일치하리라는 보장은 없어요. 예를 들어, 이 수준의 읽기 전용 트랜잭션조차도 배치가 완료됐다고 표시하도록 갱신된 제어 레코드는 봤는데, 그 배치에 논리적으로 속한 세부 레코드 중 하나는 보지 못할 수 있어요. 제어 레코드의 더 이전 버전을 읽었기 때문이에요. 이 격리 수준에서 비즈니스 규칙을 강제하려는 트랜잭션은, 충돌하는 트랜잭션을 막는 명시적 잠금을 신중하게 쓰지 않으면 맞게 동작하지 않을 가능성이 커요.

Repeatable Read 격리 수준은 학술 데이터베이스 문헌과 일부 다른 데이터베이스 제품에서 Snapshot Isolation이라고 부르는 기법으로 구현돼요. 전통적인 잠금 기법(동시성을 줄이는)을 쓰는 시스템과 비교하면 동작과 성능 면에서 차이가 관찰될 수 있어요. 어떤 시스템은 Repeatable Read와 Snapshot Isolation을 동작이 다른 별개의 격리 수준으로 제공하기도 해요. 이 두 기법을 구분 짓는 허용 현상들은 SQL 표준이 개발된 이후에야 데이터베이스 연구자들에 의해 공식화되었고, 이 매뉴얼의 범위 밖이에요.

참고: PostgreSQL 9.1 이전에는 Serializable 격리 수준을 요청하면 여기서 설명한 것과 정확히 같은 동작을 제공했어요. 이전 Serializable 동작을 유지하려면 이제 Repeatable Read를 요청하면 돼요.

13.2.3. Serializable 격리 수준

Serializable 격리 수준은 가장 엄격한 트랜잭션 격리를 제공해요. 이 수준은 모든 커밋된 트랜잭션의 직렬 실행을 흉내 내요. 마치 트랜잭션들이 동시에가 아니라 하나씩 차례로 실행된 것처럼요. 하지만 Repeatable Read와 마찬가지로, 이 수준을 쓰는 애플리케이션은 직렬화 실패로 인한 재시도에 대비해야 해요. 실제로 이 격리 수준은 Repeatable Read와 정확히 똑같이 동작하는데, 한 가지가 더해져요. 일련의 동시 Serializable 트랜잭션 실행이 모든 가능한 직렬 실행과 일치하지 않게 만들 수 있는 조건을 감시한다는 점이에요. 이 감시는 repeatable read에 이미 있는 것 이상의 블로킹을 추가하지는 않지만, 감시 자체의 오버헤드는 있고, 직렬화 이상을 일으킬 수 있는 조건이 감지되면 직렬화 실패를 일으켜요.

한 가지 예를 들어볼게요. 처음에 이런 내용이 든 mytab 테이블을 생각해볼게요.

 class | value
-------+-------
     1 |    10
     1 |    20
     2 |   100
     2 |   200

Serializable 트랜잭션 A가 이걸 계산한다고 해보죠.

SELECT SUM(value) FROM mytab WHERE class = 1;

그리고 결과(30)를 class = 2인 새 행의 value로 삽입해요. 동시에 Serializable 트랜잭션 B가 이걸 계산해요.

SELECT SUM(value) FROM mytab WHERE class = 2;

그리고 결과 300을 class = 1인 새 행에 삽입해요. 그리고 두 트랜잭션 모두 커밋을 시도해요. 만약 둘 다 Repeatable Read 수준이었다면 둘 다 커밋이 허용됐겠지만, 결과와 일치하는 직렬 실행 순서가 존재하지 않으므로, Serializable을 쓰면 한 트랜잭션은 커밋되고 다른 트랜잭션은 이런 메시지와 함께 롤백돼요.

ERROR:  could not serialize access due to read/write dependencies among transactions

A가 B보다 먼저 실행됐다면 B는 합 300이 아니라 330을 계산했을 테고, 반대 순서라면 A가 계산하는 합이 달라졌을 테니까요.

Serializable 트랜잭션에 의존해서 이상을 막을 때 중요한 점이 있어요. 영구 사용자 테이블에서 읽은 데이터는, 그것을 읽은 트랜잭션이 성공적으로 커밋되기 전까지는 유효한 것으로 간주하지 말아야 해요. 읽기 전용 트랜잭션조차 마찬가지예요. 단, deferrable(지연 가능한) 읽기 전용 트랜잭션에서 읽은 데이터는 예외예요. 그런 트랜잭션은 그런 문제가 없는 것이 보장되는 스냅샷을 얻을 수 있을 때까지 기다렸다가 읽기를 시작하므로, 읽는 순간부터 유효한 것으로 알 수 있어요. 그 밖의 모든 경우에 애플리케이션은 나중에 중단된 트랜잭션에서 읽은 결과에 의존하면 안 되고, 성공할 때까지 트랜잭션을 재시도해야 해요.

진정한 직렬화 가능성을 보장하기 위해 PostgreSQL은 **술어 잠금(predicate locking)**을 사용해요. 이는, 어떤 쓰기가 먼저 실행됐다면 동시 트랜잭션의 이전 읽기 결과에 영향을 미쳤을지 판단할 수 있게 하는 잠금을 유지하는 것이에요. PostgreSQL에서 이 잠금들은 블로킹을 일으키지 않아서 교착 상태의 원인이 될 수 없어요. 이들은 동시 Serializable 트랜잭션들 간의 의존성을 식별하고 플래그로 표시하는 데 쓰이며, 그 의존성이 어떤 조합이면 직렬화 이상으로 이어질 수 있어요. 반면 데이터 일관성을 보장하려는 Read Committed 또는 Repeatable Read 트랜잭션은 테이블 전체에 잠금을 걸어야 할 수도 있어요. 그러면 그 테이블을 쓰려는 다른 사용자를 막을 수 있고, SELECT FOR UPDATESELECT FOR SHARE를 쓰면 다른 트랜잭션을 막을 뿐 아니라 디스크 접근까지 일으킬 수 있어요.

PostgreSQL의 술어 잠금은 대부분의 다른 데이터베이스 시스템과 마찬가지로, 트랜잭션이 실제로 접근한 데이터를 기준으로 해요. 이것들은 pg_locks 시스템 뷰에서 SIReadLock 모드로 나타나요. 쿼리 실행 중에 얻는 특정 잠금은 쿼리가 사용한 계획에 따라 달라지며, 잠금 추적용 메모리가 소진되는 것을 막기 위해 여러 개의 세밀한 잠금(예: 튜플 잠금)이 트랜잭션 중에 더 거친 잠금(예: 페이지 잠금)으로 합쳐질 수 있어요. READ ONLY 트랜잭션은, 직렬화 이상으로 이어질 수 있는 충돌이 더 이상 발생할 수 없다고 감지하면 완료 전에 SIRead 잠금을 해제할 수 있어요. 실제로 READ ONLY 트랜잭션은 시작 시점에 그 사실을 확립하고 술어 잠금을 아예 취하지 않을 수 있어요. SERIALIZABLE READ ONLY DEFERRABLE 트랜잭션을 명시적으로 요청하면 그 사실을 확립할 수 있을 때까지 블로킹돼요. (이것이 Serializable 트랜잭션이 블로킹하지만 Repeatable Read 트랜잭션은 블로킹하지 않는 유일한 경우예요.) 반면 SIRead 잠금은 겹치는 읽기-쓰기 트랜잭션이 완료될 때까지 트랜잭션 커밋 이후에도 유지돼야 하는 경우가 많아요.

Serializable 트랜잭션을 일관되게 쓰면 개발이 단순해질 수 있어요. "성공적으로 커밋된 어떤 동시 Serializable 트랜잭션 무리도 하나씩 실행한 것과 같은 효과를 낸다"는 보장은, 단일 트랜잭션 하나가 혼자 실행될 때 올바르게 동작한다는 걸 보여줄 수 있다면, 그 트랜잭션이 어떤 Serializable 트랜잭션 조합에서도 올바르게 동작하거나 아니면 성공적으로 커밋되지 않을 것임을 믿게 해줘요. 다른 트랜잭션이 뭘 하는지 전혀 모르는 채로요. 이 기법을 쓰는 환경은 직렬화 실패(항상 SQLSTATE 40001로 반환됨)를 일반화된 방법으로 처리하는 것이 중요해요. 어떤 트랜잭션이 읽기/쓰기 의존성에 기여해서 롤백되어야 할지 정확히 예측하기 매우 어렵기 때문이에요. 읽기/쓰기 의존성 감시에는 비용이 있고, 직렬화 실패로 종료된 트랜잭션의 재시작에도 비용이 있어요. 하지만 명시적 잠금과 SELECT FOR UPDATE/SELECT FOR SHARE 사용에 드는 비용·블로킹과 견주면, Serializable 트랜잭션은 어떤 환경에서는 최선의 성능 선택이 돼요.

PostgreSQL의 Serializable 격리 수준은 동시 트랜잭션들이 같은 효과를 내는 직렬 실행 순서가 존재한다는 걸 증명할 수 있을 때만 커밋을 허용하지만, 진짜 직렬 실행에서는 발생하지 않을 오류가 발생하는 것을 항상 막아주는 건 아니에요. 특히, 삽입하기 전에 키가 없다고 명시적으로 확인한 뒤에도 겹치는 Serializable 트랜잭션과의 충돌로 유니크 제약 위반이 보일 수 있어요. 이건 잠재적으로 충돌하는 키를 삽입하는 모든 Serializable 트랜잭션이 먼저 삽입할 수 있는지 명시적으로 확인하도록 함으로써 피할 수 있어요. 예를 들어 애플리케이션이 사용자에게 새 키를 요청한 뒤 그것이 이미 존재하지 않는지 먼저 select로 확인하는 경우, 또는 기존 키의 최댓값을 골라 1을 더해 새 키를 생성하는 경우를 상상해보세요. 어떤 Serializable 트랜잭션이 이 프로토콜을 따르지 않고 새 키를 바로 삽입한다면, 동시 트랜잭션들의 직렬 실행에서는 발생할 수 없는 경우에도 유니크 제약 위반이 보고될 수 있어요.

Serializable 트랜잭션에 의존해서 동시성을 제어할 때 최적의 성능을 위해 이런 점들을 고려해야 해요.

  • 가능하면 트랜잭션을 READ ONLY로 선언해요.
  • 필요하면 커넥션 풀을 써서 활성 커넥션 수를 조절해요. 이건 항상 중요한 성능 고려사항이지만, Serializable 트랜잭션을 쓰는 바쁜 시스템에서는 특히 중요해요.
  • 무결성 목적에 필요한 것보다 한 트랜잭션에 더 많이 넣지 마세요.
  • 커넥션을 "idle in transaction" 상태로 필요 이상 오래 방치하지 마세요. idle_in_transaction_session_timeout 설정 파라미터로 오래 남은 세션을 자동으로 끊을 수 있어요.
  • Serializable 트랜잭션이 자동으로 제공하는 보호 덕분에 더 이상 필요 없는 명시적 잠금·SELECT FOR UPDATE·SELECT FOR SHARE를 제거해요.
  • 술어 잠금 테이블 메모리가 부족해서 시스템이 여러 페이지 수준 술어 잠금을 단일 릴레이션 수준 술어 잠금으로 합쳐야 하면 직렬화 실패율이 높아질 수 있어요. max_pred_locks_per_transaction, max_pred_locks_per_relation, max_pred_locks_per_page를 늘리면 피할 수 있어요.
  • 순차 스캔은 항상 릴레이션 수준 술어 잠금을 필요로 해요. 이러면 직렬화 실패율이 높아질 수 있어요. random_page_cost를 줄이고/거나 관련 설정을 조정해서 인덱스 스캔을 쓰도록 권장하는 게 도움이 될 수 있어요.

13.3. 명시적 잠금 (Explicit Locking)

PostgreSQL은 테이블 데이터에 대한 동시 접근을 제어하기 위해 다양한 잠금 모드를 제공해요. 이 모드들은 MVCC가 원하는 동작을 주지 못하는 상황에서 애플리케이션이 제어하는 잠금으로 쓸 수 있어요. 또한 대부분의 PostgreSQL 명령은, 명령을 실행하는 동안 해당 테이블이 부적합한 방식으로 드롭되거나 수정되는 것을 막기 위해 적절한 모드의 잠금을 자동으로 획득해요. (예를 들어 TRUNCATE는 같은 테이블의 다른 작업과 안전하게 동시에 실행될 수 없으므로, 테이블에 ACCESS EXCLUSIVE 잠금을 걸어서 그걸 강제해요.)

데이터베이스 서버에서 현재 발행 중인 잠금 목록을 보려면 pg_locks 시스템 뷰를 사용해요.

13.3.1. 테이블 수준 잠금 (Table-Level Locks)

아래 목록은 사용 가능한 잠금 모드와 PostgreSQL이 자동으로 사용하는 맥락을 보여줘요. 이 잠금들 중 어떤 것이든 LOCK 명령으로 명시적으로 획득할 수도 있어요. 이 모든 잠금 모드는 테이블 수준 잠금이에요. 이름에 "row"라는 단어가 들어가 있어도요. 잠금 모드의 이름은 역사적인 거예요. 어느 정도는 각 잠금 모드의 전형적인 용도를 반영하지만, 의미론은 모두 같아요. 한 잠금 모드와 다른 모드의 유일한 진짜 차이는, 서로 **충돌(conflict)**하는 잠금 모드의 집합이에요. 두 트랜잭션은 동시에 같은 테이블에서 충돌하는 모드의 잠금을 가질 수 없어요. (단, 트랜잭션은 결코 자기 자신과 충돌하지 않아요. 예를 들어 같은 테이블에서 ACCESS EXCLUSIVE 잠금을 얻고 나중에 ACCESS SHARE 잠금을 얻을 수 있어요.) 충돌하지 않는 잠금 모드는 여러 트랜잭션이 동시에 가질 수 있어요. 특히 주목할 점은, 어떤 잠금 모드는 자기 자신과 충돌하고(예: ACCESS EXCLUSIVE는 한 번에 두 트랜잭션이 가질 수 없음) 어떤 모드는 그렇지 않다(예: ACCESS SHARE는 여러 트랜잭션이 가질 수 있음)는 거예요.

테이블 수준 잠금 모드

  • ACCESS SHARE (AccessShareLock): ACCESS EXCLUSIVE 잠금 모드와만 충돌해요. SELECT 명령이 참조하는 테이블에서 이 모드의 잠금을 얻어요. 일반적으로 테이블을 읽기만 하고 수정하지 않는 어떤 쿼리든 이 잠금 모드를 얻어요.
  • ROW SHARE (RowShareLock): EXCLUSIVEACCESS EXCLUSIVE 잠금 모드와 충돌해요. SELECT 명령이 FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE 옵션 중 하나가 지정된 모든 테이블에서 이 모드의 잠금을 얻어요(명시적 FOR ... 잠금 옵션 없이 참조된 다른 테이블에서는 ACCESS SHARE 잠금에 더해서).
  • ROW EXCLUSIVE (RowExclusiveLock): SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE 잠금 모드와 충돌해요. UPDATE, DELETE, INSERT, MERGE 명령이 대상 테이블에서 이 잠금 모드를 얻어요(다른 참조 테이블에서는 ACCESS SHARE 잠금에 더해서). 일반적으로 테이블의 데이터를 수정하는 어떤 명령이든 이 잠금 모드를 얻어요.
  • SHARE UPDATE EXCLUSIVE (ShareUpdateExclusiveLock): SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE 잠금 모드와 충돌해요. 이 모드는 테이블을 동시 스키마 변경과 VACUUM 실행으로부터 보호해요. VACUUM(FULL 제외), ANALYZE, CREATE INDEX CONCURRENTLY, CREATE STATISTICS, COMMENT ON, REINDEX CONCURRENTLY, 그리고 일부 ALTER INDEX·ALTER TABLE 변형이 얻어요.
  • SHARE (ShareLock): ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE 잠금 모드와 충돌해요. 이 모드는 테이블을 동시 데이터 변경으로부터 보호해요. CREATE INDEX(CONCURRENTLY 제외)가 얻어요.
  • SHARE ROW EXCLUSIVE (ShareRowExclusiveLock): ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE 잠금 모드와 충돌해요. 이 모드는 테이블을 동시 데이터 변경으로부터 보호하고, 자기 자신과도 배타적이라 한 번에 한 세션만 가질 수 있어요. CREATE TRIGGER와 일부 ALTER TABLE 형태가 얻어요.
  • EXCLUSIVE (ExclusiveLock): ROW SHARE, ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE 잠금 모드와 충돌해요. 이 모드는 동시 ACCESS SHARE 잠금만 허용해요. 즉, 이 잠금 모드를 가진 트랜잭션과 병렬로 진행될 수 있는 것은 테이블의 읽기뿐이에요. REFRESH MATERIALIZED VIEW CONCURRENTLY가 얻어요.
  • ACCESS EXCLUSIVE (AccessExclusiveLock): 모든 모드의 잠금과 충돌해요. 이 모드는 보유자가 어떤 방식으로든 테이블에 접근하는 유일한 트랜잭션임을 보장해요. DROP TABLE, TRUNCATE, REINDEX, CLUSTER, VACUUM FULL, REFRESH MATERIALIZED VIEW(CONCURRENTLY 제외) 명령이 얻어요. 많은 형태의 ALTER INDEX·ALTER TABLE도 이 수준의 잠금을 얻어요. 또한 모드를 명시하지 않은 LOCK TABLE 문의 기본 잠금 모드이기도 해요.

: ACCESS EXCLUSIVE 잠금만이 SELECT(FOR UPDATE/SHARE 없는)를 막아요.

일단 얻어진 잠금은 보통 트랜잭션이 끝날 때까지 유지돼요. 하지만 저장점(savepoint)을 설정한 후 잠금을 얻었다면, 그 저장점으로 롤백하면 잠금은 즉시 해제되어요. 이는 ROLLBACK이 저장점 이후의 모든 명령 효과를 취소한다는 원칙과 일치해요. PL/pgSQL 예외 블록 안에서 얻은 잠금도 마찬가지로, 블록에서 오류로 빠져나가면 그 안에서 얻은 잠금이 해제돼요.

충돌하는 잠금 모드 (Table 13.2)

요청 잠금 모드 \ 기존 잠금 모드 ACCESS SHARE ROW SHARE ROW EXCL. SHARE UPDATE EXCL. SHARE SHARE ROW EXCL. EXCL. ACCESS EXCL.
ACCESS SHARE X
ROW SHARE X X
ROW EXCL. X X X X
SHARE UPDATE EXCL. X X X X X
SHARE X X X X X
SHARE ROW EXCL. X X X X X X
EXCL. X X X X X X X
ACCESS EXCL. X X X X X X X X

13.3.2. 행 수준 잠금 (Row-Level Locks)

테이블 수준 잠금에 더해 행 수준 잠금이 있어요. 아래에 PostgreSQL이 자동으로 사용하는 맥락과 함께 나열돼요. 트랜잭션은 심지어 서로 다른 하위 트랜잭션에서도 같은 행에 충돌하는 잠금을 가질 수 있어요. 하지만 그 외에는 두 트랜잭션이 같은 행에 충돌하는 잠금을 절대 가질 수 없어요. 행 수준 잠금은 데이터 쿼리에는 영향을 주지 않아요. 같은 행에 대한 쓰기와 잠금만 막아요. 행 수준 잠금은 테이블 수준 잠금과 마찬가지로 트랜잭션 종료 시 또는 저장점 롤백 시 해제돼요.

  • FOR UPDATE: SELECT 문이 검색한 행을 갱신할 목적으로 잠가요. 이는 현재 트랜잭션이 끝날 때까지 다른 트랜잭션들이 그 행을 잠그거나 수정하거나 삭제하지 못하게 해요. 즉, 그 행에 대해 UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR NO KEY UPDATE, SELECT FOR SHARE, SELECT FOR KEY SHARE를 시도하는 다른 트랜잭션은 현재 트랜잭션이 끝날 때까지 블로킹돼요. 반대로 SELECT FOR UPDATE는 같은 행에서 그런 명령 중 하나를 실행한 동시 트랜잭션을 기다렸다가, 갱신된 행(또는 행이 삭제됐으면 행 없음)을 잠그고 반환해요. 단, REPEATABLE READ 또는 SERIALIZABLE 트랜잭션 안에서는, 잠그려는 행이 트랜잭션 시작 이후 바뀌었다면 오류가 발생해요. FOR UPDATE 잠금 모드는 어떤 행에 대한 DELETE와, 특정 열의 값을 수정하는 UPDATE에서도 획득돼요. UPDATE의 경우 고려되는 열 집합은 현재, 외래 키에 쓸 수 있는 유니크 인덱스가 있는 열들이에요(부분 인덱스와 표현식 인덱스는 고려되지 않음). 이건 미래에 바뀔 수 있어요.
  • FOR NO KEY UPDATE: FOR UPDATE와 비슷하게 동작하지만, 획득하는 잠금이 더 약해요. 이 잠금은 같은 행에 잠금을 얻으려는 SELECT FOR KEY SHARE 명령을 막지 않아요. 이 잠금 모드는 FOR UPDATE 잠금을 획득하지 않는 어떤 UPDATE에서도 획득돼요.
  • FOR SHARE: FOR NO KEY UPDATE와 비슷하지만, 각 검색 행에 배타 잠금이 아니라 공유 잠금을 얻어요. 공유 잠금은 다른 트랜잭션의 UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR NO KEY UPDATE를 막지만, SELECT FOR SHARESELECT FOR KEY SHARE는 막지 않아요.
  • FOR KEY SHARE: FOR SHARE와 비슷하지만 잠금이 더 약해요. SELECT FOR UPDATE는 막지만 SELECT FOR NO KEY UPDATE는 막지 않아요. 키 공유 잠금은 다른 트랜잭션의 DELETE나 키 값을 바꾸는 어떤 UPDATE를 막지만, 키를 바꾸지 않는 UPDATE는 막지 않아요. SELECT FOR NO KEY UPDATE, SELECT FOR SHARE, SELECT FOR KEY SHARE도 막지 않아요.

PostgreSQL은 수정된 행에 대한 정보를 메모리에 기억하지 않으므로 한 번에 잠글 수 있는 행 수에 제한이 없어요. 하지만 행을 잠그면 디스크 쓰기가 발생할 수 있어요. 예를 들어 SELECT FOR UPDATE는 선택된 행을 잠금 표시하도록 수정하므로 디스크 쓰기가 발생해요.

충돌하는 행 수준 잠금 (Table 13.3)

요청 잠금 모드 \ 현재 잠금 모드 FOR KEY SHARE FOR SHARE FOR NO KEY UPDATE FOR UPDATE
FOR KEY SHARE X
FOR SHARE X X
FOR NO KEY UPDATE X X X
FOR UPDATE X X X X

13.3.3. 페이지 수준 잠금 (Page-Level Locks)

테이블 잠금과 행 잠금에 더해, 페이지 수준 공유/배타 잠금이 공유 버퍼 풀에서 테이블 페이지에 대한 읽기/쓰기 접근을 제어하는 데 쓰여요. 이 잠금은 행을 가져오거나 갱신한 직후에 해제돼요. 애플리케이션 개발자는 보통 페이지 수준 잠금에 신경 쓸 필요가 없지만, 완전성을 위해 여기 언급해요.

13.3.4. 교착 상태 (Deadlocks)

명시적 잠금의 사용은 **교착 상태(deadlock)**의 가능성을 높일 수 있어요. 교착 상태는 둘(또는 그 이상)의 트랜잭션이, 각각 상대편이 원하는 잠금을 보유하고 있는 상황이에요. 예를 들어 트랜잭션 1이 테이블 A에 배타 잠금을 얻고 이어서 테이블 B에 배타 잠금을 얻으려 하는데, 트랜잭션 2가 이미 테이블 B를 배타 잠금했고 이제 테이블 A를 원한다면, 어느 쪽도 진행할 수 없어요. PostgreSQL은 교착 상태를 자동으로 감지하고, 관련 트랜잭션 중 하나를 중단(abort)시켜서 해결해요. (정확히 어떤 트랜잭션이 중단될지는 예측하기 어려우니 그걸 믿으면 안 돼요.)

교착 상태는 행 수준 잠금의 결과로도 발생할 수 있어요. (따라서 명시적 잠금을 안 써도 발생할 수 있어요.) 두 트랜잭션이 테이블을 수정하는 경우를 생각해보죠. 첫 트랜잭션이 실행해요.

UPDATE accounts SET balance = balance + 100.00 WHERE acctnum = 11111;

이건 지정된 계좌 번호의 행에 행 수준 잠금을 얻어요. 그 다음 두 번째 트랜잭션이 실행해요.

UPDATE accounts SET balance = balance + 100.00 WHERE acctnum = 22222;
UPDATE accounts SET balance = balance - 100.00 WHERE acctnum = 11111;

UPDATE는 지정된 행에 행 수준 잠금을 성공적으로 얻어 그 행을 갱신해요. 하지만 두 번째 UPDATE는 갱신하려는 행이 이미 잠겨있음을 발견하고, 그 잠금을 얻은 트랜잭션이 끝날 때까지 기다려요. 이제 트랜잭션 2는 트랜잭션 1이 끝나기를 기다리며 진행을 멈춰요. 이제 트랜잭션 1이 실행해요.

UPDATE accounts SET balance = balance - 100.00 WHERE acctnum = 22222;

트랜잭션 1이 지정된 행에 행 수준 잠금을 얻으려 하지만, 트랜잭션 2가 이미 그 잠금을 보유하고 있어서 얻을 수 없어요. 그래서 트랜잭션 2가 끝나기를 기다려요. 즉, 트랜잭션 1은 트랜잭션 2에 막히고, 트랜잭션 2는 트랜잭션 1에 막혀요: 교착 상태예요. PostgreSQL은 이 상황을 감지하고 트랜잭션 중 하나를 중단해요.

교착 상태에 대한 최선의 방어는 일반적으로 모든 애플리케이션이 여러 객체에 대한 잠금을 일관된 순서로 얻도록 보장해서 피하는 것이에요. 위 예에서 두 트랜잭션이 행을 같은 순서로 갱신했다면 교착 상태는 발생하지 않았을 거예요. 또한 트랜잭션에서 어떤 객체에 얻는 첫 잠금이 그 객체에 대해 필요할 가장 제한적인 모드가 되도록 해야 해요. 이것을 미리 확인하는 것이 불가능하다면, 교착 상태로 중단된 트랜잭션을 재시도해서 즉석에서 처리할 수 있어요.

교착 상태가 감지되지 않는 한, 테이블 수준 또는 행 수준 잠금을 구하는 트랜잭션은 충돌하는 잠금이 해제될 때까지 무기한 기다려요. 즉, 애플리케이션이 트랜잭션을 오랫동안 열어두는 것(예: 사용자 입력을 기다리면서)은 좋지 않은 생각이에요.

13.3.5. 애드바이저리 잠금 (Advisory Locks)

PostgreSQL은 애플리케이션이 정의한 의미를 가진 잠금을 만드는 수단을 제공해요. 이것들은 **애드바이저리 잠금(advisory lock)**이라고 불러요. 시스템이 그 사용을 강제하지 않기 때문이에요. 그 사용법을 올바르게 지키는 것은 애플리케이션의 몫이에요. 애드바이저리 잠금은 MVCC 모델에 맞추기 어색한 잠금 전략에 유용할 수 있어요. 예를 들어 애드바이저리 잠금의 흔한 용도는 이른바 "플랫 파일" 데이터 관리 시스템에서 전형적인 비관적 잠금 전략을 흉내 내는 것이에요. 테이블에 플래그를 저장해 같은 목적을 할 수도 있지만, 애드바이저리 잠금은 더 빠르고 테이블 블로트를 피하며, 세션이 끝나면 서버가 자동으로 정리해줘요.

PostgreSQL에서 애드바이저리 잠금을 얻는 방법은 두 가지가 있어요. 세션 수준트랜잭션 수준이에요. 세션 수준으로 얻으면 명시적으로 해제하거나 세션이 끝날 때까지 유지돼요. 표준 잠금 요청과 달리, 세션 수준 애드바이저리 잠금 요청은 트랜잭션 의미론을 따르지 않아요. 나중에 롤백되는 트랜잭션 중에 얻은 잠금은 롤백 후에도 여전히 유지되고, 언락도 호출 트랜잭션이 나중에 실패해도 유효해요. 소유 프로세스가 잠금을 여러 번 얻을 수 있으며, 각 완료된 잠금 요청에 대해 실제로 해제되기 전에 대응하는 언락 요청이 있어야 해요. 반면 트랜잭션 수준 잠금 요청은 일반 잠금 요청처럼 동작해요. 트랜잭션 끝에 자동으로 해제되고 명시적 언락 작업이 없어요. 이 동작은 애드바이저리 잠금을 단기간 사용할 때 세션 수준보다 종종 더 편리해요. 같은 애드바이저리 잠금 식별자에 대한 세션 수준과 트랜잭션 수준 잠금 요청은 예상되는 방식으로 서로를 블로킹해요. 세션이 주어진 애드바이저리 잠금을 이미 보유하고 있으면, 다른 세션이 그 잠금을 기다리고 있더라도 추가 요청은 항상 성공해요. 이 말은 기존 잠금 보유와 새 요청이 세션 수준이든 트랜잭션 수준이든 관계없이 참이에요.

모든 PostgreSQL 잠금처럼, 어떤 세션이든 현재 보유하고 있는 애드바이저리 잠금의 전체 목록은 pg_locks 시스템 뷰에서 찾을 수 있어요. 애드바이저리 잠금과 일반 잠금 모두 max_locks_per_transactionmax_connections 설정 변수로 크기가 정의된 공유 메모리 풀에 저장돼요. 이 메모리를 소진하지 않도록 주의해야 해요. 소진되면 서버가 잠금을 전혀 부여할 수 없게 되니까요. 이는 서버가 부여할 수 있는 애드바이저리 잠금 수에 상한을 두는데, 서버 구성에 따라 보통 수만에서 수십만 개 정도예요.

어떤 경우에는, 특히 명시적 순서와 LIMIT 절을 포함한 쿼리에서 애드바이저리 잠금을 쓸 때, SQL 표현식이 평가되는 순서 때문에 획득되는 잠금을 제어하는 데 주의해야 해요. 예를 들어요.

SELECT pg_advisory_lock(id) FROM foo WHERE id = 12345; -- OK
SELECT pg_advisory_lock(id) FROM foo WHERE id > 12345 LIMIT 100; -- 위험!
SELECT pg_advisory_lock(q.id) FROM
(
  SELECT id FROM foo WHERE id > 12345 LIMIT 100
) q; -- OK

위 쿼리에서 두 번째 형태는 위험해요. LIMIT가 잠금 함수가 실행되기 전에 적용된다는 보장이 없기 때문이에요. 이는 애플리케이션이 예상하지 못한 잠금이 획득되어, (세션이 끝날 때까지) 해제하지 못하게 할 수 있어요. 애플리케이션 관점에서 그런 잠금은 매달려 있는(dangling) 것처럼 보이지만, 여전히 pg_locks에는 보여요.


13.4. 애플리케이션 수준 데이터 일관성 검사 (Data Consistency Checks at the Application Level)

Read Committed 트랜잭션으로 데이터 무결성에 관한 비즈니스 규칙을 강제하는 것은 매우 어려워요. 데이터의 뷰가 문장마다 계속 변하고, 심지어 단일 문장조차도 쓰기 충돌이 발생하면 그 문장의 스냅샷에만 국한되지 않을 수 있기 때문이에요.

반면 Repeatable Read 트랜잭션은 실행 내내 안정적인 데이터 뷰를 가지지만, MVCC 스냅샷을 데이터 일관성 검사에 쓸 때는 읽기/쓰기 충돌이라는 미묘한 문제가 있어요. 한 트랜잭션이 데이터를 쓰고, 동시 트랜잭션이 같은 데이터를 읽으려고 한다면(쓰기 전이든 후든), 그 트랜잭션은 다른 트랜잭션의 작업을 볼 수 없어요. 그러면 어느 쪽이 먼저 시작했든 먼저 커밋했든, 독자는 자신이 먼저 실행된 것처럼 보이게 돼요. 여기까지면 문제가 없어요. 하지만 그 독자도, 동시 트랜잭션이 읽는 데이터를 쓰게 되면, 이제 앞서 언급한 트랜잭션들 중 어느 것보다 먼저 실행된 것으로 보이는 트랜잭션이 생겨요. 실제로 마지막에 실행된 것처럼 보이는 트랜잭션이 먼저 커밋하면, 트랜잭션 실행 순서 그래프에 **사이클(cycle)**이 나타나기 매우 쉬워요. 그런 사이클이 나타나면, 어떤 도움 없이는 무결성 검사가 올바르게 동작하지 않아요.

앞에서 언급했듯, Serializable 트랜잭션은 Repeatable Read 트랜잭션에 읽기/쓰기 충돌의 위험한 패턴을 위한 비차단 모니터링을 추가한 것이에요. 겉보기 실행 순서에 사이클을 일으킬 수 있는 패턴이 감지되면, 관련 트랜잭션 중 하나가 사이클을 끊기 위해 롤백돼요.

13.4.1. Serializable 트랜잭션으로 일관성 강제하기

만약 모든 쓰기와 일관된 뷰가 필요한 모든 읽기에 대해 Serializable 격리 수준을 사용한다면, 일관성을 보장하기 위해 다른 노력이 필요 없어요. 다른 환경에서 직렬화 트랜잭션으로 일관성을 보장하기 위해 작성된 소프트웨어는 PostgreSQL에서 이 점에 있어 "그냥 동작"해야 해요.

이 기법을 쓸 때, 애플리케이션 소프트웨어가 직렬화 실패로 롤백된 트랜잭션을 자동으로 재시도하는 프레임워크를 거치게 되면 애플리케이션 프로그래머에게 불필요한 부담을 만들지 않을 거예요. default_transaction_isolationserializable로 설정하는 것도 좋은 생각일 수 있어요. 또한 트리거에서 트랜잭션 격리 수준을 검사해서, 다른 트랜잭션 격리 수준이 실수로든 무결성 검사를 우회하기 위해든 사용되지 않도록 조치를 취하는 것도 현명해요.


13.5. 직렬화 실패 처리 (Serialization Failure Handling)

Repeatable Read와 Serializable 격리 수준 모두, 직렬화 이상을 막기 위해 설계된 오류를 만들 수 있어요. 앞서 말했듯, 이 수준을 쓰는 애플리케이션은 직렬화 오류로 실패한 트랜잭션을 재시도할 준비를 해야 해요. 그런 오류의 메시지 텍스트는 정확한 상황에 따라 다양하지만, 항상 SQLSTATE 코드 40001 (serialization_failure)을 가져요.

이것은 사실상 직렬화 실패지만, 서버가 그것을 그렇게 감지하지는 못할 수 있어요. 삽입된 값과 이전 읽기 사이의 연결을 서버가 "볼" 수 없기 때문이에요. 또한 서버가 직렬화 문제가 근본 원인임을 알기에 충분한 정보를 원칙적으로 가지는데도 유니크 키 또는 배타(EXCLUSION) 제약 오류를 내는 몇 가지 모서리 경우도 있어요.

serialization_failure 오류는 그냥 무조건 재시도하는 것이 권장되지만, 다른 오류 코드를 재시도할 때는 더 주의가 필요해요. 그것들은 일시적 실패가 아니라 지속적인 오류 조건일 수 있으니까요. 트랜잭션 전체를 재시도하는 것이 중요해요. 어떤 SQL을 발행할지 그리고/또는 어떤 값을 쓸지 결정하는 모든 로직을 포함해서요. 그래서 PostgreSQL은 자동 재시도 기능을 제공하지 않아요. 올바름을 보장하면서 그렇게 할 수 없기 때문이에요.

또한 트랜잭션 재시도가 그 재시도된 트랜잭션이 완료된다는 것을 보장하지는 않아요. 여러 번의 재시도가 필요할 수 있고, 경쟁이 매우 치열한 경우엔 완료까지 많은 시도가 걸릴 수 있어요. 충돌하는 prepared(준비된) 트랜잭션이 관련된 경우, 그 prepared 트랜잭션이 커밋되거나 롤백될 때까지 진행하지 못할 수도 있어요.


13.6. 주의사항 (Caveats)

일부 DDL 명령, 현재로는 TRUNCATE와 테이블을 다시 쓰는 형태의 ALTER TABLE만이 MVCC-안전하지 않아요. 즉, 잘림(truncation)이나 재작성(rewrite)이 커밋된 후, DDL 명령이 커밋되기 전에 찍은 스냅샷을 사용하는 동시 트랜잭션에게는 그 테이블이 비어 보일 수 있어요.

이 문제는 DDL 명령이 시작되기 전에 해당 테이블에 접근하지 않은 트랜잭션에서만 발생해요. 접근한 트랜잭션은 최소한 ACCESS SHARE 테이블 잠금을 보유하고 있을 테고, 그것이 그 트랜잭션이 끝날 때까지 DDL 명령을 블로킹할 거예요. 그래서 이 명령들은 대상 테이블에 대한 연속 쿼리에서 테이블 내용의 겉보기 불일치를 일으키진 않지만, 대상 테이블과 데이터베이스의 다른 테이블 사이의 내용에서 눈에 보이는 불일치를 일으킬 수 있어요.

Serializable 격리 수준 지원은 아직 핫 스탠바이 복제 대상에 추가되지 않았어요. 핫 스탠바이 모드에서 현재 지원되는 가장 엄격한 격리 수준은 Repeatable Read예요. 프라이머리에서 모든 영구 데이터베이스 쓰기를 Serializable 트랜잭션 안에서 수행하면 모든 스탠바이가 결국 일관된 상태에 도달하는 것을 보장하지만, 스탠바이에서 실행되는 Repeatable Read 트랜잭션은 프라이머리 트랜잭션의 어떤 직렬 실행과도 일치하지 않는 일시적인 상태를 볼 수 있어요.

시스템 카탈로그에 대한 내부 접근은 현재 트랜잭션의 격리 수준을 사용하지 않아요. 즉, 새로 생성된 데이터베이스 객체(예: 테이블)는 그것이 포함한 행이 그렇지 않은데도 동시 Repeatable Read 및 Serializable 트랜잭션에 보일 수 있어요. 반면 시스템 카탈로그를 명시적으로 검사하는 쿼리는 더 높은 격리 수준에서 동시에 생성된 데이터베이스 객체를 나타내는 행을 보지 못해요.


13.7. 잠금과 인덱스 (Locking and Indexes)

인덱스 접근 메서드는 여러 프로세스에 의한 인덱스의 동시 갱신을 처리해야 해요. PostgreSQL 코어 시스템은 인덱스 스캔 중 인덱스에 AccessShareLock을, 인덱스 갱신 중(일반 VACUUM 포함)에는 RowExclusiveLock을 얻어요. 이 잠금 유형들은 서로 충돌하지 않으므로, 접근 메서드가 필요할 수 있는 세밀한 잠금을 처리할 책임은 접근 메서드에게 있어요.

인덱스 전체에 대한 ACCESS EXCLUSIVE 잠금은 인덱스 생성·파괴·REINDEX 중에만 걸려요(CONCURRENTLY일 때는 대신 SHARE UPDATE EXCLUSIVE가 걸려요). 동시 갱신을 지원하는 인덱스 타입을 만드는 것은 보통 요구되는 동작에 대한 광범위하고 미묘한 분석을 필요로 해요. b-tree와 hash 인덱스 타입에 대해서는 src/backend/access/nbtree/READMEsrc/backend/access/hash/README에서 관련 설계 결정을 읽을 수 있어요.

인덱스 자체의 내부 일관성 요구 외에도, 동시 갱신은 부모 테이블(heap)과 인덱스 사이의 일관성에 관한 문제를 만들어요. PostgreSQL이 heap의 접근·갱신과 인덱스의 그것을 분리하기 때문에, 인덱스가 heap과 불일치할 수 있는 창(window)이 있어요. 우리는 이 문제를 다음 규칙들로 처리해요.

  • 새 heap 항목은 인덱스 항목을 만들기 전에 만들어진다. (그래서 동시 인덱스 스캔은 그 heap 항목을 보지 못할 가능성이 크다. 인덱스 읽는 사람은 어차피 커밋되지 않은 행에 관심이 없으니 괜찮다.)
  • heap 항목이 삭제될 때(VACUUM에 의해), 그 모든 인덱스 항목은 먼저 제거되어야 한다.
  • 인덱스 스캔은 amgettuple이 마지막으로 반환한 항목을 담은 인덱스 페이지에 핀(pin)을 유지해야 하고, ambulkdelete는 다른 백엔드가 핀을 잡고 있는 페이지의 항목을 삭제할 수 없다.

세 번째 규칙이 필요한 이유를 설명하면 이래요. 이 규칙이 없으면, 인덱스 읽는 사람이 VACUUM이 제거하기 직전에 인덱스 항목을 보고, 그 후에 VACUUM이 제거한 대응 heap 항목에 도달할 수 있어요.

MVCC-호환 스냅샷을 사용할 때는 문제가 없어요. 슬롯의 새 점유자는 그 스냅샷 테스트를 통과하기에 확실히 너무 새로운 것이기 때문이에요. 하지만 MVCC-호환되지 않는 스냅샷(예: SnapshotAny)에서는, 실제로 스캔 키와 일치하지 않는 행을 받아들이고 반환하는 것이 가능해요. 모든 경우에 스캔 키를 heap 행에 대해 재확인(recheck)하도록 요구하면 이 시나리오를 방어할 수 있지만, 그건 너무 비싸요. 대신 우리는 인덱스 페이지의 핀을, 읽는 사람이 인덱스 항목에서 일치하는 heap 항목으로 "이동 중(in flight)"일 수 있다는 프록시로 사용해요. ambulkdelete가 그런 핀에서 블로킹하게 하면 VACUUM이 읽는 사람이 끝나기 전에 heap 항목을 삭제할 수 없게 돼요.

위 분석에 따라, MVCC-호환되지 않는 스냅샷에는 동기적 접근을, MVCC 스냅샷을 사용하는 쿼리에는 비동기 스캔이 작동 가능해요. amgetbitmap 인덱스 스캔에서 접근 메서드는 반환된 튜플 중 어느 것에도 인덱스 핀을 유지하지 않아요. 그래서 그런 스캔은 MVCC-호환 스냅샷에서만 안전해요.

ampredlocks 플래그가 설정되지 않으면, 직렬화 트랜잭션 안에서 그 인덱스 접근 메서드를 사용하는 어떤 스캔도 전체 인덱스에 비차단 술어 잠금을 획득해요. 이는 동시 직렬화 트랜잭션에 의한 그 인덱스로의 어떤 튜플 삽입과도 읽기-쓰기 충돌을 만들어요. 일련의 동시 직렬화 트랜잭션들 사이에서 특정 읽기-쓰기 충돌 패턴이 감지되면, 그 트랜잭션 중 하나가 데이터 무결성을 보호하기 위해 취소될 수 있어요. 그 플래그가 설정되면, 인덱스 접근 메서드가 더 세밀한 술어 잠금을 구현함을 나타내며, 그러한 트랜잭션 취소 빈도를 줄이는 경향이 있어요.


본 문서는 PostgreSQL 공식 문서 18 (Chapter 13. Concurrency Control)을 참고하여 한국어로 재해석한 해설입니다. 원문의 기술적 내용과 규칙을 우선하며, 이해를 돕기 위한 설명을 덧붙였습니다.