Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
10 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# App / Security
NEXT_PUBLIC_APP_URL=
COOKIE_PASSWORD=
RATE_LIMIT_SALT=
CRON_SECRET=

# Database
# PostgreSQL ๊ธฐ์ค€์ž…๋‹ˆ๋‹ค.
# DATABASE_URL: ๋Ÿฐํƒ€์ž„ ์—ฐ๊ฒฐ URL
# DIRECT_URL: migration / Prisma CLI์šฉ ์ง์ ‘ ์—ฐ๊ฒฐ URL
DATABASE_URL=
DIRECT_URL=

# OAuth
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
KAKAO_CLIENT_ID=
KAKAO_CLIENT_SECRET=
KAKAO_REDIRECT_URI=

# Supabase Realtime
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=

# Cloudflare Media / Webhook
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH=
NEXT_PUBLIC_CLOUDFLARE_STREAM_DOMAIN=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_WEBHOOK_SECRET=
CLOUDFLARE_STREAM_WEBHOOK_SECRET=

# SMS (CoolSMS)
COOLSMS_API_KEY=
COOLSMS_API_SECRET=
COOLSMS_SENDER_NUMBER=

# Email (Resend)
RESEND_API_KEY=

# Push
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=

# Maps
NEXT_PUBLIC_KAKAO_MAP_API_KEY=
90 changes: 63 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BoardPort

BoardPort๋Š” ๋ณด๋“œ๊ฒŒ์ž„ ๊ฑฐ๋ž˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ์ฑ„ํŒ… ์•ฝ์†, ๋ผ์ด๋ธŒ ๋ฐฉ์†ก, ์•Œ๋ฆผ, ๊ด€๋ฆฌ์ž ์šด์˜์„ ํ•˜๋‚˜์˜ ์‚ฌ์šฉ์ž ํ๋ฆ„์œผ๋กœ ์—ฐ๊ฒฐํ•œ ๋ชจ๋ฐ”์ผ ํผ์ŠคํŠธ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค.
BoardPort๋Š” ๋ณด๋“œ๊ฒŒ์ž„ ์ค‘๊ณ ๊ฑฐ๋ž˜์—์„œ ์ž์ฃผ ๋ถ„๋ฆฌ๋˜๋Š” ์ƒํ’ˆ ํƒ์ƒ‰, ๋ฌธ์˜, ์ง๊ฑฐ๋ž˜ ์•ฝ์†, ํ›„๊ธฐ, ์ฝ˜ํ…์ธ , ์šด์˜ ํ๋ฆ„์„ ํ•˜๋‚˜์˜ ์„œ๋น„์Šค๋กœ ์—ฐ๊ฒฐํ•œ ๋ชจ๋ฐ”์ผ ํผ์ŠคํŠธ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค.

๋ฒ”์šฉ ์ค‘๊ณ ๊ฑฐ๋ž˜ ์•ฑ์—์„œ ๋ถ€์กฑํ•œ ๋ณด๋“œ๊ฒŒ์ž„ ํŠนํ™” ๋งฅ๋ฝ์„ ๋ณด์™„ํ•˜๊ธฐ ์œ„ํ•ด, ๊ฑฐ๋ž˜ยท๋ฃฐ ์งˆ๋ฌธยทํ›„๊ธฐยทํ”Œ๋ ˆ์ด ๊ณต์œ ๊ฐ€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ด์–ด์ง€๋Š” ํ๋ฆ„์— ์ดˆ์ ์„ ๋งž์ท„์Šต๋‹ˆ๋‹ค.

Expand All @@ -13,11 +13,16 @@ BoardPort๋Š” ๋ณด๋“œ๊ฒŒ์ž„ ๊ฑฐ๋ž˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ์ฑ„ํŒ… ์•ฝ์†, ๋ผ์ด๋ธŒ ๋ฐฉ์†ก,
| Domain | Board Game Marketplace ยท Community ยท Live Streaming ยท Chat |
| Core Stack | Next.js 14 App Router, React 18, TypeScript, Prisma, PostgreSQL, TanStack Query v5, Zustand, Supabase Realtime |

## Feature Flow
## Key Engineering Problems

BoardPort๋Š” ๊ฑฐ๋ž˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ๋ฐฉ์†ก, ์•Œ๋ฆผ, ๊ด€๋ฆฌ์ž ์šด์˜์ด ๋…๋ฆฝ์ ์œผ๋กœ ๋™์ž‘ํ•˜๋ฉด์„œ ๋ณด๋“œ๊ฒŒ์ž„ ๋„๊ฐ์œผ๋กœ ์ฝ˜ํ…์ธ  ๋งฅ๋ฝ์„ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค.
| ๋ฌธ์ œ | ํ•ด๊ฒฐ ๋ฐฉํ–ฅ | ๊ด€๋ จ ๋ฌธ์„œ |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| ์ฑ„ํŒ… ์•ฝ์† ์ˆ˜๋ฝ๊ณผ ์ƒํ’ˆ ์˜ˆ์•ฝ ์ƒํƒœ๊ฐ€ ์–ด๊ธ‹๋‚  ์ˆ˜ ์žˆ์Œ | ์•ฝ์† ์ˆ˜๋ฝ, ์ƒํ’ˆ ์˜ˆ์•ฝ ์ „ํ™˜, ๋‹ค๋ฅธ ๋Œ€๊ธฐ ์•ฝ์† ์ทจ์†Œ, ์‹œ์Šคํ…œ ๋ฉ”์‹œ์ง€๋ฅผ ํ•˜๋‚˜์˜ transaction์œผ๋กœ ๋ฌถ๊ณ  `updateMany` ์กฐ๊ฑด์œผ๋กœ ๋™์‹œ ์ˆ˜๋ฝ์„ ๋ฐฉ์–ด | [Appointment Atomic Transition](./docs/troubleshooting/troubleshooting-appointment-atomic-transition.md) |
| App Router ๋ชจ๋‹ฌ ์ƒ์„ธ์™€ ์ผ๋ฐ˜ ์ƒ์„ธ์˜ ๋ณต๊ท€ ๋ฌธ๋งฅ์ด ์„ž์ž„ | Intercepting Route ๋ชจ๋‹ฌ, ์ผ๋ฐ˜ ์ƒ์„ธ, ์ˆ˜์ •/์‚ญ์ œ ๋ณต๊ท€๋ฅผ `returnTo`, `flow`, refresh flag๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  mixed tree ์ผ€์ด์Šค๋ฅผ ๋ฌธ๋งฅ๋ณ„๋กœ ์ •๋ฆฌ | [Product Modal Routing](./docs/troubleshooting/troubleshooting-product-modal-routing.md) |
| ์™ธ๋ถ€ ๋™์˜์ƒ ์ธ์ฝ”๋”ฉ ์ด๋ฒคํŠธ์™€ ๊ฒŒ์‹œ๊ธ€ ์ €์žฅ ์ˆœ์„œ๊ฐ€ ๋ณด์žฅ๋˜์ง€ ์•Š์Œ | Cloudflare webhook์˜ READY ์„ ๋„์ฐฉ๊ณผ error payload๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ , `draftKey`๋ฅผ ์‹ค์ œ ๊ฒŒ์‹œ๊ธ€ ์—ฐ๊ฒฐ ์ „๊นŒ์ง€ ๋ณด์กดํ•ด READY/FAILED ์ƒํƒœ๋กœ ์ˆ˜๋ ด | [Post Video Webhook](./docs/troubleshooting/troubleshooting-post-video-cloudflare-webhook.md) |
| Server State, UI State, Realtime ์ด๋ฒคํŠธ๊ฐ€ ์„ž์—ฌ ๊ฐฑ์‹  ๊ธฐ์ค€์ด ๋ถ„์‚ฐ๋จ | Zustand๋Š” UI ์ƒํƒœ, TanStack Query๋Š” ์„œ๋ฒ„ ์ƒํƒœ, Realtime์€ invalidate/refetch ์‹ ํ˜ธ๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  Route Handler fetch์™€ Query Key Factory๋กœ ์žฌ๊ฒ€์ฆ ๊ฒฝ๋กœ๋ฅผ ํ†ต์ผ | [State Management Modernization](./docs/architecture/case-study-state-management-modernization.md) |

![BoardPort Feature Flow](./docs/assets/readme/boardport-feature-flow.png)
๊ฐ ๋ฌธ์„œ๋Š” ๋ฆด๋ฆฌ์ฆˆ ์ „ QA์—์„œ ํ™•์ธํ•œ ์ฆ์ƒ, ์›์ธ, ์ฝ”๋“œ ๊ธฐ์ค€, ์šด์˜ ํŒ๋‹จ์„ ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.

## Demo

Expand All @@ -39,6 +44,12 @@ BoardPort์˜ ์ฃผ์š” ๋„๋ฉ”์ธ์„ ๋น ๋ฅด๊ฒŒ ํ›‘์–ด๋ณด๋Š” ์ „์ฒด ํ๋ฆ„์ž…๋‹ˆ๋‹ค.

<video src="https://github.com/user-attachments/assets/ca062392-83cf-47c9-8903-70a098b19f41" controls muted playsinline width="100%"></video>

## Feature Flow

BoardPort๋Š” ๊ฑฐ๋ž˜, ์ปค๋ฎค๋‹ˆํ‹ฐ, ๋ฐฉ์†ก, ์•Œ๋ฆผ, ๊ด€๋ฆฌ์ž ์šด์˜์ด ๋…๋ฆฝ์ ์œผ๋กœ ๋™์ž‘ํ•˜๋ฉด์„œ ๋ณด๋“œ๊ฒŒ์ž„ ๋„๊ฐ์œผ๋กœ ์ฝ˜ํ…์ธ  ๋งฅ๋ฝ์„ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค.

![BoardPort Feature Flow](./docs/assets/readme/boardport-feature-flow.png)

## Feature Tour

<details>
Expand Down Expand Up @@ -178,8 +189,10 @@ App Router์˜ ์ผ๋ฐ˜ ์ƒ์„ธ ํŽ˜์ด์ง€์™€ ๋ชจ๋‹ฌ ์ƒ์„ธ ํŽ˜์ด์ง€๊ฐ€ ๊ฐ™์€ ๋ฐ

- [Project Overview](./docs/architecture/boardport-project-overview.md)
- [State Management Modernization](./docs/architecture/case-study-state-management-modernization.md)
- [Product Modal Routing Troubleshooting](./docs/troubleshooting/troubleshooting-product-modal-routing.md)
- [Appointment Atomic Transition Troubleshooting](./docs/troubleshooting/troubleshooting-appointment-atomic-transition.md)
- [Post Video Cloudflare Webhook Troubleshooting](./docs/troubleshooting/troubleshooting-post-video-cloudflare-webhook.md)
- [Product Modal Routing Troubleshooting](./docs/troubleshooting/troubleshooting-product-modal-routing.md)
- [Access Control Matrix](./docs/operations/access-control-matrix.md)
- [PWA Web Push Routing Troubleshooting](./docs/troubleshooting/troubleshooting-pwa-web-push-routing.md)

## Project Structure
Expand All @@ -202,7 +215,7 @@ prisma/ Prisma schema, seed, migration
npm install
```

2. `.env.local`์— ์•„๋ž˜ ํ•„์ˆ˜ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์ฑ„์›๋‹ˆ๋‹ค.
2. `.env.example`์„ ์ฐธ๊ณ ํ•ด `.env.local`์— ํ•„์ˆ˜ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์ฑ„์›๋‹ˆ๋‹ค.

3. ๋กœ์ปฌ ๋˜๋Š” ๊ฐœ๋ฐœ DB์— Prisma ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

Expand All @@ -228,15 +241,21 @@ npm run dev

์‹ค์ œ ๊ฐ’์€ `.env` ๋˜๋Š” Vercel Environment Variables์— ์„ค์ •ํ•˜๊ณ  ์ €์žฅ์†Œ์—๋Š” ์ปค๋ฐ‹ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

#### Core
#### App / Security

| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| --------------------- | --------------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | ๋Œ€ํ‘œ URL, ์ธ์ฆ ์ฝœ๋ฐฑ, ๊ณต์œ  ๋งํฌ ๊ธฐ์ค€ URL |
| `COOKIE_PASSWORD` | iron-session ์ฟ ํ‚ค ์•”ํ˜ธํ™” ํ‚ค |
| `RATE_LIMIT_SALT` | IP ๊ธฐ๋ฐ˜ rate limit hash ์ƒ์„ฑ์šฉ ์„œ๋ฒ„ ์‹œํฌ๋ฆฟ ํ‚ค |
| `CRON_SECRET` | Vercel Cron ํ˜ธ์ถœ ์ธ์ฆ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค |

#### Database

| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| --------------------- | ---------------------------------------------- |
| `DATABASE_URL` | Prisma ๊ธฐ๋ณธ DB ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด |
| `DIRECT_URL` | Prisma ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜/์ง์ ‘ ์—ฐ๊ฒฐ์šฉ DB ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด |
| `COOKIE_PASSWORD` | iron-session ์ฟ ํ‚ค ์•”ํ˜ธํ™” ํ‚ค |
| `NEXT_PUBLIC_APP_URL` | ๋Œ€ํ‘œ URL, ์ธ์ฆ ์ฝœ๋ฐฑ, ๊ณต์œ  ๋งํฌ ๊ธฐ์ค€ URL |
| `CRON_SECRET` | Vercel Cron ํ˜ธ์ถœ ์ธ์ฆ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค |
| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| -------------- | ------------------------------------------ |
| `DATABASE_URL` | ๋Ÿฐํƒ€์ž„ PostgreSQL ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด |
| `DIRECT_URL` | Prisma ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜/CLI์šฉ ์ง์ ‘ ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด |

#### Supabase

Expand All @@ -245,17 +264,29 @@ npm run dev
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase ํ”„๋กœ์ ํŠธ URL |
| `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Supabase ๊ณต๊ฐœ anon ํ‚ค |

#### Auth / SMS / Email
#### OAuth

| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| ---------------------- | --------------------------------- |
| `GITHUB_CLIENT_ID` | GitHub OAuth ํด๋ผ์ด์–ธํŠธ ID |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ ํ‚ค |
| `KAKAO_CLIENT_ID` | Kakao OAuth ํด๋ผ์ด์–ธํŠธ ID |
| `KAKAO_CLIENT_SECRET` | Kakao OAuth ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ ํ‚ค |
| `KAKAO_REDIRECT_URI` | Kakao OAuth ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ URI |

#### SMS (CoolSMS)

| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| ----------------------- | -------------------------------- |
| `KAKAO_CLIENT_ID` | Kakao OAuth ํด๋ผ์ด์–ธํŠธ ID |
| `KAKAO_CLIENT_SECRET` | Kakao OAuth ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ ํ‚ค |
| `KAKAO_REDIRECT_URI` | Kakao OAuth ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ URI |
| `COOLSMS_API_KEY` | CoolSMS API ํ‚ค |
| `COOLSMS_API_SECRET` | CoolSMS API ์‹œํฌ๋ฆฟ ํ‚ค |
| `COOLSMS_SENDER_NUMBER` | CoolSMS ๋ฐœ์‹  ๋ฒˆํ˜ธ |
| `RESEND_API_KEY` | Resend ์ด๋ฉ”์ผ ๋ฐœ์†ก API ํ‚ค |
| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| ----------------------- | --------------------- |
| `COOLSMS_API_KEY` | CoolSMS API ํ‚ค |
| `COOLSMS_API_SECRET` | CoolSMS API ์‹œํฌ๋ฆฟ ํ‚ค |
| `COOLSMS_SENDER_NUMBER` | CoolSMS ๋ฐœ์‹  ๋ฒˆํ˜ธ |

#### Email (Resend)

| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| ---------------- | ------------------------- |
| `RESEND_API_KEY` | Resend ์ด๋ฉ”์ผ ๋ฐœ์†ก API ํ‚ค |

#### Cloudflare

Expand All @@ -268,12 +299,17 @@ npm run dev
| `CLOUDFLARE_WEBHOOK_SECRET` | Cloudflare ์›นํ›… ์š”์ฒญ ๊ฒ€์ฆ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค |
| `CLOUDFLARE_STREAM_WEBHOOK_SECRET` | Cloudflare Stream ์›นํ›… ์„œ๋ช… ๊ฒ€์ฆ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค |

#### Push / ์ง€๋„
#### Push

| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| ------------------------------ | --------------------- |
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Web Push VAPID ๊ณต๊ฐœํ‚ค |
| `VAPID_PRIVATE_KEY` | Web Push VAPID ๊ฐœ์ธํ‚ค |

#### Maps

| ๋ณ€์ˆ˜๋ช… | ์„ค๋ช… |
| ------------------------------- | ----------------------------- |
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Web Push VAPID ๊ณต๊ฐœํ‚ค |
| `VAPID_PRIVATE_KEY` | Web Push VAPID ๊ฐœ์ธํ‚ค |
| `NEXT_PUBLIC_KAKAO_MAP_API_KEY` | ์นด์นด์˜ค ์ง€๋„ JavaScript SDK ํ‚ค |

## Scripts
Expand Down
33 changes: 19 additions & 14 deletions app/(app)/(tabs)/streams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
* 2026.04.20 ์ž„๋„ํ—Œ Modified sm ๊ตฌ๊ฐ„ ๋ฐ์Šคํฌํ†ฑ ํ—ค๋”๊ฐ€ ๋’ค ์ฝ˜ํ…์ธ ๋ฅผ ๋น„์น˜์ง€ ์•Š๋„๋ก ๋ฐ˜ํˆฌ๋ช… ํ—ค๋”/์นดํ…Œ๊ณ ๋ฆฌ ๋ ˆ์ผ ํ‘œ๋ฉด์„ ๋ถˆํˆฌ๋ช… ํ†ค์œผ๋กœ ์ •๋ฆฌ
* 2026.05.08 ์ž„๋„ํ—Œ Modified ์ŠคํŠธ๋ฆผ ์กฐํšŒ ๋ฒ”์œ„ ํƒ€์ž…์„ StreamScope ๊ณต์šฉ ํƒ€์ž…์œผ๋กœ ๊ต์ฒด
* 2026.05.17 ์ž„๋„ํ—Œ Modified prefetch ๋ฐ์ดํ„ฐ ํƒ€์ž…์„ InfiniteData๋กœ ๋ช…์‹œ
* 2026.06.25 ์ž„๋„ํ—Œ Modified ์„œ๋ฒ„ prefetch query key๋ฅผ ์กฐํšŒ์ž/ํŒ”๋กœ์ž‰ ํ•„ํ„ฐ ์Šค์ฝ”ํ”„์™€ ์ผ์น˜ํ•˜๋„๋ก ์ •๋ฆฌ
*/
import { Suspense } from "react";
import { Metadata } from "next";
Expand Down Expand Up @@ -136,42 +137,47 @@ export default async function StreamsPage({ searchParams }: StreamsPageProps) {
sort: recordingSort,
scope: scope === "following" ? "following" : "",
};
// ๋กœ๊ทธ์ธ ๊ฐ€๋“œ ์ดํ›„์—๋Š” viewerId๊ฐ€ ์กด์žฌํ•˜์ง€๋งŒ, ํด๋ผ์ด์–ธํŠธ ํ›…์˜ query key ์Šค์ฝ”ํ”„์™€ ๋งž์ถ”๊ธฐ ์œ„ํ•ด guest fallback์„ ์œ ์ง€ํ•œ๋‹ค.
const liveListQueryKey = queryKeys.streams.list(scope, {
...liveQueryParams,
viewerId: viewerId ?? "guest",
});
const recordingListQueryKey = queryKeys.streams.recordingList(
recordingSort,
{
...recordingQueryParams,
followingOnly: scope === "following",
viewerId: viewerId ?? "guest",
}
);

const queryClient = getQueryClient();
const [, unreadCount] = await Promise.all([
// ๋ผ์ด๋ธŒ/๋‹ค์‹œ๋ณด๊ธฐ์˜ ์„œ๋กœ ๋‹ค๋ฅธ ์ฟผ๋ฆฌ ํ‚ค/fetcher์— ๋งž์ถ˜ ํ˜„์žฌ ๋ชจ๋“œ ๋ชฉ๋ก๋งŒ ์„œ๋ฒ„ ์„ ํ”„๋ฆฌํŒจ์น˜
mode === "recordings"
? queryClient.prefetchInfiniteQuery({
queryKey: queryKeys.streams.recordingList(
recordingSort,
recordingQueryParams
),
queryKey: recordingListQueryKey,
queryFn: () =>
getRecordingsListAction(
recordingSort,
scope === "following",
null,
recordingQueryParams,
viewerId
recordingQueryParams
),
initialPageParam: null as number | null,
})
: queryClient.prefetchInfiniteQuery({
queryKey: queryKeys.streams.list(scope, liveQueryParams),
queryKey: liveListQueryKey,
queryFn: () =>
getStreamsListAction(scope, null, liveQueryParams, viewerId),
getStreamsListAction(scope, null, liveQueryParams),
initialPageParam: null as number | null,
}),
getUnreadNotificationCount(),
]);

const prefetchData = queryClient.getQueryData<
InfiniteData<StreamsListPage | RecordingsPage>
>(
mode === "recordings"
? queryKeys.streams.recordingList(recordingSort, recordingQueryParams)
: queryKeys.streams.list(scope, liveQueryParams)
);
>(mode === "recordings" ? recordingListQueryKey : liveListQueryKey);
const firstPage = prefetchData?.pages[0];
const isDataEmpty =
mode === "recordings"
Expand Down Expand Up @@ -401,4 +407,3 @@ export default async function StreamsPage({ searchParams }: StreamsPageProps) {
</div>
);
}

70 changes: 70 additions & 0 deletions app/api/streams/recordings/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* File Name : app/api/streams/recordings/route.test.ts
* Description : ๋‹ค์‹œ๋ณด๊ธฐ ๋ชฉ๋ก API ๊ถŒํ•œ ๊ฒฝ๊ณ„ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ
* Author : ์ž„๋„ํ—Œ
*
* History
* Date Author Status Description
* 2026.06.25 ์ž„๋„ํ—Œ Created URL viewerId๋ฅผ ์‹ ๋ขฐํ•˜์ง€ ์•Š๋Š” ์„ธ์…˜ ๊ธฐ์ค€ ์กฐํšŒ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
*/

import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
getRecordingsList: vi.fn(),
}));

vi.mock("@/lib/session", () => ({
default: mocks.getSession,
}));

vi.mock("@/features/stream/service/list", () => ({
getRecordingsList: mocks.getRecordingsList,
}));

describe("GET /api/streams/recordings", () => {
beforeEach(() => {
mocks.getSession.mockReset();
mocks.getRecordingsList.mockReset();
});

it("๋น„๋กœ๊ทธ์ธ ์š”์ฒญ์˜ viewerId query๋ฅผ ์กฐํšŒ์ž ๊ถŒํ•œ์œผ๋กœ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค", async () => {
const { GET } = await import("./route");
const request = new NextRequest(
"http://localhost/api/streams/recordings?followingOnly=true&viewerId=123"
);

mocks.getSession.mockResolvedValue(null);

const response = await GET(request);

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
recordings: [],
nextCursor: null,
});
expect(mocks.getRecordingsList).not.toHaveBeenCalled();
});

it("์„ธ์…˜์ด ์žˆ์œผ๋ฉด query viewerId๋ณด๋‹ค ์„ธ์…˜ ID๋ฅผ ์šฐ์„ ํ•œ๋‹ค", async () => {
const { GET } = await import("./route");
const request = new NextRequest(
"http://localhost/api/streams/recordings?followingOnly=true&viewerId=123&cursor=50"
);

mocks.getSession.mockResolvedValue({ id: 7 });
mocks.getRecordingsList.mockResolvedValue([]);

await GET(request);

expect(mocks.getRecordingsList).toHaveBeenCalledWith(
expect.objectContaining({
followingOnly: true,
viewerId: 7,
cursor: 50,
})
);
});
});
5 changes: 3 additions & 2 deletions app/api/streams/recordings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* History
* Date Author Status Description
* 2026.05.19 ์ž„๋„ํ—Œ Created Client queryFn์—์„œ ์กฐํšŒ์šฉ Server Action์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜์ง€ ์•Š๋„๋ก ๋‹ค์‹œ๋ณด๊ธฐ ๋ชฉ๋ก ์กฐํšŒ API ๋ถ„๋ฆฌ
* 2026.06.25 ์ž„๋„ํ—Œ Modified URL viewerId fallback ์ œ๊ฑฐ ๋ฐ ์„ธ์…˜ ๊ธฐ์ค€ ์กฐํšŒ์ž ๊ถŒํ•œ ๊ณ ์ •
*/

import { NextRequest, NextResponse } from "next/server";
Expand Down Expand Up @@ -52,8 +53,8 @@ export async function GET(request: NextRequest) {
const sort: RecordingSort =
searchParams.get("sort") === "popular" ? "popular" : "latest";
const followingOnly = searchParams.get("followingOnly") === "true";
const viewerId =
session?.id ?? parseNullableNumberParam(searchParams.get("viewerId"));
// /api ๊ฒฝ๋กœ๋Š” middleware ์ธ์ฆ ๊ฐ€๋“œ๋ฅผ ํƒ€์ง€ ์•Š์œผ๋ฏ€๋กœ URL viewerId๋ฅผ ์‹ ๋ขฐํ•˜์ง€ ์•Š๊ณ  ์„ธ์…˜๋งŒ ์กฐํšŒ์ž ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค.
const viewerId = session?.id ?? null;

if (!viewerId) {
return NextResponse.json({ recordings: [], nextCursor: null });
Expand Down
Loading