From 56cbe49584d7769f349b6c3661287a75655921d2 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Sat, 23 May 2026 22:41:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EC=96=B4=20Redis=20=EB=B6=84=EC=82=B0?= =?UTF-8?q?=EB=9D=BD=20=EC=A0=81=EC=9A=A9=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DistributedLockExecutor: Redisson 기반 분산락 공통 실행기 추가 (global/lock/) - ErrorCode: LOCK_TIMEOUT 추가 (HTTP 409) - ReservationService: 좌석 차감 경로 3곳에 분산락 적용 - create / createReservationFromAi / updateReservation - 같은 좌석에 동시 요청을 직렬화하여 초과 예약 방지 - 충돌 시 명시 응답으로 전환 (REMAIN_EXHAUSTED / LOCK_TIMEOUT) --- .../global/exception/ErrorCode.java | 1 + .../global/lock/DistributedLockExecutor.java | 48 ++++++ .../service/ReservationService.java | 157 ++++++++++-------- 3 files changed, 140 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/catchtable/global/lock/DistributedLockExecutor.java diff --git a/src/main/java/com/catchtable/global/exception/ErrorCode.java b/src/main/java/com/catchtable/global/exception/ErrorCode.java index 523069e..e48af03 100644 --- a/src/main/java/com/catchtable/global/exception/ErrorCode.java +++ b/src/main/java/com/catchtable/global/exception/ErrorCode.java @@ -68,6 +68,7 @@ public enum ErrorCode implements ResponseCode { REMAIN_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 예약 시간대입니다."), REMAIN_EXHAUSTED(HttpStatus.BAD_REQUEST, "해당 시간대의 예약이 마감되었습니다."), OPTIMISTIC_LOCK_CONFLICT(HttpStatus.CONFLICT, "이미 다른 사용자가 예약하여 마감되었습니다. 다시 시도해주세요."), + LOCK_TIMEOUT(HttpStatus.CONFLICT, "예약 요청이 많아 처리하지 못했습니다. 잠시 후 다시 시도해주세요."), // Review REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 리뷰입니다."), diff --git a/src/main/java/com/catchtable/global/lock/DistributedLockExecutor.java b/src/main/java/com/catchtable/global/lock/DistributedLockExecutor.java new file mode 100644 index 0000000..ed4bcf4 --- /dev/null +++ b/src/main/java/com/catchtable/global/lock/DistributedLockExecutor.java @@ -0,0 +1,48 @@ +package com.catchtable.global.lock; + +import com.catchtable.global.exception.CustomException; +import com.catchtable.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DistributedLockExecutor { + + private final RedissonClient redissonClient; + + /** + * 분산 락을 잡은 뒤 task를 실행하고, 끝나면 락을 해제한다. + * + * @param lockKey 락 키. 같은 키끼리만 직렬화된다. + * @param waitSeconds 락을 잡으려고 기다리는 최대 시간(초). + * @param leaseSeconds 락을 잡은 뒤 자동으로 풀리는 시간(초). 작업이 죽어도 이 시간 후 해제된다. + * @param task 락 안에서 실행할 작업. + */ + public T executeWithLock(String lockKey, long waitSeconds, long leaseSeconds, Supplier task) { + RLock lock = redissonClient.getLock(lockKey); + boolean acquired = false; + try { + acquired = lock.tryLock(waitSeconds, leaseSeconds, TimeUnit.SECONDS); + if (!acquired) { + log.warn("분산 락 획득 실패 (대기 {}초 초과): {}", waitSeconds, lockKey); + throw new CustomException(ErrorCode.LOCK_TIMEOUT); + } + return task.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.LOCK_TIMEOUT); + } finally { + if (acquired && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 6e9c051..635bdce 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -6,6 +6,7 @@ import com.catchtable.coupon.service.CouponService; import com.catchtable.global.exception.CustomException; import com.catchtable.global.exception.ErrorCode; +import com.catchtable.global.lock.DistributedLockExecutor; import com.catchtable.notification.event.ReservationCanceledEvent; import com.catchtable.notification.event.ReservationChangedEvent; import com.catchtable.notification.event.ReservationVisitedEvent; @@ -39,7 +40,9 @@ import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDate; import java.time.LocalTime; @@ -65,6 +68,8 @@ public class ReservationService { private final PaymentRepository paymentRepository; private final PaymentService paymentService; private final StoreRepository storeRepository; + private final DistributedLockExecutor lockExecutor; + private final PlatformTransactionManager transactionManager; // ============================================================ // AI Tools @@ -85,7 +90,6 @@ public List searchStoresByName( @Tool(description = "사용자의 자연어 요청을 기반으로 레스토랑 예약을 생성합니다. " + "반드시 searchStoresByName으로 매장명을 확인한 후 호출하세요. " + "매장 이름, 예약 날짜, 예약 시간, 인원수가 모두 필요합니다.") - @Transactional public String createReservationFromAi( @ToolParam(description = "매장 이름 (정확한 이름 사용)") String storeName, @ToolParam(description = "예약 날짜, ISO 형식 (예: 2025-05-11)") LocalDate date, @@ -110,26 +114,34 @@ public String createReservationFromAi( return "STORE_OR_SLOT_NOT_FOUND: 요청하신 매장(" + storeName + ")의 " + date + " " + time + " 시간대에 예약 가능한 자리가 없습니다."; } - Reservation saved = createReservationCore( - currentUserId, availableRemain.get().getId(), member, couponId); - - // Payment 레코드 생성 (결제창 호출을 위해 orderId 필요) - String orderId = "CATCH-" + saved.getId() + "-" + System.currentTimeMillis(); - Payment payment = Payment.builder() - .reservation(saved) - .orderId(orderId) - .amount(DEPOSIT_AMOUNT) - .build(); - paymentRepository.save(payment); - - log.info("AI 예약 성공: reservationId={}, orderId={}", saved.getId(), orderId); - - PendingPaymentHolder.set(new PendingPaymentInfo(saved.getId(), orderId, DEPOSIT_AMOUNT)); - - return String.format( - "네, %s 레스토랑 %s %s 시간으로 %d명 예약이 완료되었습니다. " + - "보증금 10,000원 결제 후 예약이 최종 확정됩니다. 예약 번호는 %d번입니다.", - storeName, date, time, member, saved.getId()); + Long remainId = availableRemain.get().getId(); + String lockKey = "lock:reservation:remain:" + remainId; + + return lockExecutor.executeWithLock(lockKey, 3, 5, () -> { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + return tx.execute(status -> { + Reservation saved = createReservationCore( + currentUserId, remainId, member, couponId); + + // Payment 레코드 생성 (결제창 호출을 위해 orderId 필요) + String orderId = "CATCH-" + saved.getId() + "-" + System.currentTimeMillis(); + Payment payment = Payment.builder() + .reservation(saved) + .orderId(orderId) + .amount(DEPOSIT_AMOUNT) + .build(); + paymentRepository.save(payment); + + log.info("AI 예약 성공: reservationId={}, orderId={}", saved.getId(), orderId); + + PendingPaymentHolder.set(new PendingPaymentInfo(saved.getId(), orderId, DEPOSIT_AMOUNT)); + + return String.format( + "네, %s 레스토랑 %s %s 시간으로 %d명 예약이 완료되었습니다. " + + "보증금 10,000원 결제 후 예약이 최종 확정됩니다. 예약 번호는 %d번입니다.", + storeName, date, time, member, saved.getId()); + }); + }); } @Tool(description = "사용자의 예약 목록을 조회합니다. '내 예약 보여줘', '예약 현황' 등의 요청에 사용하세요.") @@ -200,20 +212,27 @@ public String cancelReservationFromAi( } } - @Transactional public ReservationCreateResponseDto create(Long userId, ReservationCreateRequestDto request) { - Reservation saved = createReservationCore(userId, request.remainId(), request.member(), request.couponId()); - - // ConfirmedEvent는 결제 완료 시점(PaymentService.confirmPayment)에서 발행한다. - String orderId = "CATCH-" + saved.getId() + "-" + System.currentTimeMillis(); - Payment payment = Payment.builder() - .reservation(saved) - .orderId(orderId) - .amount(DEPOSIT_AMOUNT) - .build(); - paymentRepository.save(payment); - - return new ReservationCreateResponseDto(saved.getId(), orderId, DEPOSIT_AMOUNT, saved.getStatus()); + String lockKey = "lock:reservation:remain:" + request.remainId(); + + return lockExecutor.executeWithLock(lockKey, 3, 5, () -> { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + return tx.execute(status -> { + Reservation saved = createReservationCore( + userId, request.remainId(), request.member(), request.couponId()); + + // ConfirmedEvent는 결제 완료 시점(PaymentService.confirmPayment)에서 발행한다. + String orderId = "CATCH-" + saved.getId() + "-" + System.currentTimeMillis(); + Payment payment = Payment.builder() + .reservation(saved) + .orderId(orderId) + .amount(DEPOSIT_AMOUNT) + .build(); + paymentRepository.save(payment); + + return new ReservationCreateResponseDto(saved.getId(), orderId, DEPOSIT_AMOUNT, saved.getStatus()); + }); + }); } /** @@ -252,39 +271,45 @@ public void cancelReservation(Long reservationId, Long userId) { } } - @Transactional public ReservationUpdateResponseDto updateReservation(Long reservationId, Long userId, ReservationUpdateRequestDto request) { - Reservation oldReservation = cancelReservationCore(reservationId, userId, ReservationStatus.REPLACED); - StoreRemain oldStoreRemain = oldReservation.getStoreRemain(); - - // 기존 결제를 새 예약으로 이전 - Payment oldPayment = paymentRepository.findByReservation_Id(reservationId).orElse(null); - - Reservation newReservation = createReservationCore(userId, request.newRemainId(), request.newMember(), request.couponId()); - newReservation.changeStatus(ReservationStatus.CONFIRMED); - - if (oldPayment != null) { - oldPayment.transferToReservation(newReservation); - } - - StoreRemain newStoreRemain = newReservation.getStoreRemain(); - eventPublisher.publishEvent(new ReservationChangedEvent( - newReservation.getId(), - userId, - newStoreRemain.getStore().getStoreName(), - oldStoreRemain.getRemainDate().toString(), - oldStoreRemain.getRemainTime().toString(), - newStoreRemain.getRemainDate().toString(), - newStoreRemain.getRemainTime().toString() - )); - - return new ReservationUpdateResponseDto( - newReservation.getId(), - newReservation.getStoreRemain().getId(), - newReservation.getMember(), - newReservation.getStatus().name().toLowerCase(), - newReservation.getUpdatedAt() != null ? newReservation.getUpdatedAt() : java.time.LocalDateTime.now() - ); + String lockKey = "lock:reservation:remain:" + request.newRemainId(); + + return lockExecutor.executeWithLock(lockKey, 3, 5, () -> { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + return tx.execute(status -> { + Reservation oldReservation = cancelReservationCore(reservationId, userId, ReservationStatus.REPLACED); + StoreRemain oldStoreRemain = oldReservation.getStoreRemain(); + + // 기존 결제를 새 예약으로 이전 + Payment oldPayment = paymentRepository.findByReservation_Id(reservationId).orElse(null); + + Reservation newReservation = createReservationCore(userId, request.newRemainId(), request.newMember(), request.couponId()); + newReservation.changeStatus(ReservationStatus.CONFIRMED); + + if (oldPayment != null) { + oldPayment.transferToReservation(newReservation); + } + + StoreRemain newStoreRemain = newReservation.getStoreRemain(); + eventPublisher.publishEvent(new ReservationChangedEvent( + newReservation.getId(), + userId, + newStoreRemain.getStore().getStoreName(), + oldStoreRemain.getRemainDate().toString(), + oldStoreRemain.getRemainTime().toString(), + newStoreRemain.getRemainDate().toString(), + newStoreRemain.getRemainTime().toString() + )); + + return new ReservationUpdateResponseDto( + newReservation.getId(), + newReservation.getStoreRemain().getId(), + newReservation.getMember(), + newReservation.getStatus().name().toLowerCase(), + newReservation.getUpdatedAt() != null ? newReservation.getUpdatedAt() : java.time.LocalDateTime.now() + ); + }); + }); } @Transactional(readOnly = true)