유연한 API 함수 만들기

전병민·2023년 4월 30일
0

프론트엔드

목록 보기
2/3

서론

프론트엔드 개발을 하다보면 백엔드에 특정 리소스를 요청하기 위한 API 함수들을 작성하곤 합니다. 백엔드 개발자와 함께 API를 설계하다보면 많은 규칙들이 만들어지기 마련이고, 이런 규칙 하에 API 요청 함수를 작성하면 많은 양의 코드가 중복되는 것을 발견할 수 있습니다. 예를들면 다음과 같습니다.

export async function createBoardAPI(board: CreateBoardParams) {
  const response = await fetch(createBoardPath(), {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(board),
  });
  const data = await response.json();
  return data;
};

export async function getBoardsAPI(): Promise<Board[]> {
  const response = await fetch(getBoardsPath());
  const data = await response.json();
  return data;
};


export async function updateBoardAPI({
  params: UpdateBoardParams,
  body: UpdateBoardBody,
}) {
  const response = await fetch(updateBoardPath(params.id), {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  const data = await response.json();
  return data;
};

export async function deleteBoardAPI({ id }: DeleteBoardParams) {
  const response = await fetch(deleteBoardPath(id), {
    method: 'DELETE',
  });

  const data = await response.json();
  return data;
};

이처럼 하나의 리소스에 대해서만 CRUD API를 작성해도 많은 중복 코드를 발견할 수 있습니다. 추가적인 API가 생긴다면 더 많은 중복 코드를 만들어낼 것입니다. 중복 코드를 줄이면서 유연한 API 함수를 만들어내었던 과정을 소개합니다.


본론

fetcher 만들기

interface EndpointOptions {
  data?: Record<string, unknown>;
}

export async function fetcher(
  endpoint: string,
  { method, data, ...customConfig }: EndpointOptions & RequestInit = {
    method: 'GET',
  },
) {
  const headers: RequestInit['headers'] = {};

  if (data) {
    headers['Content-Type'] = 'application/json';
  }

  const config: RequestInit = {
    method,
    headers,
    body: data ? JSON.stringify(data) : undefined,
    ...customConfig,
  };

  try {
    const response = await fetch(
      `${process.env.BASE_URL}/${endpoint}`,
      config,
    );

    const data = await response.json();

    return response.ok ? data : Promise.reject(data);
  } catch (error) {
    return Promise.reject(error);
  }
}

fetcher는 fetch 의 wrapper 함수입니다. fetcher가 해결하는 주요 부분은 다음과 같습니다.

  • body에 들어갈 data 가 존재한다면 Content-Type을 application/json 으로 설정합니다.
  • response.json() 을 통해 response body 텍스트를 json으로 파싱한 결과를 가져옵니다.

fetcher 에 의해 생기는 부수효과로는 다음과 같습니다.

  • 추후 API와 관련하여 한번에 적용해야 할 부분이 있을 때 일일이 모든 API 함수를 살펴볼 필요없이 fetcher 를 수정하면 됩니다.
  • API 함수를 테스트할 때 중복되는 단위 테스트를 fetcher 테스트에서 일임할 수 있습니다. (e.g. response가 ok가 아니라면 promise를 reject합니다)

이제 우리 API 함수는 다음과 같습니다.

export async function createBoardAPI(board: CreateBoardParams) {
  return await fetcher(getBoardsPath(), {
    method: 'POST',
    data: board,
  });
};

export async function getBoardsAPI() {
  return await fetcher(getBoardsPath());
};

export async function updateBoardAPI(
  params: UpdateBoardParams,
  body: UpdateBoardBody,
) {
  return await fetcher(getBoardPath(params.id), {
    method: 'PATCH',
    data: body,
  });
};

export async function deleteBoardAPI({ id }: DeleteBoardParams) {
  return await fetcher(getBoardPath(id), {
    method: 'DELETE',
  });
};

PathGenerator 만들기

기존에는 path를 아래와 같이 함수로 관리하고 있었습니다.

export const getBoardsPath = () => 'boards';
export const getBoardPath = (id: string) => `boards/${id}`;

이 방식의 문제는 쿼리 파라미터가 필요해질 때마다 함수를 늘리거나 기존 함수를 수정해야 한다는 것입니다. 쿼리 파라미터를 유연하게 추가하기 위해서 아래와 같이 코드를 수정할 수 있습니다. (함수 이름도 더 적절히 변경하였습니다.)

export const generateBoardsPath = (params) => {
  const { id, queries } = params ?? {};
  let path = id ? `'boards'/${id}` : 'boards';
  
  if (queries) {
    // URLSearchParams 대신 qs를 사용할 수 있습니다
    const queryParams = new URLSearchParams();
    Object.entries(queries).forEach(([key, value]) => {
      if (value) {
        queryParams.append(key, value);
      }
    });
    path += `?${queryParams.toString()}`;
  }

  return path;
};

다른 API의 path에서도 이용하기 위해서 generate 로직을 추출하여 팩토리 함수를 생성하였습니다.

export function createPathGenerator<T extends QueryParamsType>(
  resourceName: string,
): PathGenerator<T> {
  if (!resourceName) {
    throw new Error('resourceName이 주어지지 않았습니다');
  }

  return (params) => {
    const { id, queries } = params ?? {};
    let path = id ? `${resourceName}/${id}` : resourceName;

    if (queries) {
      const queryParams = new URLSearchParams();
      Object.entries(queries).forEach(([key, value]) => {
        if (value) {
          queryParams.append(key, value);
        }
      });
      path += `?${queryParams.toString()}`;
    }

    return path;
  };
}

generator를 만들 때에는 아래와 같이 코드를 작성합니다.

import { createPathGenerator } from '@/utils/path';

export type BoardsPathQuery = {
  // Add query params here
};

const BOARD_RESOURCE_NAME = 'boards';

export const generateBoardsPath =
  createPathGenerator<BoardsPathQuery>(BOARD_RESOURCE_NAME);

이제 API 함수는 아래와 같습니다.

export async function createBoardAPI(
  { queries }: CreateBoardParams,
  board: CreateBoardBody,
) {
  return await fetcher(generateBoardsPath({ queries }), {
    method: 'POST',
    data: board,
  });
}


export async function getBoardsAPI({ queries }: GetBoardsParams) {
  return await fetcher(generateBoardsPath({ queries }));
}

export async function updateBoardAPI(
  { boardID, queries }: UpdateBoardParams,
  body: UpdateBoardBody,
) {
  return await fetcher(generateBoardsPath({ id: boardID, queries }), {
    method: 'PATCH',
    data: body,
  });
}


export async function deleteBoardAPI({ boardID, queries }: DeleteBoardParams) {
  return await fetcher(generateBoardsPath({ id: boardID, queries }), {
    method: 'DELETE',
  });
}

이렇게 PathGenerator 를 만들어 해결한 문제는 다음과 같습니다.

  • 팩토리 함수 createPathGenerator 를 통해 resource가 추가되어도 얼마든지 새로운 pathGenerator를 만들어낼 수 있습니다.
  • generator를 통해 기존 path에서 쿼리 파라미터를 얼마든지 추가할 수 있습니다.

profile
JavaScript/React 개발자

0개의 댓글