Post

04 Isolation&Rock

04 Isolation&Rock

1. Note

  • 격리수준과 락
    • 격리수준은 다른 트랜잭션의 변경이 내 트랜잭션에 어떻게 보일지
    • 락은 다른 트랜잭션이 해당 데이터에 대해 읽기/쓰기 행동을 못 하게 제한

2. Isolation&Rock

1. Isolation (격리 수준)

  • “트랜잭션끼리 서로 얼마나 안 보이게 할 것인가”
  • 종류

    격리수준Dirty ReadNon-Repeatable ReadPhantom Read특징
    READ UNCOMMITTEDOOO거의 사용 안 함
    READ COMMITTEDXOO대부분 DB 기본
    REPEATABLE READXXO*MySQL 기본
    SERIALIZABLEXXX가장 강력 (성능 ↓)

2. Lock (락)

  • “데이터를 실제로 누가 건드릴 수 있는지 막는 기술”
  • 종류

    항목OPTIMISTICPESSIMISTIC_READPESSIMISTIC_WRITE
    조회자유롭게 가능가능일반 조회는 가능
    수정가능 (충돌 시 실패)제한완전 차단
    동시 실행가능가능불가능 (대기)
    충돌 처리예외 발생없음대기

3. Note

  • 스프링에서 Isolation의 기본값은 DB를 따라감
  • 두가지 다 필수적인 항목은 아님.
  • 상황에 따라서, 필요에따라서 선언하고 사용하는 과정.

3. 케이스1 (조회 → 검증 → 수정)

1. 소스

  • Service
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    // 내가 읽은 데이터를 트랜잭션 동안 일관되게 유지하기 위한 것
    // 지금은 Rock이 걸려서 크게 막 어마어마한 효과 X
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void reserve(Long seatId) {
      // 좌석 조회
      Seat seat = seatRepository.findForUpdate(seatId)
              .orElseThrow();
    
      // 예약좌석 여부 검증
      if (seat.isReserved()) {
          throw new IllegalStateException();
      }
    
      // 예약좌석으로 수정
      seat.reserve();
    }
    
  • Repository
    1
    2
    3
    4
    
    // 내가 자업중에 다른 트랜잭션이 그 데이터를 수정하지 못하게 막기 위한 것
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Seat s where s.id = :id")
    Optional<Seat> findForUpdate(Long id);
    

2. Memo

  • Lock만 있고 Isolation 없으면
    • 내가 두 번 조회했는데 값이 바뀔 수도 있음 (특히 DB마다 다름)
    • range query에서는 깨짐 (phantom)
  • Isolation만 있고 Lock 없으면
    • 다른 트랜잭션이 값을 바꿔버림
    • 내가 outdated 값으로 update (Lost Update)
  • 둘다 없다면
    • 트랜잭션 A와 B가 돌 때,
    • A가 조회후에 B가 커밋을 한 경우에
    • A는 캐싱되어있는것을 조회하기때문에 별도 확인X
    • 그냥 초기 조회 상태로 업데이트 진행함.

4. 케이스2 (범위 조건 + 팬텀 리드 방지)

1. 소스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Transactional(isolation = Isolation.SERIALIZABLE)
public void createReservation(LocalDateTime time) {

    // 특정 시간대에 예약이 있는지 조회
    boolean exists = reservationRepository.existsByTimeRange(time);

    // 예약이 있으면 예외발생
    if (exists) {
        throw new IllegalStateException();
    }

    // 아니면 막기
    reservationRepository.save(new Reservation(time));
}

2. memo

  • Repository에서 락을 걸 수 없는 이유
    • query가 where start_time <= ? and end_time >= ? 이런 패턴
    • 여기에서 데이터가 있다면 특정 로우 범위에 락이 가능하기는 한데,
    • 데이터 자체가 존재하지 않는다면 락을 걸 대상 자체가 없음.
    • 그렇다고 전체 테이블에 락을 거는건 비효율적임.
  • 대안
    • SERIALIZABLE로 Gap-rock형태로 만들어서 그 공간을 제어할 수 없도록 함.
    • Time 컬럼에 인덱스추가 + PESSIMISTIC_WRITE을 사용해 gapRock 유도

5. 케이스3(읽기 일관성 + 외부 시스템 연동)

1. 소스

  • Service
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void process(Long id) {
    
      //주문 조회하고
      Order order = orderRepository.findByIdForUpdate(id)
              .orElseThrow();
    
      // 외부 API 호출
      externalApi.call(order.getAmount());
    
      //상태 변경처리
      order.complete();
    }
    
  • Repository
    1
    2
    3
    
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select o from Order o where o.id = :id")
    Optional<Order> findByIdForUpdate(Long id);
    

2. Memo

  • 1번 케이스와 매우 유사함
    • 일관성 유지

6. 케이스4(읽기 안정성 + 충돌 시도 자체 차단)

1. 소스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
public void issueCoupon(Long userId) {

    // 1. 발급 여부 체크
    boolean exists = couponRepository.existsByUserId(userId);
    if (exists) {
        throw new IllegalStateException("이미 발급됨");
    }

    // 2. 발급
    couponRepository.save(new Coupon(userId));

    // 3. 로그 저장
    couponLogRepository.save(new CouponLog(userId));
}

2. Memo

  • 순차적으로 여러레파지토리 작업을 진행해야하는 경우
    • 서비스에 락을 걸면 순서대로 락을 걸면서 진행하게됨
    • 만약에 중간에 락 대기 또는 예외가 터지는 경우 전체 롤백 처리
This post is licensed under CC BY 4.0 by the author.