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..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,4 +23,10 @@ public interface WaitingActivationPort { // 대기열에서 제거 - 취소/만료 시 호출 void remove(UUID restaurantId, String token); + 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 b6e29e1..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,29 +46,36 @@ public class WaitingService { UUID.fromString("00000000-0000-0000-0000-000000000001"); // 대기 등록 - public WaitingResult enterWaiting(EnterWaitingCommand command){ + 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); }); + // 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 순번 조회 - 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; + } } // 상태 조회 @@ -99,6 +106,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 상태인지 검증 - 예약 서비스가 예약 전 호출 @@ -143,6 +152,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()) { @@ -170,16 +182,23 @@ public void activateNextBatch(UUID restaurantId){ waiting.activate(); waitingRepository.save(waiting); + activeSaved = true; + + // ACTIVE 전환 후 예약 완료 시 재등록 가능하도록 플래그 제거 + waitingActivationPort.removeUser(restaurantId, waiting.getUserId()); + // 3. 동일한 Outbox 인스턴스 PROCESSED 로 update outbox.markProcessed(LocalDateTime.now()); 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); @@ -194,8 +213,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..8cbd65f 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,15 @@ private String buildSeqKey(UUID restaurantId){ return SEQ_PREFIX + 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; + } + + private void validateToken(String token){ if(token == null || token.isBlank()) throw new WaitingException(WaitingErrorCode.INVALID_TOKEN); @@ -108,4 +118,21 @@ public void remove(UUID restaurantId, String token) { validateToken(token); redisTemplate.opsForZSet().remove(buildKey(restaurantId), token); } + + @Override + 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 + public void removeUser(UUID restaurantId, UUID userId) { + redisTemplate.delete(buildUserKey(restaurantId, userId)); + } }