๐ Next.js ์ฌํ 11์ฅ: Next.js + Spring ์์ ํ ์ธ์ฆ ์ํคํ ์ฒ โ BFF ํจํด๊ณผ ํ์ด๋ธ๋ฆฌ๋ ํ ํฐ ์ค๊ณ
๐ ๊ฐ์
BFF ํจํด๊ณผ ํ์ด๋ธ๋ฆฌ๋ ํ ํฐ ์ค๊ณ๋ก Next.js์ Spring์ ์ฐ๊ฒฐํ๋ ์ธ์ฆ ์ํคํ ์ฒ์ ๋๋ค.
๐ ๋ชฉ์ฐจ
- ๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
- ๐ค ์ ์์์ผ ํ๋๊ฐ
- ๐๏ธ ๋น์ ๋ก ๋จผ์ ์ดํดํ๊ธฐ
- ๐บ๏ธ 4๊ฐ์ง ์ํคํ ์ฒ ๋น๊ต โ ์ด๋ค ๊ฑธ ์ ํํด์ผ ํ๋๊ฐ ๐ข
- ๐ฅ ํ์ด๋ธ๋ฆฌ๋ BFF ์์ ๊ตฌํ ๐ก
- ๐ ํ ํฐ ๊ฐฑ์ Route Handler โ ๋ฆฌํ๋ ์ ํต์ฌ ๋ก์ง ๐ก
- ๐ฅ๏ธ SSR ์ด๊ธฐ ๋ ๋๋ง๊ณผ ์ฌ์ฉ์ ํ๋กํ ํตํฉ ๐ก
- ๐ฅ ์์ BFF ํ๋ก์ ๊ตฌํ ๐ด
- ๐ก๏ธ ๋ณด์ ๊ฐํ ์ ๋ต ๐ด
- ๐ฅ ์๋ฌ ํด๊ฒฐ ์นดํ๋ก๊ทธ
- ๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
- ๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
- ๐ ๋ ์์๋ณด๊ธฐ
๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
โฑ๏ธ ์์ ์ฝ๊ธฐ ์๊ฐ: 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์ด์๊ฒ ์ค๋ช ํ๋ค๋ฉด?
์ํํธ ํค๋ฅผ ๋ ๊ฐ ๋ง๋๋ ๊ฑฐ์ผ:
- ๋ง์คํฐ ํค(๋ฆฌํ๋ ์ ํ ํฐ): ๊ธ๊ณ (HttpOnly ์ฟ ํค) ์์ ๋ณด๊ด. ๊ฒฝ๋น์ค(Next.js ์๋ฒ)๋ง ๊บผ๋ผ ์ ์์ด. 7์ผ์ง๋ฆฌ์ผ.
- ์ผ์ผ ํจ์ค(์ก์ธ์ค ํ ํฐ): ์ง๊ฐ(์ผ๋ฐ ์ฟ ํค, 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_token | false | 1์๊ฐ | CSR โ Spring ์ง์ ํธ์ถ |
| refresh_token | true | 7์ผ | ์๋ฒ ์ ์ฉ ํ ํฐ ๊ฐฑ์ |
โ ๏ธ ์ ๋ ํ์ง ๋ง ๊ฒ
| ์ํฉ | โ ๋์ ์ | โ ์ข์ ์ |
|---|---|---|
| ํ ํฐ ์ ์ฅ | localStorage | HttpOnly ์ฟ ํค |
| ๋ฆฌํ๋ ์ ํ ํฐ | ์ผ๋ฐ ์ฟ ํค(JS ์ ๊ทผ ๊ฐ๋ฅ) | httpOnly=true |
| CORS ์ค์ | allowedOrigins("*") | ๋ช ์์ ๋๋ฉ์ธ ๋ฆฌ์คํธ |
| SSR์์ redirect | try ๋ธ๋ก ์ | 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 ์ฟ ํค๋ก ๊ฝ๊ฝ ์จ๊ธฐ๊ณ , ์ก์ธ์ค ํ ํฐ์ ํ์ํ ๋๋ง ๊บผ๋ด ์ฐ๋ ์๋ฆฌํ ์ค๊ณ๋ฅผ ์ค์ฒํ์!"
์ํธ ๋ฆฌ๋ ๋์ด ๋ฐฐ๋ฌ ๋งค๋์ ์ ์ฃผ๋ฐฉ ๋น์ ๋ฅผ ๋ค์ด ์ค๋ช ํด ์ฃผ์ค ๋, ์ ์ํฉ์ ๋ง๋ ์ํคํ ์ฒ ์ ํ์ด ์ค์ํ์ง ๋จ๋ฒ์ ์ดํด๊ฐ ๊ฐ๋๋ผ. ๋จ์ํ ์ฝ๋๋ฅผ ์ง๋ ๊ฑธ ๋์ด, ์ ์ฒด ์์คํ ์ ํ๋ฆ์ ์ค๊ณํ๋ ๊ฒ ์ผ๋ง๋ ์ฆ๊ฑฐ์ด ์ผ์ธ์ง ๊นจ๋ฌ์์ด. ์ค๋ ๋๋ฌด ๊ธด์ฅํ๋ฉด์ ์ค๊ณ๋ฅผ ๋ง์ณค๋๋ ์จ๋ชธ์ด ๋ป๊ทผํ๋ค. ํด๊ทผ๊ธธ์ ๋ง์๋ ๊ฑฐ ์ฌ ๋ค๊ณ ๊ฐ์ ํน ์ฌ๋ฉด์ ์ค๋ ๋ฐฐ์ด ์ด 'ํ์์ ์ฝค๋น' ์ํคํ ์ฒ๋ฅผ ๋ณต์ตํด์ผ๊ฒ ์ด. ๋ด์ผ์ ๋ '๋์ ์์ผ' ๋ฅผ ๊ฐ์ง ๊ฐ๋ฐ์๊ฐ ๋ ๊ฑฐ์ผ! ๐ฃ