- "API 연동해줘 / 연동 세팅하자"
- "orval / 제너레이터 나온 거 붙여줘"
- mock 데이터를 실제 API 호출로 교체하는 작업
docs/architecture/state.md→ 서버 상태는 React Query가 단일 출처docs/architecture/structure.md→_queries/위치 규칙apps/timo-web/api/generated/→ orval이 생성한 훅·zod 스키마·모델 (직접 수정 금지)apps/timo-web/api/client/custom-instance.ts,apps/timo-web/api/client/axios.ts→ axios 인스턴스, 에러 인터셉터apps/timo-web/api/error/api-error.ts→ApiError,parseApiError
api/generated/endpoints/{domain}/{domain}.ts— orval이 생성한 react-query 훅(useGetXxx)과 순수 fetcher 함수,queryKey헬퍼,getXxxQueryOptions팩토리logout/withdraw/deleteTodo/deleteTag/completeOnboarding처럼 실제로 반환 데이터가 없는 엔드포인트는 백엔드가data를 JavaObject(빈 스키마)로 선언해서 여전히data: zod.unknown().optional()로 생성된다 — 이건 버그가 아니라 정상이다(정말로 shape이 없는 값이므로).api/generated/models/*.ts— 응답 DTO 타입. 실제로 항상 오는 필드는 이제?없이 생성된다. 다만 gen 스키마는 백엔드가 스펙을 바꿀 때마다 사람 리뷰 없이 재생성되므로 100% 맹신하지는 않는다.- 그래도 도메인 로컬 zod 스키마(
app/(domain)/_types/*.ts)로 한 번 더 검증한다 — 로컬 스키마의.parse()가 "백엔드가 계약을 조용히 느슨하게 바꿨는지"를 잡아내는 방어선 역할을 한다. 로컬 스키마 작성 시 gen 응답 스키마를 기반 초안으로 그대로 가져다 쓰고, UI가 추가로 좁혀야 하는 부분(더 좁은 enum, 파생 필드 등)만 수정한다.
/v3/api-docs에서 대상 성공 응답의application/jsoncontent type과 필드별required를 확인한다.- 확인 결과를 반영하도록
pnpm gen:api를 실행한 뒤 생성된 모델·Zod 스키마를 검토한다. api/generated/endpoints/{domain}/{domain}.ts에서 필요한 fetcher 함수(예:getHome)와getGetXxxQueryKey헬퍼를 찾는다.- 응답이
BaseResponseXxx { status, message, data }형태로 감싸져 있는지api/generated/models/에서 확인한다 — 실제 필요한 값은.data에 있다.
- 도메인
_types/*.ts에 이미 손으로 작성한 zod 스키마가 있는지 확인한다. 없으면 생성된 응답 zod 스키마(api/generated/endpoints/{domain}/{domain}.zod.ts)를 기반 초안으로 가져와 작성한다 — 이제 실제 shape을 담고 있으므로 신뢰할 수 있다. - gen 스키마와 UI가 실제로 필요로 하는 shape이 다르면(더 좁은 enum, 파생 필드 등) 로컬 스키마에서 추가로 좁힌다.
select에서 로컬 스키마의.parse()/.safeParse()로 최종 검증한다 — gen 스키마를 UI까지 직접 노출하지 않고 반드시 로컬 스키마를 한 번 거친다. 백엔드가 스펙을 다시 느슨하게 바꿔도 이 단계가 조용히 깨지지 않고 파싱 실패로 드러나게 하는 방어선이다.
- 위치:
app/(domain)/_queries/use-xxx-query.ts(조회) /use-xxx-mutation.ts(변경) - 네이밍: 파일명은
-query.ts/-mutation.ts로, export하는 함수명은~Query/~Mutation으로 끝낸다. 파일당 훅 1개가 기본이다. →docs/conventions/naming.md참조 useSuspenseQuery를 쓸지useQuery를 쓸지는 컴포넌트가AsyncBoundary(Suspense) 안에 있는지로 결정한다.AsyncBoundary로 감싸져 있으면useSuspenseQuery를 쓴다.- 주의: 생성된
getGetXxxQueryOptions()팩토리를 그대로useSuspenseQuery에 스프레드하면UseQueryOptions가 허용하는skipToken과UseSuspenseQueryOptions가 타입 충돌을 일으킨다.queryKey/queryFn은 생성된getGetXxxQueryKey+ 원본 fetcher 함수로 직접 조립한다. select에서BaseResponse.data를 언랩하고 로컬 zod 스키마로.parse()해서 반환한다.
조회 훅 예시 (_queries/use-home-view-query.ts):
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
getGetHomeQueryKey,
getHome,
} from "@/api/generated/endpoints/home/home";
import { homeViewDataSchema } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type";
export const useHomeViewQuery = ({ filter, baseDate }: GetHomeViewParams) =>
useSuspenseQuery({
queryKey: getGetHomeQueryKey({ filter, baseDate }),
queryFn: ({ signal }) => getHome({ filter, baseDate }, undefined, signal),
select: ({ data }) => homeViewDataSchema.parse(data),
});변경 훅 예시 (_queries/use-withdraw-mutation.ts):
"use client";
import { useMutation } from "@tanstack/react-query";
import { withdraw } from "@/api/generated/endpoints/auth/auth";
import { useClearSession } from "@/app/[locale]/(main)/settings/_hooks/use-clear-session";
export const useWithdrawMutation = () => {
const clearSession = useClearSession();
return useMutation({
mutationFn: () => withdraw(),
onSuccess: () => {
clearSession();
},
});
};- mock 함수 import를 제거하고 새 훅으로 교체한다.
- 다른 도메인이 같은 mock을 참조하고 있는지 확인(
Grep)하고, 참조 중이면 mock 파일 자체는 지우지 않는다. - 로그인 연동 전이라 요청이 401/네트워크 에러로 실패할 수 있다 —
useSuspenseQuery가 던지는 에러는 상위error.tsx(라우트 레벨) 또는AsyncBoundary의errorFallback이 잡는지 확인한다. 둘 다 없으면 최소한 라우트 레벨error.tsx존재 여부를 확인해 화면이 완전히 깨지지 않게 한다.
- 훅 파일명/함수명이
-query/~Query,-mutation/~Mutation컨벤션을 따르는가 -
api/generated/내부 파일을 직접 수정하지 않았는가 (재생성 시 사라짐) - 응답을 로컬 zod 스키마로
.parse()해서 검증했는가 (gen 응답 스키마를 그대로 UI까지 노출하지 않았는가) -
useSuspenseQuery사용 시queryKey/queryFn을 직접 조립해 타입 충돌을 피했는가 -
tsc --noEmit,eslint통과 - 다른 도메인이 참조하는 mock을 실수로 지우지 않았는가