2026-09-10: 설명과 코드 예시를 보완했습니다. 아래 예시는 글의 설계 의도를 전달하기 위한 것이며, 원 프로젝트에 반영된 변경 내역과는 구분합니다.
API 함수가 늘어날 때마다 query key와 옵션을 반복해서 작성하는 일이 번거로웠다. 함수 정보를 바탕으로 옵션 파일을 만드는 스크립트를 작성한 이유다.
다만 반복된 모양을 찾는 것과 요청의 의미를 알아내는 것은 다른 문제다. 처음 작성한 예시에서는 삭제 함수 하나에 Query, Mutation, Infinite Query 옵션을 모두 만들었다. 생성은 쉬워졌지만, 잘못된 사용법까지 쉽게 만들 수 있는 구조였다.
이번에는 자동화할 범위를 좁혀 예시를 다시 정리했다. 아래 코드는 글의 설명을 위한 개선안이며, 기존 프로젝트에 반영한 결과를 보고하는 코드는 아니다.
삭제는 사용자가 명시적으로 실행하는 변경 작업이므로 Mutation으로 다룬다. 이를 일반 Query로 등록하면 조회의 재실행 정책과 변경 작업의 의도가 뒤섞인다. TanStack Query: Mutations
함수 이름만으로 그 의미를 완전히 추론하지 않고, 생성 대상 목록에 종류를 적기로 했다.
const endpoints = [
{
domain: 'section',
name: 'getSection',
source: '#libs/section/getSection',
kind: 'query',
},
{
domain: 'sns',
name: 'deleteSnsLock',
source: '#libs/sns/deleteSnsLock',
kind: 'mutation',
},
];
여기서는 API 함수가 JSON으로 표현 가능한 입력 객체 하나를 받고 Promise를 반환한다고 가정한다. getSection({ sectionId }), deleteSnsLock({ id }) 같은 형태다. 여러 인자나 선택적 인자를 받는 기존 함수에는 별도 어댑터가 필요하다.
아래 함수는 신뢰하는 프로젝트 내부의 목록을 받아 TypeScript 파일 내용을 만든다. TypeScript 소스를 정규식으로 해석하지 않는다. 소스 분석까지 자동화하려면 AST를 사용하는 도구를 별도로 검토할 수 있다.
// optionGenerator.mjs
export function generateOptions(endpoint) {
const { domain, name, source, kind } = endpoint;
if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
throw new Error('API 함수 이름을 확인하세요.');
}
if (kind !== 'query' && kind !== 'mutation') {
throw new Error(`지원하지 않는 요청 종류: ${kind}`);
}
const key = JSON.stringify([domain, name]);
const prefix = `import { ${name} } from ${JSON.stringify(source)};\n`;
if (kind === 'mutation') {
return prefix + `
export const ${name}MutationOptions = () => ({
mutationKey: ${key},
mutationFn: ${name},
});
`;
}
return prefix + `
export const ${name}QueryOptions = (
input: Parameters<typeof ${name}>[0],
) => ({
queryKey: [...${key}, input] as const,
queryFn: () => ${name}(input),
});
`;
}
파일 탐색, 저장 경로, 포매팅은 이 함수 바깥의 프로젝트 스크립트에서 처리한다. 생성 결과를 저장할 때는 대상 경로와 기존 파일 덮어쓰기 정책도 정해야 한다. 이 예시는 생성 규칙을 보여주는 핵심 함수이지, 모든 프로젝트에서 바로 실행할 수 있는 CLI 전체는 아니다.
사용처에서는 조회와 변경이 구분된다.
const section = useQuery(getSectionQueryOptions({ sectionId }));
const deletion = useMutation(deleteSnsLockMutationOptions());
return (
<button
type="button"
disabled={deletion.isPending}
onClick={() => deletion.mutate({ id })}
>
삭제
</button>
);
생성기가 staleTime: Infinity나 retry: 3을 일괄 적용하지 않게 했다. 데이터가 얼마나 오래 유효한지, 실패한 변경 요청을 재시도해도 되는지는 API와 서비스의 정책에 따라 결정해야 한다. 삭제 후 어떤 조회 캐시를 무효화할지도 사용처 또는 도메인 계층에서 정한다.
페이지네이션은 일반 조회 함수에 옵션 이름만 붙여서 만들기 어렵다. 다음 페이지를 나타내는 값과 종료 조건이 필요하기 때문이다.
type Page = {
items: Array<{ id: string }>;
nextCursor: string | null;
};
// listItems는 프로젝트의 API 함수이며 Promise<Page>를 반환한다.
export function itemsInfiniteOptions(category: string) {
return {
queryKey: ['items', category] as const,
initialPageParam: null as string | null,
queryFn: ({ pageParam }: { pageParam: string | null }) =>
listItems({ category, cursor: pageParam }),
getNextPageParam: (lastPage: Page) =>
lastPage.nextCursor ?? undefined,
};
}
이 예시에서는 null이 첫 페이지 요청을, 응답의 nextCursor: null이 마지막 페이지를 의미한다. 이는 해당 API의 약속이다. 다른 API라면 그 계약에 맞춰 바꿔야 한다. 매 요청마다 새 입력을 만들고, 이전 호출의 인자 배열을 수정하지 않는다. TanStack Query: Infinite Queries
키와 파일 이름처럼 반복되는 규칙은 생성기가 맡을 수 있다. 조회인지 변경인지, 어떤 캐시를 갱신할지, 페이지가 언제 끝나는지는 먼저 정의되어 있어야 한다.
이 실험에서 다시 확인한 것은 자동화의 양보다 경계의 중요성이다. 규칙이 명확한 반복을 줄이면서도, 개발자가 결정해야 할 부분은 코드에서 보이게 남겨두고 싶다.
Assisted by AI