Kim-Baek 개발자 이야기

DB 트랜잭션 격리 수준 - 동시성과 일관성의 트레이드오프 본문

컴퓨터 공학/DB

DB 트랜잭션 격리 수준 - 동시성과 일관성의 트레이드오프

김백개발자 2026. 1. 10. 11:43
반응형

같은 쿼리가 다른 결과를 보여줬다

예약 통계를 보여주는 대시보드를 만들고 있었다.

@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

 

반응형
Comments