이 글에서 제시하는 동작 방식과 수치는 일반적인 운영 환경에서 관측되는 대략적인 범위입니다. 실제 동작은 워크로드, 설정, DB 버전에 따라 달라질 수 있습니다.
PART 1. Isolation의 의미
1. Isolation은 무엇을 보장하려는가
2. ANSI SQL 표준과 현실의 간극
PART 2. 동시성 문제의 실체
3. Isolation이 다루는 현상들
PART 3. Isolation Level의 구현
4. READ UNCOMMITTED / READ COMMITTED
5. REPEATABLE READ — Snapshot 고정
6. SERIALIZABLE — 순차 실행 보장
7. Isolation Level별 허용 현상 정리
PART 4. PostgreSQL vs MySQL 구현 비교
8. MVCC 구조의 차이
9. 기본 Isolation Level과 동작 차이
PART 5. Isolation의 한계와 Lock
10. Isolation Level만으로 해결되지 않는 문제
11. Lock과 Isolation의 관계
12. 비관적 락 vs 낙관적 락
PART 6. 실전 선택 가이드
13. Isolation Level 선택이 시스템에 미치는 영향
14. 상황별 Isolation + Lock 전략
15. 정리 — Isolation은 "구현 전략"이다
데이터베이스는 수십, 수백 개의 트랜잭션이 동시에 실행됩니다. 각 트랜잭션은 서로 다른 커넥션에서 시작되고, 같은 데이터를 읽거나 수정할 수 있습니다.
시각 T1: 트랜잭션 A가 계좌 잔액 읽기 (10,000원)
시각 T2: 트랜잭션 B가 계좌 잔액 읽기 (10,000원)
시각 T3: 트랜잭션 A가 5,000원 출금하고 COMMIT (잔액 5,000원)
시각 T4: 트랜잭션 B가 3,000원 출금하고 COMMIT (잔액 7,000원?)
두 트랜잭션이 모두 10,000원을 읽었고, A가 5,000원을 출금했습니다. 최종 잔액은 5,000원이어야 합니다. 그런데 B가 3,000원을 출금하면서 잔액을 7,000원으로 덮어쓴다면, A의 출금 내역이 사라집니다.
이것이 Isolation이 다루는 문제입니다.
Isolation은 "동시에 실행되는 트랜잭션들이 서로에게 어떤 영향을 미치는가"를 제어합니다.
완벽하게 격리된다면, 각 트랜잭션은 마치 자신만 실행되는 것처럼 동작합니다. 하지만 이는 성능 측면에서 비현실적입니다. 한 번에 하나의 트랜잭션만 실행하는 것과 같기 때문입니다.
높은 격리 수준은 정합성을 보장하지만, 동시성을 제한합니다. 낮은 격리 수준은 동시성을 높이지만, 정합성 문제가 발생할 수 있습니다.
이 트레이드오프를 다루기 위해 ANSI SQL 표준은 네 가지 격리 수준을 정의했습니다. 그리고 각 데이터베이스는 이를 자신만의 방식으로 구현합니다.
ANSI SQL-92 표준은 네 가지 Isolation Level을 정의합니다.
표준은 각 수준에서 어떤 "현상(phenomenon)"이 발생할 수 있는지 정의합니다. Dirty Read, Non-repeatable Read, Phantom Read가 그것입니다.
문제는 표준이 "최소한의 요구사항"만 정의한다는 점입니다. 실제 데이터베이스는 표준보다 더 강한 보장을 제공하거나, 표준에 없는 문제(Lost Update, Write Skew 등)를 다루기도 합니다.
PostgreSQL과 MySQL은 모두 ANSI SQL 표준을 따르지만, 내부 구현이 완전히 다릅니다. 같은 Isolation Level이라도 동작이 다를 수 있습니다.
| DB | 기본 Isolation Level | 배경 |
|---|---|---|
| PostgreSQL | READ COMMITTED | 동시성 최대화, 실용적 선택 |
| MySQL (InnoDB) | REPEATABLE READ | 바이너리 로그 복제와의 호환성 |
| Oracle | READ COMMITTED | 동시성 최대화 |
| SQL Server | READ COMMITTED | 동시성과 안정성의 균형 |
MySQL이 REPEATABLE READ를 기본값으로 선택한 이유는 역사적 배경이 있습니다. Statement-based replication에서 READ COMMITTED는 복제 불일치를 일으킬 수 있었기 때문입니다. Row-based replication이 기본이 된 지금도 기본값은 유지되고 있습니다.
PostgreSQL은 처음부터 READ COMMITTED를 기본값으로 선택했습니다. 대부분의 워크로드에서 충분하며, 동시성 측면에서 유리하다는 판단입니다.
다른 트랜잭션이 아직 COMMIT하지 않은 데이터를 읽는 현상입니다.
-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = balance - 5000 WHERE id = 1;
-- 아직 COMMIT 안 함
-- 트랜잭션 B (READ UNCOMMITTED 수준)
SELECT balance FROM accounts WHERE id = 1;
-- 5,000원이 차감된 값을 읽음
트랜잭션 A가 ROLLBACK하면, B가 읽은 값은 실제로 존재하지 않았던 값입니다.
READ COMMITTED 이상에서는 Dirty Read가 발생하지 않습니다. 대부분의 데이터베이스는 READ UNCOMMITTED를 거의 사용하지 않으며, PostgreSQL은 아예 구현하지 않았습니다.
같은 트랜잭션 내에서 같은 데이터를 두 번 읽었을 때 값이 다른 현상입니다.
-- 트랜잭션 A (READ COMMITTED 수준)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 10,000원
-- 트랜잭션 B
BEGIN;
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;
-- 트랜잭션 A (계속)
SELECT balance FROM accounts WHERE id = 1; -- 5,000원
COMMIT;
같은 트랜잭션에서 같은 row를 두 번 읽었는데 값이 달라졌습니다. READ COMMITTED는 이를 허용합니다.
REPEATABLE READ 이상에서는 Non-repeatable Read가 발생하지 않습니다.
같은 트랜잭션 내에서 같은 쿼리를 두 번 실행했을 때, 결과 집합에 포함되는 row 개수가 다른 현상입니다.
-- 트랜잭션 A (REPEATABLE READ 수준)
BEGIN;
SELECT * FROM orders WHERE status = 'PENDING'; -- 10건
-- 트랜잭션 B
BEGIN;
INSERT INTO orders (status) VALUES ('PENDING');
COMMIT;
-- 트랜잭션 A (계속)
SELECT * FROM orders WHERE status = 'PENDING'; -- 11건?
COMMIT;
새로운 row가 추가되거나 삭제되어, 같은 조건의 쿼리 결과가 달라집니다.
ANSI SQL 표준에서는 SERIALIZABLE만 Phantom Read를 방지한다고 정의하지만, MySQL의 REPEATABLE READ는 Next-Key Lock을 사용하여 Phantom Read도 방지합니다.
두 트랜잭션이 같은 데이터를 읽고, 각자 수정한 뒤 COMMIT하면서 한쪽의 변경사항이 사라지는 현상입니다.
-- 트랜잭션 A
BEGIN;
SELECT stock FROM products WHERE id = 1; -- 100개
-- 재고 확인 후 10개 차감
UPDATE products SET stock = 90 WHERE id = 1;
-- 트랜잭션 B (동시 진행)
BEGIN;
SELECT stock FROM products WHERE id = 1; -- 100개
-- 재고 확인 후 5개 차감
UPDATE products SET stock = 95 WHERE id = 1;
COMMIT;
-- 트랜잭션 A (계속)
COMMIT;
최종 재고는 90개가 됩니다. B의 5개 차감이 사라졌습니다.
Lost Update는 ANSI SQL 표준에 명시되지 않았지만, 실무에서 가장 자주 마주치는 문제입니다. 재고 관리, 포인트 차감, 잔액 처리 등에서 발생합니다.
두 트랜잭션이 서로 다른 row를 읽고, 각자 다른 row를 수정하지만 전체 제약 조건을 위반하는 현상입니다. Lost Update와 달리 같은 row를 수정하지 않기 때문에 감지하기 어렵습니다.
-- 병원 당직 시스템: 최소 1명의 의사가 당직이어야 함
-- 현재 Alice와 Bob 두 명이 당직 중
-- 트랜잭션 A (Alice가 당직 해제 요청)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2명
-- 2명이니까 1명 빠져도 괜찮음
UPDATE doctors SET on_call = false WHERE name = 'Alice';
-- 트랜잭션 B (Bob이 당직 해제 요청, 동시 진행)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2명
-- 2명이니까 1명 빠져도 괜찮음
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
-- 트랜잭션 A (계속)
COMMIT;
-- 결과: 당직 의사 0명! 제약 조건 위반
각 트랜잭션은 서로 다른 row를 수정했기 때문에 충돌이 감지되지 않습니다. 하지만 전체 시스템의 불변 조건("최소 1명 당직")이 깨졌습니다.
Write Skew는 REPEATABLE READ에서도 발생할 수 있으며, PostgreSQL의 SERIALIZABLE(SSI)에서만 감지됩니다.
Lost Update와 Write Skew는 특정 패턴에서 발생합니다.
1. 데이터 읽기 (SELECT)
2. 읽은 값을 기반으로 판단
3. 새로운 값 계산
4. 데이터 쓰기 (UPDATE)
이 패턴은 애플리케이션 로직에서 매우 흔합니다. 하지만 Isolation Level만으로는 이 문제를 완전히 해결할 수 없습니다.
REPEATABLE READ는 Lost Update를 방지하지 못합니다. MVCC 기반 DB에서는 각 트랜잭션이 독립적인 Snapshot을 보기 때문에, 서로의 변경사항을 인지하지 못한 채 UPDATE가 진행됩니다.
이 문제는 PART 5에서 자세히 다룹니다.
READ UNCOMMITTED는 다른 트랜잭션이 COMMIT하지 않은 데이터도 읽을 수 있는 수준입니다. Dirty Read가 발생할 수 있습니다.
실무에서는 거의 사용되지 않습니다. PostgreSQL은 아예 구현하지 않았고, READ UNCOMMITTED로 설정해도 내부적으로 READ COMMITTED로 동작합니다.
MySQL에서는 구현되어 있지만, 사용을 권장하지 않습니다.
READ COMMITTED는 COMMIT된 데이터만 읽을 수 있습니다. 가장 흔히 사용되는 격리 수준입니다.
-- PostgreSQL, Oracle, SQL Server 기본값
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
READ COMMITTED의 핵심은 "각 쿼리마다 새로운 Snapshot을 본다"는 점입니다.
-- 트랜잭션 A (READ COMMITTED)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 10,000원 (Snapshot 1)
-- 트랜잭션 B
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;
-- 트랜잭션 A (계속)
SELECT balance FROM accounts WHERE id = 1; -- 5,000원 (Snapshot 2)
COMMIT;
첫 번째 SELECT는 Snapshot 1을 보고, 두 번째 SELECT는 Snapshot 2를 봅니다. 트랜잭션 B가 COMMIT한 변경사항이 반영됩니다.
READ COMMITTED는 "각 쿼리의 시작 시점"을 기준으로 Snapshot을 생성합니다. 따라서 트랜잭션 내에서도 값이 달라질 수 있습니다.
이는 장점이자 단점입니다.
장점
단점
대부분의 OLTP 워크로드에서는 READ COMMITTED로 충분합니다.
REPEATABLE READ는 "트랜잭션 시작 시점"의 Snapshot을 고정합니다. 트랜잭션이 끝날 때까지 같은 Snapshot을 봅니다.
-- MySQL 기본값
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 트랜잭션 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 10,000원
-- 트랜잭션 B
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;
-- 트랜잭션 A (계속)
SELECT balance FROM accounts WHERE id = 1; -- 10,000원 (여전히)
COMMIT;
트랜잭션 A는 시작 시점의 Snapshot을 보기 때문에, B의 변경사항을 보지 못합니다.
ANSI SQL 표준에서는 REPEATABLE READ가 Phantom Read를 방지하지 못한다고 정의합니다. 하지만 실제 구현은 다릅니다.
MySQL (InnoDB)
MySQL은 Next-Key Lock을 사용하여 Phantom Read도 방지합니다.
-- 트랜잭션 A (MySQL REPEATABLE READ)
BEGIN;
SELECT * FROM orders WHERE status = 'PENDING'; -- 10건
-- 트랜잭션 B
INSERT INTO orders (status) VALUES ('PENDING');
-- Lock을 획득하지 못하고 대기
-- 트랜잭션 A
SELECT * FROM orders WHERE status = 'PENDING'; -- 여전히 10건
COMMIT;
-- 이제 트랜잭션 B의 INSERT가 진행됨
PostgreSQL
PostgreSQL의 REPEATABLE READ는 MVCC만 사용하며, Lock을 걸지 않습니다. Phantom Read가 발생하지 않는데, 이는 Lock이 아니라 Snapshot Isolation 덕분입니다.
-- 트랜잭션 A (PostgreSQL REPEATABLE READ)
BEGIN;
SELECT * FROM orders WHERE status = 'PENDING'; -- 10건
-- 트랜잭션 B
INSERT INTO orders (status) VALUES ('PENDING');
COMMIT; -- 바로 진행됨 (Lock 없음)
-- 트랜잭션 A
SELECT * FROM orders WHERE status = 'PENDING'; -- 여전히 10건 (Snapshot)
COMMIT;
| 측면 | MySQL REPEATABLE READ | PostgreSQL REPEATABLE READ |
|---|---|---|
| Snapshot 고정 | O | O |
| Phantom Read 방지 | O (Next-Key Lock) | O (Snapshot Isolation) |
| Lock 방식 | 범위 Lock 사용 | Lock 없음 |
| 동시성 | 낮음 | 높음 |
| Write Skew 방지 | X | X |
두 DB 모두 Phantom Read를 방지하지만, 방식이 완전히 다릅니다.
SERIALIZABLE은 "여러 트랜잭션이 동시에 실행되더라도, 순차적으로 하나씩 실행한 것과 같은 결과"를 보장합니다.
가장 강력한 격리 수준이지만, 성능 비용도 가장 큽니다.
PostgreSQL은 Serializable Snapshot Isolation (SSI)을 사용합니다. MVCC를 기반으로 하며, 트랜잭션 간 의존 관계를 추적하여 직렬화 가능성을 검증합니다.
SSI가 감지하는 대표적인 문제는 Write Skew입니다.
-- 병원 당직 시스템: 최소 1명의 의사가 당직이어야 함
-- 현재 Alice와 Bob 두 명이 당직 중
-- 트랜잭션 A
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2명
UPDATE doctors SET on_call = false WHERE name = 'Alice';
-- 트랜잭션 B (동시 진행)
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2명
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT; -- 성공
-- 트랜잭션 A (계속)
COMMIT;
-- ERROR: could not serialize access due to read/write dependencies among transactions
PostgreSQL은 두 트랜잭션 간의 읽기-쓰기 의존성을 추적하여, 직렬화 불가능한 상황을 감지하면 한쪽을 ROLLBACK시킵니다.
같은 row에 대한 동시 UPDATE도 감지합니다.
-- 트랜잭션 A
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1; -- 10,000원
UPDATE accounts SET balance = balance - 5000 WHERE id = 1;
-- 트랜잭션 B (동시 진행)
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1; -- 10,000원
UPDATE accounts SET balance = balance - 8000 WHERE id = 1;
COMMIT;
-- 트랜잭션 A (계속)
COMMIT;
-- ERROR: could not serialize access due to concurrent update
SSI는 다음을 추적합니다.
순환 의존성이 발견되면, 한 트랜잭션을 abort하여 직렬화 가능성을 보장합니다.
이는 애플리케이션에서 재시도 로직이 필요함을 의미합니다.
MySQL의 SERIALIZABLE은 PostgreSQL과 완전히 다릅니다. 모든 SELECT를 SELECT ... FOR SHARE처럼 동작하게 만들어, 읽기에도 Lock을 겁니다.
-- MySQL SERIALIZABLE
BEGIN;
SELECT * FROM accounts WHERE id = 1;
-- 내부적으로 Shared Lock 획득
-- 다른 트랜잭션
UPDATE accounts SET balance = 5000 WHERE id = 1;
-- Shared Lock과 충돌하여 대기
Lock 기반이기 때문에 동시성이 크게 떨어집니다.
| 측면 | PostgreSQL SERIALIZABLE | MySQL SERIALIZABLE |
|---|---|---|
| 구현 방식 | SSI (의존성 추적) | Lock 기반 |
| 읽기에 Lock | X | O |
| 동시성 | 상대적으로 높음 | 매우 낮음 |
| 트랜잭션 abort | 자주 발생 가능 | 거의 없음 (대신 대기) |
| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 |
| READ COMMITTED | 불가 | 가능 | 가능 |
| REPEATABLE READ | 불가 | 불가 | 가능 |
| SERIALIZABLE | 불가 | 불가 | 불가 |
| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read | Lost Update | Write Skew |
|---|---|---|---|---|---|
| READ COMMITTED | 불가 | 가능 | 가능 | 가능 | 가능 |
| REPEATABLE READ | 불가 | 불가 | 불가 | 가능 | 가능 |
| SERIALIZABLE | 불가 | 불가 | 불가 | 불가 | 불가 |
PostgreSQL의 REPEATABLE READ는 Snapshot Isolation 덕분에 Phantom Read도 방지합니다. 하지만 Lost Update와 Write Skew는 여전히 발생할 수 있습니다.
| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read | Lost Update | Write Skew |
|---|---|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 | 가능 | 가능 |
| READ COMMITTED | 불가 | 가능 | 가능 | 가능 | 가능 |
| REPEATABLE READ | 불가 | 불가 | 불가 | 가능 | 가능 |
| SERIALIZABLE | 불가 | 불가 | 불가 | 불가 | 불가 |
MySQL의 REPEATABLE READ는 Next-Key Lock 덕분에 Phantom Read도 방지합니다. 하지만 Lost Update와 Write Skew는 여전히 발생할 수 있습니다.
PostgreSQL은 각 row의 여러 버전을 테이블에 직접 저장합니다.
테이블 페이지 내부:
[row v1: id=1, balance=10000, xmin=100, xmax=200]
[row v2: id=1, balance=5000, xmin=200, xmax=NULL]
xmin: 이 버전을 생성한 트랜잭션 IDxmax: 이 버전을 삭제/수정한 트랜잭션 ID각 트랜잭션은 자신의 Snapshot에 맞는 버전을 선택하여 읽습니다.
오래된 버전은 VACUUM 프로세스가 정리합니다.
MySQL (InnoDB)는 최신 버전만 테이블에 저장하고, 이전 버전은 Undo Log에 보관합니다.
테이블:
[row: id=1, balance=5000]
Undo Log:
[row 이전 버전: id=1, balance=10000]
오래된 트랜잭션이 읽기를 시도하면, Undo Log를 참조하여 과거 버전을 재구성합니다.
Purge 쓰레드가 더 이상 필요 없는 Undo Log를 정리합니다.
PostgreSQL VACUUM
autovacuum 프로세스가 자동으로 실행MySQL Purge
| 측면 | PostgreSQL | MySQL |
|---|---|---|
| 버전 저장 위치 | 테이블 내부 | Undo Log |
| 읽기 성능 | 빠름 (버전이 바로 있음) | 느릴 수 있음 (Undo 재구성) |
| 쓰기 성능 | 느림 (버전 추가) | 빠름 (최신만 유지) |
| 공간 관리 | VACUUM 필요 | Purge 자동 |
| Bloat 가능성 | 높음 | 낮음 |
PostgreSQL은 READ COMMITTED를 기본값으로 사용합니다.
-- 기본값
SHOW default_transaction_isolation;
-- read committed
대부분의 워크로드에서 충분하며, 동시성이 높습니다.
MySQL은 REPEATABLE READ를 기본값으로 사용합니다.
-- 기본값
SELECT @@transaction_isolation;
-- REPEATABLE-READ
Statement-based replication과의 호환성 때문에 선택된 기본값입니다.
PostgreSQL READ COMMITTED
Phantom Read 발생 가능:
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'PENDING'; -- 10
-- 다른 트랜잭션이 INSERT
SELECT COUNT(*) FROM orders WHERE status = 'PENDING'; -- 11
COMMIT;
MySQL REPEATABLE READ
Next-Key Lock으로 방지:
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'PENDING'; -- 10
-- 다른 트랜잭션의 INSERT는 Lock 대기
SELECT COUNT(*) FROM orders WHERE status = 'PENDING'; -- 10
COMMIT;
PostgreSQL
SERIALIZABLE에서는 Serialization Failure 발생:
ERROR: could not serialize access due to read/write dependencies among transactions
애플리케이션에서 재시도 필요.
MySQL
Lock 기반이므로 Deadlock 발생 가능:
ERROR: Deadlock found when trying to get lock
InnoDB가 자동으로 한 트랜잭션을 ROLLBACK.
재고 차감 예시를 다시 봅시다.
-- 트랜잭션 A (REPEATABLE READ)
BEGIN;
SELECT stock FROM products WHERE id = 1; -- 100개
-- 애플리케이션에서 재고 확인: 100 >= 10 → OK
UPDATE products SET stock = stock - 10 WHERE id = 1;
-- 트랜잭션 B (동시 진행)
BEGIN;
SELECT stock FROM products WHERE id = 1; -- 100개
-- 애플리케이션에서 재고 확인: 100 >= 95 → OK
UPDATE products SET stock = stock - 95 WHERE id = 1;
COMMIT; -- stock = 5
-- 트랜잭션 A (계속)
COMMIT; -- stock = 90
최종 재고는 90개입니다. B의 95개 차감이 사라졌습니다.
REPEATABLE READ를 사용했지만, Lost Update가 발생했습니다.
값을 읽고 조건을 판단한 후 쓰는 패턴에서 문제가 발생합니다.
-- 포인트 차감
SELECT points FROM users WHERE id = 1; -- 1000 포인트
-- 애플리케이션: 1000 >= 500 → OK
UPDATE users SET points = points - 500 WHERE id = 1;
-- 동시에 다른 곳에서
SELECT points FROM users WHERE id = 1; -- 1000 포인트
-- 애플리케이션: 1000 >= 800 → OK
UPDATE users SET points = points - 800 WHERE id = 1;
-- 최종: -300 포인트 (음수!)
이 패턴에서는 Isolation Level만으로 부족합니다.
MVCC는 "읽기와 쓰기가 서로를 블로킹하지 않는다"는 장점이 있습니다. 하지만 이것이 Lost Update를 허용하는 원인이기도 합니다.
각 트랜잭션이 독립적인 Snapshot을 보기 때문에:
PostgreSQL SERIALIZABLE은 이를 감지하여 abort하지만, REPEATABLE READ는 허용합니다.
Isolation Level은 "어떤 데이터를 볼 수 있는가"를 제어합니다.
Lock은 "어떤 데이터를 수정할 수 있는가"를 제어합니다.
두 개념은 독립적이지만, 함께 사용되어야 합니다.
전통적인 Lock 기반 DB에서는:
트랜잭션 A: SELECT → Shared Lock 획득
트랜잭션 B: UPDATE → Shared Lock과 충돌, 대기
MVCC 기반 DB에서는:
트랜잭션 A: SELECT → Lock 없음 (Snapshot 읽기)
트랜잭션 B: UPDATE → 바로 진행 (최신 버전 쓰기)
읽기와 쓰기가 서로를 블로킹하지 않습니다.
명시적으로 Lock을 걸 수 있습니다.
SELECT FOR UPDATE
다른 트랜잭션이 해당 row를 UPDATE하거나 SELECT FOR UPDATE하지 못하게 합니다.
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- Exclusive Lock
-- 이제 다른 트랜잭션은 이 row를 수정하거나 FOR UPDATE로 읽지 못함
UPDATE products SET stock = stock - 10 WHERE id = 1;
COMMIT;
SELECT FOR SHARE
다른 트랜잭션이 해당 row를 UPDATE하지 못하게 하지만, SELECT FOR SHARE는 허용합니다.
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR SHARE; -- Shared Lock
-- 다른 트랜잭션도 FOR SHARE로 읽을 수 있지만, UPDATE는 불가
COMMIT;
다음 경우에 명시적 Lock이 필요합니다.
-- Lost Update 방지
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- Lock 획득
-- 이제 안전하게 재고 차감 가능
UPDATE products SET stock = stock - 10 WHERE id = 1;
COMMIT;
"충돌이 자주 발생할 것"이라고 가정하고, 미리 Lock을 획득합니다.
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- Lock을 획득했으므로 안전
UPDATE products SET stock = stock - 10 WHERE id = 1;
COMMIT;
장점
단점
"충돌이 드물 것"이라고 가정하고, Lock 없이 진행합니다. COMMIT 직전에 충돌 여부를 확인합니다.
-- 읽기
SELECT stock, version FROM products WHERE id = 1;
-- stock = 100, version = 1
-- 수정 시도
UPDATE products
SET stock = stock - 10, version = version + 1
WHERE id = 1 AND version = 1;
-- 영향받은 행이 0이면 충돌 발생 → 재시도
장점
단점
비관적 락을 선택하는 경우
낙관적 락을 선택하는 경우
DB 레벨
애플리케이션 레벨
두 레벨을 적절히 조합하여 사용합니다.
Isolation Level이 높아질수록 동시성이 감소하고, TPS와 지연 시간에 영향을 미칩니다.
아래 수치는 일반적인 OLTP 환경에서 관측되는 대략적인 범위입니다. 실제 결과는 하드웨어, 동시 연결 수, 쿼리 패턴 등에 따라 크게 달라질 수 있습니다.
PostgreSQL 기준
| Isolation Level | 상대적 TPS | 평균 지연 | 특징 |
|---|---|---|---|
| READ COMMITTED | 100% | 낮음 | 기준선 |
| REPEATABLE READ | 80-90% | 약간 증가 | Snapshot 고정 비용 |
| SERIALIZABLE | 50-70% | 높음 | 충돌 감지 및 abort |
MySQL 기준
| Isolation Level | 상대적 TPS | 평균 지연 | 특징 |
|---|---|---|---|
| READ COMMITTED | 100% | 낮음 | 기준선 |
| REPEATABLE READ | 70-85% | 증가 | Next-Key Lock |
| SERIALIZABLE | 30-50% | 매우 높음 | 모든 읽기에 Lock |
긴 트랜잭션은 MVCC 시스템에 부담을 줍니다.
PostgreSQL
오래된 트랜잭션이 있으면:
-- 긴 트랜잭션 확인
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY xact_start;
MySQL
오래된 트랜잭션이 있으면:
-- Undo Log 크기 확인
SHOW ENGINE INNODB STATUS\G
-- History list length 확인
REPEATABLE READ
SERIALIZABLE
대부분의 웹 서비스에서 관측되는 조합입니다.
PostgreSQL
-- 기본값 사용
-- READ COMMITTED
-- 필요한 곳만 명시적 Lock
BEGIN;
-- 일반 읽기
SELECT * FROM users WHERE id = 1;
-- 중요한 쓰기
UPDATE accounts SET balance = balance - 100
WHERE user_id = 1 AND balance >= 100;
COMMIT;
MySQL
-- 기본값 사용
-- REPEATABLE READ
-- 필요한 곳만 명시적 Lock
BEGIN;
-- 일반 읽기
SELECT * FROM users WHERE id = 1;
-- 중요한 쓰기
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
COMMIT;
"읽고 판단하고 쓰는" 패턴에서는 명시적 Lock이 필수입니다.
-- 재고 차감 (비관적 락)
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 애플리케이션에서 재고 확인
-- if stock >= 10:
UPDATE products SET stock = stock - 10 WHERE id = 1;
COMMIT;
또는 낙관적 락:
-- 재고 차감 (낙관적 락)
BEGIN;
SELECT stock, version FROM products WHERE id = 1;
-- stock = 100, version = 5
UPDATE products
SET stock = stock - 10, version = version + 1
WHERE id = 1 AND version = 5;
-- 영향받은 행이 0이면 재시도
COMMIT;
대량 데이터 처리, 일관된 Snapshot 필요.
-- PostgreSQL: REPEATABLE READ
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 정산 시작 시점의 Snapshot으로 모든 데이터 처리
SELECT SUM(amount) FROM transactions WHERE created_at >= '2024-01-01';
INSERT INTO daily_settlements (date, total_amount, ...)
VALUES (...);
COMMIT;
주의사항:
읽기 위주
쓰기 위주
ANSI SQL 표준은 네 가지 Isolation Level을 정의했지만, 각 데이터베이스는 이를 자신만의 방식으로 구현합니다.
PostgreSQL과 MySQL은 모두 REPEATABLE READ를 제공하지만:
같은 이름, 다른 동작입니다.
PostgreSQL
MySQL
각 DB의 설계 철학이 Isolation 구현에 반영되어 있습니다.
Isolation Level은 기본적인 보장만 제공합니다. 완벽한 정합성은 애플리케이션이 책임져야 합니다.
DB의 역할
애플리케이션의 역할
Isolation Level을 이해하고, Lock을 적절히 사용하며, 애플리케이션 로직으로 보완하는 것이 중요합니다.
흔히 "Isolation Level을 높이면 안전하다"고 생각하지만, 이는 절반만 맞는 이야기입니다. SERIALIZABLE을 사용하더라도 재시도 로직이 없다면 트랜잭션 실패로 이어질 수 있고, READ COMMITTED에 명시적 Lock을 더하면 충분히 안전할 수 있습니다.
중요한 것은:
Isolation은 체크박스가 아니라, 시스템 설계의 핵심 요소입니다.