From 1fd8dc790695412f550a0d4ced8c0b088337d6b9 Mon Sep 17 00:00:00 2001 From: Jinyoung-Kim96 Date: Tue, 19 May 2026 09:54:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=8C=80=EA=B8=B0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=A4=91=EB=B3=B5=20=EC=B2=B4=ED=81=AC=20Redis=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EC=9C=BC=EB=A1=9C=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/WaitingActivationPort.java | 10 ++++++ .../application/service/WaitingService.java | 27 ++++++++++++---- .../domain/repository/WaitingRepository.java | 2 -- .../jpa/WaitingRepositoryImpl.java | 14 ++------ .../redis/RedisWaitingActivationAdapter.java | 32 +++++++++++++++++++ 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/michelet/waiting/application/port/WaitingActivationPort.java b/src/main/java/com/michelet/waiting/application/port/WaitingActivationPort.java index 2145550..99307f7 100644 --- a/src/main/java/com/michelet/waiting/application/port/WaitingActivationPort.java +++ b/src/main/java/com/michelet/waiting/application/port/WaitingActivationPort.java @@ -23,4 +23,14 @@ public interface WaitingActivationPort { // 대기열에서 제거 - 취소/만료 시 호출 void remove(UUID restaurantId, String token); + // 유저 중복 등록 여부 확인 ( Redis ) + boolean existsUser(UUID restaurantId, UUID userId); + + // 유저 플래그 저장 ( 대기 등록 시 ) + void addUser(UUID restaurantId, UUID userId); + + // 유저 플래그 제거 ( 취소/완료/만료 시 ) + void removeUser(UUID restaurantId, UUID userId); + + } diff --git a/src/main/java/com/michelet/waiting/application/service/WaitingService.java b/src/main/java/com/michelet/waiting/application/service/WaitingService.java index b6e29e1..f005678 100644 --- a/src/main/java/com/michelet/waiting/application/service/WaitingService.java +++ b/src/main/java/com/michelet/waiting/application/service/WaitingService.java @@ -48,11 +48,9 @@ public class WaitingService { // 대기 등록 public WaitingResult enterWaiting(EnterWaitingCommand command){ - waitingRepository.findWaitingByRestaurantId(command.restaurantId()) - .stream() - .filter(w -> w.getUserId().equals(command.userId())) - .findAny() - .ifPresent(w ->{throw new WaitingException(WaitingErrorCode.ALREADY_IN); }); + if (waitingActivationPort.existsUser(command.restaurantId(), command.userId())) { + throw new WaitingException(WaitingErrorCode.ALREADY_IN); + } Waiting waiting = Waiting.create(command.userId(), command.restaurantId()); @@ -61,6 +59,10 @@ public WaitingResult enterWaiting(EnterWaitingCommand command){ // redis 순번 등록 waitingActivationPort.add(command.restaurantId(), saved.getToken().value()); + + // 이후 중복 등록 시도 시 Redis에서 바로 차단 + waitingActivationPort.addUser(command.restaurantId(), command.userId()); + // redis 순번 조회 Long position = waitingActivationPort.getPosition( command.restaurantId(), waiting.getToken().value() @@ -99,6 +101,8 @@ public void cancelWaiting(UUID waitingId, UUID deletedBy){ waitingRepository.save(waiting); waitingRepository.softDelete(waitingId, deletedBy); waitingActivationPort.remove(waiting.getRestaurantId(), waiting.getToken().value()); + // 취소 후 재등록 가능하도록 플래그 제거 + waitingActivationPort.removeUser(waiting.getRestaurantId(), waiting.getUserId()); } // ACTIVE 상태인지 검증 - 예약 서비스가 예약 전 호출 @@ -168,7 +172,10 @@ public void activateNextBatch(UUID restaurantId){ // 2. DB ACTIVE 전환 waiting.activate(); + waitingRepository.save(waiting); + // ACTIVE 전환 후 예약 완료 시 재등록 가능하도록 플래그 제거 + waitingActivationPort.removeUser(restaurantId, waiting.getUserId()); // 3. 동일한 Outbox 인스턴스 PROCESSED 로 update outbox.markProcessed(LocalDateTime.now()); @@ -194,8 +201,16 @@ public void expireWaitings(){ expired.forEach(waiting -> { waiting.expire(); waitingRepository.save(waiting); - waitingActivationPort.remove(waiting.getRestaurantId(), waiting.getToken().value()); + waitingActivationPort.remove( + waiting.getRestaurantId(), + waiting.getToken().value()); + waitingActivationPort.removeUser( + waiting.getRestaurantId(), + waiting.getUserId() + ); }); + + } public void retryPendingOutbox(){ diff --git a/src/main/java/com/michelet/waiting/domain/repository/WaitingRepository.java b/src/main/java/com/michelet/waiting/domain/repository/WaitingRepository.java index 0b5f534..a3ded42 100644 --- a/src/main/java/com/michelet/waiting/domain/repository/WaitingRepository.java +++ b/src/main/java/com/michelet/waiting/domain/repository/WaitingRepository.java @@ -1,7 +1,6 @@ package com.michelet.waiting.domain.repository; import com.michelet.waiting.domain.entity.Waiting; - import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -13,7 +12,6 @@ public interface WaitingRepository { Optional findByToken(String token); Optional findById(UUID id); Optional findByAccessToken(String accessToken); - List findWaitingByRestaurantId(UUID restaurantId); List findExpiredActives(LocalDateTime expiredBefore); List findDistinctRestaurantIdsWithWaiting(); void softDelete(UUID waitingId, UUID deletedBy); diff --git a/src/main/java/com/michelet/waiting/infrastructure/persistence/jpa/WaitingRepositoryImpl.java b/src/main/java/com/michelet/waiting/infrastructure/persistence/jpa/WaitingRepositoryImpl.java index e02a2dd..34859f7 100644 --- a/src/main/java/com/michelet/waiting/infrastructure/persistence/jpa/WaitingRepositoryImpl.java +++ b/src/main/java/com/michelet/waiting/infrastructure/persistence/jpa/WaitingRepositoryImpl.java @@ -1,16 +1,14 @@ package com.michelet.waiting.infrastructure.persistence.jpa; import com.michelet.waiting.domain.entity.Waiting; -import com.michelet.waiting.domain.enums.WaitingStatus; import com.michelet.waiting.domain.repository.WaitingRepository; import com.michelet.waiting.infrastructure.persistence.querydsl.WaitingQueryRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor @@ -42,14 +40,6 @@ public Optional findByAccessToken(String accessToken) { .map(WaitingJpaEntity::toDomain); } - @Override - public List findWaitingByRestaurantId(UUID restaurantId) { - return jpa.findByRestaurantIdAndStatusAndDeletedAtIsNull(restaurantId, WaitingStatus.WAITING) - .stream() - .map(WaitingJpaEntity::toDomain) - .toList(); - } - @Override public List findExpiredActives(LocalDateTime expiredBefore) { return queryRepository.findExpiredActives(expiredBefore) diff --git a/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java b/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java index 2f53574..46dfb5c 100644 --- a/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java +++ b/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java @@ -18,6 +18,7 @@ public class RedisWaitingActivationAdapter implements WaitingActivationPort { private final RedisTemplate redisTemplate; + private static final String USER_PREFIX = "waiting:user:"; private static final String PREFIX = "waiting:queue:"; private static final String SEQ_PREFIX = "waiting:seq:"; @@ -35,6 +36,13 @@ private String buildSeqKey(UUID restaurantId){ return SEQ_PREFIX + restaurantId; } + // userKey create - "waiting:user:{restaurantId}:{userId}" + // 유저별 식당 대기 등록 여부 관리 + private String buildUserKey(UUID restaurantId, UUID userId) { + return USER_PREFIX + restaurantId + ":" + userId; + } + + private void validateToken(String token){ if(token == null || token.isBlank()) throw new WaitingException(WaitingErrorCode.INVALID_TOKEN); @@ -108,4 +116,28 @@ public void remove(UUID restaurantId, String token) { validateToken(token); redisTemplate.opsForZSet().remove(buildKey(restaurantId), token); } + + // 유저 중복 등록 여부 확인 + // Redis key 존재 여부로 O(1) 체크 + @Override + public boolean existsUser(UUID restaurantId, UUID userId) { + return redisTemplate.hasKey(buildUserKey(restaurantId, userId)); + } + + // 유저 플래그 저장 + // 대기 등록 성공 시 호출 + @Override + public void addUser(UUID restaurantId, UUID userId) { + redisTemplate.opsForValue().set( + buildUserKey(restaurantId, userId), "1" + ); + } + + + // 유저 플래그 제거 + // 취소/완료/만료/ACTIVE 전환 시 호출 + @Override + public void removeUser(UUID restaurantId, UUID userId) { + redisTemplate.delete(buildUserKey(restaurantId, userId)); + } } From 2a918221a59acd500d730cf7afe4d4efeffe2a76 Mon Sep 17 00:00:00 2001 From: Jinyoung-Kim96 Date: Tue, 19 May 2026 10:34:38 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20null=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/redis/RedisWaitingActivationAdapter.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java b/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java index 46dfb5c..7de6f80 100644 --- a/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java +++ b/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java @@ -39,6 +39,8 @@ private String buildSeqKey(UUID restaurantId){ // userKey create - "waiting:user:{restaurantId}:{userId}" // 유저별 식당 대기 등록 여부 관리 private String buildUserKey(UUID restaurantId, UUID userId) { + Objects.requireNonNull(restaurantId, "restaurantId must not be null"); + Objects.requireNonNull(userId, "userId must not be null"); return USER_PREFIX + restaurantId + ":" + userId; } From bae045ffd61e473efaf21c6da1c1ea19493eed51 Mon Sep 17 00:00:00 2001 From: Jinyoung-Kim96 Date: Tue, 19 May 2026 10:35:00 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=B3=B5=EA=B5=AC=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/WaitingService.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/michelet/waiting/application/service/WaitingService.java b/src/main/java/com/michelet/waiting/application/service/WaitingService.java index f005678..5d66f88 100644 --- a/src/main/java/com/michelet/waiting/application/service/WaitingService.java +++ b/src/main/java/com/michelet/waiting/application/service/WaitingService.java @@ -147,6 +147,9 @@ public void activateNextBatch(UUID restaurantId){ List scoredTokens = waitingActivationPort.popNextTokensWithScore(restaurantId,batchSize); for(ScoredToken scoredToken : scoredTokens){ + + boolean activeSaved = false; + try{ Optional waitingOpt = waitingRepository.findByToken(scoredToken.token()); if (waitingOpt.isEmpty()) { @@ -172,8 +175,10 @@ public void activateNextBatch(UUID restaurantId){ // 2. DB ACTIVE 전환 waiting.activate(); - waitingRepository.save(waiting); + + activeSaved = true; + // ACTIVE 전환 후 예약 완료 시 재등록 가능하도록 플래그 제거 waitingActivationPort.removeUser(restaurantId, waiting.getUserId()); @@ -182,11 +187,13 @@ public void activateNextBatch(UUID restaurantId){ waitingOutboxRepository.update(outbox); }catch (Exception e){ // 4. DB 저장 실패 시 원래 score로 Redis 복구 - waitingActivationPort.addWithScore( - restaurantId, - scoredToken.token(), - scoredToken.score() - ); + if (!activeSaved) { + waitingActivationPort.addWithScore( + restaurantId, + scoredToken.token(), + scoredToken.score() + ); + } log.warn("[스케줄러] ACTIVE 전환 실패 Redis 복구 - token : {}", scoredToken.token(),e); From 0e825036a480cc4d929e91d83c7b2dd33fabe313 Mon Sep 17 00:00:00 2001 From: Jinyoung-Kim96 Date: Tue, 19 May 2026 10:42:29 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20TOCTOU=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/WaitingActivationPort.java | 6 +-- .../application/service/WaitingService.java | 37 +++++++++++-------- .../redis/RedisWaitingActivationAdapter.java | 21 ++++------- 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/michelet/waiting/application/port/WaitingActivationPort.java b/src/main/java/com/michelet/waiting/application/port/WaitingActivationPort.java index 99307f7..3ab3c8d 100644 --- a/src/main/java/com/michelet/waiting/application/port/WaitingActivationPort.java +++ b/src/main/java/com/michelet/waiting/application/port/WaitingActivationPort.java @@ -23,11 +23,7 @@ public interface WaitingActivationPort { // 대기열에서 제거 - 취소/만료 시 호출 void remove(UUID restaurantId, String token); - // 유저 중복 등록 여부 확인 ( Redis ) - boolean existsUser(UUID restaurantId, UUID userId); - - // 유저 플래그 저장 ( 대기 등록 시 ) - void addUser(UUID restaurantId, UUID userId); + boolean tryAddUser(UUID restaurantId, UUID userId); // 유저 플래그 제거 ( 취소/완료/만료 시 ) void removeUser(UUID restaurantId, UUID userId); diff --git a/src/main/java/com/michelet/waiting/application/service/WaitingService.java b/src/main/java/com/michelet/waiting/application/service/WaitingService.java index 5d66f88..0aad245 100644 --- a/src/main/java/com/michelet/waiting/application/service/WaitingService.java +++ b/src/main/java/com/michelet/waiting/application/service/WaitingService.java @@ -46,31 +46,36 @@ public class WaitingService { UUID.fromString("00000000-0000-0000-0000-000000000001"); // 대기 등록 - public WaitingResult enterWaiting(EnterWaitingCommand command){ + public WaitingResult enterWaiting(EnterWaitingCommand command) { - if (waitingActivationPort.existsUser(command.restaurantId(), command.userId())) { + // Redis SETNX로 원자적 중복 체크 + 플래그 저장 + // false 반환 시 이미 대기 중인 유저 + if (!waitingActivationPort.tryAddUser(command.restaurantId(), command.userId())) { throw new WaitingException(WaitingErrorCode.ALREADY_IN); } - Waiting waiting = Waiting.create(command.userId(), command.restaurantId()); + try { + Waiting waiting = Waiting.create(command.userId(), command.restaurantId()); - // DB 저장 - Waiting saved = waitingRepository.save(waiting); + // DB 저장 + Waiting saved = waitingRepository.save(waiting); - // redis 순번 등록 - waitingActivationPort.add(command.restaurantId(), saved.getToken().value()); - - // 이후 중복 등록 시도 시 Redis에서 바로 차단 - waitingActivationPort.addUser(command.restaurantId(), command.userId()); - - // redis 순번 조회 - Long position = waitingActivationPort.getPosition( - command.restaurantId(), waiting.getToken().value() - ); + // Redis 순번 등록 + waitingActivationPort.add(command.restaurantId(), saved.getToken().value()); + // Redis 순번 조회 + Long position = waitingActivationPort.getPosition( + command.restaurantId(), waiting.getToken().value() + ); - return WaitingResult.of(saved, position); + return WaitingResult.of(saved, position); + } catch (Exception e) { + // DB 저장 실패 시 Redis 유저 플래그 제거 + // 재등록 가능하도록 + waitingActivationPort.removeUser(command.restaurantId(), command.userId()); + throw e; + } } // 상태 조회 diff --git a/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java b/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java index 7de6f80..8cbd65f 100644 --- a/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java +++ b/src/main/java/com/michelet/waiting/infrastructure/redis/RedisWaitingActivationAdapter.java @@ -119,23 +119,16 @@ public void remove(UUID restaurantId, String token) { redisTemplate.opsForZSet().remove(buildKey(restaurantId), token); } - // 유저 중복 등록 여부 확인 - // Redis key 존재 여부로 O(1) 체크 @Override - public boolean existsUser(UUID restaurantId, UUID userId) { - return redisTemplate.hasKey(buildUserKey(restaurantId, userId)); - } - - // 유저 플래그 저장 - // 대기 등록 성공 시 호출 - @Override - public void addUser(UUID restaurantId, UUID userId) { - redisTemplate.opsForValue().set( - buildUserKey(restaurantId, userId), "1" - ); + public boolean tryAddUser(UUID restaurantId, UUID userId) { + Objects.requireNonNull(restaurantId, "restaurantId must not be null"); + Objects.requireNonNull(userId, "userId must not be null"); + String key = buildUserKey(restaurantId, userId); + // SETNX — 키가 없을 때만 저장, 있으면 false 반환 + Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1"); + return Boolean.TRUE.equals(result); } - // 유저 플래그 제거 // 취소/완료/만료/ACTIVE 전환 시 호출 @Override