#SameSite
#Cookie
#ssr
#nextjs
Written by Paul
"์ฟ ํค๋ฅผ ์ด๋ป๊ฒ ์ฐ๊ณ ๊ณ์ ๊ฐ์?"
์ด ์ง๋ฌธ์ ๋น๋นํ๊ฒ ์ค๋ช
ํ ์ ์๋ ์ฌ๋์ ์๊ฐ๋ณด๋ค ๋ง์ง ์์ต๋๋ค. ์น ๊ฐ๋ฐ์ ํ๋ค ๋ณด๋ฉด ๋ฌด์ฌ์ฝ
cookie
, sameSite
, httpOnly
, secure
๋ฑ์ ์ต์
์ ์ค์ ํ์ง๋ง, ์ ๊ทธ๋ ๊ฒ ํด์ผ ํ๋์ง, ๋๋ ์ด๋ค ์๋ฏธ๋ฅผ ์ง๋๋์ง ๊น์ด ๊ณ ๋ฏผํด๋ณธ ์ ์ ๋๋ญ
๋๋ค.์ด๋ฒ ๊ธ์์๋ ์ฟ ํค์ ๋์ ์๋ฆฌ์ ํจ๊ป
SameSite
์์ฑ์ ์๋ฏธ, ๊ทธ๋ฆฌ๊ณ ์ด๋ฅผ ๋๋ฌ์ผ ๋ณด์ ์ด์๊น์ง ์ง์ด๋ณด๋ ค ํฉ๋๋ค.๐ช ์ฟ ํค๋ ์ ์ฌ์ฉํ ๊น?
์ฟ ํค(Cookie)๋ ์น ์๋ฒ์ ๋ธ๋ผ์ฐ์ ๊ฐ ์ํ ์ ๋ณด๋ฅผ ์ ์งํ๊ธฐ ์ํด ์ฌ์ฉํฉ๋๋ค.
๋ํ์ ์ธ ํ์ฉ ์ฌ๋ก๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค:
- ์ฌ์ฉ์์ ๋ก๊ทธ์ธ ์ํ ์ ์ง
- ์ ์ ์๋ณ ๋ฐ ๊ฐ์ธํ
- API ์ธ์ฆ ์ ๋ณด ์ ์ฅ (ํนํ ์ธ์ ๊ธฐ๋ฐ ์ธ์ฆ ์์คํ ์์)
๐ธ ๋ด ์น์ฌ์ดํธ์์ ์ค์ ํ ์ฟ ํค, ๋ค๋ฅธ ๋๋ฉ์ธ์์๋ ์ฐ์ผ๊น?
๋๋ต์ **"์"**์
๋๋ค.
์กฐ๊ฑด๋ง ๋ง๋๋ค๋ฉด, A ๋๋ฉ์ธ์์ ์ค์ ๋ ์ฟ ํค๋ B ๋๋ฉ์ธ์์ A ๋๋ฉ์ธ์ผ๋ก ์์ฒญ์ ๋ณด๋ผ ๋ ํจ๊ป ์ ๋ฌ๋ ์ ์์ต๋๋ค.
์๋ฅผ ๋ค์ด:
- ์ฌ์ฉ์๊ฐ A.com์์ ๋ก๊ทธ์ธํ๊ณ
access_token
์ฟ ํค๊ฐ ์์ฑ๋จ
- ์
์์ ์ธ B.com์์
<form action="https://A.com/api/send-money" method="POST">
๊ฐ์ ์์ฒญ์ ์ฌ์ฉ์ ๋ชฐ๋ ์คํ
- ์ด๋ A.com์์ ์ค์ ํ ์ฟ ํค๋ ์๋์ผ๋ก ํฌํจ๋์ด ์์ฒญ์ด ์ ์ก๋จ
- ๊ฒฐ๊ณผ์ ์ผ๋ก ์ฌ์ฉ์์ ์์ง์ ์๊ด์์ด ๋ฏผ๊ฐํ ๋์์ด ์คํ๋ ์ ์์
์ด๊ฒ์ด ๋ฐ๋ก CSRF (Cross Site Request Forgery) ๊ณต๊ฒฉ์
๋๋ค.
๐งฑ SameSite ์ ์ฑ ์ ๋ฑ์ฅ
์ด๋ฌํ ๋ณด์ ์ํ์ ๋ง๊ธฐ ์ํด ๋ฑ์ฅํ ๊ฒ์ด
SameSite
์ฟ ํค ์ ์ฑ
์
๋๋ค. ์ด ์ ์ฑ
์ ๋ธ๋ผ์ฐ์ ๊ฐ ์ฟ ํค๋ฅผ ์ธ์ ์ ์กํ ์ ์๋์ง๋ฅผ ์ ์ดํฉ๋๋ค.SameSite ์ ์ฑ ์ข ๋ฅ
๊ฐ | ์ค๋ช
|
Strict | ์์ ์ ์ฌ์ดํธ์์ ๋ฐ์ํ ์์ฒญ์๋ง ์ฟ ํค ์ ์ก. ์ธ๋ถ ๋งํฌ/ํผ๋ ํฌํจ ์๋จ |
Lax | ๋๋ถ๋ถ์ ๊ฒฝ์ฐ ์ฐจ๋จ๋์ง๋ง, GET ๊ธฐ๋ฐ์ ์ ์ ์ธ ๋งํฌ๋ form ์์ฒญ ๋ฑ์ ํ์ฉ |
None | ๋ชจ๋ ์์ฒญ์ ์ฟ ํค๊ฐ ์ ์ก๋จ. ๋จ, Secure: true ๋ฐ๋์ ํ์ |
์์ฝ
Strict
โ ๊ฐ์ฅ ๋ณด์์ด ๊ฐํจ, ์ธ๋ถ ์์ฒญ ์ฐจ๋จ
Lax
โ ๊ธฐ๋ณธ๊ฐ. GET ์์ฒญ๋ง ํ์ฉ
None
โ ํฌ๋ก์ค ๋๋ฉ์ธ ์์ฒญ ํ์ฉ (๋ณด์ ์ทจ์ฝ ๊ฐ๋ฅ์ฑ ์์)
โ ๏ธ SameSite=none์ ์ํํ ๊น?
๋จ๋
์ผ๋ก ๋ณด๋ฉด ๊ทธ๋ ์ต๋๋ค.
์ฟ ํค๋ฅผ ํตํด ์ธ์ฆ์ ์ฒ๋ฆฌํ๋ฉด์ SameSite๋ฅผ
None
์ผ๋ก ์ค์ ํด๋๋ฉด, ํ ๋๋ฉ์ธ์์ ์
์์ ์ธ ์์ฒญ์๋ ์ธ์ฆ ์ฟ ํค๊ฐ ํจ๊ป ์ ์ก๋ ์ ์์ต๋๋ค.ํ์ง๋ง ์์ฆ์ ๋๋ถ๋ถ Authorization ํค๋ ๊ธฐ๋ฐ์ JWT ์ธ์ฆ ๋ฐฉ์์ ์ฌ์ฉํฉ๋๋ค:
Authorization: Bearer <token>
์ด ๋ฐฉ์์ ์ฐ๋ฉด CSRF์ ๊ฐํฉ๋๋ค. ์๋ํ๋ฉด ๋ธ๋ผ์ฐ์ ๋ ์ค์ค๋ก Authorization ํค๋๋ฅผ ์์ฑํ์ง ๋ชปํ๊ธฐ ๋๋ฌธ์
๋๋ค. ์ฌ์ฉ์๊ฐ ์ง์ ์คํฌ๋ฆฝํธ๋ฅผ ์คํํ๊ฑฐ๋ fetch ์์ฒญ์ ๋ช
์์ ์ผ๋ก ๋ง๋ค์ด์ผ๋ง ์๋ฒ์์ ์ธ์ฆ์ ํต๊ณผํ ์ ์์ต๋๋ค.
๊ฒฐ๊ตญ ์์ฆ์ ์ธ์ฆ ํ๋ฆ์ ์ด๋ ๊ฒ ์ ๋ฆฌ๋ฉ๋๋ค:
- ์ฟ ํค๋ ๋ก๊ทธ์ธ ์ธ์ ์ ์ง ๋ฑ SSR ๋ชฉ์ ์ผ๋ก๋ง ์ฌ์ฉ
- ์ค์ ์ธ์ฆ์ Authorization ํค๋ ๊ธฐ๋ฐ์ผ๋ก ์ํ
- SameSite=None์ ์ฐ๋๋ผ๋, ์๋ฒ๋ Authorization์ผ๋ก ์ธ์ฆ์ ํ์ธ
์ด๋ ๊ฒ ๊ตฌ์ฑํ๋ฉด ๋ณด์์ฑ๊ณผ ์ ์ฐ์ฑ์ ๋ชจ๋ ํ๋ณดํ ์ ์์ต๋๋ค.
โ๏ธ ์ ๋ฆฌํ๋ฉฐ
SameSite์ ์ฟ ํค๋ ๋จ์ํ ์ค์ ๊ฐ์ฒ๋ผ ๋ณด์ด์ง๋ง, ๋ณด์๊ณผ ๋ฐ์ ํ๊ฒ ์ฐ๊ฒฐ๋์ด ์์ต๋๋ค.
โ
์ฟ ํค ๊ธฐ๋ฐ ์ธ์ฆ๋ง์ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ์
SameSite=Strict
๋๋ Lax
๋ฅผ ๊ถ์ฅโ
JWT ๊ธฐ๋ฐ ์ธ์ฆ์ ๋์
ํ๋ค๋ฉด,
SameSite=None + Secure + httpOnly
์กฐํฉ์ผ๋ก๋ ์์ ํ๊ฒ ์ด์ ๊ฐ๋ฅโ
Server Side Rendering (SSR) ์์ ๋ก๊ทธ์ธ ์ํ ์ ์ง๋ฅผ ์ํด ์ฟ ํค๋ฅผ ์ฌ์ฉํ ์ ์์ผ๋, ์ค์ํ ์ธ์ฆ์ ํญ์ Authorization ํค๋๋ฅผ ๊ธฐ์ค์ผ๋ก ํ๋จ
์ฟ ํค๋ ์ฌ์ ํ ์ ์ฉํ ๋๊ตฌ์ ๋๋ค. ๋ค๋ง, ๊ทธ ์ฌ์ฉ์ฒ์ ๋์ ๋ฒ์๋ฅผ ์ ํํ ์ดํดํ๊ณ ์์ด์ผ ์ง์ง ์์ ํ ์ธ์ฆ ์์คํ ์ ๋ง๋ค ์ ์์ต๋๋ค.
๐งโ๐ป SSR์์ ๋ก๊ทธ์ธ ์ํ ์ ์ง โ ์ฟ ํค์ ์ญํ
๋ง์ ์ฌ๋๋ค์ด
JWT
์ ๊ฐ์ ํ ํฐ ๊ธฐ๋ฐ ์ธ์ฆ์ CSR(Client Side Rendering) ํ๊ฒฝ์์๋ง ๊ณ ๋ คํ์ง๋ง, ์ค์ ๋ก SSR(Server Side Rendering) ํ๊ฒฝ์์๋ ์ ์ ์ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์งํ๋ ๋ฐฉ์์ ์ฌ์ ํ ์ค์ํฉ๋๋ค.ํนํ Next.js์์ ํ์ด์ง๋ฅผ ์๋ฒ์์ ๋ ๋๋งํ ๊ฒฝ์ฐ, ํด๋ผ์ด์ธํธ์ ์ธ์ฆ ์ ๋ณด๋ฅผ ์๋ฒ์์ ์ฝ์ด์ผ ํ์ฌ ์ ์ ์ ์ํ๋ฅผ ๋ฐ์ํ ์ ์์ต๋๋ค.
๐ฆ ์ฟ ํค๋ฅผ ํ์ฉํ SSR ์ธ์ฆ ํ๋ฆ
- ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธํ๋ฉด ์๋ฒ๋
Set-Cookie
ํค๋๋ฅผ ํตํด accessToken ์ ์ฅ
- ์ดํ ์ฌ์ฉ์๊ฐ ๋ธ๋ผ์ฐ์ ๋ฅผ ํตํด ํ์ด์ง์ ์ ๊ทผํ ๋๋ง๋ค
- ๋ธ๋ผ์ฐ์ ๋ ์๋์ผ๋ก ์ฟ ํค๋ฅผ ํจ๊ป ์ ์ก
- SSR ๋จ๊ณ์์ ์๋ฒ๋ ์ด ์ฟ ํค๋ฅผ ์ฝ๊ณ ์ธ์ฆ ์ํ ํ์ธ ๊ฐ๋ฅ
๐ง ์์ : Next.js
App Router์์ SSR ์ธ์ฆ ์ฒ๋ฆฌ
// app/layout.tsx or app/page.tsx import { cookies } from 'next/headers' import { decodeJwt } from '@/libs/utils/jwt' export default async function Layout({ children }: { children: React.ReactNode }) { const cookieStore = cookies() const accessToken = cookieStore.get('access_token')?.value let user = null if (accessToken) { try { user = decodeJwt(accessToken) } catch (e) { console.warn('Invalid token') } } return ( <html> <body> {user ? <Header user={user} /> : <Header />} {children} </body> </html> ) }
cookies()๋ Next.js App Router์ ์๋ฒ ์ปดํฌ๋ํธ์์ ์ฌ์ฉ ๊ฐ๋ฅํ API์ ๋๋ค. ์ด ๋ฐฉ์์ผ๋ก SSR ๋จ๊ณ์์๋ ๋ก๊ทธ์ธ ์ ๋ณด๋ฅผ ํ์ธํ ์ ์์ต๋๋ค.
๐ ์ธ์ฆ ๋ฏธ๋ค์จ์ด๋ก ๋ณดํธํ ์๋ ์์
// middleware.ts import { NextRequest, NextResponse } from 'next/server' export function middleware(req: NextRequest) { const token = req.cookies.get('access_token')?.value if (!token) { return NextResponse.redirect(new URL('/login', req.url)) } return NextResponse.next() } export const config = { matcher: ['/dashboard/:path*', '/admin/:path*'], // ๋ณดํธํ ๊ฒฝ๋ก ์ง์ }
โ๏ธ ์ฟ ํค + SSR: ์ฃผ์ํ ์
ํญ๋ชฉ | ์ค๋ช
|
SameSite | ํฌ๋ก์ค๋๋ฉ์ธ ์ธ์ฆ ์๊ตฌ ์ None + Secure ํ์ |
httpOnly | ํด๋ผ์ด์ธํธ JS์์ ์ฟ ํค๋ฅผ ์ฝ์ ์ ์๋๋ก ์ค์ |
decodeJwt | SSR์์๋ ์ธ๋ถ API ํธ์ถ๋ณด๋ค JWT ๋์ฝ๋ฉ์ด ๋น ๋ฅด๊ณ ํจ์จ์ |
์ฟ ํค ๋ง๋ฃ | SSR ํ๊ฒฝ์์๋ ์ฟ ํค๊ฐ ๋ง๋ฃ๋๋ฉด SSR ์์ ์์ ์ ์ ์ ๋ณด ์์์ผ๋ก ์ฒ๋ฆฌ๋จ |
โ ๊ฒฐ๋ก
CSR์ Authorization ํค๋ ๊ธฐ๋ฐ
SSR์ ์ฟ ํค ๊ธฐ๋ฐ ์ธ์ฆ ์ ์ง๊ฐ ํ์ฌ ๊ฐ์ฅ ๋ง์ด ์ฐ์ด๋ ํจํด์
๋๋ค.
Next.js์ ๊ฐ์ ํ์ด๋ธ๋ฆฌ๋ ํ๋ ์์ํฌ์์๋ ์ด ๋ ์ ๋ต์ ๋ณํํ๋ฉฐ, ๋ณด์์ฑ๊ณผ ์ฌ์ฉ์ ๊ฒฝํ์ ๋ชจ๋ ์งํฌ ์ ์์ต๋๋ค.
์ฌ์ฉ์์ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ํํ ํ์ ํ๊ณ ์ถ๋ค๋ฉด, SSR์์๋ ์ฟ ํค๋ฅผ ํ์ฉํ JWT ํ์ฑ์ ๊ณ ๋ คํด๋ณด์ธ์.
๐ ๊ทธ๋ ๋ค๋ฉด ์ค์ ๋ก๊ทธ์ธ/๋ก๊ทธ์์์ ์ด๋ป๊ฒ ๊ตฌํํ ๊น?
์์ ์ค๋ช
ํ ๊ฒ์ฒ๋ผ, SSR ํ๊ฒฝ์์๋ ์ฟ ํค๋ฅผ ํตํด ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์งํ ์ ์์ต๋๋ค. ๊ทธ๋ ๋ค๋ฉด ๋ก๊ทธ์ธ/๋ก๊ทธ์์ ์ฒ๋ฆฌ์, ๋ง์ฝ ์ฟ ํค๊ฐ ์์กฐ๋๊ฑฐ๋ ๋ง๋ฃ๋ ๊ฒฝ์ฐ ์ด๋ป๊ฒ ๋์ํ ์ ์์์ง๊น์ง๋ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
1๏ธโฃ ๋ก๊ทธ์ธ ์ accessToken์ ์ฟ ํค๋ก ์ ์ฅ
์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ์ ์ฑ๊ณตํ๋ฉด ์๋ฒ์์ accessToken์
Set-Cookie
ํค๋๋ฅผ ํตํด ๋ธ๋ผ์ฐ์ ์ ์ ๋ฌํฉ๋๋ค. ์ด ์ฟ ํค๋ httpOnly
, Secure
, SameSite=None
๋ฑ ๋ณด์ ์ต์
๊ณผ ํจ๊ป ์ค์ ํด์ผ ํฉ๋๋ค.// app/api/auth/signin/route.ts import { cookies } from 'next/headers' import { serialize } from 'cookie' export async function POST(req: NextRequest) { const { email, password } = await req.json() const { authToken } = await apiClient.auth.signIn({ email, password, provider: 'email', platform: 'web', }) const cookie = serialize('access_token', authToken.accessToken, { httpOnly: true, secure: true, sameSite: 'none', path: '/', maxAge: 60 * 60 * 24 * 7, domain: process.env.NODE_ENV === 'development' ? undefined : '.yourdomain.com', }) return new Response(null, { status: 200, headers: { 'Set-Cookie': cookie, }, }) }
2๏ธโฃ ๋ก๊ทธ์์ ์ ์ฟ ํค ๋ง๋ฃ ์ฒ๋ฆฌ
๋ก๊ทธ์์์ ์ฒ๋ฆฌํ ๋๋ ๋จ์ํ ์ฟ ํค๋ฅผ ์ ๊ฑฐํ๋ฉด ๋ฉ๋๋ค. ์ด๋
maxAge: 0
์ต์
์ ์ฃผ๋ฉด ์ฟ ํค๊ฐ ์ฆ์ ๋ง๋ฃ๋ฉ๋๋ค.// app/api/auth/signout/route.ts import { serialize } from 'cookie' export async function POST() { const expiredCookie = serialize('access_token', '', { httpOnly: true, secure: true, sameSite: 'none', path: '/', maxAge: 0, domain: process.env.NODE_ENV === 'development' ? undefined : '.yourdomain.com', }) return new Response(null, { status: 200, headers: { 'Set-Cookie': expiredCookie, }, }) }
3๏ธโฃ ๋ฐฑ์๋์์ 401 ๋ฐํ ์ ์๋ ๋ก๊ทธ์์ ์ฒ๋ฆฌ
์ฟ ํค๊ฐ ์์กฐ๋์๊ฑฐ๋ ๋ง๋ฃ๋ ๊ฒฝ์ฐ, ์๋ฒ API๋
401 Unauthorized
๋ฅผ ๋ฐํํ๊ฒ ๋ฉ๋๋ค. ์ด๋ ํด๋ผ์ด์ธํธ์์๋ ์๋์ผ๋ก signout
API๋ฅผ ํธ์ถํ๊ณ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋๋ ์
ํ ์ ์์ต๋๋ค.// libs/fetchWithAuth.ts export async function fetchWithAuth(input: RequestInfo, init: RequestInit = {}) { const res = await fetch(input, { ...init, credentials: 'include', // ์ฟ ํค ํฌํจ }) if (res.status === 401) { await fetch('/api/auth/signout', { method: 'POST' }) if (typeof window !== 'undefined') { window.location.href = '/login?expired=1' } return null } return res.json() }
4๏ธโฃ SSR ํ๊ฒฝ์์๋ ์ ์ ์ ๋ณด ์ ์ง
์๋ฒ์์ ๋ ๋๋งํ ๋๋ ๋ธ๋ผ์ฐ์ ๊ฐ ๋ณด๋ธ ์ฟ ํค๋ฅผ ์ฝ์ด ์ ์ ์ ๋ณด๋ฅผ ํ์
ํ ์ ์์ต๋๋ค. ๋ณดํต์
decodeJwt
๋ฑ์ ํตํด accessToken์์ ์ ์ ์ ๋ณด๋ฅผ ๋์ฝ๋ฉํ๋ ์์ผ๋ก ์ฒ๋ฆฌํฉ๋๋ค.// app/layout.tsx import { cookies } from 'next/headers' import { decodeJwt } from '@/libs/utils/jwt' export default async function Layout({ children }: { children: React.ReactNode }) { const token = cookies().get('access_token')?.value let user = null if (token) { try { user = decodeJwt(token) } catch { // ์ฟ ํค๊ฐ ์์กฐ๋์๊ฑฐ๋ ๋ง๋ฃ๋จ } } return ( <html> <body> {user ? <Header user={user} /> : <Header />} {children} </body> </html> ) }
โ ย ์ฟ ํค๋ฅผ ์ฌ์ฉํ SSR + ๋ก๊ทธ์ธ๊ณผ ๋ก๊ทธ์์ ์ฒ๋ฆฌ ์ ๋ฆฌ
์ฐ๋ฆฌ๊ฐ SSR ํ๊ฒฝ์์๋ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์งํ๊ณ , ๋ณด์์ฑ์ ํด์น์ง ์์ผ๋ ค๋ฉด ์๋์ ๊ฐ์ ์ ๋ต์ด ํ์ํฉ๋๋ค:
- ์ฟ ํค๋
httpOnly
,Secure
,SameSite=None
๋ฑ ๋ณด์ ์ต์ ๊ณผ ํจ๊ป ์ค์
- CSR ํ๊ฒฝ์์๋ Authorization ํค๋๋ก ์ธ์ฆ, SSR์์๋ ์ฟ ํค๋ฅผ ํ์ฑํ์ฌ ์ ์ ์ํ ์ ์ง
- ๋ฐฑ์๋์์
401
๋ฐํ ์ ํด๋ผ์ด์ธํธ๋ ์๋์ผ๋ก ๋ก๊ทธ์์ ์ฒ๋ฆฌ
- ์์กฐ๋ ํ ํฐ์ด๋ ๋ง๋ฃ๋ ์ฟ ํค๋ SSR ์์๋ ํ์ฑ ์คํจ๋ก ์ฒ๋ฆฌ
์ด์ฒ๋ผ CSR๊ณผ SSR ํ๊ฒฝ ๋ชจ๋์์ ์ผ๊ด์ฑ ์๊ฒ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์งํ๋ฉด์, ๋ณด์์ฑ์ ํ๋ณดํ๋ ๋ฐฉ์์ด ์์ฆ ์ธ์ฆ ์์คํ
์ ํต์ฌ์
๋๋ค.
ย
๊ทธ๋ ๋ค๋ฉด Nextjs์์ middleware์์์ ์ฒ๋ฆฌ๋ ์ด๋ค์ง ์์๋ณด๊ฒ ์ต๋๋ค.
๐งฑ Middleware๋ก ๋ณดํธ๋๋ ์ธ์ฆ ๋ผ์ฐํธ
์ง๊ธ๊น์ง ์ฟ ํค๋ฅผ ํตํด SSR ํ๊ฒฝ์์๋ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ ์งํ๋ ๋ฐฉ๋ฒ์ ์ดํด๋ดค๋ค๋ฉด, ์ด์ ๋ ๋ก๊ทธ์ธ์ด ํ์ํ ํ์ด์ง๋ฅผ ์ฌ์ ์ ์ฐจ๋จํ๊ฑฐ๋ ๋ฆฌ๋๋ ์
์ํค๋ ๋ฐฉ๋ฒ๋ ์์์ผ ํฉ๋๋ค.
Next.js์์๋
middleware.ts
ํ์ผ์ ํตํด ํด๋ผ์ด์ธํธ๊ฐ ์์ฒญ์ ์๋ฒ๋ก ๋ณด๋ด๊ธฐ ์ ์ ํน์ ์กฐ๊ฑด์ ๊ฒ์ฌํ๊ณ ํ๋ฆ์ ์ ์ดํ ์ ์์ต๋๋ค.๐ฎ ๋ก๊ทธ์ธ ์ ๋ฌด์ ๋ฐ๋ผ ํ์ด์ง ์ ๊ทผ ์ ํํ๊ธฐ
์๋๋ ์ฌ์ฉ์๊ฐ ํน์ ๋ณดํธ ํ์ด์ง(
/dashboard
๋ /admin
๋ฑ)์ ์ ๊ทผํ ๋, ์ฟ ํค ๊ธฐ๋ฐ์ผ๋ก ๋ก๊ทธ์ธ ์ฌ๋ถ๋ฅผ ๊ฒ์ฌํ๊ณ ๋ก๊ทธ์ธํ์ง ์์ ๊ฒฝ์ฐ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋๋ ์
ํ๋ ์์์
๋๋ค:// middleware.ts import { NextRequest, NextResponse } from 'next/server' import { jwtVerify } from 'jose' const COOKIE_ACCESS_TOKEN_KEY = 'access_token' export async function middleware(req: NextRequest) { const token = req.cookies.get(COOKIE_ACCESS_TOKEN_KEY)?.value if (!token) { // ๋ก๊ทธ์ธํ์ง ์์ ์ฌ์ฉ์ โ ๋ก๊ทธ์ธ ํ์ด์ง๋ก return NextResponse.redirect(new URL('/login', req.url)) } try { // ํ ํฐ ์ ํจ์ฑ ๊ฒ์ฌ await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET)) return NextResponse.next() } catch { // ํ ํฐ ์์กฐ ํน์ ๋ง๋ฃ โ ์ฟ ํค ์ญ์ + ๋ก๊ทธ์ธ ํ์ด์ง๋ก const response = NextResponse.redirect(new URL('/login', req.url)) response.cookies.set(COOKIE_ACCESS_TOKEN_KEY, '', { httpOnly: true, secure: true, sameSite: 'none', maxAge: 0, path: '/', domain: process.env.NODE_ENV === 'development' ? undefined : '.yourdomain.com', }) return response } }
๐ ์ด๋ค ๊ฒฝ๋ก์๋ง middleware๋ฅผ ์ ์ฉํ ๊น?
์๋์ฒ๋ผ
config
์์ middleware๋ฅผ ์คํํ ๊ฒฝ๋ก๋ฅผ ์ง์ ํ ์ ์์ต๋๋ค. ๋ก๊ทธ์ธํ์ง ์์ ์ฌ์ฉ์๊ฐ ์ง์ ์ ์ํด์๋ ์ ๋๋ ํ์ด์ง์๋ง ์ ํ์ ๊ฑธ์ด๋๋ฉด ๋ฉ๋๋ค.export const config = { matcher: ['/dashboard/:path*', '/admin/:path*', '/mypage/:path*'], }
๐ก middleware๋ฅผ ์ธ ๋ ์ฃผ์ํ ์
์ฃผ์์ฌํญ | ์ค๋ช
|
์๋ต ์๋ | middleware๋ ๋ชจ๋ ์์ฒญ ์ ์ฒ๋ฆฌ๋๋ฏ๋ก ์ต์ํ์ ๋ก์ง๋ง ํฌํจํ ๊ฒ |
JWT Secret | ์๋ฒ์์ .env ๋ฅผ ํตํด ํ ํฐ ๊ฒ์ฆ ์ ํ์ํ secret์ ๋ฐ๋์ ์ ๊ณต |
์ฟ ํค ์ต์
| ์ฟ ํค๊ฐ httpOnly , Secure , SameSite ์ค์ ์ด ๋์ด ์์ด์ผ ์์ ํ๊ฒ ๋์ํจ |
redirect loop | ๋ก๊ทธ์ธ ํ์ด์ง ์ ๊ทผ๋ middleware์ ๊ฑธ๋ฆฌ์ง ์๋๋ก matcher ๊ฒฝ๋ก ์ฃผ์ |
โ ์ ๋ฆฌํ๋ฉด
- SSR์์๋ ์ฟ ํค๋ฅผ ํ์ฑํด์ ์ฌ์ฉ์ ์ธ์ฆ ์ ๋ณด๋ฅผ ํ์ธํ ์ ์๊ณ
- CSR์์๋
fetch
๋ก ์ธ์ฆ ์ํ๋ฅผ ์ฒ๋ฆฌํ๋ฉฐ
- middleware๋ฅผ ํ์ฉํ๋ฉด ์ธ์ฆ์ด ํ์ํ ํ์ด์ง ์ ๊ทผ์ ์ฌ์ ์ฐจ๋จํ ์ ์์ต๋๋ค.
์ด๋ฌํ ์ ๋ต์ ์กฐํฉํ๋ฉด, ์๋ฒ/ํด๋ผ์ด์ธํธ/๋ผ์ฐํ
๋ชจ๋ ์ธก๋ฉด์์ ์์ ํ๊ณ ์ผ๊ด๋ ์ธ์ฆ ํ๋ฆ์ ๊ตฌํํ ์ ์์ต๋๋ค.
๐ Refresh Token ๊ธฐ๋ฐ ์ฌ๋ฐ๊ธ๊ณผ RBAC๊น์ง
์ค์ ์ด์ ํ๊ฒฝ์์๋ accessToken์ด ์งง๊ฒ ๋ง๋ฃ๋๋ ๊ตฌ์กฐ๋ฅผ ์ฑํํ๋ ๊ฒฝ์ฐ๊ฐ ๋ง์ต๋๋ค. ์ด๋๋ middleware์์ accessToken์ด ๋ง๋ฃ๋์๋๋ผ๋, refreshToken์ด ์ ํจํ๋ค๋ฉด ์๋ก์ด accessToken์ ๋ฐ๊ธ๋ฐ์ ๋ค์ ์ ์ ์์ฒญ์ ์ฒ๋ฆฌํ ์ ์๊ฒ ๋ง๋ค์ด์ผ ํฉ๋๋ค.
๋ํ ๊ด๋ฆฌ์๊ฐ ์ ๊ทผํ ์ ์๋
/admin
, ์ ์ ๋ง ์ ๊ทผ ๊ฐ๋ฅํ /mypage
๊ฐ์ ํ์ด์ง๋ฅผ ๋ถ๊ธฐํ๋ ค๋ฉด Role ๊ธฐ๋ฐ ์ธ์ฆ(RBAC) ๋ ํจ๊ป ๊ณ ๋ คํด์ผ ํฉ๋๋ค.๐ช Refresh Token ๊ธฐ๋ฐ ์ฌ๋ฐ๊ธ ๊ตฌ์กฐ
// middleware.ts import { NextRequest, NextResponse } from 'next/server' import { jwtVerify, decodeJwt } from 'jose' const ACCESS_COOKIE = 'access_token' const REFRESH_COOKIE = 'refresh_token' async function verify(token: string) { return await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET)) } export async function middleware(req: NextRequest) { const accessToken = req.cookies.get(ACCESS_COOKIE)?.value const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value // 1. accessToken์ด ์๊ณ ์ ํจํ๋ฉด ๊ทธ๋๋ก ํต๊ณผ if (accessToken) { try { await verify(accessToken) return NextResponse.next() } catch { // ๊ณ์ ์งํ (2๋ฒ์ผ๋ก ๋์ด๊ฐ) } } // 2. accessToken์ด ๋ง๋ฃ๋์๊ณ , refreshToken์ผ๋ก ์ฌ๋ฐ๊ธ ์๋ if (refreshToken) { try { const { payload } = await verify(refreshToken) // refreshToken ์ ํจ โ ์๋ก์ด accessToken ์์ฑ const newAccessToken = generateNewAccessToken(payload) // JWT ์ฌ๋ฐ๊ธ ํจ์ const response = NextResponse.next() response.cookies.set(ACCESS_COOKIE, newAccessToken, { httpOnly: true, secure: true, sameSite: 'none', path: '/', maxAge: 60 * 15, // 15๋ถ domain: process.env.NODE_ENV === 'development' ? undefined : '.yourdomain.com', }) return response } catch { // refreshToken๋ ์ ํจํ์ง ์์ โ ๋ก๊ทธ์ธ ํ์ด์ง๋ก } } // 3. ๋ก๊ทธ์ธ ํ์ return NextResponse.redirect(new URL('/login', req.url)) }
๐ง ์ฌ๊ธฐ์ generateNewAccessToken() ํจ์๋ ์๋ฒ ๋ด๋ถ์ JWT ์ฌ๋ฐ๊ธ ๋ก์ง์ผ๋ก ๋ฐ๋ก ์ ์๋์ด์ผ ํ๋ฉฐ, ๋ณดํต์ ์ฌ์ฉ์ ID์ Role์ payload๋ก ํฌํจ์ํต๋๋ค.
๐งโ๐ผ RBAC (Role-Based Access Control)
middleware
์์ ์ ์ ์ role
๊น์ง ๊ฒ์ฌํด์ ๋ผ์ฐํ
์ ํ์ ๊ฑธ ์๋ ์์ต๋๋ค.const ADMIN_PATHS = ['/admin', '/admin/settings'] export async function middleware(req: NextRequest) { const token = req.cookies.get(ACCESS_COOKIE)?.value if (!token) { return NextResponse.redirect(new URL('/login', req.url)) } try { const { payload } = await verify(token) const userRole = payload.role as string const pathname = req.nextUrl.pathname if (ADMIN_PATHS.some(path => pathname.startsWith(path)) && userRole !== 'admin') { return NextResponse.redirect(new URL('/unauthorized', req.url)) } return NextResponse.next() } catch { return NextResponse.redirect(new URL('/login', req.url)) } }
โ ์ต์ข ์์ฝ
๊ธฐ๋ฅ | ์ฒ๋ฆฌ ๋ฐฉ์ |
๋ก๊ทธ์ธ ์ | accessToken + refreshToken ์ฟ ํค ์ ์ฅ |
accessToken ๋ง๋ฃ ์ | middleware์์ refreshToken ๊ฒ์ฆ ํ ์ฌ๋ฐ๊ธ |
refreshToken๋ ๋ง๋ฃ ์ | ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋๋ ์
|
SSR ๋ ๋๋ง ์ ์ธ์ฆ ์ํ ์ ์ง | ์ฟ ํค ํ์ฑ ํ ํ ํฐ ๋์ฝ๋ฉ |
๊ด๋ฆฌ์ ์ ์ฉ ํ์ด์ง ๋ณดํธ | role ๊ฒ์ฌ ํ ์ ๊ทผ ์ ํ (RBAC) |
์ด์ฒ๋ผ
Next.js
์ middleware
, JWT
, cookie
, role ๊ธฐ๋ฐ ์ ์ด
, ํ ํฐ ์ฌ๋ฐ๊ธ
์ ์กฐํฉํ๋ฉด ์ค์ ์๋น์ค์์ ํ์ํ ์ธ์ฆ ๋ก์ง์ ์์ ํ๊ฒ ๊ตฌํํ ์ ์์ต๋๋ค.ย