Conversation
|
Caution Review failedThe pull request is closed. Walkthrough대기 등록 및 취소 기능을 추가하고, 레디스 기반 멱등성·대기 제한 로직과 예외/엔티티(예약 취소 관련) 변경을 도입하며 몇몇 테스트와 빌드 의존성을 조정합니다. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Controller as WaitingController
participant Service as WaitingService
participant Idempotency as WaitingIdempotencyRepository
participant Redis as WaitingRedisRepository
participant Reservation as ReservationRepository
participant Publisher as ApplicationEventPublisher
User->>Controller: POST /v2/users/me/waitings/{publicCode}
Controller->>Service: registerWaiting(user, code, request, httpReq)
Service->>Idempotency: findByKey(idempotentKey)
alt cached
Idempotency-->>Service: cached response
Service-->>Controller: return cached response
else not cached
Service->>Redis: idempotentKeyKeyExists(key)
Service->>Redis: incrementAndCheckWaitingLimit(userId, limit)
Service->>Redis: incrementDailySequence(dailySeqKey)
Service->>Reservation: save(reservation WAITING)
Service->>Publisher: publishEvent(AddWaitingRegisterEvent)
Publisher->>Redis: addWaiting(storeId,userId,timestamp)
Service->>Idempotency: saveIdempotencyValue(key,response)
Service-->>Controller: return RegisterWaitingResponse
end
Controller-->>User: 200 OK
sequenceDiagram
actor User
participant Controller as WaitingController
participant Service as WaitingService
participant Reservation as ReservationRepository
participant Redis as WaitingRedisRepository
User->>Controller: DELETE /v2/users/me/waitings/{publicCode}
Controller->>Service: cancelWaiting(user, code, request)
Service->>Reservation: findReservationByReservationNumber(waitingNumber)
Reservation-->>Service: reservation
Service->>Reservation: markAsCancelled(updatedAt)
alt already cancelled
Reservation-->>Service: throw AlreadyDeletedWaitingException
Service-->>Controller: propagate -> global handler
Controller-->>User: 409 CONFLICT
else cancelled
Service->>Redis: removeWaiting(storeId,userId)
Service-->>Controller: return CancelWaitingResponse
Controller-->>User: 200 OK
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 12
🤖 Fix all issues with AI agents
In
`@nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java`:
- Around line 227-233: In GlobalExceptionHandler, the alreadyWaitingException
handler returns the wrong error code; update the ErrorResponse construction in
the alreadyWaitingException(AlreadyDeletedWaitingException e, WebRequest
request) method to use the ALREADY_DELETED_RESERVATION code constant instead of
DUPLICATE_RESERVATION so the response message and code align (keep alarm(e,
request) and logging as-is).
In
`@nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java`:
- Around line 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.
In
`@nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java`:
- Around line 131-138: The current cancel flow in WaitingService uses
reservationRepository.findReservationByReservationNumber(request.getWaitingNumber())
without verifying ownership, allowing IDOR; change the lookup to include storeId
and userId (or validate after fetch) so only reservations matching storeId and
user.getId() can be cancelled. Specifically, update the query or replace the
call so Reservation is retrieved via a method that accepts (reservationNumber,
storeId, userId) or after fetching assert
reservation.getStoreId().equals(storeId) &&
reservation.getUserId().equals(user.getId()) and throw
ReservationNotFoundException/AccessDenied when mismatch, then proceed to call
reservation.markAsCancelled(...) and
waitingRedisRepository.removeWaiting(storeId, user.getId()).
- Around line 81-96: The Redis counters (incrementAndCheckWaitingLimit and
incrementDailySequence) are being incremented before persisting the Reservation
(Reservation.builder()/reservationRepository.save), which causes leak on DB
failure; modify the flow to ensure atomicity by either using an atomic Lua
script (see WaitingPermitLuaRepository) to perform Redis increments and
reservation permit checks together, or wrap the DB save in a try-catch and
decrement/compensate the Redis counters on any persistence failure, or move
Redis increments to after successful reservationRepository.save; update
WaitingService to use one of these approaches so Redis changes are rolled back
or applied atomically with the DB write.
- Around line 64-78: Move idempotency validation ahead of waitingNumber/sequence
generation in WaitingService: first read and validate the Idempotency-Key header
(ensure it's non-null and non-empty) before calling generateWaitingNumber or any
Redis sequence; if present, use waitingIdempotencyRepository.findByKey(...) to
return the stored response. Replace the current find→save pattern used by
saveIdempotencyValue and any direct Redis writes with an atomic set-if-absent
(e.g., redisTemplate.opsForValue().setIfAbsent or a Lua script) to ensure only
one request wins the race; apply the same null/empty-key guard to all places
that call waitingIdempotencyRepository.save or waitingRedisRepository so you
never create or query a "null" key and prevent losing sequence numbers on
duplicate requests.
In
`@nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java`:
- Around line 1-188: The concurrent test class ReservationServiceConcurrencyTest
(and its test method concurrentRegisterWaiting) is fully commented out, hiding
concurrency checks; either restore and adapt this test to the new WaitingService
API (update usages of ReservationService.registerWaiting, WaitingResponseDto,
ReservationCreateRequestDto and CustomOAuth2User mocks to match new method
signatures) or explicitly disable it so CI reports a skipped test by adding
`@Disabled`("리팩토링 진행 중 - `#348`") on the class or the concurrentRegisterWaiting
method; ensure references to WaitingUserRedisRepository,
WaitingPermitLuaRepository and Redis setup remain consistent when restoring.
In
`@nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/entity/Reservation.java`:
- Around line 82-93: Reservation.markAsCancelled currently throws
AlreadyDeletedWaitingException while markUpdated(ReservationStatus.CANCELLED)
throws ReservationAlreadyCancelledException, causing inconsistent behavior; fix
by delegating markAsCancelled(LocalDateTime updatedAt) to the existing
markUpdated(ReservationStatus.CANCELLED, LocalDateTime) method (or, if you
prefer the other approach, change markAsCancelled to throw
ReservationAlreadyCancelledException) so both paths use the same validation and
exception handling; update markAsCancelled to call
markUpdated(ReservationStatus.CANCELLED, updatedAt) and remove duplicated status
checks/exception logic in markAsCancelled.
In
`@nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java`:
- Around line 119-126: The expireAt10Minute() currently always returns today at
00:10 which can be in the past; change it to compute the next occurrence of
00:10 (mirror the expireAtNext03() pattern): get ZoneId.of("Asia/Seoul"),
compute now = LocalDateTime.now(zone), build candidate =
now.toLocalDate().atTime(0, 10) and if candidate.isBefore(now) or
!candidate.isAfter(now) add one day to candidate, then convert
candidate.atZone(zone).toInstant() and return Date.from(...). Update the
expireAt10Minute() method accordingly.
In
`@nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java`:
- Around line 212-224: The isWaiting method currently ignores the result of
redisTemplate.opsForZSet().score and always returns true; change it to capture
the Double score returned by
redisTemplate.opsForZSet().score(RedisKeyUtils.buildWaitingKeyPrefix() +
storeId, String.valueOf(userId)) and return Boolean.valueOf(score != null) (or
simply score != null) so the method returns true only when a score exists for
that user in the store waiting ZSet; update WaitingRedisRepository.isWaiting to
use that returned value.
- Around line 185-198: incrementAndCheckWaitingLimit is incrementing a per-user
counter key produced by buildUserWaitingLimitCountKey(String.valueOf(userId))
without setting TTL, so once a user exceeds the limit they can be permanently
blocked; fix by setting an expiry when the counter is first created (i.e., when
increment returns 1) using redisTemplate.expireAt(userWaitingLimitCountKey,
RedisKeyUtils.expireAtNext03()), and ensure you only set the TTL on first
increment (do not overwrite existing TTL) while keeping the current null check
and the decrement+UserWaitingLimitExceededException behavior.
- Around line 125-146: The addWaiting method currently performs two separate
redisTemplate.opsForZSet().add calls (on queueKey and userListKey) which can
leave state inconsistent if one succeeds and the other fails; change this to an
atomic operation by using a Redis Lua script (EVAL) that performs both ZADD
calls together (add queueKey and userListKey with the same score) or implement a
compensating rollback in the catch that removes the previously-added key, and
apply the same atomic approach to removeWaiting; also align timestamp
calculation with the rest of the code by replacing ZoneId.systemDefault() in
addWaiting (and any timestamp logic in removeWaiting) with
ZoneId.of("Asia/Seoul") so the ZSet scores are consistent with RedisKeyUtils’
timezone usage.
- Around line 148-167: The removeWaiting method currently decrements
userWaitingLimitCountKey without validating the result, allowing negative counts
and limit bypass; update removeWaiting to perform an atomic decrement-and-clamp
(or use a Lua script similar to WaitingPermitLuaRepository) that: 1) decrements
the counter but ensures it never goes below 0 (if result < 0, set it back to 0
or skip decrement), 2) returns the new value and use that for logging, and 3)
ensure a TTL is set when the key is first created (set expiry if absent) so the
daily reset works; implement this using redisTemplate.execute with a
RedisCallback or by reusing the existing Lua pattern to keep operations atomic
and reference userWaitingLimitCountKey / removeWaiting for locating the change.
🧹 Nitpick comments (6)
nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingRequest.java (1)
10-12:partySize필드에 유효성 검증 어노테이션 추가를 권장합니다.웨이팅 등록 요청에서
partySize는 필수 값이며, 최소 1명 이상이어야 합니다. 현재 유효성 검증이 없어null이나 0 이하의 값이 전달될 수 있습니다.또한,
RegisterWaitingResponse는int를 사용하고 있어 타입 일관성도 고려해 주세요.♻️ 유효성 검증 추가 제안
package com.nowait.applicationuser.waiting.dto; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; `@Getter` `@NoArgsConstructor` `@AllArgsConstructor` public class RegisterWaitingRequest { + `@NotNull`(message = "인원수는 필수입니다.") + `@Min`(value = 1, message = "인원수는 1명 이상이어야 합니다.") private Integer partySize; }nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingIdempotencyValue.java (1)
10-13:state필드를 enum으로 정의하는 것을 고려해 보세요.현재
state가String으로 되어 있어 오타나 잘못된 값이 들어갈 수 있습니다. AI 요약에 따르면"COMPLETED"상태를 사용하는데, 향후 상태가 추가될 경우("PENDING","FAILED"등) enum을 사용하면 타입 안전성이 향상됩니다.♻️ Enum 사용 제안 (선택사항)
public enum IdempotencyState { PENDING, COMPLETED, FAILED }public class WaitingIdempotencyValue { - private String state; + private IdempotencyState state; private RegisterWaitingResponse response; }nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingRequest.java (1)
10-12:waitingNumber필드에 유효성 검증 어노테이션 추가를 권장합니다.취소 요청 시
waitingNumber는 필수 값으로 보입니다.@NotBlank어노테이션을 추가하면 컨트롤러 레벨에서 빈 값이나 null 값을 조기에 차단할 수 있습니다.♻️ 제안하는 수정
package com.nowait.applicationuser.waiting.dto; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import jakarta.validation.constraints.NotBlank; `@Getter` `@NoArgsConstructor` `@AllArgsConstructor` public class CancelWaitingRequest { + `@NotBlank`(message = "웨이팅 번호는 필수입니다") private String waitingNumber; }nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java (2)
40-52: 상태값 하드코딩 개선을 권장합니다.
"COMPLETED"문자열이 하드코딩되어 있습니다. 타입 안전성과 재사용성을 위해 enum 또는 상수로 관리하는 것을 권장합니다.♻️ 제안하는 수정
+ private static final String STATUS_COMPLETED = "COMPLETED"; + public void saveIdempotencyValue(String key, RegisterWaitingResponse response) { WaitingIdempotencyValue waitingIdempotencyValue = new WaitingIdempotencyValue( - "COMPLETED", + STATUS_COMPLETED, response );또는 별도의 enum으로 관리:
public enum IdempotencyStatus { COMPLETED, PENDING, FAILED }
35-37: 예외 타입 재검토를 권장합니다.
IllegalArgumentException은 일반적으로 잘못된 인자에 사용됩니다. Redis 직렬화/역직렬화 실패는 인프라 관련 문제이므로, 보다 구체적인 커스텀 예외나RuntimeException의 다른 서브클래스가 더 적합할 수 있습니다.현재 구현도 동작에 문제는 없으나, 예외 처리 시 구분이 어려울 수 있습니다.
Also applies to: 49-51
nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java (1)
77-155: 주석 처리된 테스트는@Disabled등으로 관리 권장
대량 주석은 유지보수에 불리하니 제거하거나@Disabled로전환해 의도를 남겨주세요.
...-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java
Show resolved
Hide resolved
| @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); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for usage patterns of findByKey and saveIdempotencyValue
rg -n -A 10 "findByKey|saveIdempotencyValue" --type javaRepository: 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
findByKey와 saveIdempotencyValue 사이의 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.
There was a problem hiding this comment.
🧩 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과 리뷰 코멘트 링크가 포함되어 있습니다.
| // 웨이팅 고유 번호 생성 - YYYYMMDD-storeId-sequence number 일련 번호 | ||
| Long storeId = store.getStoreId(); | ||
| LocalDateTime timestamp = LocalDateTime.now(); | ||
| String waitingNumber = generateWaitingNumber(storeId, timestamp); | ||
| String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); | ||
|
|
||
| // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 | ||
| // waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.WAITING.name()); | ||
|
|
||
| // TODO 멱등성 검증 로직 점검 필요 | ||
| Optional<WaitingIdempotencyValue> existingIdempotencyValue = waitingIdempotencyRepository.findByKey(idempotentKey); | ||
| if (existingIdempotencyValue.isPresent()) { | ||
| log.info("Existing idempotency key found: {}", idempotentKey); | ||
| return existingIdempotencyValue.get().getResponse(); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "WaitingService.java" | head -5Repository: GTable/NoWait_BE_MultiModule
Length of output: 170
🏁 Script executed:
find . -type f -name "*WaitingIdempotencyRepository*" | head -5Repository: GTable/NoWait_BE_MultiModule
Length of output: 182
🏁 Script executed:
find . -type f -name "WaitingIdempotency*.java" | head -10Repository: GTable/NoWait_BE_MultiModule
Length of output: 286
🏁 Script executed:
cat -n ./nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java | head -150Repository: GTable/NoWait_BE_MultiModule
Length of output: 6855
🏁 Script executed:
cat -n ./nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 1995
🏁 Script executed:
cat -n ./nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingIdempotencyValue.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 455
🏁 Script executed:
wc -l ./nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 174
🏁 Script executed:
sed -n '145,160p' ./nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 618
멱등키 처리 순서/유효성 및 원자성 보강 필수
웨이팅 번호 생성 시 Redis 시퀀스 카운터가 증가(line 67)한 후 멱등키 재사용 여부를 확인(line 74)하므로, 동일한 Idempotency-Key 재요청에서도 일일 시퀀스가 소모됩니다. 또한 Idempotency-Key 헤더가 null이거나 빈 문자열일 때 검증 없이 Redis에 저장/조회되어 "null" 키로 오염되며, 모든 null 요청이 같은 Redis 엔트리를 공유합니다. find→save 패턴이 원자적이지 않아(redisTemplate.set()은 SETNX가 아님) 동시 요청에서 양쪽 모두 등록 로직을 실행하는 race condition도 발생합니다.
해결 방법:
- 멱등키 존재/유효성 확인을 시퀀스 생성 전에 수행
- 멱등키가 null이거나 비어있으면 저장/조회 불수행
- setIfAbsent() 또는 Lua 스크립트로 find→save를 원자적 처리
이는 line 112-114의 saveIdempotencyValue 호출에도 동일하게 적용됩니다.
🤖 Prompt for AI Agents
In
`@nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java`
around lines 64 - 78, Move idempotency validation ahead of
waitingNumber/sequence generation in WaitingService: first read and validate the
Idempotency-Key header (ensure it's non-null and non-empty) before calling
generateWaitingNumber or any Redis sequence; if present, use
waitingIdempotencyRepository.findByKey(...) to return the stored response.
Replace the current find→save pattern used by saveIdempotencyValue and any
direct Redis writes with an atomic set-if-absent (e.g.,
redisTemplate.opsForValue().setIfAbsent or a Lua script) to ensure only one
request wins the race; apply the same null/empty-key guard to all places that
call waitingIdempotencyRepository.save or waitingRedisRepository so you never
create or query a "null" key and prevent losing sequence numbers on duplicate
requests.
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, get details of issue `#348` to understand the parent context
echo "=== Fetching issue `#348` details ==="
gh issue view 348 --repo GTable/NoWait_BE_MultiModule --json number,title,body,url
echo -e "\n=== Creating sub-issue under `#348` ==="
# Create the sub-issue in Korean with consolidated concerns
gh issue create \
--repo GTable/NoWait_BE_MultiModule \
--title "멱등키(Idempotency-Key) 처리 로직 개선 - 검증 순서, null 처리, 원자성 보강" \
--body "## 📋 상위 이슈
`#348`
## 🔍 문제점
웨이팅 등록 서비스(`WaitingService.registerWaiting`)의 멱등키 처리에서 다음 세 가지 문제가 발견되었습니다:
### 1. 멱등키 검증 순서 문제
- **현재**: 웨이팅 번호 생성(line 67) → Redis 시퀀스 증가 → 멱등키 확인(line 74)
- **문제**: 동일한 Idempotency-Key로 재요청 시에도 일일 시퀀스가 소모됨
- **영향**: 시퀀스 번호 낭비, 웨이팅 번호 불일치 가능성
### 2. Null/빈 멱등키 처리 누락
- **현재**: Idempotency-Key 헤더가 null이거나 빈 문자열일 때 검증 없이 Redis에 저장/조회
- **문제**: \"null\" 키로 Redis 오염, 모든 null 요청이 같은 엔트리 공유
- **영향**: 서로 다른 요청이 동일한 멱등 응답을 받을 수 있음
### 3. Race Condition (find→save 패턴 비원자성)
- **현재**: \`findByKey()\` → 없으면 → \`save()\` 패턴 사용
- **문제**: 두 연산 사이에 다른 요청이 끼어들 수 있음 (redisTemplate.set()은 SETNX가 아님)
- **영향**: 동시 요청 시 양쪽 모두 등록 로직 실행, 중복 웨이팅 생성 가능
## ✅ 해결 방안
### 1. 멱등키 검증 순서 변경
\`\`\`java
// BEFORE: line 67-74
String waitingNumber = generateWaitingNumber(storeId, timestamp);
String idempotentKey = httpServletRequest.getHeader(\"Idempotency-Key\");
Optional<WaitingIdempotencyValue> existingIdempotencyValue =
waitingIdempotencyRepository.findByKey(idempotentKey);
// AFTER: 멱등키 검증을 먼저 수행
String idempotentKey = httpServletRequest.getHeader(\"Idempotency-Key\");
Optional<WaitingIdempotencyValue> existingIdempotencyValue =
waitingIdempotencyRepository.findByKey(idempotentKey);
if (existingIdempotencyValue.isPresent()) {
return existingIdempotencyValue.get().getResponse();
}
String waitingNumber = generateWaitingNumber(storeId, timestamp);
\`\`\`
### 2. Null/빈 멱등키 검증 추가
\`\`\`java
String idempotentKey = httpServletRequest.getHeader(\"Idempotency-Key\");
if (idempotentKey == null || idempotentKey.trim().isEmpty()) {
throw new InvalidIdempotencyKeyException(); // 또는 적절한 처리
}
\`\`\`
### 3. 원자적 연산으로 변경
- **Option A**: Redis \`setIfAbsent()\` 사용
\`\`\`java
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, value, duration);
if (!success) {
// 이미 존재하는 경우
return redisTemplate.opsForValue().get(key);
}
\`\`\`
- **Option B**: Lua 스크립트 활용 (더 복잡한 로직에 적합)
\`\`\`lua
local key = KEYS[1]
local value = ARGV[1]
local ttl = ARGV[2]
if redis.call('EXISTS', key) == 1 then
return redis.call('GET', key)
else
redis.call('SET', key, value, 'EX', ttl)
return value
end
\`\`\`
## 📍 영향 범위
- **파일**: \`nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java\`
- **라인**: 64-78, 112-114
- **관련 클래스**: \`WaitingIdempotencyRepository\`
## 🔗 참고 링크
- PR: https://github.com/GTable/NoWait_BE_MultiModule/pull/349
- 리뷰 코멘트: https://github.com/GTable/NoWait_BE_MultiModule/pull/349#discussion_r2751174980
## 👤 담당자
`@Jjiggu`" \
--assignee Jjiggu
echo -e "\n✅ Sub-issue created successfully"
Length of output: 443
@Jjiggu, #348 하위 이슈로 등록 완료했습니다! 🎯
생성된 이슈: #351 - 멱등키(Idempotency-Key) 처리 로직 개선 - 검증 순서, null 처리, 원자성 보강
멱등키 관련 세 가지 문제점을 통합해서 이슈에 포함했습니다:
- 멱등키 검증 순서 문제 - 시퀀스 생성 전에 검증하도록 수정 필요
- Null/빈 멱등키 처리 누락 - 헤더 검증 로직 추가 필요
- Race Condition -
setIfAbsent()또는 Lua 스크립트로 원자적 처리 필요
해결 방안 코드 예제와 함께 이 PR 및 코멘트 링크도 포함했으며, 담당자로 배정해두었습니다! 👍
| // 일일 가능 웨이팅 최대 개수 초과 검증 | ||
| // TODO race condition 발생 가능성 점검 필요 | ||
| waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L); | ||
|
|
||
| // DB에 상태 값 저장 | ||
| Reservation reservation = Reservation.builder() | ||
| .reservationNumber(waitingNumber) | ||
| .store(store) | ||
| .user(user) | ||
| .status(ReservationStatus.WAITING) | ||
| .partySize(waitingRequest.getPartySize()) | ||
| .requestedAt(timestamp) | ||
| .updatedAt(timestamp) | ||
| .build(); | ||
|
|
||
| reservationRepository.save(reservation); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, locate the WaitingService.java file and examine the relevant sections
find . -name "WaitingService.java" -type fRepository: GTable/NoWait_BE_MultiModule
Length of output: 170
🏁 Script executed:
# Search for Redis increment/decrement operations and transaction handling
rg -n "incrementAndCheckWaitingLimit|incrementDailySequence|decrement|rollback|Transactional|@Transactional|LuaScript|lua" --type java -iRepository: GTable/NoWait_BE_MultiModule
Length of output: 21365
🏁 Script executed:
# Search for try-catch blocks or CompensatingTransaction patterns
rg -n "try\s*\{|catch|CompensatingTransaction|rollback|undo" --type javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 6213
🏁 Script executed:
# Read WaitingService.java to see the full context around lines 81-96 and 149-157
cat -n ./nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java | head -180 | tail -130Repository: GTable/NoWait_BE_MultiModule
Length of output: 5466
🏁 Script executed:
# Read the WaitingRedisRepository implementation to see try-catch blocks and decrement logic
cat -n ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java | sed -n '130,210p'Repository: GTable/NoWait_BE_MultiModule
Length of output: 3657
🏁 Script executed:
# Check if there's a ReservationService using Lua and what the current pattern is
cat -n ./nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java | head -50Repository: GTable/NoWait_BE_MultiModule
Length of output: 3107
Redis 카운트/시퀀스 누수 문제 확인 필요
incrementAndCheckWaitingLimit과 incrementDailySequence가 데이터베이스 저장 전에 실행되는데, 저장 실패 시 Redis 변경사항이 롤백되지 않습니다. @Transactional은 DB 트랜잭션만 관리하며 Redis 증가값은 보상되지 않습니다. 코드 내 주석(line 154)에서도 "웨이팅 실패 시 카운터 롤백 처리 필요"로 명시되어 있습니다.
권장 방안:
- Lua 스크립트를 통한 원자적 작업 (ReservationService의 WaitingPermitLuaRepository 참고)
- 또는 try-catch로 실패 시 감소 로직 추가
- 또는 Redis 작업 후 DB 저장 순서 변경 검토
🤖 Prompt for AI Agents
In
`@nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java`
around lines 81 - 96, The Redis counters (incrementAndCheckWaitingLimit and
incrementDailySequence) are being incremented before persisting the Reservation
(Reservation.builder()/reservationRepository.save), which causes leak on DB
failure; modify the flow to ensure atomicity by either using an atomic Lua
script (see WaitingPermitLuaRepository) to perform Redis increments and
reservation permit checks together, or wrap the DB save in a try-catch and
decrement/compensate the Redis counters on any persistence failure, or move
Redis increments to after successful reservationRepository.save; update
WaitingService to use one of these approaches so Redis changes are rolled back
or applied atomically with the DB write.
There was a problem hiding this comment.
🧩 Analysis chain
@Jjiggu, 이슈를 등록하겠습니다.
🏁 Script executed:
#!/bin/bash
# Create GitHub issue for Redis counter leak problem
gh issue create \
--title "웨이팅 등록 시 Redis 카운터 누수 문제 해결" \
--body "## 문제 설명
\`WaitingService.registerWaiting()\` 메서드에서 Redis 카운터(\`incrementAndCheckWaitingLimit\`, \`incrementDailySequence\`)가 데이터베이스 저장 전에 증가되는데, DB 저장이 실패할 경우 이미 증가된 Redis 값이 롤백되지 않아 데이터 불일치가 발생합니다.
## 현재 동작
1. Redis 카운터 증가 (\`incrementAndCheckWaitingLimit\`, \`incrementDailySequence\`)
2. Reservation 엔티티 생성 및 저장
3. DB 저장 실패 시 Redis 변경사항이 롤백되지 않음
## 영향
- 사용자별 일일 웨이팅 제한 카운터가 실제보다 높게 유지됨
- 일일 시퀀스 번호가 누락되어 웨이팅 번호에 gaps 발생
## 권장 해결 방안
- Lua 스크립트를 통한 원자적 작업 구현 (ReservationService의 WaitingPermitLuaRepository 참고)
- 또는 try-catch로 실패 시 보상 트랜잭션(감소 로직) 추가
- 또는 Redis 작업을 DB 저장 후로 이동하여 순서 변경 검토
## 관련 정보
- PR: https://github.com/GTable/NoWait_BE_MultiModule/pull/349
- 코드 리뷰 코멘트: https://github.com/GTable/NoWait_BE_MultiModule/pull/349#discussion_r2751174981
- 파일: \`nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java\` (lines 81-96)
- 요청자: `@Jjiggu`
## 참고
코드 내 주석(line 154)에도 \"웨이팅 실패 시 카운터 롤백 처리 필요\"로 명시되어 있습니다." \
--assignee Jjiggu
Length of output: 58
@Jjiggu, 이슈가 등록되었습니다!
🔗 Issue #352: 웨이팅 등록 시 Redis 카운터 누수 문제 해결
Redis 카운터 누수 문제에 대한 상세한 설명과 권장 해결 방안이 포함되어 있으며, 담당자로 지정되었습니다.
| // DB 웨이팅 상태 취소 처리 | ||
| Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber()) | ||
| .orElseThrow(ReservationNotFoundException::new); | ||
|
|
||
| reservation.markAsCancelled(LocalDateTime.now()); | ||
|
|
||
| // Redis 대기열 취소 이벤트 발행 | ||
| waitingRedisRepository.removeWaiting(storeId, user.getId()); |
There was a problem hiding this comment.
예약 취소 권한 검증 누락 (IDOR 위험)
예약 번호만으로 조회해 취소 처리하고 있어, 다른 사용자의 예약도 취소 가능해집니다. storeId/userId 매칭 검증(또는 조건 조회)으로 권한을 제한하세요.
🔒 권한 검증 추가 예시
Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber())
.orElseThrow(ReservationNotFoundException::new);
+
+ if (!reservation.getUser().getId().equals(user.getId())
+ || !reservation.getStore().getStoreId().equals(storeId)) {
+ throw new ReservationNotFoundException();
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // DB 웨이팅 상태 취소 처리 | |
| Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber()) | |
| .orElseThrow(ReservationNotFoundException::new); | |
| reservation.markAsCancelled(LocalDateTime.now()); | |
| // Redis 대기열 취소 이벤트 발행 | |
| waitingRedisRepository.removeWaiting(storeId, user.getId()); | |
| // DB 웨이팅 상태 취소 처리 | |
| Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber()) | |
| .orElseThrow(ReservationNotFoundException::new); | |
| if (!reservation.getUser().getId().equals(user.getId()) | |
| || !reservation.getStore().getStoreId().equals(storeId)) { | |
| throw new ReservationNotFoundException(); | |
| } | |
| reservation.markAsCancelled(LocalDateTime.now()); | |
| // Redis 대기열 취소 이벤트 발행 | |
| waitingRedisRepository.removeWaiting(storeId, user.getId()); |
🤖 Prompt for AI Agents
In
`@nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java`
around lines 131 - 138, The current cancel flow in WaitingService uses
reservationRepository.findReservationByReservationNumber(request.getWaitingNumber())
without verifying ownership, allowing IDOR; change the lookup to include storeId
and userId (or validate after fetch) so only reservations matching storeId and
user.getId() can be cancelled. Specifically, update the query or replace the
call so Reservation is retrieved via a method that accepts (reservationNumber,
storeId, userId) or after fetching assert
reservation.getStoreId().equals(storeId) &&
reservation.getUserId().equals(user.getId()) and throw
ReservationNotFoundException/AccessDenied when mismatch, then proceed to call
reservation.markAsCancelled(...) and
waitingRedisRepository.removeWaiting(storeId, user.getId()).
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Create GitHub issue for IDOR vulnerability in waiting cancellation
gh issue create \
--repo GTable/NoWait_BE_MultiModule \
--title "예약 취소 API에서 권한 검증 누락 (IDOR 취약점)" \
--body "## 문제 설명
웨이팅 취소 기능(\`WaitingService.cancelWaiting\`)에서 예약 번호만으로 예약을 조회하고 취소 처리하고 있어, 다른 사용자의 예약도 취소할 수 있는 보안 취약점(IDOR - Insecure Direct Object Reference)이 존재합니다.
## 위치
- 파일: \`nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java\`
- 메서드: \`cancelWaiting\`
- 라인: 131-138
## 위험도
🔴 Critical - 인증된 사용자가 다른 사용자의 예약을 취소할 수 있는 권한 상승 취약점
## 필요한 조치
예약 조회 시 또는 조회 후에 다음을 검증해야 합니다:
- 예약의 \`storeId\`가 요청된 \`storeId\`와 일치하는지
- 예약의 \`userId\`가 현재 인증된 사용자의 ID와 일치하는지
일치하지 않을 경우 \`ReservationNotFoundException\` 또는 적절한 권한 오류를 발생시켜야 합니다.
## 관련 링크
- PR: https://github.com/GTable/NoWait_BE_MultiModule/pull/349
- 리뷰 코멘트: https://github.com/GTable/NoWait_BE_MultiModule/pull/349#discussion_r2751174982
- 요청자: `@Jjiggu`" \
--assignee Jjiggu
Length of output: 58
@Jjiggu, 이슈가 생성되었습니다!
🔗 Issue #353: 예약 취소 API에서 권한 검증 누락 (IDOR 취약점)
해당 이슈에는 보안 취약점에 대한 상세한 설명과 필요한 조치사항이 포함되어 있으며, 담당자로 지정되어 있습니다.
| public static Date expireAt10Minute() { | ||
| ZoneId zone = ZoneId.of("Asia/Seoul"); | ||
| LocalDateTime now = LocalDateTime.now(zone); | ||
| LocalDateTime nextHour = now.toLocalDate().atTime(0, 10); | ||
| Instant instant = nextHour.atZone(zone).toInstant(); | ||
|
|
||
| return Date.from(instant); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "RedisKeyUtils.java" | head -5Repository: GTable/NoWait_BE_MultiModule
Length of output: 172
🏁 Script executed:
cat -n ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java | sed -n '110,135p'Repository: GTable/NoWait_BE_MultiModule
Length of output: 759
🏁 Script executed:
rg "expireAt10Minute" -n --type javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 215
🏁 Script executed:
cat -n ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java | head -130Repository: GTable/NoWait_BE_MultiModule
Length of output: 4618
🏁 Script executed:
rg "expireAt10Minute|expireAtNext03" --type java -B 3 -A 3Repository: GTable/NoWait_BE_MultiModule
Length of output: 4246
🏁 Script executed:
rg "expireAt10Minute\(\)" --type javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 211
🏁 Script executed:
rg "expireAt10Minute" --type java --type kotlinRepository: GTable/NoWait_BE_MultiModule
Length of output: 211
🏁 Script executed:
grep -r "expireAt" ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 151
만료 시각이 과거로 설정될 수 있습니다
현재 로직은 항상 당일 00:10으로 고정되어, 00:10 이후 호출 시 즉시 만료됩니다. expireAtNext03() 메서드의 패턴을 따르면 다음 00:10로 설정해야 할 것으로 보이며, 또는 '현재 시각+10분'이 의도라면 별도로 수정이 필요합니다.
🛠️ 수정 제안
다음 00:10 기준:
- LocalDateTime nextHour = now.toLocalDate().atTime(0, 10);
+ LocalDateTime nextHour = now.toLocalDate().plusDays(1).atTime(0, 10);또는 현재 시각 기준 10분:
- LocalDateTime nextHour = now.toLocalDate().atTime(0, 10);
+ Instant instant = now.plusMinutes(10).atZone(zone).toInstant();🤖 Prompt for AI Agents
In
`@nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java`
around lines 119 - 126, The expireAt10Minute() currently always returns today at
00:10 which can be in the past; change it to compute the next occurrence of
00:10 (mirror the expireAtNext03() pattern): get ZoneId.of("Asia/Seoul"),
compute now = LocalDateTime.now(zone), build candidate =
now.toLocalDate().atTime(0, 10) and if candidate.isBefore(now) or
!candidate.isAfter(now) add one day to candidate, then convert
candidate.atZone(zone).toInstant() and return Date.from(...). Update the
expireAt10Minute() method accordingly.
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Create GitHub issue for the expireAt10Minute() bug
gh issue create \
--repo GTable/NoWait_BE_MultiModule \
--title "버그: RedisKeyUtils.expireAt10Minute() 메서드가 과거 시각을 반환할 수 있음" \
--body "## 문제 설명
\`RedisKeyUtils.expireAt10Minute()\` 메서드가 항상 당일 00:10으로 고정되어 있어, 00:10 이후에 호출될 경우 과거 시각을 반환하여 즉시 만료되는 문제가 있습니다.
## 발견 위치
- 파일: \`nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java\`
- 메서드: \`expireAt10Minute()\`
## 관련 링크
- PR: https://github.com/GTable/NoWait_BE_MultiModule/pull/349
- 리뷰 코멘트: https://github.com/GTable/NoWait_BE_MultiModule/pull/349#discussion_r2751174985
## 요청자
`@Jjiggu`
## 참고 사항
동일 파일의 \`expireAtNext03()\` 메서드는 다음 날 03:00을 올바르게 계산하고 있습니다. 이 패턴을 참고하여 수정이 필요합니다." \
--assignee Jjiggu
Length of output: 58
@Jjiggu 이슈를 등록했습니다! 🎯
이슈 링크: #354
expireAt10Minute() 메서드의 과거 시각 반환 문제를 이슈로 등록하고 담당자로 지정했습니다.
| public void addWaiting(Long storeId, Long userId, LocalDateTime timestamp) { | ||
| String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; | ||
| String userListKey = RedisKeyUtils.buildWaitingUserListKeyPrefix() + userId; | ||
| String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); | ||
|
|
||
| long score = timestamp | ||
| .atZone(ZoneId.systemDefault()) | ||
| .toInstant() | ||
| .toEpochMilli(); | ||
|
|
||
| // TODO ttl 설정 필요 | ||
| try { | ||
| redisTemplate.opsForZSet().add(queueKey, String.valueOf(userId), score); | ||
| log.info("웨이팅 대기열 추가 - storeId : {}, userId : {}", storeId, userId); | ||
|
|
||
| redisTemplate.opsForZSet().add(userListKey, String.valueOf(storeId), score); | ||
| log.info("유저 웨이팅 목록 추가 - userId : {}, storeId : {}", userId, storeId); | ||
| } catch (Exception e) { | ||
| log.error("Redis 웨이팅 대기열 추가 실패 - storeId : {}, userId : {}, error: {}", storeId, userId, e.getMessage()); | ||
| throw e; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd -t f "WaitingRedisRepository" --type fRepository: GTable/NoWait_BE_MultiModule
Length of output: 297
🏁 Script executed:
cd nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository && pwdRepository: GTable/NoWait_BE_MultiModule
Length of output: 181
🏁 Script executed:
find . -type f -name "WaitingRedisRepository.java" | head -1 | xargs wc -lRepository: GTable/NoWait_BE_MultiModule
Length of output: 196
🏁 Script executed:
find . -type f -name "*.java" | xargs grep -l "addWaiting" | head -10Repository: GTable/NoWait_BE_MultiModule
Length of output: 310
🏁 Script executed:
find . -type f -name "*Redis*Config*.java" | head -5Repository: GTable/NoWait_BE_MultiModule
Length of output: 165
🏁 Script executed:
find . -type f -name "*.lua" | head -10Repository: GTable/NoWait_BE_MultiModule
Length of output: 54
🏁 Script executed:
git ls-files | grep -i redis | grep -E "\.(java|properties|yml|yaml)$" | head -20Repository: GTable/NoWait_BE_MultiModule
Length of output: 2306
🏁 Script executed:
cat -n nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 9976
🏁 Script executed:
cat -n nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 2115
🏁 Script executed:
cat -n nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 7111
🏁 Script executed:
cat -n nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/listener/AddWaitingRegisterListener.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 1193
🏁 Script executed:
grep -r "ZoneId\|TimeZone\|timezone" nowait-domain/domain-redis/src --include="*.java" | head -20Repository: GTable/NoWait_BE_MultiModule
Length of output: 773
🏁 Script executed:
grep -r "addWaiting" --include="*.java" | head -20Repository: GTable/NoWait_BE_MultiModule
Length of output: 482
🏁 Script executed:
find . -name "application.yml" -o -name "application.properties" -o -name "application-*.yml" | xargs grep -l "timezone\|ZoneId" 2>/dev/nullRepository: GTable/NoWait_BE_MultiModule
Length of output: 56
Redis 대기열 추가 작업의 원자성 미보장 및 타임존 불일치 문제 해결 필요
두 가지 심각한 문제가 있습니다:
-
원자성 미보장:
queueKey와userListKey에 대한 두 개의opsForZSet().add()호출이 분리되어 있어, 첫 번째 쓰기는 성공하고 두 번째가 실패하면 대기열 상태가 불일치합니다. Lua 스크립트로 두 작업을 원자적으로 처리하거나, 실패 시 보상 삭제 로직을 추가하세요. -
타임존 불일치:
systemDefault()를 사용하면 서버마다 다른 타임존으로 계산될 수 있으며, 코드베이스의RedisKeyUtils에서는Asia/Seoul을 명시적으로 사용하고 있습니다. 이 불일치로 인해 점수(ZSet 정렬) 계산이 부정확해져 대기 순서가 뒤틀릴 수 있습니다.Asia/Seoul로 통일하세요.removeWaiting()메서드도 동일한 원자성 문제를 가지고 있으니 함께 개선하세요.
🛠️ 타임존 수정 예시
- long score = timestamp
- .atZone(ZoneId.systemDefault())
+ long score = timestamp
+ .atZone(ZoneId.of("Asia/Seoul"))
.toInstant()
.toEpochMilli();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public void addWaiting(Long storeId, Long userId, LocalDateTime timestamp) { | |
| String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; | |
| String userListKey = RedisKeyUtils.buildWaitingUserListKeyPrefix() + userId; | |
| String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); | |
| long score = timestamp | |
| .atZone(ZoneId.systemDefault()) | |
| .toInstant() | |
| .toEpochMilli(); | |
| // TODO ttl 설정 필요 | |
| try { | |
| redisTemplate.opsForZSet().add(queueKey, String.valueOf(userId), score); | |
| log.info("웨이팅 대기열 추가 - storeId : {}, userId : {}", storeId, userId); | |
| redisTemplate.opsForZSet().add(userListKey, String.valueOf(storeId), score); | |
| log.info("유저 웨이팅 목록 추가 - userId : {}, storeId : {}", userId, storeId); | |
| } catch (Exception e) { | |
| log.error("Redis 웨이팅 대기열 추가 실패 - storeId : {}, userId : {}, error: {}", storeId, userId, e.getMessage()); | |
| throw e; | |
| } | |
| } | |
| public void addWaiting(Long storeId, Long userId, LocalDateTime timestamp) { | |
| String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; | |
| String userListKey = RedisKeyUtils.buildWaitingUserListKeyPrefix() + userId; | |
| String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); | |
| long score = timestamp | |
| .atZone(ZoneId.of("Asia/Seoul")) | |
| .toInstant() | |
| .toEpochMilli(); | |
| // TODO ttl 설정 필요 | |
| try { | |
| redisTemplate.opsForZSet().add(queueKey, String.valueOf(userId), score); | |
| log.info("웨이팅 대기열 추가 - storeId : {}, userId : {}", storeId, userId); | |
| redisTemplate.opsForZSet().add(userListKey, String.valueOf(storeId), score); | |
| log.info("유저 웨이팅 목록 추가 - userId : {}, storeId : {}", userId, storeId); | |
| } catch (Exception e) { | |
| log.error("Redis 웨이팅 대기열 추가 실패 - storeId : {}, userId : {}, error: {}", storeId, userId, e.getMessage()); | |
| throw e; | |
| } | |
| } |
🤖 Prompt for AI Agents
In
`@nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java`
around lines 125 - 146, The addWaiting method currently performs two separate
redisTemplate.opsForZSet().add calls (on queueKey and userListKey) which can
leave state inconsistent if one succeeds and the other fails; change this to an
atomic operation by using a Redis Lua script (EVAL) that performs both ZADD
calls together (add queueKey and userListKey with the same score) or implement a
compensating rollback in the catch that removes the previously-added key, and
apply the same atomic approach to removeWaiting; also align timestamp
calculation with the rest of the code by replacing ZoneId.systemDefault() in
addWaiting (and any timestamp logic in removeWaiting) with
ZoneId.of("Asia/Seoul") so the ZSet scores are consistent with RedisKeyUtils’
timezone usage.
| public void removeWaiting(Long storeId, Long userId) { | ||
| String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; | ||
| String userListKey = RedisKeyUtils.buildWaitingUserListKeyPrefix() + userId; | ||
| String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); | ||
|
|
||
| try { | ||
| redisTemplate.opsForZSet().remove(queueKey, String.valueOf(userId)); | ||
| log.info("웨이팅 대기열 제거 - storeId : {}, userId : {}", storeId, userId); | ||
|
|
||
| redisTemplate.opsForZSet().remove(userListKey, String.valueOf(storeId)); | ||
| log.info("유저 웨이팅 목록 제거 - userId : {}, storeId : {}", userId, storeId); | ||
|
|
||
| redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1); | ||
| log.info("유저 웨이팅 제한 카운트 감소 - userId : {}, currentCount : {}", userId, | ||
| redisTemplate.opsForValue().get(userWaitingLimitCountKey)); | ||
| } catch (Exception e) { | ||
| log.error("Redis 웨이팅 대기열 제거 실패 - storeId : {}, userId : {}, error: {}", storeId, userId, e.getMessage()); | ||
| throw e; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the WaitingRedisRepository file
find . -name "WaitingRedisRepository.java" -type fRepository: GTable/NoWait_BE_MultiModule
Length of output: 192
🏁 Script executed:
# Search for files in the domain-redis module
find . -path "*domain-redis*" -name "*.java" | grep -i waitingRepository: GTable/NoWait_BE_MultiModule
Length of output: 569
🏁 Script executed:
# Let's check the general structure of the repository
ls -la | head -20Repository: GTable/NoWait_BE_MultiModule
Length of output: 1320
🏁 Script executed:
# Let's examine the WaitingRedisRepository.java file
cat -n ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java | head -200Repository: GTable/NoWait_BE_MultiModule
Length of output: 9110
🏁 Script executed:
# Let's check the file size first
wc -l ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 196
🏁 Script executed:
# Let's check the WaitingPermitLuaRepository to see if there's Lua script usage
cat -n ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 7111
🏁 Script executed:
# Let's check where the limit is enforced - search for incrementAndCheckWaitingLimit usage
rg "incrementAndCheckWaitingLimit" -A 3 -B 3Repository: GTable/NoWait_BE_MultiModule
Length of output: 2066
🏁 Script executed:
# Let's check the RedisKeyUtils to understand the key structure
find . -name "RedisKeyUtils.java" -type fRepository: GTable/NoWait_BE_MultiModule
Length of output: 172
🏁 Script executed:
# Let's check the RedisKeyUtils
cat -n ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 4618
🏁 Script executed:
# Let's search for where removeWaiting is called to understand the context
rg "removeWaiting" -A 2 -B 2Repository: GTable/NoWait_BE_MultiModule
Length of output: 3772
🏁 Script executed:
# Let's check how increment and decrement work with non-existent keys in Redis
# Let's also see if there's any initialization of the count key
rg "buildUserWaitingLimitCountKey" -A 3 -B 3Repository: GTable/NoWait_BE_MultiModule
Length of output: 4694
decrement로 인한 음수 카운트 및 대기 한도 우회 취약점
removeWaiting 메서드의 160번 줄에서 decrement 후 반환값을 검증하지 않으므로, 카운트가 음수로 내려갈 수 있습니다. 다음과 같은 시나리오에서 한도 우회가 발생합니다:
- 키가 없거나 만료된 상태에서
removeWaiting호출 → 카운트가 -1 - 중복 취소 시 카운트가 -2, -3 등으로 계속 감소
incrementAndCheckWaitingLimit에서current > maxLimit만 체크하므로, 음수값은 3L보다 작아서 검증 통과- 사용자가 최대 3회 대기 제한을 우회 가능
또한 userWaitingLimitCountKey에 TTL이 설정되지 않아 일일 한도 리셋 메커니즘이 작동하지 않습니다.
권장 해결 방안
- redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1);
- log.info("유저 웨이팅 제한 카운트 감소 - userId : {}, currentCount : {}", userId,
- redisTemplate.opsForValue().get(userWaitingLimitCountKey));
+ Long current = redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1);
+ if (current != null && current < 0) {
+ redisTemplate.opsForValue().set(userWaitingLimitCountKey, "0");
+ current = 0L;
+ }
+ log.info("유저 웨이팅 제한 카운트 감소 - userId : {}, currentCount : {}", userId, current);또는 WaitingPermitLuaRepository의 Lua 스크립트 패턴처럼 원자적 연산으로 처리하는 것이 더 견고합니다.
🤖 Prompt for AI Agents
In
`@nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java`
around lines 148 - 167, The removeWaiting method currently decrements
userWaitingLimitCountKey without validating the result, allowing negative counts
and limit bypass; update removeWaiting to perform an atomic decrement-and-clamp
(or use a Lua script similar to WaitingPermitLuaRepository) that: 1) decrements
the counter but ensures it never goes below 0 (if result < 0, set it back to 0
or skip decrement), 2) returns the new value and use that for logging, and 3)
ensure a TTL is set when the key is first created (set expiry if absent) so the
daily reset works; implement this using redisTemplate.execute with a
RedisCallback or by reusing the existing Lua pattern to keep operations atomic
and reference userWaitingLimitCountKey / removeWaiting for locating the change.
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Create a GitHub issue for the removeWaiting decrement vulnerability
gh issue create \
--title "웨이팅 취소 시 카운트 음수 처리 및 대기 한도 우회 취약점" \
--body "## 문제 설명
\`removeWaiting\` 메서드에서 사용자별 웨이팅 제한 카운트를 감소시킬 때 반환값을 검증하지 않아, 카운트가 음수로 내려가는 문제가 발생합니다.
### 발생 가능한 시나리오
1. 키가 존재하지 않거나 만료된 상태에서 \`removeWaiting\` 호출 → 카운트가 -1로 설정됨
2. 중복 취소 요청 시 카운트가 -2, -3 등으로 계속 감소
3. \`incrementAndCheckWaitingLimit\`에서 \`current > maxLimit\`만 검증하므로, 음수 값은 3L보다 작아 검증을 통과
4. 사용자가 최대 3회 대기 제한을 우회할 수 있는 취약점 발생
### 추가 문제점
- \`userWaitingLimitCountKey\`에 TTL이 설정되지 않아 일일 한도 리셋 메커니즘이 작동하지 않음
## 관련 파일
- \`nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java\`
- 해당 메서드: \`removeWaiting(Long storeId, Long userId)\` (Line 148-167)
## 관련 링크
- Pull Request: https://github.com/GTable/NoWait_BE_MultiModule/pull/349
- 리뷰 코멘트: https://github.com/GTable/NoWait_BE_MultiModule/pull/349#discussion_r2751174990
## 제안된 해결 방안
1. \`decrement\` 후 반환값을 검증하여 0 미만이면 0으로 설정
2. \`WaitingPermitLuaRepository\`의 Lua 스크립트 패턴을 활용하여 원자적 연산으로 처리
3. \`userWaitingLimitCountKey\`에 TTL 설정 추가 (일일 리셋 메커니즘)
---
_이 이슈는 `@Jjiggu의` 요청으로 생성되었습니다._" \
--assignee "Jjiggu" \
--repo "GTable/NoWait_BE_MultiModule"
Length of output: 58
@Jjiggu, 이슈를 생성했습니다!
🔗 Issue #355: 웨이팅 취소 시 카운트 음수 처리 및 대기 한도 우회 취약점
removeWaiting 메서드의 decrement 검증 및 TTL 설정 문제에 대한 내용을 담았으며, 담당자로 지정되었습니다.
| public void incrementAndCheckWaitingLimit(Long userId, Long maxLimit) { | ||
| String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); | ||
|
|
||
| Long current = redisTemplate.opsForValue().increment(userWaitingLimitCountKey, 1); | ||
| log.info("유저 웨이팅 제한 카운트 증가 - userId : {}, currentCount : {}", userId, redisTemplate.opsForValue().get(userWaitingLimitCountKey)); | ||
|
|
||
| // TTL 없으면 하루 단위로 묶어야 함 (중요) | ||
| // redisTemplate.expireAt(key, RedisKeyUtils.expireAtNext03()); | ||
|
|
||
| if (current != null && current > maxLimit) { | ||
| redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1); | ||
| throw new UserWaitingLimitExceededException(); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the WaitingRedisRepository file
find . -name "WaitingRedisRepository.java" -type fRepository: GTable/NoWait_BE_MultiModule
Length of output: 192
🏁 Script executed:
# Find RedisKeyUtils to understand how keys are built and if TTL is handled elsewhere
find . -name "RedisKeyUtils.java" -type fRepository: GTable/NoWait_BE_MultiModule
Length of output: 172
🏁 Script executed:
# Search for expireAtNext03 method usage in the codebase
rg "expireAtNext03" --type javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 576
🏁 Script executed:
# Check if there are other Redis repository implementations that handle TTL
rg "opsForValue\(\)\.increment" --type java -A 3Repository: GTable/NoWait_BE_MultiModule
Length of output: 1972
🏁 Script executed:
# Read RedisKeyUtils.java to understand key building and TTL methods
cat ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.javaRepository: GTable/NoWait_BE_MultiModule
Length of output: 3729
🏁 Script executed:
# Read WaitingRedisRepository.java to see full context around lines 185-198
cat -n ./nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java | sed -n '170,210p'Repository: GTable/NoWait_BE_MultiModule
Length of output: 1794
🏁 Script executed:
# Check if buildUserWaitingLimitCountKey includes date-based components
rg "buildUserWaitingLimitCountKey" --type java -A 5Repository: GTable/NoWait_BE_MultiModule
Length of output: 3851
TTL 미설정으로 한도 초과 시 영구적인 잠금 발생 가능
buildUserWaitingLimitCountKey()는 waiting:user:{userId}:limit:count 형식의 키를 생성하며 날짜 정보가 포함되지 않습니다. 주석에서 언급한 대로 TTL이 없으면 카운터가 영구적으로 유지되어, 한 번 제한을 초과하면 영구적으로 차단됩니다. 첫 증가 시 expireAtNext03()을 사용하여 만료 시각을 설정하세요.
🛠️ TTL 부여 예시
Long current = redisTemplate.opsForValue().increment(userWaitingLimitCountKey, 1);
+ if (current != null && current == 1) {
+ redisTemplate.expireAt(userWaitingLimitCountKey, RedisKeyUtils.expireAtNext03());
+ }
log.info("유저 웨이팅 제한 카운트 증가 - userId : {}, currentCount : {}", userId, redisTemplate.opsForValue().get(userWaitingLimitCountKey));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public void incrementAndCheckWaitingLimit(Long userId, Long maxLimit) { | |
| String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); | |
| Long current = redisTemplate.opsForValue().increment(userWaitingLimitCountKey, 1); | |
| log.info("유저 웨이팅 제한 카운트 증가 - userId : {}, currentCount : {}", userId, redisTemplate.opsForValue().get(userWaitingLimitCountKey)); | |
| // TTL 없으면 하루 단위로 묶어야 함 (중요) | |
| // redisTemplate.expireAt(key, RedisKeyUtils.expireAtNext03()); | |
| if (current != null && current > maxLimit) { | |
| redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1); | |
| throw new UserWaitingLimitExceededException(); | |
| } | |
| } | |
| public void incrementAndCheckWaitingLimit(Long userId, Long maxLimit) { | |
| String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); | |
| Long current = redisTemplate.opsForValue().increment(userWaitingLimitCountKey, 1); | |
| if (current != null && current == 1) { | |
| redisTemplate.expireAt(userWaitingLimitCountKey, RedisKeyUtils.expireAtNext03()); | |
| } | |
| log.info("유저 웨이팅 제한 카운트 증가 - userId : {}, currentCount : {}", userId, redisTemplate.opsForValue().get(userWaitingLimitCountKey)); | |
| // TTL 없으면 하루 단위로 묶어야 함 (중요) | |
| // redisTemplate.expireAt(key, RedisKeyUtils.expireAtNext03()); | |
| if (current != null && current > maxLimit) { | |
| redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1); | |
| throw new UserWaitingLimitExceededException(); | |
| } | |
| } |
🤖 Prompt for AI Agents
In
`@nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java`
around lines 185 - 198, incrementAndCheckWaitingLimit is incrementing a per-user
counter key produced by buildUserWaitingLimitCountKey(String.valueOf(userId))
without setting TTL, so once a user exceeds the limit they can be permanently
blocked; fix by setting an expiry when the counter is first created (i.e., when
increment returns 1) using redisTemplate.expireAt(userWaitingLimitCountKey,
RedisKeyUtils.expireAtNext03()), and ensure you only set the TTL on first
increment (do not overwrite existing TTL) while keeping the current null check
and the decrement+UserWaitingLimitExceededException behavior.
| // 웨이팅 여부 조회 | ||
| // TODO: 구현 필요 | ||
| public Boolean isWaiting(Long storeId, Long userId) { | ||
| redisTemplate.opsForZSet() | ||
| .score( | ||
| RedisKeyUtils.buildWaitingKeyPrefix() + storeId, | ||
| String.valueOf(userId) | ||
| ); | ||
|
|
||
| Boolean isWaiting = true; | ||
|
|
||
| return isWaiting; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "WaitingRedisRepository.java"Repository: GTable/NoWait_BE_MultiModule
Length of output: 192
🏁 Script executed:
head -230 nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java | tail -30Repository: GTable/NoWait_BE_MultiModule
Length of output: 788
isWaiting 메서드가 항상 true를 반환합니다
score() 조회 결과를 사용하지 않아 실제 대기 여부 판단이 불가능합니다. score != null 기준으로 반환해야 합니다.
🛠️ 올바른 대기 여부 판정
- redisTemplate.opsForZSet()
- .score(
- RedisKeyUtils.buildWaitingKeyPrefix() + storeId,
- String.valueOf(userId)
- );
-
- Boolean isWaiting = true;
-
- return isWaiting;
+ Double score = redisTemplate.opsForZSet()
+ .score(
+ RedisKeyUtils.buildWaitingKeyPrefix() + storeId,
+ String.valueOf(userId)
+ );
+ return score != null;🤖 Prompt for AI Agents
In
`@nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java`
around lines 212 - 224, The isWaiting method currently ignores the result of
redisTemplate.opsForZSet().score and always returns true; change it to capture
the Double score returned by
redisTemplate.opsForZSet().score(RedisKeyUtils.buildWaitingKeyPrefix() +
storeId, String.valueOf(userId)) and return Boolean.valueOf(score != null) (or
simply score != null) so the method returns true only when a score exists for
that user in the store waiting ZSet; update WaitingRedisRepository.isWaiting to
use that returned value.
Refactor : waiting code review
작업 요약
Issue Link
#348
문제점 및 어려움
해결 방안
Reference
Summary by CodeRabbit
릴리스 노트
New Features
Bug Fixes
Refactor
Tests / Chores
✏️ Tip: You can customize this high-level summary in your review settings.