diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java index cf09ca46..78c82358 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java @@ -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); diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingSnapshot.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingSnapshot.java new file mode 100644 index 00000000..7f75bdbb --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingSnapshot.java @@ -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; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingUserRedisRepository.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingUserRedisRepository.java index d11e5a30..bd340511 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingUserRedisRepository.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingUserRedisRepository.java @@ -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; @@ -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 // 특정 주점에 대한 예약 등록 @@ -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 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 response = (List) 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; @@ -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 results = redisTemplate.executePipelined((RedisCallback) 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); + } } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java index d4ec8ab0..ca69a598 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java @@ -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; @@ -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; @@ -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, @@ -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; } } diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java index 1f81d2f8..b27505c9 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java @@ -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)에 사용할 키 접두사 */ diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.java index ffab7c60..71fb8fc9 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.java @@ -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) 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; @@ -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) 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; }); } @@ -96,9 +170,17 @@ public Set 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) 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; }); }