한우의 개발일기

[Next.js] next-cookies(서버/클라이언트 쿠키 사용기) 본문

NextJS

[Next.js] next-cookies(서버/클라이언트 쿠키 사용기)

한우코딩 2024. 11. 11. 15:11

일단 쿠키란 무엇인가??

쿠키(Cookie)란?

쿠키는 웹 서버가 생성하여 웹 브라우저로 전송하는 작은 정보 파일입니다. 웹 브라우저는 수신한 쿠키를 미리 정해진 기간 동안 또는 웹 사이트에서의 사용자 세션 기간 동안 저장합니다. 웹 브라우저는 향후 사용자가 웹 서버에 요청할 때 관련 쿠키를 첨부합니다.

그렇다면 Next에서의 쿠키는 어떻게 쓰일까??

Next.js의 쿠키 처리는 크게 4가지 환경에서 이루어진다고한다

  1. 서버 컴포넌트(Server Components)에서의 쿠키
// Server Component
import { cookies } from 'next/headers'

export default function Page() {
  const cookieStore = cookies()
  const theme = cookieStore.get('theme')

  return <div>현재 테마: {theme?.value}</div>
}
  1. 서버 액션(Server Actions)에서의 쿠키
'use server'
import { cookies } from 'next/headers'

export async function updateTheme(theme: string) {
  cookies().set('theme', theme)
}
  1. 미들웨어(Middleware)에서의 쿠키
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // 쿠키 읽기
  const theme = request.cookies.get('theme')

  // 쿠키 설정
  const response = NextResponse.next()
  response.cookies.set('visited', 'true')

  return response
}
  1. Route Handlers에서의 쿠키
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const cookieStore = cookies()
  const token = cookieStore.get('token')

  return NextResponse.json({ token: token?.value })
}

이렇게 사용이 되는데 여기서의 주요 특징과 개념을 간단하게 말해보자면

주요 특징과 개념

  1. 서버/클라이언트 분리
  • 서버 컴포넌트: cookies() API 사용
  • 클라이언트: document.cookie 또는 js-cookie 사용
  • 미들웨어: NextRequest/NextResponse 쿠키 API 사용
  1. 보안 기능
  • HttpOnly 쿠키 지원
  • Secure 플래그 지원
  • SameSite 설정 가능
  1. 유용한 기능들
  • 쿠키 설정/읽기/삭제
  • 만료 시간 설정
  • 도메인 및 경로 설정
  • 암호화된 쿠키 지원

이렇게 되어있다.

그렇다면
내가 프로젝트에서 쓰기위해 만든 next-cookies 유틸함수를 살펴보자

"use server";

import { cookies } from "next/headers";

interface CookieOptions {
  /**
   * 쿠키의 만료 일시를 설정
   * Date 객체나 타임스탬프(밀리초) 사용
   * @example
   * expires: new Date('2024-12-31')
   * expires: Date.now() + 24 * 60 * 60 * 1000  // 24시간 후
   */
  expires?: Date | number;

  /**
   * 쿠키가 유효한 상대적 시간(초)
   * @example
   * maxAge: 60 * 60  // 1시간
   * maxAge: 24 * 60 * 60  // 1일
   * maxAge: 30 * 24 * 60 * 60  // 30일
   */
  maxAge?: number;

  /**
   * 쿠키가 유효한 경로
   * @default "/"
   * @example
   * path: "/"  // 모든 경로에서 접근 가능
   * path: "/admin"  // /admin 경로에서만 접근 가능
   * path: "/shop/cart"  // /shop/cart 경로에서만 접근 가능
   */
  path?: string;

  /**
   * 쿠키가 유효한 도메인
   * @example
   * domain: "example.com"  // example.com과 그 서브도메인에서 접근 가능
   * domain: "api.example.com"  // api.example.com에서만 접근 가능
   */
  domain?: string;

  /**
   * HTTPS 전송 여부
   * - true: HTTPS 연결에서만 쿠키 전송
   * - false: HTTP에서도 쿠키 전송
   * @default true in production
   * @example
   * secure: true  // HTTPS에서만 쿠키 전송 
   * secure: false  // HTTP에서도 쿠키 전송 (개발 환경)
   */
  secure?: boolean;

  /**
   * JavaScript에서의 쿠키 접근 제한
   * - true: document.cookie로 접근 불가 (보안 강화)
   * - false: document.cookie로 접근 가능
   * @default true
   * @example
   * httpOnly: true  // JavaScript에서 접근 불가
   * httpOnly: false  // JavaScript에서 접근 가능
   */
  httpOnly?: boolean;

  /**
   * 크로스 사이트 요청에 대한 쿠키 전송 정책
   * - "strict": 같은 사이트 요청에만 쿠키 전송
   * - "lax": 일부 크로스 사이트 요청에 쿠키 전송 허용 (기본값)
   * - "none": 모든 크로스 사이트 요청에 쿠키 전송 (secure: true 필요)
   * @default "lax"
   * @example
   * sameSite: "strict"  // 가장 엄격한 보안
   * sameSite: "lax"     // 적절한 보안과 사용성 균형
   * sameSite: "none"    // 크로스 사이트 요청 허용
   */
  sameSite?: "lax" | "strict" | "none";
}


export async function setCookie(
  name: string,
  value: string,
  options: CookieOptions = {},
): Promise<void> {
  const defaultOptions: CookieOptions = {
    path: "/",
    maxAge: 24 * 60 * 60, // 1일
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
  };

  const cookieOptions = { ...defaultOptions, ...options };
  cookies().set(name, value, cookieOptions);
}

export async function getCookie(name: string): Promise<string | undefined> {
  try {
    return cookies().get(name)?.value;
  } catch (error) {
    console.error(`Error getting cookie ${name}:`, error);
    return undefined;
  }
}

export async function deleteCookie(
  name: string,
  options: Pick<CookieOptions, "path" | "domain"> = {},
): Promise<void> {
  try {
    cookies().delete({ name, ...options });
  } catch (error) {
    console.error(`Error deleting cookie ${name}:`, error);
  }
}


export async function getAllCookies(): Promise<Record<string, string>> {
  const cookieStore = cookies();
  return Object.fromEntries(
    cookieStore.getAll().map((cookie) => [cookie.name, cookie.value]),
  );
}


export async function hasCookie(name: string): Promise<boolean> {
  return cookies().has(name);
}

만약 httponlyfalse로 해준다면 document.cookie를 통하여 클라이언트 컴포넌트에서도 쿠키를 가져올 수 있지만 보안상 좋지 않을거 같음과 동시에 클라이언트 단에서 쿠키가 자주 필요할경우 매번 서버를 한번 거쳐 통신을 하게 될거같았다

클라이언트 → document.cookie 설정
          → 서버 액션 호출
          → 서버에서 쿠키 설정
          → 응답

이런식으로 된다면 네트워크 요청이 계속 발생하므로 서버에 좋지 않을거라 판단이 되었다.

그렇다면 어떻게 해결할까?

인터넷을 찾아보면 여러가지 쿠키 관련 라이브러리를 통해 해결을 할 수있다고는 하는데

나는 전역상태 관리 툴인 zustand를 이용해 보기로 했다

전역상태관리로 어떻게 해결을할까?

동작 원리

  1. 상태 중앙화
  • Zustand store가 token 상태를 전역적으로 관리
    • 모든 컴포넌트가 동일한 token 값을 참조
    • 상태 변경시 자동으로 구독 컴포넌트 리렌더링
  1. 영구 저장소 동기화
    • token 상태 변경시 자동으로 쿠키에 동기화
    • 새로고침해도 쿠키에서 상태 복원 가능

토큰을 메모리에 유지하면서 필요할 때마다 서버 요청 없이 바로 사용할 수 있고 , 새로고침시에도 쿠키에서 토큰을 복원할 수 있을것이다
로그인/로그아웃시에만 서버와 통신하면 되므로 서버에 부담도 없을거라고 생각을 했다.

코드

"use client";

import { create } from "zustand";

import { deleteCookie, getCookie, setCookie } from "@/utils/next-cookies";

interface AuthStore {
  token: string | null;
  setToken: (token: string) => Promise<void>;
  clearToken: () => Promise<void>;
  initToken: () => Promise<void>;
}
/**
 * 인증 토큰을 관리하는 Zustand 스토어 훅
 *
 * @example
 * // 2. API 요청시 토큰 사용
 * function ProtectedComponent() {
 *   const { token } = useAuth();
 *
 *   const fetchData = async () => {
 *     const response = await fetch('/api/protected', {
 *       headers: {
 *         'Authorization': `Bearer ${token}`
 *       }
 *     });
 *   };
 * }
 *
 * @example
 * // 3. 컴포넌트 마운트시 토큰 초기화
 * function App() {
 *   const { initToken } = useAuth();
 *
 *   useEffect(() => {
 *     initToken(); // 페이지 로드시 쿠키에서 토큰 복원
 *   }, []);
 * }
 *
 * @example
 * // 4. 로그아웃시 토큰 제거
 * function LogoutButton() {
 *   const { clearToken } = useAuth();
 *
 *   const handleLogout = async () => {
 *     await clearToken(); // 로그아웃시 토큰 삭제
 *   };
 * }
 */

export const useAuth = create<AuthStore>((set) => ({
  token: null,

  setToken: async (token: string) => {
    await setCookie("token", token);
    set({ token });
  },

  clearToken: async () => {
    await deleteCookie("token");
    set({ token: null });
  },

  initToken: async () => {
    const token = await getCookie("token");
    if (token) set({ token });
  },
}));

이러한 방식으로 클라이언트 단에선 Zustand store에서 중앙관리 된 쿠키의 토큰을 사용하고 서버에선 서버액션을 통한 쿠키의 토큰을 사용해서 최적화를 할 수 있었다.