๐Ÿš€ Next.js ์‹ฌํ™” 11์žฅ: Next.js + Spring ์™„์ „ํ•œ ์ธ์ฆ ์•„ํ‚คํ…์ฒ˜ โ€” BFF ํŒจํ„ด๊ณผ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ํ† ํฐ ์„ค๊ณ„

๐Ÿ“‹ ๊ฐœ์š”

BFF ํŒจํ„ด๊ณผ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ํ† ํฐ ์„ค๊ณ„๋กœ Next.js์™€ Spring์„ ์—ฐ๊ฒฐํ•˜๋Š” ์ธ์ฆ ์•„ํ‚คํ…์ฒ˜์ž…๋‹ˆ๋‹ค.

๐Ÿ“‹ ๋ชฉ์ฐจ


๐Ÿ“Œ ์ด ๋ฌธ์„œ๋ฅผ ์ฝ๊ธฐ ์ „์—

โฑ๏ธ ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„: 25๋ถ„ (์ „์ฒด) / ํ•ต์‹ฌ ํŒŒํŠธ๋งŒ: 12๋ถ„

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ๋ฐฐ๊ฒฝ ์„ธ๊ณ„๊ด€: '์˜์ˆ˜๋„ค ์ปค๋ฎค๋‹ˆํ‹ฐ'

  • ์˜์ˆ˜(PM/๋ฐฑ์—”๋“œ): "Spring์œผ๋กœ ์ธ์ฆ API ๋งŒ๋“ค์—ˆ์–ด์š”. /auth/login์ด JWT ๋ฐœ๊ธ‰ํ•˜๊ณ , /auth/refresh๊ฐ€ ํ† ํฐ ๊ฐฑ์‹ ํ•ด์š”. ๊ทธ๋Ÿฐ๋ฐ ํ”„๋ก ํŠธ(Next.js)์—์„œ ์ด ํ† ํฐ์„ ์–ด๋–ป๊ฒŒ ๊ด€๋ฆฌํ•ด์•ผ ํ• ์ง€ ๋ชจ๋ฅด๊ฒ ์–ด์š”. ํด๋ผ์ด์–ธํŠธ์—์„œ Spring์„ ์ง์ ‘ ํ˜ธ์ถœํ•ด์•ผ ํ•˜๋Š”๋ฐ, ํ† ํฐ์„ localStorage์— ์ €์žฅํ•˜๋ฉด ์˜ํ˜ธ๊ฐ€ XSS ์œ„ํ—˜ํ•˜๋‹ค๊ณ  ํ•˜๊ณ ..."
  • ์˜ํ˜ธ(๋ฆฌ๋“œ): "์ •ํ™•ํžˆ ๋ดค์–ด์š”. ํ•ต์‹ฌ ์งˆ๋ฌธ์€ '๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์€ ์™„์ „ํžˆ ์ˆจ๊ธฐ๊ณ , ์•ก์„ธ์Šค ํ† ํฐ์€ ํด๋ผ์ด์–ธํŠธ๊ฐ€ Spring์— ์ง์ ‘ ๋ถ™์ผ ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ•˜๋Š”๊ฐ€?'์˜ˆ์š”. ๋‹ต์€ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF์˜ˆ์š”. ํ† ํฐ์„ ์—ญํ• ์— ๋”ฐ๋ผ ๋‘ ๊ฐœ๋กœ ๋ถ„๋ฆฌํ•ด์„œ ๋ณด๊ด€ํ•˜๋Š” ๊ฑฐ์˜ˆ์š”."

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ํ๋ฆ„
์•„ํ‚คํ…์ฒ˜ 4๊ฐ€์ง€ ๋น„๊ต โ†’ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF ๊ตฌํ˜„ โ†’ ํ† ํฐ ๊ฐฑ์‹  ํ•ธ๋“ค๋Ÿฌ โ†’ SSR ์„ธ์…˜ ํ†ตํ•ฉ โ†’ ์™„์ „ BFF ํŒจํ„ด โ†’ ๋ณด์•ˆ ๊ฐ•ํ™”

๐ŸŽฏ ์ด ๋ฌธ์„œ๋ฅผ ๋‹ค ์ฝ์œผ๋ฉด ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ

  • Next.js + ๋ณ„๋„ Spring API ์„œ๋ฒ„ ๊ตฌ์กฐ์—์„œ ์ตœ์ ์˜ ์ธ์ฆ ๋ฐฉ์‹์„ ์„ ํƒํ•  ์ˆ˜ ์žˆ๋‹ค
  • ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์€ HttpOnly๋กœ ๋ณดํ˜ธํ•˜๊ณ , ์•ก์„ธ์Šค ํ† ํฐ์€ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์“ธ ์ˆ˜ ์žˆ๋Š” ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ฟ ํ‚ค ํŒจํ„ด์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค
  • ํ† ํฐ ๊ฐฑ์‹ (/api/auth/refresh) Route Handler์™€ SSR ์„ธ์…˜ ํ†ตํ•ฉ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค

๐Ÿค” ์™œ ์•Œ์•„์•ผ ํ•˜๋Š”๊ฐ€

๊ตญ๋‚ด ์‹ค๋ฌด์—์„œ ํ”ํ•œ ์•„ํ‚คํ…์ฒ˜๊ฐ€ ์žˆ์–ด: Next.js๊ฐ€ ํ”„๋ก ํŠธ(BFF), Spring์ด ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„. ์ด ๊ตฌ์กฐ์—์„œ ์ธ์ฆ์€ ์ƒ๊ฐ๋ณด๋‹ค ๋ณต์žกํ•œ ๋ฌธ์ œ์•ผ.

ํ•ต์‹ฌ ๋”œ๋ ˆ๋งˆ๋ฅผ ์ดํ•ดํ•ด๋ด:

๋ฌธ์ œ 1: ๋ณด์•ˆ vs ํŽธ์˜์„ฑ

HttpOnly ์ฟ ํ‚ค๋กœ ํ† ํฐ ์™„์ „ ๋ณดํ˜ธ
  โ†’ XSS ๊ณต๊ฒฉ ์•ˆ์ „
  โ†’ ๊ทธ๋Ÿฐ๋ฐ ํด๋ผ์ด์–ธํŠธ JS๊ฐ€ ํ† ํฐ์„ ์ฝ์„ ์ˆ˜ ์—†์Œ
  โ†’ ๋ธŒ๋ผ์šฐ์ €์—์„œ Spring API ์ง์ ‘ ํ˜ธ์ถœ ๋ถˆ๊ฐ€!

์ผ๋ฐ˜ ์ฟ ํ‚ค๋‚˜ localStorage
  โ†’ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ํ† ํฐ์„ ์ฝ์–ด Spring์— ์ง์ ‘ ๋ณด๋‚ผ ์ˆ˜ ์žˆ์Œ
  โ†’ ๊ทธ๋Ÿฌ๋‚˜ XSS ๊ณต๊ฒฉ์— ๋…ธ์ถœ

๋ฌธ์ œ 2: SSR vs CSR

SSR (์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ): ์„œ๋ฒ„์—์„œ ์ฟ ํ‚ค ์ฝ์–ด Spring ํ˜ธ์ถœ โ†’ ์ธ์ฆ๋œ ์ดˆ๊ธฐ HTML
CSR (ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ): ๋ธŒ๋ผ์šฐ์ €์—์„œ ํ† ํฐ์„ ์ง์ ‘ Spring์— ์ „์†ก
โ†’ ๋‘ ๊ฒฝ์šฐ ๋ชจ๋‘ ์ง€์›ํ•ด์•ผ ํ•จ

ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF๊ฐ€ ์ด ๋”œ๋ ˆ๋งˆ๋ฅผ ํ•ด๊ฒฐํ•˜๋Š” ๋ฐฉ๋ฒ•:

์žฅ๊ธฐ ํ† ํฐ(๋ฆฌํ”„๋ ˆ์‹œ)์€ ์™„์ „ํžˆ ์ˆจ๊ธฐ๊ณ , ๋‹จ๊ธฐ ํ† ํฐ(์•ก์„ธ์Šค)๋งŒ ์ œํ•œ์ ์œผ๋กœ ๋…ธ์ถœํ•œ๋‹ค.


๐Ÿ—๏ธ ๋น„์œ ๋กœ ๋จผ์ € ์ดํ•ดํ•˜๊ธฐ

๐Ÿง’ 5์‚ด์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?
์•„ํŒŒํŠธ ํ‚ค๋ฅผ ๋‘ ๊ฐœ ๋งŒ๋“œ๋Š” ๊ฑฐ์•ผ:

  1. ๋งˆ์Šคํ„ฐ ํ‚ค(๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ): ๊ธˆ๊ณ (HttpOnly ์ฟ ํ‚ค) ์•ˆ์— ๋ณด๊ด€. ๊ฒฝ๋น„์‹ค(Next.js ์„œ๋ฒ„)๋งŒ ๊บผ๋‚ผ ์ˆ˜ ์žˆ์–ด. 7์ผ์งœ๋ฆฌ์•ผ.
  2. ์ผ์ผ ํŒจ์Šค(์•ก์„ธ์Šค ํ† ํฐ): ์ง€๊ฐ‘(์ผ๋ฐ˜ ์ฟ ํ‚ค, JS ์ ‘๊ทผ ๊ฐ€๋Šฅ)์— ๋ณด๊ด€. 1์‹œ๊ฐ„์งœ๋ฆฌ์•ผ. ์„ธ์ž…์ž(๋ธŒ๋ผ์šฐ์ €)๊ฐ€ ์ง์ ‘ ๊ฐ–๊ณ  ๋‹ค๋‹ ์ˆ˜ ์žˆ์–ด.

๋งˆ์Šคํ„ฐ ํ‚ค๋Š” ์žƒ์–ด๋ฒ„๋ฆฌ๋ฉด ์•ˆ ๋˜๋‹ˆ๊นŒ ๊ธˆ๊ณ ์—. ์ผ์ผ ํŒจ์Šค๋Š” ์–ด์ฐจํ”ผ 1์‹œ๊ฐ„์งœ๋ฆฌ๋ผ ์žƒ์–ด๋ฒ„๋ ค๋„ ํ”ผํ•ด๊ฐ€ ์ œํ•œ์ ์ด์•ผ.


๐Ÿ—บ๏ธ 4๊ฐ€์ง€ ์•„ํ‚คํ…์ฒ˜ ๋น„๊ต โ€” ์–ด๋–ค ๊ฑธ ์„ ํƒํ•ด์•ผ ํ•˜๋Š”๊ฐ€ ๐ŸŸข

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • 4๊ฐ€์ง€ ๋ฐฉ์‹์˜ ์ฐจ์ด์ ๊ณผ ์ถ”์ฒœ ์šฐ์„ ์ˆœ์œ„๋ฅผ ์•Œ๊ณ  ํŒ€ ์ƒํ™ฉ์— ๋งž๋Š” ์„ ํƒ์„ ํ•  ์ˆ˜ ์žˆ๋‹ค
์ˆœ์œ„๋ฐฉ์‹CSR์—์„œ Spring ์ง์ ‘ ํ˜ธ์ถœSSR ์ง€์›๋ณด์•ˆ ์ˆ˜์ค€๊ตฌํ˜„ ๋ณต์žก๋„์ ํ•ฉํ•œ ๊ฒฝ์šฐ
๐Ÿฅ‡ 1์œ„ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFFโœ… ๊ฐ€๋Šฅ (์•ก์„ธ์Šค ํ† ํฐ ์ผ๋ฐ˜ ์ฟ ํ‚ค)โœ… ์™„์ „๐ŸŸข ๋†’์Œ์ค‘๊ฐ„๋Œ€๋ถ€๋ถ„ ์„œ๋น„์Šค
๐Ÿฅˆ 2์œ„์™„์ „ BFF ํ”„๋ก์‹œโŒ ๋ถˆ๊ฐ€ (Next.js ๊ฒฝ์œ  ํ•„์ˆ˜)โœ… ์™„์ „๐ŸŸข ์ตœ๊ณ ๋†’์Œ๊ธˆ์œตยท์˜๋ฃŒยท๊ณ ๋ณด์•ˆ
๐Ÿฅ‰ 3์œ„Direct + Secure Cookieโœ… ๊ฐ€๋Šฅโœ… ์ง€์›๐ŸŸก ์ค‘๊ฐ„๋‚ฎ์Œ๋™์ผ ๋„๋ฉ”์ธ๋งŒ
4์œ„localStorageโœ… ๊ฐ€๋ŠฅโŒ ๋ถˆ๊ฐ€๐Ÿ”ด ์ทจ์•ฝ๋งค์šฐ ๋‚ฎ์Œํ”„๋กœ๋•์…˜ ๋น„๊ถŒ์žฅ

ํ† ํฐ ํ๋ฆ„ ๊ตฌ์กฐ ๋น„๊ต:

๐Ÿฅ‡ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF (๊ถŒ์žฅ):
๋กœ๊ทธ์ธ โ†’ Next.js Server Action โ†’ Spring /auth/login
           โ†“
    ์ฟ ํ‚ค ๋‘ ๊ฐœ ์„ค์ •:
      refresh_token: httpOnly=true  (์„œ๋ฒ„๋งŒ ์ ‘๊ทผ, 7์ผ)
      access_token:  httpOnly=false (๋ธŒ๋ผ์šฐ์ € ์ ‘๊ทผ ๊ฐ€๋Šฅ, 1์‹œ๊ฐ„)

SSR ์ปดํฌ๋„ŒํŠธ: cookies()๋กœ access_token ์ฝ๊ธฐ โ†’ Spring API ์„œ๋ฒ„ ํ˜ธ์ถœ
CSR ์ปดํฌ๋„ŒํŠธ: document.cookie๋กœ access_token ์ฝ๊ธฐ โ†’ Spring API ์ง์ ‘ ํ˜ธ์ถœ
ํ† ํฐ ๋งŒ๋ฃŒ:  โ†’ Next.js /api/auth/refresh ํ˜ธ์ถœ โ†’ refresh_token์œผ๋กœ ๊ฐฑ์‹ 

๐Ÿฅˆ ์™„์ „ BFF:
๋ชจ๋“  API โ†’ ๋ธŒ๋ผ์šฐ์ € โ†’ Next.js Route Handler โ†’ Spring API
             (HttpOnly ์ฟ ํ‚ค๋งŒ ์‚ฌ์šฉ, ๋ธŒ๋ผ์šฐ์ €๋Š” ํ† ํฐ ๋ชป ๋ด„)

๐Ÿฅ‡ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF ์™„์ „ ๊ตฌํ˜„ ๐ŸŸก

๐Ÿ“ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ

app/
  (auth)/
    login/page.tsx
  api/
    auth/
      refresh/route.ts     โ† ํ•ต์‹ฌ: ํ† ํฐ ๊ฐฑ์‹  ์—”๋“œํฌ์ธํŠธ
  lib/
    session.ts             โ† ์ฟ ํ‚ค ๊ด€๋ฆฌ (server-only)
    dal.ts                 โ† ์„ธ์…˜ ๊ฒ€์ฆ (server-only)
    springClient.ts        โ† Spring API ์„œ๋ฒ„ ํ†ต์‹  ํด๋ผ์ด์–ธํŠธ
  actions/
    auth.ts                โ† ๋กœ๊ทธ์ธ/๋กœ๊ทธ์•„์›ƒ Server Actions
middleware.ts              โ† 1์ฐจ ๋ผ์šฐํŠธ ๋ณดํ˜ธ

๐Ÿช ์ฟ ํ‚ค ๊ด€๋ฆฌ โ€” ํ† ํฐ ๋ถ„๋ฆฌ ์ €์žฅ

// lib/session.ts
import 'server-only'
import { cookies } from 'next/headers'
 
const ACCESS_TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'
 
// ๋กœ๊ทธ์ธ ์„ฑ๊ณต ํ›„ Spring์ด ๋ฐœ๊ธ‰ํ•œ ๋‘ ํ† ํฐ์„ ์—ญํ• ์— ๋”ฐ๋ผ ๋‹ค๋ฅด๊ฒŒ ๋ณด๊ด€
export async function setTokenCookies(
  accessToken: string,
  refreshToken: string,
  accessExpiresIn: number  // ์ดˆ ๋‹จ์œ„, ๋ณดํ†ต 3600 (1์‹œ๊ฐ„)
) {
  const cookieStore = await cookies()
 
  // โœ… ์•ก์„ธ์Šค ํ† ํฐ: httpOnly=false โ†’ CSR์—์„œ JS๋กœ ์ฝ์–ด Spring ์ง์ ‘ ํ˜ธ์ถœ ๊ฐ€๋Šฅ
  cookieStore.set(ACCESS_TOKEN_KEY, accessToken, {
    httpOnly: false,                                          // ํด๋ผ์ด์–ธํŠธ ์ ‘๊ทผ ํ—ˆ์šฉ (์˜๋„์ )
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    expires: new Date(Date.now() + accessExpiresIn * 1000),  // 1์‹œ๊ฐ„
  })
 
  // โœ… ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ: httpOnly=true โ†’ JS ์ ‘๊ทผ ์™„์ „ ์ฐจ๋‹จ, ์„œ๋ฒ„๋งŒ ์ฝ์„ ์ˆ˜ ์žˆ์Œ
  cookieStore.set(REFRESH_TOKEN_KEY, refreshToken, {
    httpOnly: true,                                           // JS ์ ‘๊ทผ ์ฐจ๋‹จ (๋ณด์•ˆ ํ•ต์‹ฌ!)
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7์ผ
  })
}
 
export async function getAccessToken(): Promise<string | null> {
  const cookieStore = await cookies()
  return cookieStore.get(ACCESS_TOKEN_KEY)?.value ?? null
}
 
export async function getRefreshToken(): Promise<string | null> {
  const cookieStore = await cookies()
  return cookieStore.get(REFRESH_TOKEN_KEY)?.value ?? null
}
 
export async function clearTokenCookies() {
  const cookieStore = await cookies()
  cookieStore.delete(ACCESS_TOKEN_KEY)
  cookieStore.delete(REFRESH_TOKEN_KEY)
}

๐Ÿ” ๋กœ๊ทธ์ธ Server Action

// actions/auth.ts
'use server'
import { redirect } from 'next/navigation'
import { setTokenCookies, clearTokenCookies } from '@/lib/session'
 
interface SpringAuthResponse {
  accessToken: string
  refreshToken: string
  expiresIn: number
}
 
export async function login(prevState: unknown, formData: FormData) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string
 
  try {
    // Spring ์ธ์ฆ API ํ˜ธ์ถœ (์„œ๋ฒ„-์„œ๋ฒ„ ํ†ต์‹ , ์•ˆ์ „)
    const response = await fetch(`${process.env.SPRING_API_URL}/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
 
    if (!response.ok) {
      const error = await response.json()
      return { error: error.message || '์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•„์š”' }
    }
 
    const { accessToken, refreshToken, expiresIn }: SpringAuthResponse = await response.json()
 
    // ๋‘ ํ† ํฐ์„ ์—ญํ• ์— ๋”ฐ๋ผ ๋ถ„๋ฆฌ ์ €์žฅ
    await setTokenCookies(accessToken, refreshToken, expiresIn)
  } catch {
    return { error: '์„œ๋ฒ„ ์—ฐ๊ฒฐ์— ์‹คํŒจํ–ˆ์–ด์š”. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.' }
  }
 
  redirect('/dashboard')
}
 
export async function logout() {
  // Spring ๋กœ๊ทธ์•„์›ƒ API ํ˜ธ์ถœ (์„ ํƒ์  โ€” ์„œ๋ฒ„ ์ธก ํ† ํฐ ๋ฌดํšจํ™”)
  const { getAccessToken } = await import('@/lib/session')
  const accessToken = await getAccessToken()
 
  if (accessToken) {
    await fetch(`${process.env.SPRING_API_URL}/auth/logout`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${accessToken}` },
    }).catch(console.error)  // ์‹คํŒจํ•ด๋„ ๋กœ์ปฌ ์ฟ ํ‚ค๋Š” ์ œ๊ฑฐ
  }
 
  await clearTokenCookies()
  redirect('/login')
}

๐ŸŒ Spring API ์„œ๋ฒ„ ํด๋ผ์ด์–ธํŠธ (์„œ๋ฒ„ ์ „์šฉ)

// lib/springClient.ts โ€” ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์šฉ Spring API ํ†ต์‹ 
import 'server-only'
import { getAccessToken } from './session'
 
const SPRING_API_URL = process.env.SPRING_API_URL!
 
interface FetchOptions extends RequestInit {
  requireAuth?: boolean
}
 
// ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ Spring API๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋ž˜ํผ
export async function springFetch(path: string, options: FetchOptions = {}) {
  const { requireAuth = true, ...fetchOptions } = options
 
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(fetchOptions.headers as Record<string, string>),
  }
 
  if (requireAuth) {
    const accessToken = await getAccessToken()
    if (!accessToken) {
      throw new Error('UNAUTHORIZED')
    }
    headers['Authorization'] = `Bearer ${accessToken}`
  }
 
  const response = await fetch(`${SPRING_API_URL}${path}`, {
    ...fetchOptions,
    headers,
  })
 
  if (response.status === 401) {
    // ์•ก์„ธ์Šค ํ† ํฐ ๋งŒ๋ฃŒ โ€” ์„œ๋ฒ„์—์„œ ๊ฐฑ์‹  ์‹œ๋„ ๋ถˆ๊ฐ€ (๋ธŒ๋ผ์šฐ์ € ์ฟ ํ‚ค ์ˆ˜์ • ๋ถˆ๊ฐ€)
    // ํด๋ผ์ด์–ธํŠธ๊ฐ€ /api/auth/refresh๋ฅผ ํ˜ธ์ถœํ•ด์•ผ ํ•จ
    throw new Error('TOKEN_EXPIRED')
  }
 
  if (!response.ok) {
    throw new Error(`Spring API error: ${response.status}`)
  }
 
  return response.json()
}

๐Ÿ”„ ํ† ํฐ ๊ฐฑ์‹  Route Handler โ€” ๋ฆฌํ”„๋ ˆ์‹œ ํ•ต์‹ฌ ๋กœ์ง ๐ŸŸก

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์•ก์„ธ์Šค ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ ํ˜ธ์ถœํ•˜๋Š” /api/auth/refresh ์—”๋“œํฌ์ธํŠธ๋ฅผ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค

์ด Route Handler๊ฐ€ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF์˜ ํ•ต์‹ฌ์ด์•ผ. ํด๋ผ์ด์–ธํŠธ(CSR)์—์„œ ์•ก์„ธ์Šค ํ† ํฐ์ด ๋งŒ๋ฃŒ๋์„ ๋•Œ ์ด ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด, ์„œ๋ฒ„์—์„œ HttpOnly ์ฟ ํ‚ค์˜ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ์ฝ์–ด ์ƒˆ ํ† ํฐ์œผ๋กœ ๊ต์ฒดํ•ด์ค˜.

// app/api/auth/refresh/route.ts
import { NextResponse } from 'next/server'
import { getRefreshToken, setTokenCookies } from '@/lib/session'
 
export async function POST() {
  const refreshToken = await getRefreshToken()
 
  if (!refreshToken) {
    // ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ๋„ ์—†์œผ๋ฉด ์™„์ „ ๋กœ๊ทธ์•„์›ƒ ์ƒํƒœ
    return NextResponse.json({ error: '์„ธ์…˜์ด ๋งŒ๋ฃŒ๋์–ด์š”' }, { status: 401 })
  }
 
  try {
    // Spring์˜ ํ† ํฐ ๊ฐฑ์‹  API ํ˜ธ์ถœ
    const response = await fetch(`${process.env.SPRING_API_URL}/auth/refresh`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${refreshToken}`,
      },
    })
 
    if (!response.ok) {
      // ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ๋„ ๋งŒ๋ฃŒ๋จ โ†’ ๊ฐ•์ œ ๋กœ๊ทธ์•„์›ƒ
      return NextResponse.json({ error: '๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”' }, { status: 401 })
    }
 
    const { accessToken, refreshToken: newRefreshToken, expiresIn } = await response.json()
 
    // ์ƒˆ ํ† ํฐ์œผ๋กœ ์ฟ ํ‚ค ๊ฐฑ์‹ 
    await setTokenCookies(accessToken, newRefreshToken, expiresIn)
 
    return NextResponse.json({
      success: true,
      accessToken,    // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ฆ‰์‹œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์‘๋‹ต์—๋„ ํฌํ•จ
    })
  } catch {
    return NextResponse.json({ error: 'ํ† ํฐ ๊ฐฑ์‹ ์— ์‹คํŒจํ–ˆ์–ด์š”' }, { status: 500 })
  }
}

ํด๋ผ์ด์–ธํŠธ(CSR)์—์„œ ์ž๋™ ํ† ํฐ ๊ฐฑ์‹  ํŒจํ„ด:

// lib/springClient.ts โ€” ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์šฉ Spring API ํ†ต์‹  ๋ž˜ํผ
'use client'
 
function getAccessTokenFromCookie(): string | null {
  // httpOnly=false์ธ access_token๋งŒ ์ฝ์„ ์ˆ˜ ์žˆ์Œ
  const match = document.cookie.match(/access_token=([^;]+)/)
  return match ? match[1] : null
}
 
async function refreshAccessToken(): Promise<string | null> {
  const response = await fetch('/api/auth/refresh', { method: 'POST' })
  if (!response.ok) {
    // ๊ฐฑ์‹  ์‹คํŒจ โ†’ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ
    window.location.href = '/login'
    return null
  }
  const { accessToken } = await response.json()
  return accessToken
}
 
// ์ž๋™ ๊ฐฑ์‹  ์ธํ„ฐ์…‰ํ„ฐ๊ฐ€ ํฌํ•จ๋œ Spring API ํ˜ธ์ถœ ํ•จ์ˆ˜
export async function springApiClient(path: string, options: RequestInit = {}) {
  const SPRING_API_URL = process.env.NEXT_PUBLIC_SPRING_API_URL!
 
  let accessToken = getAccessTokenFromCookie()
 
  const makeRequest = async (token: string) =>
    fetch(`${SPRING_API_URL}${path}`, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
        ...(options.headers as Record<string, string>),
      },
    })
 
  if (!accessToken) {
    // ์ฟ ํ‚ค์— ์•ก์„ธ์Šค ํ† ํฐ ์—†์Œ โ†’ ๊ฐฑ์‹  ์‹œ๋„
    accessToken = await refreshAccessToken()
    if (!accessToken) return null
  }
 
  let response = await makeRequest(accessToken)
 
  if (response.status === 401) {
    // 401 โ†’ ์•ก์„ธ์Šค ํ† ํฐ ๋งŒ๋ฃŒ โ†’ ๊ฐฑ์‹  ํ›„ ์žฌ์‹œ๋„
    const newToken = await refreshAccessToken()
    if (!newToken) return null
    response = await makeRequest(newToken)
  }
 
  if (!response.ok) throw new Error(`API Error: ${response.status}`)
  return response.json()
}

๐Ÿ–ฅ๏ธ SSR ์ดˆ๊ธฐ ๋ Œ๋”๋ง๊ณผ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ํ†ตํ•ฉ ๐ŸŸก

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ ์„ธ์…˜ ํ† ํฐ์œผ๋กœ Spring API๋ฅผ ํ˜ธ์ถœํ•ด ์ธ์ฆ๋œ ์ดˆ๊ธฐ HTML์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค
// lib/dal.ts โ€” ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์šฉ ์„ธ์…˜ ๊ฒ€์ฆ + Spring API ํ†ตํ•ฉ
import 'server-only'
import { getAccessToken } from './session'
import { springFetch } from './springClient'
 
interface UserProfile {
  id: string
  name: string
  email: string
  avatar: string
}
 
// ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ (SSR์šฉ)
export async function getCurrentUser(): Promise<UserProfile | null> {
  const accessToken = await getAccessToken()
  if (!accessToken) return null
 
  try {
    return await springFetch('/api/me', {
      next: { revalidate: 60 },  // 1๋ถ„ ์บ์‹ฑ (์ž์ฃผ ๋ฐ”๋€Œ์ง€ ์•Š๋Š” ํ”„๋กœํ•„)
    })
  } catch (error: unknown) {
    if ((error as Error).message === 'TOKEN_EXPIRED') {
      // SSR ์ค‘ ํ† ํฐ ๋งŒ๋ฃŒ โ†’ null ๋ฐ˜ํ™˜ (ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๊ฐฑ์‹  ์ฒ˜๋ฆฌ)
      return null
    }
    throw error
  }
}
// app/dashboard/page.tsx โ€” ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ธ์ฆ๋œ ์ดˆ๊ธฐ ๋ Œ๋”
import { getCurrentUser } from '@/lib/dal'
import { redirect } from 'next/navigation'
import { DashboardClient } from '@/components/features/DashboardClient'
 
export default async function DashboardPage() {
  // ์„œ๋ฒ„์—์„œ Spring API ํ˜ธ์ถœ โ†’ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด
  const user = await getCurrentUser()
 
  if (!user) {
    redirect('/login')  // ๋ฏธ์ธ์ฆ โ†’ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€
  }
 
  // ์„œ๋ฒ„์—์„œ ๊ฐ€์ ธ์˜จ ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ๋ฅผ ํด๋ผ์ด์–ธํŠธ์— props๋กœ ์ „๋‹ฌ
  return (
    <div>
      <h1>์•ˆ๋…•ํ•˜์„ธ์š”, {user.name}๋‹˜!</h1>
      {/* DashboardClient๋Š” ์ดํ›„ CSR์—์„œ springApiClient๋ฅผ ์‚ฌ์šฉ */}
      <DashboardClient initialUser={user} />
    </div>
  )
}

๐Ÿฅˆ ์™„์ „ BFF ํ”„๋ก์‹œ ๊ตฌํ˜„ ๐Ÿ”ด

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • ๋ชจ๋“  API ์š”์ฒญ์„ Next.js Route Handler๊ฐ€ ์ค‘๊ณ„ํ•˜๋Š” ์™„์ „ BFF ํŒจํ„ด์„ ์ดํ•ดํ•œ๋‹ค
  • ๊ธˆ์œตยท์˜๋ฃŒ์ฒ˜๋Ÿผ ๊ณ ๋ณด์•ˆ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ ์–ธ์ œ ์ด ๋ฐฉ์‹์„ ์„ ํƒํ•ด์•ผ ํ•˜๋Š”์ง€ ์•ˆ๋‹ค

์™„์ „ BFF์—์„œ๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ Spring์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜์ง€ ์•Š์•„. ๋ชจ๋“  ์š”์ฒญ์ด Next.js๋ฅผ ๊ฑฐ์ณ.

// app/api/proxy/[...path]/route.ts โ€” ๋ฒ”์šฉ API ํ”„๋ก์‹œ
import { NextRequest, NextResponse } from 'next/server'
import { getAccessToken } from '@/lib/session'
 
// GET /api/proxy/users/1 โ†’ Spring /api/users/1 ๋กœ ์ค‘๊ณ„
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  return proxyToSpring(request, path.join('/'), 'GET')
}
 
export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  return proxyToSpring(request, path.join('/'), 'POST', await request.json())
}
 
async function proxyToSpring(
  request: NextRequest,
  path: string,
  method: string,
  body?: unknown
) {
  const accessToken = await getAccessToken()
 
  if (!accessToken) {
    return NextResponse.json({ error: '์ธ์ฆ์ด ํ•„์š”ํ•ด์š”' }, { status: 401 })
  }
 
  const response = await fetch(`${process.env.SPRING_API_URL}/api/${path}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${accessToken}`,  // ์„œ๋ฒ„์—์„œ ํ† ํฐ ์ฃผ์ž…
    },
    body: body ? JSON.stringify(body) : undefined,
  })
 
  const data = await response.json()
  return NextResponse.json(data, { status: response.status })
}
// ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ ์‚ฌ์šฉ (ํ† ํฐ ์—†์ด Next.js ํ”„๋ก์‹œ๋กœ ํ˜ธ์ถœ)
async function fetchUserPosts(userId: string) {
  // ๋ธŒ๋ผ์šฐ์ €๊ฐ€ /api/proxy/๋ฅผ ํ˜ธ์ถœ โ†’ Next.js๊ฐ€ Spring์œผ๋กœ ์ค‘๊ณ„
  const response = await fetch(`/api/proxy/users/${userId}/posts`)
  return response.json()
}

๐Ÿ›ก๏ธ ๋ณด์•ˆ ๊ฐ•ํ™” ์ „๋žต ๐Ÿ”ด

๐Ÿ”’ CSRF ๋ฐฉ์–ด

Server Action์€ ๋‚ด์žฅ CSRF ๋ณดํ˜ธ๊ฐ€ ์žˆ์–ด. ํ•˜์ง€๋งŒ Route Handler ๊ธฐ๋ฐ˜ ํผ์ด๋ผ๋ฉด CSRF ํ† ํฐ์ด ํ•„์š”ํ•ด.

// ์ปค์Šคํ…€ ํ—ค๋” ๊ธฐ๋ฐ˜ CSRF ๋ฐฉ์–ด (Origin ๊ฒ€์ฆ)
// app/api/auth/refresh/route.ts
export async function POST(request: NextRequest) {
  const origin = request.headers.get('origin')
  const allowedOrigins = [
    process.env.NEXT_PUBLIC_APP_URL,
    'http://localhost:3000',
  ]
 
  if (!allowedOrigins.includes(origin ?? '')) {
    return NextResponse.json({ error: 'CSRF ๋ฐฉ์–ด: ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ์ถœ์ฒ˜' }, { status: 403 })
  }
  // ...
}

๐Ÿ”‘ ๋ณด์•ˆ ์ฟ ํ‚ค ์„ค์ • ์ฒดํฌ๋ฆฌ์ŠคํŠธ

cookieStore.set('refresh_token', token, {
  httpOnly: true,           // โœ… JS ์ ‘๊ทผ ์ฐจ๋‹จ
  secure: true,             // โœ… HTTPS ์ „์šฉ (ํ”„๋กœ๋•์…˜)
  sameSite: 'lax',          // โœ… CSRF ๊ธฐ๋ณธ ๋ฐฉ์–ด
  path: '/api/auth',        // โœ… ๋ฆฌํ”„๋ ˆ์‹œ ์—”๋“œํฌ์ธํŠธ๋กœ๋งŒ ์ „์†ก ์ œํ•œ
  expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
})

๐Ÿ•ต๏ธ ์„œ๋ฒ„ Action ID ๋ณด์•ˆ (Next.js ๋‚ด์žฅ)

Next.js๋Š” ๊ฐ Server Action์— ๋นŒ๋“œ๋งˆ๋‹ค ์žฌ์ƒ์„ฑ๋˜๋Š” ์•”ํ˜ธํ™”๋œ ID๋ฅผ ๋ถ€์—ฌํ•ด. ๊ณต๊ฒฉ์ž๊ฐ€ ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ง์ ‘ ์ถ”์ธกํ•˜๊ณ  ํ˜ธ์ถœํ•˜๋Š” ๊ฒƒ์ด ๊ฑฐ์˜ ๋ถˆ๊ฐ€๋Šฅํ•ด. ์ด ๋•๋ถ„์— Server Action ๊ธฐ๋ฐ˜ ์ธ์ฆ์€ CSRF ๊ณต๊ฒฉ์— ๊ธฐ๋ณธ์ ์œผ๋กœ ๊ฐ•ํ•ด.


๐Ÿ’ฅ ์—๋Ÿฌ ํ•ด๊ฒฐ ์นดํƒˆ๋กœ๊ทธ


โŒ CSR์—์„œ Spring API ์ง์ ‘ ํ˜ธ์ถœ ์‹œ CORS ์—๋Ÿฌ

์›์ธ: Spring ์„œ๋ฒ„์—์„œ Next.js ์•ฑ์˜ ๋„๋ฉ”์ธ์„ CORS ํ—ˆ์šฉ ๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์Œ.

Spring ์„ค์ •:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins(
                "http://localhost:3000",
                "https://youngsu.community"
            )
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowCredentials(true)   // ์ฟ ํ‚ค ํฌํ•จ ์š”์ฒญ ํ—ˆ์šฉ
            .allowedHeaders("*");
    }
}

โŒ ํ† ํฐ ๊ฐฑ์‹  ๋ฌดํ•œ ๋ฃจํ”„

์›์ธ: ๊ฐฑ์‹  ์‹คํŒจ ์‹œ ๋˜ ๊ฐฑ์‹  ์‹œ๋„๊ฐ€ ๋ฐ˜๋ณต๋˜๋Š” ๊ฒฝ์šฐ.

ํ•ด๊ฒฐ์ฑ…:

// ๊ฐฑ์‹  ์‹œ๋„ ํšŸ์ˆ˜ ์ œํ•œ
let refreshAttempts = 0
 
async function refreshWithLimit() {
  if (refreshAttempts >= 1) {
    refreshAttempts = 0
    window.location.href = '/login'
    return null
  }
  refreshAttempts++
  const result = await refreshAccessToken()
  refreshAttempts = 0
  return result
}

โŒ SSR์—์„œ cookies() ํ˜ธ์ถœ ์‹œ "Headers is already sent" ์—๋Ÿฌ

์›์ธ: Server Action ๋‚ด๋ถ€์—์„œ ์ฟ ํ‚ค๋ฅผ ์„ค์ •ํ•œ ํ›„ redirect()๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ์ˆœ์„œ ๋ฌธ์ œ.

ํ•ด๊ฒฐ์ฑ…: redirect()๋Š” try-catch ๋ธ”๋ก ๋ฐ–์—์„œ ํ˜ธ์ถœํ•ด์•ผ ํ•ด. redirect()๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ์˜ˆ์™ธ๋ฅผ ๋˜์ง€๊ธฐ ๋•Œ๋ฌธ์ด์•ผ.

// โŒ ๋ฌธ์ œ ์žˆ๋Š” ์ฝ”๋“œ
try {
  await setTokenCookies(...)
  redirect('/dashboard')  // try ๋ธ”๋ก ์•ˆ์— ์žˆ์œผ๋ฉด catch๋กœ ๋น ์งˆ ์ˆ˜ ์žˆ์Œ
} catch (e) {
  console.error(e)
}
 
// โœ… ์˜ฌ๋ฐ”๋ฅธ ์ฝ”๋“œ
try {
  await setTokenCookies(...)
} catch (e) {
  return { error: '๋กœ๊ทธ์ธ ์‹คํŒจ' }
}
redirect('/dashboard')  // try ๋ธ”๋ก ๋ฐ–์—์„œ ํ˜ธ์ถœ

๐Ÿ ์ด๋ฒˆ์— ๋ฐฐ์šด ๋‚ด์šฉ ์ด์ •๋ฆฌ

๐Ÿ“‹ ์•„ํ‚คํ…์ฒ˜ ์„ ํƒ ๊ธฐ์ค€

์ƒํ™ฉ์„ ํƒ
์ผ๋ฐ˜ ์„œ๋น„์Šค, CSR์—์„œ Spring ์ง์ ‘ ํ˜ธ์ถœ ํ•„์š”ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF
๊ธˆ์œตยท์˜๋ฃŒยท์ตœ๊ณ  ๋ณด์•ˆ ํ•„์š”์™„์ „ BFF ํ”„๋ก์‹œ
๋ชจ๋†€๋ฆฌ์‹ + ๋™์ผ ๋„๋ฉ”์ธDirect + Secure Cookie

๐Ÿ“‹ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF ์ฟ ํ‚ค ์ „๋žต

ํ† ํฐhttpOnly๋งŒ๋ฃŒ์—ญํ• 
access_tokenfalse1์‹œ๊ฐ„CSR โ†’ Spring ์ง์ ‘ ํ˜ธ์ถœ
refresh_tokentrue7์ผ์„œ๋ฒ„ ์ „์šฉ ํ† ํฐ ๊ฐฑ์‹ 

โš ๏ธ ์ ˆ๋Œ€ ํ•˜์ง€ ๋ง ๊ฒƒ

์ƒํ™ฉโŒ ๋‚˜์œ ์˜ˆโœ… ์ข‹์€ ์˜ˆ
ํ† ํฐ ์ €์žฅlocalStorageHttpOnly ์ฟ ํ‚ค
๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์ผ๋ฐ˜ ์ฟ ํ‚ค(JS ์ ‘๊ทผ ๊ฐ€๋Šฅ)httpOnly=true
CORS ์„ค์ •allowedOrigins("*")๋ช…์‹œ์  ๋„๋ฉ”์ธ ๋ฆฌ์ŠคํŠธ
SSR์—์„œ redirecttry ๋ธ”๋ก ์•ˆtry ๋ธ”๋ก ๋ฐ–

๐Ÿ“ ๋งˆ๋ฌด๋ฆฌ ํ€ด์ฆˆ

Q1. ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF์—์„œ access_token์„ httpOnly: false๋กœ ์„ค์ •ํ•˜๋Š” ์ด์œ ๋Š”?

  • A) ๋ณด์•ˆ์„ ์‹ ๊ฒฝ ์“ฐ์ง€ ์•Š์•„์„œ
  • B) SSR์—์„œ ์ฟ ํ‚ค๋ฅผ ์ฝ๊ธฐ ์œ„ํ•ด
  • C) CSR์—์„œ JS๊ฐ€ ํ† ํฐ์„ ์ฝ์–ด Spring API๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•˜๊ธฐ ์œ„ํ•ด
  • D) ์ฟ ํ‚ค ํฌ๊ธฐ๋ฅผ ์ค„์ด๊ธฐ ์œ„ํ•ด

โœ… ์ •๋‹ต: C

ํ•ด์„ค: CSR ํ™˜๊ฒฝ์—์„œ ๋ธŒ๋ผ์šฐ์ € JS๊ฐ€ Spring API๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋ ค๋ฉด Authorization: Bearer <token> ํ—ค๋”๋ฅผ ๋ถ™์—ฌ์•ผ ํ•ด. ํ† ํฐ์ด httpOnly๋ฉด document.cookie๋กœ ์ฝ์„ ์ˆ˜ ์—†์–ด์„œ ๋ถˆ๊ฐ€๋Šฅํ•ด. ์•ก์„ธ์Šค ํ† ํฐ์€ 1์‹œ๊ฐ„๋งŒ ์œ ํšจํ•ด์„œ ํƒˆ์ทจ ํ”ผํ•ด๊ฐ€ ์ œํ•œ์ ์ด๋ผ ์ด ํŠธ๋ ˆ์ด๋“œ์˜คํ”„๊ฐ€ ์ˆ˜์šฉ ๊ฐ€๋Šฅํ•ด.

๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: "๊ธธ๊ฒŒ ์‚ฌ๋Š” ๊ฑด ์ˆจ๊ธฐ๊ณ (refresh=httpOnly), ์งง๊ฒŒ ์‚ฌ๋Š” ๊ฑด ๊บผ๋‚ด ์“ธ ์ˆ˜ ์žˆ๊ฒŒ(access=์ผ๋ฐ˜ ์ฟ ํ‚ค)."


Q2. ์•„๋ž˜ ์‹œ๋‚˜๋ฆฌ์˜ค์—์„œ ํ† ํฐ ๊ฐฑ์‹  /api/auth/refresh๊ฐ€ ํ˜ธ์ถœ๋ผ์•ผ ํ•˜๋Š” ์ƒํ™ฉ์€?

  • A) ๋กœ๊ทธ์ธ ํผ์„ ์ œ์ถœํ•  ๋•Œ
  • B) ๋กœ๊ทธ์•„์›ƒ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅผ ๋•Œ
  • C) CSR์—์„œ Spring API ํ˜ธ์ถœ ์‹œ 401 ์‘๋‹ต์„ ๋ฐ›์•˜์„ ๋•Œ
  • D) ํŽ˜์ด์ง€๋ฅผ ์ฒ˜์Œ ๋กœ๋“œํ•  ๋•Œ

โœ… ์ •๋‹ต: C

ํ•ด์„ค: ์•ก์„ธ์Šค ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜๋ฉด Spring API๊ฐ€ 401์„ ๋ฐ˜ํ™˜ํ•ด. ์ด๋•Œ ํด๋ผ์ด์–ธํŠธ๋Š” /api/auth/refresh๋ฅผ ํ˜ธ์ถœํ•ด HttpOnly ์ฟ ํ‚ค์— ์žˆ๋Š” ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์œผ๋กœ ์ƒˆ ์•ก์„ธ์Šค ํ† ํฐ์„ ๋ฐœ๊ธ‰๋ฐ›์•„.

๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: 401 โ†’ ๊ฐฑ์‹  ์‹œ๋„ โ†’ ์„ฑ๊ณตํ•˜๋ฉด ์žฌ์š”์ฒญ, ์‹คํŒจํ•˜๋ฉด ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€.


Q3. ์นœ๊ตฌ์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?

์™„์ „ BFF์™€ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF์˜ ์ฐจ์ด๋ฅผ ๋ฐฐ๋‹ฌ ๋น„์œ ๋กœ ์„ค๋ช…ํ•ด๋ด.

์˜ˆ์‹œ ๋‹ต๋ณ€:

"์™„์ „ BFF๋Š” '๋ชจ๋“  ์ฃผ๋ฌธ์„ ๋งค๋‹ˆ์ €(Next.js)๊ฐ€ ๋ฐ›์•„์„œ ์ฃผ๋ฐฉ(Spring)์— ์ „๋‹ฌํ•˜๋Š”' ๋ฐฉ์‹์ด์•ผ. ์†๋‹˜(๋ธŒ๋ผ์šฐ์ €)์€ ์ฃผ๋ฐฉ ๋ฒˆํ˜ธ๋ฅผ ์ ˆ๋Œ€ ๋ชฐ๋ผ. ๋ณด์•ˆ ์ตœ๊ณ . ๊ทผ๋ฐ ๋งค๋‹ˆ์ €๊ฐ€ ๋ณ‘๋ชฉ์ด ๋˜๋ฉด ๋А๋ ค. ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF๋Š” '์ฒซ ์ž…์žฅ๊ณผ ๋ฉ”๋‰ด ํ™•์ธ์€ ๋งค๋‹ˆ์ €(SSR)๊ฐ€, ์ถ”๊ฐ€ ์ฃผ๋ฌธ์€ ์†๋‹˜์ด ์ฃผ๋ฐฉ์— ์ง์ ‘(CSR โ†’ Spring)' ํ•˜๋Š” ๋ฐฉ์‹์ด์•ผ. ์กฐ๊ธˆ ๋œ ์•ˆ์ „ํ•˜์ง€๋งŒ ๋น ๋ฅด๊ณ , ๋Œ€๋ถ€๋ถ„ ์„œ๋น„์Šค๋Š” ์ด๊ฑธ๋กœ ์ถฉ๋ถ„ํ•ด."


๐Ÿฃ ์˜์ฒ ์ด์˜ ํ‡ด๊ทผ ์ผ๊ธฐ

์˜ค๋Š˜์€ ์ •๋ง ๋„ฅ์ŠคํŠธ์™€ ์Šคํ”„๋ง ๋ถ€ํŠธ๋ผ๋Š” ๋‘ ๊ฑฐ๋Œ€ํ•œ ์„ธ๊ณ„๋ฅผ ์ž‡๋Š” 'Next.js + Spring Auth Architecture' ๋ฅผ ๋ฐฐ์šฐ๋ฉด์„œ ๊ฐœ๋ฐœ์ž๋กœ์„œ ํ•œ ๋‹จ๊ณ„ ๋” ์ ํ”„ํ•œ ๊ธฐ๋ถ„์ด ๋“ค์—ˆ์–ด! ์ฒ˜์Œ์—” "ํ† ํฐ์„ ์–ด๋””์— ์ €์žฅํ•˜๊ณ  ์–ด๋–ป๊ฒŒ ๊ฐฑ์‹ ํ•˜์ง€?" ๋ผ๋ฉฐ ๋จธ๋ฆฌ๊ฐ€ ๋ณต์žกํ–ˆ๋Š”๋ฐ, ํ•˜์ด๋ธŒ๋ฆฌ๋“œ BFF๋ผ๋Š” ์šฐ์•„ํ•œ ์ •๋‹ต์„ ์ฐพ๊ณ  ๋‚˜๋‹ˆ ์ •๋ง ์†์ด ๋‹ค ์‹œ์›ํ•˜๋”๋ผ.

๐Ÿ’ก ์˜ค๋Š˜์˜ ๊ตํ›ˆ: "BFF๋Š” ๋‹จ์ˆœํ•œ ํ”„๋ก์‹œ๊ฐ€ ์•„๋‹ˆ๋ผ, ํ”„๋ก ํŠธ์™€ ๋ฐฑ์—”๋“œ ์‚ฌ์ด์˜ ๋“ ๋“ ํ•œ ๊ฐ€๊ต์ด๋‹ค. ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์€ HttpOnly ์ฟ ํ‚ค๋กœ ๊ฝ๊ฝ ์ˆจ๊ธฐ๊ณ , ์•ก์„ธ์Šค ํ† ํฐ์€ ํ•„์š”ํ•  ๋•Œ๋งŒ ๊บผ๋‚ด ์“ฐ๋Š” ์˜๋ฆฌํ•œ ์„ค๊ณ„๋ฅผ ์‹ค์ฒœํ•˜์ž!"

์˜ํ˜ธ ๋ฆฌ๋“œ ๋‹˜์ด ๋ฐฐ๋‹ฌ ๋งค๋‹ˆ์ €์™€ ์ฃผ๋ฐฉ ๋น„์œ ๋ฅผ ๋“ค์–ด ์„ค๋ช…ํ•ด ์ฃผ์‹ค ๋•Œ, ์™œ ์ƒํ™ฉ์— ๋งž๋Š” ์•„ํ‚คํ…์ฒ˜ ์„ ํƒ์ด ์ค‘์š”ํ•œ์ง€ ๋‹จ๋ฒˆ์— ์ดํ•ด๊ฐ€ ๊ฐ€๋”๋ผ. ๋‹จ์ˆœํžˆ ์ฝ”๋“œ๋ฅผ ์งœ๋Š” ๊ฑธ ๋„˜์–ด, ์ „์ฒด ์‹œ์Šคํ…œ์˜ ํ๋ฆ„์„ ์„ค๊ณ„ํ•˜๋Š” ๊ฒŒ ์–ผ๋งˆ๋‚˜ ์ฆ๊ฑฐ์šด ์ผ์ธ์ง€ ๊นจ๋‹ฌ์•˜์–ด. ์˜ค๋Š˜ ๋„ˆ๋ฌด ๊ธด์žฅํ•˜๋ฉด์„œ ์„ค๊ณ„๋ฅผ ๋งˆ์ณค๋”๋‹ˆ ์˜จ๋ชธ์ด ๋ป๊ทผํ•˜๋„ค. ํ‡ด๊ทผ๊ธธ์— ๋ง›์žˆ๋Š” ๊ฑฐ ์‚ฌ ๋“ค๊ณ  ๊ฐ€์„œ ํ‘น ์‰ฌ๋ฉด์„œ ์˜ค๋Š˜ ๋ฐฐ์šด ์ด 'ํ™˜์ƒ์˜ ์ฝค๋น„' ์•„ํ‚คํ…์ฒ˜๋ฅผ ๋ณต์Šตํ•ด์•ผ๊ฒ ์–ด. ๋‚ด์ผ์€ ๋” '๋„“์€ ์‹œ์•ผ' ๋ฅผ ๊ฐ€์ง„ ๊ฐœ๋ฐœ์ž๊ฐ€ ๋  ๊ฑฐ์•ผ! ๐Ÿฃ


๐Ÿ”— ๋” ์•Œ์•„๋ณด๊ธฐ