next14에서 fetch interceptor 써보기

JIORNO·2024년 1월 1일
7

Axios가 있는데 왜 fetch를 쓰나요?


작년 11월에 Next14가 출시되고 나서부터 server action이 꽤나 안정되었다.
page router를 사용하면서는 SEO 향상에 필요한 부분이나 클라이언트에서 호출을 막는 서드파티 API를 사용할 때만(medium 글을 불러온다던가 등) getServerSideProps를 사용했었다.

SSR을 이제 컴포넌트단에서도 호출할 수 있고, 무려 폼 액션도 사용할 수 있기 때문에 Next14의 app router 도입을 더 주저할 일은 없을 것 같다.
결국엔 부분적으로 fetch 사용이 더 많아지는 것은 피할 수 없을 것 같다.

회사 프로젝트의 레거시에선 axios의 interceptor를 사용해서 서버의 base url이나 전역적인 에러 핸들링 처리를 해뒀었다.
이 코드를 fetch로 리팩토링하게 되면 보일러플레이트 코드 작성이 꽤나 늘어나게 된다.

// 일반적인 [GET] fetch 함수 작성
export function getProduct = async (id: number) => {
	try {
      const response = await fetch(`${BASE_URL}/products/${id}`, {
      	method: 'GET',
        headers: {
        	"Content-Type": "application/json",
          	"Authorization": `Bearer ${accessToken}`,
        }
      });
      const data = await response.json();
      return data;
    } catch (error: unknown) {
      throw new Error(error);
    }
}

일단 기본적으로 이런 형식의 요청코드를 작성해야하고, fetch는 axios와 다르게 SyntaxError가 나는 경우에는 따로 에러 처리를 해줘야하기 때문에 try 블럭이 조금 더 지저분해 질 수 도 있다.

요구 사항 중에 에러 발생 시 토스트 메시지로 에러 내용을 보여줘야한다. 가 있었는데, 몇몇 API에 429 throtling 이 걸려있기도 했어서 일부 API에서는 의도적으로 토스트를 감춰야하는 등도 감안해서 처리해야했다.

interceptor 구현을 위해 생각했던 것들


처음에는 직접 interceptor 처리를 위해 클래스를 처리해주었다.

class NextFetch {
  private headers: HeadersInit = {
  	"Content-Type": "application/json"
  }

  constructor(headers: HeadersInit) {
    this.headers = headers;
  }
  
  private isTokenException() {}
  public GET() {}
  public POST() {}
  public UPDATE() {}
  public DELETE() {}
  //....
}

처리하다보니 비생산적이라는 생각이 너무 크게 들었다. 꼭 바퀴를 재발명하는 느낌이 들었달까..
지금 다시 생각나는 케이스만 적어보겠다.
body가 있는 경우, 없는 경우, accessToken이 빠지는 경우, baseURL을 매번 클래스 생성마다 넣어주기 불편하니 싱글톤으로 만들어야하고, 이걸 다시 전역상태로 랩핑해서 provider로 넣어줘야한다.

또, next의 경우엔 fetch를 라이브러리단에서 확장해서 next prop을 사용할 수 있다.

const response = await fetch('/products', {
	method: 'GET',
  	headers: {},
  	next: { tag: ['product', 'list'], revalidate: 300 }, // 캐싱키랑 5분동안 revalicate를 막아두는 속성
});

생각보다 공수가 커보여서 라이브러리를 찾던 도중 return-fetch라는 라이브러리를 발견했다.

return-fetch


return-fetch는 대중적인 라이브러리는 아니지만 Next v13에서부터 적용되던 서버 액션을 위해 고안된 라이브러리이다. fetch interceptor를 구현하기에 안성맞춤이었다.

구현에 앞서서 요구사항을 먼저 정리해보겠다.

  • base url을 매번 기입하지 않고도 올바른 API를 쏴줘야한다.
  • axios처럼 return type을 기입한 경우 type-safe해야한다.
  • 에러 처리를 느슨하게 처리해야한다.(토스트, 에러바운더리, 콘솔 등)

개인적인 선호로 앞으로 fetch 대신 사용할 interceptor가 적용된 fetch는 nextFetch로 작성하려고 한다.

Base URL 처리

export const nextFetch = returnFetch({
  baseUrl: 'https://www.MY_BACK_END.com',
  headers: { 'Content-Type': 'application/json' }
});

const response = await nextFetch('/products');
const response = await nextFetch('https://my-s3-address/products');

간혹 백엔드 서버를 여러 개를 사용하거나, public storage를 사용하는 프로젝트에서는 호스트가 달라지는 경우가 있는데 이런 처리는 따로 안해줘도 된다는 점을 알아두면 좋을 것 같다.

type-safe

이 처리가 커링을 사용해서 적용되는데, returnFetch 부분을 따로 떼와서 직접 정의해야할 필요가 있다.

/**
 * SyntaxError나 plain/text로 응답이 오는 경우 처리
 */
function safeJsonParser(text: string): object | string {
  try {
    return JSON.parse(text);
  } catch (e) {
    if ((e as Error).name !== 'SyntaxError') {
      throw e;
    }

    return text.trim();
  }
}

/**
 * 인터셉터, 리턴 타입 지정 용도.
 */
export const returnFetchJson = (args?: ReturnFetchDefaultOptions) => {
  const fetch = returnFetch(args);

  return async <T>(
    url: FetchArgs[0],
    init?: Fetch.Init,
  ): Promise<Fetch.Json<T>> => {
    const response = await fetch(url, {
      ...init,
      body: init?.body && JSON.stringify(init.body),
    });
    const body = safeJsonParser(await response.text()) as T;
    return {
      headers: response.headers,
      ok: response.ok,
      redirected: response.redirected,
      status: response.status,
      statusText: response.statusText,
      type: response.type,
      url: response.url,
      body: body,
    } as Fetch.Json<T>;
  };
};

export const nextFetch = returnFetchJson({
  baseUrl: Config.API_URL,
  headers: { Accept: 'application/json' },
  //...
});

// 사용
const response = await nextFetch<Product.List>(`producst`, {
  method: 'GET',
  next: { tags: ['product', 'list'] }
})

제네릭으로 받아온 부분에서 리턴타입을 따로 정의해주면 type-safe하게 작성이 가능하고, 또 위에서 잠깐 언급했었던 syntaxError의 케이스도 에러를 던져주면 사용성이 훨씬 좋다.
중간에 직접 정의한 Fetch.Init 부분이 있는데 기존 fetch에 확장해서 들어갈 부분을 정의해주면 좋다.

type MethodType = 'GET' | 'POST' | 'PATCH' | 'DELETE';

type JsonRequestInit = Omit<
  NonNullable<RequestInit>,
  'method' | 'body' | 'next'
> & {
  body?: object;
  next?: {
    revalidate?: number | false;
    tags?: string[];
  };
  method: MethodType;
};

declare namespace Fetch {
  type Init = JsonRequestInit;
}

커스텀 에러 처리

이 부분이 가장 고민이었는데 나는 request의 headers에 커스텀 헤더를 넣어서 사용하기로 했다.
커스텀 헤더를 추가해주면 비즈니스 로직 상 토스트를 빼야하는 부분에 대해 직접 액션을 넣어줘서 대응이 가능하다.
먼저 Fetch.Init 타입을 확장해주자.

type ErrorHandleMethod = 'reject' | 'toast' | 'component';
type MethodType = 'GET' | 'POST' | 'PATCH' | 'DELETE';

type JsonRequestInit = Omit<
  NonNullable<RequestInit>,
  'method' | 'body' | 'next'
> & {
  body?: object;
  next?: {
    revalidate?: number | false;
    tags?: string[];
  };
  method: MethodType;
  errorHandleMethod?: ErrorHandleMethod;
  disabled?: boolean;
};

에러 액션 config 이외에 disabled를 넣어줬는데, react-query를 사용할 때 자주 사용했던 enabled 속성을 구현하기 위해 넣어줬다. 이처럼 평소 자주사용하는 기능이 있다면 이쪽에 타입을 정의해주고 interceptor나 response 단에 직접 구현해주면 되겠다.

에러 처리를 SSR과 CSR에서 모두 대응해야한다면 toast를 꼼꼼히 봐야한다. toast를 라이브러리로 대응하는 경우에는 상태처리 로직이 있기 때문에 클라이언트 사이드에서만 실행이 된다는 점이다. 따라서 서버인지 판별해서 default 액션을 정해주면 편리하다.

type CustomHeaderType = HeadersInit & {
  'X-Error-Handle-Method'?: ErrorHandleMethod;
};
const isServerSide =
  typeof window === 'undefined' || typeof document === 'undefined';
export const accessTokenKey = 'MY_ACCESS_TOKEN';
const basicHeaders: CustomHeaderType = {
  'Content-Type': 'application/json',
  'X-Error-Handle-Method': isServerSide ? 'reject' : 'toast',
};

나머지는 비즈니스 로직에 맞추어 nextFetch 정의부에 넣어주면 되겠다.

export const nextFetch = returnFetchJson({
  baseUrl: Config.API_URL,
  headers: { Accept: 'application/json' },
  interceptors: {
    request: async ([url, configs]) => {
      // 로그인 request는 플래그를 따로 처리해준다.
      const isRequestAPI:boolean = /.../;

      const accessToken = getAccessToken(accessTokenKey);
      let headers = {
        ...basicHeaders,
        ...configs?.headers,
      };
        
      /** 
      * 커스텀 헤더가 있는지 확인 후 있는 경우 대응
      * 저는 에러처리랑 disabled 속성을 대응해두었습니다.
      */
      if (isCustomRequest(configs)) {
        if (configs.errorHandleMethod) {
          headers = {
            ...headers,
            'X-Error-Handle-Method': configs.errorHandleMethod,
          };
        }
        
        if (configs.disabled) {
          errorHandler({
            status: 400,
            error: 'System prevented',
            message: 'This action is temporarily prevented',
            isFetchError: true,
            errorHandleMethod: headers['X-Error-Handle-Method'],
          });
          throw new Error('This action is temporarily prevented');
        }
      }
      
      // 로그인 요청이 아닌 경우 헤더에 accessToken을 넣어준다.
      if (accessToken && !isAuthRequest) {
        headers = {
          ...headers,
          Authorization: `Bearer ${accessToken}`,
        };
      }
      if (!configs) {
        configs = {
          headers,
        };
      } else {
        configs.headers = headers;
      }

      return [url, configs];
    },
    response: async (response, reqestArgs) => {
      const [_, reqConfigs] = reqestArgs;
      
      // 헤더에 에러처리 메소드가 정의되어있는 경우에 에러핸들러 config로 전달
      if (!response.ok && reqConfigs?.headers) {
        const data = (await response.json()) as Fetch.Error;
        const headerMap = new Map(Object.entries(reqConfigs.headers));
        const errorHandleMethod = headerMap.get(
          'X-Error-Handle-Method',
        ) as ErrorHandleMethod;
        errorHandler({
          status: data.statusCode,
          error: data.error,
          message: data.message,
          isFetchError: true,
          errorHandleMethod,
        });
      }

      return response;
    },
  },
});

전역 핸들러를 꼼꼼히 두면 try-catch를 피할 수 있어서 코드량이 꽤나 줄어든다.
또, Next 환경에서 이런 마이그레이션 과정에서 fetch를 axios에 가깝게 구현했기 때문에 다른 FE 개발자들과 협업에 있어서도 좋다.

profile
나 『죠르노 죠바나』에겐 옳다고 믿는 꿈이 있다.

0개의 댓글