테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

동시성과 격리 수준

여러 트랜잭션이 동시에 같은 데이터를 만질 때 생기는 일과, 락·MVCC로 막는 방법, 격리 수준마다 허용하는 이상 현상이다.

목차 (7)
  1. 동시에 고치면 생기는 일
  2. 잠금과 2단계 잠금
  3. MVCC
  4. 격리 수준
  5. 스냅숏 격리와 직렬화 가능 스냅숏 격리
  6. PostgreSQL과 MySQL의 기본값
  7. 낙관적 잠금과 비관적 잠금

동시에 고치면 생기는 일

트랜잭션을 하나씩 차례로 돌리면(직렬 실행) 문제가 없지만 느리다. 데이터베이스는 여러 트랜잭션의 연산을 섞어 동시에 돌리고, 그 결과가 "어떤 순서로 하나씩 돌린 것"과 같으면 직렬화 가능(serializable)하다고 한다. 섞는 방법이 허술하면 직렬 실행에서는 절대 나올 수 없는 결과가 나온다. 이것이 이상 현상이다.

더티 리드: T1이 끝내 취소된 값 80을 읽었다. 반복 불가능 읽기: 같은 행을 두 번 읽었는데 값이 다르다. 팬텀: 같은 조건으로 셌는데 행이 늘었다더티 리드T1이 끝내 취소된 값 80을 읽었다T1T2x ← 80x 읽기: 80ROLLBACK반복 불가능 읽기같은 행을 두 번 읽었는데 값이 다르다T1T2x 읽기: 50x ← 80COMMITx 읽기: 80팬텀같은 조건으로 셌는데 행이 늘었다T1T2나이>30 세기: 3나이 40 넣기COMMIT나이>30 세기: 4
읽기 쪽 이상 현상. 커밋 안 된 값을 읽거나(더티 리드), 한 트랜잭션 안에서 같은 행(반복 불가능 읽기)이나 같은 조건의 행 집합(팬텀)이 바뀌어 보인다.
갱신 손실: 두 개 팔았는데 재고가 하나만 줄었다. 쓰기 치우침: 각자 규칙(당직 ≥ 1명)을 지켰는데 합치면 0명갱신 손실두 개 팔았는데 재고가 하나만 줄었다T1T2재고 읽기: 10재고 읽기: 10재고 ← 9, 커밋재고 ← 9, 커밋쓰기 치우침각자 규칙(당직 ≥ 1명)을 지켰는데 합치면 0명T1T2당직 세기: 2당직 세기: 2앨리스 빼기, 커밋밥 빼기, 커밋
쓰기 쪽 이상 현상. 둘이 읽고 각자 쓰면 먼저 쓴 쪽이 덮어써지고(갱신 손실), 서로 다른 행을 고쳐도 둘이 함께 기댄 규칙이 깨질 수 있다(쓰기 치우침).

SQL 표준은 앞의 세 가지(더티 리드, 반복 불가능 읽기, 팬텀)로 격리 수준을 정의한다. 1995년 Berenson 등의 「A Critique of ANSI SQL Isolation Levels」는 이 정의가 모호하고, 갱신 손실과 쓰기 치우침처럼 표준이 이름 붙이지 않은 이상이 있으며, 특히 스냅숏 격리가 표준의 어느 수준에도 딱 맞지 않는다는 점을 보였다. 이 노트의 낱말과 표는 그 논문을 따른다.

잠금과 2단계 잠금

가장 오래된 해법은 잠금이다. 읽을 때는 공유 잠금(S, 여럿이 함께 가질 수 있음), 쓸 때는 배타 잠금(X, 혼자만)을 잡는다. 하지만 연산마다 잡았다 바로 풀면 직렬화가 보장되지 않는다. 읽고 푼 사이에 남이 고칠 수 있기 때문이다.

2단계 잠금(two-phase locking, 2PL)은 규칙 하나를 더한다. 한 번이라도 잠금을 풀었으면 그 뒤로는 새 잠금을 잡지 않는다. 그러면 트랜잭션은 잠금을 늘리기만 하는 단계와 줄이기만 하는 단계로 나뉘고, 이 규칙만으로 실행 결과가 직렬화 가능함이 증명된다. 실무에서는 커밋할 때까지 모든 잠금을 들고 있는 엄격한 2PL(strict 2PL)을 쓴다. 중간에 풀면 남이 커밋 안 된 값을 읽고, 이쪽이 취소될 때 그 트랜잭션까지 함께 취소해야 하기 때문이다.

2단계 잠금에서 잠금 수는 늘어나는 단계 뒤 줄어드는 단계로만 바뀌고, 엄격한 2PL은 커밋 때 모두 푼다. 오른쪽은 T1과 T2가 서로가 가진 잠금을 기다려 대기 그래프에 사이클 T1 → T2 → T1이 생긴 교착잠금 수의 변화시간 →늘어나는 단계줄어드는 단계실선: 엄격한 2PL(커밋 때 한꺼번에)점선: 기본 2PL교착(deadlock)행 A행 BT1T2가짐기다림가짐기다림대기 그래프 사이클 T1 → T2 → T1하나를 골라 중단시켜야 풀린다
2단계 잠금과 교착. 잠금을 기다리는 관계를 화살표로 그린 대기 그래프에 사이클이 생기면 어느 쪽도 끝나지 않는다.

잠금을 기다리다 보면 교착(deadlock)이 생긴다. T1이 A를 쥐고 B를 기다리는데 T2가 B를 쥐고 A를 기다리는 경우다. 데이터베이스는 대기 그래프에서 사이클을 찾아 한쪽을 중단시키거나 (PostgreSQL은 deadlock_timeout만큼 기다린 뒤 검사한다), 미리 순서를 정해 사이클이 생기지 않게 한다. 응용 프로그램 쪽에서는 여러 행을 늘 같은 순서(예: 기본 키 순)로 잠그고, 중단되면 트랜잭션 전체를 다시 시도하는 것이 정석이다. PostgreSQL 문서의 Explicit Locking 장이 잠금 종류와 교착 처리를 설명한다.

팬텀은 행 잠금만으로 못 막는다. 아직 없는 행은 잠글 수 없기 때문이다. 그래서 조건 자체를 잠그는 술어 잠금(predicate lock)이나, 인덱스 항목 사이의 틈을 잠그는 갭 잠금(gap lock)을 쓴다.

MVCC

2PL에서는 읽는 쪽과 쓰는 쪽이 서로를 기다린다. 긴 보고서 질의 하나가 쓰기를 모두 막을 수 있다. 다중 버전 동시성 제어(multi-version concurrency control, MVCC)는 행을 덮어쓰지 않고 새 버전을 만든다. 각 트랜잭션은 시작할 때(또는 문장마다) 찍은 스냅숏에 보이는 버전만 읽으므로, 읽기는 쓰기를 막지 않고 쓰기도 읽기를 막지 않는다.

MVCC 단계 그림: 잔액 100인 행을 T2가 50으로 바꾸고 커밋하는 동안 T1이 세 번 읽는다. 반복 읽기는 세 번 모두 100, 커밋된 읽기는 마지막에 50을 본다잔액 행의 버전(새것이 위)T1이 읽은 값100xmin 100xmax —커밋된 트랜잭션: 100지금 움직인 쪽: —읽기반복 읽기커밋된 읽기100xmin 100xmax —커밋된 트랜잭션: 100지금 움직인 쪽: T1(101)읽기반복 읽기커밋된 읽기1번째10010050xmin 102xmax —100xmin 100xmax 102커밋된 트랜잭션: 100지금 움직인 쪽: T2(102)읽기반복 읽기커밋된 읽기1번째10010050xmin 102xmax —100xmin 100xmax 102커밋된 트랜잭션: 100지금 움직인 쪽: T1(101)읽기반복 읽기커밋된 읽기1번째1001002번째10010050xmin 102xmax —100xmin 100xmax 102커밋된 트랜잭션: 100, 102지금 움직인 쪽: T2(102)읽기반복 읽기커밋된 읽기1번째1001002번째10010050xmin 102xmax —100xmin 100xmax 102커밋된 트랜잭션: 100, 102지금 움직인 쪽: T1(101)읽기반복 읽기커밋된 읽기1번째1001002번째1001003번째1005050xmin 102xmax —100xmin 100xmax 102커밋된 트랜잭션: 100, 102지금 움직인 쪽: —읽기반복 읽기커밋된 읽기1번째1001002번째1001003번째10050커밋된 쪽이 만든 버전T1(반복 읽기)이 본 버전
1 / 7

처음 상태. 잔액 행의 버전이 하나 있다(만든 트랜잭션 xmin=100, 커밋됨). T1(101)과 T2(102)가 시작한다.

MVCC. 행을 덮어쓰지 않고 버전을 더하므로 읽기와 쓰기가 서로를 막지 않는다. 각 버전의 xmin(만든 트랜잭션)과 xmax(지운 트랜잭션)를 스냅숏의 커밋 목록과 견줘 볼 버전을 고른다.
  • 버전의 표시: PostgreSQL은 행마다 만든 트랜잭션 번호(xmin)와 지운 트랜잭션 번호(xmax)를 적고, 고치기는 "옛 버전에 xmax 적기 + 새 버전 넣기"다(MVCC 소개). MySQL InnoDB는 최신 버전만 테이블에 두고 옛 버전은 undo 로그에서 거꾸로 만들어 낸다.
  • 보이는 규칙: 만든 쪽이 스냅숏 시점에 커밋돼 있고, 지운 쪽이 없거나 스냅숏 시점에 아직 커밋 전이면 보인다. 자기 변경은 늘 보인다.
  • 쓰기끼리는 여전히 충돌: 두 트랜잭션이 같은 행을 고치면 나중 쪽은 먼저 쪽이 끝나기를 기다린다. 그다음 어떻게 할지가 격리 수준마다 다르다(마지막 절).
  • 청소: 아무도 볼 수 없는 옛 버전은 PostgreSQL의 VACUUM, InnoDB의 purge가 치운다. 오래 열린 트랜잭션이 있으면 그 스냅숏이 볼 수 있는 버전을 못 치워 테이블이 부푼다.

격리 수준

완전한 직렬화는 비싸서, 데이터베이스는 어느 이상까지 허용할지 고르게 해 준다. 이것이 격리 수준(isolation level)이다. 아래 표는 Berenson 등의 논문 표 4를 줄인 것이다.

격리 수준 다섯 개와 이상 현상 다섯 개의 표. 스냅숏 격리는 반복 읽기가 막지 못하는 팬텀을 대부분 막지만 쓰기 치우침은 허용하고, 반복 읽기는 그 반대다더티 리드반복 불가능팬텀갱신 손실쓰기 치우침READ UNCOMMITTED생김생김생김생김생김READ COMMITTED막음생김생김생김생김REPEATABLE READ막음막음생김막음막음스냅숏 격리막음막음때때로막음생김SERIALIZABLE막음막음막음막음막음잠금으로 정의한 수준 기준이다. 실제 제품은 이름이 같아도 더 많이 막기도 한다.예: PostgreSQL의 REPEATABLE READ는 스냅숏 격리라 팬텀도 막지만 쓰기 치우침은 생긴다.
격리 수준별로 생길 수 있는 이상. 반복 읽기와 스냅숏 격리는 서로가 막는 것이 달라 어느 한쪽이 더 강하다고 할 수 없다.

표에서 눈여겨볼 곳은 아래 두 줄이다. 잠금으로 구현한 반복 읽기는 읽은 행을 잠가 두므로 갱신 손실과 쓰기 치우침을 막지만, 행이 새로 생기는 팬텀은 막지 못한다. 스냅숏 격리는 그 반대로, 스냅숏이 고정돼 팬텀이 거의 안 보이지만 서로 다른 행을 고치는 쓰기 치우침은 지나간다. 그래서 같은 "REPEATABLE READ"라는 이름이라도 제품마다 실제로 막는 것이 다르다.

스냅숏 격리와 직렬화 가능 스냅숏 격리

스냅숏 격리(snapshot isolation, SI)는 MVCC로 자연스럽게 얻는 수준이다. 트랜잭션 전체가 시작 시점 스냅숏을 읽고, 같은 행을 동시에 고친 두 트랜잭션 중 나중에 커밋하려는 쪽을 실패시킨다 (먼저 커밋한 쪽이 이긴다). 남는 구멍이 쓰기 치우침(write skew)이다.

T1은 당직 두 명을 읽고 앨리스를 빼고, T2는 같은 두 명을 읽고 밥을 뺀다. 서로가 쓴 행을 읽은 rw 의존이 양쪽으로 이어져, 스냅숏 격리에서는 둘 다 커밋되어 당직이 0명이 되고 SSI에서는 나중 쪽이 직렬화 실패로 중단된다규칙: 당직 의사는 늘 한 명 이상T1당직 2명 읽음 → 앨리스 뺌T2당직 2명 읽음 → 밥 뺌rw: T1이 읽은 밥을 T2가 고침rw: T2가 읽은 앨리스를 T1이 고침스냅숏 격리고친 행이 서로 달라 쓰기 충돌이 없다.둘 다 커밋 →당직 0명직렬화 가능 스냅숏 격리(SSI)읽은 범위를 기록해 rw 의존 두 개가이어진 위험한 구조를 찾는다.한쪽을 중단 → 재시도하면 규칙 지킴
당직 의사 예. 두 트랜잭션이 상대가 고칠 행을 읽었다는 읽기-쓰기 의존이 고리를 이룬다. 어느 순서로 하나씩 돌려도 이 결과는 나올 수 없으므로 직렬화 가능하지 않다.

막는 방법은 셋이다. 응용 프로그램이 SELECT … FOR UPDATE로 읽은 행을 잠그거나, 규칙을 제약 조건으로 표현하거나, 데이터베이스가 직렬화를 보장하게 하는 것이다. 마지막 방법이 직렬화 가능 스냅숏 격리(serializable snapshot isolation, SSI)다. 스냅숏 격리 위에서 트랜잭션이 읽은 범위를 기록해 두고, 그림처럼 읽기-쓰기 의존 두 개가 이어진 위험한 구조가 생기면 한쪽을 중단시킨다. 잠금처럼 기다리게 하지 않으므로 읽기가 많은 부하에서도 2PL보다 처리량이 잘 나온다. 대신 실제로는 문제가 없는데 중단되는 경우(거짓 양성)가 있어 재시도가 늘어난다. PostgreSQL 9.1이 이를 구현했고, 설계는 Ports와 Grittner의 「Serializable Snapshot Isolation in PostgreSQL」에 있다.

PostgreSQL과 MySQL의 기본값

두 데이터베이스는 기본 격리 수준부터 다르다. PostgreSQL의 기본은 READ COMMITTED이고, MySQL InnoDB의 기본은 REPEATABLE READ다. 같은 이름의 수준도 구현이 달라, 같은 순서로 실행해도 결과가 갈린다.

T1이 잔액을 읽고, T2가 50으로 바꿔 커밋한 뒤, T1이 다시 읽고 10을 뺀다. PostgreSQL 기본은 두 번째 읽기에서 50과 결과 40, PostgreSQL REPEATABLE READ는 100을 읽고 갱신에서 직렬화 오류, MySQL 기본은 100을 읽지만 갱신은 최신 값 50에 적용해 40이 된다PostgreSQL 기본READ COMMITTEDPostgreSQLREPEATABLE READMySQL InnoDB 기본REPEATABLE READT1SELECT 잔액100100100T2잔액 ← 50, COMMIT———T1SELECT 잔액50새 스냅숏100처음 스냅숏100처음 스냅숏T1잔액 ← 잔액 − 1040최신 값에 적용오류직렬화 실패, 재시도40쓰기는 최신 값을 읽음칸의 값은 T1이 그 단계에서 본(또는 만든) 잔액이다. 처음 잔액은 100.
같은 실행 순서, 세 가지 결과. MySQL의 반복 읽기는 SELECT에는 처음 스냅숏을 쓰지만 UPDATE·DELETE는 최신 커밋 값에 적용한다. PostgreSQL의 반복 읽기는 그 경우 오류를 내고 재시도를 요구한다.
  • PostgreSQL(Transaction Isolation): 실제로는 세 수준만 있다. READ UNCOMMITTED를 고르면 READ COMMITTED로 동작한다. READ COMMITTED는 문장마다 새 스냅숏을 찍는다. REPEATABLE READ는 스냅숏 격리라 표준이 허용하는 팬텀도 막지만, 동시에 고친 행을 만나면 could not serialize access due to concurrent update 오류를 낸다. SERIALIZABLE은 위의 SSI다.
  • MySQL InnoDB(Transaction Isolation Levels): REPEATABLE READ의 평범한 SELECT는 처음 읽을 때 찍은 스냅숏을 끝까지 쓴다(Consistent Nonlocking Reads). 반면 UPDATE·DELETE와 SELECT … FOR UPDATE 같은 잠그는 읽기는 최신 커밋 값을 읽고, 인덱스 항목과 그 앞 틈을 함께 잠그는 다음 키 잠금으로 그 범위에 새 행이 끼어드는 것(팬텀)을 막는다. SERIALIZABLE은 평범한 SELECT도 공유 잠금을 잡는 잠금 방식이다.

어느 쪽이든 결론은 같다. 격리 수준은 이름이 아니라 그 제품 문서의 정의로 확인하고, 직렬화 실패나 교착으로 중단된 트랜잭션은 처음부터 다시 시도하도록 응용 프로그램을 짠다. 재시도할 수 없는 외부 호출(결제, 메일)은 트랜잭션 밖으로 뺀다.

낙관적 잠금과 비관적 잠금

사람이 화면을 열어 두고 몇 분 뒤에 저장하는 일처럼 한 작업이 여러 요청에 걸치면, 데이터베이스 트랜잭션을 그동안 열어 둘 수 없다. 이때 응용 프로그램이 고르는 두 전략이 Martin Fowler가 『Patterns of Enterprise Application Architecture』에서 이름 붙인 비관적 잠금과 낙관적 잠금이다.

비관적 잠금낙관적 잠금
가정충돌이 잦다충돌이 드물다
방법쓰기 전에 먼저 잠가 한 번에 한 작업만 들어오게 한다(한 트랜잭션 안이면 SELECT … FOR UPDATE)잠그지 않고 일한 뒤, 저장하는 순간 그사이 누가 고쳤는지 확인해 고쳤으면 되돌린다
대가기다림이 생기고 동시에 일할 수 있는 양이 준다. 잠금을 쥔 채 사라진 작업을 풀어 줄 규칙이 필요하다충돌하면 그때까지 한 일을 다시 해야 한다

낙관적 잠금은 흔히 행마다 버전 열을 두고 UPDATE … SET …, version = version + 1 WHERE id = ? AND version = ?처럼 읽을 때 본 버전을 조건에 넣어 구현한다. 고친 행이 0개면 그사이 남이 먼저 저장한 것이므로 사용자에게 알리고 다시 읽게 한다. 확인과 저장이 한 문장(한 트랜잭션) 안에서 함께 일어나야 한다는 것이 핵심이다. 앞 절의 갱신 손실을 응용 프로그램 수준에서 막는 방법이다.