๐ ์๋ฆผ ๊ฒ์ํ (JWT ์ธ์ฆ + SSE ์ค์๊ฐ ์๋ฆผ)
๋ชฉ์ฐจ11
ํํ ๋ฆฌ์ผ โ ๐ ์๋ฆผ ๊ฒ์ํ (JWT ์ธ์ฆ + SSE ์ค์๊ฐ ์๋ฆผ)
ํ์๊ฐ์ ํ๊ณ , ๋ก๊ทธ์ธํ๊ณ (ํ ํฐ์ ์์ ํ๊ฒ httpOnly ์ฟ ํค๋ก), ๊ธ์ ์ฐ๊ณ , ๋จ์ ๊ธ์ ๋๊ธ์ ๋ต๋๋ค. ๊ทธ๋ฆฌ๊ณ โ ๋๊ตฐ๊ฐ ๋ด ๊ธ์ ๋๊ธ์ ๋ฌ๋ฉด, ์๋ก๊ณ ์นจ ์์ด ํค๋์ ๐ ์ ์๋ฆผ์ด ๋น๋๋ค. ์ด "์ค์๊ฐ ์๋ฆผ" ์ด ๋ฐ๋ก SSE๊ฐ ํ์ ์์ ๊ฐ์ฅ ๋ง์ด ์ฐ์ด๋ ๋ชจ์ต์ด์์.
์ด ํํ ๋ฆฌ์ผ์ ์์น โ ๊ทธ๋ ํ์ํ ๊ฒ๋ง.
๊ณ ์์ด ๊ฐค๋ฌ๋ฆฌ์์ ๊ทธ๋ฌ๋ฏ, ๊ธฐ๋ฅ์ ๋ํ ๋๋ง๋ค ๊ทธ ๊ธฐ๋ฅ์ ํ์ํ ๊ฒ๋ง ๊บผ๋ ๋๋ค. 1๋จ๊ณ(๊ฐ์ )์ ์ฟ ํค๋ SSE๋ ์์ต๋๋ค. ๋ก๊ทธ์ธ์ด ๋ฑ์ฅํ ๋ ๋น๋ก์ JWTยทhttpOnly ์ฟ ํค๋ฅผ, "๋๊ธ ์๋ฆผ" ์ด ๋ฑ์ฅํ ๋ ๋น๋ก์ SSE๋ฅผ ๊บผ๋ ๋๋ค. SSE๋ ๋งจ ๋ง์ง๋ง, ์ ๋ง ํ์ํด์ง ๋ ๋ฑ์ฅํด์.
์์ฑํ๋ฉด ์ด๋ ๊ฒ ๋์ํฉ๋๋ค.
- ๐ ํ์๊ฐ์ โ ๋ก๊ทธ์ธํ๋ฉด JWT๊ฐ httpOnly ์ฟ ํค๋ก ์ฌ๊ธด๋ค (JS๋ก ๋ชป ์ฝ์ = XSS์ ์์ )
- โ๏ธ ๋ก๊ทธ์ธํด์ผ ๊ธ์ ์ด๋ค. ๋น๋ก๊ทธ์ธ์ ๋งํ๋ค.
- ๐ฌ ์๋ฌด ๊ธ์๋ ๋๊ธ์ ๋จ๋ค.
- ๐ ๋ด ๊ธ์ ๋๊ธ์ด ๋ฌ๋ฆฌ๋ฉด, ๋ด ํ๋ฉด ํค๋ ๐ ์ ์ค์๊ฐ์ผ๋ก ๋นจ๊ฐ ๋ฐฐ์ง๊ฐ ๋ฌ๋ค (SSE ํธ์)
๋ง๋๋ ์์์ โ ๊ทธ๋ ์๋ก ๊บผ๋ด๋ ๋๊ตฌ
| ๋จ๊ณ | ๋ง๋๋ ๊ธฐ๋ฅ | ์ด ๋จ๊ณ์์ ์๋ก ํ์ํ ๊ฒ |
|---|---|---|
| 0 | (๊ฐ๋ ) SSE๋ก "์๋ฆผ" ์ ์ด๋ป๊ฒ? | SSE = ์๋ฒ๊ฐ ๋จผ์ ๋ฏธ๋ ๋จ๋ฐฉํฅ ์ฑ๋ |
| 1 | ํ์๊ฐ์ | ์ธ๋ฉ๋ชจ๋ฆฌ DB, bcrypt ๋น๋ฐ๋ฒํธ ํด์ โ ์ฟ ํค๋ SSE๋ ์์ง โ |
| 2 | ๋ก๊ทธ์ธ / ๋ก๊ทธ์์ / ๋ ๋๊ตฌ์ผ | JWT(jose) + httpOnly ์ฟ ํค, AuthContext |
| 3 | ๊ฒ์ํ + ๊ธ์ฐ๊ธฐ | ์ธ์ฆ์ด ํ์ํ API(getCurrentUser), ๋ณดํธ๋ ํ์ด์ง |
| 4 | ๋๊ธ + SSE ์ค์๊ฐ ์๋ฆผ | ์ฌ๊ธฐ์ SSE ๋ฑ์ฅ โ ์๋ฆผ ํ๋ธ, ์ฌ์ฉ์๋ณ ์คํธ๋ฆผ, EventSource |
์ SSE๊ฐ 4๋จ๊ณ์์์ผ ๋์ค๋ โ 1~3๋จ๊ณ๋ "์์ฒญํ๋ฉด ์๋ตํ๋" ํ๋ฒํ ํ๋ฆ์ด๋ผ SSE๊ฐ ํ์ ์์ต๋๋ค. ๊ทธ๋ฐ๋ฐ 4๋จ๊ณ์ ์๋ฆผ์ ๋ค๋ฆ ๋๋ค. bob์ด ๋๊ธ์ ๋ค๋ ํ๋์ ๊ฒฐ๊ณผ๊ฐ, ๊ฐ๋งํ ์๋ alice์ ํ๋ฉด์ ๋ํ๋์ผ ํด์. alice๋ ์๋ฌด๊ฒ๋ ์์ฒญํ์ง ์์๋๋ฐ ์๋ฒ๊ฐ ๋จผ์ ๋ง์ ๊ฑธ์ด์ผ ํฉ๋๋ค. ์ด๊ฒ ํด๋ง(1์ด๋ง๋ค "์๋ฆผ ์์ด์?" ๋ฌป๊ธฐ)์ผ๋ก๋ ๋์ง๋ง ๋ญ๋น๊ฐ ํฌ์ฃ . ์๋ฒ๊ฐ ์๋ฆผ์ด ์๊ธด ์๊ฐ ๋ฐ์ด ์ฃผ๋ SSE๊ฐ ์ ํํ ์ด๊ฑธ ์ํ ๋๊ตฌ์ ๋๋ค.
0๋จ๊ณ โ "์๋ฆผ" ๊ณผ SSE (๊ฐ๋ )
์น์์ "๋จ์ด ํ ํ๋์ ๋ด ํ๋ฉด์ ์ค์๊ฐ์ผ๋ก" ๋์ฐ๋ ๋ฐฉ๋ฒ์ ์ ์ ๋๋ค.
| ๋ฐฉ์ | ์ด๋ป๊ฒ | ์๋ฆผ์ ์ธ ๋ |
|---|---|---|
| ํด๋ง | ํด๋ผ์ด์ธํธ๊ฐ "์๋ฆผ ์์ด์?" ๋ฅผ 1~5์ด๋ง๋ค ๋ฐ๋ณต ์ง๋ฌธ | ๋๋ถ๋ถ "์์ด์" โ ์์ฒญ ๋ญ๋น, ์ด์ง ๋ฆ์ |
| SSE | ์ฐ๊ฒฐ ํ๋๋ฅผ ๊ธธ๊ฒ ์ด์ด ๋๊ณ , ์๋ฒ๊ฐ ์๊ธฐ๋ ์ฆ์ ๋ฐ์ด ์ค | ์๋ฒโํด๋ผ ๋จ๋ฐฉํฅ ์๋ฆผ์ ๋ฑ |
| WebSocket | ์๋ฐฉํฅ ์์ ์ฐ๊ฒฐ | ์ฑํ ยทํ์ ์ฒ๋ผ ํด๋ผ๋ ์ค์๊ฐ์ผ๋ก ์ ๋ |
์๋ฆผ์ ์๋ฒ โ ํด๋ผ์ด์ธํธ ํ ๋ฐฉํฅ์ ๋๋ค(ํด๋ผ์ด์ธํธ๊ฐ ์๋ฆผ์ ์๋ฒ๋ก ๋ณด๋ด์ง ์์ฃ ). ๊ทธ๋ฌ๋ SSE๊ฐ ๊ฐ์ฅ ๋จ์ํ๊ณ ์ ํํ ์ ํ์ ๋๋ค. WebSocket์ ์๋ฐฉํฅ์ด ํ์ํ ๋(์ฑํ ์ ๋ ฅ ๋ฑ) ๊บผ๋ ๋๋ค.
SSE์ ํ ์ค ์ ์ โ ์๋ฒ๊ฐ HTTP ์๋ต ํ๋๋ฅผ ๋ซ์ง ์๊ณ ์ด์ด ๋ ์ฑ, ๊ทธ ํต๋ก๋ก ์ด๋ฒคํธ๋ฅผ ์ค์ค์ด ํ๋ ค๋ณด๋ด๋ ํ์ค. ๋ฐ๋ ์ชฝ์ ๋ธ๋ผ์ฐ์ ๋ด์ฅ EventSource ํ ์ค.
์ด๋ฒ ํ๋ก์ ํธ์์ SSE๋ฅผ ์ฐ๋ฉฐ ๋ถ๋ชํ ํต์ฌ ๋ ๊ฐ์ง๋ฅผ ๋ฏธ๋ฆฌ ๋ชป ๋ฐ์ ๋ก๋๋ค.
EventSource๋ ์ปค์คํ ํค๋๋ฅผ ๋ชป ๋ถ์ ๋๋ค. ๊ทธ๋ผ SSE ์ฐ๊ฒฐ์ ์ด๋ป๊ฒ ์ธ์ฆํ์ฃ ? โ httpOnly ์ฟ ํค๊ฐ ์๋์ผ๋ก ๋ฐ๋ผ๊ฐ๋๋ค. ๊ทธ๋์ ์ฐ๋ฆฌ๊ฐ 2๋จ๊ณ์์ ํ ํฐ์ ์ฟ ํค๋ก ์ฌ๋ ์ ํ์ด, 4๋จ๊ณ SSE ์ธ์ฆ๊ณผ ์์ฐ์ค๋ฝ๊ฒ ๋ง๋ฌผ๋ฆฝ๋๋ค. (์ด๊ฒ ์ด ํํ ๋ฆฌ์ผ์ ์จ์ ์ค๊ณ์์.)- "๋๊ตฌ์๊ฒ ๋ณด๋ผ์ง" ๋ ์ฐ๋ฆฌ๊ฐ ์ ํด์ผ ํฉ๋๋ค. SSE ์์ฒด๋ ๊ทธ๋ฅ ํต๋ก์ผ ๋ฟ. "bob์ ๋๊ธ์ alice์๊ฒ๋ง" ๋ณด๋ด๋ ค๋ฉด, ์๋ฒ๊ฐ ์ด๋ ค ์๋ ์ฐ๊ฒฐ๋ค ์ค alice์ ๊ฒ์ ์ฐพ์ ๋ฐ์ด๋ฃ์ด์ผ ํฉ๋๋ค. ๊ทธ ๋ช ๋จ์ ๊ด๋ฆฌํ๋ ๊ฒ 4๋จ๊ณ์ ์๋ฆผ ํ๋ธ์ ๋๋ค.
์ด์ ๋ฐ๋ฅ๋ถํฐ ์์ ์ฌ๋ฆฝ๋๋ค.
์ค๋น โ ํ๋ก์ ํธ ๋ง๋ค๊ธฐ
๋น Next.js(App Router + TypeScript + Tailwind) ํ๋ก์ ํธ๋ฅผ ๋ง๋ค๊ณ , ์ด๋ฒ์ ์ธ ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ๊น๋๋ค.
# 1) ํ๋ก์ ํธ ์์ฑ (์ ๋ถ ๊ธฐ๋ณธ๊ฐ์ผ๋ก, src ๋๋ ํฐ๋ฆฌ๋ ์ฐ์ง ์์) npx create-next-app@latest board-notify \ --ts --app --tailwind --eslint --no-src-dir --import-alias "@/*" --yes cd board-notify # 2) ์ธ์ฆ์ ์ธ ๋ ํจํค์ง npm install bcryptjs jose npm install -D @types/bcryptjs # 3) ํฌํธ๋ฅผ 3700์ผ๋ก (์ ํ) โ package.json ์ dev ์คํฌ๋ฆฝํธ # "dev": "next dev -p 3700" npm run dev # http://localhost:3700
create-next-app ์ด ๋ง๋ค์ด ์ฃผ๋ ๊ฒ ์ค ์ฐ๋ฆฌ๊ฐ ์ฐ๋ ๊ฒ โ app/layout.tsx(๋ฃจํธ ๋ ์ด์์), app/globals.css(Tailwind ํฌํจ), app/page.tsx(์ฒซ ํ๋ฉด). import-alias "@/*" ๋๋ถ์ @/lib/..., @/types ๊ฐ์ ๊ฒฝ๋ก๊ฐ ๊ณง ํ๋ก์ ํธ ๋ฃจํธ๋ฅผ ๊ฐ๋ฆฌํต๋๋ค.
๋ง๋ค๋ฉด์ ํด๋๋ ๊ทธ๋๊ทธ๋ ์๊น๋๋ค โ
types/,lib/,contexts/,components/,app/api/.... ํ์ผ์ ์ฐ๋ฉด ํด๋๋ ๋ฐ๋ผ ์๊ธฐ๋ ๋ฏธ๋ฆฌ ๋ง๋ค ํ์๋ ์์ด์.
ํด๋ ๊ตฌ์กฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ(์์ฑ ์):
lib/ db.ts ยท auth.ts ยท notificationHub.ts types/ index.ts app/api/ auth/{signup,login,logout,me} ยท posts ยท posts/[id] ยท posts/[id]/comments ยท notifications/stream contexts/ AuthContext.tsx ยท NotificationContext.tsx components/ Header.tsx ยท NotificationBell.tsx ยท PostDetail.tsx app/ page(๊ฒ์ํ) ยท login ยท signup ยท posts/new ยท posts/[id] ยท layout.tsx
1๋จ๊ณ โ ํ์๊ฐ์ (์ฟ ํค๋ SSE๋ ์์ด)
๊ฐ์
์ ๊ทธ๋ฅ "์์ด๋ยท๋น๋ฐ๋ฒํธ๋ฅผ ๋ฐ์ ์ ์ฅ" ์
๋๋ค. ๋จ, ๋น๋ฐ๋ฒํธ๋ฅผ ์ ๋ ํ๋ฌธ์ผ๋ก ์ ์ฅํ์ง ์์ต๋๋ค โ bcrypt๋ก ํด์ํด์ ์ ์ฅํด์. ์ฌ๊ธฐ์ ํ ํฐ๋ ์ฟ ํค๋ SSE๋ ํ์ ์์ต๋๋ค.
1-1. ์ธ๋ฉ๋ชจ๋ฆฌ DB
์ง์ง DB ๋์ ๋ฉ๋ชจ๋ฆฌ ๋ฐฐ์ด๋ก ๋ก๋๋ค(์๋ฒ๋ฅผ ๋๋ฉด ์ฌ๋ผ์ง์ง๋ง ํํ ๋ฆฌ์ผ์ ์ถฉ๋ถ). globalThis์ ๋งค๋ฌ์ ๋๋ฉด ๊ฐ๋ฐ ์ค ํ์ผ ์ ์ฅ(HMR)์๋ ๋ฐ์ดํฐ๊ฐ ์ด์๋จ์ต๋๋ค.
types/index.ts
export type User = { id: string; username: string; passwordHash: string; // โ ํ๋ฌธ์ด ์๋๋ผ ํด์! createdAt: number; }; export type PublicUser = { id: string; username: string }; // ํด๋ผ์ด์ธํธ๋ก ๋ด๋ณด๋ด๋ ์์ ํ ํํ export type Post = { id: string; title: string; content: string; authorId: string; authorName: string; createdAt: number; }; export type Comment = { id: string; postId: string; content: string; authorId: string; authorName: string; createdAt: number; }; export type Notification = { id: string; postId: string; postTitle: string; commenterName: string; createdAt: number; };
lib/db.ts
import type { User, Post, Comment } from "@/types"; type Store = { users: User[]; posts: Post[]; comments: Comment[] }; // globalThis์ ๋งค๋ฌ์ HMR์๋ ๋ฐ์ดํฐ ์ ์ง const g = globalThis as unknown as { __boardStore?: Store }; const store: Store = g.__boardStore ?? (g.__boardStore = { users: [], posts: [], comments: [] }); export function createUser(username: string, passwordHash: string): User { const user: User = { id: crypto.randomUUID(), username, passwordHash, createdAt: Date.now() }; store.users.push(user); return user; } export function findUserByUsername(username: string) { return store.users.find((u) => u.username === username); } export function findUserById(id: string) { return store.users.find((u) => u.id === id); } // posts/comments ํฌํผ๋ 3ยท4๋จ๊ณ์์ ์ถ๊ฐ โ ์ง๊ธ์ user๋ง ์์ผ๋ฉด ๋๋ค
1-2. ๋น๋ฐ๋ฒํธ ํด์ โ ์, ์ด๋ป๊ฒ
ํ๋ฌธ ์ ์ฅ์ ์ ๋ ๊ธ๋ฌผ์
๋๋ค. DB๊ฐ ์ ์ถ๋๋ฉด ๋ชจ๋์ ๋น๋ฐ๋ฒํธ๊ฐ ๊ทธ๋๋ก ํธ๋ ค์. bcrypt๋ ๋จ๋ฐฉํฅ ํด์๋ผ ์๋ฌธ ๋ณต์์ด ๋ถ๊ฐ๋ฅํ๊ณ , ๋ก๊ทธ์ธ ๋๋ "์
๋ ฅ๊ฐ์ ๊ฐ์ ๋ฐฉ์์ผ๋ก ํด์ํด ๋น๊ต" ํฉ๋๋ค.
lib/auth.ts โ v1 (ํด์๋ง)
import bcrypt from "bcryptjs"; export function hashPassword(plain: string): Promise<string> { return bcrypt.hash(plain, 10); // 10 = cost(๋์์๋ก ๋๋ฆฌ๊ณ ์์ ) } export function verifyPassword(plain: string, hash: string): Promise<boolean> { return bcrypt.compare(plain, hash); }
1-3. ๊ฐ์ API
app/api/auth/signup/route.ts
import { NextResponse } from "next/server"; import { createUser, findUserByUsername } from "@/lib/db"; import { hashPassword } from "@/lib/auth"; export async function POST(req: Request) { const { username, password } = await req.json(); if (!username || !password || password.length < 4) { return NextResponse.json({ error: "์์ด๋์ 4์ ์ด์ ๋น๋ฐ๋ฒํธ๊ฐ ํ์ํฉ๋๋ค." }, { status: 400 }); } if (findUserByUsername(username)) { return NextResponse.json({ error: "์ด๋ฏธ ์๋ ์์ด๋์ ๋๋ค." }, { status: 409 }); } const passwordHash = await hashPassword(password); const user = createUser(username, passwordHash); return NextResponse.json({ id: user.id, username: user.username }, { status: 201 }); }
ํ์ธ โ
โ curl -X POST localhost:3700/api/auth/signup -H 'Content-Type: application/json' -d '{"username":"alice","password":"pass1234"}' โ {"id":"โฆ","username":"alice"}. ๊ฐ์ ์์ด๋๋ก ๋ ํ๋ฉด 409. ์ฌ๊ธฐ๊น์ง ์ฟ ํค๋ ํ ํฐ๋ ์์ต๋๋ค.
2๋จ๊ณ โ ๋ก๊ทธ์ธ (JWT + httpOnly ์ฟ ํค ๋ฑ์ฅ)
์ด์ "๋ก๊ทธ์ธ ์ํ" ๊ฐ ํ์ํฉ๋๋ค. ๋งค ์์ฒญ๋ง๋ค ์์ด๋ยท๋น๋ฒ์ ๋ค์ ๋ณด๋ผ ์ ์์ฃ . ์๋ฒ๊ฐ "์ด ์ฌ๋ ๋ก๊ทธ์ธํ์" ์ ์ฆ๋ช ํ๋ ํ ํฐ(JWT) ์ ๋ฐ๊ธํ๊ณ , ๋ธ๋ผ์ฐ์ ๊ฐ ๊ทธ๊ฑธ ๋ค๊ณ ๋ค๋๊ฒ ํฉ๋๋ค. ์ด๋ ๋น๋ก์ JWT์ ์ฟ ํค๊ฐ ๋ฑ์ฅํฉ๋๋ค.
2-1. ์ httpOnly ์ฟ ํค์ธ๊ฐ
ํ ํฐ์ ์ด๋์ ๋๊น์?
- localStorage โ JS๋ก ์ฝ๊ณ ์๋๋ค. ํธํ์ง๋ง XSS ๊ณต๊ฒฉ์ ์ทจ์ฝ: ์
์ฑ ์คํฌ๋ฆฝํธ๊ฐ
localStorage๋ฅผ ์ฝ์ด ํ ํฐ์ ํต์งธ๋ก ํ์ณ ๊ฐ๋๋ค. - httpOnly ์ฟ ํค โ ์๋ฒ๊ฐ ์ฌ๊ณ , JS(
document.cookie)๋ก๋ ์ฝ์ ์ ์์ต๋๋ค. XSS๊ฐ ๋๋ ํ ํฐ์ ๋ชป ๊ฐ์ ธ๊ฐ์. ๊ทธ๋ฆฌ๊ณ ์์ฒญ ๋ ๋ธ๋ผ์ฐ์ ๊ฐ ์๋์ผ๋ก ์ค์ด ๋ณด๋ ๋๋ค.
ํ์๊ฐ ์ ์์ ๋๋ค. ๊ฒ๋ค๊ฐ ์๋ ์ ์ก ์ด๋ผ๋ ์ฑ์ง์ด 4๋จ๊ณ SSE์์ ๊ฒฐ์ ์ ์ญํ ์ ํฉ๋๋ค(๊ณง ๋ด ๋๋ค).
2-2. JWT ๋ฐ๊ธ/๊ฒ์ฆ + ์ฟ ํค์์ ์ฌ์ฉ์ ๊บผ๋ด๊ธฐ
lib/auth.ts์ JWT(jose)์ "์ฟ ํค โ ํ์ฌ ์ฌ์ฉ์" ํฌํผ๋ฅผ ๋ํฉ๋๋ค. auth๊ฐ ํ ๋ฒ์ ์์ฑ๋ ๊ฒ ์๋๋ผ, ๋ก๊ทธ์ธ์ด ํ์ํด์ง ์ง๊ธ ์๋๋๋ค.
lib/auth.ts โ v2 (JWT + ์ฟ ํค)
import bcrypt from "bcryptjs"; import { SignJWT, jwtVerify } from "jose"; import { cookies } from "next/headers"; import { findUserById } from "@/lib/db"; import type { PublicUser, User } from "@/types"; export const COOKIE_NAME = "token"; const secret = new TextEncoder().encode(process.env.JWT_SECRET ?? "dev-secret-please-change-me"); export function hashPassword(p: string) { return bcrypt.hash(p, 10); } export function verifyPassword(p: string, h: string) { return bcrypt.compare(p, h); } export async function signToken(user: User) { return new SignJWT({ username: user.username }) .setProtectedHeader({ alg: "HS256" }) .setSubject(user.id) // sub = userId .setExpirationTime("7d") .sign(secret); } async function verifyToken(token: string): Promise<string | null> { try { const { payload } = await jwtVerify(token, secret); return (payload.sub as string) ?? null; } catch { return null; // ์์กฐยท๋ง๋ฃ } } // ์ฟ ํค์์ ํ์ฌ ์ฌ์ฉ์ ๊บผ๋ด๊ธฐ โ Route Handler/Server Component ์ด๋์๋ export async function getCurrentUser(): Promise<User | null> { const token = (await cookies()).get(COOKIE_NAME)?.value; if (!token) return null; const userId = await verifyToken(token); if (!userId) return null; return findUserById(userId) ?? null; } export function toPublicUser(u: User): PublicUser { return { id: u.id, username: u.username }; // passwordHash ์ ๊ฑฐ }
ํ ํฐ์ ๋ฏผ๊ฐ์ ๋ณด๋ฅผ ๋ฃ์ง ์์ต๋๋ค.
sub์ userId, payload์ username ์ ๋๋ง. ๋น๋ฐ๋ฒํธ ํด์ ๊ฐ์ ๊ฑด ์ ๋ ๋ฃ์ง ์์์.
2-3. ๋ก๊ทธ์ธ API โ ์ฟ ํค ์ฌ๊ธฐ
app/api/auth/login/route.ts
import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { findUserByUsername } from "@/lib/db"; import { COOKIE_NAME, signToken, verifyPassword } from "@/lib/auth"; export async function POST(req: Request) { const { username, password } = await req.json(); const user = findUserByUsername(username); // ์์ด๋ ์ ๋ฌด๋ฅผ ๋คํค์ง ์๊ฒ ๋ฉ์์ง๋ ๋์ผํ๊ฒ if (!user || !(await verifyPassword(password, user.passwordHash))) { return NextResponse.json({ error: "์์ด๋ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ํ๋ ธ์ต๋๋ค." }, { status: 401 }); } const token = await signToken(user); // โ JWT๋ฅผ httpOnly ์ฟ ํค๋ก ์ฌ๋๋ค (await cookies()).set(COOKIE_NAME, token, { httpOnly: true, // JS๋ก ๋ชป ์ฝ์ sameSite: "lax", secure: process.env.NODE_ENV === "production", // ์ด์์์ HTTPS๋ง path: "/", maxAge: 60 * 60 * 24 * 7, // 7์ผ }); return NextResponse.json({ id: user.id, username: user.username }); }
2-4. ๋ก๊ทธ์์ / "๋ ๋๊ตฌ์ผ"
httpOnly ์ฟ ํค๋ JS๋ก ๋ชป ์ฝ์ผ๋, ์๋ก๊ณ ์นจ ํ "๋ด๊ฐ ๋ก๊ทธ์ธ ์ํ์ธ์ง" ๋ฅผ ํด๋ผ์ด์ธํธ๊ฐ ์ค์ค๋ก ์ ์ ์์ต๋๋ค. ์๋ฒ์ ๋ฌผ์ด๋ด์ผ ํด์ โ /api/auth/me.
app/api/auth/logout/route.ts
import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { COOKIE_NAME } from "@/lib/auth"; export async function POST() { (await cookies()).delete(COOKIE_NAME); return NextResponse.json({ ok: true }); }
app/api/auth/me/route.ts
import { NextResponse } from "next/server"; import { getCurrentUser, toPublicUser } from "@/lib/auth"; export async function GET() { const user = await getCurrentUser(); return NextResponse.json({ user: user ? toPublicUser(user) : null }); }
2-5. ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ฑ ์ ์ฒด๊ฐ ๊ณต์ โ AuthContext
์ฌ๋ฌ ์ปดํฌ๋ํธ(ํค๋ยท๊ธ์ฐ๊ธฐยท๋๊ธ)๊ฐ "์ง๊ธ ๋๊ฐ ๋ก๊ทธ์ธํ๋" ๋ฅผ ๊ณต์ ํด์ผ ํฉ๋๋ค. ๊ณ ์์ด ๊ฐค๋ฌ๋ฆฌ์์ ๋ณธ ๊ทธ ์ํฉ โ ๊ณต์ ์ํ๋ Context๋ก.
contexts/AuthContext.tsx
"use client"; import { createContext, useCallback, useContext, useEffect, useState } from "react"; import type { PublicUser } from "@/types"; type AuthValue = { user: PublicUser | null; loading: boolean; login: (u: string, p: string) => Promise<void>; signup: (u: string, p: string) => Promise<void>; logout: () => Promise<void>; }; const AuthContext = createContext<AuthValue | null>(null); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<PublicUser | null>(null); const [loading, setLoading] = useState(true); // ์๋ก๊ณ ์นจํด๋ ๋ก๊ทธ์ธ ๋ณต์: ์๋ฒ์ "๋ ๋๊ตฌ์ผ?" ๋ฅผ ๋ฌผ์ด๋ณธ๋ค const refresh = useCallback(async () => { const res = await fetch("/api/auth/me"); const data = await res.json(); setUser(data.user); setLoading(false); }, []); useEffect(() => { refresh(); }, [refresh]); const login = useCallback(async (username: string, password: string) => { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "๋ก๊ทธ์ธ ์คํจ"); setUser(data); }, []); const signup = useCallback(async (username: string, password: string) => { const res = await fetch("/api/auth/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "๊ฐ์ ์คํจ"); await login(username, password); // ๊ฐ์ ํ ์๋ ๋ก๊ทธ์ธ }, [login]); const logout = useCallback(async () => { await fetch("/api/auth/logout", { method: "POST" }); setUser(null); }, []); return ( <AuthContext.Provider value={{ user, loading, login, signup, logout }}> {children} </AuthContext.Provider> ); } export function useAuth() { const ctx = useContext(AuthContext); if (ctx === null) throw new Error("useAuth๋ <AuthProvider> ์์์๋ง ์ฌ์ฉํ ์ ์์ต๋๋ค."); return ctx; }
2-6. ๋ ์ด์์์ AuthProvider ๋ผ์ฐ๊ธฐ
useAuth()๋ฅผ ์ฐ๋ ค๋ฉด ํธ๋ฆฌ ์์ชฝ์ <AuthProvider>๊ฐ ์์ด์ผ ํฉ๋๋ค. create-next-app์ด ๋ง๋ app/layout.tsx๋ฅผ ์ด๋ ๊ฒ ๋ฐ๊ฟ๋๋ค(์์ง ํค๋๋ ์์ต๋๋ค โ 3๋จ๊ณ์์ ๋ํด์).
app/layout.tsx โ v1
import type { Metadata } from "next"; import "./globals.css"; import { AuthProvider } from "@/contexts/AuthContext"; export const metadata: Metadata = { title: "๐ ์๋ฆผ ๊ฒ์ํ", }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ko"> <body> <AuthProvider> <div className="mx-auto max-w-2xl p-4">{children}</div> </AuthProvider> </body> </html> ); }
2-7. ๋ก๊ทธ์ธ / ํ์๊ฐ์ ํ์ด์ง
useAuth()์ login/signup์ ๋ถ๋ฅด๋ ๋จ์ํ ํผ์
๋๋ค.
app/login/page.tsx
"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useAuth } from "@/contexts/AuthContext"; export default function LoginPage() { const { login } = useAuth(); const router = useRouter(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); try { await login(username, password); router.push("/"); } catch (err) { setError((err as Error).message); } } return ( <form onSubmit={handleSubmit} className="mx-auto max-w-sm space-y-3"> <h1 className="text-lg font-bold">๋ก๊ทธ์ธ</h1> <input className="w-full rounded border px-3 py-2" placeholder="์์ด๋" value={username} onChange={(e) => setUsername(e.target.value)} /> <input type="password" className="w-full rounded border px-3 py-2" placeholder="๋น๋ฐ๋ฒํธ" value={password} onChange={(e) => setPassword(e.target.value)} /> {error && <p className="text-sm text-red-600">{error}</p>} <button className="w-full rounded bg-blue-500 py-2 text-white">๋ก๊ทธ์ธ</button> <p className="text-center text-sm text-gray-500"> ๊ณ์ ์ด ์๋์? <Link href="/signup" className="text-blue-600">ํ์๊ฐ์ </Link> </p> </form> ); }
app/signup/page.tsx
login์ signup์ผ๋ก ๋ฐ๊พธ๊ณ (๊ฐ์
ํ ์๋ ๋ก๊ทธ์ธ๋จ), ์๋ด ๋ฌธ๊ตฌ๋ง ๋ค๋ฆ
๋๋ค.
"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useAuth } from "@/contexts/AuthContext"; export default function SignupPage() { const { signup } = useAuth(); const router = useRouter(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); try { await signup(username, password); // ๊ฐ์ ํ ์๋ ๋ก๊ทธ์ธ router.push("/"); } catch (err) { setError((err as Error).message); } } return ( <form onSubmit={handleSubmit} className="mx-auto max-w-sm space-y-3"> <h1 className="text-lg font-bold">ํ์๊ฐ์ </h1> <input className="w-full rounded border px-3 py-2" placeholder="์์ด๋" value={username} onChange={(e) => setUsername(e.target.value)} /> <input type="password" className="w-full rounded border px-3 py-2" placeholder="๋น๋ฐ๋ฒํธ (4์ ์ด์)" value={password} onChange={(e) => setPassword(e.target.value)} /> {error && <p className="text-sm text-red-600">{error}</p>} <button className="w-full rounded bg-blue-600 py-2 text-white">๊ฐ์ ํ๊ธฐ</button> <p className="text-center text-sm text-gray-500"> ์ด๋ฏธ ๊ณ์ ์ด ์๋์? <Link href="/login" className="text-blue-600">๋ก๊ทธ์ธ</Link> </p> </form> ); }
ํ์ธ โ
โ ๋ก๊ทธ์ธํ๋ฉด ์๋ต ํค๋์ Set-Cookie: token=โฆ; HttpOnly ๊ฐ ๋ถ๊ณ , ์๋ก๊ณ ์นจํด๋ /api/auth/me๊ฐ ๋๋ฅผ ๊ธฐ์ตํฉ๋๋ค. document.cookie๋ฅผ ์ฝ์์ ์ฐ์ด ๋ณด๋ฉด โ ํ ํฐ์ด ์ ๋ณด์
๋๋ค(httpOnly๋๊น). ๊ทธ๊ฒ ์ ์์ด๊ณ ์์ ํ ๊ฒ๋๋ค.
3๋จ๊ณ โ ๊ฒ์ํ + ๊ธ์ฐ๊ธฐ (์ธ์ฆ ๊ฐ๋)
์ด์ ๊ธ์ ์๋๋ค. ๋ชฉ๋ก์ ๋๊ตฌ๋ ๋ณด์ง๋ง, ์ฐ๊ธฐ๋ ๋ก๊ทธ์ธํ ์ฌ๋๋ง. ์๋ฒ๋ getCurrentUser()๋ก ์ฟ ํค๋ฅผ ๊น์ ๊ฒ์ฌํฉ๋๋ค. ์์ง SSE๋ ํ์ ์์ด์ โ ํ๋ฒํ ์์ฒญ/์๋ต์
๋๋ค.
3-1. DB์ ๊ธ ํฌํผ ์ถ๊ฐ
// lib/db.ts ์ ์ถ๊ฐ export function createPost(title: string, content: string, author: User): Post { const post: Post = { id: crypto.randomUUID(), title, content, authorId: author.id, authorName: author.username, createdAt: Date.now(), }; store.posts.push(post); return post; } export function listPosts(): Post[] { return [...store.posts].sort((a, b) => b.createdAt - a.createdAt); } export function findPost(id: string) { return store.posts.find((p) => p.id === id); }
3-2. ๊ธ API โ ์ฐ๊ธฐ๋ ์ธ์ฆ ํ์
app/api/posts/route.ts
import { NextResponse } from "next/server"; import { createPost, listPosts } from "@/lib/db"; import { getCurrentUser } from "@/lib/auth"; export function GET() { return NextResponse.json(listPosts()); // ๋ชฉ๋ก โ ๋๊ตฌ๋ } export async function POST(req: Request) { const user = await getCurrentUser(); // โ ์ฟ ํค ๊ฒ์ฌ if (!user) return NextResponse.json({ error: "๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค." }, { status: 401 }); const { title, content } = await req.json(); if (!title?.trim() || !content?.trim()) { return NextResponse.json({ error: "์ ๋ชฉ๊ณผ ๋ด์ฉ์ ์ ๋ ฅํ์ธ์." }, { status: 400 }); } const post = createPost(title.trim(), content.trim(), user); return NextResponse.json(post, { status: 201 }); }
app/api/posts/[id]/route.ts โ ๋จ์ผ ๊ธ ์กฐํ
import { NextResponse } from "next/server"; import { findPost } from "@/lib/db"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const post = findPost(id); if (!post) return NextResponse.json({ error: "์๋ ๊ธ์ ๋๋ค." }, { status: 404 }); return NextResponse.json(post); }
3-3. ํค๋ โ ๋ก๊ทธ์ธ ์ํ์ ๋ฐ๋ผ ๋ค๋ฅธ ๋ฉ๋ด
์ด์ ํ๋ฉด์ ๋ด๋น๊ฒ์ด์
์ด ํ์ํฉ๋๋ค. useAuth()๋ก ๋ก๊ทธ์ธ ์ฌ๋ถ๋ฅผ ๋ณด๊ณ ๋ฉ๋ด๋ฅผ ๋ฐ๊ฟ๋๋ค. (๐ ์๋ฆผ ๋ฒจ์ 4๋จ๊ณ์์ ๋ํฉ๋๋ค.)
components/Header.tsx โ v1
"use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; export default function Header() { const { user, loading, logout } = useAuth(); const router = useRouter(); async function handleLogout() { await logout(); router.push("/"); } return ( <header className="border-b"> <div className="mx-auto flex max-w-2xl items-center justify-between p-4"> <Link href="/" className="text-xl font-bold">๐ ์๋ฆผ ๊ฒ์ํ</Link> <nav className="flex items-center gap-3 text-sm"> {loading ? null : user ? ( <> <Link href="/posts/new" className="rounded bg-blue-500 px-3 py-1 text-white">๊ธ์ฐ๊ธฐ</Link> <span className="text-gray-600">{user.username}</span> <button onClick={handleLogout} className="text-gray-500 hover:underline">๋ก๊ทธ์์</button> </> ) : ( <> <Link href="/login">๋ก๊ทธ์ธ</Link> <Link href="/signup" className="font-bold text-blue-600">ํ์๊ฐ์ </Link> </> )} </nav> </div> </header> ); }
๋ ์ด์์์ ํค๋๋ฅผ ๋ผ์๋๋ค(2-6์ v1์์ ํค๋ ์ค๋ง ์ถ๊ฐ).
// app/layout.tsx โ ํค๋ ์ถ๊ฐ import Header from "@/components/Header"; // ... <AuthProvider> <Header /> {/* โ ์ถ๊ฐ */} <div className="mx-auto max-w-2xl p-4">{children}</div> </AuthProvider>
3-4. ๊ฒ์ํ ๋ชฉ๋ก + ๊ธ์ฐ๊ธฐ ํ์ด์ง
app/page.tsx โ ๊ฒ์ํ(๋ชฉ๋ก)
"use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import type { Post } from "@/types"; export default function BoardPage() { const [posts, setPosts] = useState<Post[]>([]); const [loading, setLoading] = useState(true); useEffect(() => { let ignore = false; fetch("/api/posts") .then((r) => r.json()) .then((data: Post[]) => { if (!ignore) { setPosts(data); setLoading(false); } }); return () => { ignore = true; }; }, []); if (loading) return <p className="text-gray-500">๋ถ๋ฌ์ค๋ ์คโฆ</p>; return ( <div className="space-y-3"> <h1 className="text-lg font-bold">๊ฒ์ํ</h1> {posts.length === 0 ? ( <p className="text-gray-500">์์ง ๊ธ์ด ์์ด์. ์ฒซ ๊ธ์ ์จ ๋ณด์ธ์!</p> ) : ( <ul className="divide-y rounded border"> {posts.map((post) => ( <li key={post.id}> <Link href={`/posts/${post.id}`} className="block p-3 hover:bg-gray-50"> <p className="font-bold">{post.title}</p> <p className="text-sm text-gray-500">{post.authorName}</p> </Link> </li> ))} </ul> )} </div> ); }
app/posts/new/page.tsx โ ๊ธ์ฐ๊ธฐ (๋น๋ก๊ทธ์ธ ๊ฐ๋)
"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; export default function NewPostPage() { const { user, loading } = useAuth(); const router = useRouter(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [error, setError] = useState(""); // ๋น๋ก๊ทธ์ธ ์ฌ์ฉ์๋ ๋ก๊ทธ์ธ ํ๋ฉด์ผ๋ก useEffect(() => { if (!loading && !user) router.replace("/login"); }, [loading, user, router]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); const res = await fetch("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, content }), }); const data = await res.json(); if (!res.ok) { setError(data.error ?? "์์ฑ ์คํจ"); return; } router.push(`/posts/${data.id}`); } if (loading || !user) return null; return ( <form onSubmit={handleSubmit} className="space-y-3"> <h1 className="text-lg font-bold">๊ธ์ฐ๊ธฐ</h1> <input className="w-full rounded border px-3 py-2" placeholder="์ ๋ชฉ" value={title} onChange={(e) => setTitle(e.target.value)} /> <textarea className="w-full rounded border px-3 py-2" rows={8} placeholder="๋ด์ฉ" value={content} onChange={(e) => setContent(e.target.value)} /> {error && <p className="text-sm text-red-600">{error}</p>} <button className="rounded bg-blue-500 px-4 py-2 text-white">๋ฑ๋ก</button> </form> ); }
๊ธ ์์ธ ํ์ด์ง(
/posts/[id])๋ ๋๊ธ๊ณผ ํ ๋ชธ์ด๋ผ 4๋จ๊ณ์์ ๋๊ธ๊ณผ ํจ๊ป ๋ง๋ญ๋๋ค. ์ง๊ธ ๋ชฉ๋ก์์ ๊ธ์ ๋๋ฅด๋ฉด ๋น ํ๋ฉด์ด์ง๋ง, 4๋จ๊ณ๊ฐ ๋๋๋ฉด ์ฑ์์ง๋๋ค.
ํ์ธ โ โ ๋ก๊ทธ์ธ ์ํ์์ ๊ธ์ฐ๊ธฐ โ ๋ชฉ๋ก์ ๋ธ. ๋น๋ก๊ทธ์ธ์ผ๋ก ๊ธ์ฐ๊ธฐ API๋ฅผ ์ง์ ์น๋ฉด 401.
4๋จ๊ณ โ ๋๊ธ + SSE ์ค์๊ฐ ์๋ฆผ (๋๋์ด SSE)
์, ์ด์ ์ด ํ๋ก์ ํธ์ ์ฌ์ฅ์ ๋๋ค. ๋๊ธ ์์ฒด๋ ๊ธ์ฐ๊ธฐ์ ๋๊ฐ์ ํจํด์ด์์. ์ง์ง ์๋ก์ด ๊ฑด โ "bob์ด alice ๊ธ์ ๋๊ธ์ ๋จ ์๊ฐ, alice ํ๋ฉด์ ์๋ฆผ์ด ๋จ๋ ๊ฒ." alice๋ ์๋ฌด๊ฒ๋ ์์ฒญํ์ง ์์๋๋ฐ ์๋ฒ๊ฐ ๋จผ์ ๋ง์ ๊ฑธ์ด์ผ ํฉ๋๋ค. ์ด๊ฒ SSE๊ฐ ๋ฑ์ฅํ๋ ์ด์ ์ ๋๋ค.
4-1. ๊ทธ๋ฆผ์ผ๋ก ๋ณด๋ ํ๋ฆ
[bob ๋ธ๋ผ์ฐ์ ] [์๋ฒ] [alice ๋ธ๋ผ์ฐ์ ] ๋๊ธ POST โโโโโโโโโโโโถ ๋๊ธ ์ ์ฅ (๊ฐ๋งํ ์์) ๊ธ์ด์ด=alice ํ์ธ EventSource ์ฐ๊ฒฐ์ด notifyUser(alice, ...) โโpushโโโถ ์ด๋ ค ์์ โ ์๋ฆผ ์์ ๐
ํต์ฌ ๋ถํ ๋:
- ์๋ฆผ ํ๋ธ โ "์ง๊ธ SSE๋ก ์ฐ๊ฒฐ๋ ์ฌ์ฉ์๋ค" ์ ๋ช ๋จ. ๋๊ธ API๊ฐ ์ฌ๊ธฐ์ alice์ ์ฐ๊ฒฐ์ ์ฐพ๋๋ค.
- ์ฌ์ฉ์๋ณ SSE ์คํธ๋ฆผ โ alice๊ฐ ๋ก๊ทธ์ธํ๋ฉด
/api/notifications/stream์ ์ฐ๊ฒฐ์ ์ด๊ณ , ํ๋ธ ๋ช ๋จ์ ์๊ธฐ๋ฅผ ๋ฑ๋กํ๋ค.
4-2. ์๋ฆผ ํ๋ธ
๋๊ธ API(์์ฒญ A)์ alice์ SSE ์ฐ๊ฒฐ(์์ฒญ B)์ ์๋ก ๋ค๋ฅธ ์์ฒญ์ ๋๋ค. ํ์ชฝ์์ ๋ค๋ฅธ ์ชฝ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ๋ฐ์ด ๋ฃ์ผ๋ ค๋ฉด, ๋์ด ๊ณต์ ํ๋ ์ค๊ฐ ๋ช ๋จ์ด ํ์ํฉ๋๋ค. ๊ฐ์ Node ํ๋ก์ธ์ค์ ๋ฉ๋ชจ๋ฆฌ์ ๋๋ฉด ๋ฉ๋๋ค.
lib/notificationHub.ts
export type SSEClient = { userId: string; enqueue: (frame: string) => void; // ์ด ์ฌ๋์ ์ด๋ฆฐ ์คํธ๋ฆผ์ผ๋ก ํ๋ ์์ ๋ฐ์ด๋ฃ๋ ํจ์ }; // globalThis ์ฑ๊ธํด โ ๋๊ธ API์ SSE ์ฐ๊ฒฐ์ด ๊ฐ์ ๋ช ๋จ์ ๋ณด๊ฒ const g = globalThis as unknown as { __sseClients?: Set<SSEClient> }; const clients: Set<SSEClient> = g.__sseClients ?? (g.__sseClients = new Set()); // SSE ์ฐ๊ฒฐ์ด ์ด๋ฆด ๋ ๋ฑ๋ก. ๋ฐํ ํจ์๋ฅผ ๋ถ๋ฅด๋ฉด ๋ช ๋จ์์ ์ ๊ฑฐ. export function registerClient(client: SSEClient): () => void { clients.add(client); return () => { clients.delete(client); }; } // ํน์ ์ฌ์ฉ์์ "๋ชจ๋ ์ด๋ฆฐ ์ฐ๊ฒฐ" ๋ก ํธ์ (์ฌ๋ฌ ํญ์ด๋ฉด ์ฌ๋ฌ ์ฐ๊ฒฐ) export function notifyUser(userId: string, event: string, data: unknown) { const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; for (const client of clients) { if (client.userId === userId) { try { client.enqueue(frame); } catch { /* ๋ซํ ์ฐ๊ฒฐ ๋ฌด์ */ } } } }
4-3. ์ฌ์ฉ์๋ณ SSE ์คํธ๋ฆผ โ ์ฟ ํค๋ก ์ธ์ฆ
์ฌ๊ธฐ์ 0๋จ๊ณ์ ๋ณต์ ์ด ํ์๋ฉ๋๋ค. EventSource๋ ํค๋๋ฅผ ๋ชป ๋ถ์ด์ง๋ง, ๊ฐ์ ์ถ์ฒ๋ผ httpOnly ์ฟ ํค๋ ์๋์ผ๋ก ๋ฐ๋ผ๊ฐ๋๋ค. ๊ทธ๋์ SSE ์ฐ๊ฒฐ๋ ํ์์ฒ๋ผ getCurrentUser()๋ก ์ธ์ฆ๋ผ์. 2๋จ๊ณ์์ ํ ํฐ์ ์ฟ ํค๋ก ์ฌ์ ์ ํ์ด ์ฌ๊ธฐ์ ๋น๋ฉ๋๋ค.
app/api/notifications/stream/route.ts
import { getCurrentUser } from "@/lib/auth"; import { registerClient } from "@/lib/notificationHub"; export const dynamic = "force-dynamic"; // SSE๋ ์บ์ ๊ธ์ง export async function GET(req: Request) { const user = await getCurrentUser(); // โ ์ฟ ํค๊ฐ ์๋์ผ๋ก ๋ฐ๋ผ์ ์ธ์ฆ๋จ if (!user) return new Response("Unauthorized", { status: 401 }); const encoder = new TextEncoder(); let closed = false; const stream = new ReadableStream({ start(controller) { function enqueue(frame: string) { if (closed) return; controller.enqueue(encoder.encode(frame)); } enqueue(`event: ready\ndata: "connected"\n\n`); // ์ฐ๊ฒฐ ์งํ 1ํ // ์ด ์ฌ์ฉ์์ ์ฐ๊ฒฐ์ ํ๋ธ์ ๋ฑ๋ก โ ์ด์ notifyUser๊ฐ ์ฌ๊ธฐ๋ก ๋ฐ์ด๋ฃ๋๋ค const unregister = registerClient({ userId: user.id, enqueue }); // ํํธ๋นํธ: 25์ด๋ง๋ค ์ฃผ์(:) โ ํ๋ก์๊ฐ ์กฐ์ฉํ ์ฐ๊ฒฐ์ ๋์ง ์๊ฒ const ping = setInterval(() => enqueue(`: ping\n\n`), 25000); function cleanup() { if (closed) return; closed = true; clearInterval(ping); unregister(); // โ ๋ช ๋จ์์ ๋นผ๊ธฐ (์ ๋นผ๋ฉด ์ข๋น) controller.close(); } req.signal.addEventListener("abort", cleanup); // ํญ ๋ซ๊ธฐ/๋ก๊ทธ์์ ์ }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", }, }); }
4-4. ๋๊ธ API โ ์ ์ฅํ๊ณ , ๊ธ์ด์ด์๊ฒ ํธ์
app/api/posts/[id]/comments/route.ts
import { NextResponse } from "next/server"; import { createComment, findPost, listComments } from "@/lib/db"; import { getCurrentUser } from "@/lib/auth"; import { notifyUser } from "@/lib/notificationHub"; import type { Notification } from "@/types"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; return NextResponse.json(listComments(id)); // ๋๊ธ ๋ชฉ๋ก โ ๋๊ตฌ๋ } export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: "๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค." }, { status: 401 }); const { id } = await params; const post = findPost(id); if (!post) return NextResponse.json({ error: "์๋ ๊ธ์ ๋๋ค." }, { status: 404 }); const { content } = await req.json(); if (!content?.trim()) return NextResponse.json({ error: "๋๊ธ ๋ด์ฉ์ ์ ๋ ฅํ์ธ์." }, { status: 400 }); const comment = createComment(id, content.trim(), user); // โ โ SSE ํธ์: ๋ด๊ฐ ๋ด ๊ธ์ ๋จ ๊ฒ ์๋๋ผ๋ฉด ๊ธ์ด์ด์๊ฒ ์๋ฆผ โ โ if (post.authorId !== user.id) { const noti: Notification = { id: comment.id, postId: post.id, postTitle: post.title, commenterName: user.username, createdAt: comment.createdAt, }; notifyUser(post.authorId, "comment", noti); } return NextResponse.json(comment, { status: 201 }); }
(๋๊ธ ์ ์ฅ ํฌํผ createComment/listComments๋ lib/db.ts์ createPost์ ๊ฐ์ ๋ชจ์์ผ๋ก ์ถ๊ฐํฉ๋๋ค.)
4-5. ํด๋ผ์ด์ธํธ โ ๋ก๊ทธ์ธ ์ค์๋ง SSE๋ฅผ ์ฐ๋ค
์๋ฆผ ์ฐ๊ฒฐ์ ๋ก๊ทธ์ธํ ๋์์๋ง ์ด์ด์ผ ํฉ๋๋ค. ๋น๋ก๊ทธ์ธ์ด๋ฉด ๋ฐ์ ๊ฒ ์๊ณ , ๋ก๊ทธ์์ํ๋ฉด ๋ซ์์ผ ํ์ฃ . ๊ทธ๋์ user๋ฅผ ๊ตฌ๋
ํ๋ ๋ณ๋ Context๋ฅผ ๋ก๋๋ค.
contexts/NotificationContext.tsx
"use client"; import { createContext, useContext, useEffect, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import type { Notification } from "@/types"; type NotificationValue = { notifications: Notification[]; unread: number; markAllRead: () => void; }; const NotificationContext = createContext<NotificationValue | null>(null); export function NotificationProvider({ children }: { children: React.ReactNode }) { const { user } = useAuth(); const [notifications, setNotifications] = useState<Notification[]>([]); const [unread, setUnread] = useState(0); // โ ๋ก๊ทธ์ธํ ๋์๋ง ์ฐ๊ฒฐ. user๊ฐ ๋ฐ๋๋ฉด effect๊ฐ ๋ค์ ๋๋ฉฐ ์ฐ๊ฒฐ/์ ๋ฆฌ. useEffect(() => { if (!user) { setNotifications([]); setUnread(0); return; } // ์ฟ ํค๊ฐ ์๋ ์ ์ก๋๋ฏ๋ก ์ธ์ฆ์ ๋ฐ๋ก ์ ๋ถ์ฌ๋ ๋๋ค const es = new EventSource("/api/notifications/stream"); es.addEventListener("comment", (e) => { const noti = JSON.parse(e.data) as Notification; setNotifications((prev) => [noti, ...prev]); // ์ต์ ์ด ์๋ก setUnread((n) => n + 1); }); es.onerror = () => console.log("์๋ฆผ ์ฐ๊ฒฐ ๋๊น/์ฌ์ฐ๊ฒฐ ์ค"); // ์๋ ์ฌ์ฐ๊ฒฐ์ ๋ธ๋ผ์ฐ์ ๊ฐ return () => es.close(); // ๋ก๊ทธ์์ยท์ธ๋ง์ดํธ ์ ๋ซ๊ธฐ }, [user]); return ( <NotificationContext.Provider value={{ notifications, unread, markAllRead: () => setUnread(0) }}> {children} </NotificationContext.Provider> ); } export function useNotifications() { const ctx = useContext(NotificationContext); if (ctx === null) throw new Error("useNotifications๋ <NotificationProvider> ์์์๋ง."); return ctx; }
Provider ์์๊ฐ ์ค์ โ NotificationProvider๊ฐ useAuth()๋ก user๋ฅผ ๊ตฌ๋
ํ๋, <AuthProvider> ์์ชฝ์ ๋ก๋๋ค. 2๋จ๊ณ์ app/layout.tsx์ NotificationProvider๋ฅผ ๋ํด ์ต์ข
ํ์ผ๋ก:
app/layout.tsx โ ์ต์ข
import type { Metadata } from "next"; import "./globals.css"; import { AuthProvider } from "@/contexts/AuthContext"; import { NotificationProvider } from "@/contexts/NotificationContext"; import Header from "@/components/Header"; export const metadata: Metadata = { title: "๐ ์๋ฆผ ๊ฒ์ํ", }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ko"> <body> <AuthProvider> <NotificationProvider> <Header /> <div className="mx-auto max-w-2xl p-4">{children}</div> </NotificationProvider> </AuthProvider> </body> </html> ); }
4-6. ํค๋์ ๐ ๋ฐฐ์ง
components/NotificationBell.tsx
"use client"; import { useState } from "react"; import Link from "next/link"; import { useNotifications } from "@/contexts/NotificationContext"; export default function NotificationBell() { const { notifications, unread, markAllRead } = useNotifications(); const [open, setOpen] = useState(false); return ( <div className="relative"> <button onClick={() => { setOpen(v => !v); if (!open) markAllRead(); }} className="relative text-xl"> ๐ {unread > 0 && ( <span className="absolute -right-1 -top-1 rounded-full bg-red-500 px-1.5 text-xs text-white"> {unread} </span> )} </button> {open && ( <div className="absolute right-0 mt-2 w-72 rounded border bg-white shadow-lg"> {notifications.length === 0 ? <p className="p-3 text-sm text-gray-500">์์ง ์๋ฆผ์ด ์์ด์.</p> : notifications.map((n) => ( <Link key={n.id} href={`/posts/${n.postId}`} onClick={() => setOpen(false)} className="block border-b p-3 text-sm hover:bg-gray-50"> <b>{n.commenterName}</b> ๋์ด <b>{n.postTitle}</b> ์ ๋๊ธ์ ๋ฌ์์ด์. </Link> ))} </div> )} </div> ); }
4-7. ํค๋์ ๋ฒจ ๋ฌ๊ธฐ
3๋จ๊ณ์ Header v1์ import ํ ์ค๊ณผ <NotificationBell /> ํ ์ค๋ง ๋ํฉ๋๋ค(๋ก๊ทธ์ธ ์ค์ผ ๋๋ง ๋ณด์ด๊ฒ).
// components/Header.tsx โ v2 (๋ฒจ ์ถ๊ฐ) import NotificationBell from "./NotificationBell"; // โ ์ถ๊ฐ // ... {loading ? null : user ? ( <> <NotificationBell /> {/* โ ์ถ๊ฐ */} <Link href="/posts/new" className="rounded bg-blue-500 px-3 py-1 text-white">๊ธ์ฐ๊ธฐ</Link> <span className="text-gray-600">{user.username}</span> <button onClick={handleLogout} className="text-gray-500 hover:underline">๋ก๊ทธ์์</button> </> ) : ( /* โฆ๋ก๊ทธ์ธยทํ์๊ฐ์ ๋งํฌ๋ ๊ทธ๋๋กโฆ */ )}
4-8. ๊ธ ์์ธ + ๋๊ธ ํ๋ฉด
์๋ฆผ์ "๊ธ์ ๋๊ธ์ด ๋ฌ๋ฆฌ๋" ๋ฐ์ ๋์ต๋๋ค. ๊ทธ ๋ฌด๋์ธ ์์ธ ํ์ด์ง๋ฅผ ๋๊ธ๊ณผ ํจ๊ป ๋ง๋ญ๋๋ค(3๋จ๊ณ์์ ๋ฏธ๋ค ๋ ํ๋ฉด).
components/PostDetail.tsx
"use client"; import { useEffect, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import type { Post, Comment } from "@/types"; export default function PostDetail({ id }: { id: string }) { const { user } = useAuth(); const [post, setPost] = useState<Post | null>(null); const [comments, setComments] = useState<Comment[]>([]); const [text, setText] = useState(""); const [loading, setLoading] = useState(true); // ๊ธ + ๋๊ธ์ ํจ๊ป ๋ถ๋ฌ์จ๋ค useEffect(() => { let ignore = false; async function load() { const [postRes, commentsRes] = await Promise.all([ fetch(`/api/posts/${id}`), fetch(`/api/posts/${id}/comments`), ]); const postData = postRes.ok ? await postRes.json() : null; const commentsData = commentsRes.ok ? await commentsRes.json() : []; if (!ignore) { setPost(postData); setComments(commentsData); setLoading(false); } } load(); return () => { ignore = true; }; }, [id]); async function addComment(e: React.FormEvent) { e.preventDefault(); if (!text.trim()) return; const res = await fetch(`/api/posts/${id}/comments`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: text }), }); if (res.ok) { const created: Comment = await res.json(); setComments((prev) => [...prev, created]); setText(""); } } if (loading) return <p className="text-gray-500">๋ถ๋ฌ์ค๋ ์คโฆ</p>; if (!post) return <p className="text-red-600">์๋ ๊ธ์ ๋๋ค.</p>; return ( <article className="space-y-6"> <div className="space-y-2"> <h1 className="text-2xl font-bold">{post.title}</h1> <p className="text-sm text-gray-500">{post.authorName}</p> <p className="whitespace-pre-wrap">{post.content}</p> </div> <section className="space-y-3"> <h2 className="font-bold">๋๊ธ {comments.length}</h2> <ul className="space-y-2"> {comments.map((c) => ( <li key={c.id} className="rounded border p-3"> <p className="text-sm font-bold text-gray-700">{c.authorName}</p> <p>{c.content}</p> </li> ))} {comments.length === 0 && <li className="text-sm text-gray-500">์ฒซ ๋๊ธ์ ๋จ๊ฒจ ๋ณด์ธ์.</li>} </ul> {user ? ( <form onSubmit={addComment} className="flex gap-2"> <input className="flex-1 rounded border px-3 py-2" placeholder="๋๊ธ ๋ฌ๊ธฐ" value={text} onChange={(e) => setText(e.target.value)} /> <button className="rounded bg-blue-500 px-4 text-white">๋ฑ๋ก</button> </form> ) : ( <p className="text-sm text-gray-500">๋๊ธ์ ๋ฌ๋ ค๋ฉด ๋ก๊ทธ์ธํ์ธ์.</p> )} </section> </article> ); }
app/posts/[id]/page.tsx
์๋ฒ ์ปดํฌ๋ํธ๊ฐ URL์ id๋ฅผ ๊บผ๋ด ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ์ ๋๊น๋๋ค.
import PostDetail from "@/components/PostDetail"; export default async function PostPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; return <PostDetail id={id} />; }
ํ์ธ โ
โ ๋ธ๋ผ์ฐ์ ๋ ๊ฐ(์ผ๋ฐ + ์ํฌ๋ฆฟ)๋ก aliceยทbob ๋ก๊ทธ์ธ. bob์ด alice ๊ธ์ ๋๊ธ โ alice ํ๋ฉด ๐ ์ ์ฆ์ ๋นจ๊ฐ ๋ฐฐ์ง. ์๋ก๊ณ ์นจ ์ ํ๋๋ฐ๋์. alice๊ฐ ์๊ธฐ ๊ธ์ ๋จ ๋๊ธ์ ์๋ฆผ์ด ์ค์ง ์์ต๋๋ค(authorId !== user.id ์ฒดํฌ).
์ ์ฒด ํ์ธ โ curl๋ก ๋๊น์ง
๋ธ๋ผ์ฐ์ ์์ด๋ ์ ๊ณผ์ ์ ๊ฒ์ฆํ ์ ์์ต๋๋ค.
BASE=http://localhost:3700 # ๊ฐ์ + ๋ก๊ทธ์ธ(์ฟ ํค ์ ์ฅ) curl -s -X POST $BASE/api/auth/signup -H 'Content-Type: application/json' -d '{"username":"alice","password":"pass1234"}' curl -s -X POST $BASE/api/auth/signup -H 'Content-Type: application/json' -d '{"username":"bob","password":"pass1234"}' curl -s -c alice.cookie -X POST $BASE/api/auth/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"pass1234"}' curl -s -c bob.cookie -X POST $BASE/api/auth/login -H 'Content-Type: application/json' -d '{"username":"bob","password":"pass1234"}' # alice๊ฐ ๊ธ ์์ฑ POST=$(curl -s -b alice.cookie -X POST $BASE/api/posts -H 'Content-Type: application/json' -d '{"title":"์๋ ","content":"์ฒซ ๊ธ"}') ID=$(echo $POST | sed -E 's/.*"id":"([^"]+)".*/\1/') # (ํฐ๋ฏธ๋ 2) alice์ ์๋ฆผ ์คํธ๋ฆผ์ ์ฐ๋ค curl -N -b alice.cookie $BASE/api/notifications/stream # (ํฐ๋ฏธ๋ 1) bob์ด ๋๊ธ โ ์ ์คํธ๋ฆผ์ event: comment ๊ฐ ์ฆ์ ํ๋ฅธ๋ค curl -s -b bob.cookie -X POST $BASE/api/posts/$ID/comments -H 'Content-Type: application/json' -d '{"content":"์ข๋ค์!"}'
์คํธ๋ฆผ ์ถ๋ ฅ:
event: ready data: "connected" event: comment data: {"id":"โฆ","postId":"โฆ","postTitle":"์๋ ","commenterName":"bob","createdAt":โฆ}
ํ์ธ ํฌ์ธํธ โ โ โ alice.cookie ํ์ผ์ #HttpOnly_localhost โฆ token ์ผ๋ก ์ ํ ์์(httpOnly) โก ๋น๋ก๊ทธ์ธ /api/notifications/stream โ 401 โข alice๊ฐ ์๊ธฐ ๊ธ์ ๋๊ธ ๋ฌ๋ฉด ์คํธ๋ฆผ์ ์๋ฌด๊ฒ๋ ์ ์ด.
์ ๋ฆฌ โ "ํ์ํ ๋ ํ๋์ฉ" (์ธ์ฆยท์๋ฆผ ๋ฒ์ )
- 1๋จ๊ณ(๊ฐ์ ) ์ ์ฟ ํค๋ SSE๋ ์์์ต๋๋ค. bcrypt ํด์๋ง ์์ผ๋ฉด ๋์ฃ .
- 2๋จ๊ณ(๋ก๊ทธ์ธ) ์์ "๋ก๊ทธ์ธ ์ํ ์ ์ง" ๊ฐ ํ์ํด์ง์ ๊ทธ์ ์์ผ JWT์ httpOnly ์ฟ ํค๋ฅผ ๊บผ๋์ต๋๋ค.
- 3๋จ๊ณ(๊ธ์ฐ๊ธฐ) ๋ ๊ทธ ์ฟ ํค๋ฅผ
getCurrentUser๋ก ๊น์ ์ธ์ฆํ๋ ํ๋ฒํ ์์ฒญ์ด์๊ณ , - 4๋จ๊ณ(์๋ฆผ) ์์ "์๋ฒ๊ฐ ๋จผ์ ๋ฏธ๋" ์๊ตฌ๊ฐ ์๊ธด ๋ฐ๋ก ๊ทธ ์๊ฐ SSE๋ฅผ ๊บผ๋์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ ์จ์ ์ค๊ณ ํ๋ โ 2๋จ๊ณ์์ ํ ํฐ์ httpOnly ์ฟ ํค์ ๋ ์ ํ์ด, 4๋จ๊ณ์์ ํค๋๋ฅผ ๋ชป ๋ถ์ด๋ EventSource๋ฅผ ๊ณต์ง๋ก ์ธ์ฆํด ์คฌ์ต๋๋ค. ๋๊ตฌ๋ฅผ ํ์ํ ๋ ๊บผ๋ด๋, ์ ๋จ๊ณ์ ์ ํ์ด ๋ค ๋จ๊ณ์ ๋ง๋ฌผ๋ฆฌ๋๋ก.
ํจ๊ป ์ด ๊ฒ๋ค โ bcrypt ๋น๋ฐ๋ฒํธ ํด์, jose JWT, httpOnly ์ฟ ํค ์ธ์ฆ, getCurrentUser๋ก ๋ณดํธ๋ API, ๋ ๊ฐ์ Context(์ธ์ฆยท์๋ฆผ), ์ธ๋ฉ๋ชจ๋ฆฌ pub/sub ํ๋ธ, ์ฌ์ฉ์๋ณ ํ๊น SSE ํธ์, EventSource + ์ฟ ํค ์๋ ์ธ์ฆ.
SSE๋ฅผ ํ ์ค๋ก โ ์ด๋ฒ ํ๋ก์ ํธ์ ๊ตํ
์๋ฆผ์ฒ๋ผ "๋จ์ ํ๋์ด ๋ด ํ๋ฉด์ ์ค์๊ฐ์ผ๋ก" ๋ ์ผ ํ๋ฉด SSE. ์๋ฒ๊ฐ notifyUser๋ก ๋ฐ๊ณ , ํด๋ผ์ด์ธํธ๋ EventSource๋ก ๋ฃ๊ธฐ๋ง ํ๋ฉด ๋ฉ๋๋ค. ํด๋ผ์ด์ธํธ๋ ์ค์๊ฐ์ผ๋ก ์๋ฒ์ ์ด์ผ ํ๋ฉด(์ฑํ
) ๊ทธ๋ WebSocket์ผ๋ก.
๋ณด๋์ค / ์ฐ์ต ๊ฑฐ๋ฆฌ
- ์ฝ์ ์ํ ์์ โ ์ง๊ธ
unread๋ ์๋ก๊ณ ์นจํ๋ฉด 0. ์๋ฆผ์ ์๋ฒ์ ์ ์ฅํ๊ณ/api/notifications๋ก ๊ณผ๊ฑฐ ์๋ฆผ๊น์ง ๋ถ๋ฌ์ค๊ธฐ. - "์ข์์" ์๋ฆผ โ ๋๊ธ ๋ง๊ณ ์ข์์์๋
notifyUser. ์ด๋ฒคํธ ํ์like์ถ๊ฐ. - ํ ์คํธ โ ๐ ๋ฐฐ์ง๋ฟ ์๋๋ผ, ์๋ฆผ ๋์ฐฉ ์ ํ๋ฉด ๋ชจ์๋ฆฌ์ ์ ๊น ๋จ๋ ํ ์คํธ.
- ์ฐ๊ฒฐ ํ์ โ ์๋ฆผ ์คํธ๋ฆผ์
ready/onerror๋ก "๐ข ์ค์๊ฐ ์ฐ๊ฒฐ๋จ" ๋ฐฐ์ง (๋ก์ผ ํํ ๋ฆฌ์ผ์ ๊ทธ ํจํด). - ์ง์ง DB โ
lib/db.ts๋ง SQLite/Postgres๋ก ๊ต์ฒด. ๋๋จธ์ง ์ฝ๋๋ ๊ทธ๋๋ก โ ๊ทธ๊ฒdb.ts๋ก ๊ฒฉ๋ฆฌํด ๋ ์ด์ .
๋๊ธ
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ์ด ํ์ํฉ๋๋ค.