티스토리 뷰
이번주차는 7장이다.
트랜잭션 부분.. 정합성 측면에서 정말 중요하다고 느낀다.
최근 회사에서도 트랜잭션 관련하여 오류 하나가 있었는데 더욱 중요하다고 느껴졌다.
이 장을 통해 다양한 트랜잭션이 막을 수 있는 문제들이 무엇이 있고 어떻게 해결하는지 알게되었다.
완화된 격리 수준
1. Read Committed
가장 기본적인 트랜잭션 격리 수준. 두 가지를 보장한다.
- 커밋된 데이터만 읽을 수 있다 (더티 읽기 방지)
- 커밋된 데이터만 덮어쓸 수 있다 (더티 쓰기 방지)
2. Snapshot Isolation(MVCC)
MySQL 은 MVCC를 격리 수준마다 어떻게 사용하는지
- 각 행에 숨은 컬럼: DB_TRX_ID(이 행 마지막 수정한 txid), DB_ROLL_PTR(undo log 포인터 = 옛 버전 체인).
- 수정 시 옛 버전을 undo log에 쌓음 → 버전 체인.
- Read View(스냅샷): "지금 어떤 txid까지 커밋된 걸로 볼지" 스냅샷. 이게 가시성 판단 기준.
- 규칙: Read View보다 나중 txid가 만든 버전은 안 보이고, undo 체인 따라 보이는 옛 버전으로 내려감.

- RC: 문장 하나 끝나고 다른 트랜잭션이 커밋하면, 다음 SELECT는 그 값 봄 → 같은 걸 두 번 읽어도 값 바뀔 수 있음.
- RR: 첫 SELECT 순간 스냅샷 박제 → 트랜잭션 동안 남이 뭘 커밋하든 안 흔들림.

SI로도 못막는 것
1. Lost Update
Lost Update는 무엇인가?
- 두 트랜잭션이 같은 행을 읽고 -> 계산 -> 쓰기 하는데, 한 쪽 수정이 덮여 사라지는 것. 스냅샷 격리로도 안 먹힌다.

Lost Update는 어떻게 막을까?

2. 쓰기 스큐 & 팬텀
쓰기 스큐란
- 읽어서 조건 검사 -> 그 결과 믿고 씀. 근데 검사~쓰기 사이 다른 트랜잭션이 전제를 깸.
- 스냅샷 격리로도 못 막음.
스냅샷 격리로도 막지 못하는 이유
스냅샷 격리로는 서로 다른 행에 대한 동시 쓰기는 막지 못함.
- 쓰기 스큐는 Alice→Alice행, Bob→Bob행. 쓰는 행이 겹치지 않음 → SI 입장에선 "충돌 없음" → 둘 다 정상 커밋.

갱신 손실 vs 쓰기 스큐
- 갱신 손실: 두 트랜잭션이 같은 행 수정 -> DB가 충돌 감지 기능.
- 쓰기 스큐: 같은 조건 읽고 다른 행에 씀 -> 충돌 없음.
의사 당직 vs 회의실 예약 - 같은 쓰기 스큐지만 차이가 있음
둘 다 쓰기 스큐지만 막는 방법이 다르다.
팬텀개념이 들어감.
- 팬텀: 한 트랜잭션 쓰기가 다른 트랜잭션 WHERE 결과를 바꿈
1. 의사 당직 — (잠글 행이 있다)
- 검사하는 대상: on_call = true인 행들 = Alice, Bob. 이미 테이블에 존재함.
- 그래서 잠글 게 있다.
SELECT * FROM doctors WHERE on_call = true FOR UPDATE;
- Alice가 먼저 이 락을 잡으면, Bob은 같은 행을 잠그려다 대기한다.
- Alice 커밋 후 Bob이 다시 조회 → 이제 당직 1명 → "빠지면 안 되겠다" 판단.
- 즉, 읽는 순간 존재하는 행을 잠가버리니 다른 트랜잭션이 끼어들 틈이 없다. FOR UPDATE 한 줄로 해결.
2. 회의실 예약 — (잠글 행이 없다)
- 검사하는 대상: "그 시간대에 예약이 없는지". 즉 행의 부재를 확인한다.
SELECT count(*) FROM bookings
WHERE room_id = 5 AND time_range OVERLAPS ('12:00','13:00'); -- 0
-- "비었네" → INSERT
- 검사 결과가 0개. 없는 행은 잠글 수 없다. FOR UPDATE를 걸어도 잠글 대상 자체가 없음.
- 두 트랜잭션이 동시에 "비었네"를 확인 → 각자 INSERT → 이중 예약.
- 여기가 락으로 못 막는 순수 팬텀 지점이다.
해결책
- 충돌 구체화
- 잠글 행이 없기에 미리 만들어둔다.
- 방x시간 슬롯 조합을 time_slots 테이블에 전부 insert 해놓고, 예약 시 그 슬롯 행을 FOR UPDATRE로 잠금.
SELECT * FROM time_slots
WHERE room_id=5 AND slot='12:00' FOR UPDATE; -- 이제 잠글 실재 행 있음
- 단점 : 슬롯 조합 미리 다 생성해야 됨. 동시성 제어가 데이터 모델로 새어나옴.
직렬성 - SSI
SSI(Serializable Snapshot Isolation) - 직렬성 스냅샷 격리
- 스냅샷 격리 성능은 그대로 두고, 직렬성까지 보장하는 방법.
- 낙관적 동시성 제어. Postgres SERIALIZABLE이 이걸 씀.
배경 — 기존 직렬성 구현의 문제
직렬성 보장 방법은 원래 셋:
- 실제 직렬 실행 — 싱글 스레드로 하나씩. 단순하지만 처리량 한계.
- 2단계 잠금(2PL) — 읽고 쓰는 것마다 락. 확실하지만 느림. 락 대기·데드락 많음.
- SSI — 위 둘의 단점 극복하려고 나옴.
비관 vs 낙관 (2PL vs SSI)
- 2PL(비관적): "충돌 날지 모르니 미리 잠그고 기다린다." 충돌 안 나도 락 비용 냄. 느림.
- SSI(낙관적): "일단 다 진행시킨다. 커밋할 때 진짜 충돌났는지 검사해서, 났으면 그 트랜잭션 취소하고 재시도."
- 충돌이 드문 워크로드면 SSI가 훨씬 빠름. 충돌 잦으면 재시도 폭증해서 손해.
어떻게 동작하나 (핵심 아이디어)
- 스냅샷 격리 위에서 돎 → 각 트랜잭션은 자기 시점 스냅샷을 봄 (읽기 락 없음, 빠름).
- 문제는 쓰기 스큐: "내가 읽은 전제가 커밋 시점엔 이미 낡았을 수 있다."
- SSI는 이걸 감지함:
- 트랜잭션이 읽은 데이터를 다른 트랜잭션이 나중에 바꿨는지 추적.
- 커밋 시점에 "내 판단 근거가 된 읽기가 무효화됐나?" 검사.
- 무효화됐으면 = 직렬 실행이었다면 불가능한 결과 → abort.
- 즉 "낡은 전제 위에서 커밋하려는 트랜잭션"을 잡아 죽인다.
장단점
- 장점: 읽기에 락 안 검(스냅샷 격리 속도) + 직렬성 보장. 한 트랜잭션이 다른 걸 블록 안 함.
- 단점: 커밋 실패(abort) → 재시도 로직 필요. 충돌 잦으면 abort 비율↑ 성능↓.
회사에서 생긴 일
이슈 - @Modifying(clearAutomatically=true) 영속성 컨텍스트 유실
문제 상황
// 1. 엔티티 저장 (영속성 컨텍스트에 올라감)
val classAttend = buildAttend(command, course, nowDate, isWaiting, 0)
classAttendProvider.save(classAttend)
// 2. 쿠폰 컬럼 set 후 2차 save (아직 DB 반영 전, 컨텍스트에만 dirty 상태)
if (command.couponSeq != null) {
classAttend.couponSeq = command.couponSeq
...
classAttendProvider.save(classAttend)
}
// 3. 벌크 UPDATE
@Modifying(clearAutomatically = true)
@Query("update LivePaymentJpaEntity e set e.attendSeq = :attendSeq where e.merchantUid = :orderId")
fun updateAttendSeqByMerchantUid(...)
→ 3번 실행 시 앞서 변경한 영속성 컨텍스트 내용이 날아감.
해결
@Modifying(clearAutomatically = true, flushAutomatically = true)
- flushAutomatically = true → 벌크 실행 전에 대기 중인 변경을 DB로 flush.
- 순서 바뀜: flush(dirty→DB) → 벌크 UPDATE → clear. 유실 없음.
직접적으로 이번 장과 연관 있는 격리 문제는 아니지만
데이터가 지금 어느 계층에서 어떤 상태인지 파악하는 것이 비슷해서 가져왔다.
이번 장은 트랜잭션으로 막을 수 있는 어떤 문제들이 있는지 파악했고 특히 회의실 예약 예제에서 충돌 구체화는 평소 생각하지 못했던 거라 알게 되어 좋았다.