본문 바로가기

FrontEnd

Refactoring React(리팩토링 리액트) : Separate API Layer(API 계층 분리)

반응형

대부분의 리액트 프로젝트엔 비동기 데이터 가져오기를 위한 라이브러리가 필요합니다.

해당 API 레이어를 별도의 계층으로 관리하는 방법을 알아봅시다.

해당 게시물은 React Query에 대한 지식을 기본 전제로 합니다.

react


API 레이어를 분리해야 하는 이유

  • UI 코드와 데이터 레이어 코드의 결합도가 높아집니다.
  • 같은 데이터를 가져오는 코드가 중복되어 분산 존재하게 됩니다.
  • 컴포넌트와 API 레이어 관심사 혼재는 코드 읽기를 어렵게 합니다.

최종 분리 결과

  •  전역 api 폴더
    • 공통 설정을 위한 전역 공유 Axios 인스턴스와 리액트 쿼리 클라이언트
    • 요청을 보내기 위한 fetch 함수
  • feature/{feature-name}/api
    • 내부적으로 react-query를 사용하지만 구현 세부하항을 캡슐화하는 커스텀 훅
  • feature/{feature-name}/components
    • 커스텀 훅을 사용하지만 데이터 가져오기 로직과 디커플링된 컴포넌트


단계별 리팩토링 

Step 1 : 쿼리, 뮤테이션을 커스텀 훅으로 추출

  1. 이슈 조회(쿼리 훅)과 이슈 해결 처리(뮤테이션 훅)을 각각 추출합니다.
  2. 해당 훅을 컴포넌트에서 사용합니다.

이슈 조회 훅을 커스텀 훅으로 추출

(useGetIssues라는 훅 명으로 훅의 기능 표현)

// features/issues/api/use-get-issues.ts

import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";

export function useGetIssues(page) {
  const query = useQuery(
    ["issues", page],
    async ({ signal }) => {
      const { data } = await axios.get(
        "https://prolog-api.profy.dev/v2/issue",
        {
          params: { page, status: "open" },
          signal,
          headers: { Authorization: "my-access-token" },
        }
      );
      return data;
    },
    { staleTime: 60000, keepPreviousData: true }
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(
        ["issues", page + 1],
        async ({ signal }) => {
          const { data } = await axios.get(
            "https://prolog-api.profy.dev/v2/issue",
            {
              params: { page: page + 1, status: "open" },
              signal,
              headers: { Authorization: "my-access-token" },
            }
          );
          return data;
        },
        { staleTime: 60000 }
      );
    }
  }, [query.data, page, queryClient]);
  return query;
}

이슈 해결 훅을 커스텀 훅으로 추출

// features/issues/api/use-resolve-issues.ts

import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";

export function useResolveIssue(page) {
  const queryClient = useQueryClient();
  const ongoingMutationCount = useRef(0);
  return useMutation(
    (issueId) =>
      axios.patch(
        `https://prolog-api.profy.dev/v2/issue/${issueId}`,
        { status: "resolved" },
        { headers: { Authorization: "my-access-token" } }
      ),
    {
      onMutate: ...,
      onError: ...,
      onSettled: ...,
    }
  );
}

해당 훅을 컴포넌트에서 사용

// features/issues/components/issue-list.tsx

import { useState } from "react";
import { useGetIssues, useResolveIssue } from "../../api";

export function IssueList() {
  const [page, setPage] = useState(1);

  const issuePage = useGetIssues(page);
  const resolveIssue = useResolveIssue(page);

  const { items, meta } = issuePage.data || {};

  return (
    <Container>
      <Table>
        <head>...</thead>
        <tbody>
          {(items || []).map((issue) => (
            <IssueRow
              key={issue.id}
              issue={issue}
              resolveIssue={() => resolveIssue.mutate(issue.id)}
            />
          ))}
        </tbody>
      </Table>
      <PaginationContainer>...</PaginationContainer>
    </Container>
  );
}

코드는 길지만 작업할 것은 별것 없습니다.

얻은 것

  • 컴포넌트는 이제 API 가져오기와 로직과 격리됩니다.
  • 컴포넌트는 더 이상 우리가 Axios를 사용하는지, 어떤 API 엔드포인트가 호출되는지 설정 관련 정보를 몰라도 됩니다.
  • 더 이상 데이터 미리 가져오기 또는 낙관적 업데이트와 같은 데이터 가져오기 로직의 구현 세부사항을 몰라도 됩니다.

Step 2 : 공통 로직 재사용

추출한 훅에서 중복되는 부분이 두 가지 보입니다.

  • 쿼리키 생성
  • axios.get과 같은 fetcher

1. 하드 코딩된 쿼리 키 대신 쿼리키 제너레이터 함수 사용

// features/issues/api/use-get-issues.ts

import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";

const QUERY_KEY = "issues";

// this is also used to generate the query keys in the useResolveIssue hook
export function getQueryKey(page) {
  if (page === undefined) {
    return [QUERY_KEY];
  }
  return [QUERY_KEY, page];
}

// shared between useQuery and queryClient.prefetchQuery
async function getIssues(page, options) {
  const { data } = await axios.get("https://prolog-api.profy.dev/v2/issue", {
    params: { page, status: "open" },
    signal: options?.signal,
    headers: { Authorization: "my-access-token" },
  });
  return data;
}

export function useGetIssues(page) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { signal }),
    { staleTime: 60000, keepPreviousData: true }
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(
        getQueryKey(page + 1),
        ({ signal }) => getIssues(page + 1, { signal }),
        { staleTime: 60000 },
      );
    }
  }, [query.data, page, queryClient]);
  return query;

2. fetch 함수 추출

// features/issues/api/use-resolve-issues.ts

import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import * as GetIssues from "./use-get-issues";

async function resolveIssue(issueId) {
  const { data } = await axios.patch(
    `https://prolog-api.profy.dev/v2/issue/${issueId}`,
    { status: "resolved" },
    { headers: { Authorization: "my-access-token" } }
  );
  return data;
}

export function useResolveIssue(page) {
  const queryClient = useQueryClient();
  const ongoingMutationCount = useRef(0);
  return useMutation((issueId) => resolveIssue(issueId), {
    onMutate: async (issued ) => {
      ongoingMutationCount.current += 1;

      // use the query key generator from useGetIssues
      await queryClient.cancelQueries(GetIssues.getQueryKey());

      const currentPage = queryClient.getQueryData(
        GetIssues.getQueryKey(page)
      );
      const nextPage = queryClient.getQueryData(
        GetIssues.getQueryKey(page + 1)
      );

      // let me spare you the rest
      ...
    },
    onError: ...,
    onSettled: ...,
  });
}

얻은 것

  • DRY 함(반복 제거)
  • 커스텀 훅 계층에서 fetch 함수 계층 분리

계층 분리란 무슨 뜻일까요?
어떤 코드의 의존성의 수정이 사용처(dependent)의 수정을 유발하지 않으면 계층 분리가 된 것입니다.

 

아래 코드는 axios와 커스텀 훅이 커플링 되어, axios를 교체하면 해당 훅을 수정해야 합니다.

라이브러리를 교체하는 것은 핵심 비즈니스 로직과 전혀 관련이 없습니다.

export function useGetIssues(page) {
  const query = useQuery(
    ["issues", page],
    ({ signal }) => axios.get(...),
    ...
  );

아래 코드를 사용하면 axios를 사용하던, fetch를 사용하던, firebase client를 사용하던 해당 훅의 구현에는 전혀 영향을 미치지 않습니다.

반대로 useQuery(비동기 데이터 스토어)를 제공하는 라이브러리를 변경하고 싶을 때

fetcher와 관련된 코드는 전혀 영향을 받지 않습니다.

export function useGetIssues(page) {
  const query = useQuery(
    ["issues", page],
    ({ signal }) => getIssues(page, { signal }),
    ...
  );

즉 비동기 상태 관리(훅)과 데이터 fetcher의 책임 두 개를 잘 분리했습니다.

Step 3 : 전역 Axios 인스턴스 사용

API URL의 중복은 다음과 같은 문제 발생 시 골칫거리입니다.

  • API가 다른 하위 도메인으로 이동했습니다(드뭄).
  • 새로운 API 버전이 있습니다(가끔).
  • 개발 및 프로덕션에 서로 다른 디폴트 URL을 사용해야 합니다(잦음).
API URL의 중복

해당 문제를 해결해 봅시다.

1. 전역 API 폴더에 axios.ts 파일 생성

// api/axios.ts

import Axios from "axios";

export const axios = Axios.create({
  baseURL: "https://prolog-api.profy.dev/v2",
});

2. 환경 변수로 디폴트 URL 설정

assert를 사용하면, 해당 환경변수가 없으면 빌드가 안되므로, 쉽게 누락을 알 수 있습니다.

// api/axios.ts

import assert from "assert";
import Axios from "axios";

assert(
  process.env.NEXT_PUBLIC_API_BASE_URL,
  "env variable not set: NEXT_PUBLIC_API_BASE_URL"
);

export const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});

.env 파일에 다음과 같이 환경에 따라 base url을 설정합니다.

// .env

NEXT_PUBLIC_API_BASE_URL=https://prolog-api.profy.dev/v2

이제 가져오기 함수에서 디폴트 URL과 관련된 정보를 삭제할 수 있습니다.

// features/issues/api/use-get-issues.ts

import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { axios } from "@api/axios";
import type { Page } from "@typings/page.types";
import type { Issue } from "@features/issues";

...

async function getIssues(page, options) {
  // no need to add the base URL anymore
  const { data } = await axios.get("/issue", {
    params: { page, status: "open" },
    signal: options?.signal,
    headers: { Authorization: "my-access-token" },
  });
  return data;
}

export function useGetIssues(page) {
  ...
}

얻은 것

  • 가져오기 함수와 디폴트 URL의 분리
    • 환경 변수를 조정하기만 하면 코드 한 줄 변경하지 않고 URL을 바꿀 수 있음
  • API URL의 dry함

Step 4 : 전역 Axios 인스턴스에 공통 헤더 설정

인증 메커니즘에서 가져오기 함수를 분리하는 것이 좋습니다.

인증 메커니즘과 가져오기 기능 분리

Axios의 요청, 응답 인터셉터에서 해당 기능을 구현합니다.

// api/axios.ts

import assert from "assert";
import Axios, { AxiosRequestConfig } from "axios";

assert(
  process.env.NEXT_PUBLIC_API_BASE_URL,
  "env variable not set: NEXT_PUBLIC_API_BASE_URL"
);

assert(
  process.env.NEXT_PUBLIC_API_TOKEN,
  "env variable not set: NEXT_PUBLIC_API_TOKEN"
);

function authRequestInterceptor(config: AxiosRequestConfig) {
  config.headers.authorization = process.env.NEXT_PUBLIC_API_TOKEN;
  return config;
}

export const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});

axios.interceptors.request.use(authRequestInterceptor);

다시 환경 변수를 사용하여 액세스 토큰을 저장합니다.

// .env

NEXT_PUBLIC_API_BASE_URL=https://prolog-api.profy.dev
NEXT_PUBLIC_API_TOKEN=my-access-token

얻은 것

  • fetcher에서 인증 로직 디커플링

Step 5 : Query Client와 전역 설정

모든 쿼리에서 사용되는 중복 설정 정보를 중앙 집중식으로 관리하고 싶습니다.

모든 쿼리에서 중복되는 설정들

api/query-client.ts 위치에서 쿼리 클라이언트를 생성하며 기본 설정을 적용합니다.

// api/query-client.ts

import { QueryClient } from "@tanstack/react-query";

const defaultQueryConfig = { staleTime: 60000 };

export const queryClient = new QueryClient({
  defaultOptions: { queries: defaultQueryConfig },
});

얻은 것

  • 이제 모든 query 훅에서 기본 설정 관련 정보를 제거할 수 있습니다.

Step 6 : API 함수 추출

해당 단계는 모든 API가 단일 위치에서 정의되는 RTK Query에서 영감을 받았습니다.
Bulletproof react project structure에 위배되긴 합니다만, 위와 같은 리팩토링이 어떤 장점이 있는지 봅시다.

  • 현재 fetch 함수의 위치가 query 훅과 같은 위치에 있습니다.
    • fetcher의 수정은 query가 있는 파일을 수정하며, 이는 관심사별 모듈화가 잘 안되어 있다는 뜻입니다.
  • 다른 파일의 여러 훅이 동일한 끝점을 사용할 수 있습니다.
    • 공유 끝점이 변경되면, 변경의 여파가 여러 파일에 전파됩니다.
    • 해당 문제를 해결하는 방법은 여러 가지가 있습니다.
      • 끝점 반환 함수 만들기
      • 끝점 상수로 선언하기
      • 두 fetcher 함수를 하나의 파일로 결합하기
중복돠는 끝점

api/issues에 issues 끝점과 관련된 함수를 정의합니다.

// api/issues.ts

import { axios } from "./axios";

const ENDPOINT = "/issue";

export async function getIssues(page, filters, options) {
  const { data } = await axios.get(ENDPOINT, {
    params: { page, ...filters },
    signal: options?.signal,
  });
  return data;
}

export async function resolveIssue(issueId) {
  const { data } = await axios.patch(`${ENDPOINT}/${issueId}`, {
    status: "resolved",
  });
  return data;
}

 해당 가져오기 함수를 임포트해서 사용합니다.

import { getIssues } from "@api/issues";

export function useGetIssues(page) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { status: "open" }, { signal }),
    { keepPreviousData: true }
  );

리팩토링 리뷰

  1. REST API 관련 코드는 모두 글로벌 api 폴더에 서로 가깝게 위치합니다.
    • 해당 폴더에는 Axios 및 쿼리 클라이언트와 요청을 보내는 fetch 함수가 포함됩니다.
    • API 변경은(예: 다른 버전, 기본 URL, 헤더 또는 끝점 정보 변경) api 폴더 내에 있는 파일들만 수정하면 됩니다.
  2. 요청과 쿼리 공유 설정은 한곳에 위치합니다. (api/axios.ts or api/query-client.ts)
    • 전체 앱의 공통 설정을 변경하기 쉽습니다.
    • 특정 훅만 잘못 설정할 여지가 없습니다.
  3. 쿼리 훅은 API 레이어에 대한 정보를 모릅니다.
    • 엔드포인트를 모릅니다
    • 데이터 가져오기에 사용하는 라이브러리를 모릅니다.
    • 오직 비동기 상태관리를 처리합니다. 해당 책임은 API 레이어에 위임합니다.
  4. UI 컴포넌트는 모든 데이터 가져오기 로직에서 분리됩니다.
    • 컴포넌트 코드는 페이지네이션 데이터가 프리패치 되는지 모릅니다.
    • 변형이 낙관적 업데이트를 트리거하는지 모릅니다.

추가 리팩터링 : 라이브러리 래핑을 통한 디커플링

아래 작업은 ROI가 높지는 않지만 시도해 봄직 합니다.

  • fetcher에서 api 라이브러리 의존성을 분리합니다.

api/api-client.ts 폴더에 axios 함수 메서드 관련 정보를 다 포함합니다.

config를 통째로 넘기지 않는 이유는, 해당 작업을 수행하면 hook에서 axios 설정을 전부 사용할 수 있고
이는 axios와 fetch 함수간 인터페이스에 기반한 커플링을 형성합니다.
// api/api-client.ts

const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});

export const apiClient = {
  get: (route, config) =>
    axios.get(route, { signal: config?.signal, params: config?.params }),
  post: (route, data, config) =>
    axios.post(route, data, { signal: config?.signal }),
  put: (route, data, config) =>
    axios.put(route, data, { signal: config?.signal }),
  patch: (route, data, config) =>
    axios.patch(route, data, { signal: config?.signal }),
};

이제 API 클라이언트와 fetch 함수 간의 커플링이 사라졌습니다.
fetch 함수를 한 줄도 변경하지 않고 axios를 교체할 수 있습니다.

// api/issues.ts

import { apiClient } from "./api-client";

export async function getIssues(...) {
  const { data } = await apiClient.get(ENDPOINT, {
    params: { page, ...filters },
    signal: options?.signal,
  });
  return data;
}

이제 쿼리 훅을 봅시다. useQuery의 결과를 그대로 리턴하고 있습니다.

이 경우 컴포넌트와 리액트 쿼리 라이브러리 간의 강결합이 형성됩니다.

따라서 리액트 쿼리를 apollo로 변경하거나 rtk-query로 변경하는 것을 어렵게 합니다.

해당 라이브러리와의 결합 계층은 해당 훅 내부로만 한정합시다.

export function useGetIssues(page: number) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { status: "open" }, { signal }),
    { keepPreviousData: true }
  );

  ...

  return query;
}

다른 격리 계층을 도입하는 방법은 아주 간단합니다.

파라미터를 래핑하거나, 리턴값을 래핑합니다. (인터페이스 변경)

// features/issues/api/use-get-issues.ts

...

export function useGetIssues(page: number) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { status: "open" }, { signal }),
    { keepPreviousData: true }
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) =>
        getIssues(page + 1, { status: "open" }, { signal })
      );
    }
  }, [query.data, page, queryClient]);

  return {
    data: query.data,
    isLoading: query.isLoading,
    isError: query.isError,
    error: query.error,
    refetch: async () => {
      await query.refetch();
    },
  };
}
refetch를 그대로 리턴하지 않는것에 주목하세요
그러면 다시 react-query에 커플링이 걸립니다.

이제 react-query를 교체할 때 query hook들만 수정하면 됩니다.

 

wrapper 계층의 단점은 래퍼를 만들고 유지보수하는 오버헤드입니다.

단일 라이브러리를 쉽게 교체하는게 정말 큰 이득을 가져올까요?

상황에 따라 다른 라이브러리가 이전 라이브러리의 기능과 완벽하게 호환되지 않을 수 있으며,

결국 다른 코드도 작성하게 될 공산이 큽니다.

 

컨테이너 컴포넌트와 프리젠테이션 컴포넌트를 분리해서 작성하는 경우,

그냥 컨테이너 컴포넌트 자체를 거대한 의존성 덩어리로 생각하는 것도 나쁘지 않을 수 있습니다.

그러면 프리젠테이션 컴포넌트와 훅 간의 디커플링이 존재하므로,

컨테이너 컴포넌트를 통째로 날리고 react-query를 교체하고, 다시 프리젠테이션 컴포넌트에 데이터를 바인딩하면 되겠습니다.

 

상황에 따라 적절한 추상화를 선택하는 것이 좋겠습니다.

 

반응형