(꼼꼼 프로젝트) 클라이언트와 서버 둘다 호환되는 fetch 구현

Updated:

배경

next.js에서는 서버 단의 fetch를 override해서 중복 통신 처리 같은 유익한 기능을 추가했다.

평소에는 서버 컴포넌트와 클라이언트 컴포넌트에서 그냥 fetch를 쓰면 해당 기능이 첨가된 통신을 사용할 수 있다.

여기서 문제는 access token과 refresh token이 들어갈 때이다.

서버 컴포넌트에서는 next/headers의 쿠키를 사용하여 쿠키에 접근하고 클라이언트 컴포넌트에서는 브라우저 쿠키로 쿠키에 접근한다.

서로 다른 방식은 refresh token rotation을 위한 함수를 구현할 때 문제가 된다.

구현

아래의 코드는 나에게 업무가 넘어오기 전 다른 팀원이 짠 코드다. middleware 활용하여 구현된 방식이었다.

import { NextResponse } from "next/server";
import type { NextFetchEvent, NextRequest } from "next/server";

import { PostTeamIdAuthRefreshTokenResponse } from "./lib/apis/type";

export function middleware(request: NextRequest, event: NextFetchEvent) {
  const hasAccessToken = request.cookies.has("accessToken");
  const hasRefreshToken = request.cookies.has("refreshToken");
  const { pathname } = request.nextUrl;

  // NOTE - 로그인 후 로그인, 회원가입 페이지에 접근하는 경우
  if (
    hasAccessToken &&
    (pathname.startsWith("/login") || pathname.startsWith("/signup"))
  ) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  // NOTE - 로그인 전에 랜딩, 로그인, 회원가입 페이지 외에 다른 페이지에 접근하는 경우
  if (
    !hasAccessToken &&
    !(
      pathname.startsWith("/login") ||
      pathname.startsWith("/signup") ||
      pathname === "/"
    )
  ) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // NOTE - accessToken이 만료되어서 refreshToken을 통해 새로운 토큰을 발급해야 하는 경우
  if (!hasAccessToken && hasRefreshToken) {
    const refreshTokenValue = request.cookies.get("refreshToken")?.value;

    // NOTE - Fetch를 사용하여 새로운 accessToken을 발급받는 비동기 작업
    const fetchPromise = fetch(
      `${process.env.NEXT_PUBLIC_KKOM_KKOM_URL}/auth/refresh-token`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          refreshToken: refreshTokenValue,
        }),
      },
    )
      .then(async (response) => {
        if (!response.ok) {
          throw new Error("refresh token 요청 실패");
        }
        const data: PostTeamIdAuthRefreshTokenResponse = await response.json();

        // NextResponse 객체를 만들어 쿠키를 설정
        const nextResponse = NextResponse.next();
        nextResponse.cookies.set("accessToken", data.accessToken);

        return nextResponse;
      })
      .catch((error) => {
        return NextResponse.redirect(new URL("/login", request.url));
      });

    // waitUntil을 사용하여 비동기 작업이 완료될 때까지 기다림
    event.waitUntil(fetchPromise);

    // 비동기 작업이 완료된 후 요청을 계속 진행하도록 합니다.
    // 이때 fetchPromise에서 반환한 NextResponse 객체 사용
    return new Promise((resolve) => {
      fetchPromise.then((nextResponse) => {
        resolve(nextResponse);
      });
    });
  }

  return NextResponse.next();
}

export const config = {
  matcher: "/((?!api|_next/static|_next/image|favicon.ico).*)",
};

라우트가 변경될 때 waitUntil이라는 메소드로 원하는 통신 이전에 토큰을 처리해주는 로직이었다.

하지만 다음의 상황에서 예외가 발생했다.

  1. 서버 액션을 사용하지 않으면 프론트엔드 서버를 거치지 않고 요청을 하기 때문에 미들웨어를 거치지 않게 됨
  2. refreshToken은 있으나 다시 로그인 하라고 분기 처리 (에러)

고민해본 결과 axios의 interceptor처럼 fetch 요청 자체에 refreshToken을 처리해야 한다는 결론에 이르렀다.

서버 액션을 이용한 import

next/headers는 클라이언트 단에 있을 수 없고 서버 컴포넌트, 서버 액션에서 호출할 수 있었다. (클라이언트 단에서 호출하면 에러 처리)

그건 조건부로 실제로 실행되는 것이 아니여도 import로 연결되어 있다면 전부 해당되었다.

이때 든 생각은 “서버 액션과 일단 클라이언트 fetch를 연결하고 분기 처리하면 되지 않나?” 였다.

그러곤 다음의 코드를 작성했다.

myFetch

import { clientFetch } from "./clientFetch";
import serverFetch from "./serverFetch";
import { MyFetchOptions } from "./types";

/**
 * myFetch는 서버와 클라이언트에서 사용할 수 있는 fetch 함수입니다.
 * 서버에서 사용할 때는 serverFetch를 사용하고, 클라이언트에서 사용할 때는 clientFetch를 사용합니다.
 * 사용하실 때는 fetch처럼 사용하시면 됩니다.
 * withCredentials 옵션을 사용하면 쿠키를 전송할 수 있습니다.
 * accessToken이 필요한 fetch에서 사용하시면 됩니다.
 * 내부적으로 accessToken이 만료되었을 때, refreshToken을 사용하여 새로운 accessToken을 발급받습니다.
 *
 * @author 이승현
 * @param input
 * @param init
 * @returns Promise<T>
 **/
export async function myFetch<T>(
  input: string | URL | globalThis.Request,
  init?: MyFetchOptions,
): Promise<T> {
  if (typeof window !== "undefined") {
    const response = await clientFetch(input, init);
    const data = await response.json();
    return data;
  }
  const response = await serverFetch(input, init);
  const data = await response.json();
  return data;
}

토큰 처리 기능이 붙혀진 fetch, myFetch 이다.

clientFetch

import { getCookie, setCookie } from "cookies-next";

import { PostAuthRefreshTokenResponse } from "../type";
import type { MyFetchOptions } from "./types";

export async function clientFetch(
  input: string | URL | globalThis.Request,
  init?: MyFetchOptions,
) {
// accessToken를 읽는다.
  const accessToken = getCookie("accessToken");

  let newInit = init;

// withCredentials는 토큰 인증 처리를 하겠다는 flag이다.
  if (newInit?.withCredentials === true) {
    const headers = new Headers(init?.headers);
    headers.set("Authorization", `Bearer ${accessToken}`);
    newInit = { ...init, headers };
  }

// 토큰 처리를 한다.
  const res = await fetch(input, newInit);

// 만약 토큰이 만료되었다면
  if (res.status === 401) {
  // refresh token rotation 처리
    const refreshTokenValue = getCookie("refreshToken");
    if (refreshTokenValue) {
      const newAccessToken = await fetch(
        `${process.env.NEXT_PUBLIC_KKOM_KKOM_URL}/auth/refresh-token`,
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            refreshToken: refreshTokenValue,
          }),
        },
      );
      if (newAccessToken.ok) {
        const newAccessTokenValue: PostAuthRefreshTokenResponse =
          await newAccessToken.json();
        setCookie("accessToken", newAccessTokenValue.accessToken);
        const headers = new Headers(init?.headers);
        headers.set(
          "Authorization",
          `Bearer ${newAccessTokenValue.accessToken}`,
        );
        const newInitAfterFetch = { ...init, headers };
        const newRes = await fetch(input, newInitAfterFetch);
        return newRes;
      }
      throw new ResponseError("엑세스 토큰 호출 실패", newAccessToken);
    } else {
      throw new ResponseError("로그인을 다시 해주세요!", res);
    }
  } else if (!res.ok) {
    throw new ResponseError("에러가 발생했습니다.", res);
  }
  return res;
}

클라이언트 (window !== undefined) 상태일 때 실행될 함수이다. refresh token rotation이 필요한 경우에 받고 fetch를 다시 하도록 하는 로직을 갖고 있다.

serverFetch

"use server";

import { cookies } from "next/headers";

import { MyFetchOptions } from ".";
import { PostTeamIdAuthRefreshTokenResponse } from "../type";

export async function serverFetch(
  input: string | URL | globalThis.Request,
  init?: MyFetchOptions,
) {
  const cookieStore = cookies();
  const accessToken = cookieStore.get("accessToken")?.value;

  if (init?.withCredentials === true) {
    const headers = new Headers(init?.headers);
    headers.set("Authorization", `Bearer ${accessToken}`);
    init = { ...init, headers };
  }

  const res = await fetch(input, init);

  if (res.status === 401) {
    const refreshTokenValue = cookieStore.get("refreshToken")?.value;
    if (refreshTokenValue) {
      const newAccessToken = await fetch(
        `${process.env.NEXT_PUBLIC_KKOM_KKOM_URL}/auth/refresh-token`,
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            refreshToken: refreshTokenValue,
          }),
        },
      );
      if (newAccessToken.ok) {
        const newAccessTokenValue: PostTeamIdAuthRefreshTokenResponse =
          await newAccessToken.json();
        const headers = new Headers(init?.headers);
        headers.set(
          "Authorization",
          `Bearer ${newAccessTokenValue.accessToken}`,
        );
        const newInit = { ...init, headers };
        const newRes = await fetch(input, newInit);
        return newRes;
      } else {
        throw new Error("엑세스 토큰 호출 실패");
      }
    } else {
      throw new Error("로그인을 다시 해주세요!");
    }
  } else if (!res.ok) {
    throw responseError(res);
  }
  return res;
}

서버 상태(window===undefined)일 때 실행될 함수이다. refresh token rotation을 next/headers의 cookie를 이용해서 실행한다.

다음의 로직이 실행되어 에러가 일어나지 않는 것이다.

  1. 서버 액션을 클라이언트단에서 import하기 때문에 런타임 에러가 무시된다.
  2. next/header를 에러처리 안하고 넘어간다.
  3. window===undefined 로 분기처리했기 때문에 클라이언트에서 서버 함수를 사용하는 경우의 수는 차단된다.
  4. 서버, 클라이언트 모두 사용 가능해짐

axios의 base url 기능 구현

코드 리뷰를 하던 중에 URL을 상수로 받아서 myFetch에 내려받는 코드를 발견하였다.

axios에서는 base url을 저장해놓고 사용하는 쪽에서는 해당 url 뒤에 있는 라우트만 적어서 사용하는 기능이 있다.

커링 함수

함수형 프로그래밍에서는 이걸 어떻게 구현할까 고민을 하다가 이전에 학습했던 커링 함수가 생각났다.

커링 함수: 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠서 순차적으로 호출될 수 있게 체인 형태로 구성한 것 한번에 하나의 인자만 전달하는 것을 원칙으로 한다. 각 단계에서 받은 인자들을 모두 마지막 단계에서 참조하기 때문에 메모리에 차곡차곡 쌓였다가 마지막 단계에서 호출해서 실행 컨텍스트가 끝나면 한번에 GC의 수거 대상이 된다.

이를 이용해서 구현을 시작했다.

// instance를 위한 커링 함수
export const newFetch =
  (url: string) =>
  <T>(input: string | URL | globalThis.Request, init?: MyFetchOptions) =>
    myFetch<T>(url + input, init);

// instance.tsx
const instance = newFetch(`{url}`);

위의 함수로 타입까지 다 처리된 base_url 첨가 myFetch를 사용할 수 있게 되었다.

사용하는 쪽에서는 이제 엄청 간단해졌다.

// 호출부 
export async function getUserHistory() {
  try {
    const response = await instance<GetTeamIdUserHistoryResponse>(
      "/user/history",
      {
        method: "GET",
        withCredentials: true,
      },
    );
    return response;
  } catch (error) {
    throw error;
  }
}

세부 로직은 속으로 숨겨져 있는 상태에서 instance와 withCredentials만 사용해서 token이 처리되었다!!!

제일 좋은 점은 api만 변경되었기 때문에 다른 코드를 변경할 필요 없이 myFetch를 instance로 변경만 하면 된다는 것이다.

// getUserHistory를 사용하는 쪽
import { GetTeamIdUserHistoryResponse } from "@/lib/apis/type";
import { getUserHistory } from "@/lib/apis/user";

import DateBoxCard from "./date-box-card";

export default async function DateBoxList() {
  const data = await getUserHistory(); // 변화가 없이 사용할 수 있다.
  if (data.tasksDone.length === 0) return null;
  const history: Map<string, GetTeamIdUserHistoryResponse["tasksDone"]> =
    data.tasksDone.reduce((acc, cur) => {
      acc.set(cur.date, [...(acc.get(cur.date) || []), cur]);
      return acc;
    }, new Map());

  return (
    <section className="mt-[27px] flex flex-col gap-10 md:mt-6">
      {[...history.entries()].map(([date, history]) => (
        <DateBoxCard key={date} date={date} tasksDone={history} />
      ))}
    </section>
  );
}

후기

서버 액션을 사용해서 토큰 처리를 해보는 경험을 했다.

서버 액션으로 만들었을 때의 쾌감도 굉장했지만 base url을 구현할 때가 진짜 많이 성장했구나 생각했던 때였다.

몇개월 전에 코어 자바스크립트에서 봤던 내용인데 이제야 제대로 활용되어 이해된 느낌이 들었다.

지식은 차곡차곡 쌓이는 구나 생각하면서 짜릿했던 경험이다.

더 성장해보자.

꼼꼼 프로젝트 진행 중에 겪은 경험들입니다. 오늘도 많이 성장한 것 같아요!

읽어주셔서 감사합니다! 지켜봐주세요!

감사합니다.

Categories:

Updated:

Leave a comment