Isolation — 동시성 속에서 일관성을 지키는 전략

JH.KIM·2026년 1월 5일

이 글에서 제시하는 동작 방식과 수치는 일반적인 운영 환경에서 관측되는 대략적인 범위입니다. 실제 동작은 워크로드, 설정, 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은 "구현 전략"이다


PART 1. Isolation의 의미


1. 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이 다루는 문제의 범위

Isolation은 "동시에 실행되는 트랜잭션들이 서로에게 어떤 영향을 미치는가"를 제어합니다.

완벽하게 격리된다면, 각 트랜잭션은 마치 자신만 실행되는 것처럼 동작합니다. 하지만 이는 성능 측면에서 비현실적입니다. 한 번에 하나의 트랜잭션만 실행하는 것과 같기 때문입니다.

정합성과 성능 사이의 선택

높은 격리 수준은 정합성을 보장하지만, 동시성을 제한합니다. 낮은 격리 수준은 동시성을 높이지만, 정합성 문제가 발생할 수 있습니다.

이 트레이드오프를 다루기 위해 ANSI SQL 표준은 네 가지 격리 수준을 정의했습니다. 그리고 각 데이터베이스는 이를 자신만의 방식으로 구현합니다.


2. ANSI SQL 표준과 현실의 간극

표준이 정의한 네 가지 격리 수준

ANSI SQL-92 표준은 네 가지 Isolation Level을 정의합니다.

  1. READ UNCOMMITTED: 가장 낮은 격리 수준
  2. READ COMMITTED: 커밋된 데이터만 읽기
  3. REPEATABLE READ: 같은 데이터를 반복해서 읽어도 같은 값
  4. SERIALIZABLE: 순차 실행과 동일한 결과 보장

표준은 각 수준에서 어떤 "현상(phenomenon)"이 발생할 수 있는지 정의합니다. Dirty Read, Non-repeatable Read, Phantom Read가 그것입니다.

표준과 구현의 차이

문제는 표준이 "최소한의 요구사항"만 정의한다는 점입니다. 실제 데이터베이스는 표준보다 더 강한 보장을 제공하거나, 표준에 없는 문제(Lost Update, Write Skew 등)를 다루기도 합니다.

PostgreSQL과 MySQL은 모두 ANSI SQL 표준을 따르지만, 내부 구현이 완전히 다릅니다. 같은 Isolation Level이라도 동작이 다를 수 있습니다.

DB마다 기본값이 다른 이유

DB기본 Isolation Level배경
PostgreSQLREAD COMMITTED동시성 최대화, 실용적 선택
MySQL (InnoDB)REPEATABLE READ바이너리 로그 복제와의 호환성
OracleREAD COMMITTED동시성 최대화
SQL ServerREAD COMMITTED동시성과 안정성의 균형

MySQL이 REPEATABLE READ를 기본값으로 선택한 이유는 역사적 배경이 있습니다. Statement-based replication에서 READ COMMITTED는 복제 불일치를 일으킬 수 있었기 때문입니다. Row-based replication이 기본이 된 지금도 기본값은 유지되고 있습니다.

PostgreSQL은 처음부터 READ COMMITTED를 기본값으로 선택했습니다. 대부분의 워크로드에서 충분하며, 동시성 측면에서 유리하다는 판단입니다.


PART 2. 동시성 문제의 실체


3. Isolation이 다루는 현상들

Dirty Read

다른 트랜잭션이 아직 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은 아예 구현하지 않았습니다.

Non-repeatable Read

같은 트랜잭션 내에서 같은 데이터를 두 번 읽었을 때 값이 다른 현상입니다.

-- 트랜잭션 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가 발생하지 않습니다.

Phantom 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도 방지합니다.

Lost Update — 같은 row를 동시에 수정하는 문제

두 트랜잭션이 같은 데이터를 읽고, 각자 수정한 뒤 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 표준에 명시되지 않았지만, 실무에서 가장 자주 마주치는 문제입니다. 재고 관리, 포인트 차감, 잔액 처리 등에서 발생합니다.

Write Skew — 서로 다른 row를 읽고 쓰는 문제

두 트랜잭션이 서로 다른 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에서 자세히 다룹니다.


PART 3. Isolation Level의 구현


4. READ UNCOMMITTED / READ COMMITTED

READ UNCOMMITTED의 의미

READ UNCOMMITTED는 다른 트랜잭션이 COMMIT하지 않은 데이터도 읽을 수 있는 수준입니다. Dirty Read가 발생할 수 있습니다.

실무에서는 거의 사용되지 않습니다. PostgreSQL은 아예 구현하지 않았고, READ UNCOMMITTED로 설정해도 내부적으로 READ COMMITTED로 동작합니다.

MySQL에서는 구현되어 있지만, 사용을 권장하지 않습니다.

READ COMMITTED — 커밋된 데이터만 읽기

READ COMMITTED는 COMMIT된 데이터만 읽을 수 있습니다. 가장 흔히 사용되는 격리 수준입니다.

-- PostgreSQL, Oracle, SQL Server 기본값
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

트랜잭션마다 새 Snapshot을 보는 방식

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을 생성합니다. 따라서 트랜잭션 내에서도 값이 달라질 수 있습니다.

이는 장점이자 단점입니다.

장점

  • 항상 최신 COMMIT된 데이터를 읽음
  • Lock을 오래 유지하지 않아 동시성이 높음
  • Deadlock 가능성이 낮음

단점

  • 같은 트랜잭션에서 같은 데이터를 읽어도 값이 다를 수 있음
  • 일관된 결과를 보장하지 않음

대부분의 OLTP 워크로드에서는 READ COMMITTED로 충분합니다.


5. REPEATABLE READ — Snapshot 고정

트랜잭션 시작 시 Snapshot 고정

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의 변경사항을 보지 못합니다.

Phantom Read 방지 여부

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;

PostgreSQL과 MySQL의 차이

측면MySQL REPEATABLE READPostgreSQL REPEATABLE READ
Snapshot 고정OO
Phantom Read 방지O (Next-Key Lock)O (Snapshot Isolation)
Lock 방식범위 Lock 사용Lock 없음
동시성낮음높음
Write Skew 방지XX

두 DB 모두 Phantom Read를 방지하지만, 방식이 완전히 다릅니다.


6. SERIALIZABLE — 순차 실행 보장

Serializable의 정의

SERIALIZABLE은 "여러 트랜잭션이 동시에 실행되더라도, 순차적으로 하나씩 실행한 것과 같은 결과"를 보장합니다.

가장 강력한 격리 수준이지만, 성능 비용도 가장 큽니다.

PostgreSQL의 Serializable Snapshot Isolation (SSI)

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

트랜잭션 abort와 의존 관계

SSI는 다음을 추적합니다.

  • 읽기-쓰기 의존성 (rw-dependency)
  • 쓰기-읽기 의존성 (wr-dependency)

순환 의존성이 발견되면, 한 트랜잭션을 abort하여 직렬화 가능성을 보장합니다.

이는 애플리케이션에서 재시도 로직이 필요함을 의미합니다.

MySQL의 SERIALIZABLE

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 SERIALIZABLEMySQL SERIALIZABLE
구현 방식SSI (의존성 추적)Lock 기반
읽기에 LockXO
동시성상대적으로 높음매우 낮음
트랜잭션 abort자주 발생 가능거의 없음 (대신 대기)

7. Isolation Level별 허용 현상 정리

ANSI SQL 표준 기준

Isolation LevelDirty ReadNon-repeatable ReadPhantom Read
READ UNCOMMITTED가능가능가능
READ COMMITTED불가가능가능
REPEATABLE READ불가불가가능
SERIALIZABLE불가불가불가

실제 구현 (PostgreSQL)

Isolation LevelDirty ReadNon-repeatable ReadPhantom ReadLost UpdateWrite Skew
READ COMMITTED불가가능가능가능가능
REPEATABLE READ불가불가불가가능가능
SERIALIZABLE불가불가불가불가불가

PostgreSQL의 REPEATABLE READ는 Snapshot Isolation 덕분에 Phantom Read도 방지합니다. 하지만 Lost Update와 Write Skew는 여전히 발생할 수 있습니다.

실제 구현 (MySQL)

Isolation LevelDirty ReadNon-repeatable ReadPhantom ReadLost UpdateWrite Skew
READ UNCOMMITTED가능가능가능가능가능
READ COMMITTED불가가능가능가능가능
REPEATABLE READ불가불가불가가능가능
SERIALIZABLE불가불가불가불가불가

MySQL의 REPEATABLE READ는 Next-Key Lock 덕분에 Phantom Read도 방지합니다. 하지만 Lost Update와 Write Skew는 여전히 발생할 수 있습니다.


PART 4. PostgreSQL vs MySQL 구현 비교


8. MVCC 구조의 차이

PostgreSQL의 튜플 버전 관리

PostgreSQL은 각 row의 여러 버전을 테이블에 직접 저장합니다.

테이블 페이지 내부:
[row v1: id=1, balance=10000, xmin=100, xmax=200]
[row v2: id=1, balance=5000, xmin=200, xmax=NULL]
  • xmin: 이 버전을 생성한 트랜잭션 ID
  • xmax: 이 버전을 삭제/수정한 트랜잭션 ID

각 트랜잭션은 자신의 Snapshot에 맞는 버전을 선택하여 읽습니다.

오래된 버전은 VACUUM 프로세스가 정리합니다.

MySQL의 Undo Log 방식

MySQL (InnoDB)는 최신 버전만 테이블에 저장하고, 이전 버전은 Undo Log에 보관합니다.

테이블:
[row: id=1, balance=5000]

Undo Log:
[row 이전 버전: id=1, balance=10000]

오래된 트랜잭션이 읽기를 시도하면, Undo Log를 참조하여 과거 버전을 재구성합니다.

Purge 쓰레드가 더 이상 필요 없는 Undo Log를 정리합니다.

Vacuum vs Purge

PostgreSQL VACUUM

  • 죽은 튜플(dead tuple)을 찾아서 재사용 가능하도록 표시
  • 테이블 bloat 방지
  • 긴 트랜잭션이 있으면 VACUUM이 진행되지 않음
  • autovacuum 프로세스가 자동으로 실행

MySQL Purge

  • Undo Log에서 더 이상 필요 없는 버전 삭제
  • Purge 쓰레드가 백그라운드에서 실행
  • 테이블에는 최신 버전만 있어 bloat 적음
측면PostgreSQLMySQL
버전 저장 위치테이블 내부Undo Log
읽기 성능빠름 (버전이 바로 있음)느릴 수 있음 (Undo 재구성)
쓰기 성능느림 (버전 추가)빠름 (최신만 유지)
공간 관리VACUUM 필요Purge 자동
Bloat 가능성높음낮음

9. 기본 Isolation Level과 동작 차이

PostgreSQL: READ COMMITTED 기본

PostgreSQL은 READ COMMITTED를 기본값으로 사용합니다.

-- 기본값
SHOW default_transaction_isolation;
-- read committed

대부분의 워크로드에서 충분하며, 동시성이 높습니다.

MySQL: REPEATABLE READ 기본

MySQL은 REPEATABLE READ를 기본값으로 사용합니다.

-- 기본값
SELECT @@transaction_isolation;
-- REPEATABLE-READ

Statement-based replication과의 호환성 때문에 선택된 기본값입니다.

Phantom Read 처리 방식

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;

Deadlock vs Serialization Failure

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.


PART 5. Isolation의 한계와 Lock


10. Isolation Level만으로 해결되지 않는 문제

Lost Update 시나리오 상세

재고 차감 예시를 다시 봅시다.

-- 트랜잭션 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의 구조적 한계

MVCC는 "읽기와 쓰기가 서로를 블로킹하지 않는다"는 장점이 있습니다. 하지만 이것이 Lost Update를 허용하는 원인이기도 합니다.

각 트랜잭션이 독립적인 Snapshot을 보기 때문에:

  1. 트랜잭션 A가 재고 100개를 읽음
  2. 트랜잭션 B가 재고 100개를 읽음 (A의 변경을 모름)
  3. A가 10개 차감하고 COMMIT
  4. B가 95개 차감하고 COMMIT (A의 차감을 덮어씀)

PostgreSQL SERIALIZABLE은 이를 감지하여 abort하지만, REPEATABLE READ는 허용합니다.


11. Lock과 Isolation의 관계

Isolation Level과 Lock의 역할 분리

Isolation Level은 "어떤 데이터를 볼 수 있는가"를 제어합니다.
Lock은 "어떤 데이터를 수정할 수 있는가"를 제어합니다.

두 개념은 독립적이지만, 함께 사용되어야 합니다.

MVCC가 Lock을 줄이는 방식

전통적인 Lock 기반 DB에서는:

트랜잭션 A: SELECT → Shared Lock 획득
트랜잭션 B: UPDATE → Shared Lock과 충돌, 대기

MVCC 기반 DB에서는:

트랜잭션 A: SELECT → Lock 없음 (Snapshot 읽기)
트랜잭션 B: UPDATE → 바로 진행 (최신 버전 쓰기)

읽기와 쓰기가 서로를 블로킹하지 않습니다.

SELECT FOR UPDATE / FOR SHARE

명시적으로 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이 필요한 시점

다음 경우에 명시적 Lock이 필요합니다.

  1. "읽고 판단하고 쓰는" 패턴: 재고, 포인트, 잔액 등
  2. Lost Update 방지: 여러 트랜잭션이 같은 row를 동시에 수정
  3. Write Skew 방지: 여러 row를 읽고 일부를 수정하는 경우
-- Lost Update 방지
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- Lock 획득
-- 이제 안전하게 재고 차감 가능
UPDATE products SET stock = stock - 10 WHERE id = 1;
COMMIT;

12. 비관적 락 vs 낙관적 락

비관적 락 (Pessimistic Locking)

"충돌이 자주 발생할 것"이라고 가정하고, 미리 Lock을 획득합니다.

BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- Lock을 획득했으므로 안전
UPDATE products SET stock = stock - 10 WHERE id = 1;
COMMIT;

장점

  • 충돌을 사전에 방지
  • 재시도 로직 불필요
  • 정합성 보장

단점

  • Lock 경합으로 인한 성능 저하
  • Deadlock 가능성
  • 동시성 감소

낙관적 락 (Optimistic Locking)

"충돌이 드물 것"이라고 가정하고, 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이면 충돌 발생 → 재시도

장점

  • Lock 없어 동시성 높음
  • Deadlock 없음
  • 읽기 위주 워크로드에 유리

단점

  • 충돌 시 재시도 필요
  • 애플리케이션 로직 복잡
  • 쓰기가 많으면 비효율

각 전략의 전제 조건

비관적 락을 선택하는 경우

  • 충돌이 자주 발생하는 워크로드
  • 재시도가 비즈니스적으로 부담되는 경우 (결제, 정산)
  • 짧은 트랜잭션

낙관적 락을 선택하는 경우

  • 충돌이 드문 워크로드
  • 읽기가 많고 쓰기가 적은 경우
  • 재시도가 허용되는 경우

DB 레벨과 애플리케이션 레벨의 역할

DB 레벨

  • SELECT FOR UPDATE로 비관적 락 구현
  • Isolation Level로 기본 보장 제공
  • Constraint로 데이터 무결성 검증

애플리케이션 레벨

  • 낙관적 락 구현 (version 관리)
  • 재시도 로직
  • 비즈니스 규칙 검증

두 레벨을 적절히 조합하여 사용합니다.


PART 6. 실전 선택 가이드


13. Isolation Level 선택이 시스템에 미치는 영향

TPS와 지연 시간

Isolation Level이 높아질수록 동시성이 감소하고, TPS와 지연 시간에 영향을 미칩니다.

아래 수치는 일반적인 OLTP 환경에서 관측되는 대략적인 범위입니다. 실제 결과는 하드웨어, 동시 연결 수, 쿼리 패턴 등에 따라 크게 달라질 수 있습니다.

PostgreSQL 기준

Isolation Level상대적 TPS평균 지연특징
READ COMMITTED100%낮음기준선
REPEATABLE READ80-90%약간 증가Snapshot 고정 비용
SERIALIZABLE50-70%높음충돌 감지 및 abort

MySQL 기준

Isolation Level상대적 TPS평균 지연특징
READ COMMITTED100%낮음기준선
REPEATABLE READ70-85%증가Next-Key Lock
SERIALIZABLE30-50%매우 높음모든 읽기에 Lock

긴 트랜잭션과 버전 정리 (Vacuum/Purge)

긴 트랜잭션은 MVCC 시스템에 부담을 줍니다.

PostgreSQL

오래된 트랜잭션이 있으면:

  • VACUUM이 죽은 튜플을 정리하지 못함
  • 테이블 bloat 증가
  • 쿼리 성능 저하
-- 긴 트랜잭션 확인
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY xact_start;

MySQL

오래된 트랜잭션이 있으면:

  • Undo Log가 계속 쌓임
  • Purge가 진행되지 않음
  • Undo Log 공간 부족 가능
-- Undo Log 크기 확인
SHOW ENGINE INNODB STATUS\G
-- History list length 확인

REPEATABLE READ / SERIALIZABLE의 비용

REPEATABLE READ

  • Snapshot을 트랜잭션 내내 유지
  • 긴 트랜잭션일수록 버전 정리 지연
  • 대량의 데이터를 읽는 분석 쿼리에는 부적합

SERIALIZABLE

  • PostgreSQL: Serialization 실패로 인한 재시도 증가
  • MySQL: 거의 모든 쿼리에 Lock, 동시성 급격히 감소
  • 일반 OLTP에서는 비현실적

14. 상황별 Isolation + Lock 전략

일반적인 OLTP 서비스

대부분의 웹 서비스에서 관측되는 조합입니다.

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;

주의사항:

  • 트랜잭션을 짧게 유지
  • 대량 처리는 작은 단위로 분할
  • 긴 트랜잭션은 시스템 전체에 영향

읽기 위주 vs 쓰기 위주 워크로드

읽기 위주

  • READ COMMITTED로 충분
  • Read Replica 활용
  • Cache 적극 사용

쓰기 위주

  • 충돌 가능성 높음
  • 명시적 Lock 또는 낙관적 락
  • Queue를 통한 순차 처리 고려

15. 정리 — Isolation은 "구현 전략"이다

표준은 같지만 구현은 다르다

ANSI SQL 표준은 네 가지 Isolation Level을 정의했지만, 각 데이터베이스는 이를 자신만의 방식으로 구현합니다.

PostgreSQL과 MySQL은 모두 REPEATABLE READ를 제공하지만:

  • PostgreSQL은 Snapshot Isolation
  • MySQL은 Next-Key Lock

같은 이름, 다른 동작입니다.

DB는 각자의 방식으로 Isolation을 구현한다

PostgreSQL

  • MVCC 기반, Lock 최소화
  • READ COMMITTED 기본
  • SSI로 SERIALIZABLE 구현
  • 동시성 최대화 철학

MySQL

  • MVCC + Lock 혼용
  • REPEATABLE READ 기본
  • Next-Key Lock으로 Phantom Read 방지
  • 복제 호환성 고려

각 DB의 설계 철학이 Isolation 구현에 반영되어 있습니다.

애플리케이션의 책임 영역

Isolation Level은 기본적인 보장만 제공합니다. 완벽한 정합성은 애플리케이션이 책임져야 합니다.

DB의 역할

  • Isolation Level로 기본 보장
  • Lock 메커니즘 제공
  • Constraint로 무결성 검증

애플리케이션의 역할

  • 적절한 Isolation Level 선택
  • 필요한 곳에 명시적 Lock
  • 재시도 로직 구현
  • 비즈니스 규칙 검증

Isolation Level을 이해하고, Lock을 적절히 사용하며, 애플리케이션 로직으로 보완하는 것이 중요합니다.


Isolation은 "레벨"보다 "전략"에 가깝다

흔히 "Isolation Level을 높이면 안전하다"고 생각하지만, 이는 절반만 맞는 이야기입니다. SERIALIZABLE을 사용하더라도 재시도 로직이 없다면 트랜잭션 실패로 이어질 수 있고, READ COMMITTED에 명시적 Lock을 더하면 충분히 안전할 수 있습니다.

중요한 것은:

  • 워크로드의 특성을 이해하고
  • 각 DB의 구현 방식을 알고
  • Isolation + Lock + 애플리케이션 로직을 조합하여
  • 성능과 정합성 사이에서 적절한 지점을 찾는 것

Isolation은 체크박스가 아니라, 시스템 설계의 핵심 요소입니다.

profile
일하며 겪은 문제를 나눠요

0개의 댓글