๋ค์์ ๋ด๊ฐ ์ฒ์ ์ ์ฉํ๋ ์๋ชป๋ ๊ณตํต 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 ํค๋์ ์ค์ ํ๋๋ก ๋ง๋ค์๋ค. ํ์ง๋ง ์ฌ๊ธฐ์ ์น๋ช
์ ์ธ ๋ฌธ์ ๊ฐ ์์๋ค.
์๋ฒ/ํด๋ผ์ด์ธํธ ํผ์ฉ ๋ฌธ์
next/headers์ cookies()๋ฅผ ์ฌ์ฉํด์ผ ํ๊ณ , ๋๋ฌธ์ ํ์ผ ์ต์๋จ์ "use server"๋ฅผ ์ ์ธํ๋ค.fetchJWT()๋ฅผ ํธ์ถํ๋ฉด, ๋น๋ ์๋ฌ๊ฐ ๋ฐ์ํ๊ฑฐ๋ ์๋ฒ ์ ์ฉ ์ฝ๋๊ฐ ๋ฒ๋ค์ ํฌํจ๋์ด ํด๋ผ์ด์ธํธ ๋ฐํ์์์ ํฐ์ง ์ํ์ด ํฌ๋ค.์๋ชป๋ ์ธ์ฆ ํจํด ์ ์ฉ
localStorage/sessionStorage์ ํ ํฐ์ ์ ์ฅํ๊ณ , fetch ํธ์ถ ์ ๊บผ๋ด์ ํค๋์ ๋ถ์ด๋ ํจํด์ด๋ค.๋ฐ๋ผ์ fetch ๋ก์ง์ ์๋ฒ ์ ์ฉ / ํด๋ผ์ด์ธํธ ์ ์ฉ์ผ๋ก ๋ถ๋ฆฌํ๋ ๊ฒ์ด ์ฌ๋ฐ๋ฅธ ๋ฐฉํฅ๊ฐ๋ค.
๋ธ๋ผ์ฐ์ 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;
await fetch("/api/user/me", {
method: "GET",
credentials: "include", // ๋ธ๋ผ์ฐ์ ๊ฐ ์ฟ ํค ์๋ ์ ์ก
});
/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๋ ์ค์ง ๋ธ๋ผ์ฐ์ ๊ฐ ์ง์ ๋ฐ์ ์๋ต์๋ง ๋ฐ์๋๋ค.
accessToken์ ์ฌ์ค์ ํ๋๋ผ๋, ํด๋น ์์ฒญ์ด Next.js ์๋ฒ(fetcher) ๋ด๋ถ์์ ๋ฐ์ํ ๊ฒ์ด๋ผ๋ฉด ๋ธ๋ผ์ฐ์ ์๋ ์๋ฌด๋ฐ ์ฟ ํค ๋ณ๊ฒฝ๋ ๋ฐ์๋์ง ์๋๋ค.1. SSR: ์ฃผ๋ฌธ ์กฐํ ์๋ โ accessToken ๋ง๋ฃ โ SSR ๋ ๋ ๊ฒฐ๊ณผ: "์ฃผ๋ฌธ ์กฐํ ์คํจ"
2. CSR: ํ์ด์ง ๋ง์ดํธ ์ โ refresh ์๋ โ ์ฑ๊ณต ์ ๋ค์ ์ฃผ๋ฌธ ์ ๋ณด ์์ฒญ
3. ์ฑ๊ณตํ๋ฉด โ "์ฃผ๋ฌธ ์์ธ"๋ก ์ ํ
์๋ฒ(fetcher)์์๋ accessToken์ด ๋ง๋ฃ๋๋ฉด 401์ ๋ฐํํ๊ณ , ์ดํ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ์์ mount ์์ ์ /api/refresh ์์ฒญ์ ๋ณด๋ด ์ฟ ํค๋ฅผ ๊ฐฑ์ ํ๋ค. ๋ธ๋ผ์ฐ์ ๊ฐ ์ง์ Spring Boot์ refresh ์์ฒญ์ ๋ณด๋ด๋ฉด, Spring Boot์ Set-Cookie ์๋ต์ด ๋ธ๋ผ์ฐ์ ์ ๋๋ฌํ๊ณ , ์ค์ ์ฟ ํค๊ฐ ๊ฐฑ์ ๋๋ค.
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, ํ์ง๋ง ์ฟ ํค ๊ฐฑ์ ์ ๋ชปํ๋ค.
(์๋์ ์์ธํ ์ ์ด๋จ๋ค. ๊ฒฐ๊ตญ 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์ ์๋ต๋์ผํ๊ฒ BFF์ฌ์ฉํด์ ์๋ฒ ์ธก์์ accessToken์ ์ฌ๋ฐ๊ธ ๋ฐ์๋, ๋ธ๋ผ์ฐ์ ์ฟ ํค์ ๋ฐ์๋์ง ์์์ ๋ฌด์๋ฏธ
SSR์์ accessToken ๋ง๋ฃ
serverFetcher() โ 401 โ prefetchQuery() error ์ํ ์ ์ฅ
CSR์์ hydration ํ useQuery()์ retryOnMount: true (๊ธฐ๋ณธ๊ฐ) โ ์๋ ์ฌ์์ฒญ ๋ฐ์
clientFetcher() 401 โ /refresh ์์ฒญ โ accessToken ๊ฐฑ์ โ ์ ์์ฒญ ์ฌ์๋ โ ์ ์ ์๋
1. SSR: ์ฃผ๋ฌธ ์กฐํ ์๋ โ accessToken ๋ง๋ฃ โ SSR ๋ ๋ ๊ฒฐ๊ณผ: "์ฃผ๋ฌธ ์กฐํ ์คํจ"
2. CSR: ํ์ด์ง ๋ง์ดํธ ์ โ refresh ์๋ โ ์ฑ๊ณต ์ ๋ค์ ์ฃผ๋ฌธ ์ ๋ณด ์์ฒญ
3. ์ฑ๊ณตํ๋ฉด โ "์ฃผ๋ฌธ ์์ธ"๋ก ์ ํ
access_token์ด ์ ํจํ ๋๋ง ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์จ๋ค.null ๋ฐํrefresh โ /api/member/me ์ฌ์์ฒญ์ผ๋ก ์ฌ์ฉ์ ์ํ๋ฅผ ๋ณต๊ตฌํ๋ค.| ์์น | ์ญํ |
|---|---|
RootLayout (SSR) | SSR ์ access_token ์ฟ ํค๊ฐ ์๋ค๋ฉด getUserInfo() ์คํ โ ์ ์ ์ ๋ณด ํ๋ณด |
UserHydration | SSR์์ ์ป์ ์ ์ ์ ๋ณด๋ฅผ CSR ์ชฝ zustand๋ context์ ์ด๊ธฐ ์ฃผ์ |
UserSyncHandler | CSR์์ access_token์ด ๋ง๋ฃ๋์๊ฑฐ๋ SSR์์ ์ ์ ์ ๋ณด๋ฅผ ๋ชป ๋ถ๋ฌ์จ ๊ฒฝ์ฐ, refresh ์์ฒญ โ ์ํ ๋ณต๊ตฌ |
SessionExpiredRedirect | ๋ก๊ทธ์ธ๋ ๊ฒ์ฒ๋ผ ๋ณด์ด์ง๋ง ์ค์ ๋ก ์ธ์ฆ ์คํจ ์ํ์ผ ๋ ์๋ ๋ฆฌ๋๋ ์ ์ฒ๋ฆฌ |
fetcher ๋ถ๋ฆฌ'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;
};
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๋ ์ถ๊ฐํ๋ค..
UserSyncHandler โ CSR์์ ์ํ ๋ณต๊ตฌ์ฌ์ฉ์๊ฐ ํ์ด์ง๋ฅผ ์๋ก๊ณ ์นจํ๊ฑฐ๋, CSR์ผ๋ก ์ง์ ํน์ ํ์ด์ง์ ์ ๊ทผํ ๊ฒฝ์ฐ, Zustand์ ์ ์ฅ๋ ์ ์ ์ํ๋ ๋ฉ๋ชจ๋ฆฌ์์ ์ด๊ธฐํ๋๋ค.
์ด๋ ์ํ ๋ณต๊ตฌ๋ฅผ ์ํด HttpOnly ์ฟ ํค์ ์ ์ฅ๋ access_token์ ๊ธฐ๋ฐ์ผ๋ก /api/member/me ์์ฒญ์ ๋ณด๋ด ์ ์ ์ ๋ณด๋ฅผ ๋ค์ ๊ฐ์ ธ์จ๋ค.
UserSyncHandler๋ ๋ฐ๋ก ์ด ๊ณผ์ ์ ๋ด๋นํ๋ ํด๋ผ์ด์ธํธ ์ ์ฉ ์ปดํฌ๋ํธ๋ก, ์ ์ ์ ๋ณด๋ฅผ ๋๊ธฐํ(sync)ํ๊ณ ์ํ ๊ด๋ฆฌ ๋๊ตฌ(Zustand ๋ฑ)์ ๋ก๊ทธ์ธ ์ ๋ณด๋ฅผ ๋ณต์ํ๋ค.
'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;
}
SessionExpiredRedirect โ ์ ์ญ ๊ฐ์ง ํ ๋ก๊ทธ์์react-query์ ์ ์ญ error handler์์ SessionExpiredError๋ฅผ ๊ฐ์งํ๋ฉด, SessionExpiredRedirect ์ปดํฌ๋ํธ๋ฅผ ํตํด ๋ฆฌ๋๋ ์ ํจ
user?.email ๋ฑ์ ๋ฃ์ผ๋ฉด ์ ๋๋ ์ด์ sellerEmail, userId ๋ฑ ๋ณด๋ด์ง ์๊ณ @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 ์ํ ๋๊ธฐํ ์ฌ์ฉ |
.
.
.
.
.
์ด๊ฒ ๋์ผ ์ค ์์์ง๋ง..
์ฒ์ ์ธ์ฆ๊ตฌ์กฐ๋ฅผ ๋ง์น ๋ค, ๋ฐฑ์๋๊ฐ HttpOnly ์ฟ ํค๋ก ํ ํฐ์ ๋ด๋ ค์ค ๋ Next.js๋ ์ด๋ป๊ฒ ๋ฐ์์ผ ํ๋๊ฐ๋ฅผ ๋ค์ ์๊ฐํด๋ดค๋ค.
์ฒ์์ BFF(ํ๋ก์ Route Handler)๋ฅผ SSR ๋ด๋ถ์์ ์๋ฒโ๋ฐฑ์๋ ํธ์ถ ์ฉ๋๋ก๋ง ์ดํดํด์, ๋ธ๋ผ์ฐ์ ๊ฐ Set-Cookie๋ฅผ ๋ชป ๋ฐ์ ๊ฐฑ์ ํจ๊ณผ๊ฐ ์๋ค? ๋ผ๊ณ ๋๊ผ๋ค.
ํ์ง๋ง ํฌ์ธํธ๋ "SSR์์ BFF๋ฅผ ํธ์ถ"์ด ์๋๋ผ, "๋ธ๋ผ์ฐ์ ๊ฐ BFF์ ์๋ต์ ์ง์ ๋ฐ๋๋ก ํ๋ ๊ฒ"์ด๋ค.
/api/*(BFF)๋ก ์์ฒญํ๊ฒ ํ๊ณ , BFF๊ฐ ์์ฒญ ์ฟ ํค๋ฅผ ๋ฐฑ์๋๋ก ์ ๋ฌํ ๋ค, ๋ฐฑ์๋๊ฐ ๋ด๋ ค์ค Set-Cookie๋ฅผ ๋ธ๋ผ์ฐ์ ์๋ต์ ๊ทธ๋๋ก ์ค์ด ํจ์ค์ค๋ฃจํ๋ ๊ฒ์ด๋ค. ์ด๋ ๊ฒ ํด์ผ ๋ธ๋ผ์ฐ์ ์ฟ ํค๊ฐ ์ค์ ๋ก ๊ฐฑ์ ๋๋ค.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 ์ธ์ฆ ๋ฐฉ์์ ๋ํด์ ๊ณต๋ถํด๋ดค๋ค.
์ด ํจํด์ ์ฅ์ ์ผ๋ก๋ accessToken์ด ๋
ธ์ถ๋๋ ๋ฒ์๊ฐ ์ ํ๋๊ณ refreshToken์ HttpOnly ์ฟ ํค๋ก ๋ณดํธ๋๋ค. CSRF ์ํ์ refresh ํธ์ถ ์๋ํฌ์ธํธ๋ง ์กฐ์ฌํ๋ฉด ๋๋ค (๊ทธ๋ฆฌ๊ณ SameSite, CSRF ํ ํฐ ๋ฑ์ ํ์ฉ)
ํ์ง๋ง ์ด ๋ฐฉ์์์๋ ํ์ด์ง ์๋ก๊ณ ์นจ ์ accessToken์ด ์ฌ๋ผ์ง๋ฏ๋ก, ํด๋ผ์ด์ธํธ๊ฐ ์ด๊ธฐ ๋ง์ดํธ ์ /me ํธ์ถ โ ์๋ฌ โ refresh ์๋ โ ๋ณต๊ตฌ ๋ก์ง์ ๋ฌ์ผ ํ๋ค. refresh ์์ฒญ์ ๋ฐ๋์ ๋ธ๋ผ์ฐ์ ๊ฐ ์ง์ ๋ณด๋ด์ผ ์ฟ ํค ๊ฐฑ์ ์ด ๊ฐ๋ฅํ๋ค.