【黑马点评-3초 특가 쿠폰】3. 비관적 락 synchronized로 한 사람 한 번 구매 보장 및 단일 서버 초과 주문 초기 로직

한 사람 한 번 구매 제약 조건 해결 방법

비관적 락 사용 이유

기존의 낙관적 락 방식은 데이터 변경 여부를 확인해야 하며, 현재는 존재 여부만 판단하는 상황이라 CAS 기법을 활용하기 어렵다. 버전 번호를 사용할 수도 있지만, 추가 필드가 필요하다. 따라서 간단한 구현을 위해 비관적 락을 적용한다.

비관적 락 적용 절차

  1. MyBatis-Plus로 시작/종료 시간 검증
    쿠폰의 판매 시작 시점과 종료 시점을 확인하여 유효성 검사 수행.
  2. ThreadLocal에서 사용자 ID 추출
    UserHolder 클래스를 통해 현재 세션의 사용자 식별자(userId) 획득.
  3. synchronized 키워드로 동기화 처리
    공유 자원에 대한 동시 접근을 제어하기 위해 synchronized를 사용. 이 경우, userId.toString().intern()를 锁 객체로 지정함으로써 동일한 사용자에 대해 동시에 하나의 스레드만 실행되도록 보장.
  4. 트랜잭션 무효화 방지
    Spring의 트랜잭션은 AOP 기반으로 동작하며, 같은 클래스 내에서 비트랜잭션 메서드가 트랜잭션 메서드를 직접 호출하면 프록시를 통하지 않아 @Transactional이 작동하지 않는다. 이를 해결하기 위해 AopContext.currentProxy()를 사용해 프록시 객체를 가져와 호출한다.
  5. 주문 생성 로직 실행
    이미 해당 사용자가 주문한 기록이 있는지 확인하고, 재고 감소 후 새로운 주문 생성.

코드 예시: 동기화 블록과 트랜잭션 관리

public Result seckillVoucherPess(Long voucherId) {
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        return Result.fail("판매 시작 전입니다.");
    }
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        return Result.fail("판매 종료되었습니다.");
    }
    if (voucher.getStock() < 1) {
        return Result.fail("재고 부족");
    }

    Long userId = UserHolder.getUser().getId();

    // 사용자 별 고유 락 확보
    synchronized (userId.toString().intern()) {
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(userId, voucherId);
    }
}

주문 생성 메서드 구현

@Override
public Result createVoucherOrder(Long userId, Long voucherId) {
    // 이미 구매한 기록이 있는지 확인
    int existingCount = this.count(new LambdaUpdateWrapper<VoucherOrder>()
            .eq(VoucherOrder::getUserId, userId));

    if (existingCount >= 1) {
        return Result.fail("이미 구매하셨습니다.");
    }

    // 재고 감소 (최신 상태 반영)
    boolean stockUpdated = seckillVoucherService.update(
        new LambdaUpdateWrapper<SeckillVoucher>()
            .eq(SeckillVoucher::getVoucherId, voucherId)
            .gt(SeckillVoucher::getStock, 0)
            .setSql("stock = stock - 1")
    );

    if (!stockUpdated) {
        throw new RuntimeException("재고 감소 실패");
    }

    // 새로운 주문 생성
    VoucherOrder order = new VoucherOrder();
    long orderId = redisIdWorker.nextId("seckill_voucher_order");
    order.setId(orderId);
    order.setUserId(userId);
    order.setVoucherId(voucherId);

    boolean saved = this.save(order);
    if (!saved) {
        throw new RuntimeException("주문 저장 실패");
    }

    return Result.ok(orderId);
}

핵심 원칙 요약

  • 락 범위 최소화: synchronized는 메서드 전체보다는 가능한 작은 코드 블록에 적용해야 성능 저하를 줄일 수 있다.
  • 잠금 대상은 불변 값: Long 타입의 userId는 매 요청마다 새 인스턴스 생성됨. 이를 방지하기 위해 toString()intern()로 문자열 상수 풀에서 동일한 참조를 확보.
  • 트랜잭션 전체를 락해야 함: 락을 트랜잭션 내부의 일부 코드에만 적용하면, 트랜잭션이 아직 커밋되지 않은 상태에서 락이 해제되어 초과 주문 문제가 발생할 수 있음.
  • 프록시를 통한 트랜잭션 활성화: @Transactional은 동적 프록시 기반으로 작동하므로, 자기 자신 내부 호출 시 프록시를 거치지 않아 무효화된다. AopContext.currentProxy()를 통해 프록시를 얻어 호출해야 한다.

태그: java Spring Boot Synchronized MyBatis-Plus AOP

6월 26일 16:47에 게시됨