Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ¦¬λ·°μž…λ‹ˆλ‹€."),
Expand Down
Original file line number Diff line number Diff line change
@@ -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> T executeWithLock(String lockKey, long waitSeconds, long leaseSeconds, Supplier<T> 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();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -85,7 +90,6 @@ public List<String> searchStoresByName(
@Tool(description = "μ‚¬μš©μžμ˜ μžμ—°μ–΄ μš”μ²­μ„ 기반으둜 λ ˆμŠ€ν† λž‘ μ˜ˆμ•½μ„ μƒμ„±ν•©λ‹ˆλ‹€. " +
"λ°˜λ“œμ‹œ searchStoresByName으둜 λ§€μž₯λͺ…을 ν™•μΈν•œ ν›„ ν˜ΈμΆœν•˜μ„Έμš”. " +
"λ§€μž₯ 이름, μ˜ˆμ•½ λ‚ μ§œ, μ˜ˆμ•½ μ‹œκ°„, μΈμ›μˆ˜κ°€ λͺ¨λ‘ ν•„μš”ν•©λ‹ˆλ‹€.")
@Transactional
public String createReservationFromAi(
@ToolParam(description = "λ§€μž₯ 이름 (μ •ν™•ν•œ 이름 μ‚¬μš©)") String storeName,
@ToolParam(description = "μ˜ˆμ•½ λ‚ μ§œ, ISO ν˜•μ‹ (예: 2025-05-11)") LocalDate date,
Expand All @@ -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, () -> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

λΆ„μ‚° 락의 λŒ€κΈ° μ‹œκ°„(3초)κ³Ό μž„λŒ€ μ‹œκ°„(5초)이 ν•˜λ“œμ½”λ”©λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. 이 값듀은 create (218라인), updateReservation (277라인) λ©”μ„œλ“œμ—μ„œλ„ 반볡적으둜 μ‚¬μš©λ©λ‹ˆλ‹€.

μ½”λ“œμ˜ μœ μ§€λ³΄μˆ˜μ„±κ³Ό 일관성을 높이기 μœ„ν•΄ 이 값듀을 클래슀 레벨의 private static final μƒμˆ˜λ‘œ μΆ”μΆœν•˜λŠ” 것을 ꢌμž₯ν•©λ‹ˆλ‹€.

μ˜ˆμ‹œ:

private static final long LOCK_WAIT_SECONDS = 3;
private static final long LOCK_LEASE_SECONDS = 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 = "μ‚¬μš©μžμ˜ μ˜ˆμ•½ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€. 'λ‚΄ μ˜ˆμ•½ λ³΄μ—¬μ€˜', 'μ˜ˆμ•½ ν˜„ν™©' λ“±μ˜ μš”μ²­μ— μ‚¬μš©ν•˜μ„Έμš”.")
Expand Down Expand Up @@ -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());
});
});
}

/**
Expand Down Expand Up @@ -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()
);
});
});
Comment on lines 274 to +312

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

ν˜„μž¬ μ˜ˆμ•½ λ³€κ²½ λ‘œμ§μ€ μƒˆλ‘œμš΄ μ˜ˆμ•½ μ‹œκ°„λŒ€(request.newRemainId())에 λŒ€ν•΄μ„œλ§Œ λΆ„μ‚° 락을 νšλ“ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€. ν•˜μ§€λ§Œ μ˜ˆμ•½ λ³€κ²½ κ³Όμ •μ—λŠ” κΈ°μ‘΄ μ˜ˆμ•½μ„ μ·¨μ†Œν•˜λŠ” 둜직(cancelReservationCore 호좜)이 ν¬ν•¨λ˜μ–΄ 있으며, 이 κ³Όμ •μ—μ„œ κΈ°μ‘΄ StoreRemain의 μ’Œμ„ 수λ₯Ό λŠ˜λ¦¬λŠ” μž‘μ—…μ΄ μˆ˜ν–‰λ©λ‹ˆλ‹€.

이 κΈ°μ‘΄ StoreRemain에 λŒ€ν•œ μˆ˜μ • μž‘μ—…μ€ λΆ„μ‚° 락으둜 λ³΄ν˜Έλ˜μ§€ μ•Šμ•„, λ‹€λ₯Έ νŠΈλžœμž­μ…˜(예: ν•΄λ‹Ή μ‹œκ°„λŒ€μ— λŒ€ν•œ μ‹ κ·œ μ˜ˆμ•½ 생성)κ³Ό 경쟁 μƒνƒœ(race condition)에 빠질 수 μžˆμŠ΅λ‹ˆλ‹€. 이둜 인해 OptimisticLockingFailureException이 λ°œμƒν•˜μ—¬ μš”μ²­μ΄ μ‹€νŒ¨ν•  수 있으며, μ΄λŠ” λΆ„μ‚° 락을 λ„μž…ν•œ 본래의 λͺ©μ μ— λΆ€ν•©ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

이 문제λ₯Ό ν•΄κ²°ν•˜κΈ° μœ„ν•΄μ„œλŠ” κΈ°μ‘΄ StoreRemainκ³Ό μƒˆλ‘œμš΄ StoreRemain λͺ¨λ‘μ— λŒ€ν•΄ 락을 νšλ“ν•΄μ•Ό ν•©λ‹ˆλ‹€. ꡐ착 μƒνƒœ(deadlock)λ₯Ό λ°©μ§€ν•˜κΈ° μœ„ν•΄ 항상 μΌκ΄€λœ μˆœμ„œ(예: ID μ˜€λ¦„μ°¨μˆœ)둜 락을 νšλ“ν•˜λŠ” 것이 μ€‘μš”ν•©λ‹ˆλ‹€.

}

@Transactional(readOnly = true)
Expand Down
Loading