Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
725d823
refactor: 중복 의존성 제거
Jjiggu Jan 29, 2026
3a98b5b
test: 웨이팅 등록 유닛 테스트 로직 추가
Jjiggu Jan 29, 2026
2283de1
refactor: WaitingService 레거시 개선 및 전체 리팩토링
Jjiggu Jan 29, 2026
40eedd0
refactor: 레거시 개선 및 전체 리팩토링
Jjiggu Jan 29, 2026
bad3544
refactor: 레거시 개선 및 전체 리팩토링
Jjiggu Jan 29, 2026
384c4dd
feat: 웨이팅 등록 시 redis 동작 로직 이벤트 기반으로 분리
Jjiggu Jan 29, 2026
347ec51
feat: 웨이팅 중복 예외 추가
Jjiggu Jan 29, 2026
b275e50
feat: 웨이팅 레거시 개선을 위한 일부 로직 추가
Jjiggu Jan 29, 2026
c19cb11
refactor: 파라미터 AddWaitingRegisterEvent로 변경
Jjiggu Feb 1, 2026
9602ba9
feat: 웨이팅 멱등성 관련 예외처리 추가
Jjiggu Feb 1, 2026
7f883f8
feat: 웨이팅 soft delete 추가
Jjiggu Feb 1, 2026
b47a5ab
feat: 웨이팅 취소 기능 추가 findReservationByReservationNumber
Jjiggu Feb 1, 2026
5736dcc
feat: 웨이팅 취소 dto 추가
Jjiggu Feb 1, 2026
83a37a9
feat: 웨이팅 개수 제한 키 추가
Jjiggu Feb 1, 2026
851a464
feat: 웨이팅 멱등성 관련 예외처리 추가
Jjiggu Feb 1, 2026
432bbd4
refactor: 테스트 임시 주석처리
Jjiggu Feb 1, 2026
3dd0023
feat: 웨이팅 개수 제한 에러 추가
Jjiggu Feb 1, 2026
de996a6
feat: 웨이팅 개수 제한 에러 추가
Jjiggu Feb 1, 2026
e40ee5d
feat: 웨이팅 취소 로직 추가 및 멱등성 처리 로직 추가
Jjiggu Feb 1, 2026
9a94ba5
refactor: 사용하지 않는 코드 삭제
Jjiggu Feb 1, 2026
a248f73
refactor: 임시 주석처리
Jjiggu Feb 1, 2026
d303b9d
refactor: 임시 주석처리
Jjiggu Feb 1, 2026
117fa93
refactor: 코드 매핑 수정
Jjiggu Feb 1, 2026
d00b1c5
refactor: 멱등성 조회 로직 순서 조정
Jjiggu Feb 1, 2026
ca8feec
Merge pull request #356 from GTable/refactor/#348-Waiting-codeReview
Jjiggu Feb 1, 2026
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 @@ -43,6 +43,7 @@
import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException;
import com.nowait.domaincorerdb.order.exception.OrderUpdateUnauthorizedException;
import com.nowait.domaincorerdb.order.exception.OrderViewUnauthorizedException;
import com.nowait.domaincorerdb.reservation.exception.AlreadyDeletedWaitingException;
import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException;
import com.nowait.domaincorerdb.reservation.exception.InvalidReservationParameterException;
import com.nowait.domaincorerdb.reservation.exception.InvalidReservationStatusTransitionException;
Expand All @@ -54,7 +55,6 @@
import com.nowait.domaincorerdb.reservation.exception.ReservationUpdateUnauthorizedException;
import com.nowait.domaincorerdb.reservation.exception.ReservationViewUnauthorizedException;
import com.nowait.domaincorerdb.reservation.exception.UnsupportedReservationStatusException;
import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException;
import com.nowait.domaincorerdb.store.exception.StoreDeleteUnauthorizedException;
import com.nowait.domaincorerdb.store.exception.StoreImageEmptyException;
import com.nowait.domaincorerdb.store.exception.StoreImageNotFoundException;
Expand Down Expand Up @@ -325,12 +325,13 @@ public ErrorResponse unsupportedReservationStatusException(UnsupportedReservatio
return new ErrorResponse(e.getMessage(), UNSUPPORTED_RESERVATION_STATUS.getCode());
}

@ResponseStatus(BAD_REQUEST)
@ExceptionHandler(UserWaitingLimitExceededException.class)
public ErrorResponse userWaitingLimitExceededException(UserWaitingLimitExceededException e, WebRequest request) {

@ResponseStatus(CONFLICT)
@ExceptionHandler(AlreadyDeletedWaitingException.class)
public ErrorResponse alreadyDeletedWaitingException(AlreadyDeletedWaitingException e, WebRequest request) {
alarm(e, request);
log.error("userWaitingLimitExceededException", e);
return new ErrorResponse(e.getMessage(), USER_WAITING_LIMIT_EXCEEDED.getCode());
log.error("alreadyDeletedWaitingException", e);
return new ErrorResponse(e.getMessage(), ALREADY_DELETED_RESERVATION.getCode());
}


Expand Down
3 changes: 1 addition & 2 deletions nowait-app-user-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ dependencies {

// SPRING SECURITY
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

// jwt
Expand Down Expand Up @@ -85,4 +84,4 @@ dependencies {

test {
useJUnitPlatform()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,17 @@
import com.nowait.domaincorerdb.order.exception.DuplicateOrderException;
import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException;
import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException;
import com.nowait.domaincorerdb.reservation.exception.AlreadyDeletedWaitingException;
import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException;
import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException;
import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException;
import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException;
import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException;
import com.nowait.domaincorerdb.store.exception.StoreNotFoundException;
import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException;
import com.nowait.domaincorerdb.storepayment.exception.StorePaymentNotFoundException;
import com.nowait.domaincorerdb.token.exception.BusinessException;
import com.nowait.domaincorerdb.user.exception.UserNotFoundException;
import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException;
import com.nowait.domainuserrdb.bookmark.exception.AlreadyDeletedBookmarkException;
import com.nowait.domainuserrdb.bookmark.exception.BookmarkNotFoundException;
import com.nowait.domainuserrdb.bookmark.exception.BookmarkOwnerMismatchException;
Expand Down Expand Up @@ -223,6 +224,14 @@ public ErrorResponse duplicateReservationException(DuplicateReservationException
return new ErrorResponse(e.getMessage(), DUPLICATE_RESERVATION.getCode());
}

@ResponseStatus(CONFLICT)
@ExceptionHandler(AlreadyDeletedWaitingException.class)
public ErrorResponse alreadyWaitingException(AlreadyDeletedWaitingException e, WebRequest request) {
alarm(e, request);
log.error("alreadyWaitingException", e);
return new ErrorResponse(e.getMessage(), ALREADY_DELETED_RESERVATION.getCode());
}

@ResponseStatus(BAD_REQUEST)
@ExceptionHandler(StoreWaitingDisabledException.class)
public ErrorResponse storeWaitingDisabledException(StoreWaitingDisabledException e, WebRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.nowait.applicationuser.reservation.service;

import static com.nowait.common.exception.ErrorMessage.*;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
Expand Down Expand Up @@ -36,7 +34,6 @@
import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException;
import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException;
import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException;
import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException;
import com.nowait.domaincorerdb.reservation.repository.ReservationRepository;
import com.nowait.domaincorerdb.store.entity.ImageType;
import com.nowait.domaincorerdb.store.entity.Store;
Expand All @@ -49,6 +46,7 @@
import com.nowait.domaincorerdb.user.exception.UserNotFoundException;
import com.nowait.domaincorerdb.user.repository.UserRepository;
import com.nowait.domaincoreredis.common.util.RedisKeyUtils;
import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException;
import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository;
import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.nowait.applicationuser.waiting.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.nowait.applicationuser.waiting.dto.CancelWaitingRequest;
import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse;
import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest;
import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse;
import com.nowait.applicationuser.waiting.service.WaitingService;
import com.nowait.common.api.ApiUtils;
import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

@Tag(name = "Waiting API", description = "예약 API -리팩토링 중-")
@RestController
@RequestMapping("/v2/users/me/waitings")
@RequiredArgsConstructor
public class WaitingController {
private final WaitingService waitingService;

/**
* 대기열 리팩토링용 API
*/
@PostMapping("/{publicCode}")
@Operation(summary = "대기열 리팩토링용 API", description = "대기열 리팩토링용 API")
public ResponseEntity<?> registerWaiting(
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
@PathVariable String publicCode,
@RequestBody RegisterWaitingRequest request,
HttpServletRequest httpServletRequest
) {
RegisterWaitingResponse registerWaitingResponse = waitingService.registerWaiting(
customOAuth2User,
publicCode,
request,
httpServletRequest
);

return ResponseEntity
.ok()
.body(
ApiUtils.success(
registerWaitingResponse
)
);
}

@DeleteMapping("/{publicCode}")
@Operation(summary = "대기열 리팩토링용 API", description = "대기 취소")
public ResponseEntity<?> cancelWaiting(
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
@PathVariable String publicCode,
@RequestBody CancelWaitingRequest request
) {
CancelWaitingResponse cancelWaitingResponse = waitingService.cancelWaiting(
customOAuth2User,
publicCode,
request
);

return ResponseEntity
.ok()
.body(
ApiUtils.success(
cancelWaitingResponse
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.nowait.applicationuser.waiting.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CancelWaitingRequest {
private String waitingNumber;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.nowait.applicationuser.waiting.dto;

import java.time.LocalDateTime;

import com.nowait.common.enums.ReservationStatus;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class CancelWaitingResponse {
private String waitingNumber;
private Long storeId;
private ReservationStatus reservationStatus;
private LocalDateTime canceledAt;
private String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.nowait.applicationuser.waiting.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RegisterWaitingRequest {
private Integer partySize;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.nowait.applicationuser.waiting.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class RegisterWaitingResponse {
private final String waitingNumber;
private final int partySize;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.nowait.applicationuser.waiting.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class WaitingIdempotencyValue {
private String state;
private RegisterWaitingResponse response;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.nowait.applicationuser.waiting.event;

import java.time.LocalDateTime;

import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class AddWaitingRegisterEvent {
@NotNull
private final Long storeId;
@NotNull
private final Long userId;
@NotNull
private final LocalDateTime timestamp;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.nowait.applicationuser.waiting.event.listener;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent;
import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class AddWaitingRegisterListener {

private final WaitingRedisRepository waitingRedisRepository;

@Async
@TransactionalEventListener(
classes = AddWaitingRegisterEvent.class,
phase = TransactionPhase.AFTER_COMMIT
)
public void onAddWaitingRegister(AddWaitingRegisterEvent event) {
waitingRedisRepository.addWaiting(event.getStoreId(), event.getUserId(), event.getTimestamp());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.nowait.applicationuser.waiting.redis;

import java.time.Duration;
import java.util.Optional;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse;
import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class WaitingIdempotencyRepository {

private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;

private static final Duration TTL = Duration.ofMinutes(10);

public Optional<WaitingIdempotencyValue> findByKey(String key) {
String value = redisTemplate.opsForValue().get(key);

if (value == null) {
return Optional.empty();
}

try {
return Optional.of(
objectMapper.readValue(value, 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",
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);
}
}
}
Comment on lines +15 to +53
Copy link

@coderabbitai coderabbitai bot Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for usage patterns of findByKey and saveIdempotencyValue
rg -n -A 10 "findByKey|saveIdempotencyValue" --type java

Repository: GTable/NoWait_BE_MultiModule

Length of output: 7881


🏁 Script executed:

#!/bin/bash
# Check for distributed lock usage or synchronization around idempotency handling
rg -n -B 5 "saveIdempotencyValue" --type java | head -50

# Check if there are any `@Lock` or synchronization mechanisms
rg -n "@Lock|@Transactional|synchronized" --type java -A 3 | grep -A 3 "registerWaiting"

Repository: GTable/NoWait_BE_MultiModule

Length of output: 2276


findByKeysaveIdempotencyValue 사이의 race condition을 반드시 해결해야 합니다.

WaitingService의 registerWaiting 메서드에서 멱등성 검증과 저장이 분리되어 있어 race condition이 실제로 발생합니다. 동시에 같은 idempotent key로 요청이 들어올 경우, 두 요청 모두 74줄의 findByKey에서 존재하지 않음을 확인한 후, 80~110줄의 처리 로직을 모두 실행하고 113줄에서 saveIdempotencyValue를 호출하게 됩니다. 이로 인해 중복 처리가 발생합니다.

setIfAbsent (SETNX)를 사용하거나 Lua 스크립트를 통해 멱등키 존재 확인과 저장을 원자적 연산으로 처리하세요. 또는 분산 락(distributed lock)을 도입하여 동시 접근을 제어해야 합니다.

🤖 Prompt for AI Agents
In
`@nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java`
around lines 15 - 53, The code has a race between
WaitingService.registerWaiting's calls to WaitingIdempotencyRepository.findByKey
and saveIdempotencyValue causing duplicate processing; fix by making the
existence-check-and-store atomic in WaitingIdempotencyRepository: replace the
separate findByKey/saveIdempotencyValue flow with an atomic SETNX-based
operation (use redisTemplate.opsForValue().setIfAbsent or execute a Redis SET
with NX and TTL via redisTemplate.execute / RedisCallback) that stores the
serialized WaitingIdempotencyValue only if the key is absent, returning whether
the caller should proceed or the existing value (or alternatively implement a
Lua script performing GET/SET atomically or use a distributed lock around
registerWaiting), and update WaitingService.registerWaiting to rely on the new
atomic method instead of calling findByKey then saveIdempotencyValue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 사항 인지하고 있어. #348 하위 이슈로 등록해줘

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@Jjiggu, 이해했습니다. #348 하위 이슈로 등록하겠습니다.


🏁 Script executed:

#!/bin/bash
# Create a GitHub issue for the race condition in idempotency handling

gh issue create \
  --title "웨이팅 등록 멱등성 처리의 race condition 해결" \
  --body "## 문제 설명

\`WaitingIdempotencyRepository\`의 \`findByKey\`와 \`saveIdempotencyValue\` 메서드 사이에 race condition이 존재합니다.

\`WaitingService.registerWaiting\` 메서드에서 멱등성 검증과 저장이 분리되어 있어, 동시에 같은 idempotent key로 요청이 들어올 경우 두 요청 모두 멱등키가 존재하지 않음을 확인한 후 처리 로직을 실행하게 되어 중복 처리가 발생할 수 있습니다.

## 관련 파일
- \`nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java\`
- \`nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java\`

## 참고 링크
- Related issue: `#348`
- PR: https://github.com/GTable/NoWait_BE_MultiModule/pull/349
- Review comment: https://github.com/GTable/NoWait_BE_MultiModule/pull/349#discussion_r2751174978
- Reported by: `@Jjiggu`

## 고려사항
- 원자적 연산 구현 필요 (SETNX, Lua 스크립트, 분산 락 등)" \
  --assignee Jjiggu \
  --label "bug,enhancement"

Length of output: 58


@Jjiggu, 이슈를 생성했습니다!

Issue #350: 웨이팅 등록 멱등성 처리의 race condition 해결
🔗 #350

#348과 연관된 하위 이슈로 등록되었으며, 관련 PR과 리뷰 코멘트 링크가 포함되어 있습니다.

Loading