| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- Sort
- Kotlin
- 스프링부트
- 이펙티브자바
- 스프링
- java
- 카카오
- 김영한
- kubernetes
- 알고리즘정렬
- Effective Java 3
- 예제로 배우는 스프링 입문
- 이펙티브 자바
- JavaScript
- effectivejava
- 클린아키텍처
- Effective Java
- 스프링 핵심원리
- ElasticSearch
- 자바스크립트
- springboot
- Spring
- 알고리즘
- 자바
- 엘라스틱서치
- 데이터베이스
- 스프링핵심원리
- 오블완
- k8s
- 티스토리챌린지
- Today
- Total
Kim-Baek 개발자 이야기
DB 트랜잭션 격리 수준 - 동시성과 일관성의 트레이드오프 본문
같은 쿼리가 다른 결과를 보여줬다
예약 통계를 보여주는 대시보드를 만들고 있었다.
@Transactional
fun getStatistics(userId: Long): Statistics {
// 1. 총 예약 수
val totalCount = reservationRepository
.countByUserId(userId)
// 2. 예약 목록
val reservations = reservationRepository
.findByUserId(userId)
return Statistics(
totalCount = totalCount,
reservations = reservations
)
}
이상한 현상:
totalCount: 10
reservations.size: 11 ← 개수가 안 맞음!
같은 트랜잭션 안인데 결과가 달랐다.
알고 보니 다른 트랜잭션이 중간에 데이터를 추가한 것이었다. MySQL의 기본 격리 수준인 REPEATABLE READ는 Phantom Read를 완전히 막지 못했다.
-- Transaction A
SELECT COUNT(*) FROM reservations WHERE user_id = 1001; -- 10
-- Transaction B (동시 실행)
INSERT INTO reservations (user_id, ...) VALUES (1001, ...);
COMMIT;
-- Transaction A (계속)
SELECT * FROM reservations WHERE user_id = 1001; -- 11개!
이 경험으로 격리 수준이 무엇인지, 각각 어떤 문제를 해결하는지 깊이 이해하게 됐다.
동시성 문제의 종류
1. Dirty Read (더티 리드)
커밋 안 된 데이터 읽기:
-- Transaction A
START TRANSACTION;
UPDATE accounts SET balance = 200000 WHERE id = 1;
-- 아직 COMMIT 안 함
-- Transaction B
SELECT balance FROM accounts WHERE id = 1;
-- 200000 읽음 (커밋 안 된 데이터!)
-- Transaction A
ROLLBACK; -- 취소!
-- Transaction B는 존재하지 않는 200000을 봤음
문제: 롤백될 수 있는 데이터를 읽음
2. Non-Repeatable Read (반복 불가능 읽기)
같은 쿼리가 다른 결과:
-- Transaction A
SELECT balance FROM accounts WHERE id = 1; -- 100000
-- Transaction B
UPDATE accounts SET balance = 200000 WHERE id = 1;
COMMIT;
-- Transaction A
SELECT balance FROM accounts WHERE id = 1; -- 200000 (변경됨!)
문제: 트랜잭션 도중 데이터 변경됨
3. Phantom Read (팬텀 리드)
없던 행이 생김:
-- Transaction A
SELECT COUNT(*) FROM reservations WHERE status = 'confirmed'; -- 100
-- Transaction B
INSERT INTO reservations (status, ...) VALUES ('confirmed', ...);
COMMIT;
-- Transaction A
SELECT COUNT(*) FROM reservations WHERE status = 'confirmed'; -- 101
문제: 새 행이 나타남
4가지 격리 수준
READ UNCOMMITTED
커밋 안 된 데이터도 읽기:
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
허용되는 문제:
- ✗ Dirty Read
- ✗ Non-Repeatable Read
- ✗ Phantom Read
사용: 거의 안 씀 (너무 위험)
READ COMMITTED
커밋된 데이터만 읽기:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
해결/허용:
- ✓ Dirty Read 방지
- ✗ Non-Repeatable Read
- ✗ Phantom Read
특징:
- Oracle, PostgreSQL 기본값
- 가장 많이 사용
예시:
-- Transaction A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 100000
-- Transaction B
UPDATE accounts SET balance = 200000 WHERE id = 1;
COMMIT;
-- Transaction A
SELECT balance FROM accounts WHERE id = 1; -- 200000 (변경 보임)
REPEATABLE READ
같은 쿼리는 같은 결과:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
해결/허용:
- ✓ Dirty Read 방지
- ✓ Non-Repeatable Read 방지
- ⚠ Phantom Read (InnoDB는 대부분 방지)
특징:
- MySQL InnoDB 기본값
- MVCC로 구현
MVCC 동작:
실제 테이블:
┌────┬─────────┬────────┐
│ ID │ Balance │ TX_ID │
├────┼─────────┼────────┤
│ 1 │ 100,000 │ 100 │ ← Old Version
│ 1 │ 200,000 │ 105 │ ← New Version
└────┴─────────┴────────┘
Transaction A (TX_ID: 103):
- 시작 시점: TX_ID 103 이전 데이터만 읽음
- balance = 100,000 계속 보임 (스냅샷)
Transaction B (TX_ID: 105):
- 새 버전 생성 (200,000)
- COMMIT 후에도 A는 100,000 보임
Court Alarm 예시:
@Transactional(isolation = Isolation.REPEATABLE_READ)
fun processPayment(reservationId: Long) {
// 1. 예약 확인
val reservation = reservationRepository.findById(reservationId)
val price = reservation.price // 10,000원
// 다른 트랜잭션이 price를 20,000원으로 변경해도
// 2. 결제 처리
paymentService.charge(reservation.userId, price) // 여전히 10,000원
// 일관성 보장!
}
SERIALIZABLE
완전 격리:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
해결:
- ✓ Dirty Read 방지
- ✓ Non-Repeatable Read 방지
- ✓ Phantom Read 방지
구현: 범위 락 (Range Lock)
-- Transaction A
SELECT * FROM reservations WHERE status = 'confirmed';
-- status = 'confirmed' 범위에 락
-- Transaction B
INSERT INTO reservations (status, ...) VALUES ('confirmed', ...);
-- 대기... (범위 락 때문에)
성능:
- 가장 느림
- 동시성 최악
사용: 금융, 재고 관리 등 일관성이 매우 중요한 경우
격리 수준 비교
격리 수준 Dirty Read Non-Repeatable Phantom 성능
| READ UNCOMMITTED | 발생 | 발생 | 발생 | 최고 |
| READ COMMITTED | 방지 | 발생 | 발생 | 높음 |
| REPEATABLE READ | 방지 | 방지 | 발생* | 중간 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 최저 |
*InnoDB는 대부분 방지
실전 사례
사례 1: 예약 중복 방지
문제:
@Transactional(isolation = Isolation.READ_COMMITTED)
fun createReservation(request: Request): Reservation {
// 1. 중복 체크
val existing = reservationRepository
.findByCourtAndTime(request.courtId, request.time)
if (existing != null) {
throw DuplicateException()
}
// 2. 예약 생성
return reservationRepository.save(
Reservation(request.courtId, request.time)
)
}
동시 요청 시:
Time User A User B
──── ───────────────────────────── ─────────────────────────────
t1 중복 체크 → 없음
t2 중복 체크 → 없음
t3 예약 생성 ✓
t4 예약 생성 ✓ (중복!)
해결: SERIALIZABLE
@Transactional(isolation = Isolation.SERIALIZABLE)
fun createReservation(request: Request): Reservation {
// 범위 락으로 다른 트랜잭션 차단
val existing = reservationRepository
.findByCourtAndTime(request.courtId, request.time)
if (existing != null) {
throw DuplicateException()
}
return reservationRepository.save(
Reservation(request.courtId, request.time)
)
}
또는: Unique Index
-- DB 레벨에서 중복 방지 (더 좋음)
CREATE UNIQUE INDEX idx_court_time
ON reservations(court_id, reservation_date, reservation_time);
사례 2: 통계 일관성
문제:
@Transactional(isolation = Isolation.READ_COMMITTED)
fun getDailyStatistics(date: LocalDate): Statistics {
val confirmed = reservationRepository
.countByDateAndStatus(date, "confirmed") // 100
// 이 사이에 새 예약 추가됨
val cancelled = reservationRepository
.countByDateAndStatus(date, "cancelled") // 20
val total = reservationRepository
.countByDate(date) // 121 (100 + 20 + 1)
// total != confirmed + cancelled
}
해결: REPEATABLE READ
@Transactional(isolation = Isolation.REPEATABLE_READ)
fun getDailyStatistics(date: LocalDate): Statistics {
// 모든 조회가 같은 스냅샷
val confirmed = reservationRepository
.countByDateAndStatus(date, "confirmed")
val cancelled = reservationRepository
.countByDateAndStatus(date, "cancelled")
val total = reservationRepository
.countByDate(date)
// 일관성 보장: total == confirmed + cancelled
}
사례 3: 좌석 차감
문제:
@Transactional(isolation = Isolation.READ_COMMITTED)
fun bookSeat(courtId: Long): Reservation {
val court = courtRepository.findById(courtId)
if (court.availableSeats <= 0) {
throw NoSeatsException()
}
court.availableSeats--
courtRepository.save(court)
// 동시 요청 시 초과 예약 가능
}
해결: 비관적 락 (Pessimistic Lock)
@Transactional
fun bookSeat(courtId: Long): Reservation {
// SELECT ... FOR UPDATE
val court = courtRepository.findByIdForUpdate(courtId)
if (court.availableSeats <= 0) {
throw NoSeatsException()
}
court.availableSeats--
courtRepository.save(court)
// 다른 트랜잭션은 대기
}
interface CourtRepository : JpaRepository<Court, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Court c WHERE c.id = :id")
fun findByIdForUpdate(@Param("id") id: Long): Court?
}
격리 수준 선택 가이드
읽기 전용 쿼리
// READ COMMITTED (빠름)
@Transactional(
isolation = Isolation.READ_COMMITTED,
readOnly = true
)
fun getReservations(): List<Reservation> {
return reservationRepository.findAll()
}
통계 및 리포트
// REPEATABLE READ (일관성)
@Transactional(isolation = Isolation.REPEATABLE_READ)
fun generateReport(date: LocalDate): Report {
val data1 = getData1(date)
val data2 = getData2(date)
val data3 = getData3(date)
// 모든 데이터가 같은 시점
}
중요한 트랜잭션
// SERIALIZABLE (안전)
@Transactional(isolation = Isolation.SERIALIZABLE)
fun transferMoney(from: Long, to: Long, amount: BigDecimal) {
val fromAccount = accountRepository.findById(from)
val toAccount = accountRepository.findById(to)
fromAccount.balance -= amount
toAccount.balance += amount
accountRepository.saveAll(listOf(fromAccount, toAccount))
}
Court Alarm 전략
// 1. 예약 생성 (REPEATABLE READ + Unique Index)
@Transactional
fun createReservation(...) { ... }
// 2. 결제 처리 (REPEATABLE READ)
@Transactional(isolation = Isolation.REPEATABLE_READ)
fun processPayment(...) { ... }
// 3. 목록 조회 (READ COMMITTED)
@Transactional(
isolation = Isolation.READ_COMMITTED,
readOnly = true
)
fun listReservations(...) { ... }
// 4. 통계 (REPEATABLE READ)
@Transactional(isolation = Isolation.REPEATABLE_READ)
fun getStatistics(...) { ... }
성능 영향
벤치마크
동시 요청 1000개, 예약 생성:
READ COMMITTED:
- TPS: 800
- 평균 응답: 50ms
- 데이터 일관성: ⚠️ (중복 가능)
REPEATABLE READ:
- TPS: 600
- 평균 응답: 80ms
- 데이터 일관성: ✓ (MVCC)
SERIALIZABLE:
- TPS: 200
- 평균 응답: 300ms
- 데이터 일관성: ✓✓ (완벽)
권장사항
일반 조회: READ COMMITTED
→ 빠름, 충분히 안전
복잡한 트랜잭션: REPEATABLE READ (기본값)
→ 균형, MVCC로 성능 괜찮음
금융 거래: SERIALIZABLE 또는 락
→ 느려도 안전 우선
면접에서 이렇게 답하자
1단계: 격리 수준 설명 (20초)
"트랜잭션 격리 수준은 동시 실행되는 트랜잭션들이 서로에게 미치는 영향을 제어합니다. READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE 4단계가 있으며, 높을수록 일관성이 강하지만 성능이 떨어집니다."
2단계: 문제별 설명 (40초)
"Dirty Read는 커밋 안 된 데이터를 읽는 것이고, Non-Repeatable Read는 같은 쿼리가 다른 결과를 반환하는 것이며, Phantom Read는 없던 행이 나타나는 것입니다. MySQL InnoDB는 REPEATABLE READ가 기본이며 MVCC로 스냅샷 읽기를 구현해서 Non-Repeatable Read를 방지합니다."
3단계: 실무 경험 (1분 30초)
"Court Alarm에서 통계 조회 시 totalCount와 실제 목록 개수가 안 맞는 문제가 있었습니다. READ COMMITTED였는데, 중간에 다른 트랜잭션이 데이터를 추가해서 Phantom Read가 발생한 것입니다. REPEATABLE READ로 변경하니 트랜잭션 시작 시점의 스냅샷을 읽어서 일관성이 보장됐습니다."
"예약 중복 방지는 SERIALIZABLE보다 Unique Index를 사용했습니다. DB 레벨에서 중복을 막는 게 더 효율적이고, SERIALIZABLE의 성능 저하를 피할 수 있었습니다. 좌석 차감은 SELECT FOR UPDATE로 비관적 락을 걸어서 동시 요청을 직렬화했습니다."
핵심 팁
문제와 해결:
- ❌ "격리 수준을 높였어요"
- ✅ "Phantom Read 발생 → REPEATABLE READ로 변경 → MVCC로 스냅샷 읽기"
성능 트레이드오프:
- READ COMMITTED: 800 TPS
- REPEATABLE READ: 600 TPS (기본)
- SERIALIZABLE: 200 TPS
정리
격리 수준은 동시성과 일관성의 균형이다.
4가지 격리 수준:
수준 특징 사용
| READ UNCOMMITTED | 가장 빠름, 위험 | 거의 안 씀 |
| READ COMMITTED | 일반적, PostgreSQL 기본 | 조회 |
| REPEATABLE READ | 균형, MySQL 기본 | 대부분 |
| SERIALIZABLE | 가장 안전, 느림 | 금융 |
핵심 개념:
- Dirty Read: 커밋 안 된 데이터
- Non-Repeatable Read: 결과 변경
- Phantom Read: 새 행 출현
- MVCC: 스냅샷으로 일관성
실무 전략:
- 일반 조회: READ COMMITTED
- 트랜잭션: REPEATABLE READ
- 중요 거래: SERIALIZABLE + 락
- 중복 방지: Unique Index
'컴퓨터 공학 > DB' 카테고리의 다른 글
| DB 복제(Replication)와 샤딩(Sharding) - 확장의 두 가지 길 (0) | 2026.01.10 |
|---|---|
| DB 백업 및 복구 전략 - 데이터 손실 제로를 향한 여정 (1) | 2026.01.09 |
| NoSQL 특징 - 언제, 왜 사용하는가 (0) | 2026.01.09 |
| 데이터베이스 - RDBMS 특징 (1) | 2026.01.09 |
| MongoDB에서 Time Series 기능을 사용하지 않고 일반 컬렉션을 활용하는 것과의 차이점 (0) | 2025.01.21 |
