📋 알림 게시판 (JWT 인증 + SSE 실시간 알림)

튜토리얼 — 📋 알림 게시판 (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로 격리해 둔 이유.

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