header_logo

COLDSURF BETA

ํ‹ฐ์ผ“ ์ฐพ๊ธฐ

์†Œ๊ฐœ

๋ธ”๋กœ๊ทธ

๋กœ๊ทธ์ธ

#SameSite

#Cookie

#ssr

#nextjs

Written by Paul
"์ฟ ํ‚ค๋ฅผ ์–ด๋–ป๊ฒŒ ์“ฐ๊ณ  ๊ณ„์‹ ๊ฐ€์š”?"
์ด ์งˆ๋ฌธ์— ๋‹น๋‹นํ•˜๊ฒŒ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋Š” ์‚ฌ๋žŒ์€ ์ƒ๊ฐ๋ณด๋‹ค ๋งŽ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์›น ๊ฐœ๋ฐœ์„ ํ•˜๋‹ค ๋ณด๋ฉด ๋ฌด์‹ฌ์ฝ” cookie, sameSite, httpOnly, secure ๋“ฑ์˜ ์˜ต์…˜์„ ์„ค์ •ํ•˜์ง€๋งŒ, ์™œ ๊ทธ๋ ‡๊ฒŒ ํ•ด์•ผ ํ•˜๋Š”์ง€, ๋˜๋Š” ์–ด๋–ค ์˜๋ฏธ๋ฅผ ์ง€๋‹ˆ๋Š”์ง€ ๊นŠ์ด ๊ณ ๋ฏผํ•ด๋ณธ ์ ์€ ๋“œ๋ญ…๋‹ˆ๋‹ค.
์ด๋ฒˆ ๊ธ€์—์„œ๋Š” ์ฟ ํ‚ค์˜ ๋™์ž‘ ์›๋ฆฌ์™€ ํ•จ๊ป˜ SameSite ์†์„ฑ์˜ ์˜๋ฏธ, ๊ทธ๋ฆฌ๊ณ  ์ด๋ฅผ ๋‘˜๋Ÿฌ์‹ผ ๋ณด์•ˆ ์ด์Šˆ๊นŒ์ง€ ์งš์–ด๋ณด๋ ค ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿช ์ฟ ํ‚ค๋Š” ์™œ ์‚ฌ์šฉํ• ๊นŒ?

์ฟ ํ‚ค(Cookie)๋Š” ์›น ์„œ๋ฒ„์™€ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ƒํƒœ ์ •๋ณด๋ฅผ ์œ ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
๋Œ€ํ‘œ์ ์ธ ํ™œ์šฉ ์‚ฌ๋ก€๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค:
  • ์‚ฌ์šฉ์ž์˜ ๋กœ๊ทธ์ธ ์ƒํƒœ ์œ ์ง€
  • ์œ ์ € ์‹๋ณ„ ๋ฐ ๊ฐœ์ธํ™”
  • API ์ธ์ฆ ์ •๋ณด ์ €์žฅ (ํŠนํžˆ ์„ธ์…˜ ๊ธฐ๋ฐ˜ ์ธ์ฆ ์‹œ์Šคํ…œ์—์„œ)

๐Ÿ•ธ ๋‚ด ์›น์‚ฌ์ดํŠธ์—์„œ ์„ค์ •ํ•œ ์ฟ ํ‚ค, ๋‹ค๋ฅธ ๋„๋ฉ”์ธ์—์„œ๋„ ์“ฐ์ผ๊นŒ?

๋Œ€๋‹ต์€ **"์˜ˆ"**์ž…๋‹ˆ๋‹ค.
์กฐ๊ฑด๋งŒ ๋งž๋Š”๋‹ค๋ฉด, A ๋„๋ฉ”์ธ์—์„œ ์„ค์ •๋œ ์ฟ ํ‚ค๋Š” B ๋„๋ฉ”์ธ์—์„œ A ๋„๋ฉ”์ธ์œผ๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ๋•Œ ํ•จ๊ป˜ ์ „๋‹ฌ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
์˜ˆ๋ฅผ ๋“ค์–ด:
  1. ์‚ฌ์šฉ์ž๊ฐ€ A.com์—์„œ ๋กœ๊ทธ์ธํ•˜๊ณ  access_token ์ฟ ํ‚ค๊ฐ€ ์ƒ์„ฑ๋จ
  1. ์•…์˜์ ์ธ B.com์—์„œ <form action="https://A.com/api/send-money" method="POST"> ๊ฐ™์€ ์š”์ฒญ์„ ์‚ฌ์šฉ์ž ๋ชฐ๋ž˜ ์‹คํ–‰
  1. ์ด๋•Œ A.com์—์„œ ์„ค์ •ํ•œ ์ฟ ํ‚ค๋Š” ์ž๋™์œผ๋กœ ํฌํ•จ๋˜์–ด ์š”์ฒญ์ด ์ „์†ก๋จ
  1. ๊ฒฐ๊ณผ์ ์œผ๋กœ ์‚ฌ์šฉ์ž์˜ ์˜์ง€์™€ ์ƒ๊ด€์—†์ด ๋ฏผ๊ฐํ•œ ๋™์ž‘์ด ์‹คํ–‰๋  ์ˆ˜ ์žˆ์Œ
์ด๊ฒƒ์ด ๋ฐ”๋กœ 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 ์ธ์ฆ ํ๋ฆ„

  1. ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธํ•˜๋ฉด ์„œ๋ฒ„๋Š” Set-Cookie ํ—ค๋”๋ฅผ ํ†ตํ•ด accessToken ์ €์žฅ
  1. ์ดํ›„ ์‚ฌ์šฉ์ž๊ฐ€ ๋ธŒ๋ผ์šฐ์ €๋ฅผ ํ†ตํ•ด ํŽ˜์ด์ง€์— ์ ‘๊ทผํ•  ๋•Œ๋งˆ๋‹ค
      • ๋ธŒ๋ผ์šฐ์ €๋Š” ์ž๋™์œผ๋กœ ์ฟ ํ‚ค๋ฅผ ํ•จ๊ป˜ ์ „์†ก
      • 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 ๊ธฐ๋ฐ˜ ์ œ์–ด, ํ† ํฐ ์žฌ๋ฐœ๊ธ‰์„ ์กฐํ•ฉํ•˜๋ฉด ์‹ค์ œ ์„œ๋น„์Šค์—์„œ ํ•„์š”ํ•œ ์ธ์ฆ ๋กœ์ง์„ ์™„์ „ํ•˜๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
ย 

ยฉ 2025 COLDSURF, Inc.

Privacy Policy

Terms of Service