[Poppy] Redisson 기반 분산 락을 활용한 예약 시스템 동시성 제어

2025. 2. 26. 15:17·Projects

1. 예약 시스템에서의 동시성 문제

팝업스토어 예약 시스템에서는 특정 시간대에 예약 가능한 인원이 제한된다. 예를 들어, 한 타임에 최대 30명이 예약할 수 있다면 31번째 예약 요청은 거부되어야 한다.
그러나 여러 사용자가 동시에 예약 버튼을 클릭할 경우, 다음과 같은 문제가 발생할 수 있다.

  • 중복 예약: 같은 사용자가 여러 개의 예약을 생성하는 경우
  • 초과 예약: 30개 슬롯을 초과하여 예약이 이루어지는 경우
  • 데이터 정합성 문제: 두 개 이상의 서버에서 동일한 슬롯을 동시 처리할 때, 중복 예약이 발생할 가능성이 있음

이를 해결하기 위해 비관적 락(Pessimistic Lock) 방식과 Redisson을 활용한 Redis 기반 분산 락을 적용했다. 
 


2. 동시성 문제 해결을 위한 락 개념

낙관적 락 (Optimistic Lock)

낙관적 락은 충돌 가능성이 낮은 경우 적합한 방식이다. JPA에서는 `@Version` 어노테이션을 활용하여 데이터 수정 시 버전 번호를 증가시키고, 갱신 시 기존 버전과 일치하는지 검사하여 충돌을 감지한다.

@Entity
public class Reservation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Version
    private Long version;  // 낙관적 락을 위한 버전 필드

    private int availableSlot;
}

 
하지만, 예약 시스템에서는 다수의 사용자가 동시에 같은 슬롯을 예약하려는 경우가 많아 충돌이 빈번하게 발생한다. 낙관적 락은 충돌이 발생하면 트랜잭션을 실패시키고 별도의 재시도 로직을 구현해야 하므로, 경쟁이 심한 환경에서는 충돌 및 재시도 빈도가 높아져 성능 저하가 발생할 수 있다.
 
따라서, 예약 시스템에서는 충돌을 미리 방지하는 비관적 락 방식이 적절하다.
 

비관적 락 (Pessimistic Lock)

비관적 락은 충돌 가능성이 높은 경우, 데이터를 선점하여 다른 트랜잭션이 접근하지 못하도록 방지하는 방식이다.
대표적인 방법으로 데이터베이스의 `SELECT ... FOR UPDATE` 쿼리를 실행하여 다른 트랜잭션의 접근을 차단하는 방법이 있다.

SELECT * FROM reservation_available_slot WHERE id = 1 FOR UPDATE;

 
DB 비관적 락은 같은 DB를 바라보는 여러 서버에서도 row 단위로 직렬화를 보장한다. 다만 트랜잭션이 끝날 때까지 락이 유지되기 때문에, 트래픽이 폭주하면 커넥션 점유 시간이 길어져 커넥션 풀이 고갈되거나 다른 요청이 대기에 묶이는 부작용이 발생할 수 있다. 

 

이 부담을 줄이기 위해 요청 단계에서 1차로 트래픽을 직렬화하는 Redis 기반 분산 락을 함께 도입했다. Redis 분산 락은 DB까지 가지 않고 애플리케이션 레벨에서 동시 요청을 제어할 수 있어, DB 락의 부하를 완화하는 안전망 역할을 한다.


3. Redis 기반 분산 락: Lettuce vs Redisson 비교

분산 환경에서 동시성 제어를 위해 Redis를 활용한 분산 락을 사용할 수 있으며, 대표적인 구현 방식으로는 Lettuce 기반 락과 Redisson 기반 락이 있다.
 

Lettuce 기반 락

 Lettuce를 사용해 분산 락을 직접 구현할 경우, 일반적으로 Redis의 `SETNX(SET if Not Exists)` 명령어로 락을 잡고, 락 획득 실패 시엔 스핀 락(spin lock) 방식으로 일정 시간 대기 후 다시 요청을 보내는 식으로 동작한다.
 
이 방식은 아래와 같은 문제점이 있다.
1️⃣ 스핀 락 방식: 락 획득 실패 시 짧은 간격으로 재요청을 반복 → Redis에 부하 발생
2️⃣ 락 설정과 만료 시간 설정의 원자성 문제: SETNX 후 EXPIRE를 별도로 실행하면 두 명령 사이에 클라이언트가 죽을 경우 만료 시간이 설정되지 않아 데드락이 발생할 수 있음. (SET key value NX EX 한 번에 처리하면 해결되지만, 직접 구현 시 누락되기 쉽다.)
3️⃣ 재시도/락 해제 안전장치 부재: 락 획득 실패 시의 재시도 로직, 락 점유자 식별 로직 등을 직접 구현해야 함
 
따라서, 다중 서버 환경에서 견고한 분산 락을 직접 구현하기는 부담이다.
 

Redisson 기반 락

Redisson은 내부적으로 Lua 스크립트를 사용해 락 획득과 해제 과정을 원자적으로 처리하고, 락이 점유 중일 때는 Pub/Sub 방식으로 락 해제 이벤트를 구독해 대기한다.

RLock lock = redissonClient.getLock("reservation:lock:123");
lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);

 락을 획득하지 못한 클라이언트는 계속 Redis에 요청을 보내는 것이 아니라, 해당 락 채널을 구독한 상태로 대기한다. 이후 락이 해제되면 구독 중이던 클라이언트가 이벤트를 수신해 락을 획득한다. 덕분에 Redis 서버에 불필요한 요청이 가지 않아 부하가 적다.
 
Redisson은 원자적인 분산 락 처리를 위해 두 가지 방식을 사용한다.
1️⃣ Redis의 단일 스레드 처리
Redis는 단일 스레드 기반으로 모든 명령어를 순차적으로 처리하기 떄문에, 동시에 여러 요청이 들어와도  SET ... NX 같은 락 설정 명령은 원자적으로 실행되어 단 하나의 클라이언트만 락을 획득할 수 있다.

2️⃣ Lua 스크립트를 통한 트랜잭션 처리
Redisson은 락 획득, 갱신, 해제를 SET key value NX PX 명령어 조합 또는 Lua 스크립트 기반 로직으로 처리해, 복잡한 락 제어를 하나의 트랜잭션처럼 원자적으로 처리한다.

lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)
  • `waitTime`: 락을 기다리는 최대 시간
  • `leaseTime`: 락을 획득한 뒤 자동 해제까지 유지되는 시간

Redisson은 락을 해제할 때도, 락을 설정했던 고유 식별자 (clientId:threadId)가 일치하는지 검증하여, 다른 스레드나 클라이언트가 락을 실수로 해제하는 문제를 방지한다.


다음은 처음 구현했던 코드를 다시 점검하면서, 더 정확한 트랜잭션-락 경계로 다듬은 결과를 정리한 내용이다.

4. Facade 패턴으로 락과 트랜잭션 경계 분리

처음에 구현한 코드는  @Transactional 메서드 안에서 락을 잡고 푸는 형태였다.

  @Transactional
  public ReservationPaymentRspDto reservation(Long storeId, ...) {
      RLock lock = redissonClient.getLock(lockKey);
      try {
          lock.tryLock(3L, 3L, TimeUnit.SECONDS);
          // 비즈니스 로직
          return createPaymentAndGetResponse(...);
      } finally {
          if (lock.isHeldByCurrentThread()) lock.unlock();
      }
  }

이 구조에서는 실행 순서가 다음과 같이 된다.

트랜잭션 시작 → 락 획득 → 작업 → 락 해제(finally) → 트랜잭션 커밋(메서드 종료 시점)

 

락이 풀리는 시점과 트랜잭션이 커밋되는 시점 사이에 갭이 존재하기 때문에, 락이 풀린 직후 다른 노드가 락을 잡고 DB를 조회하면 이전 트랜잭션의 변경사항이 아직 보이지 않는 상태에서 작업할 수 있다.

 

추가로, 결제 완료 시 슬롯을 실제로 차감하는 completeReservation에는 락이 없었다는 점, cancelReservationByReservationId가 같은 클래스 내부의 @Transactional 메서드를 this.cancelReservation(...)로 호출하고 있었다는 점도 함께 발견했다. Spring AOP 프록시는 self-invocation을 가로채지 못하므로 트랜잭션이 적용되지 않는다.

 

이 세 가지를 한 번에 해결하기 위해 락과 트랜잭션을 다른 빈으로 분리해 호출 순서를 바꿨다.

[Facade] 락 획득 → [Service @Transactional] 트랜잭션 시작 → 작업 → 트랜잭션 커밋 → [Facade] 락 해제

 

@Transactional은 Spring AOP 프록시 기반으로 동작하기 때문에, 다른 빈에서 호출되어야 프록시가 가로채 트랜잭션을 시작한다. Facade에서 Service를 호출하는 것은 외부 빈 호출이라 프록시가 정상 작동하고, Service 메서드가 끝나는 시점에 트랜잭션 커밋도 끝난다. 이후 제어가 Facade로 돌아온 뒤에야 락이 해제되므로 위에서 본 갭이 사라진다.

 

▶ ReservationFacade

@Service
  @RequiredArgsConstructor
  public class ReservationFacade {
      private final RedissonClient redissonClient;
      private final ReservationService reservationService;

      private static final String LOCK_PREFIX = "reservation:lock:";
      private static final long WAIT_TIME = 5L;
      private static final long LEASE_TIME = 10L;

      public ReservationPaymentRspDto reservation(Long storeId, LocalDate date, LocalTime time, int person) {
          return executeWithLock(
                  buildLockKey(storeId, date, time),
                  ErrorCode.RESERVATION_CONFLICT,
                  ErrorCode.RESERVATION_FAILED,
                  () -> reservationService.reservation(storeId, date, time, person)
          );
      }

      public Reservation completeReservation(String orderId) {
          Reservation tempReservation = reservationService.findReservationByOrderId(orderId);
          return executeWithLock(
                  buildLockKey(tempReservation.getPopupStore().getId(),
                               tempReservation.getDate(),
                               tempReservation.getTime()),
                  ErrorCode.RESERVATION_CONFLICT,
                  ErrorCode.RESERVATION_FAILED,
                  () -> reservationService.completeReservation(orderId)
          );
      }

      // cancelReservation, cancelReservationByReservationId 동일 패턴
      
      private String buildLockKey(Long storeId, LocalDate date, LocalTime time) {
          return LOCK_PREFIX + storeId + ":" + date + ":" + time;
      }

      private <T> T executeWithLock(String lockKey, ErrorCode conflictError,
                                    ErrorCode interruptError, Supplier<T> action) {
          RLock lock = redissonClient.getLock(lockKey);
          boolean acquired = false;
          try {
              acquired = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);
              if (!acquired) throw new BusinessException(conflictError);
              return action.get();
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
              throw new BusinessException(interruptError);
          } finally {
              if (acquired && lock.isHeldByCurrentThread()) lock.unlock();
          }
      }
  }

 

▶ ReservationService.java

  @Service
  @RequiredArgsConstructor
  public class ReservationService {
      // RedissonClient 의존성 제거 — 락은 Facade의 책임으로 분리

      private final PopupStoreRepository popupStoreRepository;
      private final ReservationAvailableSlotRepository reservationAvailableSlotRepository;
      private final ReservationRepository reservationRepository;
      private final PaymentRepository paymentRepository;
      private final RedisSlotService redisSlotService;
      private final AsyncRedisSlotDecrementService asyncRedisSlotDecrementService;
      private final PaymentService paymentService;
      private final NotificationService notificationService;
      private final LoginUserProvider loginUserProvider;

      @Transactional
      public ReservationPaymentRspDto reservation(Long storeId, LocalDate date, LocalTime time, int person) {
          if (storeId == null || date == null || time == null || person <= 0)
              throw new BusinessException(ErrorCode.NOT_NULL_PARAMETER);

          PopupStore popupStore = popupStoreRepository.findById(storeId)
                  .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));

          if (popupStore.getReservationType() != ReservationType.ONLINE)
              throw new BusinessException(ErrorCode.INVALID_RESERVATION);

          Integer redisSlot = redisSlotService.getSlotFromRedis(storeId, date, time);
          if (redisSlot == null || redisSlot < person || redisSlot <= 0)
              throw new BusinessException(ErrorCode.NO_AVAILABLE_SLOT);

          User user = loginUserProvider.getLoggedInUser();

          // (기존 예약 체크, 임시 예약 생성 등 비즈니스 로직)
          ...
          return createPaymentAndGetResponse(tempReservation, user, person);
      }

      @Transactional
      public Reservation completeReservation(String orderId) {
          Payment payment = paymentRepository.findByOrderId(orderId)
                  .orElseThrow(() -> new BusinessException(ErrorCode.PAYMENT_NOT_FOUND));
          Reservation tempReservation = payment.getReservation();

          try {
              redisSlotService.decrementSlot(
                      tempReservation.getPopupStore().getId(),
                      tempReservation.getDate(),
                      tempReservation.getTime(),
                      tempReservation.getPerson()
              );
          } catch (Exception e) {
              asyncRedisSlotDecrementService.decrementRedisSlot(...);
          }

          Reservation reservation = processReservation(tempReservation);
          notificationService.sendNotification(reservation, reservation.getStatus(), NotificationType.RESERVATION_CHECK);
          return reservation;
      }

      @Transactional
      public void cancelReservationByReservationId(Long userId, Long reservationId) {
          // @Transactional 추가 — 외부에서 Facade를 통해 호출되므로 트랜잭션이 정상 적용됨
          Reservation reservation = reservationRepository.findByIdAndUserId(reservationId, userId)
                  .orElseThrow(() -> new BusinessException(ErrorCode.RESERVATION_NOT_FOUND));

          cancelReservation(
                  userId,
                  reservation.getPopupStore().getId(),
                  reservation.getDate(),
                  reservation.getTime(),
                  reservation.getPerson()
          );
          notificationService.sendNotification(reservation, ReservationStatus.CANCELED, NotificationType.RESERVATION_CANCEL);
      }

      // Facade에서 락 키 구성을 위해 사용
      @Transactional(readOnly = true)
      public Reservation findReservationByOrderId(String orderId) {
          Payment payment = paymentRepository.findByOrderId(orderId)
                  .orElseThrow(() -> new BusinessException(ErrorCode.PAYMENT_NOT_FOUND));
          return payment.getReservation();
      }

      @Transactional(readOnly = true)
      public Reservation findReservation(Long userId, Long reservationId) {
          return reservationRepository.findByIdAndUserId(reservationId, userId)
                  .orElseThrow(() -> new BusinessException(ErrorCode.RESERVATION_NOT_FOUND));
      }
  }

 


5. DB 비관적 Lock 추가

 분산 락을 올바른 위치로 옮긴 뒤에도 한 가지 시나리오가 남아 있었다.
 

▶  Redis lease 만료 시나리오

  1. Request A가 Redisson 락 획득 (lease=10s)
  2. A의 트랜잭션이 부하로 인해 12초 걸림
  3. 10초 시점에 Redis 락이 자동 해제
  4. Request B가 Redis 락 획득 → DB 작업 시작
  5. A와 B가 동시에 같은 slot을 차감 → 초과 예약

 

Redis 분산 락만으로는 lease 만료, Redis 일시 장애 같은 케이스를 막기 어렵다. 이를 보완하기 위해 DB 레벨에서 한 번 더 직렬화하기로 했다.
 

▶ Repository에 PESSIMISTIC_WRITE 추가

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query("SELECT s FROM ReservationAvailableSlot s " +
         "WHERE s.popupStore.id = :storeId AND s.date = :date AND s.time = :time")
  Optional<ReservationAvailableSlot> findByPopupStoreIdAndDateAndTimeForUpdate(
          @Param("storeId") Long storeId,
          @Param("date") LocalDate date,
          @Param("time") LocalTime time);

DB 쓰기가 일어나는 두 곳에서 이 메서드를 사용하도록 변경했다.

 1️⃣  processReservation (슬롯 차감)
 2️⃣  cancelReservation (슬롯 증가)

 

결과 - 두 단계 직렬화

Layer 1: Redisson 분산 락 (Facade) → 분산 환경에서 1차 동시성 제어

Layer 2: DB Pessimistic Lock (Service) → DB 수준의 최종 정합성 방어

 

정상 흐름에서는 Layer 1이 대부분의 동시 요청을 막아주기 때문에 Layer 2의 락 대기는 거의 발생하지 않는다. 하지만 lease 만료, Redis 장애 같은 케이스에서 Layer 2가 안전망으로 동작한다.


6. 결론

처음 도입한 Redisson 분산 락만으로도 표면적인 동시성 문제는 해결되는 것처럼 보였다. 하지만 코드를 다시 점검하니 트랜잭션과 락의 호출 순서가 어긋나는 지점이 있었고, 일부 메서드에는 락이 빠져 있었다.
분산 락 하나만으로 정합성이 보장된다고 단정할 수 없으며, 락의 위치와 트랜잭션 경계가 만나는 지점이 미묘한 버그가 생기는 지점이라는 것을 체감했다.

'Projects' 카테고리의 다른 글

[InConcert] SSE+Redis를 활용한 실시간 알림  (0) 2025.01.22
[InConcert] LazyInitializationException 해결  (0) 2025.01.19
[InConcert] 스크래핑 비동기 처리(2)  (0) 2025.01.15
[InConcert] 스크래핑 비동기 처리(1)  (0) 2025.01.10
'Projects' 카테고리의 다른 글
  • [InConcert] SSE+Redis를 활용한 실시간 알림
  • [InConcert] LazyInitializationException 해결
  • [InConcert] 스크래핑 비동기 처리(2)
  • [InConcert] 스크래핑 비동기 처리(1)
oniwon
oniwon
  • oniwon
    눙눙의 개발일지
    oniwon
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Java
      • Spring
      • CS
      • Infra
      • Projects
      • F-Lab
      • Airflow
  • 인기 글

  • 블로그 메뉴

    • 홈
    • 개인정보처리방침 (Privacy Policy)
    • 블로그 소개 (About)
  • 최근 글

  • 태그

    JVM
    redis
    Airflow
    에프랩
    자바백엔드
    네트워크
    병렬 처리
    F-Lab
    커넥터스2385
    Flab후기
    SSE
    에프랩멘토링
    자바면접
    Java
    FLAB
    Flab 멘토링
    Flab멘토링
    동시성
    비동기 처리
    Flab추천인코드
    tls
    dns
    inconcert
    Flab커넥터스
    자바 다형성
    DAG
    https
    자바 내부 동작
    Flab 추천인코드
    Flab 3주차 후기
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.1
oniwon
[Poppy] Redisson 기반 분산 락을 활용한 예약 시스템 동시성 제어
상단으로

티스토리툴바