본문으로 건너뛰기
면접 학습
복습세션 →

과제 기반 — 기차예약

토픽 6 · Phase 2

API 통합 — `tosslib` http + `isHttpError` + React Query 키 전략

질문 5개
  1. Q6-1

    PDF 가이드에서 `tosslib`의 `http` 클라이언트와 `isHttpError`를 예시로 보여줬는데요, 이걸 어떻게 활용하셨나요?

    핵심 포인트

    • PDF는 http.get/postisHttpError 두 개를 명시적 예시로 보여줌 — "fetch를 직접 쓰지 말고 tosslib을 쓰라"는 시그널.
    • 4개 API(/api/stations, /api/tickets, /api/reservations POST/GET) 모두 http.get/post로 통일.
    • isHttpError는 사용자 친화 토스트 노출 같은 본격적 에러 처리에 쓰는 타입 가드 — 6시간 안에서는 react-query 자동 에러 관리에 맡기고 직접 호출은 보류 → README to-do.
    • 솔직하게 인정: "isHttpError 활용은 미구현이고, 실제로 했다면 mutation onError에서 토스트 노출이 표준 흐름"이라고 답변에서 명시.
    모범 답안먼저 답해보고 펼치기

    토스 가이드 PDF에 httpisHttpError 두 가지가 코드 스니펫으로 명시돼 있어서, 이 둘을 어떻게 다룰지를 의식적으로 설계했습니다.

    http.get/post는 모든 API 호출에 그대로 적용했습니다. useStationsQuery.ts, useTicketsQuery.ts, useReservationMutation.ts, useReservationQuery.ts 네 군데 모두 http.get<ResponseType>('/api/...', { searchParams: ... }) 또는 http.post<ResponseType>('/api/...', { json: body }) 형태입니다. fetch나 axios를 직접 쓰면 토스가 깔아둔 default header·base URL·인증 처리 같은 것들을 다시 깔아야 하는데, 이건 토스 가이드의 "익숙한 방법" 원칙에도 어긋난다고 봤어요.

    isHttpError는 사용을 보류했습니다. 이건 type guard라 try/catche: unknown을 좁혀서 e.statuse.message에 안전하게 접근하기 위한 도구예요. 본격적인 활용처는 mutation의 onError 콜백에서 4xx/5xx로 분기해 사용자 친화 토스트를 띄우는 자리입니다. 다만 이번 과제에서는 — react-query가 isError boolean과 error 객체로 에러 상태를 자동 관리하기 때문에 — UI 측에서는 isPending으로 버튼만 disabled 하는 정도로 마무리하고, 본격적인 토스트 분기는 README to-do로 미뤘습니다. 솔직히 말씀드리면 이 부분은 미구현이라 인정하고, 실제로 했다면 mutation.onError에서 isHttpError(error) ? toast(error.message) : toast('알 수 없는 오류') 흐름이 표준이라고 답변할 준비를 했습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q6-1-a. isHttpError를 안 쓴 게 마이너스 평가는 아닐까요? → 그래서 README에 명시적으로 to-do로 남겨두고 인터뷰에서도 회피하지 않고 답하기로 했어요. 토스 가이드의 "사용자 경험 개선 지점은 README 등에 문서화" 원칙과도 맞고, 6시간 안에 골격(편도/왕복, 다시 선택 영속화, 검증) 안정성에 우선 시간을 썼다는 우선순위 결정이 더 중요하다고 봤습니다. 못 했다고 회피보다 "왜 우선순위에서 뺐는지" 설명이 더 신뢰감 있다고 생각해요.

    • Q6-1-b. mutation onError에서 isHttpError로 분기하면 구체적으로 어떤 코드가 되나요? → 대략 이런 형태입니다:

      useMutation({
        mutationFn: postReservationApi,
        onError: (error) => {
          if (isHttpError(error)) {
            if (error.status === 409) toast('이미 예약된 좌석입니다');
            else if (error.status >= 500) toast('잠시 후 다시 시도해주세요');
            else toast(error.message);
          } else {
            toast('네트워크 오류가 발생했어요');
          }
        },
      });
      

      type guard로 분기해야 status code 기반의 친절한 메시지가 가능해집니다.

    • Q6-1-c. http.get의 searchParams에 undefined를 그대로 넘기는 패턴은 안전한가요?useStationsQuery에서 searchParams: params?.keyword ? { keyword: params.keyword } : undefined로 처리한 부분 말씀이시죠. tosslib의 http는 ky 기반이라 searchParams가 undefined면 query string을 붙이지 않습니다. 빈 문자열을 넘기면 ?keyword=가 그대로 붙어서 서버가 빈 키워드로 검색하는 사고가 날 수 있어요. 그래서 keyword가 falsy일 때는 명시적으로 undefined를 넘겨 query string 자체를 빼는 패턴을 썼습니다.

    CS · 이론
    • Type Guard (isHttpError): (error is HttpError) 형태의 user-defined type predicate. try/catchunknown을 좁히는 표준 패턴 — TypeScript 4.4+의 useUnknownInCatchVariables 옵션과 짝을 이룹니다.
    • HTTP Client 추상화의 가치: base URL·auth header·retry·logging 같은 cross-cutting concern을 한 곳에 둘 수 있게 합니다. 직접 fetch를 쓰면 같은 boilerplate가 흩어집니다.
    • 에러 처리의 layer 구분: 네트워크 레이어(http) → 데이터 페칭 레이어(react-query의 isError/onError) → UI 레이어(toast). 각 레이어가 자기 역할만 책임지면 디버깅과 테스트가 쉬워집니다.
  2. Q6-2

    queryKey를 `[QUERY_KEYS.STATIONS, params]` 형태로 단순한 두 단계 배열로 두셨는데, 더 정교한 키 구조(factory 패턴 등)를 안 쓴 이유는요?

    핵심 포인트

    • TKDodo의 Effective React Query Keys 글에서 권장하는 query key factory 패턴이 있음.
    • 4개 API뿐인 과제 규모에서는 over-engineering — QUERY_KEYS 객체 + [KEY, params] 두 단계 배열이 충분.
    • params를 두 번째 요소로 두면 react-query가 deep equal로 캐시 키 매칭 — keyword, id 변경 시 자동 새 캐시.
    • "토스 가이드 화려한 방법보다 익숙한 방법" 원칙에 부합.
    모범 답안먼저 답해보고 펼치기

    react-query 커뮤니티에 query key factory라는 패턴이 있어요. TKDodo의 글에서 자세히 설명하는데, key를 객체 함수 트리로 관리해서 keys.stations.detail(id) 같은 형태로 접근하는 방식입니다. 큰 프로덕트에서는 invalidate 범위를 정밀하게 제어할 수 있어서 정말 가치가 있어요.

    다만 이번 과제는 API가 4개뿐이고 — /api/stations, /api/tickets, /api/reservations POST/GET — 각 query는 invalidate가 거의 일어나지 않습니다(역 검색·티켓 조회는 서버에서 데이터가 변하지 않고, 예약 조회는 한 번 만들어진 후 끝). 그래서 query/keys.tsQUERY_KEYS = { STATIONS, TICKETS, RESERVATIONS } 단순 상수 객체만 두고, queryKey를 [QUERY_KEYS.STATIONS, params] 형태의 두 단계 배열로 통일했습니다.

    이렇게 두면 react-query가 두 번째 요소(params)를 deep equal로 비교해서 캐시를 매칭해줍니다. params.keyword가 바뀌면 자동으로 새 캐시 슬롯이 만들어지고, id가 바뀌면 새로운 reservation을 fetch하는 식이에요. factory 패턴 없이도 자동 캐시 격리가 동작합니다.

    토스 PDF의 "화려한 방법보다 평소에 익숙한 방법" 원칙도 여기에 적용된 결정이었습니다. factory 패턴이 더 정교하긴 한데, 이번 과제 규모에서는 주는 이득보다 코드 줄 수와 인지 부담이 더 커요. 만약 API가 10개를 넘어가고 invalidate 범위 제어가 필요해지면 그때 factory로 옮기면 됩니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q6-2-a. invalidate가 일어나는 케이스가 있나요? 예약 후에 reservations를 invalidate해야 하는 상황 같은 건요? → 이번 과제는 그런 상황이 자연스럽게 안 생겼어요. 예약 mutation 성공 후 곧바로 /complete/:id로 navigate하기 때문에 CompletePage가 새로 마운트되면서 useReservationQuery(id)로 fetch가 일어나거든요. 같은 id의 reservation이 또 변할 가능성이 없으니 invalidate가 불필요합니다. 만약 "예약 목록" 화면이 추가되면 그때는 mutation onSuccess에서 queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.RESERVATIONS] })를 호출하는 게 표준이에요.

    • Q6-2-b. params를 객체 그대로 두 번째 요소로 두면 객체 참조가 매번 바뀌어서 캐시 미스가 나지 않나요? → react-query는 queryKey를 deep equal로 비교합니다 — 이게 핵심이에요. 참조가 달라도 안의 값이 같으면 같은 키로 인식해서 동일 캐시 슬롯을 씁니다. useStationsQuery({ keyword: '서울' })을 매 렌더에 새 객체로 호출해도 캐시 미스가 안 나는 이유입니다. 다만 객체 안에 함수나 클래스 인스턴스 같은 비직렬화 값이 들어가면 안 됩니다.

    • Q6-2-c. QUERY_KEYS.STATIONS가 단순 string인데, TypeScript 타입으로 더 강하게 만들 수 있지 않나요? → 가능합니다. as const + literal union으로 좁히거나, enum 또는 query factory로 가면 더 강해져요. 다만 string 상수로도 오타를 막는 1차 효과는 충분하고, 객체 자체를 한 곳에서만 import해서 쓰니까 사용처에서 키를 잘못 쓸 위험이 매우 낮았어요. 6시간 안에서는 이 정도가 적정선이라고 봤습니다.

    CS · 이론
    • Query Key Factory Pattern: TKDodo의 권장 패턴. 큰 앱에서 invalidate 범위·재사용성·타입 안전성을 모두 챙기는 표준이지만, 작은 앱에는 over-engineering이 됩니다.
    • Deep Equality 기반 캐시 매칭: react-query는 queryKey를 structural equal로 비교하므로 객체 참조 변경에 영향받지 않습니다. 이게 hooks를 자유롭게 호출할 수 있게 해주는 토대.
    • Invalidate 범위와 모델링의 관계: 데이터가 어떻게 변하는지를 알면 key 구조도 자연히 따라갑니다. 변경이 거의 없는 read-only 데이터는 단순 키로 충분합니다.
  3. Q6-3

    `staleTime: 60s`, `retry: 1`, `refetchOnWindowFocus: false`를 default로 두셨는데, 각 옵션을 그렇게 결정한 근거는요?

    핵심 포인트

    • staleTime: 60s — 역 목록·티켓 조회는 1분 안에 자주 안 바뀜. 같은 검색을 빠르게 반복할 때 불필요한 네트워크 호출 차단.
    • retry: 1 — 한 번은 일시적 네트워크 흔들림 가능성, 두 번 이상 자동 재시도는 사용자 대기시간만 늘림.
    • refetchOnWindowFocus: false — 기차 예약 흐름은 다른 탭과 오가며 사용 안 함. 자동 refetch가 오히려 진행 중인 입력값을 깜빡이게 만듦.
    • 모든 결정의 공통 잣대: 사용자가 진행 중인 흐름을 흔들지 않는 것.
    모범 답안먼저 답해보고 펼치기

    queryClient의 default option은 lib/queryClient.ts에 한 곳에서 결정했습니다. 세 옵션 모두 "기차 예약"이라는 도메인 흐름의 특성에 맞춰 골랐어요.

    staleTime: 60 * 1000은 1분이에요. 역 목록이나 티켓 정보는 1분 안에 자주 바뀌는 데이터가 아닙니다. 사용자가 SearchPage에서 검색하다가 잠깐 다른 화면으로 갔다가 돌아왔을 때 같은 키워드를 또 fetch하면 네트워크 비용도 들고 사용자 입장에서는 빈 화면이 깜빡일 수 있어요. 1분이면 같은 검색 흐름을 돌아오기에 충분한 시간이라 봤습니다.

    retry: 1은 react-query의 default가 3인데 1로 줄였어요. 한 번 실패한 요청은 두 번째에 성공할 확률이 낮고, 모바일 환경에서 느린 네트워크일수록 자동 재시도가 사용자 대기 시간만 누적시킵니다. 한 번만 재시도하고 그 다음에는 명시적 에러 상태로 가서 사용자에게 빠르게 피드백하는 게 좋다고 봤어요. 금융 도메인에서는 "오래 기다리는 것보다 빠르게 실패하는 것"이 신뢰감을 줍니다.

    refetchOnWindowFocus: false는 react-query의 default가 true예요. 이건 SaaS 대시보드 같은 "탭을 오가며 최신 데이터를 보고 싶은" 화면에 어울리는 default인데, 기차 예약 흐름은 단방향(SearchPage → ConfirmPage)이라 탭 전환과 무관합니다. 더 큰 문제는, 사용자가 인원 BottomSheet를 열어두고 잠깐 알림 탭으로 갔다가 돌아왔을 때 useStationsQuery가 재실행되면서 화면이 깜빡일 수 있다는 점이에요. 진행 중인 흐름을 흔드는 건 UX 손해라 false로 두는 게 맞다고 봤습니다.

    이 세 옵션의 공통점은 — 사용자가 진행 중인 흐름을 흔들지 않는 것이 default 결정의 잣대였다는 점이에요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q6-3-a. staleTime이 1분이면 한 번 받은 응답이 너무 오래 살아남지 않나요? → 살아남는 게 정확히 의도예요. staleTime은 "이 시간 안에는 다시 fetch하지 않는다"는 의미고, 그 시간이 지나면 마운트 시 자동으로 fresh fetch가 일어납니다. SaaS처럼 실시간성이 중요하면 더 짧게 잡아야 하지만, 이번 과제 같이 한 번의 검색 흐름이 1~2분 안에 끝나는 시나리오에서는 1분이면 사용자가 같은 흐름을 한 번 오갔을 때 캐시가 살아있는 정도라 적정합니다.

    • Q6-3-b. retry: 1로 줄이면 일시적 네트워크 끊김에 약해지지 않나요? → 단발 끊김에는 1번이면 충분하고, 연속적 끊김이면 사용자가 직접 재시도하는 게 나아요. 자동 재시도가 누적되면 사용자는 "왜 응답이 안 올까" 답답해하는데, 명시적 에러로 빠르게 끝나면 "다시 시도하기" 같은 행동을 사용자가 직접 결정할 수 있습니다. 다만 retry 함수에 (failureCount, error) => isHttpError(error) && error.status >= 500 ? failureCount < 1 : false 같은 분기를 넣어서 "5xx만 한 번 재시도"로 정교하게 만들 수도 있어요. 6시간 안에서는 default 1로 두고 README에 to-do로 남겼습니다.

    • Q6-3-c. queries default와 mutations default가 다른데, mutations에는 retry를 안 주신 이유는요? → mutation은 side effect가 있는 요청이라 자동 재시도가 위험합니다. 예약 mutation을 자동으로 재시도하면 같은 예약이 두 번 만들어질 수 있어요. react-query default도 mutation은 retry 0이라 그대로 뒀습니다. 만약 idempotent한 mutation이라면 retry를 켤 수 있지만, 예약 같은 non-idempotent operation은 명시적으로 사용자가 "다시 시도하기"를 누르도록 두는 게 안전합니다.

    CS · 이론
    • staleTime vs cacheTime(gcTime): staleTime은 "fresh"로 간주하는 기간, cacheTime은 마지막 구독자가 사라진 후 메모리에서 garbage collect 되기 전 보유 기간. 두 개념을 혼동하면 안 됩니다.
    • Idempotent vs Non-idempotent operation: HTTP 메서드의 멱등성. GET·PUT·DELETE는 idempotent, POST는 non-idempotent. 자동 재시도 정책은 멱등성에 따라 갈립니다.
    • Pessimistic vs Optimistic update: react-query의 mutation은 default가 pessimistic — 응답이 와야 onSuccess가 실행됩니다. 빠른 UI를 원하면 onMutate에서 optimistic update를 직접 구현해야 합니다.
  4. Q6-4

    `useStationsQuery`에 `placeholderData: keepPreviousData`를, `useTicketsQuery`에는 `enabled: params !== null`을 주셨는데, 이 두 옵션은 어떤 UX 문제를 풀려고 한 건가요?

    핵심 포인트

    • keepPreviousData (StationsQuery) — 사용자가 검색어를 한 글자씩 타이핑할 때 매번 빈 리스트가 깜빡이는 문제 차단. 직전 결과를 유지하면서 새 결과로 부드럽게 전환.
    • enabled: params !== null (TicketsQuery) — 가는편 출발역·도착역·날짜가 다 모이지 않으면 fetch 자체를 트리거하지 않음. dependent query 패턴.
    • 두 옵션 모두 "사용자 흐름의 끊김을 줄이는" UX 결정.
    • useReservationQuery에도 같은 enabled: id !== null 패턴 적용 — 일관성.
    모범 답안먼저 답해보고 펼치기

    같은 useQuery 훅이지만 query마다 트리거·로딩 정책이 달라야 자연스러운 UX가 나옵니다.

    useStationsQuery는 검색 입력 즉시 호출되는 query예요. 사용자가 "서울"을 한 글자씩 타이핑할 때 — "ㅅ" → "서" → "서ㅇ" → "서울" — 매 글자마다 query가 실행됩니다. default 동작은 새 query가 실행되는 동안 isLoading=true가 되면서 직전 결과(data)가 undefined로 사라져요. 사용자 입장에서는 "타이핑할 때마다 리스트가 깜빡이는" 답답한 UX가 됩니다. placeholderData: keepPreviousData를 주면 새 query가 진행되는 동안 직전 data를 그대로 유지하고, 새 결과가 도착하는 순간 부드럽게 교체됩니다.

    useTicketsQuery는 다른 문제예요. 이 query는 출발역·도착역·날짜 세 가지가 모두 결정돼야 의미가 있습니다. 검색 조건이 부족한 상태에서 호출되면 안 돼요. 그래서 useTicketsQuery(params: GetTicketApiParams | null) 시그니처로 받고, 컴포넌트 측에서 atom 값을 조립해 모자라면 null을 전달합니다. query 안에서는 enabled: params !== null로 fetch 자체를 막아요. 이게 react-query의 dependent query 패턴입니다.

    useReservationQuery도 같은 dependent 패턴이에요. URL의 :idNumber.parseInt해서 NaN이면 null로 변환한 뒤 useReservationQuery(id)에 넘기고, query 내부에서 enabled: id !== null로 막습니다. 패턴을 일관되게 가져가서 새 query를 추가할 때 똑같이 적용할 수 있도록 했어요.

    두 옵션의 공통점은 — 사용자 흐름의 끊김을 줄이려는 UX 결정이라는 점이에요. 검색 자동완성은 깜빡이지 않게, 의존 query는 데이터가 모일 때까지 기다리게.

    이 답변, 어땠나요?

    꼬리 질문
    • Q6-4-a. keepPreviousData를 쓰면 직전 쿼리의 stale 상태와 새 쿼리의 fresh 상태를 구분해야 할 것 같은데, 그건 어떻게 처리하나요? → react-query v5에서 keepPreviousData를 쓰면 data는 직전 결과를 유지하면서 isPlaceholderData: true 또는 isFetching: true로 "지금 fetch 중"임을 알려줍니다. UI에서는 "부드럽게 직전 데이터를 보여주되 약하게 로딩 인디케이터(반투명 등)로 시각적 시그널"을 줄 수 있어요. 이번 과제에서는 simple하게 직전 데이터만 보여주는 정도로 마무리했습니다 — 검색 응답이 빠른 환경 가정 하에서요.

    • Q6-4-b. enabled: params !== null로 막아두면 처음 진입 시 isLoading이 어떻게 되나요? → enabled가 false인 동안 query는 'pending' 상태긴 하지만 fetch는 일어나지 않아요. isLoading은 enabled가 true가 되는 순간부터 의미를 가집니다. 그래서 "params가 null인 동안은 빈 리스트 또는 안내 UI를 보여주고, params가 set되면 그제야 isLoading 처리"라는 분기가 필요합니다. TicketList에서는 isReturnPhase에 따라 params를 조립해서 한 번 set되면 fetch가 자동으로 시작되도록 풀었어요.

    • Q6-4-c. useTicketsQueryqueryFn에서 params!로 non-null assertion을 쓴 게 좀 거슬리는데요, 더 깔끔한 방법은 없을까요? → 맞아요, 거슬리는 부분이긴 합니다. enabled가 true일 때만 queryFn이 호출된다는 react-query의 invariant를 신뢰해서 !를 쓴 건데, 더 안전하게 가려면 useTicketsQuery(params) 자체의 시그니처를 (params: GetTicketApiParams)로 두고 호출자가 enabled 옵션을 직접 결정하게 분리하는 방식도 가능해요. 그러면 하나의 hook이 "params 받아서 fetch만" 책임지고, 호출 가능 시점은 호출자가 결정하는 분리가 됩니다. 6시간 안에서는 hook 안에 enabled까지 캡슐화한 게 호출자 입장에서 더 단순해서 그쪽으로 갔어요. trade-off가 있는 부분입니다.

    CS · 이론
    • Dependent Query 패턴: 하나의 query 결과나 외부 상태가 또 다른 query의 입력이 되는 패턴. react-query는 enabled 옵션으로 표현합니다.
    • Stale-While-Revalidate (SWR) 모델: HTTP 캐시에서 유래한 개념. "오래된 데이터라도 일단 보여주고, 백그라운드에서 새 데이터를 받아 교체"하는 전략. keepPreviousData가 정확히 이 모델의 표현입니다.
    • Optimistic UI vs Skeleton vs Stale-while-revalidate: 로딩 상태를 다루는 세 가지 패턴. 각각 사용자가 받는 인지 부담이 다릅니다 — 빈 스켈레톤은 "기다림"을 명시, SWR은 "변환"을 부드럽게.
  5. Q6-5

    mutation 성공 후 `resetSelectedTickets` + `navigate(/complete/:id, replace)` 흐름인데, mutation의 invalidate를 일부러 안 쓴 이유가 있나요?

    핵심 포인트

    • 예약 mutation 성공 → /complete/:id navigate → CompletePage가 마운트되며 useReservationQuery(id)로 fresh fetch.
    • 같은 id로 fetch하는 query가 캐시에 없으니 invalidate할 대상 자체가 없음.
    • mutation 결과(reservationId)는 캐시에 직접 prefetch 하지 않고 navigate 후 자연스러운 fetch에 맡김 — 단순함 우선.
    • 만약 "예약 목록" 화면이 있었다면 invalidateQueries({ queryKey: [RESERVATIONS] })가 필요했을 것.
    모범 답안먼저 답해보고 펼치기

    mutation의 onSuccess에서 invalidate를 호출하는 게 react-query의 표준 패턴이긴 한데, 이번 과제에서는 그걸 호출할 자리가 자연스럽게 안 생겼어요.

    흐름을 보면 — useReservationMutation.mutate(body, { onSuccess: ({ reservationId }) => { resetSelectedTickets(); navigate(/complete/${reservationId}, { replace: true }); } }) 입니다. mutation이 성공하면 선택한 티켓 atom을 비우고 CompletePage로 이동하죠. CompletePage는 URL의 :id를 읽어 useReservationQuery(id)로 새 query를 시작합니다. 이 시점에 reservation 캐시는 비어 있어요 — 방금 만들어진 ID라 누구도 조회한 적이 없으니까요. 그러니 invalidate할 대상이 없습니다.

    만약 "예약 목록"을 보여주는 화면이 있었다면, 새 예약이 생긴 뒤 그 목록을 invalidate해야 했을 거예요. queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.RESERVATIONS] })로 모든 reservation 관련 캐시를 stale로 표시하면, 다음 마운트 시 fresh fetch가 일어나죠. 이번 과제는 예약 목록 화면이 없으니 이 코드가 불필요합니다.

    또 한 가지 — mutation의 응답({ reservationId })을 곧바로 reservation 캐시에 prefetch할 수도 있었어요. queryClient.setQueryData([QUERY_KEYS.RESERVATIONS, reservationId], responseData) 같은 식으로요. 그러면 CompletePage 진입 시 추가 fetch 없이 캐시된 데이터로 바로 그릴 수 있습니다. 다만 mutation 응답에는 reservationId만 있고, GET /api/reservations/:id 응답은 Reservation 객체 전체라 모양이 달라서, prefetch를 하려면 mutation 응답 처리를 수정해야 했어요. 단순함을 우선해서 navigate 후 자연스러운 fetch 흐름에 맡겼습니다.

    토스 가이드의 "화려한 방법보다 익숙한 방법" 원칙과도 맞는 결정이라고 봤어요. invalidate·prefetch 같은 react-query 고급 패턴은 필요할 때 쓰면 되지, 미리 깔아두면 코드만 무거워집니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q6-5-a. CompletePage가 새로고침되면 reservation 캐시가 사라져서 다시 fetch가 일어날 텐데, 그건 사용자에게 보이나요? → 새로고침 시 query는 다시 isLoading 상태로 시작합니다. 다만 staleTime이 지나기 전이고 캐시가 살아있다면 즉시 cache hit이라 인지 가능한 로딩이 없어요. SPA 안에서 navigate로 이동했다면 캐시가 살아있지만, 새로고침은 메모리 리셋이라 fetch가 다시 일어납니다. 그래서 CompletePage에는 isLoading 시 스켈레톤이나 안내 UI가 필요한데, 이번 과제에서는 react-query의 isLoading boolean으로 분기하는 정도로 처리했습니다.

    • Q6-5-b. mutation 응답을 setQueryData로 미리 채우는 게 ROI가 어느 정도일까요? → 이 흐름에서는 ROI가 낮습니다. 사용자는 mutation 성공 → navigate 한 번을 거치는데, navigate가 인스턴트라 fetch가 즉시 시작돼요. fetch가 200ms 걸린다고 하면 그 동안 isLoading 스켈레톤이 보이는데, 이걸 setQueryData로 없애면 200ms 깜빡임이 사라집니다. 페이지 진입 즉시 데이터를 보고 싶은 critical path라면 의미가 있지만, 예약 완료 화면처럼 "끝났음"을 알리는 화면은 200ms 정도의 로딩이 오히려 자연스러울 수 있어요. UX 우선순위에 따라 달라집니다.

    • Q6-5-c. mutation 자체에 retry나 onError 핸들링이 더 들어가야 하지 않나요? → 네, 그게 README to-do의 미구현 영역입니다. 이상적으로는 mutation에 (1) onError에서 isHttpError로 분기해 토스트, (2) retry: false 명시(default가 false긴 하지만), (3) optimistic UI 같은 것까지 들어가는데, 6시간 안에서는 골격을 우선해서 mutate(body, { onSuccess }) 단순 흐름에서 멈췄어요. 인터뷰에서 솔직히 인정하고 우선순위 결정 근거를 답변할 준비가 돼 있습니다.

    CS · 이론
    • Cache invalidation의 두 가지 길: invalidateQueries(기존 캐시를 stale로 표시 + 다음 마운트/리포커스 시 자동 fetch) vs setQueryData(응답을 직접 캐시에 주입). 전자는 단순·안전, 후자는 빠르지만 응답 모양 일치가 필요.
    • "There are only two hard things in Computer Science: cache invalidation and naming things" (Phil Karlton의 격언) — 캐시 invalidation은 본질적으로 어려운 문제이므로, 가능하면 캐시 의존이 적은 단순한 흐름으로 설계하는 게 정답일 때가 많습니다.
    • react-query의 mutation lifecycle: mutate 호출 → onMutate(optimistic) → 응답 → onSuccess/onErroronSettled. 각 hook이 어디서 동작하는지 알면 invalidate·prefetch·rollback 패턴이 자연스럽게 따라옵니다.