Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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 @@ -215,7 +215,7 @@ public ErrorResponse reservationNotFoundException(ReservationNotFoundException e
return new ErrorResponse(e.getMessage(), NOTFOUND_RESERVATION.getCode());
}

@ResponseStatus(BAD_REQUEST)
@ResponseStatus(CONFLICT)
@ExceptionHandler(DuplicateReservationException.class)
public ErrorResponse duplicateReservationException(DuplicateReservationException e, WebRequest request) {
alarm(e, request);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.nowait.applicationuser.reservation.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class WaitingSnapshot {
private final Long rank;
private final Integer partySize;
private final String reservationId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Repository;

import com.nowait.applicationuser.reservation.dto.WaitingSnapshot;
import com.nowait.domaincoreredis.common.util.RedisKeyUtils;

import lombok.RequiredArgsConstructor;
Expand All @@ -28,6 +30,48 @@
@RequiredArgsConstructor
public class WaitingUserRedisRepository {
private final StringRedisTemplate redisTemplate;
private static final String ADD_WAITING_LUA = """
-- KEYS
-- 1: queueKey
-- 2: partyKey
-- 3: statusKey
-- 4: numberMapKey
-- 5: dailySeqKey

-- ARGV
-- 1: userId
-- 2: timestamp (ms)
-- 3: partySize
-- 4: today (YYYYMMDD)
-- 5: storeId
-- 6: ttlMillis

-- 1) 큐 등록 (중복 방지)
local added = redis.call('ZADD', KEYS[1], 'NX', ARGV[2], ARGV[1])
if added == 0 then
local rid = redis.call('HGET', KEYS[4], ARGV[1])
return {0, rid}
end

-- 2) 일일 시퀀스
local seq = redis.call('INCR', KEYS[5])
local seqStr = string.format('%04d', seq)
local reservationId = ARGV[5] .. '-' .. ARGV[4] .. '-' .. seqStr

-- 3) 메타 저장
redis.call('HSET', KEYS[4], ARGV[1], reservationId)
redis.call('HSET', KEYS[2], ARGV[1], ARGV[3])
redis.call('HSET', KEYS[3], ARGV[1], 'WAITING')

-- 4) TTL은 최초 1회만
if redis.call('PTTL', KEYS[1]) < 0 then
for i = 1, #KEYS do
redis.call('PEXPIRE', KEYS[i], ARGV[6])
end
end

return {1, reservationId}
""";

// 중복 등록 방지: 이미 있으면 추가X
// 특정 주점에 대한 예약 등록
Expand Down Expand Up @@ -68,6 +112,62 @@ public String addToWaitingQueue(Long storeId, String userId, Integer partySize,
return reservationId;
}

// 루아 스크립트 사용
public WaitingSnapshot addToWaitingQueueLua(
Long storeId,
String userId,
Integer partySize,
long ts,
Duration ttl
) {
String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId;
String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId;
String statusKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId;
String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId);

String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String dailySeqKey = RedisKeyUtils.buildReservationSeqKey(storeId) + ":" + today;

List<String> keys = List.of(
queueKey,
partyKey,
statusKey,
numberMapKey,
dailySeqKey
);

Object result = redisTemplate.execute(
new DefaultRedisScript<>(ADD_WAITING_LUA, List.class),
keys,
userId,
String.valueOf(ts),
String.valueOf(partySize),
today,
String.valueOf(storeId),
String.valueOf(ttl.toMillis())
);

if (result == null) return null;

@SuppressWarnings("unchecked")
List<Object> response = (List<Object>) result;
if (response.size() < 2) return null;

Long added = response.get(0) instanceof Long l ? l : Long.parseLong(String.valueOf(response.get(0)));
String reservationId = String.valueOf(response.get(1));

// added == 0이면 중복, 1이면 신규 등록
// 중복인 경우 rank를 null로 반환하여 구분 가능하게 함
if (added == 0) {
Integer existingPartySize = getPartySize(storeId, userId);
Long existingRank = getRank(storeId, userId);
return new WaitingSnapshot(existingRank, existingPartySize, reservationId);
}

Long actualRank = getRank(storeId, userId);
return new WaitingSnapshot(actualRank, partySize, reservationId);
}

// 예약한 사람이 등록한 동반인원(partySize) 조회
public Integer getPartySize(Long storeId, String userId) {
String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId;
Expand Down Expand Up @@ -214,6 +314,52 @@ public String GenerateReservationNumber(String seqKey, Long storeId) {

return reservationId;
}

public WaitingSnapshot getWaitingSnapshot(Long storeId, String userId) {
String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId;
String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId;
String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId);

List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) conn -> {
byte[] qk = redisTemplate.getStringSerializer().serialize(queueKey);
byte[] pk = redisTemplate.getStringSerializer().serialize(partyKey);
byte[] nk = redisTemplate.getStringSerializer().serialize(numberMapKey);
byte[] uid = redisTemplate.getStringSerializer().serialize(userId);

conn.zRank(qk, uid);
conn.hGet(pk, uid);
conn.hGet(nk, uid);
return null;
});

if (results == null || results.size() < 3) {
return new WaitingSnapshot(null, null, null);
}

// 1) rank
Long rank = (results.get(0) instanceof Long r) ? r : null;

// 2) partySize
Integer partySize = null;
Object psObj = results.get(1);
if (psObj instanceof String s) {
partySize = Integer.valueOf(s);
} else if (psObj instanceof byte[] b) {
String deserialized = redisTemplate.getStringSerializer().deserialize(b);
partySize = deserialized != null ? Integer.valueOf(deserialized) : null;
}

// 3) reservationId
String reservationId = null;
Object ridObj = results.get(2);
if (ridObj instanceof String s) {
reservationId = s;
} else if (ridObj instanceof byte[] b) {
reservationId = redisTemplate.getStringSerializer().deserialize(b);
}

return new WaitingSnapshot(rank, partySize, reservationId);
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto;
import com.nowait.applicationuser.reservation.dto.ReservationCreateResponseDto;
import com.nowait.applicationuser.reservation.dto.WaitingResponseDto;
import com.nowait.applicationuser.reservation.dto.WaitingSnapshot;
import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository;
import com.nowait.common.enums.ReservationStatus;
import com.nowait.common.enums.Role;
Expand Down Expand Up @@ -118,18 +119,6 @@ public WaitingResponseDto registerWaiting(Long storeId, CustomOAuth2User princip
String userId = user.getId().toString();
Duration ttlTo3am = waitingUserRedisRepository.calculateTTLUntilNext03AM();

// 1) 이미 해당 store에 대기 중이면 임대 없이 현재 상태 반환 (중복 요청 허용)
if (Boolean.TRUE.equals(waitingUserRedisRepository.isUserWaiting(storeId, userId))) {
Long rank = waitingUserRedisRepository.getRank(storeId, userId);
Integer ps = waitingUserRedisRepository.getPartySize(storeId, userId);
String reservationId = waitingUserRedisRepository.getReservationId(storeId, userId);
return WaitingResponseDto.builder()
.reservationNumber(reservationId)
.rank(rank == null ? -1 : rank.intValue() + 1)
.partySize(ps == null ? 0 : ps)
.build();
}

// 1) 임대 획득
String token = java.util.UUID.randomUUID().toString();
int attempts = 0;
Expand All @@ -146,23 +135,30 @@ public WaitingResponseDto registerWaiting(Long storeId, CustomOAuth2User princip
}
}

String reservationId = null;
// 2) 임대 획득 후 중복 체크
WaitingSnapshot existingSnapshot = waitingUserRedisRepository.getWaitingSnapshot(storeId, userId);
if (existingSnapshot.getRank() != null) {
waitingPermitLuaRepository.releaseLease(userId, token);
throw new DuplicateReservationException();
}

try {
// 2) 스토어 큐 등록(기존 메서드 그대로)
long ts = System.currentTimeMillis();
reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, dto.getPartySize(), ts);
if (reservationId == null)
existingSnapshot = waitingUserRedisRepository.addToWaitingQueueLua(storeId, userId, dto.getPartySize(), ts, ttlTo3am);
if (existingSnapshot.getReservationId().isEmpty())
throw new ReservationNumberIssueFailException();

// 3) 확정(holding→active)
waitingPermitLuaRepository.finalizeActive(userId, token, String.valueOf(storeId), reservationId, ttlTo3am);
// 3) 확정(holding → active)
waitingPermitLuaRepository.finalizeActive(userId, token, String.valueOf(storeId), existingSnapshot.getReservationId(), ttlTo3am);

WaitingSnapshot after = waitingUserRedisRepository.getWaitingSnapshot(storeId, userId);

// 4) 응답
Long rank = waitingUserRedisRepository.getRank(storeId, userId);
return WaitingResponseDto.builder()
.reservationNumber(reservationId)
.rank(rank == null ? -1 : rank.intValue() + 1)
.partySize(dto.getPartySize() == null ? 0 : dto.getPartySize())
.reservationNumber(after.getReservationId())
.rank(after.getRank() == null ? -1 : after.getRank().intValue() + 1)
.partySize(after.getPartySize() == null ? 0 : after.getPartySize())
.build();

} catch (RuntimeException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ public static String buildReservationUserKey(Long storeId) {
return String.format("reservation:user:%d", storeId);
}

public static String buildUserLeaseCountKey(String userId) { return "userID:{" + userId + "}:lease:cnt"; }

/**
* 대기 호출 시각(hash)에 사용할 키 접두사
*/
Expand Down
Loading