본문으로 바로가기
윤창원uiwwsw · 작은 우주

개발과 기술

React Query 옵션 생성 자동화 — 요청의 의미까지 구분하기

2026-09-10: 설명과 코드 예시를 보완했습니다. 아래 예시는 글의 설계 의도를 전달하기 위한 것이며, 원 프로젝트에 반영된 변경 내역과는 구분합니다.

API 함수를 하나 만들면 query key를 적고, 옵션을 만들고, 다시 비슷한 파일을 만든다. Query, Mutation, Infinite Query를 쓸 때마다 비슷한 손놀림이 이어졌다. “이거 또 써야 해?” 싶어서 옵션 파일을 생성하는 스크립트를 작성했다.

하고 싶었던 일은 API 함수와 옵션 사이의 반복을 줄이는 것이었다. 다만 처음 예시처럼 삭제 함수 하나에도 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와 서비스의 정책에 따라 결정해야 한다. 삭제 후 어떤 조회 캐시를 무효화할지도 사용처 또는 도메인 계층에서 정한다.

Infinite Query는 페이지 계약부터

페이지네이션은 일반 조회 함수에 옵션 이름만 붙여서 만들기 어렵다. 다음 페이지를 나타내는 값과 종료 조건이 필요하기 때문이다.

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

생성기에 남길 일

키와 파일 이름은 정해진 규칙대로 만들면 된다. 조회인지 변경인지, 어떤 캐시를 갱신할지, 페이지가 언제 끝나는지는 그 전에 정해져 있어야 한다.

사용처에는 어떤 API를 호출하는지 남고, 매번 복사하던 옵션은 생성기가 만든다. 처음 이 스크립트를 만들고 싶었던 이유도 그 정도였다. API 하나 추가할 때마다 같은 키와 파일 이름을 다시 적는 일. 그 정도의 귀찮음이라면 자동화해도 좋겠다.

Assisted by AI

© uiwwsw · 본인 소유의 글·이미지에 대한 무단 복제·재게시를 허용하지 않습니다. 개인 열람·원문 링크 공유·검색 색인과 제한적인 미리보기는 허용합니다. 법령상 이용 예외, 기존 이용 허락, 코드·제삼자 자료의 기존 라이선스는 유지합니다. 콘텐츠 이용 안내

무단 복제·재게시를 허용하지 않습니다. 콘텐츠 이용 안내

윤창원이 벨로그에 남긴 글을 이 작은 우주에도 모았습니다. 사진은 누르면 원본 크기로 볼 수 있습니다. 원문의 전체 서식 보기 ↗

모든 글 둘러보기 →