트랜잭션 격리 수준
트랜잭션 격리 수준 (Transaction Isolation)
여러 트랜잭션이 동시에 달릴 때 서로의 작업을 어디까지 볼 수 있게 할지는 생각보다 까다로운 문제예요. SQL 표준은 이 문제를 네 가지 격리 수준(isolation level) 으로 정리했는데요, PostgreSQL은 표준의 네 단계를 모두 요청할 수 있으면서 내부적으로는 세 가지만 실제 구현해요. 격리 수준을 제대로 이해하면 "왜 이 트랜잭션은 재시도해야 할까" 같은 질문이 자연스럽게 풀려요. 이번에는 각 격리 수준이 어떤 비정상 현상을 막고, PostgreSQL에서는 각 수준이 실제로 어떻게 동작하는지 살펴볼게요.
표준이 정의한 네 수준과 금지되는 현상
SQL 표준에서 가장 엄격한 수준은 Serializable 이에요. Serializable 트랜잭션들이 동시에 실행된 결과는, 어떤 순서로든 "하나씩 차례로 실행한 결과"와 항상 같아야 한다고 정의돼요. 나머지 세 수준은 각 수준에서 일어나면 안 되는 현상(phenomena) 으로 정의돼요. Serializable 수준에서는 정의상 이 현상들이 전부 불가능해요.
각 수준에서 금지되는 현상들을 먼저 짚고 갈게요.
- dirty read(오염 읽기) — 트랜잭션이 커밋되지 않은 다른 동시 트랜잭션의 데이터를 읽는 것
- nonrepeatable read(비반복 읽기) — 트랜잭션이 앞서 읽은 데이터를 다시 읽었는데, 그 사이에 다른 트랜잭션(첫 읽기 이후 커밋된)이 수정한 걸 발견하는 것
- phantom read(팬텀 읽기) — 검색 조건을 만족하는 행 집합을 반환하는 질의를 다시 실행했을 때, 최근 커밋된 다른 트랜잭션 때문에 그 집합이 바뀐 것을 발견하는 것
- serialization anomaly(직렬화 이상) — 트랜잭션 그룹을 성공적으로 커밋한 결과가, 그 트랜잭션들을 하나씩 차례로 실행한 모든 가능한 순서와 모순되는 것
이 현상들과 SQL 표준·PostgreSQL 구현의 관계를 표로 정리하면 이렇게 돼요.
| 격리 수준 | Dirty Read | Nonrepeatable Read | Phantom Read | Serialization Anomaly |
|---|---|---|---|---|
| Read uncommitted | 가능하지만 PG에선 불가 | 가능 | 가능 | 가능 |
| Read committed | 불가능 | 가능 | 가능 | 가능 |
| Repeatable read | 불가능 | 불가능 | 가능하지만 PG에선 불가 | 가능 |
| Serializable | 불가능 | 불가능 | 불가능 | 불가능 |
PostgreSQL은 네 표준 격리 수준을 전부 요청할 수 있지만, 내부적으로는 세 가지 고유 수준만 구현해요. Read Uncommitted 모드는 Read Committed처럼 동작하죠. 이것이 표준 격리 수준을 PostgreSQL의 MVCC 아키텍처에 매핑하는 유일하게 합리적인 방법이에요. 또 PostgreSQL의 Repeatable Read는 phantom read를 허용하지 않는다는 점도 표에서 보이는데요, 표준은 각 수준에서 반드시 일어나지 않아야 할 이상만 규정하기 때문에 더 강한 보장을 제공하는 건 허용돼요.
격리 수준은 SET TRANSACTION 명령으로 설정해요. 그리고 한 가지 알아둘 점이 있어요. 일부 데이터 타입과 함수는 트랜잭션 동작에 특별한 규칙이 있는데, 특히 시퀀스(sequence)에 대한 변경은(그래서 serial로 선언한 열의 카운터도) 즉시 다른 트랜잭션에 보이고, 그 변경을 만든 트랜잭션이 중단되어도 롤백되지 않아요.
Read Committed — 기본 격리 수준
Read Committed는 PostgreSQL의 기본 격리 수준이에요. 이 수준의 트랜잭션에서 SELECT 질의(FOR UPDATE/SHARE 절이 없는)는 질의가 시작되기 전에 커밋된 데이터만 보아요. 커밋되지 않은 데이터나 질의 실행 중 다른 트랜잭션이 커밋한 변경은 절대 보지 못하죠. 즉 SELECT는 질의가 실행을 시작하는 순간의 데이터베이스 스냅샷을 보는 셈이에요. 다만 같은 트랜잭션 안에서 이전에 실행한 변경의 효과는 아직 커밋되지 않았어도 SELECT가 볼 수 있어요. 그리고 한 트랜잭션 안의 두 연속 SELECT라도, 그 사이에 다른 트랜잭션이 커밋하면 서로 다른 데이터를 볼 수 있답니다.
UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR SHARE도 대상 행을 찾는 방식은 SELECT와 같아요. 명령 시작 시점에 커밋된 행만 찾죠. 그런데 그 대상 행이 이미 다른 동시 트랜잭션에 의해 갱신(또는 삭제·잠금)됐을 수 있어요. 이 경우 갱신하려는 쪽은 먼저 갱신하는 트랜잭션이 커밋하거나 롤백할 때까지 기다려요. 먼저 갱신한 쪽이 롤백하면 그 효과가 무효화되어 원래 찾은 행에 작업을 진행할 수 있고, 커밋하면 행을 삭제했을 땐 무시하고 아니면 갱신된 버전의 행에 작업을 적용하려 시도해요. 이때 명령의 검색 조건(WHERE 절)을 다시 평가해서 갱신된 행이 여전히 조건에 맞는지 확인해요. SELECT FOR UPDATE/SHARE의 경우에는 갱신된 버전의 행이 잠기고 클라이언트에 반환돼요.
ON CONFLICT DO UPDATE가 붙은 INSERT도 비슷해요. Read Committed 모드에서 삽입하려는 각 행은 삽입되거나 갱신되는 둘 중 하나의 결과를 내요. 관련 없는 오류가 없다면 그 두 결과 중 하나는 보장돼요. ON CONFLICT DO NOTHING이 붙은 INSERT는, INSERT 스냅샷에 보이지 않는 다른 트랜잭션의 결과 때문에 행 삽입이 진행되지 않을 수 있어요. 이것도 Read Committed 모드에서만 그런 경우예요.
이 규칙들 때문에 갱신 명령이 일관되지 않은 스냅샷을 볼 수 있어요. 자기가 갱신하려는 같은 행에 대한 동시 갱신 효과는 볼 수 있지만, 데이터베이스의 다른 행에 대한 그 명령들의 효과는 못 보는 거죠. 이 특성 때문에 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는 복잡한 질의·갱신을 하는 애플리케이션에는 부족할 수 있고, 그때는 더 엄격한 격리 수준이 필요해요.
Repeatable Read
Repeatable Read 수준은 트랜잭션이 시작되기 전에 커밋된 데이터만 보아요. 트랜잭션 실행 중 다른 트랜잭션이 커밋한 변경은 보지 못하죠(자기 트랜잭션 안의 이전 갱신 효과는 봐요). 이는 SQL 표준이 이 수준에 요구하는 것보다 더 강한 보장이고, serialization anomaly를 제외한 표의 모든 현상을 막아요. 표준은 각 수준의 최소 보호만 정의하므로 이렇게 더 강하게 해도 허용되는 거예요.
Read Committed와 다른 핵심은, repeatable read 트랜잭션의 질의가 트랜잭션 안의 현재 문장 시작 시점이 아니라 트랜잭션의 첫 번째 비제어문이 시작된 시점의 스냅샷을 본다는 거예요. 그래서 한 트랜잭션 안의 연속된 SELECT는 같은 데이터를 보고, 자기 트랜잭션 시작 후 커밋된 다른 트랜잭션의 변경은 못 봐요.
이 수준을 쓰는 애플리케이션은 serialization 실패 때문에 트랜잭션을 재시도할 준비가 돼 있어야 해요. UPDATE, DELETE, MERGE, SELECT FOR UPDATE/SHARE는 트랜잭션 시작 시점에 커밋된 대상 행만 찾아요. 그런데 그 행이 이미 다른 동시 트랜잭션에 갱신(또는 삭제·잠금)됐다면 기다렸다가, 먼저 갱신한 쪽이 롤백하면 진행하고, 커밋하면서 실제로 행을 갱신·삭제했다면 repeatable read 트랜잭션은 다음처럼 롤백돼요.
ERROR: could not serialize access due to concurrent update
이 에러를 받으면 현재 트랜잭션을 중단하고 처음부터 재시도해야 해요. 두 번째 시도에서는 이전에 커밋된 변경이 초기 뷰에 포함되어 있으므로, 새 행 버전을 시작점으로 써도 논리적 충돌이 없어요. 갱신 트랜잭션만 재시도가 필요하다는 점도 기억하세요. 읽기 전용 트랜잭션은 serialization 충돌을 절대 겪지 않아요.
Repeatable Read는 각 트랜잭션이 완전히 안정된 데이터베이스 뷰를 보도록 보장해요. 하지만 그 뷰가 항상 같은 수준의 동시 트랜잭션들을 "하나씩 차례로 실행한" 것과 일치하지는 않을 수 있어요. 그래서 이 수준에서 비즈니스 규칙을 강제하려면, 충돌하는 트랜잭션을 막기 위한 명시적 잠금을 신중하게 써야 제대로 동작하기 어려워요. 이 격리 수준은 학계와 일부 DB 제품에서 Snapshot Isolation(스냅샷 격리) 이라고 부르는 기법으로 구현돼요. 또 참고로, PostgreSQL 9.1 이전에는 Serializable을 요청하면 여기서 설명한 것과 똑같은 동작이 나왔어요. 옛 Serializable 동작을 유지하려면 이제는 Repeatable Read를 요청하면 돼요.
Serializable — 가장 엄격한 수준
Serializable 수준은 가장 엄격한 트랜잭션 격리를 제공해요. 모든 커밋된 트랜잭션이 동시가 아니라 하나씩 차례로 실행된 것처럼 동작하도록 흉내내죠. 다만 Repeatable Read처럼 이 수준도 serialization 실패 때문에 재시도할 준비가 필요해요. 실제로는 Repeatable Read와 거의 똑같이 동작하는데, 한 가지가 더해져요. 동시 Serializable 트랜잭션 집합의 실행이 가능한 모든 직렬 실행과 모순되게 만들 수 있는 조건을 감시한다는 거예요. 이 감시는 repeatable read에 있는 것 이상의 블로킹을 만들지는 않지만, 감시 자체에 약간의 오버헤드가 있고, serialization anomaly를 낼 수 있는 조건이 감지되면 serialization failure를 일으켜요.
예시를 들어볼게요. 처음에 다음 데이터를 담은 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는 합계 300이 아니라 330을 계산했을 테고, 반대 순서라면 A가 다른 합계를 계산했을 테니까요.
Serializable로 이상을 막으려면, 영구 사용자 테이블에서 읽은 데이터는 그 트랜잭션이 성공적으로 커밋되기 전까지는 유효하다고 간주하지 않는 게 중요해요. 읽기 전용 트랜잭션도 마찬가지인데, 단 deferrable 읽기 전용 트랜잭션 안에서 읽은 데이터는 그런 문제가 없는 스냅샷을 얻을 때까지 기다렸다가 읽기 시작하므로 읽는 즉시 유효하다고 알 수 있어요. 그 외의 경우 애플리케이션은 나중에 중단된 트랜잭션 중에 읽은 결과에 의존하면 안 되고, 성공할 때까지 재시도해야 해요.
진짜 직렬화 가능성을 보장하기 위해 PostgreSQL은 predicate locking(술어 잠금) 을 사용해요. 이 잠금들은 블로킹을 일으키지 않아 교착 상태에 기여하지 않고, 동시 Serializable 트랜잭션 간의 의존 관계를 식별·표시하는 데만 쓰여요. 이 잠금들은 대부분 DB 시스템처럼 트랜잭션이 실제로 접근한 데이터를 기반으로 해요. pg_locks 시스템 뷰에서 mode가 SIReadLock인 항목으로 나타나요. 쿼리 실행 중 얻는 잠금은 사용된 계획에 따라 달라지고, 너무 많은 잠금 추적 메모리를 쓰는 걸 막으려고 트랜잭션 진행 중에 세밀한 잠금(예: 튜플 잠금)이 더 거친 잠금(예: 페이지 잠금)으로 합쳐질 수 있어요. READ ONLY 트랜잭션은 serialization anomaly로 이어질 충돌이 더 이상 없음을 감지하면 시작 전에 SIRead 잠금을 안 걸 수도 있어요. SERIALIZABLE READ ONLY DEFERRABLE 트랜잭션을 명시적으로 요청하면 그 사실을 확립할 수 있을 때까지 블로킹되는데, 이는 Serializable이 블로킹하지만 Repeatable Read는 블로킹하지 않는 유일한 경우예요.
Serializable을 일관되게 쓰면 개발이 단순해져요. 성공적으로 커밋된 동시 Serializable 트랜잭션 집합은 항상 하나씩 차례로 실행한 것과 같은 효과를 내므로, 단일 트랜잭션이 혼자 실행될 때 올바르게 동작한다는 걸 보일 수 있다면 다른 Serializable 트랜잭션들과 어떤 조합으로 섞여도 올바르게 동작하거나 아니면 커밋에 실패한다는 걸 믿을 수 있어요. 이 기법을 쓰는 환경에서는 serialization 실패(항상 SQLSTATE 40001로 돌아옴)를 처리하는 일반화된 방법이 필요해요. 어느 트랜잭션이 read/write 의존성에 기여하고 롤백돼야 할지 예측하기 어렵기 때문이에요. 감시 비용과 실패로 끝난 트랜잭션의 재시작 비용을 명시적 잠금·SELECT FOR UPDATE/SHARE의 비용과 저울질하면, 일부 환경에서 Serializable이 최선의 성능 선택이 되기도 해요.
Serializable에서 최적 성능을 위해 생각해 볼 것들이 몇 가지 있어요.
- 가능하면 트랜잭션을
READ ONLY로 선언해요. - 커넥션 풀을 쓰며 활성 연결 수를 제어해요. 항상 중요한 고려인데, Serializable을 쓰는 바쁜 시스템에서 특히 중요해요.
- 무결성 용도에 필요한 것보다 한 트랜잭션에 더 많이 담지 마세요.
idle in transaction상태로 연결을 오래 방치하지 마세요.idle_in_transaction_session_timeout파라미터로 지연 세션을 자동으로 끊을 수 있어요.- Serializable이 자동으로 제공하는 보호 때문에 더 이상 필요 없어진 명시적 잠금·
SELECT FOR UPDATE/SHARE는 제거해요. - 술어 잠금 테이블에 메모리가 부족해 페이지 수준 잠금 여러 개를 관계 수준 잠금 하나로 합쳐야 할 때 serialization 실패율이 올라갈 수 있어요.
max_pred_locks_per_transaction,max_pred_locks_per_relation,max_pred_locks_per_page를 늘려 피할 수 있어요. - 순차 스캔은 항상 관계 수준 술어 잠금을 요구해 serialization 실패율을 높일 수 있어요.
random_page_cost를 낮추거나cpu_tuple_cost를 높여 인덱스 스캔을 유도하는 게 도움이 될 수 있어요.
Serializable 격리 수준은 학계에서 Serializable Snapshot Isolation 이라는 기법으로 구현돼요. Snapshot Isolation에 serialization anomaly 검사를 더한 방식이죠.
더 알아보기
- 트랜잭션 기본 — 트랜잭션의 시작·커밋·롤백
- MVCC 개념 — 격리 수준의 밑바탕이 되는 동시성 제어
- serialization 실패 처리 — 재시도하는 올바른 방법
- SET TRANSACTION — 격리 수준을 설정하는 명령