Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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,49 @@ public String addToWaitingQueue(Long storeId, String userId, Integer partySize,
return reservationId;
}

// 루아 스크립트 사용
public String 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;
return response.size() >= 2 ? String.valueOf(response.get(1)) : null;

}

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

// 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,16 +119,10 @@ 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) 이미 해당 store에 대기 중이면 임대 없이 현재 상태 반환 (중복 요청 허용 X)
WaitingSnapshot waitingSnapshot = waitingUserRedisRepository.getWaitingSnapshot(storeId, userId);
if (waitingSnapshot.getRank() != null) {
throw new DuplicateReservationException();
}

// 1) 임대 획득
Expand All @@ -146,23 +141,25 @@ public WaitingResponseDto registerWaiting(Long storeId, CustomOAuth2User princip
}
}

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

// 3) 확정(holding→active)
waitingPermitLuaRepository.finalizeActive(userId, token, String.valueOf(storeId), reservationId, 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
Original file line number Diff line number Diff line change
Expand Up @@ -29,29 +29,95 @@ public class WaitingPermitLuaRepository {
"redis.call('ZADD', KEYS[1], tonumber(ARGV[1]) + tonumber(ARGV[2]), ARGV[4]);" +
"return 1;";

private static final String ACQUIRE_SCRIPT_V2 =
"""
-- KEYS[1] = holding zset (u:{uid}:holding)
-- KEYS[2] = active set (u:{uid}:active)
-- KEYS[3] = lease count (u:{uid}:lease:cnt)

-- ARGV[1] = nowMs
-- ARGV[2] = leaseMs
-- ARGV[3] = limit
-- ARGV[4] = token
-- ARGV[5] = ttlMs

-- 1) 만료된 holding 정리 + cnt 보정
local removed = redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
if removed and removed > 0 then
local after = redis.call('DECRBY', KEYS[3], removed)
if after < 0 then redis.call('SET', KEYS[3], 0) end
end

-- 2) 정확한 limit 체크 (핵심)
local active = redis.call('SCARD', KEYS[2])
local holding = redis.call('ZCARD', KEYS[1])

if (active + holding) >= tonumber(ARGV[3]) then
return 0
end

-- 3) lease 카운트 증가
redis.call('INCR', KEYS[3])

-- 4) holding 추가
redis.call(
'ZADD',
KEYS[1],
tonumber(ARGV[1]) + tonumber(ARGV[2]),
ARGV[4]
)

-- 5) TTL 동기화
redis.call('PEXPIRE', KEYS[1], ARGV[5])
redis.call('PEXPIRE', KEYS[2], ARGV[5])
redis.call('PEXPIRE', KEYS[3], ARGV[5])

return 1
""";

private static final String FINALIZE_SCRIPT =
"redis.call('ZREM', KEYS[1], ARGV[1]);" +
"redis.call('SADD', KEYS[2], ARGV[2]);" +
"return 1;";

private static final String RELEASE_SCRIPT_V2 =
"""
local removed = redis.call('ZREM', KEYS[1], ARGV[1])
if removed == 1 then
local cnt = redis.call('DECR', KEYS[2])
if cnt < 0 then redis.call('SET', KEYS[2], 0) end
end
return removed
""";

private static final String REMOVE_ACTIVE_SCRIPT =
"""
local removed = redis.call('SREM', KEYS[1], ARGV[1])
if removed == 1 then
local cnt = redis.call('DECR', KEYS[2])
if cnt < 0 then redis.call('SET', KEYS[2], 0) end
end
return removed
""";


public boolean acquireLease(String userId, String token, long nowMs, long leaseMs, int limit, Duration ttlTo3am) {
final String hk = RedisKeyUtils.buildUserHoldingKey(userId); // u:{uid}:holding
final String ak = RedisKeyUtils.buildUserActiveKey(userId); // u:{uid}:active
final String ck = RedisKeyUtils.buildUserLeaseCountKey(userId); // u:{uid}:lease:cnt

Long ok = redis.execute((RedisCallback<Long>) conn -> {
Object res = conn.eval(
ACQUIRE_SCRIPT.getBytes(StandardCharsets.UTF_8),
ACQUIRE_SCRIPT_V2.getBytes(StandardCharsets.UTF_8),
ReturnType.INTEGER,
2,
raw(hk), raw(ak),
3,
raw(hk), raw(ak), raw(ck),
raw(Long.toString(nowMs)),
raw(Long.toString(leaseMs)),
raw(Integer.toString(limit)),
raw(token)
raw(token),
raw(Long.toString(ttlTo3am.toMillis()))
);
// TTL 정렬(스크립트 밖에서)
conn.pExpire(raw(hk), ttlTo3am.toMillis());
conn.pExpire(raw(ak), ttlTo3am.toMillis());
return (Long) res;
});
return ok != null && ok == 1L;
Expand All @@ -77,8 +143,16 @@ public void finalizeActive(String userId, String token, String storeId, String r

public void releaseLease(String userId, String token) {
final String hk = RedisKeyUtils.buildUserHoldingKey(userId);
final String ck = RedisKeyUtils.buildUserLeaseCountKey(userId);

redis.execute((RedisCallback<Void>) conn -> {
conn.zRem(raw(hk), raw(token));
conn.eval(
RELEASE_SCRIPT_V2.getBytes(StandardCharsets.UTF_8),
ReturnType.INTEGER,
2,
raw(hk), raw(ck),
raw(token)
);
return null;
});
}
Expand All @@ -96,9 +170,17 @@ public Set<String> getActiveMembers(String userId) {

public void removeActiveMember(String userId, String storeId, String reservationId) {
final String ak = RedisKeyUtils.buildUserActiveKey(userId);
final String ck = RedisKeyUtils.buildUserLeaseCountKey(userId);
final String member = storeId + ":" + reservationId;

redis.execute((RedisCallback<Void>) conn -> {
conn.sRem(raw(ak), raw(member));
conn.eval(
REMOVE_ACTIVE_SCRIPT.getBytes(StandardCharsets.UTF_8),
ReturnType.INTEGER,
2,
raw(ak), raw(ck),
raw(member)
);
return null;
});
}
Expand Down