1. 트랜잭션이란?
- 데이터베이스에서 하나 이상의 SQL 작업을 묶어서 하나의 단위로 처리하는 것
- 모든 작업이 성공하면 COMMIT, 하나라도 실패하면 ROLLBACK을 수행하여 변경 사항 취소
- 데이터의 정합성을 보장하는 것이 목적
✔️ 트랜잭션이 필요한 이유 ✔️
- 데이터 정합성 보장
- 트랜잭션이 없으면 일부 쿼리만 성공했을 때 데이터 불일치 발생
- 장애 발생 시 안전한 복구
- ROLLBACK을 통해 안전하게 복구 가능
- 동시성 제어
- 여러 사용자가 동시에 같은 데이터를 수정할 때 충돌 방지
- 원자적 실행 보장
- 트랜잭션이 적용된 연산은 모두 성공하거나, 모두 실패
2. 트랜잭션 4대 특성 ACID란?
- Atomicity (원자성)
- 모든 연산이 성공하거나, 하나라도 실패하면 전체 취소
- Consistency (일관성)
- 트랜잭션 실행 후에도 데이터 무결성 유지
- Isolation (고립성)
- 동시에 실행되는 트랜잭션이 서로 영향을 주지 않음
- Durability (지속성)
- 트랜잭션이 COMMIT 되면 데이터가 영구적으로 저장
3. 격리 수준이 필요한 이유는?
적절한 격리 수준을 조절하지 않고 서비스 운영 중 동시에 여러 사용자가 같은 데이터를 조회하거나 수정하는 상황이 온다면 아래와 같은 문제들이 발생할 수 있다.
- Dirty Read
- Non-Repeatable Read
- Phantom Read
각 문제들에 대해서 간단하게 설명해보겠다.
✅ Dirty Read란?
다른 트랜잭션이 아직 커밋하지 않은 변경 데이터를 읽는 것을 말한다.
더러운 값을 읽었다고 해서 Dirty Read라고 부른다고 한다.
계좌 이체 중인 상황을 예시로 들어보자.
- 트랜잭션 A (보내는 쪽)
START TRANSACTION;
-- 100원 출금
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 아직 COMMIT 안함
- 트랜잭션 B (읽는 쪽)
SELECT balance FROM accounts WHERE id = 1;
-- Dirty Read 발생 : 이미 출금된 걸로 보임
위 상황에서 만일 트랜잭션 A가 ROLLBACK을 한다면 트랜잭션 B는 존재하지 않는 값을 읽은게 된다.
✅ Non-Repeatable Read란?
같은 트랜잭션 안에서 같은 데이터를 두 번 읽었을 때 값이 다르게 나오는 현상을 말한다.
계좌 잔액을 조회해서 비교하는 로직을 예시로 들어보자.
- 트랜잭션 A (잔액 비교 로직)
START TRANSACTION;
-- 첫 번째 읽기
SELECT balance FROM accounts WHERE id = 1; -- 결과: 1000
- 트랜잭션 B (다른 곳에서 수정)
START TRANSACTION;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
- 트랜잭션 A (두 번째 읽기)
-- 두 번째 읽기
SELECT balance FROM accounts WHERE id = 1; -- 결과: 500
트랜잭션 A가 첫 번째 읽기 후 두 번째 읽기로 가는 과정에서 트랜잭션 B가 업데이트 후 COMMIT을 하면서 두 번째 읽기의 결과값이 달라지게 되었다.
✅ Phantom Read란?
같은 조건으로 데이터를 두 번 조회했는데, 첫 번째에는 없던 새로운 행이 두 번째에서 나타나는 현상을 말한다.
나이가 30살 이상인 고객 수를 조회하는 상황을 예시로 들어보자.
- 트랜잭션 A (데이터 조회 중)
START TRANSACTION;
-- 첫 번째 조회
SELECT COUNT(*) FROM users WHERE age >= 30; -- 결과: 5
- 트랜잭션 B (새로운 유저 추가)
START TRANSACTION;
INSERT INTO users (name, age) VALUES ('Alice', 35);
COMMIT;
- 트랜잭션 A (재조회)
-- 두 번째 조회 (같은 조건)
SELECT COUNT(*) FROM users WHERE age >= 30; -- 결과: 6
트랜잭션 A가 처음 조회하고 재조회를 하는 사이에 트랜잭션 B가 새로운 유저를 INSERT 하고 COMMIT 하면서 A가 재조회 했을 때 결과값에 차이가 생기게 되었다.
위와 같은 문제들은 결과적으로 데이터의 신뢰성과 정합성을 깨뜨리게 된다.
이 문제들을 해결하기 위해 트랜잭션의 격리 수준 (Isolation Level) 개념을 적용한다.
4. MySQL에서 지원하는 격리 수준 4가지
✅ READ UNCOMMITTED (읽기 미확정)
- 가장 낮은 격리 수준 (성능 최적화, 데이터 정합성 낮음)
- Dirty Read 발생 가능
- Non-Repeatable Read 발생 가능
- Phantom Read 발생 가능
-- 트랜잭션 1 (수정, 아직 COMMIT 안함)
START TRANSACTION;
UPDATE accounts SET balance = 500 WHERE id = 1;
-- 트랜잭션 2 (수정된 값 조회 가능)
SELECT balance FROM accounts WHERE id = 1; -- 결과: 500, 하지만 아직 COMMIT 되지 않음
✅ READ COMMITTED (읽기 확정)
- 트랜잭션이 COMMIT 한 데이터만 읽을 수 있음
- Dirty Read 방지
- Non-Repeatable Read 발생 가능
- Phantom Read 발생 가능
- 일반적인 웹 애플리케이션에 추천
-- 트랜잭션 1 (첫 번재 조회)
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 결과: 1000
-- 트랜잭션 2 (값 수정 후 COMMIT)
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- 트랜잭션 1 (두 번째 조회)
SELECT balance FROM accounts WHERE id = 1; -- 결과: 500 (첫 번째 조회 결과와 다름)
✅ REPEATABLE READ (반복 가능한 읽기)
- 트랜잭션이 시작된 시점의 데이터를 계속 읽을 수 있음
- Dirty Read 방지
- Non-Repeatable Read 방지
- Phantom Read 발생 가능
💡 MySQL의 기본 값이며 InnoDB에서는 MVCC (다중 버전 동시성 제어)와 Next-Key Locking 덕분에 해당 격리 수준에서도 Phantom Read도 사실상 방지 가능하다.
💡 즉, MySQL의 InnoDB 기준 Dirty Read, Non-Repeatable Read, Phantom Read 모두 방지 가능하다.
-- 트랜잭션 1 (고객 수 조회)
START TRANSACTION;
SELECT COUNT(*) FROM users WHERE age > 30; -- 결과: 5
-- 트랜잭션 2 (새로운 고객 추가)
INSERT INTO users (name, age) VALUES ('Alice', 35);
COMMIT;
-- 트랜잭션 1 (같은 조건으로 다시 조회)
SELECT COUNT(*) FROM users WHERE age > 30; -- 결과: 6 (이전과 다른 결과)
✅ SERIALIZABLE (직렬화)
- 모든 트랜잭션이 순차적으로 실행되는 것처럼 동작
- 읽기조차도 잠금을 걸어 다른 트랜잭션이 해당 범위에 INSERT / UPDATE를 못하게 함
- Dirty Read 방지
- Non-Repeatable Read 방지
- Phantom Read 방지
- 모든 SELECT 문에도 공유 잠금이 걸리므로 성능 저하가 매우 클 수 있음
-- 트랜잭션 A
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM users WHERE age > 30; -- 이 시점에 Shared Lock이 걸림
-- 트랜잭션 B
INSERT INTO users (name, age) VALUES ('Tom', 35); -- 대기하거나 롤백됨
5. 예제 코드 (🔨 Kotlin + Spring Boot + Spring Data JPA 🔨)
✅ READ UNCOMMITTED (읽기 미확정)
- Dirty Read 발생 가능하지만, 큰 문제가 되지 않는 경우
- ex) 로그 데이터 저장 시 (정확한 데이터보다는 빠른 성능이 중요할 때 사용)
@Service
class LogService(
private val logRepository: LongRepository
) {
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
fun saveLog(
message: String
) {
val log = LogEntity(
message = message
)
logRepository.save(log)
}
}
✅ READ COMMITTED (읽기 확정)
- Dirty Read를 방지하고, Non-Repeatable Read는 허용 가능
- ex) 주문 조회
@Service
class OrderService(
private val orderRepository: OrderRepository
) {
@Transactional(isolation = Isolation.READ_COMMITTED)
fun getOrder(
orderId: Long
): Order {
return orderRepository.findById(orderId)
.orElseThrow { NoSuchElementException("Order not found") }
}
}
✅ REPEATABLE READ (반복 가능한 읽기)
- 금융 서비스, 결제 시스템 등 한 번 조회한 값이 동일해야 할 때
- MySQL의 기본 수준이며 InnoDB는 Phantom Read 또한 방지 가능
- ex) 계좌 잔액 조회 후 업데이트
@Service
class BankService(
private val accountRepository: AccountRepository
) {
@Transactional(isolation = Isolation.REPEATABLE_READ)
fun transfer(
senderId: Long,
receiverId: Long,
amount: Int
) {
val sender = accountRepository.findById(senderId)
.orElseThrow { NoSuchElementException("Sender not found") }
if (sender.balance < amount) {
throw IllegalStateException("Insufficient balance")
}
sender.balance -= amount
val receiver = accountRepository.findById(receiverId)
.orElseThrow { NoSuchElementException("Receiver not found") }
receiver.balance += amount
accountRepository.save(sender)
accountRepository.save(receiver)
}
}
✅ SERIALIZABLE (직렬화)
- 은행 시스템, 항공권 예약, 재고 관리 등 가장 높은 정합성이 필요할 때 사용
- ex) 항공권 예약
@Service
class BookingService(
private val seatRepository: SeatRepository
) {
@Transactional(isolation = Isolation.SERIALIZABLE)
fun bookSeat(
seatId: Long,
userId: Long
) {
val seat = seatRepository.findById(seatId)
.orElseThrow { NoSuchElementException("Seat not found") }
if (seat.isBooked) {
throw IllegalStateException("Seat already booked")
}
seat.isBooked = true
seat.userId = userId
seatRepository.save(seat)
}
}
✔️ MySQL는 REPEATABLE READ가 기본이며 InnoDB의 경우 Phantom Read 까지 방지 가능
✔️ 대부분 REPEATABLE READ 격리 수준이 적절