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 @@ -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,13 @@
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;
private final boolean isNew;
}
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,52 @@
@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) ZADD NX
local added = redis.call('ZADD', KEYS[1], 'NX', ARGV[2], ARGV[1])

local isNew = 0
local reservationId

if added == 1 then
isNew = 1
local seq = redis.call('INCR', KEYS[5])
local seqStr = string.format('%04d', seq)
reservationId = ARGV[5] .. '-' .. ARGV[4] .. '-' .. seqStr

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')
else
reservationId = redis.call('HGET', KEYS[4], ARGV[1])
end

local rank = redis.call('ZRANK', KEYS[1], ARGV[1])

-- TTL: 키가 처음 생성된 경우만
if redis.call('PTTL', KEYS[1]) == -1 then
for i = 1, #KEYS do
redis.call('PEXPIRE', KEYS[i], ARGV[6])
end
end

return {isNew, rank, ARGV[3], reservationId}
""";

// 중복 등록 방지: 이미 있으면 추가X
// 특정 주점에 대한 예약 등록
Expand Down Expand Up @@ -68,6 +116,61 @@ 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 == null || response.size() < 4) return null;

boolean isNew = ((Long) response.get(0)) == 1L;
Long rank = response.get(1) == null ? null : (Long) response.get(1);
Integer ps = response.get(2) == null ? null : Integer.valueOf(response.get(2).toString());
String reservationId = response.get(3) == null ? null : response.get(3).toString();

if (!isNew) {
// 중복 등록: Redis 재조회 없음
return new WaitingSnapshot(rank, ps, reservationId, false);
}

// 신규 등록
return new WaitingSnapshot(rank, ps, reservationId, true);
}

// 예약한 사람이 등록한 동반인원(partySize) 조회
public Integer getPartySize(Long storeId, String userId) {
String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId;
Expand Down Expand Up @@ -214,6 +317,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, false);
}

// 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, false);
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;

Expand All @@ -22,6 +23,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,20 +120,8 @@ 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();
String token = UUID.randomUUID().toString();
int attempts = 0;
while (true) {
boolean ok = waitingPermitLuaRepository.acquireLease(userId, token, System.currentTimeMillis(), LEASE_MS,
Expand All @@ -146,28 +136,57 @@ public WaitingResponseDto registerWaiting(Long storeId, CustomOAuth2User princip
}
}

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

WaitingSnapshot snapshot = null;
try {
// 2) 스토어 큐 등록(기존 메서드 그대로)
long ts = System.currentTimeMillis();
reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, dto.getPartySize(), ts);
if (reservationId == null)

snapshot = waitingUserRedisRepository.addToWaitingQueueLua(
storeId, userId, dto.getPartySize(), ts, ttlTo3am
);

if (snapshot == null || snapshot.getReservationId() == null)
throw new ReservationNumberIssueFailException();

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

if (snapshot.isNew()) {
waitingPermitLuaRepository.finalizeActive(
userId,
token,
String.valueOf(storeId),
snapshot.getReservationId(),
ttlTo3am
);
} else {
// Lua 스크립트 중복 감지 케이스
waitingPermitLuaRepository.releaseLease(userId, token);
throw new DuplicateReservationException();
}

// 3) 확정(holding → active)
WaitingSnapshot after = waitingUserRedisRepository.getWaitingSnapshot(storeId, userId);
if (after == null)
throw new ReservationNumberIssueFailException();

// 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) {
// 실패 시 임대 반납
waitingPermitLuaRepository.releaseLease(userId, token);
if (snapshot == null || (snapshot.isNew())) {
waitingPermitLuaRepository.releaseLease(userId, token);
}
throw 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