Skip to content

Commit cbca76a

Browse files
authored
Merge pull request #359 from GTable/feature/#348-Waiting-Rebuild
refactor: 예약 취소 내 멱등성 구현 및 웨이팅 등록 유닛테스트 추가
2 parents c1acf92 + 3388353 commit cbca76a

6 files changed

Lines changed: 371 additions & 140 deletions

File tree

nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,14 @@ public ResponseEntity<?> registerWaiting(
6363
public ResponseEntity<?> cancelWaiting(
6464
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
6565
@PathVariable String publicCode,
66-
@RequestBody CancelWaitingRequest request
66+
@RequestBody CancelWaitingRequest request,
67+
HttpServletRequest httpServletRequest
6768
) {
6869
CancelWaitingResponse cancelWaitingResponse = waitingService.cancelWaiting(
6970
customOAuth2User,
7071
publicCode,
71-
request
72+
request,
73+
httpServletRequest
7274
);
7375

7476
return ResponseEntity
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.nowait.applicationuser.waiting.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
import lombok.NoArgsConstructor;
6+
7+
@Getter
8+
@NoArgsConstructor
9+
@AllArgsConstructor
10+
public class WaitingCancelIdempotencyValue {
11+
private String state;
12+
private CancelWaitingResponse response;
13+
}

nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import org.springframework.stereotype.Repository;
88

99
import com.fasterxml.jackson.databind.ObjectMapper;
10+
import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse;
1011
import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse;
12+
import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue;
1113
import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue;
1214

1315
import lombok.RequiredArgsConstructor;
@@ -21,22 +23,42 @@ public class WaitingIdempotencyRepository {
2123

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

26+
// 멱등키 조회 메서드
2427
public Optional<WaitingIdempotencyValue> findByKey(String key) {
25-
String value = redisTemplate.opsForValue().get(key);
28+
String idempotencyValue = redisTemplate.opsForValue().get(key);
2629

27-
if (value == null) {
30+
if (idempotencyValue == null) {
2831
return Optional.empty();
2932
}
3033

3134
try {
3235
return Optional.of(
33-
objectMapper.readValue(value, WaitingIdempotencyValue.class)
36+
objectMapper.readValue(idempotencyValue, WaitingIdempotencyValue.class)
3437
);
3538
} catch (Exception e) {
3639
throw new IllegalArgumentException("Failed to deserialize value from Redis", e);
3740
}
3841
}
3942

43+
// 멱등키 조회 메서드
44+
public Optional<WaitingCancelIdempotencyValue> findByCancelKey(String key) {
45+
String idempotencyValue = redisTemplate.opsForValue().get(key);
46+
47+
if (idempotencyValue == null) {
48+
return Optional.empty();
49+
}
50+
51+
try {
52+
return Optional.of(
53+
objectMapper.readValue(idempotencyValue, WaitingCancelIdempotencyValue.class)
54+
);
55+
} catch (Exception e) {
56+
throw new IllegalArgumentException("Failed to deserialize value from Redis", e);
57+
}
58+
}
59+
60+
61+
// 멱등키 저장 메서드 - 대기 등록
4062
public void saveIdempotencyValue(String key, RegisterWaitingResponse response) {
4163
WaitingIdempotencyValue waitingIdempotencyValue = new WaitingIdempotencyValue(
4264
"COMPLETED",
@@ -50,4 +72,19 @@ public void saveIdempotencyValue(String key, RegisterWaitingResponse response) {
5072
throw new IllegalArgumentException("Failed to serialize value for Redis", e);
5173
}
5274
}
75+
76+
// 멱등키 저장 메서드 - 대기 취소
77+
public void saveCancelIdempotencyValue(String key, CancelWaitingResponse response) {
78+
WaitingCancelIdempotencyValue waitingIdempotencyValue = new WaitingCancelIdempotencyValue(
79+
"COMPLETED",
80+
response
81+
);
82+
83+
try {
84+
String jsonValue = objectMapper.writeValueAsString(waitingIdempotencyValue);
85+
redisTemplate.opsForValue().set(key, jsonValue, TTL);
86+
} catch (Exception e) {
87+
throw new IllegalArgumentException("Failed to serialize value for Redis", e);
88+
}
89+
}
5390
}

nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import java.time.LocalDateTime;
44
import java.time.format.DateTimeFormatter;
5-
import java.util.Optional;
65

76
import org.springframework.context.ApplicationEventPublisher;
87
import org.springframework.stereotype.Service;
@@ -13,6 +12,7 @@
1312
import com.nowait.applicationuser.waiting.dto.GetWaitingSizeResponse;
1413
import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest;
1514
import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse;
15+
import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue;
1616
import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue;
1717
import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent;
1818
import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository;
@@ -59,13 +59,11 @@ public class WaitingService {
5959
@Transactional
6060
public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, String publicCode, RegisterWaitingRequest waitingRequest, HttpServletRequest httpServletRequest) {
6161

62-
String idempotentKey = httpServletRequest.getHeader("Idempotency-Key");
63-
64-
// TODO 멱등성 검증 로직 점검 필요
65-
Optional<WaitingIdempotencyValue> existingIdempotencyValue = waitingIdempotencyRepository.findByKey(idempotentKey);
66-
if (existingIdempotencyValue.isPresent()) {
67-
log.info("Existing idempotency key found: {}", idempotentKey);
68-
return existingIdempotencyValue.get().getResponse();
62+
// TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등)
63+
RegisterWaitingResponse registerWaitingResponse = validateIdempotency(httpServletRequest);
64+
if (registerWaitingResponse != null) {
65+
log.info("Idempotent request detected. Returning existing response.");
66+
return registerWaitingResponse;
6967
}
7068

7169
// TODO 유저 및 주점 존재 검증은 공통으로 많이 쓰이니 AOP로 빼는게 좋을 듯
@@ -75,19 +73,15 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri
7573
User user = userRepository.findById(oAuth2User.getUserId())
7674
.orElseThrow(UserNotFoundException::new);
7775

76+
// 일일 가능 웨이팅 최대 개수 초과 검증
77+
// TODO race condition 발생 가능성 점검 필요, DB 저장 로직 실패 시 롤백 처리 필요
78+
waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L);
79+
7880
// 웨이팅 고유 번호 생성 - YYYYMMDD-storeId-sequence number 일련 번호
7981
Long storeId = store.getStoreId();
8082
LocalDateTime timestamp = LocalDateTime.now();
8183
String waitingNumber = generateWaitingNumber(storeId, timestamp);
8284

83-
// 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인
84-
// waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.WAITING.name());
85-
86-
87-
// 일일 가능 웨이팅 최대 개수 초과 검증
88-
// TODO race condition 발생 가능성 점검 필요
89-
waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L);
90-
9185
// DB에 상태 값 저장
9286
Reservation reservation = Reservation.builder()
9387
.reservationNumber(waitingNumber)
@@ -115,25 +109,27 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri
115109
.partySize(waitingRequest.getPartySize())
116110
.build();
117111

118-
// 멱등키가 있다면 멱등 응답 저장
119-
waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response);
112+
// TODO 멱등키 응답 실패 시 어떻게 처리할 지 점검 필요
113+
saveIdempotencyResponse(httpServletRequest.getHeader("Idempotency-Key"), response);
120114

121115
return response;
122116
}
123117

124118
@Transactional
125-
public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request) {
119+
public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request, HttpServletRequest httpServletRequest) {
120+
// TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등)
121+
CancelWaitingResponse cancelWaitingResponse = validateCancelIdempotency(httpServletRequest);
122+
if (cancelWaitingResponse != null) {
123+
log.info("Idempotent request detected. Returning existing response.");
124+
return cancelWaitingResponse;
125+
}
126126

127127
Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode).orElseThrow(StoreNotFoundException::new);
128128
Long storeId = store.getStoreId();
129129

130130
User user = userRepository.findById(oAuth2User.getUserId())
131131
.orElseThrow(UserNotFoundException::new);
132132

133-
// TODO 멱등키 검증 로직 점검 필요
134-
// String idempotentKey = generateIdempotentKey(storeId, user.getId());
135-
// waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.CANCELLED.name());
136-
137133
// DB 웨이팅 상태 취소 처리
138134
Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber())
139135
.orElseThrow(ReservationNotFoundException::new);
@@ -143,28 +139,62 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p
143139
// Redis 대기열 취소 이벤트 발행
144140
waitingRedisRepository.removeWaiting(storeId, user.getId());
145141

146-
return CancelWaitingResponse.builder()
142+
CancelWaitingResponse response = CancelWaitingResponse.builder()
147143
.waitingNumber(reservation.getReservationNumber())
148144
.storeId(storeId)
149145
.reservationStatus(reservation.getStatus())
150146
.canceledAt(reservation.getUpdatedAt())
151147
.message("대기 취소가 완료되었습니다.")
152148
.build();
149+
150+
// 멱등키가 있다면 멱등 응답 저장
151+
waitingIdempotencyRepository.saveCancelIdempotencyValue(httpServletRequest.getHeader("Idempotency-Key"), response);
152+
153+
return response;
154+
}
155+
156+
// 멱등키 검증 메서드
157+
private RegisterWaitingResponse validateIdempotency(HttpServletRequest httpServletRequest) {
158+
String idempotentKey = httpServletRequest.getHeader("Idempotency-Key");
159+
160+
// 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인
161+
// TODO 멱등성 검증 로직 점검 필요
162+
return waitingIdempotencyRepository.findByKey(idempotentKey)
163+
.map(WaitingIdempotencyValue::getResponse)
164+
.orElse(null);
165+
}
166+
167+
private CancelWaitingResponse validateCancelIdempotency(HttpServletRequest httpServletRequest) {
168+
String idempotentKey = httpServletRequest.getHeader("Idempotency-Key");
169+
170+
// 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인
171+
// TODO 멱등성 검증 로직 점검 필요
172+
return waitingIdempotencyRepository.findByCancelKey(idempotentKey)
173+
.map(WaitingCancelIdempotencyValue::getResponse)
174+
.orElse(null);
153175
}
154176

177+
// 멱등키 응답 저장 메서드
178+
private void saveIdempotencyResponse(String idempotentKey, RegisterWaitingResponse response) {
179+
if (idempotentKey != null && !idempotentKey.isBlank()) {
180+
waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response);
181+
}
182+
}
183+
184+
// 현재 대기 인원 수 조회
155185
public GetWaitingSizeResponse getWaitingCount(CustomOAuth2User oAuth2User, String publicCode) {
156186

157187
Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode)
158188
.orElseThrow(StoreNotFoundException::new);
159189

160-
Long storeId = store.getStoreId();
161-
162190
User user = userRepository.findById(oAuth2User.getUserId())
163191
.orElseThrow(UserNotFoundException::new);
164192

165193
Department department = departmentRepository.findById(store.getDepartmentId())
166194
.orElseThrow(DepartmentNotFoundException::new);
167195

196+
Long storeId = store.getStoreId();
197+
168198
Long waitingCount = waitingRedisRepository.getWaitingCount(storeId);
169199

170200
return GetWaitingSizeResponse.builder()
@@ -190,8 +220,4 @@ private String generateWaitingNumber(Long storeId, LocalDateTime timestamp) {
190220
// 4) 최종 ID 조합
191221
return today + "-" + storeId + "-" + seqStr;
192222
}
193-
194-
private String generateIdempotentKey(Long storeId, Long userId) {
195-
return "idempotentKey" + ":" + storeId + ":" + userId;
196-
}
197223
}

0 commit comments

Comments
 (0)