Repository navigation
Feat(client): HASHI-115 실제 API 기반 예약 생성 및 포인트 결제 연동 - #94
Conversation
- 식당 상세와 보유 포인트를 실제 API로 조회해 예약 정보와 결제 금액을 동기화한다 - 일반·어디든 예약 생성 후 포인트 캐시를 무효화하고 예약 상세 화면으로 이동한다 - 쿼리 키 팩토리와 mutation 캐시 동기화 기준을 정리해 후속 API 연동의 일관성을 높인다
- 렌더링 전 동기 잠금으로 연속 확인에 따른 예약 중복 생성을 막습니다. - API barrel 결합을 제거해 환경 변수 없는 테스트에서도 모듈 초기화 실패를 방지합니다.
gyeongbibin
left a comment
There was a problem hiding this comment.
고생하셨습니당 몇개 코멘트 확인부탁드려용
| return RESTAURANT_MOCKS[restaurantId] | ||
| } | ||
| const parseRestaurantId = (restaurantId?: string) => { | ||
| const parsedRestaurantId = Number(restaurantId) |
There was a problem hiding this comment.
Number()를 사용하게되면 1e2, 0x10, 5.0 같은 값도 각각 100, 16, 5로 변환되어, 잘못된 URL이 다른 식당의 정상 ID로 처리될 수 있을 것 같습니다.
route param은 양의 십진수 정수 문자열만 허용하도록 /^[1-9]\d*$/로 먼저 검증한 뒤 Number()로 변환하면 더 안전할 것 같아용~~ 변환 후에는 너무 큰 숫자를 막기 위해 Number.isSafeInteger() 검증도 그대로 유지하면 좋을 것 같습니다!
There was a problem hiding this comment.
번 예약 화면에서 getMyPointBalance 응답을 실제 결제 계산에 사용하게 되는 것같은데
현재 getMyPointBalance는 data=null이거나 balance가 누락된 경우도 0포인트로 정규화하고 있습니다.
Swagger에서는 포인트 이력이 없는 사용자도 { balance: 0 }으로 응답한다고 명시되어 있어서, 실제 0포인트와 응답 계약 위반을 구분하는 편이 안전할 것 같습니다. response === null이거나 balance가 숫자가 아니면 오류를 던지도록 보강하고, null/필드 누락 테스트도 추가하면 좋을듯하네여
- 계약 위반 응답이 실제 0포인트로 처리되는 결제 오류를 방지한다. - 정상 0포인트와 누락 응답을 테스트 및 스펙에서 구분한다.
| if (pointState.maxUsablePoint !== maxUsablePoint) { | ||
| setPointState({ | ||
| maxUsablePoint, | ||
| usedPoint: Math.min(pointState.usedPoint, maxUsablePoint), | ||
| }) | ||
| } |
There was a problem hiding this comment.
maxUsablePoint가 바뀌었는지 확인하고나서 컴포넌트가 렌더링되는 도중에 바로 setPointState를 호출하고 있는 것 같네요!
React에서는 렌더링 중 상태 변경을 피하는 게 더 안전하다고 생각하는데 어떠신가요?? 렌더링은 UI를 계산하는 단계인데 그 안에서 다시 상태를 바꾸면 렌더가 한 번 더 발생하고 흐름을 예측하기 어려워질 수 있을 것 같아요!
There was a problem hiding this comment.
확인해보니 이 로직은 maxUsablePoint 변경 시 기존 사용 포인트를 영구적으로 clamp하기 위한 상태 보정입니다. 동일 컴포넌트의 상태를 이전 값과 비교한 뒤 조건부로 갱신하고, 조건 안에서 비교 기준도 최신화해 무한 렌더 없이 수렴합니다.
React 공식 문서에서도 prop 변경에 따른 일부 상태 보정은 Effect보다 렌더 중 조건부 갱신을 권장하고 있어 현재 방식을 유지하려고 합니다!
📌 Summary
일반 예약과 어디든 예약 흐름을 최신 OpenAPI 계약에 맞춰 실제 서버 API에 연결했습니다.
일반 예약 화면은 route의
restaurantId로 식당 요약과 매장 정보를 병렬 조회해 식당명, 이미지, 주소, 예약 수수료, 요일별 영업시간을 사용합니다. 예약 요청 화면은 현재 사용자 포인트를 조회해 결제 금액을 계산하고, 예약 유형에 따라 일반 예약 또는 어디든 예약 생성 API를 호출한 뒤 응답의 예약 ID로 상세 화면에 이동합니다.리뷰 이후에는 깨끗한 CI 환경에서 API Client가 불필요하게 초기화되던 barrel 결합을 제거하고, mutation 상태가 다시 렌더링되기 전의 연속 클릭으로 예약 생성 요청이 두 번 실행되는 race condition을 수정했습니다. 요청 중에는 확인/취소와 다이얼로그 닫기를 모두 잠그며, timezone과 환경 변수 회귀 조건을 포함한 테스트를 추가했습니다.
Jira: HASHI-115
📚 Tasks
🔎 Review 반영 및 원인 분석
1. 환경 변수 없는 Test CI 실패
원인
features/restaurantDetail/index.ts와features/point/index.ts에서 UI, query key, query options, API 함수를 함께 re-export하고 있었습니다. ESM barrel은 요청한 export만 선택적으로 초기화하지 않기 때문에, 쿼리 키나 UI 컴포넌트만 import해도 다음 경로가 함께 평가됐습니다.로컬에는 추적되지 않는
.env.local이 있어 문제가 가려졌지만, 별도 환경 변수가 없는 GitHub Test CI에서는 모듈 수집 단계에 예외가 발생했습니다.수정
point,restaurantDetail의 query key/query options/API 신규 export를 UI barrel에서 제거했습니다.이제
VITE_API_BASE_URL=''조건에서도 전체 client 테스트가 모듈 수집 단계부터 정상 실행됩니다.2. 예약 확인 연속 클릭 중복 요청
원인
기존 handler는
mutation.isPending만 확인했습니다.isPending은 mutation observer의 상태가 반영되어 컴포넌트가 다시 렌더링된 뒤 변경되므로, 그 전에 확인 버튼을 연속 클릭하면 두 handler가 모두 이전 렌더의false를 보고mutate()를 실행할 수 있었습니다.수정 전 회귀 테스트에서 대기 없이 확인 버튼을 두 번 누르면 예약 생성 함수가 실제로 2회 호출되는 것을 확인했습니다.
수정
useRef기반 동기 잠금을 설정합니다.isPending중 하나라도 활성화돼 있으면 추가 요청을 실행하지 않습니다.onSettled에서 잠금을 해제해 실패 후 재시도가 가능합니다.예약과취소버튼을 모두 비활성화합니다.UI의 disabled 상태는 사용자 피드백을 담당하고, ref 잠금은 React 재렌더링 전 race window를 담당하도록 역할을 분리했습니다.
3. timezone 없는 예약 일시 검증
예약 확인 화면은 timezone 없는 값을
new Date()로 파싱하지 않고YYYY-MM-DD문자열을 직접 분해해 표시합니다. 서버 전송값도 OpenAPI 예시와 동일하게YYYY-MM-DDTHH:mm:ss로 구성합니다.TZ=UTC환경에서2026-06-28 23:30이 날짜 이동 없이2026.6.28. 23:30으로 표시되는 회귀 테스트를 추가했습니다.🧭 구현 상세
1. 일반 예약 식당 정보 API
일반 예약 화면에서 다음 endpoint를
useSuspenseQueries로 병렬 호출합니다.thumbnailUrl, 첫 번째imageUrls순서로 사용합니다.2. 포인트 조회와 결제 금액
예약 요청 화면에서
GET /api/v1/points/me를 조회하고 응답의balance를availablePoint로 정규화합니다.전액사용은 최대 사용 가능 포인트를 적용합니다.reservationFee를 사용합니다.3. 일반 예약 및 어디든 예약 생성
draft 유형에 따라 다음 endpoint를 호출합니다.
공통 요청 변환은 다음과 같습니다.
reservedAt: 선택 날짜와 시간을YYYY-MM-DDTHH:mm:ss로 결합adultCount,teenCount,childCount: 인원별 값을 전달requestNote: trim 후 빈 값이면 요청에서 생략usedPoint: 사용자가 확정한 포인트amount: 예약 수수료에서 사용 포인트를 차감한 금액일반 예약은 양의 정수
restaurantId를 전달하고, 어디든 예약은 사용자가 입력한 식당명과 주소를 전달합니다. 성공 응답에reservationId가 없으면 완료로 처리하지 않습니다. 성공 후에는/reservations/{reservationId}로 이동하고 포인트 잔액 query를 invalidate합니다.4. Query key와 cache synchronization
기존 leaf key 값은 유지해 캐시 호환성을 보존했습니다. 예약 생성 응답은 최신 포인트 잔액이나 완전한 예약 상세 객체를 보장하지 않고 성공 후 다른 route로 이동하므로, speculative
setQueryData대신pointQueryKeys.myBalance()만 invalidate합니다.후속 mutation 구현 기준도 저장소 문서와 API 스킬에 반영했습니다.
setQueryDatainvalidateQueriesexact: trueresetQueries,removeQueries를 사용하지 않음5. OpenAPI, 인증, 요청사항 입력
pnpm gen:api-types로 최신 backend schema를 반영했습니다.localStorage.accessToken을 우선 사용하고, 개발 모드에서만VITE_DEV_USER_ACCESS_TOKEN을 fallback으로 사용합니다..env.local에서만 관리합니다.TextField.Box기반 공통 컴포넌트로 합쳤습니다.6. 최신 develop 병합 및 API Client 충돌 해소
origin/develop의 최신 변경을 feature branch에 merge했습니다. 충돌이 발생한apiClient.ts는 HASHI-115에서 추가한 access token resolver와 명시적 Authorization header 보존 동작을 유지하고, develop에서 추가된 localStorage 접근 실패 대응은getStoredAccessToken()책임으로 이동해 결합했습니다. develop의requestSuccessResponse와 관련 export도 그대로 반영했습니다. localStorage 접근 자체가 예외를 던질 때 개발용 토큰으로 fallback하는 회귀 테스트를 추가했습니다.⚙️ 성능 및 안정성 고려
✅ Verification
TZ=UTC VITE_API_BASE_URL='' pnpm exec turbo test --filter=@hashi/client --filter=@hashi/hds-ui --forcepnpm lintpnpm typecheckpnpm buildpnpm format:checkgit diff --checkpnpm lint-staged👀 To Reviewer
isPending확인은 렌더링 이후만 보호하므로, 이번 수정에서 동기 ref 잠금을 추가했습니다.reservationFee, 어디든 예약은 서버 계약의 4,000원을 사용합니다.📚 참고 자료