Files
tasteby/docs/design/282-frontend-admin/README.md
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical).
- 17개 설계서를 Draft → Approved로 갱신
- #267(backend-user)은 critical 결함으로 06-Reviewer 유지
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영
  (critical 3 / major 46 / minor 75)
- docs/README.md에 18개 설계서 인덱스 추가
- CHANGELOG.md 2026-06-15 섹션 추가

Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
2026-06-15 11:08:18 +09:00

19 KiB

설계서: 프론트 - 어드민 페이지 (#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. 인수조건 (이미 구현된 동작 기준)

  • 비로그인 사용자는 "로그인이 필요합니다" 안내가 표시된다.
  • 로그인했지만 is_admin !== true이면 "읽기 전용" 뱃지가 헤더에 표시되고, 모든 변경 액션 버튼이 숨겨지고 입력 필드가 disabled된다.
  • 헤더의 캐시 초기화 버튼은 관리자에게만 보이고 confirm 후 api.flushCache() 호출.
  • 채널 탭: ID/이름/필터 입력으로 추가, 행 클릭으로 설명/태그/순서 인라인 편집, 채널별 "스캔"/"전체 스캔" 결과를 인라인 표시.
  • 영상 탭: 채널·상태·제목 필터, 4개 키 정렬 토글(↕/↑/↓), 페이지당 15개 페이지네이션, 체크박스 다중 선택, 행 클릭 시 상세 패널 오픈/토글.
  • 영상 상세: 자막 자동/수동/생성됨 모드 토글, 자막 수동 가져오기, 프롬프트 표시·복사, 추출된 식당 인라인 편집/삭제/수동 추가, 제목 인라인 수정.
  • 벌크 자막/LLM/벡터/음식종류/메뉴태그 작업은 SSE 스트리밍 진행률 카드로 실시간 표시되고, 완료 시 목록을 재로드한다.
  • 식당 탭: 이름 검색, 6개 키 정렬, 페이지네이션, 행 선택 상세 패널, 9개 필드 인라인 편집, 테이블링/캐치테이블 검색·연결·해제, 연결된 영상 목록.
  • 벌크 테이블링/캐치테이블 연결은 진행률 막대(선형)로 표시되고 완료 시 alert.
  • 유저 탭: 20명 페이지네이션, 관리자 ON/OFF 토글, 유저 선택 시 찜/리뷰/메모 3분할 패널.
  • 데몬 탭: 스캔/처리 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        │
│                                                          │
│  <main>                                                   │
│    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):

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<void> state void alert(e.message) 단순
handleSaveChannel 채널 메타 저장 (id) => Promise<void> id void alert 단순
handleDelete (Ch) 채널 삭제 (confirm) (id, name) => Promise<void> id, name void alert 단순
handleScan 채널 스캔(증분/전체) (channelId, full?) => Promise<void> id, full void (scanResult map) 인라인 메시지 단순
VideosPanel 영상 목록·필터·정렬·페이지·상세·벌크 ({ isAdmin }) => JSX isAdmin JSX 다중 catch 복잡
handleSelectVideo 상세 토글/로드 (v) => Promise<void> Video void alert 단순
handleProcess 대기 영상 일괄 처리 () => Promise<void> - void 인라인 메시지 단순
startBulkStream 자막/LLM 벌크 SSE 처리 (mode, ids?) => Promise<void> "transcript"|"extract", ids? void network/parse fail 복잡
startRebuildVectors 전체 벡터 재생성 SSE () => Promise<void> - void alert 복잡
startRemapCuisine 음식 종류 재분류 SSE () => Promise<void> - void alert 복잡
startRemapFoods 메뉴 태그 재생성 SSE () => Promise<void> - void alert 복잡
handleSort (V) 정렬 키/방향 토글 (key) => void VideoSortKey void - 단순
toggleSelect / toggleSelectAll 행 선택 관리 (id?) => void id void - 단순
handleBulkSkip / handleBulkDelete 선택 행 일괄 처리 () => Promise<void> - void 실패 카운트 alert 단순
RestaurantsPanel 식당 CRUD + 예약처 연결 ({ isAdmin }) => JSX isAdmin JSX alert 복잡
handleSelect (R) 식당 상세 로드/폼 prefill (r) => void Restaurant void - 단순
handleSave (R) 식당 업데이트 () => Promise<void> editForm void alert 단순
handleDelete (R) 식당 삭제 (confirm) () => Promise<void> - void alert 단순
벌크 테이블링/캐치테이블 미연결 식당 일괄 검색 SSE inline async - void alert 복잡
UsersPanel 유저 목록 + 상세 () => JSX - JSX console.error 단순
loadUsers 페이지별 유저 fetch (p) => Promise<void> page void console.error 단순
handleSelectUser 유저 상세(찜/리뷰/메모) 병렬 로드 (u) => Promise<void> AdminUser void console.error 단순
DaemonPanel 데몬 설정/수동 실행 ({ isAdmin }) => JSX isAdmin JSX result 메시지 단순
handleSave (D) 설정 저장 () => Promise<void> state void result 메시지 단순
handleRunScan / handleRunProcess 수동 실행 () => Promise<void> - 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].sortpaged = 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 가드를 모든 액션 버튼에 수동으로 분산 → 신규 액션 추가 시 가드 누락 가능. 대안: <AdminGate> 래퍼 컴포넌트.
  • 타입 캐스팅: (res as Record<string, unknown>).filtered 등 ad-hoc 캐스팅 → 응답 스키마를 타입으로 명세하는 것이 안전.
  • 에러 swallowing: 다수의 catch { /* ignore */ } — 운영자 디버깅 어려움. 콘솔 로깅 보강 권장.

12. 미해결 질문 (Open Questions)

  • 어드민 페이지를 탭별 라우트(/admin/channels, /admin/videos...)로 쪼개야 하는가? (북마크/딥링크 측면)
  • SSE 도중 페이지를 떠나면 진행률이 손실됨 — 서버 측 작업 상태 폴링 API가 필요한가?
  • 벌크 작업 동시 실행을 허용해야 할 시나리오가 있는가? (현재는 상호배타)
  • 캐치테이블/테이블링 외 추가 예약처(망고플레이트 등)가 들어올 때 어드민 UI 패턴은?
  • 권한 모델 확장(편집자/뷰어 등 다단계)이 필요한가? 현재는 admin/non-admin 이진.
  • 사용자 삭제·일괄 차단 등 운영 기능은 별도 설계서로 분리할 것인가?
  • 어드민 활동 감사 로그(누가 무엇을 언제 변경했는지) 표시는?