Skip to content

Feat(client): HASHI-115 실제 API 기반 예약 생성 및 포인트 결제 연동 - #94

Merged
chungyo merged 4 commits into
developfrom
feat/HASHI-115-reservation-api
Jul 13, 2026
Merged

chungyo merged 4 commits into
developfrom
feat/HASHI-115-reservation-api

Conversation

@chungyo

@chungyo chungyo commented Jul 13, 2026 •

Copy link
Copy Markdown
Contributor

📌 Summary

일반 예약과 어디든 예약 흐름을 최신 OpenAPI 계약에 맞춰 실제 서버 API에 연결했습니다.

일반 예약 화면은 route의 restaurantId로 식당 요약과 매장 정보를 병렬 조회해 식당명, 이미지, 주소, 예약 수수료, 요일별 영업시간을 사용합니다. 예약 요청 화면은 현재 사용자 포인트를 조회해 결제 금액을 계산하고, 예약 유형에 따라 일반 예약 또는 어디든 예약 생성 API를 호출한 뒤 응답의 예약 ID로 상세 화면에 이동합니다.

리뷰 이후에는 깨끗한 CI 환경에서 API Client가 불필요하게 초기화되던 barrel 결합을 제거하고, mutation 상태가 다시 렌더링되기 전의 연속 클릭으로 예약 생성 요청이 두 번 실행되는 race condition을 수정했습니다. 요청 중에는 확인/취소와 다이얼로그 닫기를 모두 잠그며, timezone과 환경 변수 회귀 조건을 포함한 테스트를 추가했습니다.

Jira: HASHI-115

📚 Tasks

  • 최신 backend OpenAPI schema로 client generated 타입 갱신
  • 일반 예약 화면에 식당 요약/매장 정보 API 연동
  • 식당별 요일 영업시간, 휴무일, 브레이크 타임 기반 예약 시간 생성
  • 예약 요청 화면에 내 포인트 잔액 API 연동
  • 일반 예약/어디든 예약 생성 API 연동
  • 예약 생성 성공 후 포인트 잔액 query invalidation
  • 포인트/식당상세 query key factory와 query options 분리
  • 일반 예약과 어디든 예약의 요청사항 입력을 공통 컴포넌트로 통합하고 최대 1,000자 적용
  • 최신 develop 병합 및 API Client 충돌 해소
  • API access token 주입 경로와 최신 OpenAPI 타입 반영
  • API/query module을 UI barrel에서 분리해 import-time side effect 제거
  • 같은 tick의 예약 확인 연속 클릭을 동기 잠금으로 차단
  • 예약 생성 중 확인/취소 버튼과 다이얼로그 dismiss 차단
  • 환경 변수 없는 CI 및 UTC 환경 회귀 테스트 보강
  • mutation cache synchronization 기준 문서와 API 스킬 갱신

🔎 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해도 다음 경로가 함께 평가됐습니다.

feature barrel
→ query options / API module
→ shared/api/request
→ shared/api/apiClient
→ VITE_API_BASE_URL 검증

로컬에는 추적되지 않는 .env.local이 있어 문제가 가려졌지만, 별도 환경 변수가 없는 GitHub Test CI에서는 모듈 수집 단계에 예외가 발생했습니다.

수정

  • point, restaurantDetail의 query key/query options/API 신규 export를 UI barrel에서 제거했습니다.
  • 예약 관련 내부 모듈과 테스트는 query key/query options 파일을 직접 import합니다.
  • 실제 네트워크 요청을 검증하지 않는 page/query-options 테스트는 API 함수만 최소 단위로 mock합니다.
  • 테스트 전역에 임의의 API URL을 주입해 결합을 숨기지 않고, 불필요한 API Client 초기화 자체를 제거했습니다.

이제 VITE_API_BASE_URL='' 조건에서도 전체 client 테스트가 모듈 수집 단계부터 정상 실행됩니다.

2. 예약 확인 연속 클릭 중복 요청

원인

기존 handler는 mutation.isPending만 확인했습니다. isPending은 mutation observer의 상태가 반영되어 컴포넌트가 다시 렌더링된 뒤 변경되므로, 그 전에 확인 버튼을 연속 클릭하면 두 handler가 모두 이전 렌더의 false를 보고 mutate()를 실행할 수 있었습니다.

수정 전 회귀 테스트에서 대기 없이 확인 버튼을 두 번 누르면 예약 생성 함수가 실제로 2회 호출되는 것을 확인했습니다.

수정

  • handler 진입 즉시 useRef 기반 동기 잠금을 설정합니다.
  • 잠금 또는 isPending 중 하나라도 활성화돼 있으면 추가 요청을 실행하지 않습니다.
  • mutation onSettled에서 잠금을 해제해 실패 후 재시도가 가능합니다.
  • pending 중 예약과 취소 버튼을 모두 비활성화합니다.
  • pending 중 Escape 또는 dialog open-state 변경으로 모달을 닫으려는 요청도 무시합니다.

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로 병렬 호출합니다.

GET /api/v1/restaurants/{restaurantId}/summary
GET /api/v1/restaurants/{restaurantId}/store-information
  • route param은 요청 전에 양의 정수로 검증합니다.
  • 식당명, 주소, 대표 이미지, 예약 수수료를 예약 draft에 반영합니다.
  • 대표 이미지는 thumbnailUrl, 첫 번째 imageUrls 순서로 사용합니다.
  • 요일별 영업 시작/종료 시각, 휴무 여부, 브레이크 타임을 예약 가능 시간 계산에 사용합니다.
  • 휴무일과 오늘/과거 날짜는 선택할 수 없습니다.
  • 예약 시간은 현재 정책에 따라 30분 간격으로 만들고 브레이크 타임은 제외합니다.
  • 날짜가 바뀌면 기존 시간 선택을 초기화합니다.

2. 포인트 조회와 결제 금액

예약 요청 화면에서 GET /api/v1/points/me를 조회하고 응답의 balance를 availablePoint로 정규화합니다.

maxUsablePoint = min(보유 포인트, 예약 수수료)
finalPaymentAmount = 예약 수수료 - 사용 포인트
  • 숫자가 아닌 입력은 제거합니다.
  • 전액사용은 최대 사용 가능 포인트를 적용합니다.
  • 보유 포인트가 refetch로 감소하면 현재 사용 포인트도 새 상한에 맞춰 보정합니다.
  • 일반 예약은 식당 요약 API의 reservationFee를 사용합니다.
  • 어디든 예약은 서버 계약의 4,000원을 사용합니다.

3. 일반 예약 및 어디든 예약 생성

draft 유형에 따라 다음 endpoint를 호출합니다.

POST /api/v1/reservations
POST /api/v1/reservations/anywhere

공통 요청 변환은 다음과 같습니다.

  • reservedAt: 선택 날짜와 시간을 YYYY-MM-DDTHH:mm:ss로 결합
  • adultCount, teenCount, childCount: 인원별 값을 전달
  • requestNote: trim 후 빈 값이면 요청에서 생략
  • usedPoint: 사용자가 확정한 포인트
  • amount: 예약 수수료에서 사용 포인트를 차감한 금액

일반 예약은 양의 정수 restaurantId를 전달하고, 어디든 예약은 사용자가 입력한 식당명과 주소를 전달합니다. 성공 응답에 reservationId가 없으면 완료로 처리하지 않습니다. 성공 후에는 /reservations/{reservationId}로 이동하고 포인트 잔액 query를 invalidate합니다.

4. Query key와 cache synchronization

pointQueryKeys
└─ myBalance()

restaurantDetailQueryKeys
└─ details()
   └─ detail(restaurantId)
      ├─ main(restaurantId)
      └─ storeInformation(restaurantId)

기존 leaf key 값은 유지해 캐시 호환성을 보존했습니다. 예약 생성 응답은 최신 포인트 잔액이나 완전한 예약 상세 객체를 보장하지 않고 성공 후 다른 route로 이동하므로, speculative setQueryData 대신 pointQueryKeys.myBalance()만 invalidate합니다.

후속 mutation 구현 기준도 저장소 문서와 API 스킬에 반영했습니다.

  • 같은 화면에 완전한 최신 상세 객체를 즉시 반영: setQueryData
  • 이동 후 재조회, 일부 응답, 목록 순서/개수 변경: invalidateQueries
  • 상세 즉시 반영과 목록 갱신이 모두 필요: 두 방식 혼합
  • detail prefix 하위 cache를 제외하고 detail만 갱신: exact: true
  • 일반 mutation 성공 처리에는 resetQueries, removeQueries를 사용하지 않음

5. OpenAPI, 인증, 요청사항 입력

  • pnpm gen:api-types로 최신 backend schema를 반영했습니다.
  • endpoint boundary에서 generated schema를 request/response 타입으로 사용합니다.
  • localStorage.accessToken을 우선 사용하고, 개발 모드에서만 VITE_DEV_USER_ACCESS_TOKEN을 fallback으로 사용합니다.
  • 기존 Authorization header는 덮어쓰지 않습니다.
  • 실제 토큰 값은 .env.local에서만 관리합니다.
  • 두 예약 화면의 요청사항 입력을 HDS TextField.Box 기반 공통 컴포넌트로 합쳤습니다.
  • 입력과 붙여넣기 모두 최대 1,000자로 제한합니다.

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하는 회귀 테스트를 추가했습니다.

⚙️ 성능 및 안정성 고려

  • 식당 요약/매장 정보 query를 병렬 실행해 suspense waterfall을 방지했습니다.
  • query key factory로 캐시 충돌과 잘못된 prefix invalidation 가능성을 줄였습니다.
  • UI barrel과 API/query module을 분리해 import-time side effect 범위를 축소했습니다.
  • 동기 잠금으로 mutation observer 재렌더링 전 중복 요청을 차단했습니다.
  • pending 중 취소와 dismiss를 막아 취소할 수 없는 서버 요청과 UI 상태가 분리되지 않게 했습니다.
  • endpoint boundary에서 route ID와 필수 응답 필드를 검증합니다.
  • 사용 포인트를 보유 포인트와 수수료 이하로 제한해 음수 결제 금액을 만들지 않습니다.

✅ Verification

  • TZ=UTC VITE_API_BASE_URL='' pnpm exec turbo test --filter=@hashi/client --filter=@hashi/hds-ui --force
    • client: 75 files, 345 tests passed
    • HDS UI: 19 files, 178 tests passed
  • pnpm lint
  • pnpm typecheck
  • pnpm build
  • pnpm format:check
  • git diff --check
  • commit hook pnpm lint-staged
  • 대기 없는 예약 확인 2회 클릭 시 API 1회 호출
  • 예약 생성 중 취소 버튼과 Escape dismiss 차단
  • 환경 변수 없는 query/page 테스트 모듈 수집
  • UTC 환경에서 timezone 없는 예약 일시 표시 유지

👀 To Reviewer

  • 이전 구현의 isPending 확인은 렌더링 이후만 보호하므로, 이번 수정에서 동기 ref 잠금을 추가했습니다.
  • 테스트용 API URL을 전역 설정하는 방식은 선택하지 않았습니다. 쿼리 키/UI import가 API Client 초기화를 유발하던 결합을 제거하는 편이 실제 원인을 해결한다고 판단했습니다.
  • 일반 예약 수수료는 식당 요약 API의 reservationFee, 어디든 예약은 서버 계약의 4,000원을 사용합니다.
  • API에 예약 간격 필드가 없어 시간 선택 간격은 현재 정책인 30분으로 고정했습니다.
  • 예약 생성 응답으로 완전한 예약 상세 cache를 추측하지 않고, 현재 query owner가 있는 포인트 잔액만 invalidate합니다.
  • generated OpenAPI 파일은 backend schema 갱신 결과라 diff가 상대적으로 큽니다.

📚 참고 자료

- 식당 상세와 보유 포인트를 실제 API로 조회해 예약 정보와 결제 금액을 동기화한다
- 일반·어디든 예약 생성 후 포인트 캐시를 무효화하고 예약 상세 화면으로 이동한다
- 쿼리 키 팩토리와 mutation 캐시 동기화 기준을 정리해 후속 API 연동의 일관성을 높인다
@github-actions github-actions Bot added ✨ FEAT 새로운 기능 추가 🥸 충영 38기 WEB 김충영 labels Jul 13, 2026
@github-actions

Copy link
Copy Markdown

@chungyo
chungyo marked this pull request as ready for review July 13, 2026 06:14
@chungyo
chungyo requested a review from a team as a code owner July 13, 2026 06:14
- 렌더링 전 동기 잠금으로 연속 확인에 따른 예약 중복 생성을 막습니다.

- API barrel 결합을 제거해 환경 변수 없는 테스트에서도 모듈 초기화 실패를 방지합니다.
@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

@gyeongbibin gyeongbibin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

고생하셨습니당 몇개 코멘트 확인부탁드려용

return RESTAURANT_MOCKS[restaurantId]
}
const parseRestaurantId = (restaurantId?: string) => {
const parsedRestaurantId = Number(restaurantId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Number()를 사용하게되면 1e2, 0x10, 5.0 같은 값도 각각 100, 16, 5로 변환되어, 잘못된 URL이 다른 식당의 정상 ID로 처리될 수 있을 것 같습니다.
route param은 양의 십진수 정수 문자열만 허용하도록 /^[1-9]\d*$/로 먼저 검증한 뒤 Number()로 변환하면 더 안전할 것 같아용~~ 변환 후에는 너무 큰 숫자를 막기 위해 Number.isSafeInteger() 검증도 그대로 유지하면 좋을 것 같습니다!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

번 예약 화면에서 getMyPointBalance 응답을 실제 결제 계산에 사용하게 되는 것같은데
현재 getMyPointBalance는 data=null이거나 balance가 누락된 경우도 0포인트로 정규화하고 있습니다.
Swagger에서는 포인트 이력이 없는 사용자도 { balance: 0 }으로 응답한다고 명시되어 있어서, 실제 0포인트와 응답 계약 위반을 구분하는 편이 안전할 것 같습니다. response === null이거나 balance가 숫자가 아니면 오류를 던지도록 보강하고, null/필드 누락 테스트도 추가하면 좋을듯하네여

- 계약 위반 응답이 실제 0포인트로 처리되는 결제 오류를 방지한다.
- 정상 0포인트와 누락 응답을 테스트 및 스펙에서 구분한다.
@github-actions

Copy link
Copy Markdown

Comment on lines +28 to +33
if (pointState.maxUsablePoint !== maxUsablePoint) {
setPointState({
maxUsablePoint,
usedPoint: Math.min(pointState.usedPoint, maxUsablePoint),
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maxUsablePoint가 바뀌었는지 확인하고나서 컴포넌트가 렌더링되는 도중에 바로 setPointState를 호출하고 있는 것 같네요!

React에서는 렌더링 중 상태 변경을 피하는 게 더 안전하다고 생각하는데 어떠신가요?? 렌더링은 UI를 계산하는 단계인데 그 안에서 다시 상태를 바꾸면 렌더가 한 번 더 발생하고 흐름을 예측하기 어려워질 수 있을 것 같아요!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

확인해보니 이 로직은 maxUsablePoint 변경 시 기존 사용 포인트를 영구적으로 clamp하기 위한 상태 보정입니다. 동일 컴포넌트의 상태를 이전 값과 비교한 뒤 조건부로 갱신하고, 조건 안에서 비교 기준도 최신화해 무한 렌더 없이 수렴합니다.

React 공식 문서에서도 prop 변경에 따른 일부 상태 보정은 Effect보다 렌더 중 조건부 갱신을 권장하고 있어 현재 방식을 유지하려고 합니다!

@chungyo
chungyo merged commit e87a683 into develop Jul 13, 2026
10 checks passed
@chungyo
chungyo deleted the feat/HASHI-115-reservation-api branch July 13, 2026 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ FEAT 새로운 기능 추가 🥸 충영 38기 WEB 김충영

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants