GOORM-DEEP DIVE 백엔드 3회차<스타벅스 클론> 주문 번호 구현, 동시성 오류 비관적 락(Pessimistic Lock)으로 해결하기

Cori1304·2025년 7월 16일
0

프로젝트 회고

목록 보기
2/2

🤔 왜 비관적 락을 선택했나?

  • 주문번호는 매장/날짜별로 반드시 유일해야 하며,
  • 여러 사용자가 동시에 주문을 생성할 때 중복된 번호가 발생하면 절대 안 됨
  • 단 하나의 트랜잭션만이 해당 카운터 row에 접근하도록 제어해야 함

🛡️ 비관적 락(Pessimistic Lock) 적용 이유

  • 비관적 락을 사용하면, 해당 row에 대한 트랜잭션이 완료될 때까지 다른 트랜잭션의 접근이 차단
  • 즉, 동시에 여러 사용자가 주문을 해도 race condition을 원천적으로 예방
  • 예외 상황(충돌 등)에 대한 처리도 단순해짐
  • 주문 생성 트래픽이 아주 높지 않다면, 성능 저하 없이 데이터 무결성을 보장할 수 있음

⚙️ JPA에서 비관적 락 적용 방법

// Repository에서 비관적 락 쿼리 작성 예시
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM OrderDailyCounter c WHERE c.id = :id")
Optional<OrderDailyCounter> findByIdForUpdate(@Param("id") OrderDailyCounterId id);
  • 서비스에서 findByIdForUpdate로 카운터를 조회하면, 해당 row에 쓰기 락이 걸림
  • 락이 해제될 때까지(트랜잭션 종료 시점까지) 다른 트랜잭션은 대기

🚦 실제 적용 흐름

  1. 트랜잭션 시작
  2. findByIdForUpdate로 카운터 row 조회(비관적 락)
  3. 카운터 값 증가
  4. 저장 및 트랜잭션 커밋
  5. 락 해제, 다음 트랜잭션 진행

✅ 장점

  • Race condition 완벽 방지
  • 예외 상황 처리 단순(재시도 로직 등 불필요)
  • 데이터 무결성 보장

⚠️ 단점

  • 트래픽이 매우 높은 상황에서는 대기/병목 발생 가능
  • 트랜잭션이 길어지면 데드락 위험
  • DB에 락이 많이 걸리면 성능 저하 가능

📝 결론

  • 주문번호처럼 절대 중복이 허용되지 않는 데이터에는 비관적 락이 매우 효과적
  • 단, 트래픽 규모와 시스템 특성을 고려해 적용 필요
  • 트래픽이 급격히 늘어날 경우, Redis나 Sequence 등 다른 대안도 함께 검토할 것!

🛠️ 실제 코드 변경 상세 (feat/#71-orderNumber-race-condition)

1. Repository에 비관적 락 메서드 추가

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM OrderDailyCounter c WHERE c.id = :id")
Optional<OrderDailyCounter> findByIdWithPessimisticLock(@Param("id") OrderDailyCounterId id);
  • 기존 findById 대신, PESSIMISTIC_WRITE 락을 거는 쿼리 메서드를 추가

2. 서비스 로직에서 락 메서드 사용

변경 전:

OrderDailyCounter counter = orderDailyCounterRepository.findById(id)
    .orElseGet(() -> new OrderDailyCounter(id, 0));

변경 후:

OrderDailyCounter counter = orderDailyCounterRepository.findByIdWithPessimisticLock(id)
    .orElseGet(() -> {
        OrderDailyCounter newCounter = new OrderDailyCounter(id, 0);
        orderDailyCounterRepository.saveAndFlush(newCounter);
        return newCounter;
    });
  • findByIdWithPessimisticLock로 row-level lock을 적용하여 카운터를 조회
  • 만약 해당 row가 없으면 새로 생성 후 즉시 저장(saveAndFlush)하여 락이 적용된 상태로 카운터 관리

3. 카운터 증가 및 저장

변경 전/후 동일:

counter.increment();
orderDailyCounterRepository.save(counter);

4. 전체 흐름

  1. 트랜잭션 시작
  2. findByIdWithPessimisticLock로 카운터 row 조회(또는 신규 생성)
  3. 카운터 값 증가
  4. 저장 및 트랜잭션 커밋
  5. 락 해제, 다음 트랜잭션 진행

💡 변경 효과

  • 동시성 문제(race condition) 완전 차단
  • 주문번호 중복 없이, 안정적으로 주문 처리 가능
  • 예외 상황 처리 단순화(재시도 로직 필요 없음)
  • 트래픽이 아주 높지 않다면 성능 저하 없이 데이터 무결성 보장

변경후 결과

profile
개발 공부 기록

0개의 댓글