본문 바로가기
개발/사이드 프로젝트

[디프만 13기] next 13 server-side 환경에서 api 호출하기

by Dahna 2023. 8. 22.

안녕하세요. 다나입니다.

이번 글에서는 디프만 13기에 참여하며 진행했던 딩동 프로젝트에서 어떻게 next 13의 server side 환경에서 api를 호출하도록 설정했는지 설명해보려고 합니다.

 

먼저 요구사항을 설정하기 위해 프로젝트를 간단히 요약하면, 동아리/소모임에 처음 가입한 사람이 낯설지 않게 기존 사람들과 친해질 수 있도록 돕는 서비스입니다. 따라서 인증된 사용자가 커뮤니티에 가입하고 커뮤니티 내에서 활동할 수 있도록 설정이 되어야 했습니다. 이번 글에서는 server-side 환경에서 인증된 사용자의 데이터 호출 설정을 주제로 다루어 가려고 합니다.

 

결론부터 말씀드리자면 axios interceptor에서 사용자 인증 설정을 적용했으며, server-side, client-side 환경에 따라 각각 axios instance를 만들어 api를 호출하도록 구현했습니다. 어떻게 이렇게 의사결정하게 되었는지, 구현된 결과물은 무엇인지 설명해보려고 합니다. 

axios로 일원화한 이유

먼저 서버컴포넌트에서 data fetching이 발생할 때는 fe server인 next server에서 처리하게 됩니다. 서버환경에서 data fetching시에는 fetch api가 권장되지만, 상태의 캐싱보다는 최신화가 중요했기 때문에 저희는 react-query에 hydrate해서 사용하도록 했습니다. axios로 http 모듈을 통일하여 구현 및 사용의 편의성을 택했습니다.

 

  • 서버 상태의 최신화가 중요
    • fetch api 사용시 popstate등 일부 route 이벤트시 데이터가 불러와지지 않음
    • error boundary등 react-query가 사용이 더 편리함
  • -> 서버 상태 관리를 react-query로 일원화하고, hydrate하는 방식을 선택

 

axios instance와 interceptor의 파일 구분

또한 사용자 인증을 처리하기 위해서는 accessToken과 refreshToken을 저장할 곳이 필요했는데, server-side와 client-side 모두에서 접근할 수 있는 저장소로 cookie를 채택했습니다. 하지만 server-side와 client-side의 cookie 접근 방식이 달라 파일을 분리하여 작성하게 되었습니다. 파일 구조는 다음과 같습니다.

 

  • publicApi.ts: 인증이 필요하지 않은 axios instance는 환경 구분 필요 없어 하나로 작성
  • privateApi.client.ts: 인증된 axios instance(client-side에서 사용)
  • privateApi.server.ts: 인증된 axios instance(server-side에서 사용)
  • interceptor.ts: 환경 구분 필요 없는 interceptor들
  • interceptor.client.ts: client-side에서 사용하는 interceptor들
  • interceptor.server.ts: server-side에서 사용하는 interceptor들

 

사용자 인증이 필요한 경우 요청 단계에서 헤더 설정이 필요하며, token 리프레시 로직을 추가하기 위해 응답 에러 역시 캐치하여 적용해주어야 합니다. 여기서 주의할부분은 interceptor를 환경별로 파일을 분리하여 작성해준 부분입니다. 이렇게 작성하게된 배경은 환경별로 쿠키 접근 방식이 달랐고, 이에 대응하고자 파일을 분리하여 작성했습니다. next/headers를 client-side에서 import하면 에러가 발생하고, 반대로 document.cookie를 server-side에서 import해도 에러가 발생합니다. 아직 조금더 공부가 필요하지만 server-side 환경에서는 next가 service worker에서 구동되고, client-side 환경에서는 브라우저에서 구동되기 때문에 동작 방식이 달라서 에러가 발생하는 것이 아닐까 생각하고 있습니다. 이 에러에 대응하기 위해서 axios instance, interceptor, api 파일까지 환경별로 작성하여 처리했는데, 더 좋은 방법을 찾기 위해 지금도 고민하고 있습니다.

 

interceptor 코드

다음은 서버환경에서의 interceptor 코드입니다. 요청시에 헤더에 accessToken을 설정해 줍니다. 그리고 인증이 만료되었지만 refreshToken이 존재하는 경우에는 token refresh 요청을 통해 인증을 갱신해줍니다. 그리고 다시 한번 재요청을 진행하여 사용자는 끈김 없이 인증을 유지할수 있도록 처리했습니다. getAccessToken_ 함수도 내부 api 호출 및 쿠키 접근으로 인해 환경별로 작성했습니다.

// interceptor.server.ts
import { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { cookies } from 'next/headers';

import { AUTH_COOKIE_KEYS } from '~/types/auth';
import { AUTH_ERROR_CODES } from '~/types/errorCodes';
import { getAccessTokenServer } from '~/utils/auth/tokenValidator.server';

import { ErrorType } from './api.types';
import { ApiError } from './customError';
import { privateApi } from './privateApi.server';

export const onRequestServer = async (config: InternalAxiosRequestConfig) => {
  try {
    const cookieStore = cookies();
    const accessToken = cookieStore.get(AUTH_COOKIE_KEYS.accessToken)?.value;

    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`; // 헤더 설정
      return config;
    }
    throw new Error('로그인이 필요합니다.');
  } catch (error) {
    return Promise.reject(error);
  }
};

export const onResponseErrorServer = async (
  error: AxiosError<ErrorType, InternalAxiosRequestConfig>,
) => {
  if (error.response) {
    const data = error.response.data;
    const { success, statusCode, errorCode, reason } = data;

    if (statusCode === 401) {
      try {
        const cookieStore = cookies();
        const refreshToken = cookieStore.get(AUTH_COOKIE_KEYS.refreshToken)?.value;

        const validTokenResponse = await getAccessTokenServer({ refreshToken }); // token refresh
        if (!validTokenResponse) {
          throw new ApiError(
            success,
            statusCode,
            AUTH_ERROR_CODES.UNAUTHORIZED_ERROR,
            'accessToken 발급중 오류가 발생했습니다.',
          );
        } else if (validTokenResponse instanceof ApiError) {
          throw validTokenResponse;
        } else {
          const prevRequest = error.config;
          if (!prevRequest) {
            throw new ApiError(
              success,
              statusCode,
              AUTH_ERROR_CODES.UNAUTHORIZED_ERROR,
              '이전 요청 정보가 없습니다.',
            );
          }
          prevRequest.headers['Authorization'] = `Bearer ${validTokenResponse}`;
          return privateApi(prevRequest);
        }
      } catch (e) {
        return Promise.reject(e);
      }
    }

    // 서버에서 보낸 custom 에러 메세지가 없을 경우 기본 메세지를 에러 메세지로 전달
    return Promise.reject(new ApiError(success, statusCode, errorCode, reason));
  }
  if (error.request) {
    // 요청이 이루어 졌으나 응답을 받지 못했습니다.
    // `error.request`는 브라우저의 XMLHttpRequest 인스턴스 또는
    // Node.js의 http.ClientRequest 인스턴스입니다.
    console.log(error.request);
  } else {
    // 오류를 발생시킨 요청을 설정하는 중에 문제가 발생했습니다.
    console.log('Error', error.message);
  }

  return Promise.reject(new Error('요청 도중 에러 발생'));
};

다음으로 클라이언트 환경에서의 interceptor 코드입니다. interceptor.server와 거의 동일하지만 쿠키를 document에서 참조하고 getAccessTokenClient 함수 사용이 다릅니다.

//interceptor.client.ts
import { AxiosError, InternalAxiosRequestConfig } from 'axios';

import { ErrorType } from '~/api/config/api.types';
import { AUTH_ERROR_CODES } from '~/types/errorCodes';
import { getAuthTokensByCookie } from '~/utils/auth/tokenHandlers';
import { getAccessTokenClient } from '~/utils/auth/tokenValidator.client';

import { ApiError } from './customError';
import privateApi from './privateApi';

export const onRequestClient = async (config: InternalAxiosRequestConfig) => {
  try {
    const auth = getAuthTokensByCookie(document.cookie);

    if (auth.accessToken) {
      config.headers.Authorization = `Bearer ${auth.accessToken}`;
    }
    return config;
  } catch (error) {
    return Promise.reject(error);
  }
};

export const onResponseErrorClient = async (
  error: AxiosError<ErrorType, InternalAxiosRequestConfig>,
) => {
  if (error.response) {
    const data = error.response.data;
    const { success, statusCode, errorCode, reason } = data;

    if (statusCode === 401) {
      try {
        const auth = getAuthTokensByCookie(document.cookie);
        const validTokenResponse = await getAccessTokenClient(auth);
        // interceptor.server.ts와 동일
      } catch (e) {
        return Promise.reject(e);
      }
    }
    return Promise.reject(new ApiError(success, statusCode, errorCode, reason));
  }
  if (error.request) {
	 // interceptor.server.ts와 동일
  }

  return Promise.reject(new Error('요청 도중 에러 발생'));
};

 

instance 코드

api instance를 초기화하는 부분 코드를 살펴보겠습니다.

인증이 필요하지 않은 axios instance는 환경 구분 필요 없어 하나로 작성했습니다. 공통 interceptor를 적용해줍니다.

// publicApi.ts
import axios from 'axios';

import { CustomInstance } from '~/api/config/api.types';

import { onResponse, onResponseError } from './interceptor';
import { ROOT_API_URL } from './requestUrl';

const publicApi: CustomInstance = axios.create({
  baseURL: ROOT_API_URL,
});

publicApi.defaults.timeout = 2500;

publicApi.interceptors.response.use(onResponse, onResponseError);

export default publicApi;

client-side, server-side 환경에서 사용하는 api instance를 생성해 주었습니다. 각각 interceptor.client, interceptor.server 에서 가져와 적용해줍니다.

// privateApi.client.ts
'use client';
import axios from 'axios';

import { CustomInstance } from '~/api/config/api.types';
import { onResponse } from './interceptor';
import { onRequestClient, onResponseErrorClient } from './interceptor.client';
import { ROOT_API_URL } from './requestUrl';

export const privateApi: CustomInstance = axios.create({
  baseURL: ROOT_API_URL,
});

privateApi.defaults.timeout = 2500;

privateApi.interceptors.request.use(onRequestClient);

privateApi.interceptors.response.use(onResponse, onResponseErrorClient);

 

// privateApi.server.ts
import axios from 'axios';

import { CustomInstance } from './api.types';
import { onResponse } from './interceptor';
import { onRequestServer, onResponseErrorServer } from './interceptor.server';
import { ROOT_API_URL } from './requestUrl';

export const privateApi: CustomInstance = axios.create({
  baseURL: ROOT_API_URL,
});

privateApi.defaults.timeout = 2500;

privateApi.interceptors.request.use(onRequestServer);

privateApi.interceptors.response.use(onResponse, onResponseErrorServer);

 

 

api 작성 및 사용법

api 작성부

api역시 환경별로 각각 작성해야만 했습니다. api 파일 뒤에 환경명을 명시하는 방법으로 컨벤션을 정해 작성했습니다.

// api/domain/community.api.client.ts
import privateApi from '~/api/config/privateApi.client'; // 포인트

export const getCommunityIdCard = async (id: number) => {
  return privateApi.get(`communities/${id}/idCards`);
};

 

// api/domain/community.api.server.ts
import { privateApi } from '../config/privateApi.server'; // 포인트

export const getCommunityIdCard = async (id: number) => {
  return privateApi.get(`communities/${id}/idCards`);
};

api 사용부

// app/accounts/CallApiTest
'use client';

import { useQuery } from '@tanstack/react-query';

import { getCommunityIdCard } from '~/api/domain/community.api.client'; // 포인트

const CallApiTest = () => {
  const data = useQuery(['communityIdCard'], () => getCommunityIdCard(1), {
    retry: 0,
  });
  console.log(data.data);
  return <div>client fetch test</div>;
};
export default CallApiTest;

 

// app/accounts/page.tsx
import 'server-only';

import { getCommunityIdCard } from '~/api/domain/community.api.server'; // 포인트

import CallApiTest from './callApiTest';

const AccountHome = async () => {
  const communities = await getCommunityIdCard(1);
  console.log(communities);
  return (
    <div>
      로그인이 필요한 페이지 테스트
      <CallApiTest />
    </div>
  );
};

export default AccountHome;

 

 

마무리

next 13을 처음 배우며 프로젝트에 적용해볼수 있어서 즐거웠습니다. 환경별로 파일을 나눠서 작성하는 것이 최선인가 하는 고민이 남아있지만 서버 환경에서 데이터를 불러오면 속도나 보안면에서 이점이 있을것으로 생각되어 앞으로가 기대가 됩니다. 앞으로 next에 대한 이해도를 좀더 높여 보다 유익한 글로 연재해보고 싶네요! 어디선가 app router 도입을 고민하시는 분께 조금이나마 도움이 되었으면 좋겠습니다 :)

 

저희 프로젝트는 동아리/소모임에 처음 가입한 사람이 낯설지 않게 기존 사람들과 친해질 수 있도록 돕는 서비스입니다.

프로젝트 소개가 궁금하시다면 여기를 참고해 주세요! git bebance

 

DingDongㅣ서로의 TMI를 공유하고 친해지고 싶은 마음을 전하세요

DingDongㅣ서로의 TMI를 공유하고 친해지고 싶은 마음을 전하세요 게시: 2023년 7월 19일

www.behance.net

 

댓글