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 (백로그)
19 KiB
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:라인 파싱
- 일반 CRUD:
- 스타일: 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매핑
- I/O: 모든
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. 흐름 / 알고리즘
① 페이지 부트스트랩
useAuth()로딩 중 → "로딩 중..."!user→ 로그인 안내 + 메인 링크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):
AdminPage인증 가드 —user=null/user.is_admin=false분기 렌더ChannelsPanel.handleAdd— 빈 입력 차단, 정상 호출 시 reloadVideosPanel필터/정렬/페이징 순수 로직 (filteredVideos/sortedVideos/pagedVideos) 추출 후 테스트toggleSelectAll— 전체 선택/해제 토글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 이진.
- 사용자 삭제·일괄 차단 등 운영 기능은 별도 설계서로 분리할 것인가?
- 어드민 활동 감사 로그(누가 무엇을 언제 변경했는지) 표시는?