우아한테크코스 레벨 4 팀 프로젝트 festabook에서 학습한 내용을 정리한 글입니다.
💭 들어가며
서버에서 동시성 문제가 발생한 것을 확인했다. 팀원들과 논의하는 과정에서, 정확한 해결책을 찾으려면 트랜잭션 격리 수준에 대한 이해가 선행되어야 한다는 필요성을 느꼈다. 마침 레벨 4 강의에서 트랜잭션 격리 수준을 다루고 있어, 이를 학습하고 내용을 정리하고자 했다. 해당 글은 MySQL(InnoDB)을 기준으로 작성되었다.
✅ 트랜잭션
트랜잭션(Transaction)은 데이터베이스에서 수행되는 작업 단위를 의미한다. 여러 SQL 문장을 하나의 논리적 단위로 묶어서, 전부 성공하거나 전부 실패하도록 보장한다.
🔽 트랜잭션 기본 구조
BEGIN
UPDATE
INSERT
COMMIT
-- ROLLBACK
- BEGIN: 트랜잭션 시작
- UPDATE, INSERT, DELETE: 트랜잭션 내에서 실행되는 SQL 문들
- COMMIT: 모든 작업을 확정 → DB에 영구 저장
- ROLLBACK: 작업 도중 실패 시 이전 상태로 복구
🔽 트랜잭션의 4가지 특징 (ACID)
- 원자성(Atomicity): 트랜잭션에 포함된 작업은 모두 성공하거나, 모두 실패해야 한다.
- 일관성(Consistency): 트랜잭션 수행 전후에 데이터는 일관된 상태를 유지해야 한다.
- 격리성(Isolation): 동시에 실행되는 트랜잭션은 서로 간섭하지 않아야 한다. 격리 수준을 통해 조정된다.
- 지속성(Durability): 트랜잭션이 COMMIT 되면, 그 결과는 영구적으로 저장된다.
✅ 동시성 문제 (Read 계열)
일반적으로 동시성 문제는 다양한 유형으로 분류된다. 이 글에서는 트랜잭션 격리 수준과 직접적으로 연관된 Read 계열의 3가지 문제(Dirty Read, Non-Repeatable Read, Phantom Read)만 다룬다. 예시 코드에 나오는 격리 수준은 아래에서 따로 다룰 예정이니, 지금은 어떤 문제가 발생하는지만 파악한다.
이외의 동시성 문제들은 Lock과 함께 별도의 글에서 다룰 예정이다. (추후 해당 글에도 업데이트)
▶ DIRTY READ
아직 커밋되지 않은(즉, 롤백될 수도 있는) 다른 트랜잭션의 데이터를 읽는 문제를 말한다.
🔽 상황
- 트랜잭션 A가 balance = 100 → 200으로 수정했지만 커밋하지 않은 상태이다.
- 트랜잭션 B가 그 값을 읽어서 200으로 인식한다.
- 그런데 트랜잭션 A가 롤백하면 실제 값은 다시 100이 되지만, B는 잘못된 값(200)을 기반으로 로직을 수행하게 된다.
🔽 예시 코드
@Test
void dirtyReadExample() throws SQLException {
// Testcontainers로 MySQL 실행
var mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.30"))
.withLogConsumer(new Slf4jLogConsumer(log));
mysql.start();
setUp(createMySQLDataSource(mysql));
// 초기 데이터 준비: balance=100인 계좌 생성
accountDao.insert(dataSource.getConnection(), new Account(1L, 100));
// 트랜잭션 A: 시작
var connectionA = dataSource.getConnection();
connectionA.setAutoCommit(false);
connectionA.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
// 트랜잭션 A: balance=200으로 변경 (아직 COMMIT 전)
accountDao.updateBalance(connectionA, 1L, 200);
// 트랜잭션 B: 실행 (READ_UNCOMMITTED 수준)
new Thread(RunnableWrapper.accept(() -> {
var connectionB = dataSource.getConnection();
connectionB.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED);
// 트랜잭션 B: A가 COMMIT하지 않은 데이터를 읽음 -> Dirty Read 발생
int balance = accountDao.findBalanceById(connectionB, 1L);
assertThat(balance).isEqualTo(200);
})).start();
sleep(0.5);
// 트랜잭션 A: ROLLBACK → balance=100으로 복원
connectionA.rollback();
}
▶ NON-REPEATABLE READ
같은 트랜잭션 안에서 같은 쿼리를 두 번 실행했는데, 그 사이에 다른 트랜잭션이 기존 데이터를 수정/삭제해서 결과가 달라지는 문제를 말한다.
🔽 상황
- 트랜잭션 A가 SELECT balance FROM account WHERE id=1 → 100을 읽는다.
- 트랜잭션 B가 같은 row를 100 → 200으로 수정하고 커밋한다.
- 트랜잭션 A가 다시 같은 쿼리를 실행했을 때 200이 나온다.
- 즉, 동일한 조건으로 읽었는데 값이 달라져 반복해서 읽을 수 없다는 문제가 발생한다.
🔽 예시 코드
@Test
void nonRepeatableReadExample() throws SQLException {
// Testcontainers로 MySQL 실행
var mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.30"))
.withLogConsumer(new Slf4jLogConsumer(log));
mysql.start();
setUp(createMySQLDataSource(mysql));
// 초기 데이터 준비: balance=100인 계좌 생성
accountDao.insert(dataSource.getConnection(), new Account(1L, 100));
// 트랜잭션 A: 시작 (READ_COMMITTED 수준)
var connectionA = dataSource.getConnection();
connectionA.setAutoCommit(false);
connectionA.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
// 트랜잭션 A: balance 조회 -> 100
int firstRead = accountDao.findBalanceById(connectionA, 1L);
// 트랜잭션 B: balance=200으로 수정 후 COMMIT
new Thread(RunnableWrapper.accept(() -> {
var connectionB = dataSource.getConnection();
accountDao.updateBalance(connectionB, 1L, 200);
})).start();
sleep(0.5);
// 트랜잭션 A: 같은 row 다시 조회 -> 200
int secondRead = accountDao.findBalanceById(connectionA, 1L);
// Non-Repeatable Read 발생 확인
assertThat(firstRead).isNotEqualTo(secondRead);
connectionA.rollback();
}
▶ PHANTOM READ
같은 트랜잭션 안에서 같은 조건으로 조회했는데, 그 사이에 다른 트랜잭션이 새로운 row를 추가하거나 삭제하여 결과 집합의 개수가 달라지는 문제를 말한다.
🔽 상황
- 트랜잭션 A가 SELECT * FROM orders WHERE amount > 100 실행 → 5건 조회한다.
- 트랜잭션 B가 새로운 주문(amount=200)을 INSERT하고 커밋한다.
- 트랜잭션 A가 다시 같은 쿼리를 실행했을 때 6건이 조회된다.
- 기존 행의 값이 바뀐 게 아니라 “새로운 행이 생겨나거나 사라지는 것”만 해당된다.
🔽 예시 코드
@Test
void phantomReadExample() throws SQLException {
// Testcontainers로 MySQL 실행
var mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.30"))
.withLogConsumer(new Slf4jLogConsumer(log));
mysql.start();
setUp(createMySQLDataSource(mysql));
// 초기 데이터 삽입: amount=150 주문 5건
for (int i = 1; i <= 5; i++) {
orderDao.insert(dataSource.getConnection(), new Order((long) i, 150));
}
// 트랜잭션 A: 시작 (READ_COMMITTED 수준)
var connectionA = dataSource.getConnection();
connectionA.setAutoCommit(false);
connectionA.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
// 트랜잭션 A: amount > 100 조건으로 조회 -> 5건
var firstRead = orderDao.findByAmountGreaterThan(connectionA, 100);
// 트랜잭션 B: 새로운 주문 추가 후 COMMIT
new Thread(RunnableWrapper.accept(() -> {
var connectionB = dataSource.getConnection();
connectionB.setAutoCommit(false);
orderDao.insert(connectionB, new Order(6L, 200));
connectionB.commit();
})).start();
sleep(0.5);
// 트랜잭션 A: 다시 같은 조건으로 조회 -> 6건
var secondRead = orderDao.findByAmountGreaterThan(connectionA, 100);
// Phantom Read 발생 확인
assertThat(firstRead.size()).isNotEqualTo(secondRead.size());
connectionA.rollback();
mysql.close();
}
🔽 참고
MySQL(InnoDB)의 특성: InnoDB는 REPEATABLE READ 격리 수준에서 갭 락(Gap Lock)을 사용하기 때문에 Phantom Read가 발생하지 않는다.
✅ 트랜잭션 격리 수준
트랜잭션 격리 수준(Isolation Level)이란 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 기준이다.
격리 수준은 크게 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE의 네 가지로 구분된다. 뒤로 갈수록 트랜잭션 간 격리 정도는 높아지지만, 일반적으로 동시 처리 성능은 낮아진다.
Real MySQL 8.0에 따르면, 일반적으로 데이터베이스에서는 READ_COMMITTED와 REPEATABLE READ가 가장 많이 사용되며, MySQL(InnoDB)의 기본 격리 수준은 REPEATABLE READ이다. 또한, SQL-92/SQL-99 표준에 따르면 REPEATABLE READ에서는 PHANTOM READ가 발생할 수 있다고 규정하지만, InnoDB에서는 갭 락(Gap Lock)을 사용하기 때문에 PHANTOM READ가 발생하지 않는다.
▶ READ UNCOMMITTED
이 격리 수준에서는 트랜잭션의 변경 내용이 COMMIT이나 ROLLBACK 여부와 상관없이 다른 트랜잭션에서 보일 수 있다.
- 따라서, DIRTY READ가 발생한다.
🔽 참고
Real MySQL 8.0에 따르면, READ UNCOMMITTED는 정합성 문제가 심각하여 RDBMS 표준에서도 사실상 권장되지 않는다.
🔽 DIRTY READ가 발생함

- 사용자 A가 emp_no=500000, first_name = 'Lara'인 사원을 INSERT한다.
- 사용자 B는 A가 아직 COMMIT하지 않았음에도 불구하고, emp_no=500000인 사원을 조회할 수 있다.
- 만약 사용자 A가 도중에 오류로 인해 ROLLBACK을 수행하더라도, 사용자 B는 이미 ‘Lara’를 정상적인 사원으로 인식하고 후속 작업을 계속 진행할 수 있다.
▶ READ COMMITTED
이 격리 수준에서는 COMMIT이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있다.
- 따라서, DIRTY READ는 발생하지 않는다.
- 하지만, NON-REPEATABLE READ가 발생한다.
🔽 DIRTY READ가 발생하지 않음

- 사용자 A는 emp_no=500000인 사원의 first_name을 ‘Lara’에서 ‘Toto’로 UPDATE한다.
- 새로운 값 ‘Toto’는 employees 테이블에 즉시 반영되고, 이전 값 ‘Lara’는 Undo Log에 백업된다.
- 사용자 B가 A의 COMMIT 전에 해당 사원을 조회하면, Undo Log에 백업된 레코드가 반환되므로 여전히 ‘Lara’로 조회된다.
🔽 NON-REPEATABLE READ가 발생함

- 사용자 B가 트랜잭션을 시작하고 first_name = 'Toto' 조건으로 조회했지만, 결과가 없었다.
- 이후 사용자 A가 emp_no=500000인 사원의 이름을 ‘Toto’로 변경하고 COMMIT한다.
- 사용자 B가 같은 조건으로 다시 조회하면 이번에는 1건이 조회된다.
- 즉, 같은 트랜잭션 내의 같은 SELECT 쿼리가 다른 결과를 반환하게 되는데, 이는 REPEATABLE READ 정합성에 어긋난다.
🔽 참고
Undo Log: 데이터베이스 트랜잭션에서 변경 이전 상태를 기록하는 로그
▶ REPEATABLE READ
InnoDB의 기본 격리 수준으로, 트랜잭션이 ROLLBACK될 가능성에 대비해 InnoDB 스토리지 엔진은 변경 전 레코드를 Undo Log에 백업한 뒤 실제 레코드를 변경한다. 이러한 방식이 바로 MVCC(Multi-Version Concurrency Control)이다.
- 따라서, NON-REPEATABLE READ가 발생하지 않는다.
- 하지만, PHANTOM READ가 발생한다.
🔽 REPEATABLE READ도 Undo Log를 사용하는 점은 READ COMMITTED와 동일한데, 그렇다면 왜 READ COMMITTED에서는 NON-REPEATABLE READ가 발생할까?
InnoDB의 모든 트랜잭션에는 고유한 트랜잭션 번호(순차적으로 증가하는 값)가 부여되며, Undo Log에 백업된 레코드에는 이를 변경한 트랜잭션 번호가 함께 기록된다.
Undo Log를 활용하는 건 동일하지만 어느 시점의 버전을 참조하느냐에서 차이가 발생한다.
- READ COMMITTED: 매 쿼리 실행 시점마다 Undo Log에서 최신 버전을 가져온다.
- REPEATABLE READ: 트랜잭션 시작 시점에 Undo Log 버전을 한 번만 결정하고, 이후 해당 버전을 계속 사용한다.
🔽 NON-REPEATABLE READ가 발생하지 않음

- 사용자 A의 트랜잭션 번호는 12, 사용자 B는 10이다.
- 사용자 A가 특정 사원의 이름을 ‘Toto’로 변경하고 COMMIT한다.
- 사용자 B는 트랜잭션 번호 10으로 시작했으므로, 해당 트랜잭션 안의 모든 SELECT는 10보다 작은 트랜잭션 번호에서 커밋된 데이터만 조회한다.
- 따라서 같은 데이터를 여러 번 조회해도 항상 동일한 결과가 나온다.
🔽 PHANTOM READ가 발생함

- 사용자 B가 트랜잭션을 시작한 후 emp_no > 500000 조건으로 범위 조회를 한다.
- 그 사이 사용자 A가 새로운 행을 INSERT하고 COMMIT한다.
- 사용자 B가 같은 범위를 다시 조회하면 이전에 없던 새로운 행이 결과에 포함된다.
- 즉, 같은 트랜잭션 내에서 조회 결과의 행 개수가 달라지는 현상이 발생한다.
▶ SERIALIZABLE
가장 단순하면서도 가장 엄격한 격리 수준이다. 그만큼 동시 처리 성능은 다른 격리 수준보다 떨어진다.
InnoDB에서 기본적으로 순수한 SELECT 작업은 레코드 락을 설정하지 않고 실행되지만, SERIALIZABLE 격리 수준에서는 읽기 작업조차 공유 락을 획득해야 한다. 즉, 한 트랜잭션에서 읽거나 쓰는 레코드에는 다른 트랜잭션이 절대 접근할 수 없다.
- 따라서, PHANTOM READ가 발생하지 않는다.
🔽 참고
다만, InnoDB 스토리지 엔진은 갭 락(Gap Lock)과 넥스트 키 락(Next-Key Lock)을 통해 REPEATABLE READ에서도 이미 PHANTOM READ를 방지한다. 따라서 실제 운영 환경에서 SERIALIZABLE을 사용할 필요성은 거의 없다.
✅ 정리
| DIRTY READ | NON-REPEATABLE READ | PHANTOM READ | |
| READ UNCOMMITTED | 발생 | 발생 | 발생 |
| READ COMMITTED | 없음 | 발생 | 발생 |
| REPEATABLE READ | 없음 | 없음 | 발생 (InnoDB는 없음) |
| SERIALIZABLE | 없음 | 없음 | 없음 |
📍 참고 자료
- 우아한테크코스 레벨 4 자료
- Real MySQL 8.0 - 5.4 MySQL의 격리 수준
'Backend > Database' 카테고리의 다른 글
| [Database] MySQL(InnoDB) 락(Lock) (2) (2) | 2025.10.06 |
|---|---|
| [Database] 락(Lock) (1) (0) | 2025.10.04 |
| [Database] MySQL 100만 건 데이터 삽입하기 (0) | 2025.09.28 |
| [Database] 데이터베이스 무중단 마이그레이션 (2) | 2025.08.27 |
| [Database] SQL 기본 문법 정리 (0) | 2024.07.16 |