2023-02-01 RTK Query는 어떻게 use이름Query를 만드는가?

2023-02-01
  • React
  • Development
  • Redux
  • RTK Query

1. 사건의 발단

사건의 발단은 아래에 있는 코드에서 부터다. pokemonApi라는 값이 useGetPokemonByNameQuery 라는 hook을 리턴하고 있는데, 그게 어떻게 가능하냐는 것이다.

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { Pokemon } from "./types";

// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
  reducerPath: "pokemonApi",
  baseQuery: fetchBaseQuery({ baseUrl: "https://pokeapi.co/api/v2/" }),
  endpoints: (builder) => ({
    getPokemonByName: builder.query<Pokemon, string>({
      query: (name) => `pokemon/${name}`,
    }),
  }),
});

// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi;

/** @link https://redux-toolkit.js.org/rtk-query/overview#basic-usage */

그 비밀을 알기 위해서는 결국 createApi의 구현을 확인하는 수 밖에 없다. 레포지토리의 주소는 여기 이다. 레포를 보면 createApibuildCreateApi를 실행한 결과물이라는 사실을 알 수 있다.


2. 코드 뜯어보기

buildCreateApi에 모듈을 넘기고 있는데, coreModule에는 api 생성에 필수적인 로직들이 들어 있을 듯 하고, reactHooksModule에는 api가 react와 호환 가능하도록 기능을 추가해주는 어댑터 역할을 하는 로직이 들어 있을 듯 하다.

그리고 이번 궁금증과는 별개로 이러한 패턴을 잘만 활용하면 확장성 있는 인터페이스를 작성하는데 도움이 될 것 같다는 생각도 든다.

const createApi = /* @__PURE__ */ buildCreateApi(
  coreModule(),
  reactHooksModule()
);

/** @link https://github.com/reduxjs/redux-toolkit/blob/bdf8af37ec868150c771fb78bf0de99f7984841f/packages/toolkit/src/query/react/index.ts#L19-L22 */

아무튼 reactHooksModule에 내가 찾고 싶었던 useGetPokemonByNameQuery를 만드는 로직이 들어 있을 듯 하다. 여기가 아니라면 달리 어느 곳에 커스텀 훅을 만드는 코드가 있을까? 내부를 살펴보자.

export const reactHooksModule = ({
  batch = rrBatch,
  useDispatch = rrUseDispatch,
  useSelector = rrUseSelector,
  useStore = rrUseStore,
  unstable__sideEffectsInRender = false,
}: ReactHooksModuleOptions = {}): Module<ReactHooksModule> => ({
  name: reactHooksModuleName,
  init(api, { serializeQueryArgs }, context) {
    // ...
    return {
      injectEndpoint(endpointName, definition) {
        if (isQueryDefinition(definition)) {
          const {
            useQuery,
            useLazyQuery,
            useLazyQuerySubscription,
            useQueryState,
            useQuerySubscription,
          } = buildQueryHooks(endpointName);
          // ...
          (api as any)[`use${capitalize(endpointName)}Query`] = useQuery;
          (api as any)[`useLazy${capitalize(endpointName)}Query`] =
            useLazyQuery;
        } else if (isMutationDefinition(definition)) {
          const useMutation = buildMutationHook(endpointName);
          safeAssign(anyApi.endpoints[endpointName], {
            useMutation,
          });
          (api as any)[`use${capitalize(endpointName)}Mutation`] = useMutation;
        }
      },
    };
  },
});

/** @link https://github.dev/reduxjs/redux-toolkit/blob/bdf8af37ec868150c771fb78bf0de99f7984841f/packages/toolkit/src/query/react/module.ts#L124-L185 */

reactHooksModule의 반환 값인 init 메서드를 살펴보면, 다시 injectEndpoint라는 메서드를 반환하고 있다. 이 메서드의 구현을 보면 의문은 꽤나 싱겁게 해결된다. buildCreateApi의 최종 리턴값이 될 api 객체에 동적으로 키를 할당하고 있는 것이다.

(api as any)[`use${capitalize(endpointName)}Query`] = useQuery;

그리고 이 값에 대한 타입 정보는 아래와 같은 타입으로 정의되어 있다. infer와 템플릿 리터럴을 사용하여 Definitions의 키를 타입으로 추론하고, 이를 Capitalize로 대문자로 바꾼 뒤 useQuery를 붙여서 키로 사용하고 있다. 이렇게 하면 useGetPokemonByNameQuery와 같은 키를 가진 훅이 동적으로 생성되면서 타입 지원까지 가능한 것으로 보인다.

export type HooksWithUniqueNames<Definitions extends EndpointDefinitions> =
  keyof Definitions extends infer Keys
    ? Keys extends string
      ? Definitions[Keys] extends { type: DefinitionType.query }
        ? {
            [K in Keys as `use${Capitalize<K>}Query`]: UseQuery<
              Extract<Definitions[K], QueryDefinition<any, any, any, any>>
            >;
          } &
            {
              [K in Keys as `useLazy${Capitalize<K>}Query`]: UseLazyQuery<
                Extract<Definitions[K], QueryDefinition<any, any, any, any>>
              >;
            }
        : Definitions[Keys] extends { type: DefinitionType.mutation }
        ? {
            [K in Keys as `use${Capitalize<K>}Mutation`]: UseMutation<
              Extract<Definitions[K], MutationDefinition<any, any, any, any>>
            >;
          }
        : never
      : never
    : never;

3. reactHooksModule 좀 더 살펴보기

궁금증은 풀렸고 reactHooksModule을 좀 더 살펴보려고 한다.

이 함수의 실행 결과는 결국 { init: 함수 } 꼴의 객체이다. 이 객체는 아까 위에서 살펴봤던 buildCreateApi로 전달되고, 아래와 같이 init 메서드를 대신 호출하게 된다.

const initializedModules = modules.map((m) =>
  m.init(api as any, optionsWithDefaults as any, context)
);

다시 init 메서드에서는 injectEndpoint를 반환하고 있는데, 이 메서드는 createApi 내부에서 injectEndpoints를 통해 각 모듈별로 호출된다. 이 과정을 통해 위에서 살펴봤던 useGetPokemonByNameQuery와 같은 훅이 생성되는 것이다.

function injectEndpoints(inject: Parameters<typeof api.injectEndpoints>[0]) {
  const evaluatedEndpoints = inject.endpoints({
    query: (x) => ({ ...x, type: DefinitionType.query } as any),
    mutation: (x) => ({ ...x, type: DefinitionType.mutation } as any),
  });

  for (const [endpointName, definition] of Object.entries(evaluatedEndpoints)) {
    // ...
    context.endpointDefinitions[endpointName] = definition;
    for (const m of initializedModules) {
      m.injectEndpoint(endpointName, definition);
    }
  }

  return api as any;
}

return api.injectEndpoints({ endpoints: options.endpoints as any });

/** @link https://github.dev/reduxjs/redux-toolkit/blob/bdf8af37ec868150c771fb78bf0de99f7984841f/packages/toolkit/src/query/createApi.ts#L316-L359 */

4. 마치며

우연한 기회로 뜯어보게 되었고 이번에 살펴본 부분이 RTK Query의 핵심 로직도 아니지만 궁금한 부분이 생겼을 때 직접 열어보며 확인하는 경험을 해볼 수 있어서 좋았다.

그 과정에서 build 함수를 나누고, 인자로 각 기능을 담은 module 객체를 넘겨주는 패턴을 발견했다. 결국 서비스가 고도화되면서 높아지는 복잡도를 적절한 추상화 레이어 도입과 인터페이스 설계로 해결해야 하는데 좋은 예시를 발견할 수 있었다.

앞으로 더 많은 라이브러리들의 내부 구현을 살펴보고 그 과정에서 얻는 작은 깨달음들을(큰 인사이트가 아닌 단순 기록일지라도) 쪽글 형태로라도 기록해보려고 한다.

Profile picture

saengmotmi

'내가 원하는 건 문학이 아닌 기쁨이다.'