# 설계서: 프론트 - 어드민 페이지 (#282) > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **추적성** — Redmine: #282 · 관련 ADR: 없음 > · 구현 파일: `frontend/src/app/admin/page.tsx` · 테스트: TBD (현재 없음) ## 1. 목적 (Why) 운영자가 채널 등록·스캔, 영상 자막/LLM 추출, 식당 정보 보정 및 예약처(테이블링/캐치테이블) 연결, 유저 권한 관리, 백그라운드 데몬 스케줄을 **단일 페이지**에서 일관된 패턴으로 다룰 수 있게 한다. ## 2. 범위 (Scope) - **포함**: - 5개 탭: 채널 / 영상 / 식당 / 유저 / 데몬 - 채널: 추가·수정(설명/태그/순서)·삭제·증분 스캔·전체 스캔 - 영상: 채널·상태·제목 필터, 정렬, 페이지네이션, 행 선택, 상세 패널, 단건/벌크 자막 수집, 단건/벌크 LLM 추출(SSE 스트리밍 진행률), 식당 인라인 편집/수동 추가, 벡터 재생성, 음식종류/메뉴태그 재분류 - 식당: 검색·정렬·페이지네이션, 인라인 편집(주소/지역/좌표 등), 테이블링/캐치테이블 단건·벌크 검색·연결·해제·전부 초기화, 연결된 영상 목록 - 유저: 페이지네이션, 관리자 토글, 선택 유저의 찜/리뷰/메모 패널 - 데몬: 스캔/처리 스케줄 활성화·주기 설정, 수동 실행, 마지막 실행시각 표시 - Redis 캐시 플러시(헤더), 관리자/비관리자 모드(읽기 전용 뱃지) - **제외 (out of scope)**: - 어드민 권한 부여 정책 / OAuth 흐름 (LoginMenu, 백엔드) - 백엔드 API 스키마 / SSE 이벤트 명세 (각 백엔드 설계서) - 다국어, 다크모드 별도 디자인 (브랜드 토큰 기본 적용) ## 3. 인수조건 (이미 구현된 동작 기준) - [x] 비로그인 사용자는 "로그인이 필요합니다" 안내가 표시된다. - [x] 로그인했지만 `is_admin !== true`이면 "읽기 전용" 뱃지가 헤더에 표시되고, 모든 변경 액션 버튼이 숨겨지고 입력 필드가 `disabled`된다. - [x] 헤더의 캐시 초기화 버튼은 관리자에게만 보이고 confirm 후 `api.flushCache()` 호출. - [x] **채널 탭**: ID/이름/필터 입력으로 추가, 행 클릭으로 설명/태그/순서 인라인 편집, 채널별 "스캔"/"전체 스캔" 결과를 인라인 표시. - [x] **영상 탭**: 채널·상태·제목 필터, 4개 키 정렬 토글(↕/↑/↓), 페이지당 15개 페이지네이션, 체크박스 다중 선택, 행 클릭 시 상세 패널 오픈/토글. - [x] 영상 상세: 자막 자동/수동/생성됨 모드 토글, 자막 수동 가져오기, 프롬프트 표시·복사, 추출된 식당 인라인 편집/삭제/수동 추가, 제목 인라인 수정. - [x] 벌크 자막/LLM/벡터/음식종류/메뉴태그 작업은 SSE 스트리밍 진행률 카드로 실시간 표시되고, 완료 시 목록을 재로드한다. - [x] **식당 탭**: 이름 검색, 6개 키 정렬, 페이지네이션, 행 선택 상세 패널, 9개 필드 인라인 편집, 테이블링/캐치테이블 검색·연결·해제, 연결된 영상 목록. - [x] 벌크 테이블링/캐치테이블 연결은 진행률 막대(선형)로 표시되고 완료 시 alert. - [x] **유저 탭**: 20명 페이지네이션, 관리자 ON/OFF 토글, 유저 선택 시 찜/리뷰/메모 3분할 패널. - [x] **데몬 탭**: 스캔/처리 enable+주기 설정, 처리 건수(1~50), 수동 실행, 결과 메시지 색상 분기(성공/실패). ## 4. 컨텍스트 & 제약 - **프레임워크**: Next.js 16 App Router, Client Component (`"use client"`) - **인증**: `useAuth()` — `user`, `isLoading`, `user.is_admin`. 토큰은 `localStorage["tasteby_token"]`에서 직접 읽어 `Authorization: Bearer` 헤더 부착 (SSE/벌크 fetch 호출 시). - **데이터 호출**: - 일반 CRUD: `@/lib/api` 래퍼 - SSE 스트리밍: `fetch(... POST ...)` + `ReadableStream.getReader()` + `TextDecoder` + `data: ` 라인 파싱 - **스타일**: Tailwind + Saffron 토큰 (`bg-brand-*`, `bg-surface`, `text-brand-*`), 색상 분기로 작업 종류 구분 (자막=brand, LLM=purple, 벡터=teal, 음식분류=amber, 메뉴태그=brand, 테이블링=brand, 캐치테이블=violet) - **제약**: - SSE 처리가 컴포넌트 내 인라인으로 작성되어 있어 코드량 큼 (2,742 LOC) - 페이지네이션은 클라이언트 측 (전체 list fetch 후 slice). 데이터 증가 시 서버 페이징 필요 - 영상/식당 필터링도 클라이언트 측 (`Array.filter`) - **CORS**: `WebConfig`의 allowedMethods에 DELETE/POST가 포함되어야 함 (이미 포함됨) - **가정**: 백엔드가 일관된 SSE 이벤트 (`processing`, `done`, `error`, `complete`, `wait`)를 보낸다. ## 5. 아키텍처 개요 - 파일 구조 (단일 파일 내부 패널 분할): - `AdminPage` (export default) — 탭 상태 + 헤더 + 인증 가드 + 패널 라우팅 - `CacheFlushButton` — 헤더용 캐시 플러시 - `ChannelsPanel` — 채널 탭 - `VideosPanel` — 영상 탭 + 인라인 상세 + 다수 SSE 핸들러 - `RestaurantsPanel` — 식당 탭 + 인라인 상세 + 예약처 연결 - `UsersPanel` — 유저 탭 + 상세 패널 - `DaemonPanel` — 데몬 탭 - 외부 의존: - `@/lib/api` (Channel, Video, VideoDetail, VideoLink, Restaurant, DaemonConfig, getAdminUsers* 등) - `@/lib/auth-context` (`useAuth`) ``` ┌──────────────────────── AdminPage ────────────────────────┐ │ useAuth() → user, isLoading │ │ isAdmin = user?.is_admin === true │ │ │ │ [Header] logo | "Admin" | 읽기전용? | 캐시초기화? | 메인↗ │ │ [Nav] channels videos restaurants users daemon │ │ │ │
│ │ tab==='channels' → ChannelsPanel(isAdmin) │ │ tab==='videos' → VideosPanel(isAdmin) │ │ tab==='restaurants'→ RestaurantsPanel(isAdmin) │ │ tab==='users' → UsersPanel() │ │ tab==='daemon' → DaemonPanel(isAdmin) │ └──────────────────────────────────────────────────────────┘ SSE 패턴 (벌크/벡터/리맵 공통): fetch(POST endpoint, Authorization) → resp.body.getReader() → loop: read → decode → split("\n") → "data: ".substring → JSON.parse → ev.type 분기 processing | wait | done | error | complete → setBulkProgress({...}) ``` - **I/O ↔ 순수 로직 경계**: - I/O: 모든 `api.*`, `fetch`, `localStorage`, `confirm/alert`, SSE 스트림 - 순수: 필터/정렬/페이지 슬라이스 (`filteredVideos`, `sortedVideos`, `pagedVideos`), 정렬 토글, `toggleSelectAll`, `sortIcon`, `statusColor` 매핑 ## 6. 데이터 모델 타입 (in-file 또는 `@/lib/api`): ```ts type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon"; interface Channel { id: string; channel_id: string; channel_name: string; title_filter: string | null; description: string | null; tags: string | null; sort_order: number | null; video_count: number; } type VideoStatus = "pending" | "processing" | "done" | "error" | "skip"; interface Video { id: string; channel_name: string; title: string; status: VideoStatus; has_transcript: boolean; has_llm: boolean; restaurant_count: number; matched_count: number; published_at: string | null; } interface VideoDetail extends Video { transcript: string | null; llm_response: string | null; restaurants: ExtractedRestaurant[]; prompt?: string; } interface Restaurant { id: string; name: string; address: string | null; region: string | null; cuisine_type: string | null; price_range: string | null; phone: string | null; website: string | null; latitude: number | null; longitude: number | null; rating: number | null; rating_count: number | null; business_status: "OPERATIONAL" | "CLOSED_TEMPORARILY" | "CLOSED_PERMANENTLY" | null; google_place_id: string | null; tabling_url: string | null; // "NONE" = 검색 완료 결과 없음 catchtable_url: string | null; } interface AdminUser { id: string; email: string | null; nickname: string | null; avatar_url: string | null; is_admin: boolean; provider: string | null; created_at: string | null; favorite_count: number; review_count: number; memo_count: number; } interface DaemonConfig { scan_enabled: boolean; scan_interval_min: number; process_enabled: boolean; process_interval_min: number; process_limit: number; last_scan_at: string | null; last_process_at: string | null; updated_at: string | null; } // SSE 진행률 상태 type BulkProgress = { label: string; total: number; current: number; currentTitle: string; results: { title: string; detail: string; error?: boolean }[]; waiting?: number }; type VectorProgress = { phase: string; current: number; total: number; name?: string }; type RemapProgress = { current: number; total: number; updated: number }; ``` - **경계 검증**: - 채널 추가: `newId.trim() && newName.trim()` 필수 - 데몬 `process_limit`: 1~50 (input min/max), 음수 방지 - 인라인 편집 좌표/숫자: input type=text → 백엔드 파싱 의존 - 페이지 인덱스: `Math.max(0, ...)` / `Math.min(totalPages-1, ...)`로 클램프 ## 7. 함수 명세 (Function Specs) | 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | |------|-----------|----------------|------|------|-----------|-------| | `AdminPage` | 탭 라우팅 + 인증 가드 | `() => JSX` | - | JSX | isLoading/!user 분기 | **복잡** | | `CacheFlushButton` | Redis 캐시 플러시 | `() => JSX` | - | JSX | `alert` | 단순 | | `ChannelsPanel` | 채널 CRUD + 스캔 | `({ isAdmin }) => JSX` | `isAdmin` | JSX | catch+alert | **복잡** | | `handleAdd` (Ch) | 채널 생성 | `() => Promise` | state | void | alert(e.message) | 단순 | | `handleSaveChannel` | 채널 메타 저장 | `(id) => Promise` | id | void | alert | 단순 | | `handleDelete` (Ch) | 채널 삭제 (confirm) | `(id, name) => Promise` | id, name | void | alert | 단순 | | `handleScan` | 채널 스캔(증분/전체) | `(channelId, full?) => Promise` | id, full | void (scanResult map) | 인라인 메시지 | 단순 | | `VideosPanel` | 영상 목록·필터·정렬·페이지·상세·벌크 | `({ isAdmin }) => JSX` | isAdmin | JSX | 다중 catch | **복잡** | | `handleSelectVideo` | 상세 토글/로드 | `(v) => Promise` | Video | void | alert | 단순 | | `handleProcess` | 대기 영상 일괄 처리 | `() => Promise` | - | void | 인라인 메시지 | 단순 | | `startBulkStream` | 자막/LLM 벌크 SSE 처리 | `(mode, ids?) => Promise` | `"transcript"\|"extract"`, ids? | void | network/parse fail | **복잡** | | `startRebuildVectors` | 전체 벡터 재생성 SSE | `() => Promise` | - | void | alert | **복잡** | | `startRemapCuisine` | 음식 종류 재분류 SSE | `() => Promise` | - | void | alert | **복잡** | | `startRemapFoods` | 메뉴 태그 재생성 SSE | `() => Promise` | - | void | alert | **복잡** | | `handleSort` (V) | 정렬 키/방향 토글 | `(key) => void` | VideoSortKey | void | - | 단순 | | `toggleSelect` / `toggleSelectAll` | 행 선택 관리 | `(id?) => void` | id | void | - | 단순 | | `handleBulkSkip` / `handleBulkDelete` | 선택 행 일괄 처리 | `() => Promise` | - | void | 실패 카운트 alert | 단순 | | `RestaurantsPanel` | 식당 CRUD + 예약처 연결 | `({ isAdmin }) => JSX` | isAdmin | JSX | alert | **복잡** | | `handleSelect` (R) | 식당 상세 로드/폼 prefill | `(r) => void` | Restaurant | void | - | 단순 | | `handleSave` (R) | 식당 업데이트 | `() => Promise` | editForm | void | alert | 단순 | | `handleDelete` (R) | 식당 삭제 (confirm) | `() => Promise` | - | void | alert | 단순 | | 벌크 테이블링/캐치테이블 | 미연결 식당 일괄 검색 SSE | inline async | - | void | alert | **복잡** | | `UsersPanel` | 유저 목록 + 상세 | `() => JSX` | - | JSX | console.error | 단순 | | `loadUsers` | 페이지별 유저 fetch | `(p) => Promise` | page | void | console.error | 단순 | | `handleSelectUser` | 유저 상세(찜/리뷰/메모) 병렬 로드 | `(u) => Promise` | AdminUser | void | console.error | 단순 | | `DaemonPanel` | 데몬 설정/수동 실행 | `({ isAdmin }) => JSX` | isAdmin | JSX | result 메시지 | 단순 | | `handleSave` (D) | 설정 저장 | `() => Promise` | state | void | result 메시지 | 단순 | | `handleRunScan` / `handleRunProcess` | 수동 실행 | `() => Promise` | - | void | result 메시지 | 단순 | > 복잡 기준: SSE 5종(`startBulkStream`, `startRebuildVectors`, `startRemapCuisine`, `startRemapFoods`, 벌크 예약처) 및 각 Panel은 외부 I/O+상태기계+분기 다수 → 별도 `fn-*.md` 설계서 후보. ## 8. 흐름 / 알고리즘 **① 페이지 부트스트랩** 1. `useAuth()` 로딩 중 → "로딩 중..." 2. `!user` → 로그인 안내 + 메인 링크 3. `tab` 상태(`"channels"` 기본) → 해당 패널 렌더, `isAdmin` 전파 **② 영상 벌크 처리 (SSE 패턴 전형)** ``` 1. 선택 ids 또는 pending count 확인 2. confirm → setRunning(true), setBulkProgress(초기값) 3. fetch(POST /api/videos/bulk-{transcript|extract}, Authorization) 4. while (chunk = await reader.read()) { buf += decode; lines = buf.split("\n"); buf = pop for line of lines if line.startsWith("data: "): ev = JSON.parse(line.slice(6)) switch (ev.type) { processing → current=index+1, currentTitle wait → waiting=delay done → results.push({title, detail}) error → results.push({title, detail, error:true}) complete → setRunning(false), load() } } 5. finally: setRunning(false), load() ``` **③ 정렬/필터/페이지 (영상·식당 공통 패턴)** - `filtered = videos.filter(predicate)` → `sorted = [...filtered].sort` → `paged = sorted.slice(page*perPage, (page+1)*perPage)` - 정렬: `sortKey` 동일 시 방향 토글, 다르면 새 키+asc=true **④ 유저 상세** - 행 클릭 → 같은 유저면 닫기, 아니면 `Promise.all([favorites, reviews, memos])` 병렬 로드 → 3분할 그리드 **⑤ 데몬 수동 실행** - 결과 메시지에 "실패"/"API" 포함 여부로 빨강/초록 색상 분기 ## 9. 엣지케이스 & 에러 처리 - **권한 없음(읽기 전용)**: 모든 변경 액션 버튼 미렌더 + 입력 `disabled`. 단, 헤더에 "읽기 전용" 뱃지 노출하여 모드를 명시. - **isLoading 동안 빠른 클릭**: 페이지 자체가 로딩 화면이라 차단됨. - **SSE 도중 네트워크 끊김**: `while` 루프 종료 → finally에서 `setRunning(false)` + `load()`. 진행률은 마지막 값에 멈춤 (재시도는 사용자 수동). - **SSE 부분 라인**: `buf`에 미완료 라인 보관, 다음 chunk와 합쳐서 파싱 (`buf = lines.pop()`). - **JSON.parse 실패**: 개별 라인 try/catch → 무시하고 계속. - **pending.count === 0**: alert 후 조기 종료. - **bulk 중복 실행 방지**: 같은 또는 충돌 작업 버튼들에 `disabled={... || ...}` 다중 조건. - **확인 다이얼로그 취소**: 모든 파괴적 액션은 `confirm()` 우선. - **테이블링 URL "NONE"**: 검색 완료-결과없음 의미. UI에서 별도 텍스트로 처리. - **알 수 없는 유저**: `nickname || email || "?"` → 첫글자 대문자 폴백. - **페이지 인덱스 boundary**: `Math.max(0, p-1)` / `Math.min(totalPages-1, p+1)`. - **선택 행 삭제 실패 누적**: 카운트하여 마지막에 `${failed}개 삭제 실패` alert. - **인라인 편집 중 데이터 새로고침**: `setEditingRestIdx(null)` 등으로 리셋. - **alert/confirm 의존**: 모바일 어드민에서는 alert UX가 거칠지만 어드민 한정으로 허용. ## 10. 테스트 계획 현재 자동화 테스트 없음 (TBD). 권장 구성: - **단위 (Vitest + RTL)**: 1. `AdminPage` 인증 가드 — `user=null` / `user.is_admin=false` 분기 렌더 2. `ChannelsPanel.handleAdd` — 빈 입력 차단, 정상 호출 시 reload 3. `VideosPanel` 필터/정렬/페이징 순수 로직 (`filteredVideos`/`sortedVideos`/`pagedVideos`) 추출 후 테스트 4. `toggleSelectAll` — 전체 선택/해제 토글 5. `statusColor` 매핑 - **통합 (MSW + 가짜 SSE)**: - `startBulkStream("transcript")` — SSE 라인 5종 시퀀스 주입 → 진행률 상태 전이 검증 - 부분 라인 분할(`buf.split("\n")`) 케이스 - 권한 없는 사용자에 대해 변경 API 호출이 시도되지 않는지 - **E2E (Playwright)**: - 관리자 로그인 → 채널 추가 → 스캔 → 영상 상태 변화 확인 - 식당 상세에서 좌표 수정 후 저장 → 목록 갱신 - **드라이런 전략**: `process.env.NEXT_PUBLIC_API_URL`을 MSW로 가로채 SSE를 ReadableStream으로 모킹. ## 11. 리스크 & 대안 검토 - **단일 파일 2,742 LOC**: 가독성·테스트성 저하. 대안: 탭별 파일 분리(`admin/_components/*Panel.tsx`). 채택 보류 (안정성 우선), ADR 후보. - **클라이언트 측 필터/정렬/페이징**: 데이터 증가 시 메모리·렌더 비용. 대안: 서버 페이징·정렬. 영상/식당이 수만 건 도달 시 전환 필수. - **SSE 코드 중복**: 5개 핸들러가 동일 패턴. 대안: `useSSEStream(endpoint, onEvent)` 커스텀 훅. 추후 리팩토링 권장. - **`localStorage` 직접 접근**: api 레이어 외 4곳에서 토큰을 직접 읽음. 대안: `api.fetchStream()` 헬퍼 도입. - **alert/confirm UX**: 일관된 토스트/모달 시스템 부재. 어드민 한정이므로 유지. - **권한 분기 누락 위험**: `isAdmin` 가드를 모든 액션 버튼에 수동으로 분산 → 신규 액션 추가 시 가드 누락 가능. 대안: `` 래퍼 컴포넌트. - **타입 캐스팅**: `(res as Record).filtered` 등 ad-hoc 캐스팅 → 응답 스키마를 타입으로 명세하는 것이 안전. - **에러 swallowing**: 다수의 `catch { /* ignore */ }` — 운영자 디버깅 어려움. 콘솔 로깅 보강 권장. ## 12. 미해결 질문 (Open Questions) - 어드민 페이지를 탭별 라우트(`/admin/channels`, `/admin/videos`...)로 쪼개야 하는가? (북마크/딥링크 측면) - SSE 도중 페이지를 떠나면 진행률이 손실됨 — 서버 측 작업 상태 폴링 API가 필요한가? - 벌크 작업 동시 실행을 허용해야 할 시나리오가 있는가? (현재는 상호배타) - 캐치테이블/테이블링 외 추가 예약처(망고플레이트 등)가 들어올 때 어드민 UI 패턴은? - 권한 모델 확장(편집자/뷰어 등 다단계)이 필요한가? 현재는 admin/non-admin 이진. - 사용자 삭제·일괄 차단 등 운영 기능은 별도 설계서로 분리할 것인가? - 어드민 활동 감사 로그(누가 무엇을 언제 변경했는지) 표시는?