๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๋…ธํŠธ ๋ชฉ๋ก

๐Ÿ“‹ ์•Œ๋ฆผ ๊ฒŒ์‹œํŒ (JWT ์ธ์ฆ + SSE ์‹ค์‹œ๊ฐ„ ์•Œ๋ฆผ)

๊น€์„ฑ๋ฐ•1150
๋ชฉ์ฐจ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๋ฅผ ์“ฐ๋ฉฐ ๋ถ€๋”ชํž ํ•ต์‹ฌ ๋‘ ๊ฐ€์ง€๋ฅผ ๋ฏธ๋ฆฌ ๋ชป ๋ฐ•์•„ ๋‘ก๋‹ˆ๋‹ค.

  1. EventSource๋Š” ์ปค์Šคํ…€ ํ—ค๋”๋ฅผ ๋ชป ๋ถ™์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ SSE ์—ฐ๊ฒฐ์€ ์–ด๋–ป๊ฒŒ ์ธ์ฆํ•˜์ฃ ? โ†’ httpOnly ์ฟ ํ‚ค๊ฐ€ ์ž๋™์œผ๋กœ ๋”ฐ๋ผ๊ฐ‘๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์šฐ๋ฆฌ๊ฐ€ 2๋‹จ๊ณ„์—์„œ ํ† ํฐ์„ ์ฟ ํ‚ค๋กœ ์‹ฌ๋Š” ์„ ํƒ์ด, 4๋‹จ๊ณ„ SSE ์ธ์ฆ๊ณผ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋งž๋ฌผ๋ฆฝ๋‹ˆ๋‹ค. (์ด๊ฒŒ ์ด ํŠœํ† ๋ฆฌ์–ผ์˜ ์ˆจ์€ ์„ค๊ณ„์˜ˆ์š”.)
  2. "๋ˆ„๊ตฌ์—๊ฒŒ ๋ณด๋‚ผ์ง€" ๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. 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โ”€โ”€โ–ถ ์—ด๋ ค ์žˆ์Œ โ†’ ์•Œ๋ฆผ ์ˆ˜์‹  ๐Ÿ””

ํ•ต์‹ฌ ๋ถ€ํ’ˆ ๋‘˜:

  1. ์•Œ๋ฆผ ํ—ˆ๋ธŒ โ€” "์ง€๊ธˆ SSE๋กœ ์—ฐ๊ฒฐ๋œ ์‚ฌ์šฉ์ž๋“ค" ์˜ ๋ช…๋‹จ. ๋Œ“๊ธ€ API๊ฐ€ ์—ฌ๊ธฐ์„œ alice์˜ ์—ฐ๊ฒฐ์„ ์ฐพ๋Š”๋‹ค.
  2. ์‚ฌ์šฉ์ž๋ณ„ 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์œผ๋กœ.


๋ณด๋„ˆ์Šค / ์—ฐ์Šต ๊ฑฐ๋ฆฌ

  1. ์ฝ์Œ ์ƒํƒœ ์˜์† โ€” ์ง€๊ธˆ unread๋Š” ์ƒˆ๋กœ๊ณ ์นจํ•˜๋ฉด 0. ์•Œ๋ฆผ์„ ์„œ๋ฒ„์— ์ €์žฅํ•˜๊ณ  /api/notifications๋กœ ๊ณผ๊ฑฐ ์•Œ๋ฆผ๊นŒ์ง€ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ.
  2. "์ข‹์•„์š”" ์•Œ๋ฆผ โ€” ๋Œ“๊ธ€ ๋ง๊ณ  ์ข‹์•„์š”์—๋„ notifyUser. ์ด๋ฒคํŠธ ํƒ€์ž… like ์ถ”๊ฐ€.
  3. ํ† ์ŠคํŠธ โ€” ๐Ÿ”” ๋ฐฐ์ง€๋ฟ ์•„๋‹ˆ๋ผ, ์•Œ๋ฆผ ๋„์ฐฉ ์‹œ ํ™”๋ฉด ๋ชจ์„œ๋ฆฌ์— ์ž ๊น ๋œจ๋Š” ํ† ์ŠคํŠธ.
  4. ์—ฐ๊ฒฐ ํ‘œ์‹œ โ€” ์•Œ๋ฆผ ์ŠคํŠธ๋ฆผ์˜ ready/onerror๋กœ "๐ŸŸข ์‹ค์‹œ๊ฐ„ ์—ฐ๊ฒฐ๋จ" ๋ฐฐ์ง€ (๋กœ์ผ“ ํŠœํ† ๋ฆฌ์–ผ์˜ ๊ทธ ํŒจํ„ด).
  5. ์ง„์งœ DB โ€” lib/db.ts๋งŒ SQLite/Postgres๋กœ ๊ต์ฒด. ๋‚˜๋จธ์ง€ ์ฝ”๋“œ๋Š” ๊ทธ๋Œ€๋กœ โ€” ๊ทธ๊ฒŒ db.ts๋กœ ๊ฒฉ๋ฆฌํ•ด ๋‘” ์ด์œ .

๋Œ“๊ธ€

๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

๋Œ“๊ธ€์„ ์ž‘์„ฑํ•˜๋ ค๋ฉด ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.