localStorage의 한계를 극복하고 SSR + HttpOnly 쿠키로 인증 시스템 구축하기
Next.js로 쇼핑몰을 만들면서 가장 신경 쓰였던 부분 중 하나가 바로 로그인 상태 관리였습니다.
처음에는 localStorage에 토큰을 저장하고 클라이언트에서 상태를 관리했는데, 매번 페이지를 새로고침할 때마다 헤더가 깜빡이는 현상이 발생했습니다. "로그인했는데 왜 잠깐 로그아웃된 것처럼 보이지?"라는 사용자 경험이 정말 신경 쓰였습니다.
페이지 로드 → 로그아웃 상태로 렌더링 → useEffect로 토큰 확인 → 로그인 상태로 변경
이 과정에서 사용자는 "아, 로그인 안 됐구나" → "어? 로그인 됐네?"라는 혼란스러운 경험을 하게 됩니다.
localStorage에 토큰을 저장하면 XSS 공격에 취약하다는 걸 알고 있었는데, "일단 동작하게 만들고 나중에 보안 강화하자"라는 마인드로 시작했어요. 하지만 나중에 바꾸는 것보다 처음부터 제대로 하는 게 낫겠다고 생각했습니다.
XSS : 권한이 없는 사용자가 웹사이트에 Script를 삽입하여 의도치 않은 동작을 일으키는 공격
localStorage는 서버에서 접근할 수 없어서, 보호된 라우트나 SSR에서 사용자 권한을 확인하기 어려웠습니다.
Context API + localStorage// 처음에 시도했던 방식
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
// 토큰 검증 후 사용자 정보 설정
setUser(userData)
}
setLoading(false)
}, [])
문제점: 깜빡임 발생, 보안 취약점 존재
HttpOnly 쿠키 도입"쿠키를 사용하면 서버에서도 접근할 수 있고, HttpOnly로 설정하면 XSS도 방지할 수 있다"라는 생각이 들었습니다.
하지만 여전히 SSR에서 초기 상태를 주입하는 방법을 찾아야 했어요.
SSR + HttpOnly 쿠키 조합Next.js App Router의 cookies() 함수를 사용해서 서버에서 쿠키를 읽고, 초기 사용자 상태를 클라이언트에 주입하는 방식으로 해결했습니다.
HttpOnly 쿠키만 사용 (localStorage 완전 제거)SSR에서 쿠키를 읽어서 클라이언트에 전달router.refresh()로 서버 상태와 클라이언트 동기화로그인 → 서버에서 쿠키 설정 → SSR에서 쿠키 읽기 → 초기 상태 주입 → 깜빡임 없음 ✨
// client/src/app/api/session/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const { token, user } = await req.json()
if (!token || !user) return NextResponse.json({ success: false }, { status: 400 })
const res = NextResponse.json({ success: true })
// HttpOnly 쿠키로 토큰과 사용자 정보 저장
res.cookies.set('token', token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 7 // 7일
})
res.cookies.set('user', JSON.stringify(user), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 7
})
return res
}
export async function DELETE() {
const res = NextResponse.json({ success: true })
// 로그아웃 시 쿠키 완전 삭제
res.cookies.set('token', '', { httpOnly: true, path: '/', maxAge: 0 })
res.cookies.set('user', '', { httpOnly: true, path: '/', maxAge: 0 })
return res
}
왜 이렇게 했나?
httpOnly: true: XSS 공격으로부터 토큰 보호sameSite: 'lax': CSRF 공격 방지secure: true (프로덕션): HTTPS에서만 전송// client/src/app/(auth)/sign-in/page.tsx (발췌)
const response = await authApi.login({ email, password })
if (response.success && response.data) {
// localStorage.setItem() 대신 서버 API 호출
await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: response.data.token,
user: {
email: response.data.email,
name: response.data.name,
role: response.data.role,
userType: response.data.userType,
},
}),
})
// router.refresh()로 SSR 상태 동기화
router.push('/')
router.refresh()
}
핵심 포인트:
localStorage.setItem() 완전 제거API를 통해서만 쿠키 설정router.refresh()로 서버 상태와 클라이언트 동기화SSR에서 초기 상태 주입 - 깜빡임 제거의 핵심// client/src/app/layout.tsx
import { cookies } from 'next/headers'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
const raw = cookieStore.get('user')?.value
let initialUser = null
// 서버에서 쿠키를 읽어서 초기 사용자 상태 설정
try {
if (raw) initialUser = JSON.parse(raw)
} catch {
// JSON 파싱 실패 시 null로 처리
}
return (
<html lang="ko">
<body>
<Providers>
{/* initialUser를 props로 전달하여 깜빡임 방지 */}
<LayoutWrapper initialUser={initialUser}>{children}</LayoutWrapper>
</Providers>
</body>
</html>
)
}
initialUser 설정useEffect로 토큰을 확인할 필요 없음 → 깜빡임 제거// client/src/components/NewHeader.tsx (핵심 아이디어)
export default function NewHeader({ initialUser }: { initialUser?: User }) {
const router = useRouter()
// SSR에서 주입된 initialUser로 즉시 상태 설정
const [user, setUser] = useState(initialUser ?? null)
const [isAuthLoading] = useState(false) // SSR 덕분에 로딩 불필요!
const handleSignOut = async () => {
try {
await fetch('/api/session', { method: 'DELETE' })
} catch {}
// 로그아웃 후 서버 상태 동기화
router.push('/')
router.refresh()
}
// isAuthLoading이 false이므로 스켈레톤 없이 바로 렌더링
return (
<header>
{user ? (
<div>안녕하세요, {user.name}님!</div>
) : (
<div>로그인이 필요합니다</div>
)}
</header>
)
}
Before vs After:
// ❌ Before: 깜빡임 발생
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
// ... 토큰 검증 로직
setLoading(false)
}, [])
// ✅ After: 깜빡임 없음
const [user, setUser] = useState(initialUser ?? null) // SSR에서 주입
const [isAuthLoading] = useState(false) // 로딩 불필요
localStorage의 함정처음에는 "localStorage가 간단하니까 이걸로 하자"라고 생각했는데, 실제로는 여러 문제가 있었어요:
XSS 공격에 노출SSR이나 미들웨어에서 권한 제어 어려움HttpOnly 쿠키의 장점XSS 공격으로부터 토큰 보호SSR, 미들웨어에서 쿠키 읽기 가능SSR의 진짜 가치SSR을 단순히 SEO나 초기 로딩 속도 향상으로만 생각했는데, 인증 상태 주입이라는 새로운 용도를 발견했어요. 서버에서 쿠키를 읽어서 초기 상태를 주입하면 클라이언트에서 별도의 로딩 과정이 필요 없어집니다.
| 구분 | localStorage | HttpOnly 쿠키 + SSR |
|---|---|---|
| 보안 | ❌ XSS 취약 | ✅ XSS 방지 |
| 깜빡임 | ❌ 발생 | ✅ 없음 |
| 서버 접근 | ❌ 불가능 | ✅ 가능 |
| 구현 복잡도 | ✅ 간단 | ⚠️ 보통 |
| 권한 제어 | ❌ 어려움 | ✅ 쉬움 |
// 권장 설정
{
httpOnly: true, // XSS 방지
sameSite: 'lax', // CSRF 방지
secure: true, // HTTPS에서만 전송 (프로덕션)
path: '/', // 전체 사이트에서 접근 가능
maxAge: 60 * 60 * 24 * N // N일
}
Access Token + Refresh Token: 짧은 access token + 긴 refresh tokenCSRF 토큰: 상태 변경 API에만 적용이번 경험을 통해 간단한 것 같아도 실제로는 복잡한 문제 라는 걸 배웠습니다. localStorage로 시작했지만, 사용자 경험과 보안을 고려하다 보니 결국 HttpOnly 쿠키 + SSR로 가게 되었어요.
"사용자에게 보여짐으로써 완결되는" 프론트엔드에서는 이런 세심한 사용자 경험 개선이 정말 중요하다고 생각합니다. 기술적으로는 조금 더 복잡해질 수 있지만, 사용자 경험 향상을 위해서는 투자할 만한 가치가 있다고 생각해요.