실시간 채팅 처음부터 끝까지

튜토리얼 — 실시간 채팅 처음부터 끝까지
이 문서 하나로 개념부터 완성·테스트까지 끝냅니다. 위에서 아래로 순서대로 따라오면서, 나오는 코드 블록을 전부 그대로 입력하세요. 끝에는 회원가입·로그인·실시간 채팅이 동작하고 E2E 테스트까지 통과하는 앱이 나옵니다.
- 기술: Next.js 16 (App Router) · React 19 · TypeScript · Tailwind
- DB 없음 — 회원·방·메시지 전부 메모리 (서버 끄면 사라짐)
- 인증 — JWT를 httpOnly 쿠키로 · 실시간 수신 — SSE · 전송 — POST
- Redis 등 외부 도구 없음
가정: React 컴포넌트,
useState,useEffect, 폼 정도는 안다. 서버·실시간이 처음인 사람을 위한 글.
진행 방식: 사람이 개발하듯 필요한 것을 그때그때 하나씩 만듭니다. 유틸도 처음부터 다 만들지 않고 쓸 때 함수를 추가합니다.
1부 — 개념 (SSE부터 이해하기)
1-1. 네가 아는 통신 — 물어보면 답한다
fetch 는 전부 이 모양입니다.
브라우저 ──"데이터 줘"──▶ 서버 브라우저 ◀──"여기"(끝)── 서버 → 연결 종료
클라이언트가 물어봐야 서버가 답하고, 답하면 끝납니다.
1-2. 실시간의 문제
채팅은 다른 사람이 메시지를 보냅니다. 내 브라우저는 그걸 언제 알까요? fetch 로는 내가 물어봐야만 압니다(폴링). 폴링은 느리고(지연) 낭비입니다(빈 응답 반복).
해법은 발상의 전환 — 서버가 새 일이 생긴 순간 먼저 밀어 주기. 그 방법 중 하나가 SSE 입니다.
1-3. 폴링 / SSE / WebSocket
| 폴링 | SSE | WebSocket | |
|---|---|---|---|
| 방향 | 클라→서버 반복 | 서버→클라 단방향 | 양방향 |
| 브라우저 API | fetch+setInterval | EventSource(내장) | WebSocket |
| 자동 재연결 | 직접 | 자동 | 직접 |
| 적합 | 가끔 갱신 | 알림·채팅 수신 | 게임·양방향 |
우리 채팅은 받기만 실시간이면 됩니다(보내기는 POST면 충분). 그래서 단방향 SSE 가 가장 단순하고 딱 맞습니다.
1-4. SSE 한 줄 정의
SSE = 서버가 응답을 "끝내지 않고" 열어 둔 채, 새 데이터가 생길 때마다 그 통로로 한 줄씩 흘려보내는 것.
1-5. SSE 전송 포맷 (wire format)
스트림에 아무거나 넣는 게 아니라 약속된 형식으로 넣어야 브라우저가 알아듣습니다.
event: message\n data: {"text":"안녕"}\n \n ← 빈 줄로 한 메시지 끝
event: 이름— 메시지 종류. 받는 쪽은addEventListener("이름", ...)로 골라 들음. (생략하면 기본message)data: 내용— 본문. 객체는 JSON 문자열로.- 끝에 빈 줄(
\n\n) 이 구분자.
1-6. 받는 쪽 — EventSource
const es = new EventSource("/api/something"); es.addEventListener("message", (e) => console.log(JSON.parse(e.data))); es.close(); // 끊기
EventSource 는 연결을 열어 두고 서버 푸시를 기다리며, 끊기면 자동 재연결합니다.
중요 두 가지: ① GET만 가능 ② 커스텀 헤더 못 붙임(Authorization 불가) — 대신 쿠키는 자동 전송. → 그래서 인증을 쿠키로 합니다(3부).
1-7. 연결 끊김을 서버가 아는 법 — req.signal
Route Handler 의 Request.signal(AbortSignal)은 클라이언트가 연결을 끊으면 abort 이벤트를 냅니다. 채팅에선 이 abort 가 "퇴장" 신호입니다. 즉 SSE 연결이 살아 있으면 방에 있음, 끊기면 나감 — SSE 연결 자체가 출석부입니다.
1-8. 브로드캐스트
서버는 각 접속자의 "데이터 밀어넣는 함수(send)" 를 방에 모아 둡니다. 누가 메시지를 보내면 그 방의 모든 send 를 호출 → 전원에게 동시 도착. 이게 실시간 채팅의 동작 전부입니다.
A가 POST로 "안녕" ──▶ 서버: broadcast(방, "message", {...}) ├─▶ A의 SSE ─▶ A 화면 ├─▶ B의 SSE ─▶ B 화면 └─▶ C의 SSE ─▶ C 화면
이제 이 개념들을 실제로 만듭니다.
2부 — 프로젝트 준비
npx create-next-app@latest chat-app --ts --tailwind --eslint --app --no-src-dir --import-alias "@/*" cd chat-app npm install jose bcryptjs npm install -D @playwright/test @types/bcryptjs
next.config.ts 를 아래로 바꿉니다. (이유는 6부 SSE에서 자세히 — 미리 꺼 둡니다.)
// next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { // SSE 를 쓰므로 Strict Mode 를 끈다 (개발모드 useEffect 이중 실행이 SSE 를 // 연결→해제→재연결 시켜 방장 방을 즉시 폭파시키는 문제 방지 — 6부에서 설명). reactStrictMode: false, }; export default nextConfig;
3부 — Phase 1: 인증
3-1. 회원 저장소 (메모리) — lib/userStore.ts
DB가 없으니 Map 에 담습니다. globalThis 에 붙여 개발모드 핫리로드에도 유지합니다.
// lib/userStore.ts export type User = { id: string; passwordHash: string; name: string; // 실명 nickname: string; // 채팅 표시 이름 email: string; createdAt: number; }; const g = globalThis as unknown as { __users?: Map<string, User> }; const users = g.__users ?? (g.__users = new Map<string, User>()); export function createUser(u: User) { users.set(u.id, u); } export function getUser(id: string) { return users.get(id); } export function hasUser(id: string) { return users.has(id); } export function updateUser(id: string, patch: Partial<User>) { const u = users.get(id); if (!u) return null; const next = { ...u, ...patch }; users.set(id, next); return next; }
3-2. 인증 유틸 — 필요한 함수부터 하나씩 (lib/auth.ts)
처음엔 회원가입에 필요한 hashPassword 만 만듭니다. 상수와 함께 시작합니다.
// lib/auth.ts (1차 — 회원가입용) import bcrypt from "bcryptjs"; import { SignJWT, jwtVerify } from "jose"; import { cookies } from "next/headers"; const SECRET = new TextEncoder().encode( process.env.JWT_SECRET || "dev-secret-change-me-please", ); export const COOKIE_NAME = "session"; export const COOKIE_OPTIONS = { httpOnly: true, // JS 가 못 읽음 (XSS 방어) sameSite: "lax" as const, path: "/", maxAge: 60 * 60 * 24 * 7, // 7일 }; export type SessionPayload = { userId: string; nickname: string }; // 비밀번호 해시 (가입 때) export async function hashPassword(pw: string) { return bcrypt.hash(pw, 10); }
3-3. 회원가입 API — app/api/auth/signup/route.ts
방금 만든 hashPassword 를 바로 씁니다.
// app/api/auth/signup/route.ts import { NextResponse } from "next/server"; import { createUser, hasUser } from "@/lib/userStore"; import { hashPassword } from "@/lib/auth"; export async function POST(req: Request) { const { id, password, name, nickname, email } = await req.json(); if (!id || !password || !name || !nickname || !email) { return NextResponse.json({ error: "모든 항목을 입력하세요." }, { status: 400 }); } if (hasUser(id)) { return NextResponse.json({ error: "이미 존재하는 아이디입니다." }, { status: 409 }); } const passwordHash = await hashPassword(password); createUser({ id, passwordHash, name, nickname, email, createdAt: Date.now() }); return NextResponse.json({ ok: true }); }
폴더가 곧 URL:
app/api/auth/signup/route.ts→POST /api/auth/signup.
3-4. 로그인에 필요한 함수 추가 (lib/auth.ts 에 이어서)
이제 로그인을 만들 차례 → verifyPassword, signToken 이 필요합니다. lib/auth.ts 맨 아래에 추가하세요.
// lib/auth.ts (2차 — 로그인용 함수 추가) // 비밀번호 비교 (로그인 때) export async function verifyPassword(pw: string, hash: string) { return bcrypt.compare(pw, hash); } // JWT 발급 export async function signToken(payload: SessionPayload) { return new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("7d") .sign(SECRET); }
3-5. 로그인 API — app/api/auth/login/route.ts
검증 후 JWT를 httpOnly 쿠키로 굽습니다. 쿠키는 응답 객체(res.cookies.set) 에 set 합니다.
// app/api/auth/login/route.ts import { NextResponse } from "next/server"; import { getUser } from "@/lib/userStore"; import { verifyPassword, signToken, COOKIE_NAME, COOKIE_OPTIONS } from "@/lib/auth"; export async function POST(req: Request) { const { id, password } = await req.json(); const user = getUser(id); if (!user || !(await verifyPassword(password, user.passwordHash))) { return NextResponse.json( { error: "아이디 또는 비밀번호가 올바르지 않습니다." }, { status: 401 }, ); } const token = await signToken({ userId: user.id, nickname: user.nickname }); const res = NextResponse.json({ ok: true, user: { id: user.id, nickname: user.nickname }, }); res.cookies.set(COOKIE_NAME, token, COOKIE_OPTIONS); return res; }
3-6. 로그아웃 API — app/api/auth/logout/route.ts
쿠키를 빈 값 + 만료로 덮어 지웁니다.
// app/api/auth/logout/route.ts import { NextResponse } from "next/server"; import { COOKIE_NAME, COOKIE_OPTIONS } from "@/lib/auth"; export async function POST() { const res = NextResponse.json({ ok: true }); res.cookies.set(COOKIE_NAME, "", { ...COOKIE_OPTIONS, maxAge: 0 }); return res; }
3-7. 세션 읽기 함수 추가 (lib/auth.ts 에 이어서)
"현재 로그인한 사람이 누구인지" 를 쿠키에서 읽어야 합니다 → verifyToken, getSession 추가.
// lib/auth.ts (3차 — 세션 읽기 추가) export async function verifyToken(token: string): Promise<SessionPayload | null> { try { const { payload } = await jwtVerify(token, SECRET); return { userId: payload.userId as string, nickname: payload.nickname as string }; } catch { return null; } } export async function getSession(): Promise<SessionPayload | null> { const token = (await cookies()).get(COOKIE_NAME)?.value; // ★ cookies() 는 비동기 if (!token) return null; return verifyToken(token); }
Next 16 주의:
cookies()는 비동기 →await cookies(). 막히면node_modules/next/dist/docs/01-app/.../15-route-handlers.md참고.
3-8. 내 정보 API — app/api/auth/me/route.ts
// app/api/auth/me/route.ts import { NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; import { getUser } from "@/lib/userStore"; export async function GET() { const session = await getSession(); if (!session) return NextResponse.json({ user: null }); const u = getUser(session.userId); if (!u) return NextResponse.json({ user: null }); // 본인 정보라 실명·이메일 포함 (마이페이지 프리필용) return NextResponse.json({ user: { id: u.id, nickname: u.nickname, name: u.name, email: u.email }, }); }
3-9. 로그인 사용자 공유 — contexts/AuthContext.tsx
앱 어디서나 "현재 사용자" 를 알 수 있게 Context 로 공유합니다(마운트 시 /api/auth/me 로드).
// contexts/AuthContext.tsx "use client"; import { createContext, useContext, useEffect, useState } from "react"; export type CurrentUser = { id: string; nickname: string; name: string; email: string; } | null; type AuthValue = { user: CurrentUser; loading: boolean; setUser: (u: CurrentUser) => void; refresh: () => Promise<void>; }; const AuthContext = createContext<AuthValue | null>(null); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<CurrentUser>(null); const [loading, setLoading] = useState(true); async function refresh() { const res = await fetch("/api/auth/me"); const data = await res.json(); setUser(data.user); setLoading(false); } useEffect(() => { refresh(); }, []); return ( <AuthContext.Provider value={{ user, loading, setUser, refresh }}> {children} </AuthContext.Provider> ); } export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error("useAuth는 <AuthProvider> 안에서만 사용할 수 있습니다."); return ctx; }
3-10. 헤더 — components/Header.tsx
// components/Header.tsx "use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; export default function Header() { const { user, setUser } = useAuth(); const router = useRouter(); async function logout() { await fetch("/api/auth/logout", { method: "POST" }); setUser(null); router.push("/login"); } return ( <header className="flex items-center justify-between border-b p-4"> <Link href="/" className="text-xl font-bold">💬 채팅</Link> <nav className="flex items-center gap-3 text-sm"> {user ? ( <> <span data-testid="current-nickname" className="font-semibold"> {user.nickname} </span> <Link href="/mypage">마이페이지</Link> <button data-testid="logout-btn" onClick={logout} className="rounded bg-gray-200 px-2 py-1"> 로그아웃 </button> </> ) : ( <> <Link href="/login">로그인</Link> <Link href="/signup">회원가입</Link> </> )} </nav> </header> ); }
3-11. 레이아웃 — app/layout.tsx
생성된 기본 layout.tsx 를 아래로 교체합니다(Provider + Header 로 감싸기).
// app/layout.tsx import type { Metadata } from "next"; import "./globals.css"; import { AuthProvider } from "@/contexts/AuthContext"; import Header from "@/components/Header"; export const metadata: Metadata = { title: "실시간 채팅", description: "Next.js + SSE chat", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( <html lang="ko" className="h-full antialiased"> <body className="flex min-h-full flex-col"> <AuthProvider> <Header /> <main className="mx-auto w-full max-w-2xl p-4">{children}</main> </AuthProvider> </body> </html> ); }
3-12. 회원가입 페이지 — app/signup/page.tsx
// app/signup/page.tsx "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; const INITIAL = { id: "", password: "", name: "", nickname: "", email: "" }; export default function SignupPage() { const router = useRouter(); const [form, setForm] = useState(INITIAL); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); function change(e: React.ChangeEvent<HTMLInputElement>) { setForm({ ...form, [e.target.name]: e.target.value }); } async function submit(e: React.FormEvent) { e.preventDefault(); setError(""); setSubmitting(true); const res = await fetch("/api/auth/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form), }); setSubmitting(false); if (!res.ok) { const d = await res.json(); setError(d.error ?? "회원가입에 실패했습니다."); return; } router.push("/login"); } const fields: { name: keyof typeof INITIAL; label: string; type: string }[] = [ { name: "id", label: "아이디", type: "text" }, { name: "password", label: "비밀번호", type: "password" }, { name: "name", label: "이름(실명)", type: "text" }, { name: "nickname", label: "닉네임", type: "text" }, { name: "email", label: "이메일", type: "email" }, ]; return ( <div className="space-y-4"> <h1 className="text-2xl font-bold">회원가입</h1> <form onSubmit={submit} className="space-y-3"> {fields.map((f) => ( <div key={f.name}> <label className="block text-sm">{f.label}</label> <input name={f.name} type={f.type} data-testid={`signup-${f.name}`} className="block w-full rounded border px-2 py-1" value={form[f.name]} onChange={change} /> </div> ))} {error && <p data-testid="signup-error" className="text-red-600">{error}</p>} <button type="submit" disabled={submitting} data-testid="signup-submit" className="rounded bg-blue-500 px-4 py-2 text-white disabled:opacity-50"> {submitting ? "처리 중…" : "가입하기"} </button> </form> </div> ); }
3-13. 로그인 페이지 — app/login/page.tsx
// app/login/page.tsx "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; export default function LoginPage() { const router = useRouter(); const { refresh } = useAuth(); const [form, setForm] = useState({ id: "", password: "" }); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); function change(e: React.ChangeEvent<HTMLInputElement>) { setForm({ ...form, [e.target.name]: e.target.value }); } async function submit(e: React.FormEvent) { e.preventDefault(); setError(""); setSubmitting(true); const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form), }); setSubmitting(false); if (!res.ok) { const d = await res.json(); setError(d.error ?? "로그인에 실패했습니다."); return; } await refresh(); // AuthContext 갱신 router.push("/"); } return ( <div className="space-y-4"> <h1 className="text-2xl font-bold">로그인</h1> <form onSubmit={submit} className="space-y-3"> <div> <label className="block text-sm">아이디</label> <input name="id" data-testid="login-id" className="block w-full rounded border px-2 py-1" value={form.id} onChange={change} /> </div> <div> <label className="block text-sm">비밀번호</label> <input name="password" type="password" data-testid="login-password" className="block w-full rounded border px-2 py-1" value={form.password} onChange={change} /> </div> {error && <p data-testid="login-error" className="text-red-600">{error}</p>} <button type="submit" disabled={submitting} data-testid="login-submit" className="rounded bg-blue-500 px-4 py-2 text-white disabled:opacity-50"> {submitting ? "처리 중…" : "로그인"} </button> </form> </div> ); }
확인 ✅ — npm run dev → /signup 가입 → /login 로그인 → 헤더에 닉네임 표시 → 새로고침해도 유지(쿠키) → 로그아웃. (/ 홈은 아직 안 만들었으니 에러나 빈 화면이 정상 — 5부에서 만듭니다.)
왜 localStorage 가 아니라 쿠키? ① httpOnly 쿠키는 JS로 못 읽어 XSS 안전 ② 6부의 SSE
EventSource는 헤더를 못 붙이는데 쿠키는 자동 전송됩니다.
4부 — Phase 2: 마이페이지
4-1. 회원정보 수정 API — app/api/users/me/route.ts
// app/api/users/me/route.ts import { NextResponse } from "next/server"; import { getSession, signToken, COOKIE_NAME, COOKIE_OPTIONS } from "@/lib/auth"; import { updateUser } from "@/lib/userStore"; export async function PUT(req: Request) { const session = await getSession(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { name, nickname, email } = await req.json(); if (!name || !nickname || !email) { return NextResponse.json({ error: "모든 항목을 입력하세요." }, { status: 400 }); } const updated = updateUser(session.userId, { name, nickname, email }); if (!updated) return NextResponse.json({ error: "사용자 없음" }, { status: 404 }); // 닉네임이 바뀌면 토큰의 nickname 도 갱신해야 채팅 표시가 따라온다 const token = await signToken({ userId: updated.id, nickname: updated.nickname }); const res = NextResponse.json({ ok: true, user: { id: updated.id, nickname: updated.nickname, name: updated.name, email: updated.email }, }); res.cookies.set(COOKIE_NAME, token, COOKIE_OPTIONS); return res; }
4-2. 비밀번호 변경 API — app/api/users/me/password/route.ts
// app/api/users/me/password/route.ts import { NextResponse } from "next/server"; import { getSession, verifyPassword, hashPassword } from "@/lib/auth"; import { getUser, updateUser } from "@/lib/userStore"; export async function PUT(req: Request) { const session = await getSession(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { currentPassword, newPassword } = await req.json(); const u = getUser(session.userId); if (!u) return NextResponse.json({ error: "사용자 없음" }, { status: 404 }); if (!(await verifyPassword(currentPassword, u.passwordHash))) { return NextResponse.json({ error: "현재 비밀번호가 올바르지 않습니다." }, { status: 400 }); } if (!newPassword || String(newPassword).length < 4) { return NextResponse.json({ error: "새 비밀번호는 4자 이상이어야 합니다." }, { status: 400 }); } updateUser(session.userId, { passwordHash: await hashPassword(newPassword) }); return NextResponse.json({ ok: true }); }
4-3. 마이페이지 — app/mypage/page.tsx
// app/mypage/page.tsx "use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; export default function MyPage() { const router = useRouter(); const { user, loading, refresh } = useAuth(); const [profile, setProfile] = useState({ name: "", nickname: "", email: "" }); const [pw, setPw] = useState({ currentPassword: "", newPassword: "" }); const [profileMsg, setProfileMsg] = useState(""); const [pwMsg, setPwMsg] = useState(""); useEffect(() => { if (loading) return; if (!user) { router.push("/login"); return; } setProfile({ name: user.name, nickname: user.nickname, email: user.email }); }, [user, loading, router]); async function saveProfile(e: React.FormEvent) { e.preventDefault(); setProfileMsg(""); const res = await fetch("/api/users/me", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(profile), }); const d = await res.json(); if (!res.ok) { setProfileMsg(d.error ?? "수정 실패"); return; } await refresh(); // 헤더 닉네임 갱신 setProfileMsg("저장되었습니다."); } async function changePassword(e: React.FormEvent) { e.preventDefault(); setPwMsg(""); const res = await fetch("/api/users/me/password", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(pw), }); const d = await res.json(); if (!res.ok) { setPwMsg(d.error ?? "변경 실패"); return; } setPw({ currentPassword: "", newPassword: "" }); setPwMsg("비밀번호가 변경되었습니다."); } if (loading || !user) return <p>불러오는 중…</p>; return ( <div className="space-y-8"> <h1 className="text-2xl font-bold">마이페이지</h1> <form onSubmit={saveProfile} className="space-y-3"> <h2 className="font-semibold">회원정보 수정</h2> <div> <label className="block text-sm">이름(실명)</label> <input data-testid="mypage-name" className="block w-full rounded border px-2 py-1" value={profile.name} onChange={(e) => setProfile({ ...profile, name: e.target.value })} /> </div> <div> <label className="block text-sm">닉네임</label> <input data-testid="mypage-nickname" className="block w-full rounded border px-2 py-1" value={profile.nickname} onChange={(e) => setProfile({ ...profile, nickname: e.target.value })} /> </div> <div> <label className="block text-sm">이메일</label> <input data-testid="mypage-email" className="block w-full rounded border px-2 py-1" value={profile.email} onChange={(e) => setProfile({ ...profile, email: e.target.value })} /> </div> {profileMsg && <p data-testid="profile-msg" className="text-sm text-green-700">{profileMsg}</p>} <button data-testid="save-profile" className="rounded bg-blue-500 px-4 py-2 text-white">정보 저장</button> </form> <form onSubmit={changePassword} className="space-y-3 border-t pt-6"> <h2 className="font-semibold">비밀번호 변경</h2> <div> <label className="block text-sm">현재 비밀번호</label> <input type="password" data-testid="current-password" className="block w-full rounded border px-2 py-1" value={pw.currentPassword} onChange={(e) => setPw({ ...pw, currentPassword: e.target.value })} /> </div> <div> <label className="block text-sm">새 비밀번호</label> <input type="password" data-testid="new-password" className="block w-full rounded border px-2 py-1" value={pw.newPassword} onChange={(e) => setPw({ ...pw, newPassword: e.target.value })} /> </div> {pwMsg && <p data-testid="pw-msg" className="text-sm text-green-700">{pwMsg}</p>} <button data-testid="change-password" className="rounded bg-blue-500 px-4 py-2 text-white">비밀번호 변경</button> </form> </div> ); }
확인 ✅ — 닉네임 바꾸면 헤더 즉시 반영, 새 비번으로 재로그인 가능.
5부 — Phase 3: 대화방 목록·생성 (아직 SSE 없이)
5-1. 방 레지스트리 (메모리) — lib/roomRegistry.ts
지금은 생성·목록·조회·삭제 + 타입 까지만. broadcast 는 6부에서 추가합니다.
// lib/roomRegistry.ts import { randomUUID } from "crypto"; export type Member = { userId: string; nickname: string; send: (event: string, data: unknown) => void; // 이 멤버의 SSE 로 밀어넣는 함수 }; export type Room = { id: string; title: string; ownerId: string; maxCapacity: number; members: Map<string, Member>; // userId → Member (현재 방에 있는 사람들) createdAt: number; }; const g = globalThis as unknown as { __rooms?: Map<string, Room> }; const rooms = g.__rooms ?? (g.__rooms = new Map<string, Room>()); export function createRoom(title: string, maxCapacity: number, ownerId: string) { const id = randomUUID(); rooms.set(id, { id, title, ownerId, maxCapacity, members: new Map(), createdAt: Date.now() }); return id; } export function getRoom(id: string) { return rooms.get(id); } export function deleteRoom(id: string) { rooms.delete(id); } export function listRooms() { return [...rooms.values()].map((r) => ({ id: r.id, title: r.title, count: r.members.size, max: r.maxCapacity, ownerId: r.ownerId, })); }
5-2. 방 목록/생성 API — app/api/rooms/route.ts
// app/api/rooms/route.ts import { NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; import { createRoom, listRooms } from "@/lib/roomRegistry"; export async function GET() { const session = await getSession(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ rooms: listRooms() }); } export async function POST(req: Request) { const session = await getSession(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { title, maxCapacity } = await req.json(); if (!title?.trim()) return NextResponse.json({ error: "방 제목을 입력하세요." }, { status: 400 }); const cap = Number(maxCapacity); if (!Number.isInteger(cap) || cap < 2 || cap > 50) { return NextResponse.json({ error: "최대 인원은 2~50 사이여야 합니다." }, { status: 400 }); } const roomId = createRoom(title.trim(), cap, session.userId); return NextResponse.json({ ok: true, roomId }); }
5-3. 홈 화면 — app/page.tsx
생성된 기본 page.tsx 를 아래로 교체합니다.
// app/page.tsx "use client"; import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; type RoomSummary = { id: string; title: string; count: number; max: number; ownerId: string }; export default function HomePage() { const router = useRouter(); const { user, loading } = useAuth(); const [rooms, setRooms] = useState<RoomSummary[]>([]); const [title, setTitle] = useState(""); const [maxCapacity, setMaxCapacity] = useState(4); const [error, setError] = useState(""); const loadRooms = useCallback(async () => { const res = await fetch("/api/rooms"); if (res.ok) { const d = await res.json(); setRooms(d.rooms); } }, []); useEffect(() => { if (loading) return; if (!user) { router.push("/login"); return; } loadRooms(); }, [user, loading, router, loadRooms]); async function createRoom(e: React.FormEvent) { e.preventDefault(); setError(""); const res = await fetch("/api/rooms", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, maxCapacity }), }); const d = await res.json(); if (!res.ok) { setError(d.error ?? "방 생성 실패"); return; } router.push(`/rooms/${d.roomId}`); // 만들고 바로 입장 } if (loading || !user) return <p>불러오는 중…</p>; return ( <div className="space-y-6"> <form onSubmit={createRoom} className="space-y-2 rounded border p-4"> <h2 className="font-semibold">새 대화방 만들기</h2> <input data-testid="room-title" className="block w-full rounded border px-2 py-1" placeholder="방 제목" value={title} onChange={(e) => setTitle(e.target.value)} /> <label className="block text-sm"> 최대 인원:{" "} <input data-testid="room-capacity" type="number" min={2} max={50} className="w-20 rounded border px-2 py-1" value={maxCapacity} onChange={(e) => setMaxCapacity(Number(e.target.value))} /> </label> {error && <p className="text-sm text-red-600">{error}</p>} <button data-testid="create-room" className="rounded bg-blue-500 px-4 py-2 text-white">만들기</button> </form> <div className="space-y-2"> <div className="flex items-center justify-between"> <h2 className="font-semibold">대화방 목록</h2> <button data-testid="refresh-rooms" onClick={loadRooms} className="text-sm text-blue-600">새로고침</button> </div> {rooms.length === 0 ? ( <p data-testid="no-rooms" className="text-gray-500">아직 대화방이 없어요. 위에서 만들어 보세요.</p> ) : ( <ul data-testid="room-list" className="space-y-2"> {rooms.map((r) => ( <li key={r.id} data-testid={`room-${r.id}`} className="flex items-center justify-between rounded border p-3"> <span>{r.title} <span className="text-sm text-gray-500">({r.count}/{r.max})</span></span> <button data-testid={`enter-${r.id}`} onClick={() => router.push(`/rooms/${r.id}`)} className="rounded bg-green-600 px-3 py-1 text-sm text-white">입장</button> </li> ))} </ul> )} </div> </div> ); }
확인 ✅ — / 에서 방을 만들면 그 방으로 이동(다음 단계에서 채팅 화면 완성). 목록에 "제목 (0/정원)".
6부 — Phase 4: SSE 실시간 채팅 (핵심)
여기가 1부 개념이 전부 모이는 곳입니다.
6-1. 레지스트리에 broadcast 추가 (lib/roomRegistry.ts 맨 아래)
// lib/roomRegistry.ts 에 추가 // 한 방의 모든 멤버에게 한 이벤트를 밀어 준다 (브로드캐스트) export function broadcast(room: Room, event: string, data: unknown) { for (const m of room.members.values()) { try { m.send(event, data); } catch { /* 끊긴 연결 무시 */ } } }
6-2. SSE 라우트 = 입장 + 수신 + 퇴장 — app/api/rooms/[id]/stream/route.ts
가장 중요한 파일입니다. 연결 = 입장, abort = 퇴장.
// app/api/rooms/[id]/stream/route.ts import { getSession } from "@/lib/auth"; import { getRoom, deleteRoom, broadcast } from "@/lib/roomRegistry"; export const dynamic = "force-dynamic"; // 캐시/정적화 방지 export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { const session = await getSession(); if (!session) return new Response("Unauthorized", { status: 401 }); const { id } = await params; const room = getRoom(id); if (!room) return new Response("Not Found", { status: 404 }); const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { let closed = false; const enqueue = (chunk: string) => { if (closed) return; try { controller.enqueue(encoder.encode(chunk)); } catch { /* 이미 닫힘 */ } }; const send = (event: string, data: unknown) => enqueue(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); const memberList = () => [...room.members.values()].map((m) => ({ userId: m.userId, nickname: m.nickname })); // 정원 초과 거부 (이미 들어와 있으면 재연결로 보고 통과) if (!room.members.has(session.userId) && room.members.size >= room.maxCapacity) { send("rejected", { reason: "정원이 가득 찼습니다." }); closed = true; try { controller.close(); } catch {} return; } // ── 입장 ── 내 send 를 방 멤버로 등록 = 출석 room.members.set(session.userId, { userId: session.userId, nickname: session.nickname, send, }); send("hello", { roomTitle: room.title, you: session.userId, ownerId: room.ownerId, members: memberList(), }); broadcast(room, "presence", { type: "join", userId: session.userId, nickname: session.nickname, members: memberList(), }); const ping = setInterval(() => enqueue(`:ping\n\n`), 15000); // keep-alive // ── 퇴장 ── 연결이 끊기면 abort 발생 const cleanup = () => { if (closed) return; closed = true; clearInterval(ping); // 재연결로 내 자리가 덮어써지지 않았을 때만 제거 const current = room.members.get(session.userId); if (current && current.send === send) room.members.delete(session.userId); broadcast(room, "presence", { type: "leave", userId: session.userId, nickname: session.nickname, members: memberList(), }); if (room.ownerId === session.userId) { // 방장 퇴장 → 방 폭파 broadcast(room, "room_closed", { reason: "방장이 나가 방이 종료되었습니다." }); deleteRoom(room.id); } else if (room.members.size === 0) { // 모두 나감 → 삭제 deleteRoom(room.id); } try { controller.close(); } catch {} }; req.signal.addEventListener("abort", cleanup); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }, }); }
6-3. 메시지 전송 API — app/api/rooms/[id]/messages/route.ts
전송은 평범한 POST. 받아서 방에 브로드캐스트만 합니다.
// app/api/rooms/[id]/messages/route.ts import { NextResponse } from "next/server"; import { randomUUID } from "crypto"; import { getSession } from "@/lib/auth"; import { getRoom, broadcast } from "@/lib/roomRegistry"; export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const session = await getSession(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { id } = await params; const room = getRoom(id); if (!room) return NextResponse.json({ error: "방이 없습니다." }, { status: 404 }); // 방에 입장(SSE 연결)한 사람만 전송 가능 if (!room.members.has(session.userId)) { return NextResponse.json({ error: "방에 입장한 사용자만 보낼 수 있습니다." }, { status: 403 }); } const { text } = await req.json(); if (!text?.trim()) return NextResponse.json({ error: "빈 메시지" }, { status: 400 }); broadcast(room, "message", { id: randomUUID(), userId: session.userId, nickname: session.nickname, text: String(text).trim(), ts: Date.now(), }); return NextResponse.json({ ok: true }); }
6-4. 사용자 정보(실명) API — app/api/users/[id]/route.ts
채팅엔 닉네임만, 이 API 를 부를 때만 실명을 내려줍니다.
// app/api/users/[id]/route.ts import { NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; import { getUser } from "@/lib/userStore"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const session = await getSession(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { id } = await params; const u = getUser(id); if (!u) return NextResponse.json({ error: "사용자 없음" }, { status: 404 }); return NextResponse.json({ id: u.id, nickname: u.nickname, name: u.name }); }
6-5. 채팅 화면 — components/ChatRoom.tsx
수신(EventSource)·전송(fetch)·참여자(실명 보기)를 모두 담은 컴포넌트입니다.
// components/ChatRoom.tsx "use client"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; type Message = { id?: string; userId?: string; nickname?: string; text: string; ts?: number; system?: boolean; }; type Member = { userId: string; nickname: string }; type UserInfo = { id: string; nickname: string; name: string }; export default function ChatRoom({ roomId }: { roomId: string }) { const router = useRouter(); const { user, loading } = useAuth(); const [title, setTitle] = useState(""); const [messages, setMessages] = useState<Message[]>([]); const [members, setMembers] = useState<Member[]>([]); const [text, setText] = useState(""); const [info, setInfo] = useState<UserInfo | null>(null); const bottomRef = useRef<HTMLDivElement>(null); // ── SSE 연결 (입장) / 정리 함수 = 퇴장 ── useEffect(() => { if (loading) return; if (!user) { router.push("/login"); return; } const es = new EventSource(`/api/rooms/${roomId}/stream`); es.addEventListener("hello", (e) => { const d = JSON.parse((e as MessageEvent).data); setTitle(d.roomTitle); setMembers(d.members); }); es.addEventListener("presence", (e) => { const d = JSON.parse((e as MessageEvent).data); setMembers(d.members); setMessages((prev) => [ ...prev, { system: true, text: `${d.nickname}님이 ${d.type === "join" ? "입장" : "퇴장"}했습니다.` }, ]); }); es.addEventListener("message", (e) => { const d = JSON.parse((e as MessageEvent).data) as Message; setMessages((prev) => [...prev, d]); }); es.addEventListener("room_closed", () => { es.close(); router.push("/?closed=1"); }); es.addEventListener("rejected", () => { es.close(); router.push("/?full=1"); }); return () => es.close(); // 페이지를 떠나면 연결 종료 = 퇴장 }, [roomId, user, loading, router]); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); async function send(e: React.FormEvent) { e.preventDefault(); const t = text.trim(); if (!t) return; setText(""); await fetch(`/api/rooms/${roomId}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: t }), }); } async function showUser(userId: string) { const res = await fetch(`/api/users/${userId}`); if (res.ok) setInfo(await res.json()); } if (loading || !user) return <p>불러오는 중…</p>; return ( <div className="space-y-3"> <div className="flex items-center justify-between"> <h1 className="text-xl font-bold" data-testid="room-title">{title || "대화방"}</h1> <button data-testid="leave-room" onClick={() => router.push("/")} className="rounded bg-gray-200 px-3 py-1 text-sm">나가기</button> </div> {/* 참여자 (닉네임 클릭 → 실명 보기) */} <div data-testid="member-list" className="flex flex-wrap gap-2 text-sm"> {members.map((m) => ( <button key={m.userId} data-testid={`member-${m.userId}`} onClick={() => showUser(m.userId)} className="rounded-full bg-blue-100 px-2 py-0.5" title="사용자 정보 보기"> {m.nickname} </button> ))} </div> {/* 메시지 */} <ul data-testid="messages" className="h-80 space-y-1 overflow-y-auto rounded border p-3"> {messages.map((m, i) => ( <li key={m.id ?? `sys-${i}`}> {m.system ? ( <span className="text-xs text-gray-400">— {m.text} —</span> ) : ( <span> <button onClick={() => m.userId && showUser(m.userId)} className="font-semibold text-blue-700"> {m.nickname} </button> : {m.text} </span> )} </li> ))} <div ref={bottomRef} /> </ul> <form onSubmit={send} className="flex gap-2"> <input data-testid="message-input" className="flex-1 rounded border px-2 py-1" placeholder="메시지 입력" value={text} onChange={(e) => setText(e.target.value)} /> <button data-testid="send-message" className="rounded bg-blue-500 px-4 py-1 text-white">전송</button> </form> {/* 사용자 정보(실명) 모달 */} {info && ( <div data-testid="user-info-modal" className="fixed inset-0 flex items-center justify-center bg-black/40" onClick={() => setInfo(null)}> <div className="space-y-2 rounded bg-white p-6" onClick={(e) => e.stopPropagation()}> <h3 className="font-bold">사용자 정보</h3> <p>닉네임: {info.nickname}</p> <p data-testid="modal-realname">실명: {info.name}</p> <button data-testid="close-modal" onClick={() => setInfo(null)} className="rounded bg-gray-200 px-3 py-1">닫기</button> </div> </div> )} </div> ); }
6-6. 채팅방 페이지 — app/rooms/[id]/page.tsx
// app/rooms/[id]/page.tsx import ChatRoom from "@/components/ChatRoom"; export default async function RoomPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; return <ChatRoom roomId={id} />; }
확인 ✅ — 시크릿창 2개로 각각 가입/로그인 → 한쪽이 방 생성·입장 → 다른 쪽이 그 방 입장 → 서로 입장 알림 + 실시간 메시지 + 닉네임 클릭 시 실명 → 방장이 "나가기" 누르면 다른 사람은 홈으로 튕김(폭파).
6-7. 왜 reactStrictMode: false 였나 (2부의 그 설정)
만약 Strict Mode 가 켜져 있으면, 개발모드에서 React 가 useEffect 를 마운트 → 정리 → 다시 마운트 로 두 번 실행합니다. 그러면 SSE 가 연결 → 즉시 close → 재연결 되는데, 그 "즉시 close" 가 서버에선 방장 퇴장 으로 보여 방이 입장 즉시 폭파됩니다. 그래서 2부에서 미리 꺼 둔 것입니다.
7부 — 동작 원리 다시 보기
- 입장/퇴장 알림: SSE 연결 시
broadcast(presence: join),abort시broadcast(presence: leave). 클라이언트는presence이벤트를 받아 시스템 메시지로 표시. - 방장 퇴장 → 폭파:
abort핸들러에서room.ownerId === 나면broadcast(room_closed)+deleteRoom. 클라이언트는room_closed받으면/로 이동. - 전원 퇴장 → 삭제:
abort후members.size === 0이면deleteRoom. - 정원 초과: SSE 시작 시
members.size >= maxCapacity면rejected보내고 닫음. 클라이언트는/?full=1로. - 닉네임 vs 실명: 메시지·멤버엔 닉네임만. 클릭 시
GET /api/users/[id]로 실명을 그때만 가져와 모달 표시. - 나가기 = 정리 함수:
useEffect의return () => es.close()가 페이지 이탈 시 SSE 를 닫고 → 서버abort→ 퇴장 처리.
8부 — E2E 테스트로 검증
8-1. Playwright 설정 — playwright.config.ts
// playwright.config.ts import { defineConfig } from "@playwright/test"; const PORT = 3200; const BASE_URL = `http://localhost:${PORT}`; export default defineConfig({ testDir: "./e2e", timeout: 30_000, fullyParallel: false, workers: 1, // 서버가 인메모리 상태를 공유하므로 직렬 실행 reporter: "list", use: { baseURL: BASE_URL }, webServer: { command: `npm run dev -- -p ${PORT}`, url: BASE_URL, reuseExistingServer: false, timeout: 120_000, }, });
처음 한 번 브라우저 설치: npx playwright install chromium. 그리고 package.json 의 scripts 에 "test:e2e": "playwright test" 추가.
8-2. 공통 헬퍼 — e2e/helpers.ts
// e2e/helpers.ts import { expect, type Browser, type Page } from "@playwright/test"; let counter = 0; export const uniq = (p: string) => `${p}_${Date.now()}_${counter++}`; export type TestUser = { id: string; nickname: string; realname: string; ctx: Awaited<ReturnType<Browser["newContext"]>>; page: Page; }; // 새 브라우저 컨텍스트(독립 세션)에서 회원가입 + 로그인까지 마친 사용자 export async function makeUser(browser: Browser, prefix: string): Promise<TestUser> { const id = uniq(prefix); const nickname = `닉_${id}`; const realname = `실명_${id}`; const ctx = await browser.newContext(); const page = await ctx.newPage(); await page.goto("/signup"); await page.getByTestId("signup-id").fill(id); await page.getByTestId("signup-password").fill("pw1234"); await page.getByTestId("signup-name").fill(realname); await page.getByTestId("signup-nickname").fill(nickname); await page.getByTestId("signup-email").fill(`${id}@x.com`); await page.getByTestId("signup-submit").click(); await page.waitForURL("**/login"); await page.getByTestId("login-id").fill(id); await page.getByTestId("login-password").fill("pw1234"); await page.getByTestId("login-submit").click(); await expect(page.getByTestId("current-nickname")).toHaveText(nickname); return { id, nickname, realname, ctx, page }; }
8-3. 인증 테스트 — e2e/auth.spec.ts
// e2e/auth.spec.ts import { test, expect, type Page } from "@playwright/test"; import { uniq } from "./helpers"; async function signup(page: Page, u: Record<string, string>) { await page.goto("/signup"); await page.getByTestId("signup-id").fill(u.id); await page.getByTestId("signup-password").fill(u.password); await page.getByTestId("signup-name").fill(u.name); await page.getByTestId("signup-nickname").fill(u.nickname); await page.getByTestId("signup-email").fill(u.email); await page.getByTestId("signup-submit").click(); await page.waitForURL("**/login"); } async function login(page: Page, id: string, password: string) { await page.goto("/login"); await page.getByTestId("login-id").fill(id); await page.getByTestId("login-password").fill(password); await page.getByTestId("login-submit").click(); } test("회원가입 → 로그인 → 헤더 닉네임 → 로그아웃", async ({ page }) => { const id = uniq("alice"); await signup(page, { id, password: "pw1234", name: "앨리스", nickname: "앨리스냥", email: "a@a.com" }); await login(page, id, "pw1234"); await expect(page.getByTestId("current-nickname")).toHaveText("앨리스냥"); await page.getByTestId("logout-btn").click(); await page.waitForURL("**/login"); }); test("잘못된 비밀번호는 로그인 거부", async ({ page }) => { const id = uniq("bob"); await signup(page, { id, password: "pw1234", name: "밥", nickname: "밥냥", email: "b@b.com" }); await login(page, id, "wrongpw"); await expect(page.getByTestId("login-error")).toBeVisible(); }); test("마이페이지 — 닉네임 수정 반영 + 비번 변경 후 새 비번 로그인", async ({ page }) => { const id = uniq("carol"); await signup(page, { id, password: "pw1234", name: "캐롤", nickname: "캐롤냥", email: "c@c.com" }); await login(page, id, "pw1234"); await expect(page.getByTestId("current-nickname")).toHaveText("캐롤냥"); await page.goto("/mypage"); await page.getByTestId("mypage-nickname").fill("새캐롤"); await page.getByTestId("save-profile").click(); await expect(page.getByTestId("profile-msg")).toBeVisible(); await expect(page.getByTestId("current-nickname")).toHaveText("새캐롤"); await page.getByTestId("current-password").fill("pw1234"); await page.getByTestId("new-password").fill("newpw99"); await page.getByTestId("change-password").click(); await expect(page.getByTestId("pw-msg")).toBeVisible(); await page.getByTestId("logout-btn").click(); await page.waitForURL("**/login"); await login(page, id, "newpw99"); await expect(page.getByTestId("current-nickname")).toHaveText("새캐롤"); });
8-4. 실시간 채팅 테스트 — e2e/chat.spec.ts
// e2e/chat.spec.ts import { test, expect } from "@playwright/test"; import { makeUser } from "./helpers"; test("실시간 채팅 — 입장 알림 / 메시지 수신 / 실명 보기 / 방장 퇴장 시 폭파", async ({ browser }) => { const alice = await makeUser(browser, "alice"); const bob = await makeUser(browser, "bob"); await alice.page.getByTestId("room-title").fill("테스트방"); await alice.page.getByTestId("create-room").click(); await alice.page.waitForURL(/\/rooms\//); const roomId = alice.page.url().split("/rooms/")[1]; await expect(alice.page.getByTestId("room-title")).toHaveText("테스트방"); await bob.page.goto(`/rooms/${roomId}`); await expect(alice.page.getByTestId("messages")).toContainText(`${bob.nickname}님이 입장`); await bob.page.getByTestId("message-input").fill("안녕하세요 앨리스"); await bob.page.getByTestId("send-message").click(); await expect(alice.page.getByTestId("messages")).toContainText("안녕하세요 앨리스"); await expect(alice.page.getByTestId("messages")).toContainText(bob.nickname); await alice.page.getByTestId(`member-${bob.id}`).click(); await expect(alice.page.getByTestId("user-info-modal")).toBeVisible(); await expect(alice.page.getByTestId("modal-realname")).toContainText(bob.realname); await alice.page.getByTestId("close-modal").click(); await alice.page.getByTestId("leave-room").click(); await expect(bob.page).toHaveURL(/closed=1/, { timeout: 10_000 }); await alice.ctx.close(); await bob.ctx.close(); }); test("최대 인원 초과 입장 거부", async ({ browser }) => { const a = await makeUser(browser, "capa"); const b = await makeUser(browser, "capb"); const c = await makeUser(browser, "capc"); await a.page.getByTestId("room-title").fill("정원2방"); await a.page.getByTestId("room-capacity").fill("2"); await a.page.getByTestId("create-room").click(); await a.page.waitForURL(/\/rooms\//); const roomId = a.page.url().split("/rooms/")[1]; await b.page.goto(`/rooms/${roomId}`); await expect(a.page.getByTestId("messages")).toContainText(`${b.nickname}님이 입장`); await c.page.goto(`/rooms/${roomId}`); await expect(c.page).toHaveURL(/full=1/, { timeout: 10_000 }); await a.ctx.close(); await b.ctx.close(); await c.ctx.close(); });
8-5. 실행
npm run test:e2e
기대 결과 — 5개 모두 통과:
✓ 회원가입 → 로그인 → 헤더 닉네임 → 로그아웃 ✓ 잘못된 비밀번호는 로그인 거부 ✓ 마이페이지 — 닉네임 수정 반영 + 비번 변경 후 새 비번 로그인 ✓ 실시간 채팅 — 입장 알림 / 메시지 수신 / 실명 보기 / 방장 퇴장 시 폭파 ✓ 최대 인원 초과 입장 거부 5 passed
멀티 유저는
browser.newContext()로 독립 세션을 만들어 테스트합니다. 인메모리 상태를 공유하므로workers: 1(직렬) + 테스트마다 유니크 아이디가 필수입니다.
9부 — 디버깅 / 한계 / 정리
자주 막히는 곳
- 방장이 들어가자마자 방이 사라짐 →
reactStrictMode: false(2부). - SSE 메시지가 안 옴 → ①
Content-Type: text/event-stream② 포맷event: x\ndata: y\n\n(끝 빈 줄!) ③ 클라addEventListener이름 = 서버event:이름. - 실시간이 끊김(프록시) →
Cache-Control: no-transform, Nginx면X-Accel-Buffering: no. - 새로고침하면 다 사라짐 → 인메모리라 당연(회원까지 유지하려면 DB 필요 — 범위 밖).
- 배포 후 여러 명이 안 됨 → 인메모리는 단일 프로세스 전용. 여러 인스턴스로 띄우면 방이 공유 안 됨. 단일 서버 배포, 또는 확장 시 Redis Pub/Sub 로
broadcast이전. - E2E 충돌 →
workers: 1+ 유니크 아이디.
머릿속에 남길 그림
- 요청/응답은 "물어보면 답", SSE는 "연결을 열어 두고 서버가 먼저 밀어 줌".
- SSE = 안 끝나는 응답(ReadableStream) + 약속 포맷(
event:/data:/\n\n) + EventSource. - 서버는 각 접속자의
send를 방에 모아 두고broadcast로 전부 호출 → 실시간. - SSE 연결 = 출석. 연결 입장,
req.signalabort 퇴장. 클라의useEffect정리 함수(es.close()) 가 나가기. - 보내기 POST, 받기 SSE. 인증은 httpOnly 쿠키 + JWT(EventSource가 헤더를 못 붙이므로).
전체 파일 체크리스트 (이 문서에서 만든 것)
next.config.ts lib/userStore.ts lib/auth.ts lib/roomRegistry.ts contexts/AuthContext.tsx components/Header.tsx components/ChatRoom.tsx app/layout.tsx app/page.tsx app/signup/page.tsx app/login/page.tsx app/mypage/page.tsx app/rooms/[id]/page.tsx app/api/auth/{signup,login,logout,me}/route.ts app/api/users/me/route.ts app/api/users/me/password/route.ts app/api/users/[id]/route.ts app/api/rooms/route.ts app/api/rooms/[id]/messages/route.ts app/api/rooms/[id]/stream/route.ts playwright.config.ts e2e/helpers.ts e2e/auth.spec.ts e2e/chat.spec.ts
위 파일을 이 문서 순서대로 만들면 — 동작하는 실시간 채팅이 완성됩니다.

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