[Next.js] Refactoring ๊ธฐ๋ก๐Ÿ™Š

hahhhmยท2025๋…„ 6์›” 23์ผ

JWT&BFF

๋ชฉ๋ก ๋ณด๊ธฐ
2/4

Next.js ร— Spring Boot: SSR JWT ์ธ์ฆ ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ…๊ณผ BFF ์„ค๊ณ„ ๊ธฐ๋ก

JWT ๊ธ€

๋ฆฌํŒฉํ† ๋ง ์ด์ „ ๋ฌธ์ œ๐Ÿ˜ก

1) CSR ์ „์šฉ ์ธ์ฆ์„ ์„œ๋ฒ„/ํด๋ผ์ด์–ธํŠธ ๊ณตํ†ต ์œ ํ‹ธ๋กœ ์‚ฌ์šฉ

๋‹ค์Œ์€ ๋‚ด๊ฐ€ ์ฒ˜์Œ ์ ์šฉํ–ˆ๋˜ ์ž˜๋ชป๋œ ๊ณตํ†ต fetch ์œ ํ‹ธ๋ฆฌํ‹ฐ ์ฝ”๋“œ์ด๋‹ค.


// utils/fetchJWT.ts
"use server";

import { Member } from "@/interface/Member";
import { DataResponse } from "@/interface/DataResponse";
import { getCookie, setCookie } from "@/utils/cookie";

const host = process.env.BACKEND_URL || "http://localhost";


interface IRequestInit {
    method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
    body?: BodyInit | null;
    credentials?: RequestCredentials;
    cache?: RequestCache;
    next?: NextFetchRequestConfig | undefined;
    headers?: HeadersInit;
}

// ๋‘˜ ์ค‘์— ํ•˜๋‚˜ ๋ฐ˜ํ™˜
export type FetchJWTResult<T = any> =
    | { success: true; data: T; message: string; code: number }
    | { success: false; message: string; code: number };

export const fetchJWT = async <T = any>(
    url: string,
    requestInit: IRequestInit
): Promise<FetchJWTResult<T>> => {
    const member = (await getCookie("member")) as Member | undefined;

    if (!member || !member.accessToken || !member.refreshToken) {
        return { success: false, message: "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", code: 401 };
    }

    const { accessToken, refreshToken, email } = member;
    const configData = getConfigData(requestInit, accessToken);

    let response = await fetch(host + url, configData);
    let raw: DataResponse<T> = await response.json();

    if (response.ok && raw.success) {
        return { success: true, data: raw.data, message: raw.message, code: response.status };
    }

    if (raw.message === "ERROR_ACCESS_TOKEN") {
        try {
            const newJWT = await refreshJWT(accessToken, refreshToken, email, member);
            const newConfigData = getConfigData(configData, newJWT.accessToken);

            response = await fetch(host + url, newConfigData);
            raw = await response.json();

            if (response.ok && raw.success) {
                return { success: true, data: raw.data, message: raw.message, code: response.status };
            }
            return { success: false, message: raw.message || "์š”์ฒญ ์‹คํŒจ", code: response.status };
        } catch (e) {
            return { success: false, message: "JWT ๊ฐฑ์‹  ์‹คํŒจ", code: 401 };
        }
    }

    return { success: false, message: raw.message || "์„œ๋ฒ„ ์˜ค๋ฅ˜", code: response.status };
};

const getConfigData = (requestInit: IRequestInit, accessToken: string) => {
    const { headers } = requestInit;
    return {
        ...requestInit,
        headers: { ...headers, Authorization: `Bearer ${accessToken}` },
    };
};

const refreshJWT = async (
    accessToken: string,
    refreshToken: string,
    email: string,
    member: Member
) => {
    const response = await fetch(
        host + "/api/member/refresh?refreshToken=" + refreshToken,
        {
            method: "POST",
            headers: {
                Authorization: `Bearer ${accessToken}`,
                "Content-Type": "application/json",
            },
            body: JSON.stringify({ email }),
        }
    );

    if (!response.ok) {
        throw new Error("JWT ๊ฐฑ์‹ ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.");
    }

    const newJWT = await response.json();
    const newCookie = {
        ...member,
        accessToken: newJWT.accessToken,
        refreshToken: newJWT.refreshToken,
    } as Member;

    await setCookie("member", JSON.stringify(newCookie));
    return newJWT;
};

์ด ์ฝ”๋“œ๋Š” fetchJWT๋ผ๋Š” ๊ณตํ†ต ์œ ํ‹ธ์„ ํ†ตํ•ด accessToken ๋งŒ๋ฃŒ ์‹œ ์ž๋™์œผ๋กœ refreshToken์œผ๋กœ ๊ฐฑ์‹ ํ•˜๊ณ , ์š”์ฒญ์„ ์žฌ์‹œ๋„ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•œ๋‹ค.
๋‚˜๋Š” ์ด ์œ ํ‹ธ์„ ๊ฐ„๋‹จํ•˜๊ฒŒ ์„œ๋ฒ„/ํด๋ผ์ด์–ธํŠธ ๊ตฌ๋ถ„ ์—†์ด ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•˜๋ ค๊ณ  ํ–ˆ๊ณ , accessToken์ด ๋งŒ๋ฃŒ๋˜๋ฉด ํ† ํฐ์„ ๊ฐฑ์‹ ํ•ด ๋‹ค์‹œ Authorization ํ—ค๋”์— ์„ค์ •ํ•˜๋„๋ก ๋งŒ๋“ค์—ˆ๋‹ค. ํ•˜์ง€๋งŒ ์—ฌ๊ธฐ์—” ์น˜๋ช…์ ์ธ ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋‹ค.

  1. ์„œ๋ฒ„/ํด๋ผ์ด์–ธํŠธ ํ˜ผ์šฉ ๋ฌธ์ œ

    • ์ฟ ํ‚ค๋ฅผ ์ฝ๊ธฐ ์œ„ํ•ด next/headers์˜ cookies()๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ–ˆ๊ณ , ๋•Œ๋ฌธ์— ํŒŒ์ผ ์ตœ์ƒ๋‹จ์— "use server"๋ฅผ ์„ ์–ธํ–ˆ๋‹ค.
    • ์ด ์ƒํƒœ์—์„œ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ fetchJWT()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด, ๋นŒ๋“œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๊ฑฐ๋‚˜ ์„œ๋ฒ„ ์ „์šฉ ์ฝ”๋“œ๊ฐ€ ๋ฒˆ๋“ค์— ํฌํ•จ๋˜์–ด ํด๋ผ์ด์–ธํŠธ ๋Ÿฐํƒ€์ž„์—์„œ ํ„ฐ์งˆ ์œ„ํ—˜์ด ํฌ๋‹ค.
    • ์ฆ‰, ํ•˜๋‚˜์˜ ์œ ํ‹ธ์„ ์–‘์ชฝ์—์„œ ๊ณตํ†ต์œผ๋กœ ์“ฐ๋Š” ๊ตฌ์กฐ ์ž์ฒด๊ฐ€ ์ž˜๋ชป๋œ ๊ฒƒ์ด๋‹ค.
  2. ์ž˜๋ชป๋œ ์ธ์ฆ ํŒจํ„ด ์ ์šฉ

    • ์ด ๊ตฌ์กฐ๋Š” HttpOnly ์ฟ ํ‚ค ๊ธฐ๋ฐ˜ ์ธ์ฆ์ด ์•„๋‹ˆ๋ผ, Authorization ํ—ค๋”์— accessToken์„ ์ง์ ‘ ๋„ฃ๋Š” ๋ฐฉ์‹์ด์—ˆ๋‹ค.
    • ์ด ๋ฐฉ์‹์€ ํ”ํžˆ CSR ํ™˜๊ฒฝ์—์„œ localStorage/sessionStorage์— ํ† ํฐ์„ ์ €์žฅํ•˜๊ณ , fetch ํ˜ธ์ถœ ์‹œ ๊บผ๋‚ด์„œ ํ—ค๋”์— ๋ถ™์ด๋Š” ํŒจํ„ด์ด๋‹ค.
    • ํ•˜์ง€๋งŒ SSR ํ™˜๊ฒฝ์—์„œ๋Š” localStorage๋ฅผ ์“ธ ์ˆ˜ ์—†๋‹ค. ๋”ฐ๋ผ์„œ ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ ๋ชจ๋‘์—์„œ ์ด ๋ฐฉ์‹์„ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฑด ๋งž์ง€ ์•Š๋Š”๋‹ค.

๋”ฐ๋ผ์„œ fetch ๋กœ์ง์„ ์„œ๋ฒ„ ์ „์šฉ / ํด๋ผ์ด์–ธํŠธ ์ „์šฉ์œผ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์˜ฌ๋ฐ”๋ฅธ ๋ฐฉํ–ฅ๊ฐ™๋‹ค.

2) ์ธ์ฆ ๊ตฌ์กฐ์˜ ํ˜ผ๋ž€

๋ธŒ๋ผ์šฐ์ € localStorage์— JWT๋ฅผ ์ €์žฅํ•˜๊ณ  CSR fetch ๋•Œ๋งˆ๋‹ค ํ—ค๋”์— ์ˆ˜๋™์œผ๋กœ ๋ถ™์ด๋Š” ํŒจํ„ด์ด ํด๋ผ์ด์–ธํŠธ ์ค‘์‹ฌ์ด๊ณ  ์‹ค๋ฌด์—์„œ ๊ถŒ์žฅ๋˜์ง€ ์•Š๋Š”๋‹ค. ์ด์œ ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

  • XSS ์ทจ์•ฝ์„ฑ
    localStorage๋Š” ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ๋กœ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๋ฏ€๋กœ, ์Šคํฌ๋ฆฝํŠธ ์‚ฝ์ž…์— ์ทจ์•ฝํ•  ๊ฒฝ์šฐ ํ† ํฐ ํƒˆ์ทจ ์œ„ํ—˜์ด ํฌ๋‹ค.

  • SSR๊ณผ์˜ ๋น„ํ˜ธํ™˜
    ์„œ๋ฒ„ ๋ Œ๋”๋ง/์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ ํ™˜๊ฒฝ์—์„œ๋Š” localStorage์— ์ ‘๊ทผํ•  ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์— ๋™์ผ ํŒจํ„ด์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค.
    (๋‹ค๋งŒ Authorization ํ—ค๋” ์ž์ฒด๋Š” SSR์—์„œ๋„ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋ฉฐ, ์„œ๋ฒ„ ์ธก์—์„œ ์ฟ ํ‚ค๋‚˜ ์„œ๋ฒ„ ์„ธ์…˜์„ ์ฝ์–ด ํ—ค๋”๋ฅผ ๊ตฌ์„ฑํ•˜์—ฌ ๋ฐฑ์—”๋“œ๋ฅผ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๋‹ค.)

  • ์œ ์ง€๋ณด์ˆ˜ ๋น„์šฉ
    ๋งค ์š”์ฒญ๋งˆ๋‹ค ํ† ํฐ์„ ๊บผ๋‚ด ํ—ค๋”์— ๋ถ™์ด๊ณ , ๋งŒ๋ฃŒ/์žฌ์‹œ๋„/๊ฒฝํ•ฉ์„ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•ด ์ฝ”๋“œ๊ฐ€ ๋ณต์žกํ•ด์ง„๋‹ค.

๋”ฐ๋ผ์„œ ์ด ํ”„๋กœ์ ํŠธ์—์„œ๋Š” Access Token, Refresh Token ๋ชจ๋‘๋ฅผ HttpOnly ์ฟ ํ‚ค ๊ธฐ๋ฐ˜์œผ๋กœ ์ „ํ™˜ํ•œ๋‹ค. ์ด๋•Œ SameSite(Lax/Strict)์™€ CSRF ํ† ํฐ(๋”๋ธ” ์„œ๋ฐ‹ ๋“ฑ)์„ ํ•จ๊ป˜ ์ ์šฉํ•ด CSRF ๋ฐฉ์–ด๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌ์„ฑํ•œ๋‹ค.

  • HttpOnly๋Š” ์ฟ ํ‚ค์˜ ์†์„ฑ ์ค‘ ํ•˜๋‚˜๋กœ, ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ์—์„œ ์ฟ ํ‚ค์— ์ ‘๊ทผํ•˜์ง€ ๋ชปํ•˜๊ฒŒ ๋ง‰๋Š” ๋ณด์•ˆ ์„ค์ •
  • ์ด ์ฟ ํ‚ค๋Š” ๋ฐฑ์—”๋“œ(์„œ๋ฒ„)์—์„œ ๋ฐœ๊ธ‰ํ•ด์•ผ ํ•˜๋ฉฐ, ํด๋ผ์ด์–ธํŠธ์—์„œ ์ง์ ‘ ์„ค์ •ํ•  ์ˆ˜ ์—†์Œ

HttpOnly ์ฟ ํ‚ค๋Š” ๋ณด์•ˆ์„ฑ๊ณผ SSR ํ˜ธํ™˜์„ฑ์„ ๋™์‹œ์— ๊ฐ–์ถ˜ ์ธ์ฆ ๋ฐฉ์‹์œผ๋กœ, accessToken, refreshToken ๋“ฑ ๋ฏผ๊ฐํ•œ ์ •๋ณด๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ๋ณดํ˜ธํ•  ์ˆ˜ ์žˆ๋Š” ์‹ค๋ฌด์—์„œ ๊ถŒ์žฅ๋˜๋Š” ๋ฐฉ์‹์ด๋‹ค.

๋ฐฑ์—”๋“œ์—์„œ ์ฟ ํ‚ค ์„ค์ •

Set-Cookie: access_token=abcdefg; HttpOnly; Secure; Path=/; Max-Age=1800;

ํด๋ผ์ด์–ธํŠธ์—์„œ fetch ์š”์ฒญ

await fetch("/api/user/me", {
  method: "GET",
  credentials: "include", // ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ฟ ํ‚ค ์ž๋™ ์ „์†ก
});
  • HttpOnly: JS๋กœ ์ ‘๊ทผ ๋ถˆ๊ฐ€ โ†’ XSS๋กœ ํ† ํฐ ํƒˆ์ทจ ์–ด๋ ค์›€
  • ์ž๋™ ์ „์†ก: credentials: "include"๋ฉด ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์•Œ์•„์„œ ๋ถ™์ž„
  • SSR ํ˜ธํ™˜: ์„œ๋ฒ„์—์„œ cookies()๋กœ ์ฝ์„ ์ˆ˜ ์žˆ์–ด ๋ฐ์ดํ„ฐ ํŒจ์นญ์— ์œ ๋ฆฌ

๋ณ€๊ฒฝ ์š”๊ตฌ์‚ฌํ•ญ ์š”์•ฝ๐Ÿถ

  • ํ™˜๊ฒฝ: Next.js 14 (App Router) + Spring Boot
  • ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ:
    • SSR, CSR, ISR, SSG ํ˜ผํ•ฉ ์‚ฌ์šฉ
    • HttpOnly ์ฟ ํ‚ค ๊ธฐ๋ฐ˜ ์ธ์ฆ ์‹œ์Šคํ…œ ๋„์ž… (Spring Boot๊ฐ€ Set-Cookie๋กœ ํ† ํฐ ์„ค์ •)
    • accessToken ๋งŒ๋ฃŒ ์‹œ /api/member/refresh๋กœ ์ž๋™ ์žฌ๋ฐœ๊ธ‰
    • ์‚ฌ์šฉ์ž ์ •๋ณด๋Š” /api/member/me๋ฅผ ํ˜ธ์ถœํ•ด์„œ ๊ฐ€์ ธ์™€์•ผ ํ•จ

๋ฆฌํŒฉํ† ๋ง ์ค‘ ๋ฐœ์ƒํ•œ ๋ฌธ์ œ๐Ÿ™Š

์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ๋Š” ์ฟ ํ‚ค์— ์ €์žฅ๋œ accessToken refresh๊ฐ€ ๋ถˆ๊ฐ€ํ•˜๋‹ค.

Springboot์—์„œ access_token, refresh_token ๋ชจ๋‘ HttpOnly ์ฟ ํ‚ค๋กœ ์„ค์ • ์™„๋ฃŒํ•œ ํ›„์— ํ”„๋ก ํŠธ์—”๋“œ ๋ฆฌํŒฉํ† ๋ง ์ค‘์ด์˜€๋‹ค. fetcher ์œ ํ‹ธ๋ฆฌํ‹ฐ๋ฅผ ๋งŒ๋“ค๊ณ  ์•„๋ž˜์™€ ๊ฐ™์ด client, server ์„ ๋ถ„๋ฆฌ์‹œ์ผฐ๋‹ค.

export const fetcher = typeof window === 'undefined' ? serverFetcher : clientFetcher;

serverFetcher์™€ clientFetcher๋ฅผ ๊ฐ๊ฐ ๋ถ„๋ฆฌํ•˜๊ณ , ํ† ํฐ์ด ๋งŒ๋ฃŒ๋  ๊ฒฝ์šฐ refresh ์š”์ฒญ์„ ๋ณด๋‚ด accessToken์„ ์žฌ๋ฐœ๊ธ‰๋ฐ›๋Š” ๋กœ์ง๋„ ํฌํ•จ์‹œ์ผฐ๋‹ค.
์ฒ˜์Œ์—” ์„œ๋ฒ„ ์ธก(serverFetcher)์—์„œ accessToken์ด ๋งŒ๋ฃŒ๋˜๋ฉด refreshToken์„ ์ด์šฉํ•ด Spring Boot ์„œ๋ฒ„์— ์žฌ๋ฐœ๊ธ‰ ์š”์ฒญ์„ ๋ณด๋‚ด๊ณ ,
์‘๋‹ต์œผ๋กœ ๋ฐ›์€ Set-Cookie ํ—ค๋”๋ฅผ ํ†ตํ•ด ๋ธŒ๋ผ์šฐ์ €์˜ ์ฟ ํ‚ค๊ฐ€ ์ž๋™์œผ๋กœ ๊ฐฑ์‹ ๋  ๊ฒƒ์ด๋ผ ์˜ˆ์ƒํ–ˆ๋‹ค. ํ•˜์ง€๋งŒ ์—ฌ๊ธฐ์„œ ์ค‘์š”ํ•œ ์˜คํ•ด๊ฐ€ ์žˆ์—ˆ๋‹ค.

Spring Boot๋Š” refresh ์š”์ฒญ์— ๋Œ€ํ•ด accessToken์„ ์žฌ๋ฐœ๊ธ‰ํ•˜๊ณ , ์‘๋‹ต์— Set-Cookie ํ—ค๋”๋ฅผ ํฌํ•จํ•˜์—ฌ accessToken ์ฟ ํ‚ค๋ฅผ ๊ฐฑ์‹ ํ•˜๋ ค ํ•œ๋‹ค. ํ•˜์ง€๋งŒ ์ด ์š”์ฒญ์ด Next.js์˜ serverFetcher (์ฆ‰, ์„œ๋ฒ„ ์‚ฌ์ด๋“œ fetch) ์—์„œ ๋ฐœ์ƒํ•œ ๊ฒฝ์šฐ, ์‘๋‹ต์€ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ง์ ‘ ๋ฐ›์€ ๊ฒƒ์ด ์•„๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์— ์ฟ ํ‚ค๋Š” ๋ธŒ๋ผ์šฐ์ €์— ๋ฐ˜์˜๋˜์ง€ ์•Š๋Š”๋‹ค.

๋ฐฑ์—”๋“œ(Spring Boot)๋Š” ์ฟ ํ‚ค๋ฅผ ์„ค์ •(Set-Cookie)ํ–ˆ์ง€๋งŒ, ํ•ด๋‹น ์‘๋‹ต์€ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ๋ฐ›์€ ๊ฒŒ ์•„๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์—,๋ธŒ๋ผ์šฐ์ €์˜ HttpOnly ์ฟ ํ‚ค๋Š” ์—ฌ์ „ํžˆ ๋งŒ๋ฃŒ๋œ accessToken ์ƒํƒœ๋กœ ๋‚จ๊ฒŒ ๋œ๋‹ค. Set-Cookie๋Š” ์˜ค์ง ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ง์ ‘ ๋ฐ›์€ ์‘๋‹ต์—๋งŒ ๋ฐ˜์˜๋œ๋‹ค.

์ •๋ฆฌ

  • Spring Boot์—์„œ Set-Cookie๋กœ accessToken์„ ์žฌ์„ค์ •ํ•˜๋”๋ผ๋„, ํ•ด๋‹น ์š”์ฒญ์ด Next.js ์„œ๋ฒ„(fetcher) ๋‚ด๋ถ€์—์„œ ๋ฐœ์ƒํ•œ ๊ฒƒ์ด๋ผ๋ฉด ๋ธŒ๋ผ์šฐ์ €์—๋Š” ์•„๋ฌด๋Ÿฐ ์ฟ ํ‚ค ๋ณ€๊ฒฝ๋„ ๋ฐ˜์˜๋˜์ง€ ์•Š๋Š”๋‹ค.
  • ๋”ฐ๋ผ์„œ JWT ์žฌ๋ฐœ๊ธ‰ ์š”์ฒญ์€ ๋ฐ˜๋“œ์‹œ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ง์ ‘ ๋ณด๋‚ด์•ผ ํ•˜๋ฉฐ ๊ทธ ์‘๋‹ต์„ ํ†ตํ•ด์„œ๋งŒ HttpOnly ์ฟ ํ‚ค๋ฅผ ์‹ค์ œ๋กœ ๊ฐฑ์‹ ํ•  ์ˆ˜ ์žˆ๋‹ค.

ํ•ด๊ฒฐ๋ฒ• ๊ตฌ์ƒ๐Ÿ™‰

โœ…๋ฐฉ๋ฒ• 1. ์„œ๋ฒ„์—์„œ accessToken์„ ๊ฐฑ์‹ ํ•˜์ง€ ์•Š๊ณ , CSR ์—์„œ๋งŒ refresh ์ฒ˜๋ฆฌ

1. SSR: ์ฃผ๋ฌธ ์กฐํšŒ ์‹œ๋„ โ†’ accessToken ๋งŒ๋ฃŒ โ†’ SSR ๋ Œ๋” ๊ฒฐ๊ณผ: "์ฃผ๋ฌธ ์กฐํšŒ ์‹คํŒจ"
2. CSR: ํŽ˜์ด์ง€ ๋งˆ์šดํŠธ ์‹œ โ†’ refresh ์‹œ๋„ โ†’ ์„ฑ๊ณต ์‹œ ๋‹ค์‹œ ์ฃผ๋ฌธ ์ •๋ณด ์š”์ฒญ
3. ์„ฑ๊ณตํ•˜๋ฉด โ†’ "์ฃผ๋ฌธ ์ƒ์„ธ"๋กœ ์ „ํ™˜

์„œ๋ฒ„(fetcher)์—์„œ๋Š” accessToken์ด ๋งŒ๋ฃŒ๋˜๋ฉด 401์„ ๋ฐ˜ํ™˜ํ•˜๊ณ , ์ดํ›„ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ mount ์‹œ์ ์— /api/refresh ์š”์ฒญ์„ ๋ณด๋‚ด ์ฟ ํ‚ค๋ฅผ ๊ฐฑ์‹ ํ•œ๋‹ค. ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ง์ ‘ Spring Boot์— refresh ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด, Spring Boot์˜ Set-Cookie ์‘๋‹ต์ด ๋ธŒ๋ผ์šฐ์ €์— ๋„๋‹ฌํ•˜๊ณ , ์‹ค์ œ ์ฟ ํ‚ค๊ฐ€ ๊ฐฑ์‹ ๋œ๋‹ค.

โŒ๋ฐฉ๋ฒ• 2. middleware.ts์—์„œ SSR ์ง„์ž… ์ „์— accessToken ์ž๋™ ๊ฐฑ์‹ 

SSR ์ง„์ž… ์ „์— middleware์—์„œ access_token์„ ๊ฒ€์‚ฌ โ†’ ์—†์œผ๋ฉด refresh โ†’ ์ฟ ํ‚ค ๋ฎ์–ด์“ฐ๊ธฐ

Next.js์˜ middleware.ts๋Š” ๋ชจ๋“  ์š”์ฒญ ์ „์— ์‹คํ–‰๋˜๋‹ˆ๊นŒ,
SSR์ด๋“  CSR์ด๋“  access_token์ด ์—†๊ฑฐ๋‚˜ ๋งŒ๋ฃŒ๋๋‹ค๋ฉด ์—ฌ๊ธฐ์„œ ์ž๋™์œผ๋กœ /refresh ํ˜ธ์ถœํ•ด์„œ ์ƒˆ access_token ์ฟ ํ‚ค๋ฅผ ๋ฐ›์•„์˜ค๋ฉด ๋˜์ง€ ์•Š์„๊นŒ?

ํ™•์‹คํžˆ ๊ฐ„๋‹จํ•œ ๊ฒƒ ๊ฐ™๋‹ค.

๊ธฐ๋Œ€ํ•œ ํ๋ฆ„

[๋ธŒ๋ผ์šฐ์ €] GET /mypage
   โ†“
[middleware.ts] access_token ๊ฒ€์‚ฌ โ†’ ์—†๊ฑฐ๋‚˜ ๋งŒ๋ฃŒ๋จ
   โ†“
/api/member/refresh ์š”์ฒญ โ†’ ์ƒˆ access_token ์‘๋‹ต (Set-Cookie)
   โ†“
[SSR ์ปดํฌ๋„ŒํŠธ] cookies().get('access_token') โ†’ ์ƒˆ accessToken ๊ธฐ๋Œ€

SSR ์ง„์ž… ์ „์ธ middleware์—์„œ access_token์„ ๊ฐฑ์‹ ํ•ด๋„, SSR ์ปดํฌ๋„ŒํŠธ์—์„œ ์ฝ๋Š” cookies()์—๋Š” ๋ฐ˜์˜๋˜์ง€ ์•Š๋Š”๋‹ค. middleware๋Š” "์š”์ฒญ ์ฒ˜๋ฆฌ ์ „"์— ์‹คํ–‰๋˜์ง€๋งŒ SSR์—์„œ ์‚ฌ์šฉํ•˜๋Š” cookies()๋Š” ์š”์ฒญ ๋‹น์‹œ์˜ ์ฟ ํ‚ค๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค. middleware์—์„œ Set-Cookie ์‘๋‹ต์„ ๋‚ด๋ ค๋„ ํ˜„์žฌ ๋ Œ๋”๋ง ์š”์ฒญ์—๋Š” ์ ์šฉ๋˜์ง€ ์•Š๊ณ , ๋‹ค์Œ ์š”์ฒญ๋ถ€ํ„ฐ ์ ์šฉ๋œ๋‹ค. ์ฆ‰ SSR ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์—์„œ ์—ฌ์ „ํžˆ ๊ธฐ์กด (๋งŒ๋ฃŒ๋œ) ์ฟ ํ‚ค ๊ฐ’์„ ์ฝ๊ฒŒ ๋œ๋‹ค. ์‹ค๋ฌด์—์„œ middleware๋Š” ์ธ์ฆ ์—ฌ๋ถ€ ํŒ๋ณ„๊นŒ์ง€๋Š” OK, ํ•˜์ง€๋งŒ ์ฟ ํ‚ค ๊ฐฑ์‹ ์€ ๋ชปํ•œ๋‹ค.

โŒ๋ฐฉ๋ฒ• 3. BFF(Backend For Frontend)๋กœ refresh ์ฒ˜๋ฆฌ

(์•„๋ž˜์— ์ž์„ธํžˆ ์ ์–ด๋†จ๋‹ค. ๊ฒฐ๊ตญ BFF๋ฅผ ์‚ฌ์šฉํ–ˆ๋‹ค.)
๊ตฌ๊ธ€๋ง ์„œ์นญ์„ ํ†ตํ•ด BFF๋ฅผ ์‚ฌ์šฉํ•ด๋ณด๋ผ๋Š” ๊ธ€์„ ์ฝ์—ˆ๋‹ค. ํ˜น์‹œ๋‚˜ ํ•ด์„œ ์ ์šฉํ•ด๋ณธ๋‹ค.

// src/app/api/bff/me/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'

export async function GET() {
  const cookieStore = cookies()
  const accessToken = cookieStore.get('access_token')?.value
  const refreshToken = cookieStore.get('refresh_token')?.value

  if (!accessToken) {
    return NextResponse.json({ success: false, message: 'no accessToken' }, { status: 401 })
  }

  // ์‚ฌ์šฉ์ž ์ •๋ณด ์š”์ฒญ
  let res = await fetch(`${process.env.BACK_URL}/api/member/me`, {
    headers: {
      Cookie: `access_token=${accessToken}; refresh_token=${refreshToken}`
    },
    credentials: 'include',
  })

  // accessToken ๋งŒ๋ฃŒ ์‹œ โ†’ refresh
  if (res.status === 401 && refreshToken) {
    const refreshRes = await fetch(`${process.env.BACK_URL}/api/member/refresh`, {
      method: 'POST',
      headers: {
        Cookie: `refresh_token=${refreshToken}`
      },
      credentials: 'include',
    })

    if (!refreshRes.ok) {
      return NextResponse.json({ success: false, message: 'refresh failed' }, { status: 401 })
    }

    // ์ƒˆ๋กœ์šด access_token๊ณผ ํ•จ๊ป˜ ์žฌ์‹œ๋„
    res = await fetch(`${process.env.BACK_URL}/api/member/me`, {
      headers: {
        Cookie: `access_token=${accessToken}; refresh_token=${refreshToken}`
      },
      credentials: 'include',
    })
  }

  const user = await res.json()
  return NextResponse.json({ success: true, data: user })
}
  • /api/member/refresh ์š”์ฒญ ๊ฒฐ๊ณผ๋กœ ๋ฐฑ์—”๋“œ(Spring Boot)๋Š” Set-Cookie๋กœ access_token์„ ์‘๋‹ต
  • ๊ทธ๋Ÿฌ๋‚˜ ์ด ์‘๋‹ต์€ Next.js ์„œ๋ฒ„๊ฐ€ ๋ฐ›์„ ๋ฟ, ๋ธŒ๋ผ์šฐ์ €๋Š” ์ด ์‘๋‹ต์„ ์ง์ ‘ ๋ฐ›์ง€ ์•Š์Œ
  • ๋”ฐ๋ผ์„œ ๋ธŒ๋ผ์šฐ์ €์˜ ์ฟ ํ‚ค๋Š” ์ „ํ˜€ ๊ฐฑ์‹ ๋˜์ง€ ์•Š๊ณ , ์ด์ „ access_token ์ƒํƒœ ๊ทธ๋Œ€๋กœ ์œ ์ง€๋จ

๋™์ผํ•˜๊ฒŒ BFF์‚ฌ์šฉํ•ด์„œ ์„œ๋ฒ„ ์ธก์—์„œ accessToken์„ ์žฌ๋ฐœ๊ธ‰ ๋ฐ›์•„๋„, ๋ธŒ๋ผ์šฐ์ € ์ฟ ํ‚ค์— ๋ฐ˜์˜๋˜์ง€ ์•Š์•„์„œ ๋ฌด์˜๋ฏธ


๊ฒฐ๋ก : SSR์€ ์•ˆ์ „ํ•˜๊ฒŒ ์‹คํŒจํ•˜๊ณ , CSR์—์„œ ๋ณต๊ตฌํ•˜์ž๐Ÿ˜พโ†’๐Ÿ˜บ

SSR์—์„œ accessToken ๋งŒ๋ฃŒ
serverFetcher() โ†’ 401 โ†’ prefetchQuery() error ์ƒํƒœ ์ €์žฅ
CSR์—์„œ hydration ํ›„ useQuery()์˜ retryOnMount: true (๊ธฐ๋ณธ๊ฐ’) โ†’ ์ž๋™ ์žฌ์š”์ฒญ ๋ฐœ์ƒ
clientFetcher() 401 โ†’ /refresh ์š”์ฒญ โ†’ accessToken ๊ฐฑ์‹  โ†’ ์› ์š”์ฒญ ์žฌ์‹œ๋„ โœ… ์ •์ƒ ์ž‘๋™

1. SSR: ์ฃผ๋ฌธ ์กฐํšŒ ์‹œ๋„ โ†’ accessToken ๋งŒ๋ฃŒ โ†’ SSR ๋ Œ๋” ๊ฒฐ๊ณผ: "์ฃผ๋ฌธ ์กฐํšŒ ์‹คํŒจ"
2. CSR: ํŽ˜์ด์ง€ ๋งˆ์šดํŠธ ์‹œ โ†’ refresh ์‹œ๋„ โ†’ ์„ฑ๊ณต ์‹œ ๋‹ค์‹œ ์ฃผ๋ฌธ ์ •๋ณด ์š”์ฒญ
3. ์„ฑ๊ณตํ•˜๋ฉด โ†’ "์ฃผ๋ฌธ ์ƒ์„ธ"๋กœ ์ „ํ™˜

์ „๋žต ์š”์•ฝ

  1. SSR์—์„œ๋Š” access_token์ด ์œ ํšจํ•  ๋•Œ๋งŒ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค.
    • ๋งŒ๋ฃŒ๋˜๋ฉด SSR ๋‹จ๊ณ„์—์„œ๋Š” null ๋ฐ˜ํ™˜
  2. CSR์—์„œ refresh โ†’ /api/member/me ์žฌ์š”์ฒญ์œผ๋กœ ์‚ฌ์šฉ์ž ์ƒํƒœ๋ฅผ ๋ณต๊ตฌํ•œ๋‹ค.

๊ตฌํ˜„ ๋ฐฉ๋ฒ•๐Ÿ˜ป

์œ„์น˜์—ญํ• 
RootLayout (SSR)SSR ์‹œ access_token ์ฟ ํ‚ค๊ฐ€ ์žˆ๋‹ค๋ฉด getUserInfo() ์‹คํ–‰ โ†’ ์œ ์ € ์ •๋ณด ํ™•๋ณด
UserHydrationSSR์—์„œ ์–ป์€ ์œ ์ € ์ •๋ณด๋ฅผ CSR ์ชฝ zustand๋‚˜ context์— ์ดˆ๊ธฐ ์ฃผ์ž…
UserSyncHandlerCSR์—์„œ access_token์ด ๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ SSR์—์„œ ์œ ์ € ์ •๋ณด๋ฅผ ๋ชป ๋ถˆ๋Ÿฌ์˜จ ๊ฒฝ์šฐ, refresh ์š”์ฒญ โ†’ ์ƒํƒœ ๋ณต๊ตฌ
SessionExpiredRedirect๋กœ๊ทธ์ธ๋œ ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ด์ง€๋งŒ ์‹ค์ œ๋กœ ์ธ์ฆ ์‹คํŒจ ์ƒํƒœ์ผ ๋•Œ ์ž๋™ ๋ฆฌ๋””๋ ‰์…˜ ์ฒ˜๋ฆฌ

1. ์„œ๋ฒ„/ํด๋ผ์ด์–ธํŠธ fetcher ๋ถ„๋ฆฌ

serverFetcher ์˜ˆ์‹œ

'use server';
import { cookies } from 'next/headers';

export const serverFetcher = async <T = any>(
  path: string,
  options: RequestInit = {}
): Promise<T> => {
  const finalUrl = `${process.env.NEXT_PUBLIC_BACKEND_URL}${path}`;
  const cookieString = cookies().toString();

  const res = await fetch(finalUrl, {
    ...options,
    headers: {
      ...(options.headers || {}),
      cookie: cookieString,
    },
    cache: 'no-store',
  });

  const json = await res.json().catch(() => ({}));

  if (!res.ok || json?.success === false) {
    throw new Error(json?.message || '์š”์ฒญ ์‹คํŒจ');
  }

  return json.data;
};

clientFetcher ์˜ˆ์‹œ (accessToken refresh ํฌํ•จ)

import { SessionExpiredError } from '@/libs/error/errors';

export const clientFetcher = async <T = any>(
  path: string,
  options: RequestInit = {}
): Promise<T> => {
  const finalUrl = `${process.env.NEXT_PUBLIC_BACKEND_URL}${path}`;
  let res = await fetch(finalUrl, {
    ...options,
    credentials: 'include',
  });

  let json: any = await res.json().catch(() => ({}));

  if (res.status === 401) {
    const refresh = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/member/refresh`, {
      method: 'POST',
      credentials: 'include',
    });

    if (refresh.ok) {
      res = await fetch(finalUrl, {
        ...options,
        credentials: 'include',
      });
      json = await res.json().catch(() => ({}));
    } else {
      throw new SessionExpiredError();
    }
  }

  if (!res.ok || json?.success === false) {
    throw new Error(json?.message || '์š”์ฒญ ์‹คํŒจ');
  }

  return json.data;
};

๊ทธ ์™ธ accessToken์ด ํ•„์š”์—†๋Š” ๋ˆ„๊ตฌ๋‚˜ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ public API๋„ ์ถ”๊ฐ€ํ–ˆ๋‹ค..

2. UserSyncHandler โ€“ CSR์—์„œ ์ƒํƒœ ๋ณต๊ตฌ

์‚ฌ์šฉ์ž๊ฐ€ ํŽ˜์ด์ง€๋ฅผ ์ƒˆ๋กœ๊ณ ์นจํ•˜๊ฑฐ๋‚˜, CSR์œผ๋กœ ์ง์ ‘ ํŠน์ • ํŽ˜์ด์ง€์— ์ ‘๊ทผํ•  ๊ฒฝ์šฐ, Zustand์— ์ €์žฅ๋œ ์œ ์ € ์ƒํƒœ๋Š” ๋ฉ”๋ชจ๋ฆฌ์—์„œ ์ดˆ๊ธฐํ™”๋œ๋‹ค.
์ด๋•Œ ์ƒํƒœ ๋ณต๊ตฌ๋ฅผ ์œ„ํ•ด HttpOnly ์ฟ ํ‚ค์— ์ €์žฅ๋œ access_token์„ ๊ธฐ๋ฐ˜์œผ๋กœ /api/member/me ์š”์ฒญ์„ ๋ณด๋‚ด ์œ ์ € ์ •๋ณด๋ฅผ ๋‹ค์‹œ ๊ฐ€์ ธ์˜จ๋‹ค.

UserSyncHandler๋Š” ๋ฐ”๋กœ ์ด ๊ณผ์ •์„ ๋‹ด๋‹นํ•˜๋Š” ํด๋ผ์ด์–ธํŠธ ์ „์šฉ ์ปดํฌ๋„ŒํŠธ๋กœ, ์œ ์ € ์ •๋ณด๋ฅผ ๋™๊ธฐํ™”(sync)ํ•˜๊ณ  ์ƒํƒœ ๊ด€๋ฆฌ ๋„๊ตฌ(Zustand ๋“ฑ)์— ๋กœ๊ทธ์ธ ์ •๋ณด๋ฅผ ๋ณต์›ํ•œ๋‹ค.

  • ์ตœ์ดˆ ํด๋ผ์ด์–ธํŠธ ๋งˆ์šดํŠธ ์‹œ 1ํšŒ๋งŒ ์‹คํ–‰
  • 401 ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๋‚ด๋ถ€์ ์œผ๋กœ refresh ์š”์ฒญ ํ›„ ์žฌ์‹œ๋„
  • refresh๊นŒ์ง€ ์‹คํŒจํ•˜๋ฉด ์„ธ์…˜ ๋งŒ๋ฃŒ๋กœ ํŒ๋‹จ โ†’ ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌ
'use client';
import { useEffect } from 'react';
import { useUserStore } from '@/store/userStore';

export function UserSyncHandler() {
  const setUser = useUserStore((s) => s.setUser);

  useEffect(() => {
    const fetchUser = async () => {
      let res = await fetch('/api/me', { credentials: 'include' });

      if (res.status === 401) {
        const refresh = await fetch('/api/member/refresh', {
          method: 'POST',
          credentials: 'include',
        });

        if (refresh.ok) {
          res = await fetch('/api/me', { credentials: 'include' });
        }
      }

      if (res.ok) {
        const user = await res.json();
        setUser(user);
      }
    };

    fetchUser();
  }, []);

  return null;
}

3. SessionExpiredRedirect โ€“ ์ „์—ญ ๊ฐ์ง€ ํ›„ ๋กœ๊ทธ์•„์›ƒ

react-query์˜ ์ „์—ญ error handler์—์„œ SessionExpiredError๋ฅผ ๊ฐ์ง€ํ•˜๋ฉด, SessionExpiredRedirect ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ†ตํ•ด ๋ฆฌ๋””๋ ‰์…˜ํ•จ

๋™์ž‘ ์ˆœ์„œ ์š”์•ฝ

  1. SSR: serverFetcher โ†’ access_token ์ฟ ํ‚ค ๊ธฐ๋ฐ˜์œผ๋กœ ์œ ์ € ๋ฐ์ดํ„ฐ ํš๋“
  2. ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐ ์ง„์ž… (CSR): UserSyncHandler๊ฐ€ /api/me๋กœ ์ƒํƒœ ๋ณต์› ์‹œ๋„
  3. ์‹คํŒจํ•˜๋ฉด ์ž๋™ refresh
  4. refresh๋„ ์‹คํŒจ ์‹œ โ†’ throw SessionExpiredError
  5. RQProvider (React Query ์ „์—ญ onError ์‹คํ–‰) โ†’ SessionExpiredError ๋ฐœ์ƒ ์‹œ โ†’ useUserStore().setSessionExpired()
  6. app/layout.tsx์— ์žˆ๋Š” SessionExpiredRedirect๊ฐ€ ์ด๋ฅผ ๊ฐ์ง€(isSessionExpired๊ฐ€ ๋ณ€๊ฒฝ๋จ)ํ•˜๊ณ  /login ์ด๋™

SSR์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ์—”?

โŒ ์ง์ ‘ user?.email ๋“ฑ์„ ๋„ฃ์œผ๋ฉด ์•ˆ ๋˜๋Š” ์ด์œ 

  • SSR ์‹œ์ ์˜ accessToken์ด ๋งŒ๋ฃŒ๋˜์–ด ์žˆ๋‹ค๋ฉด ์ž˜๋ชป๋œ ์‚ฌ์šฉ์ž ์ •๋ณด๋กœ ์š”์ฒญํ•˜๊ฒŒ ๋จ

โœ… ํ•ด๊ฒฐ๋ฒ•: ์‚ฌ์šฉ์ž ์ •๋ณด๋Š” ๋ฐฑ์—”๋“œ์—์„œ access_token ํŒŒ์‹ฑ์œผ๋กœ ์ฒ˜๋ฆฌ

  • ์ฆ‰, ํ”„๋ก ํŠธ์—์„œ sellerEmail, userId ๋“ฑ ๋ณด๋‚ด์ง€ ์•Š๊ณ 
  • ๋ฐฑ์—”๋“œ(Spring)์—์„œ access_token์„ ์ฝ์–ด ์‚ฌ์šฉ์ž ์ •๋ณด ์ฒ˜๋ฆฌ
@GetMapping("/api/dashboard/real-time-top")
public ResponseEntity<?> getTopData(@AuthenticationPrincipal CustomUserDetails user) {
    String email = user.getUsername(); // JWT์—์„œ ์ถ”์ถœ๋œ ๊ฐ’
    ...
}

๊ฒฐ๋ก 

๊ตฌ๋ถ„์ฒ˜๋ฆฌ ๋ฐฉ์‹
SSR accessToken ๋งŒ๋ฃŒ๊ทธ๋Œ€๋กœ ์‹คํŒจ (user === null)
CSR์—์„œ ์ƒํƒœ ๋ณต๊ตฌ/refresh โ†’ /me โ†’ ์ƒํƒœ ๋™๊ธฐํ™”
SSR์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ํ•„์š” ์‹œaccessToken ํŒŒ์‹ฑ์€ ๋ฐฑ์—”๋“œ(Spring)์—์„œ ์ˆ˜ํ–‰
๋กœ๊ทธ์ธ ์งํ›„ SSR ์ฟ ํ‚ค ๋ฐ˜์˜router.refresh() ๋˜๋Š” CSR ์ƒํƒœ ๋™๊ธฐํ™” ์‚ฌ์šฉ

.
.
.
.
.

์ด๊ฒŒ ๋์ผ ์ค„ ์•Œ์•˜์ง€๋งŒ..

BFF

์ฒ˜์Œ ์ธ์ฆ๊ตฌ์กฐ๋ฅผ ๋งˆ์นœ ๋’ค, ๋ฐฑ์—”๋“œ๊ฐ€ HttpOnly ์ฟ ํ‚ค๋กœ ํ† ํฐ์„ ๋‚ด๋ ค์ค„ ๋•Œ Next.js๋Š” ์–ด๋–ป๊ฒŒ ๋ฐ›์•„์•ผ ํ•˜๋Š”๊ฐ€๋ฅผ ๋‹ค์‹œ ์ƒ๊ฐํ•ด๋ดค๋‹ค.
์ฒ˜์Œ์—” BFF(ํ”„๋ก์‹œ Route Handler)๋ฅผ SSR ๋‚ด๋ถ€์—์„œ ์„œ๋ฒ„โ†’๋ฐฑ์—”๋“œ ํ˜ธ์ถœ ์šฉ๋„๋กœ๋งŒ ์ดํ•ดํ•ด์„œ, ๋ธŒ๋ผ์šฐ์ €๊ฐ€ Set-Cookie๋ฅผ ๋ชป ๋ฐ›์•„ ๊ฐฑ์‹  ํšจ๊ณผ๊ฐ€ ์—†๋„ค? ๋ผ๊ณ  ๋А๊ผˆ๋‹ค.
ํ•˜์ง€๋งŒ ํฌ์ธํŠธ๋Š” "SSR์—์„œ BFF๋ฅผ ํ˜ธ์ถœ"์ด ์•„๋‹ˆ๋ผ, "๋ธŒ๋ผ์šฐ์ €๊ฐ€ BFF์˜ ์‘๋‹ต์„ ์ง์ ‘ ๋ฐ›๋„๋ก ํ•˜๋Š” ๊ฒƒ"์ด๋‹ค.

  • BFF๋Š” ๋ธŒ๋ผ์šฐ์ €๊ฐ€ /api/*(BFF)๋กœ ์š”์ฒญํ•˜๊ฒŒ ํ•˜๊ณ , BFF๊ฐ€ ์š”์ฒญ ์ฟ ํ‚ค๋ฅผ ๋ฐฑ์—”๋“œ๋กœ ์ „๋‹ฌํ•œ ๋’ค, ๋ฐฑ์—”๋“œ๊ฐ€ ๋‚ด๋ ค์ค€ Set-Cookie๋ฅผ ๋ธŒ๋ผ์šฐ์ € ์‘๋‹ต์— ๊ทธ๋Œ€๋กœ ์‹ค์–ด ํŒจ์Šค์Šค๋ฃจํ•˜๋Š” ๊ฒƒ์ด๋‹ค. ์ด๋ ‡๊ฒŒ ํ•ด์•ผ ๋ธŒ๋ผ์šฐ์ € ์ฟ ํ‚ค๊ฐ€ ์‹ค์ œ๋กœ ๊ฐฑ์‹ ๋œ๋‹ค.
  • ๋ฐ˜๋Œ€๋กœ, SSR์˜ ์ผ๋ฐ˜์ ์ธ ๋ฐ์ดํ„ฐ ํŒจ์นญ์€ Next ์„œ๋ฒ„๊ฐ€ ๋ฐฑ์—”๋“œ๋ฅผ ์ง์ ‘ ํ˜ธ์ถœ(์š”์ฒญ์ž์˜ ์ฟ ํ‚ค๋ฅผ Cookie ํ—ค๋”๋กœ ์ˆ˜๋™ ์ฒจ๋ถ€)ํ•˜๊ณ , ๋งŒ๋ฃŒ(401/419) ์‹œ์—๋Š” SSR์„ ์‹คํŒจ๋กœ ๋‘๊ณ  ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ํ›„ CSR์—์„œ /api/member/refresh(BFF)๋กœ ๊ฐฑ์‹ ํ•˜๋Š” ํ๋ฆ„์ด ๊ฐ€์žฅ ๋‹จ์ˆœํ•˜๊ณ  ๋น ๋ฅด๋‹ค.

์•„๋ž˜๋Š” ๊ตฌํ˜„ํ•œ BFF ๋ผ์šฐํŠธ ํ•ธ๋“ค๋Ÿฌ ์ฝ”๋“œ์ด๋‹ค.

// app/api/bff/[...path]/route.ts

import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';

const API_BASE_URL = process.env.BACKEND_URL!;
const ACCESS_TOKEN_COOKIE = 'access_token';
const REFRESH_TOKEN_COOKIE = 'refresh_token';

/** Set-Cookie ๋ฌธ์ž์—ด์—์„œ access/refresh ํ† ํฐ๋งŒ ๋ฝ‘๋Š”๋‹ค */
function parseTokensFromSetCookie(setCookieHeader: string) {
  const map = new Map<string, string>();
  // ์—ฌ๋Ÿฌ Set-Cookie๊ฐ€ ํ•˜๋‚˜์˜ ํ—ค๋”๋กœ ํ•ฉ์ณ์งˆ ์ˆ˜ ์žˆ์Œ
  const parts = setCookieHeader.split(/,(?=[^;]+=)/);

  for (const p of parts) {
    const [kv] = p.trim().split(';');
    const eq = kv.indexOf('=');
    if (eq > 0) {
      const name = kv.slice(0, eq).trim();
      const value = kv.slice(eq + 1).trim();
      if (name === ACCESS_TOKEN_COOKIE || name === REFRESH_TOKEN_COOKIE) {
        map.set(name, value);
      }
    }
  }
  return map;
}

/** ํ˜„์žฌ ์š”์ฒญ์—์„œ ์ธ์ฆ ์ฟ ํ‚ค๋ฅผ ์ฝ์–ด Cookie ํ—ค๋”๋กœ ๋งŒ๋“ ๋‹ค */
function getAuthCookiesHeader() {
  const cookieStore = cookies();
  const access = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
  const refresh = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
  return [
    access && `${ACCESS_TOKEN_COOKIE}=${access}`,
    refresh && `${REFRESH_TOKEN_COOKIE}=${refresh}`,
  ]
    .filter(Boolean)
    .join('; ');
}

/** ์—…์ŠคํŠธ๋ฆผ(๋ฐฑ์—”๋“œ)์œผ๋กœ ์›์š”์ฒญ์„ 1ํšŒ ์ „๋‹ฌ */
async function sendToBackend(
  path: string,
  req: Request,
  cookieHeader?: string,
  bodyBuf?: ArrayBuffer,
) {
  const url = new URL(req.url);
  const backendURL = `${API_BASE_URL}${url.pathname.replace(/^\/api\/bff/, '')}${url.search || ''}`;

  const headers = new Headers(req.headers);
  // ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ๋ณด๋‚ธ ๋„๋ฉ”์ธ ์ง€์›€ (Host โ†’ BFF ์ฃผ์†Œ์ด๋ฏ€๋กœ)
  headers.delete('host');
  headers.delete('cookie');
  // ํ•„์š”ํ•œ ํ—ค๋”๋งŒ ๋‹ค์‹œ ์„ธํŒ…
  if (cookieHeader) headers.set('cookie', cookieHeader);
  headers.set('accept', headers.get('accept') ?? 'application/json');

  const body =
    req.method === 'GET' || req.method === 'HEAD'
      ? undefined
      : (bodyBuf ?? (await req.arrayBuffer()));

  return fetch(backendURL, { method: req.method, headers, body });
}

/** 1) ์›์š”์ฒญ ํ˜ธ์ถœ โ†’ 2) 401์ด๋ฉด refresh โ†’ 3) ์„ฑ๊ณต ์‹œ ์žฌ์‹œ๋„ */
async function handler(req: Request, path: string) {
  // ์žฌ์‹œ๋„๋ฅผ ์œ„ํ•ด ์š”์ฒญ ๋ฐ”๋””๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์ฝ์–ด ๋ณด๊ด€
  const bodyBuf =
    req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer(); // ์š”์ฒญ body ์ „์ฒด๋ฅผ ArrayBuffer๋กœ ์ฝ์–ด์˜ด

  // 1) ํ˜„์žฌ ์ฟ ํ‚ค๋กœ ์›์š”์ฒญ ํ˜ธ์ถœ
  const cookieHeader = getAuthCookiesHeader();
  const response = await sendToBackend(path, req, cookieHeader, bodyBuf);

  if (response.status !== 401) {
    const payload = await response.arrayBuffer(); // ์›์š”์ฒญ ์‘๋‹ต์˜ body ์ „์ฒด๋ฅผ ArrayBuffer๋กœ ์ฝ์Œ
    const bffRes = new NextResponse(payload, { status: response.status });

    const setCookie = response.headers.get('set-cookie');
    if (setCookie) bffRes.headers.append('set-cookie', setCookie);

    response.headers.forEach((v, k) => {
      if (k.toLowerCase() !== 'set-cookie') bffRes.headers.set(k, v);
    });
    return bffRes;
  }

  // 2) 401 โ†’ ํ† ํฐ ๋ฆฌํ”„๋ ˆ์‹œ ์‹œ๋„
  console.log('[BFF] 401 Unauthorized โ†’ refresh ์‹œ๋„');
  const refreshHeaders = new Headers();
  if (cookieHeader) refreshHeaders.set('cookie', cookieHeader);
  const refreshRes = await fetch(`${API_BASE_URL}/api/member/refresh`, {
    method: 'POST',
    headers: refreshHeaders,
  });
  console.log('[BFF] refresh ์‘๋‹ต ์ƒํƒœ:', refreshRes.status);

  if (!refreshRes.ok) {
    // ์‹คํŒจ โ†’ ์„ธ์…˜ ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ
    const bffRes = NextResponse.json({ error: 'session_expired' }, { status: 401 });
    bffRes.cookies.set(ACCESS_TOKEN_COOKIE, '', { maxAge: 0, path: '/' });
    bffRes.cookies.set(REFRESH_TOKEN_COOKIE, '', { maxAge: 0, path: '/' });
    return bffRes;
  }

  // 3) ๋ฆฌํ”„๋ ˆ์‹œ ์„ฑ๊ณต โ†’ ์ƒˆ ์ฟ ํ‚ค ํŒจ์Šค์Šค๋ฃจ + ์›์š”์ฒญ ์žฌ์‹œ๋„
  const setCookie = refreshRes.headers.get('set-cookie');
  let retryCookieHeader = cookieHeader;

  if (setCookie) {
    // refresh ์‘๋‹ต์˜ Set-Cookie ๋ฌธ์ž์—ด์—์„œ access/refresh ํ† ํฐ๋งŒ ๋ฝ‘์•„๋ƒ„
    const parsed = parseTokensFromSetCookie(setCookie);
    const access = parsed.get(ACCESS_TOKEN_COOKIE) ?? cookies().get(ACCESS_TOKEN_COOKIE)?.value;
    const refresh = parsed.get(REFRESH_TOKEN_COOKIE) ?? cookies().get(REFRESH_TOKEN_COOKIE)?.value;
    retryCookieHeader = [
      access && `${ACCESS_TOKEN_COOKIE}=${access}`,
      refresh && `${REFRESH_TOKEN_COOKIE}=${refresh}`,
    ]
      .filter(Boolean)
      .join('; ');
  }

  // ์›๋ž˜ ์š”์ฒญ์„ ์ƒˆ ํ† ํฐ ์ฟ ํ‚ค๋กœ ๋‹ค์‹œ ๋ฐฑ์—”๋“œ์— ์ „๋‹ฌ (์žฌ์‹œ๋„)
  const retryRes = await sendToBackend(path, req, retryCookieHeader, bodyBuf);
  // ์žฌ์‹œ๋„ ์‘๋‹ต์˜ body ๋ฅผ ArrayBuffer ๋กœ ์ฝ์Œ
  const retryPayload = await retryRes.arrayBuffer();
  // Next.js ์‘๋‹ต ๊ฐ์ฒด ์ƒ์„ฑํ•ด์„œ ํด๋ผ์ด์–ธํŠธ๋กœ ๋Œ๋ ค์ค„ ์ค€๋น„
  const resp = new NextResponse(retryPayload, { status: retryRes.status });
  // refresh ์‘๋‹ต์— Set-Cookie ๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ๋Œ€๋กœ ๋ธŒ๋ผ์šฐ์ €๋กœ ์ „๋‹ฌํ•ด์„œ ์ฟ ํ‚ค ๊ฐฑ์‹ 
  if (setCookie) resp.headers.append('set-cookie', setCookie);
  // ๋‚˜๋จธ์ง€ ํ—ค๋”๋“ค๋„ ๋ณต์‚ฌ
  retryRes.headers.forEach((v, k) => {
    if (k.toLowerCase() !== 'set-cookie') resp.headers.set(k, v);
  });
  // ๋ธŒ๋ผ์šฐ์ €์— ๋Œ๋ ค์คŒ
  return resp;
}

// ๋ผ์šฐํŠธ ์—”ํŠธ๋ฆฌ
export async function GET(req: Request, { params }: { params: { path: string[] } }) {
  return handler(req, params.path.join('/'));
}
export async function POST(req: Request, { params }: { params: { path: string[] } }) {
  return handler(req, params.path.join('/'));
}
export async function PUT(req: Request, { params }: { params: { path: string[] } }) {
  return handler(req, params.path.join('/'));
}
export async function PATCH(req: Request, { params }: { params: { path: string[] } }) {
  return handler(req, params.path.join('/'));
}
export async function DELETE(req: Request, { params }: { params: { path: string[] } }) {
  return handler(req, params.path.join('/'));
}

clientFetcher ์—์„œ๋„ ์›๋ž˜๋Š” backend ๋กœ ๋ฐ”๋กœ๋ณด๋ƒˆ์ง€๋งŒ, bff๋ฅผ ๊ฒฝ์œ ํ•˜๊ฒŒ๋” ๋ณ€๊ฒฝํ•ด์•ผํ•œ๋‹ค.

// ํด๋ผ์ด์–ธํŠธ ์ „์šฉ fetcher (์ฟ ํ‚ค๋Š” ์ž๋™ ํฌํ•จ)
import { SessionExpiredError } from '@/libs/error/errors';

export const clientFetcher = async <T = any>(
  path: string,
  options: RequestInit = {},
): Promise<T> => {
  // BFF ๊ฒฝ์œ 
  const finalUrl = `/api/bff${path}`;

  const res = await fetch(finalUrl, {
    ...options,
    credentials: 'include',
    headers: {
      Accept: 'application/json',
      ...(options.headers || {}),
    },
  });

  // BFF๊ฐ€ refresh ์‹คํŒจ ์‹œ 401์„ ๊ทธ๋Œ€๋กœ ๋‚ด๋ ค์คŒ
  if (res.status === 401) {
    console.log('refresh ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
    throw new SessionExpiredError();
  }

  const json: any = await res.json().catch(() => ({}));

  if (!res.ok || json?.success === false) {
    throw new Error(json?.message || '์š”์ฒญ ์‹คํŒจ');
  }

  return json.data;

};

ํšŒ๊ณ 

์ด๋ฒˆ ๊ฒฝํ—˜์„ ํ†ตํ•ด ๋ฐฐ์šด ์ ์€ ํ† ํฐ ์ €์žฅ ์œ„์น˜๊ฐ€ ์ธ์ฆ ๊ตฌ์กฐ์˜ ์•ˆ์ „์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ์ขŒ์šฐํ•œ๋‹ค๋Š” ์ ์ด๋‹ค. ๋‹ค์‹œ accessToken, refreshToken ์ธ์ฆ ๋ฐฉ์‹์— ๋Œ€ํ•ด์„œ ๊ณต๋ถ€ํ•ด๋ดค๋‹ค.

1) AccessToken์€ ๋ฉ”๋ชจ๋ฆฌ / RefreshToken์€ HttpOnly ์ฟ ํ‚ค

  • ์ธ์ฆ ์„ฑ๊ณต ์‹œ:
    • accessToken์€ ์‘๋‹ต ๋ณธ๋ฌธ (body)์œผ๋กœ ๋‚ด๋ ค์ฃผ๊ณ , ํด๋ผ์ด์–ธํŠธ ์•ฑ์€ ์ด๋ฅผ ๋ฉ”๋ชจ๋ฆฌ(์˜ˆ: ์ƒํƒœ๊ด€๋ฆฌ ์Šคํ† ์–ด)๋กœ ์ €์žฅํ•œ๋‹ค.
    • refreshToken์€ ์„œ๋ฒ„์—์„œ Set-Cookie๋กœ HttpOnly, Secure, SameSite ์†์„ฑ์„ ์ง€์ •ํ•ด ์ฟ ํ‚ค๋กœ ๋‚ด๋ ค์ค€๋‹ค.
  • ์š”์ฒญ ์‹œ:
    • API ์š”์ฒญ์€ Authorization ํ—ค๋”์— Bearer accessToken์„ ๋ถ™์ธ๋‹ค (๋ฉ”๋ชจ๋ฆฌ์—์„œ ๊บผ๋‚ด์„œ)
  • ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ:
    • accessToken ์œ ํšจ๊ธฐ๊ฐ„์ด ๋๋‚˜๋ฉด, ํด๋ผ์ด์–ธํŠธ๊ฐ€ refresh ์š”์ฒญ์„ ๋ณด๋‚ธ๋‹ค (๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ž๋™์œผ๋กœ ์ฟ ํ‚ค์˜ refreshToken์„ ์ „์†ก).
    • ์„œ๋ฒ„๊ฐ€ refreshToken์„ ๊ฒ€์ฆํ•˜๊ณ  accessToken + (์˜ต์…˜์œผ๋กœ) ์ƒˆ๋กœ์šด refreshToken์„ ์‘๋‹ต + Set-Cookie๋กœ ๊ฐฑ์‹ .

์ด ํŒจํ„ด์˜ ์žฅ์ ์œผ๋กœ๋Š” accessToken์ด ๋…ธ์ถœ๋˜๋Š” ๋ฒ”์œ„๊ฐ€ ์ œํ•œ๋˜๊ณ  refreshToken์€ HttpOnly ์ฟ ํ‚ค๋กœ ๋ณดํ˜ธ๋œ๋‹ค. CSRF ์œ„ํ—˜์€ refresh ํ˜ธ์ถœ ์—”๋“œํฌ์ธํŠธ๋งŒ ์กฐ์‹ฌํ•˜๋ฉด ๋œ๋‹ค (๊ทธ๋ฆฌ๊ณ  SameSite, CSRF ํ† ํฐ ๋“ฑ์„ ํ™œ์šฉ)
ํ•˜์ง€๋งŒ ์ด ๋ฐฉ์‹์—์„œ๋Š” ํŽ˜์ด์ง€ ์ƒˆ๋กœ๊ณ ์นจ ์‹œ accessToken์ด ์‚ฌ๋ผ์ง€๋ฏ€๋กœ, ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ดˆ๊ธฐ ๋งˆ์šดํŠธ ์‹œ /me ํ˜ธ์ถœ โ†’ ์—๋Ÿฌ โ†’ refresh ์‹œ๋„ โ†’ ๋ณต๊ตฌ ๋กœ์ง์„ ๋‘ฌ์•ผ ํ•œ๋‹ค. refresh ์š”์ฒญ์„ ๋ฐ˜๋“œ์‹œ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ง์ ‘ ๋ณด๋‚ด์•ผ ์ฟ ํ‚ค ๊ฐฑ์‹ ์ด ๊ฐ€๋Šฅํ•˜๋‹ค.

2) ๋‘˜ ๋‹ค HttpOnly ์ฟ ํ‚ค

  • accessToken๊ณผ refreshToken์„ ๋‘˜ ๋‹ค HttpOnly ์ฟ ํ‚ค๋กœ ์„ค์ •
  • API ์„œ๋ฒ„ ๋˜๋Š” BFF๊ฐ€ ์ฟ ํ‚ค ๊ธฐ๋ฐ˜์œผ๋กœ ์ธ์ฆ ๊ฒ€์ฆ / ๊ฐฑ์‹  ํ๋ฆ„์„ ๋‹ค๋ฃธ
  • ํด๋ผ์ด์–ธํŠธ๋Š” Authorization ํ—ค๋”๋ฅผ ์ง์ ‘ ์„ค์ •ํ•  ํ•„์š” ์—†์œผ๋ฉฐ, credentials ํฌํ•จ fetch๋กœ ์š”์ฒญ๋งŒ ํ•˜๋ฉด ๋จ
  • ์žฅ์ :
    • JS์—์„œ ์ „ํ˜€ ํ† ํฐ์„ ์ฝ์„ ์ˆ˜ ์—†์Œ โ†’ XSS ๊ณต๊ฒฉ์— ๋Œ€ํ•œ ๋ฐฉ์–ด๋ ฅ ์ƒ์Šน.
    • ํด๋ผ์ด์–ธํŠธ ๋กœ์ง ๋‹จ์ˆœ (Authorization ํ—ค๋” ๋ถ™์ด๋Š” ๋กœ์ง์ด ์—†์Œ).
  • ๋‹จ์  / ์ฃผ์˜์‚ฌํ•ญ:
    • ์ฟ ํ‚ค๊ฐ€ ์ž๋™ ์ „์†ก๋˜๊ธฐ ๋•Œ๋ฌธ์— CSRF ๊ณต๊ฒฉ ๊ฐ€๋Šฅ์„ฑ ์žˆ์Œ โ†’ SameSite, CSRF ํ† ํฐ, ๊ฒฝ๋กœ ์ œํ•œ ๋“ฑ์„ ์ž˜ ๊ตฌ์„ฑํ•ด์•ผ ํ•จ.
    • ์ผ๋ถ€ ์š”์ฒญ์—์„œ๋Š” ๋ช…์‹œ์  Authorization ํ—ค๋”๋ฅผ ์จ์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์„ ๋•Œ ์œ ์—ฐ์„ฑ์ด ๋–จ์–ด์งˆ ์ˆ˜ ์žˆ์Œ
    • refreshToken์ด ๋ชจ๋“  ์š”์ฒญ ์ฟ ํ‚ค์™€ ํ•จ๊ป˜ ์ „์†ก๋˜๋Š” ๊ฑธ ๋ง‰๊ณ  ์‹ถ์œผ๋ฉด, refreshToken ์ฟ ํ‚ค์— Path=/refresh ๊ฐ™์€ ์ œํ•œ์„ ๋‘˜ ์ˆ˜ ์žˆ๋‹ค๋Š” ์ œ์•ˆ๋„ ์žˆ์Œ

์ถœ์ฒ˜

0๊ฐœ์˜ ๋Œ“๊ธ€