From 2ef30177c8b5fbea969d48b3ac0b69f122a7c464 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sat, 7 Feb 2026 00:04:07 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=EB=B3=80=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waiting/redis/WaitingIdempotencyRepository.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java index 791b3880..50f4f1f4 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java @@ -21,22 +21,25 @@ public class WaitingIdempotencyRepository { private static final Duration TTL = Duration.ofMinutes(10); + // 멱등키 조회 메서드 public Optional findByKey(String key) { - String value = redisTemplate.opsForValue().get(key); + String idempotencyValue = redisTemplate.opsForValue().get(key); - if (value == null) { + if (idempotencyValue == null) { return Optional.empty(); } try { return Optional.of( - objectMapper.readValue(value, WaitingIdempotencyValue.class) + objectMapper.readValue(idempotencyValue, WaitingIdempotencyValue.class) ); } catch (Exception e) { throw new IllegalArgumentException("Failed to deserialize value from Redis", e); } } + + // 멱등키 저장 메서드 public void saveIdempotencyValue(String key, RegisterWaitingResponse response) { WaitingIdempotencyValue waitingIdempotencyValue = new WaitingIdempotencyValue( "COMPLETED", From c90e3c710ced76f598caf3641aa6d707fa4657f8 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sat, 7 Feb 2026 00:04:30 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/WaitingRedisRepository.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java index f5f69441..b0b74bc2 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java @@ -166,22 +166,6 @@ public void removeWaiting(Long storeId, Long userId) { } } - // 웨이팅 등록 요청 시 멱등키 검증 - public void idempotentKeyKeyExists(String idempotentKey, String status) { - Boolean success = redisTemplate.opsForValue() - .setIfAbsent( - idempotentKey, - status, - Duration.ofSeconds(10) - ); - - - // TODO 멱등하지 않은 요청 응답값 검토 필요 - if (Boolean.FALSE.equals(success)) { - throw new AlreadyWaitingException(); - } - } - public void incrementAndCheckWaitingLimit(Long userId, Long maxLimit) { String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); From a3a03d4a8ec7a90f1abc29e784c015077e6ec808 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sat, 7 Feb 2026 00:04:51 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20=EB=A9=B1=EB=93=B1=ED=82=A4?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waiting/service/WaitingService.java | 72 ++++++++++++------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java index 822d31c3..cb2e7ff0 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java @@ -59,13 +59,10 @@ public class WaitingService { @Transactional public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, String publicCode, RegisterWaitingRequest waitingRequest, HttpServletRequest httpServletRequest) { - String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); - - // TODO 멱등성 검증 로직 점검 필요 - Optional existingIdempotencyValue = waitingIdempotencyRepository.findByKey(idempotentKey); - if (existingIdempotencyValue.isPresent()) { - log.info("Existing idempotency key found: {}", idempotentKey); - return existingIdempotencyValue.get().getResponse(); + RegisterWaitingResponse registerWaitingResponse = validateIdempotency(httpServletRequest); + if (registerWaitingResponse != null) { + log.info("Idempotent request detected. Returning existing response."); + return registerWaitingResponse; } // TODO 유저 및 주점 존재 검증은 공통으로 많이 쓰이니 AOP로 빼는게 좋을 듯 @@ -75,19 +72,15 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri User user = userRepository.findById(oAuth2User.getUserId()) .orElseThrow(UserNotFoundException::new); + // 일일 가능 웨이팅 최대 개수 초과 검증 + // TODO race condition 발생 가능성 점검 필요, DB 저장 로직 실패 시 롤백 처리 필요 + waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L); + // 웨이팅 고유 번호 생성 - YYYYMMDD-storeId-sequence number 일련 번호 Long storeId = store.getStoreId(); LocalDateTime timestamp = LocalDateTime.now(); String waitingNumber = generateWaitingNumber(storeId, timestamp); - // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 - // waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.WAITING.name()); - - - // 일일 가능 웨이팅 최대 개수 초과 검증 - // TODO race condition 발생 가능성 점검 필요 - waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L); - // DB에 상태 값 저장 Reservation reservation = Reservation.builder() .reservationNumber(waitingNumber) @@ -115,8 +108,8 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri .partySize(waitingRequest.getPartySize()) .build(); - // 멱등키가 있다면 멱등 응답 저장 - waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response); + // TODO 멱등키 응답 실패 시 어떻게 처리할 지 점검 필요 + saveIdempotencyResponse(httpServletRequest.getHeader("Idempotency-Key"), response); return response; } @@ -131,8 +124,11 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p .orElseThrow(UserNotFoundException::new); // TODO 멱등키 검증 로직 점검 필요 - // String idempotentKey = generateIdempotentKey(storeId, user.getId()); - // waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.CANCELLED.name()); + // Optional existingIdempotencyValue = waitingIdempotencyRepository.findByKey(idempotentKey); + // if (existingIdempotencyValue.isPresent()) { + // log.info("Existing idempotency key found: {}", idempotentKey); + // return existingIdempotencyValue.get().getResponse(); + // } // DB 웨이팅 상태 취소 처리 Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber()) @@ -143,28 +139,56 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p // Redis 대기열 취소 이벤트 발행 waitingRedisRepository.removeWaiting(storeId, user.getId()); - return CancelWaitingResponse.builder() + CancelWaitingResponse response = CancelWaitingResponse.builder() .waitingNumber(reservation.getReservationNumber()) .storeId(storeId) .reservationStatus(reservation.getStatus()) .canceledAt(reservation.getUpdatedAt()) .message("대기 취소가 완료되었습니다.") .build(); + + // 멱등키가 있다면 멱등 응답 저장 + // waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response); + + return response; } + // 멱등키 검증 메서드 + private RegisterWaitingResponse validateIdempotency(HttpServletRequest httpServletRequest) { + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + if (idempotentKey == null || idempotentKey.isBlank()) { + return null; + } + + // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 + // TODO 멱등성 검증 로직 점검 필요 + return waitingIdempotencyRepository.findByKey(idempotentKey) + .map(WaitingIdempotencyValue::getResponse) + .orElse(null); + } + + // 멱등키 응답 저장 메서드 + private void saveIdempotencyResponse(String idempotentKey, RegisterWaitingResponse response) { + if (idempotentKey != null && !idempotentKey.isBlank()) { + waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response); + } + } + + // 현재 대기 인원 수 조회 public GetWaitingSizeResponse getWaitingCount(CustomOAuth2User oAuth2User, String publicCode) { Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) .orElseThrow(StoreNotFoundException::new); - Long storeId = store.getStoreId(); - User user = userRepository.findById(oAuth2User.getUserId()) .orElseThrow(UserNotFoundException::new); Department department = departmentRepository.findById(store.getDepartmentId()) .orElseThrow(DepartmentNotFoundException::new); + Long storeId = store.getStoreId(); + Long waitingCount = waitingRedisRepository.getWaitingCount(storeId); return GetWaitingSizeResponse.builder() @@ -190,8 +214,4 @@ private String generateWaitingNumber(Long storeId, LocalDateTime timestamp) { // 4) 최종 ID 조합 return today + "-" + storeId + "-" + seqStr; } - - private String generateIdempotentKey(Long storeId, Long userId) { - return "idempotentKey" + ":" + storeId + ":" + userId; - } } From 08c7bfd1d2f6536a8f0c07fd776aad391ab03e17 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sat, 7 Feb 2026 00:05:10 +0900 Subject: [PATCH 04/10] =?UTF-8?q?test:=20=EC=9B=A8=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=9C=A0=EB=8B=9B=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waiting/service/WaitingServiceTest.java | 214 ++++++++++-------- 1 file changed, 122 insertions(+), 92 deletions(-) diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java index 61a0cd4a..d7b1fe2c 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,6 +16,7 @@ import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; import com.nowait.domaincorerdb.reservation.entity.Reservation; @@ -21,6 +25,7 @@ import com.nowait.domaincorerdb.store.repository.StoreRepository; import com.nowait.domaincorerdb.user.entity.User; import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; @@ -42,115 +47,140 @@ class WaitingServiceTest { @Mock private ReservationRepository reservationRepository; @Mock - WaitingIdempotencyRepository waitingIdempotencyRepository; + private WaitingIdempotencyRepository waitingIdempotencyRepository; + @Mock + HttpServletRequest httpServletRequest; + @Mock + CustomOAuth2User customOAuth2User; + + private static final String IDEMPOTENCY_KEY = "550e8400-e29b-41d4-a716-446655440000"; + + @BeforeEach + void setUp() { + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + } @Test - @DisplayName("웨이팅 정상 등록 테스트") - void registerWaiting() { + @DisplayName("멱등키 있는 경우 미리 저장된 응답이 반환 되는지 테스트") + void registerWaiting_idempotentKeyExists() { + // given + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + RegisterWaitingResponse idempotentResponse = RegisterWaitingResponse.builder() + .waitingNumber("20260201-2-0001") + .partySize(4) + .build(); + + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.of(new WaitingIdempotencyValue( + "COMPLETED", + idempotentResponse + ))); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + ); + + // then + assertThat(response).isSameAs(idempotentResponse); + + verify(storeRepository, never()).findByPublicCodeAndDeletedFalse(anyString()); + verify(userRepository, never()).findById(anyLong()); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + } + + @Test + @DisplayName("웨이팅 개수 제한 초과 시 예외 발생 테스트") + void registerWaiting_exceedWaitingLimit() { // given CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); RegisterWaitingRequest request = new RegisterWaitingRequest(4); + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + + + Long userId = 10L; String publicCode = "ZiVXAD1vVr5b"; - Long userId = 1L; - HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); Store store = Store.builder().publicCode(publicCode).build(); User user = User.builder().id(userId).build(); - // when when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); - when(customOAuth2User.getUserId()).thenReturn(userId); + when(customOAuth2User.getUserId()).thenReturn(10L); when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); - RegisterWaitingResponse response = waitingService.registerWaiting(customOAuth2User, publicCode, request, httpServletRequest); + + doThrow(new UserWaitingLimitExceededException()) + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(10L, 3L); // then - verify(waitingIdempotencyRepository).findByKey(anyString()); - verify(reservationRepository).save(any(Reservation.class)); - verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + )).isInstanceOf(UserWaitingLimitExceededException.class); - assertThat(response.getPartySize()).isEqualTo(4); - assertThat(response.getWaitingNumber()).isNotNull(); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); } - // @Test - // @DisplayName("DB 저장 실패 시 Redis 대기열 추가 이벤트 실행 되지 않음") - // void registerWaiting_dbSaveFail() { - // // given - // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); - // RegisterWaitingRequest request = new RegisterWaitingRequest(4); - // - // Long storeId = 1L; - // Long userId = 1L; - // - // Store store = Store.builder().storeId(storeId).build(); - // User user = User.builder().id(userId).build(); - // - // // when - // when(customOAuth2User.getUserId()).thenReturn(userId); - // when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); - // when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - // - // when(reservationRepository.save(any(Reservation.class))).thenThrow(new RuntimeException("DB 저장 실패")); - // - // // then - // assertThatThrownBy(() -> - // waitingService.registerWaiting(customOAuth2User, storeId, request) - // ).isInstanceOf(RuntimeException.class); - // - // verify(waitingRedisRepository).idempotentKeyKeyExists(anyString(), eq("WAITING")); - // verify(eventPublisher, never()).publishEvent(any()); - // } - // - // @Test - // @DisplayName("없는 주점에 웨이팅 등록 시도 시 예외 발생") - // void registerWaiting_storeNotFound() { - // // given - // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); - // RegisterWaitingRequest request = new RegisterWaitingRequest(4); - // - // Long storeId = -1L; // 존재하지 않는 주점 ID - // Long userId = 1L; - // - // // when - // when(storeRepository.findById(storeId)).thenReturn(Optional.empty()); - // - // // then - // assertThatThrownBy(() -> - // waitingService.registerWaiting(customOAuth2User, storeId, request) - // ).isInstanceOf(StoreNotFoundException.class); - // - // verify(userRepository, never()).findById(anyLong()); - // verify(waitingRedisRepository, never()).idempotentKeyKeyExists(anyString(), eq("WAITING")); - // verify(reservationRepository, never()).save(any(Reservation.class)); - // verify(eventPublisher, never()).publishEvent(any()); - // } - // - // @Test - // @DisplayName("없는 유저가 웨이팅 등록 시도 시 예외 발생") - // void registerWaiting_userNotFound() { - // // given - // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); - // RegisterWaitingRequest request = new RegisterWaitingRequest(4); - // - // Long storeId = 1L; - // Long userId = -1L; // 존재 하지 않는 유저 ID - // - // Store store = Store.builder().storeId(storeId).build(); - // - // // when - // when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); - // when(customOAuth2User.getUserId()).thenReturn(userId); - // when(userRepository.findById(userId)).thenReturn(Optional.empty()); - // - // // then - // assertThatThrownBy(() -> - // waitingService.registerWaiting(customOAuth2User, storeId, request) - // ).isInstanceOf(UserNotFoundException.class); - // - // verify(waitingRedisRepository, never()).idempotentKeyKeyExists(anyString(), eq("WAITING")); - // verify(reservationRepository, never()).save(any(Reservation.class)); - // verify(eventPublisher, never()).publishEvent(any()); - // } + + @Test + @DisplayName("웨이팅 정상 등록 시 DB 저장, 이벤트 발생, 멱등 응답 저장 수행") + void registerWaiting_success() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + + Long userId = 10L; + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + + doNothing() + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(userId, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + // then + assertThat(response).isNotNull(); + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotBlank(); + + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); + verify(reservationRepository).save(any(Reservation.class)); + verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + verify(waitingIdempotencyRepository).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + } } From b594ca53d55925d2f2b1c423c8eca097d1b9566c Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sat, 7 Feb 2026 23:09:34 +0900 Subject: [PATCH 05/10] =?UTF-8?q?test:=20=EC=9B=A8=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=98=88=EC=99=B8=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waiting/service/WaitingServiceTest.java | 152 ++++++++++++++---- 1 file changed, 124 insertions(+), 28 deletions(-) diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java index d7b1fe2c..2e407abc 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java @@ -22,8 +22,10 @@ import com.nowait.domaincorerdb.reservation.entity.Reservation; import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; import com.nowait.domaincorerdb.store.repository.StoreRepository; import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; import com.nowait.domaincorerdb.user.repository.UserRepository; import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; @@ -49,9 +51,9 @@ class WaitingServiceTest { @Mock private WaitingIdempotencyRepository waitingIdempotencyRepository; @Mock - HttpServletRequest httpServletRequest; + private HttpServletRequest httpServletRequest; @Mock - CustomOAuth2User customOAuth2User; + private CustomOAuth2User customOAuth2User; private static final String IDEMPOTENCY_KEY = "550e8400-e29b-41d4-a716-446655440000"; @@ -97,8 +99,8 @@ void registerWaiting_idempotentKeyExists() { } @Test - @DisplayName("웨이팅 개수 제한 초과 시 예외 발생 테스트") - void registerWaiting_exceedWaitingLimit() { + @DisplayName("웨이팅 정상 등록 시 DB 저장, 이벤트 발생, 멱등 응답 저장 수행") + void registerWaiting_success() { // given CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); RegisterWaitingRequest request = new RegisterWaitingRequest(4); @@ -107,40 +109,82 @@ void registerWaiting_exceedWaitingLimit() { when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) .thenReturn(Optional.empty()); - Long userId = 10L; String publicCode = "ZiVXAD1vVr5b"; Store store = Store.builder().publicCode(publicCode).build(); User user = User.builder().id(userId).build(); - // when when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); when(customOAuth2User.getUserId()).thenReturn(10L); when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); - doThrow(new UserWaitingLimitExceededException()) + doNothing() .when(waitingRedisRepository) - .incrementAndCheckWaitingLimit(10L, 3L); + .incrementAndCheckWaitingLimit(userId, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); // then + assertThat(response).isNotNull(); + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotBlank(); + + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); + verify(reservationRepository).save(any(Reservation.class)); + verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + verify(waitingIdempotencyRepository).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + } + + @Test + @DisplayName("DB 저장 중 예외 발생 시 이벤트 발행 및 멱등 저장이 수행되지 않음") + void registerWaiting_dbSaveException() { + // given + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + Store store = Store.builder().publicCode("ZiVXAD1vVr5b").build(); + User user = User.builder().id(10L).build(); + + when(storeRepository.findByPublicCodeAndDeletedFalse("ZiVXAD1vVr5b")) + .thenReturn(Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(10L)) + .thenReturn(Optional.of(user)); + + doNothing().when(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + doThrow(new RuntimeException("DB 저장 실패")) + .when(reservationRepository) + .save(any(Reservation.class)); + + + // when & then assertThatThrownBy(() -> waitingService.registerWaiting( customOAuth2User, "ZiVXAD1vVr5b", request, httpServletRequest - )).isInstanceOf(UserWaitingLimitExceededException.class); + )).isInstanceOf(RuntimeException.class); - verify(reservationRepository, never()).save(any(Reservation.class)); - verify(eventPublisher, never()).publishEvent(any()); + verify(eventPublisher, never()).publishEvent(any(AddWaitingRegisterEvent.class)); verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); - verify(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); } - @Test - @DisplayName("웨이팅 정상 등록 시 DB 저장, 이벤트 발생, 멱등 응답 저장 수행") - void registerWaiting_success() { + @DisplayName("웨이팅 개수 제한 초과 시 예외 발생 테스트") + void registerWaiting_exceedWaitingLimit() { // given CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); RegisterWaitingRequest request = new RegisterWaitingRequest(4); @@ -149,38 +193,90 @@ void registerWaiting_success() { when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) .thenReturn(Optional.empty()); + Long userId = 10L; String publicCode = "ZiVXAD1vVr5b"; Store store = Store.builder().publicCode(publicCode).build(); User user = User.builder().id(userId).build(); + // when when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); when(customOAuth2User.getUserId()).thenReturn(10L); when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); - doNothing() + doThrow(new UserWaitingLimitExceededException()) .when(waitingRedisRepository) - .incrementAndCheckWaitingLimit(userId, 3L); + .incrementAndCheckWaitingLimit(10L, 3L); - when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + // then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + )).isInstanceOf(UserWaitingLimitExceededException.class); + + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); + } + + @Test + @DisplayName("존재하지 않는 publicCode이면 StoreNotFoundException 발생") + void registerWaiting_storeNotFound() { + // given + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); // when - RegisterWaitingResponse response = waitingService.registerWaiting( + when(storeRepository.findByPublicCodeAndDeletedFalse("invalidCode")) + .thenReturn(Optional.empty()); + + // then + assertThatThrownBy(() -> waitingService.registerWaiting( customOAuth2User, - publicCode, + "invalidCode", request, httpServletRequest - ); + )).isInstanceOf(StoreNotFoundException.class); + + verify(userRepository, never()).findById(anyLong()); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("존재하지 않는 userId이면 UserNotFoundException 발생") + void registerWaiting_userNotFound() { + // given + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + + // when + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)) + .thenReturn(Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(10L)) + .thenReturn(Optional.empty()); // then - assertThat(response).isNotNull(); - assertThat(response.getPartySize()).isEqualTo(4); - assertThat(response.getWaitingNumber()).isNotBlank(); + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + )).isInstanceOf(UserNotFoundException.class); - verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); - verify(reservationRepository).save(any(Reservation.class)); - verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); - verify(waitingIdempotencyRepository).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); } } From 9adb0c7ce7d00e5f65c73d9a2e2f5d0e947b5fc5 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sat, 7 Feb 2026 23:16:17 +0900 Subject: [PATCH 06/10] =?UTF-8?q?test:=20=EC=9B=A8=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=98=88=EC=99=B8=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waiting/service/WaitingServiceTest.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java index 2e407abc..0a0a9075 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java @@ -98,6 +98,50 @@ void registerWaiting_idempotentKeyExists() { verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); } + @Test + @DisplayName("Idempotency-Key가 blank이면 멱등 로직을 타지 않는다") + void registerWaiting_idempotentKeyNotExists() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(" "); + + Long userId = 10L; + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + + doNothing() + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(userId, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + // then + assertThat(response).isNotNull(); + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotBlank(); + + verify(waitingIdempotencyRepository, never()).findByKey(any()); + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); + verify(reservationRepository).save(any(Reservation.class)); + verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + } + @Test @DisplayName("웨이팅 정상 등록 시 DB 저장, 이벤트 발생, 멱등 응답 저장 수행") void registerWaiting_success() { From b4c0c4bf1a0e6dfc8b6f90413913f72200f3d524 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sat, 7 Feb 2026 23:19:02 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20import=EB=AC=B8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nowait/applicationuser/waiting/service/WaitingService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java index cb2e7ff0..ef3d8e32 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java @@ -2,7 +2,6 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Optional; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; From 64ea4c67c8a9e6b9f25b51f3c2d8d41c08870cbb Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sun, 8 Feb 2026 13:53:27 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=EB=A9=B1=EB=93=B1=ED=82=A4?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waiting/service/WaitingService.java | 5 +-- .../waiting/service/WaitingServiceTest.java | 44 ------------------- 2 files changed, 1 insertion(+), 48 deletions(-) diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java index ef3d8e32..b92395ea 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java @@ -58,6 +58,7 @@ public class WaitingService { @Transactional public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, String publicCode, RegisterWaitingRequest waitingRequest, HttpServletRequest httpServletRequest) { + // TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등) RegisterWaitingResponse registerWaitingResponse = validateIdempotency(httpServletRequest); if (registerWaitingResponse != null) { log.info("Idempotent request detected. Returning existing response."); @@ -156,10 +157,6 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p private RegisterWaitingResponse validateIdempotency(HttpServletRequest httpServletRequest) { String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); - if (idempotentKey == null || idempotentKey.isBlank()) { - return null; - } - // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 // TODO 멱등성 검증 로직 점검 필요 return waitingIdempotencyRepository.findByKey(idempotentKey) diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java index 0a0a9075..2e407abc 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java @@ -98,50 +98,6 @@ void registerWaiting_idempotentKeyExists() { verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); } - @Test - @DisplayName("Idempotency-Key가 blank이면 멱등 로직을 타지 않는다") - void registerWaiting_idempotentKeyNotExists() { - // given - CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); - RegisterWaitingRequest request = new RegisterWaitingRequest(4); - - when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(" "); - - Long userId = 10L; - String publicCode = "ZiVXAD1vVr5b"; - - Store store = Store.builder().publicCode(publicCode).build(); - User user = User.builder().id(userId).build(); - - when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); - when(customOAuth2User.getUserId()).thenReturn(10L); - when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); - - doNothing() - .when(waitingRedisRepository) - .incrementAndCheckWaitingLimit(userId, 3L); - - when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); - - // when - RegisterWaitingResponse response = waitingService.registerWaiting( - customOAuth2User, - publicCode, - request, - httpServletRequest - ); - - // then - assertThat(response).isNotNull(); - assertThat(response.getPartySize()).isEqualTo(4); - assertThat(response.getWaitingNumber()).isNotBlank(); - - verify(waitingIdempotencyRepository, never()).findByKey(any()); - verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); - verify(reservationRepository).save(any(Reservation.class)); - verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); - } - @Test @DisplayName("웨이팅 정상 등록 시 DB 저장, 이벤트 발생, 멱등 응답 저장 수행") void registerWaiting_success() { From 8996da78cfe8982b7c5531617fe8d73352f717e2 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sun, 8 Feb 2026 15:11:20 +0900 Subject: [PATCH 09/10] =?UTF-8?q?test:=20=EB=A9=B1=EB=93=B1=ED=82=A4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waiting/service/WaitingServiceTest.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java index 2e407abc..cb5b8f84 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java @@ -98,6 +98,49 @@ void registerWaiting_idempotentKeyExists() { verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); } + @Test + @DisplayName("Idempotency-Key가 blank이면 멱등 로직을 타지 않는다") + void registerWaiting_idempotentKeyNotExists() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(" "); + + Long userId = 10L; + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + + doNothing() + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(userId, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + // then + assertThat(response).isNotNull(); + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotBlank(); + + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); + verify(reservationRepository).save(any(Reservation.class)); + verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + } + @Test @DisplayName("웨이팅 정상 등록 시 DB 저장, 이벤트 발생, 멱등 응답 저장 수행") void registerWaiting_success() { From 3388353946fb43806e2674710260e930a2174d7b Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sun, 8 Feb 2026 15:11:45 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EC=9B=A8=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EB=A9=B1?= =?UTF-8?q?=EB=93=B1=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../waiting/controller/WaitingController.java | 6 ++-- .../dto/WaitingCancelIdempotencyValue.java | 13 +++++++ .../redis/WaitingIdempotencyRepository.java | 36 ++++++++++++++++++- .../waiting/service/WaitingService.java | 28 ++++++++++----- 4 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java index e49dda65..dbc2ed03 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java @@ -63,12 +63,14 @@ public ResponseEntity registerWaiting( public ResponseEntity cancelWaiting( @AuthenticationPrincipal CustomOAuth2User customOAuth2User, @PathVariable String publicCode, - @RequestBody CancelWaitingRequest request + @RequestBody CancelWaitingRequest request, + HttpServletRequest httpServletRequest ) { CancelWaitingResponse cancelWaitingResponse = waitingService.cancelWaiting( customOAuth2User, publicCode, - request + request, + httpServletRequest ); return ResponseEntity diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java new file mode 100644 index 00000000..92bb2346 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class WaitingCancelIdempotencyValue { + private String state; + private CancelWaitingResponse response; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java index 50f4f1f4..3fe4a3d8 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java @@ -7,7 +7,9 @@ import org.springframework.stereotype.Repository; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; import lombok.RequiredArgsConstructor; @@ -38,8 +40,25 @@ public Optional findByKey(String key) { } } + // 멱등키 조회 메서드 + public Optional findByCancelKey(String key) { + String idempotencyValue = redisTemplate.opsForValue().get(key); + + if (idempotencyValue == null) { + return Optional.empty(); + } + + try { + return Optional.of( + objectMapper.readValue(idempotencyValue, WaitingCancelIdempotencyValue.class) + ); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to deserialize value from Redis", e); + } + } - // 멱등키 저장 메서드 + + // 멱등키 저장 메서드 - 대기 등록 public void saveIdempotencyValue(String key, RegisterWaitingResponse response) { WaitingIdempotencyValue waitingIdempotencyValue = new WaitingIdempotencyValue( "COMPLETED", @@ -53,4 +72,19 @@ public void saveIdempotencyValue(String key, RegisterWaitingResponse response) { throw new IllegalArgumentException("Failed to serialize value for Redis", e); } } + + // 멱등키 저장 메서드 - 대기 취소 + public void saveCancelIdempotencyValue(String key, CancelWaitingResponse response) { + WaitingCancelIdempotencyValue waitingIdempotencyValue = new WaitingCancelIdempotencyValue( + "COMPLETED", + response + ); + + try { + String jsonValue = objectMapper.writeValueAsString(waitingIdempotencyValue); + redisTemplate.opsForValue().set(key, jsonValue, TTL); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to serialize value for Redis", e); + } + } } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java index b92395ea..cfcd2476 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java @@ -12,6 +12,7 @@ import com.nowait.applicationuser.waiting.dto.GetWaitingSizeResponse; import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; @@ -115,7 +116,13 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri } @Transactional - public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request) { + public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request, HttpServletRequest httpServletRequest) { + // TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등) + CancelWaitingResponse cancelWaitingResponse = validateCancelIdempotency(httpServletRequest); + if (cancelWaitingResponse != null) { + log.info("Idempotent request detected. Returning existing response."); + return cancelWaitingResponse; + } Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode).orElseThrow(StoreNotFoundException::new); Long storeId = store.getStoreId(); @@ -123,13 +130,6 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p User user = userRepository.findById(oAuth2User.getUserId()) .orElseThrow(UserNotFoundException::new); - // TODO 멱등키 검증 로직 점검 필요 - // Optional existingIdempotencyValue = waitingIdempotencyRepository.findByKey(idempotentKey); - // if (existingIdempotencyValue.isPresent()) { - // log.info("Existing idempotency key found: {}", idempotentKey); - // return existingIdempotencyValue.get().getResponse(); - // } - // DB 웨이팅 상태 취소 처리 Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber()) .orElseThrow(ReservationNotFoundException::new); @@ -148,7 +148,7 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p .build(); // 멱등키가 있다면 멱등 응답 저장 - // waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response); + waitingIdempotencyRepository.saveCancelIdempotencyValue(httpServletRequest.getHeader("Idempotency-Key"), response); return response; } @@ -164,6 +164,16 @@ private RegisterWaitingResponse validateIdempotency(HttpServletRequest httpServl .orElse(null); } + private CancelWaitingResponse validateCancelIdempotency(HttpServletRequest httpServletRequest) { + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 + // TODO 멱등성 검증 로직 점검 필요 + return waitingIdempotencyRepository.findByCancelKey(idempotentKey) + .map(WaitingCancelIdempotencyValue::getResponse) + .orElse(null); + } + // 멱등키 응답 저장 메서드 private void saveIdempotencyResponse(String idempotentKey, RegisterWaitingResponse response) { if (idempotentKey != null && !idempotentKey.isBlank()) {