fromis_9/docs/development.md
caadiq 7c20e9bb17 feat(schedule): 행사 수정 폼 + 공개 상세 페이지 + 지도
- Admin: EventEditForm 추가 (기존 포스터 유지 + 신규 추가 조합), ScheduleItem 편집 경로에 '행사' 분기
- PC 공개 상세: EventSection 추가 - 포스터 Swiper 슬라이드 + 호버 화살표, 클릭 시 Lightbox, 카카오맵 + 마커 + 장소명 오버레이, 관련 링크는 중간점+primary 색상, max-w-5xl 및 text-2xl로 크기 확대
- Mobile 공개 상세: MobileEventSection 추가 (포스터/장소/지도/링크)
- KakaoMap 공용 컴포넌트 신규 (SDK 1회 로드 공유), VITE_KAKAO_JS_KEY 사용
- .gitignore: frontend/.env 제외
- routes/admin/events.js: PUT 핸들러의 addOrUpdateSchedule → syncScheduleById 정정
- 관련 문서(api/architecture/development) 업데이트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:24:01 +09:00

12 KiB

개발/배포 가이드

개발 모드

실행

cd /docker/fromis_9
docker compose up -d --build

컨테이너 구성

컨테이너 포트 설명
fromis9-frontend 80 Vite 개발 서버, HMR 지원
fromis9-backend 80 Fastify API, --watch 모드
fromis9-meilisearch 7700 검색 엔진
fromis9-redis 6379 캐시
  • Vite가 /api, /docs 요청을 백엔드로 프록시

로그 확인

# 전체 로그
docker compose logs -f

# 백엔드만
docker compose logs -f fromis9-backend

# 프론트엔드만
docker compose logs -f fromis9-frontend

코드 수정

  • frontend/, backend/ 폴더가 컨테이너에 마운트됨
  • node_modules도 호스트 폴더에 직접 설치됨
  • 코드 수정 시 자동 반영 (HMR, watch)

재시작

# 백엔드만 재시작
docker compose restart fromis9-backend

# 프론트엔드만 재시작
docker compose restart fromis9-frontend

# 전체 재시작
docker compose restart

배포 모드 전환

1. Dockerfile 수정

backend/Dockerfile:

# 개발 모드 주석처리
# FROM node:20-alpine
# ...

# 배포 모드 주석해제
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

frontend/Dockerfile:

# 개발 모드 주석처리
# FROM node:20-alpine
# ...

# 배포 모드 주석해제
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

2. 빌드 및 실행

docker compose up -d --build

환경 변수 (.env)

# 서버
PORT=80

# 데이터베이스
DB_HOST=mariadb
DB_PORT=3306
DB_USER=...
DB_PASSWORD=...
DB_NAME=fromis9

# Redis
REDIS_HOST=fromis9-redis
REDIS_PORT=6379

# Meilisearch
MEILI_HOST=http://fromis9-meilisearch:7700
MEILI_MASTER_KEY=...

# JWT
JWT_SECRET=...

# AWS S3
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=...
S3_BUCKET=...

# YouTube API
YOUTUBE_API_KEY=...

Caddy 설정

위치: /docker/caddy/Caddyfile

fromis_9 사이트 설정

fromis9.caadiq.co.kr {
    import custom_errors
    reverse_proxy fromis9-frontend:80
}

설정 설명

  • import custom_errors: 공통 에러 페이지 (403, 404, 500, 502, 503)
  • reverse_proxy fromis9-frontend:80: Docker 네트워크로 프론트엔드 컨테이너에 연결
  • 업로드 크기 제한 없음 (Caddy 기본값)

Caddy 재시작

docker exec caddy caddy reload --config /etc/caddy/Caddyfile

네트워크 구조

인터넷 → Caddy (:443) → fromis9-frontend (:80) → fromis9-backend (:80)
                                               ↓
                         MariaDB, Redis, Meilisearch (내부 네트워크)

프론트엔드 개발 가이드

API 클라이언트 구조

src/api/
├── index.js          # 전체 export
├── client.js         # api, authApi 헬퍼 (에러 처리, 토큰 주입)
├── public/           # 공개 API (인증 불필요)
│   ├── albums.js     # getAlbums, getAlbumByName, getTrack
│   ├── members.js    # getMembers
│   └── schedules.js  # getSchedules, getSchedule, getCategories
└── admin/            # 관리자 API (인증 필요)
    ├── auth.js       # login, verifyToken
    ├── albums.js     # createAlbum, updateAlbum, deleteAlbum, ...
    ├── bots.js       # getBots, startBot, stopBot, syncBot, getXBot, createXBot, updateXBot, deleteXBot, lookupXProfile
    ├── categories.js # getCategories, createCategory, updateCategory, ...
    ├── members.js    # updateMember
    ├── schedules.js  # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
    ├── stats.js      # getStats
    └── suggestions.js # getDict, saveDict

client.js 헬퍼:

// 공개 API 헬퍼 (인증 불필요)
import { api } from '@/api/client';

api.get('/albums');
api.post('/schedules/suggestions/save', { query: '검색어' });

// 인증 API 헬퍼 (토큰 자동 주입)
import { authApi } from '@/api/client';

authApi.get('/admin/stats');
authApi.post('/admin/schedules', data);
authApi.put('/admin/albums/1', data);
authApi.del('/admin/schedules/1');

사용 예시:

// 공개 API
import { getSchedules, getSchedule } from '@/api/public/schedules';

// 관리자 API
import * as botsApi from '@/api/admin/bots';

React Query 사용 (데이터 페칭)

데이터 페칭 시 useEffect 대신 useQuery를 사용합니다.

이유:

  • useEffect는 React StrictMode에서 2번 실행됨 (개발 모드)
  • useQuery는 자동 캐싱, 중복 요청 방지, 에러/로딩 상태 관리 제공

예시:

// ❌ Bad - useEffect 사용
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => setData(data))
    .finally(() => setLoading(false));
}, []);

// ✅ Good - useQuery 사용
import { useQuery } from '@tanstack/react-query';

const { data, isLoading } = useQuery({
  queryKey: ['data'],
  queryFn: () => fetch('/api/data').then(res => res.json()),
});

캐시 무효화:

import { useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();

// 특정 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['schedules'] });

// 모든 쿼리 무효화
queryClient.invalidateQueries();

YouTube 봇 동기화

동기화 흐름 (syncNewVideos)

  1. fetchRecentVideoIds() — Activities API로 최근 영상 ID 목록만 조회 (1 unit)
  2. DB에서 이미 존재하는 video_id 필터링
  3. 새 영상만 fetchVideoInfo() — Videos API로 상세 정보 조회 (새 영상당 1 unit)
  4. saveVideo() — DB 저장 + Meilisearch 동기화

API 할당량

  • 일일 할당량: 10,000 units
  • 새 영상 없을 때: activities.list 1 unit만 소비
  • 새 영상 있을 때: 1 + 새 영상 수 units
  • 1분 간격, 3채널 기준: ~4,320 units/일 (43%)

폴링 모드 (bot_youtube)

두 가지 모드 중 하나를 선택 — 봇 레코드에 cron_interval(분) 또는 weekly_schedule_config(JSON) 중 하나가 채워짐.

상시 폴링 (기본)

  • cron_interval이 분 단위로 지정됨. cron: */N * * * *
  • 매주 여러 날 업로드하는 채널에 적합 (예: studio_fromis_9)

주간 지정 시간 (weekly)

  • weekly_schedule_config: { dayOfWeek, startTime, intervalSeconds, durationMinutes }
  • 주 1회만 특정 요일·시각에 업로드되는 채널용 (예: 워크맨 매주 수 19:00)
  • cron: mm hh * * dayOfWeek — 시작 시각 1회만 트리거
  • 트리거 시 startWeeklyBurst()setIntervalintervalSeconds마다 폴링
  • 종료 조건 (둘 중 먼저):
    1. 새 영상 1개 발견 (stopOnFound, 기본 동작)
    2. durationMinutes 경과
  • 평상시에는 API 호출 없음 → 할당량 최소화
  • burstTimers Map에서 봇 ID별 내부 타이머 추적, stopBot()에서 같이 정리

두 모드 모두 MAX_CONSECUTIVE_ERRORS (기본 10회) 자동 정지 로직이 공통 적용됨.

주요 API 함수 (services/youtube/api.js)

함수 YouTube API 용도
fetchRecentVideoIds() activities.list (1 unit) 최근 영상 ID 목록 조회
fetchVideoInfo() videos.list (1 unit) 단일 영상 상세 정보
fetchAllVideos() playlistItems.list + videos.list 전체 영상 초기 동기화
getChannelByHandle() channels.list (1 unit) 핸들로 채널 조회
getChannelInfo() channels.list (1 unit) 채널 정보 (배너 등)

행사 (Event)

schedule_categories의 "행사" 카테고리(id=11)로 일반 일정과 분리된 상세 테이블(schedule_event)을 가짐. 세부 타입(subtype)으로 폼/UI를 분기.

세부 타입

slug label 현재 사용 필드
university 학교 축제 school_name, venue(카카오맵), 멤버, 포스터 다중, URL 다중

추가 세부 타입을 도입할 때는 1) frontend/src/pages/pc/admin/schedules/form/event/index.jsxSUBTYPES 상수에 추가, 2) 필요 시 schedule_event 컬럼 확장 (또는 details JSON), 3) routes/admin/events.jsVALID_SUBTYPES, 4) 상세 페이지 섹션(EventSection, MobileEventSection)에 분기 추가.

장소 관리

  • event_venues 테이블에 name/address/road_address/lat/lng/kakao_id 저장
  • 카카오맵 검색은 기존 /api/admin/kakao/places 엔드포인트 재사용 (콘서트와 동일)
  • kakao_id 기준 upsert — 같은 장소가 여러 행사에서 쓰여도 row는 1개

포스터 업로드 경로

S3: event/{scheduleId}/poster/{original|medium_800|thumb_400}/{파일명} services/image.jsuploadEventPoster(scheduleId, filename, buffer) 사용.

Meilisearch 검색 지원

  • source_nameschool_name이 들어가 Meilisearch 검색 가능
  • 부분 입력 대응: resolveSchoolNames(db, query)schedule_event 테이블에서 LIKE로 부분 일치 학교명을 찾아 검색 쿼리를 확장 (예: "인천대" → "인천대학교" 쿼리 추가). 멤버 별명 확장과 동일한 패턴.

X 봇 / Nitter

X 봇은 /docker/nitter/의 Nitter 인스턴스(zedeus/nitter)를 스크래핑하여 트윗을 수집합니다. 백엔드는 NITTER_URL(기본값 http://nitter:8080)로 접속합니다.

세션 관리 (sessions.jsonl)

X는 비로그인 API 접근을 막고 있어, Nitter는 /docker/nitter/sessions.jsonl에 저장된 실제 X 계정 쿠키(auth_token, ct0)로 요청을 보냅니다.

  • 세션이 만료/차단되면 Nitter 측에서 no sessions available for API 로그가 찍히고 SIGSEGV로 크래시 → 백엔드에서 [x-N] 동기화 오류: 요청 타임아웃 반복 (단, 연속 10회 실패 시 자동 정지 — logs.md 참조)
  • renew_sessions.py가 매시 세션을 점검하지만, 판별 기준(check_nitter())이 약하면 만료 상태에서도 "정상"으로 오판할 수 있음 → 기준은 트윗 본문(tweet-content 블록) 렌더 여부로 유지할 것
  • 수동 갱신: python3 /docker/nitter/create_session_curl.py <username> <password> 로 새 쿠키 발급 후 sessions.jsonl 두 줄을 덮어쓰고 docker compose restart nitter 실행

포크 관련 메모

unixfox/nitter 같은 구버전 기반 포크는 sessions.jsonl을 아예 인식하지 못해 트윗 수집이 불가능합니다. 교체 시에는 바이너리에 sessions 처리 심볼이 있는지 확인할 것(예: strings nitter | grep sessions.jsonl).


활동 로그 시스템

관리자/봇의 모든 활동을 logs 테이블에 기록하고 관리자 페이지에서 조회.

로그 기록 방법

import { logActivity } from '../utils/log.js';

// fire-and-forget: 로그 실패가 비즈니스 로직에 영향 주지 않음
logActivity(db, {
  actor: 'admin',              // "admin" 또는 봇 ID ("youtube-3", "x-1")
  action: 'create',            // create, update, delete, upload, start, stop, sync_complete, error
  category: 'album',           // album, schedule, member, bot, category, dict, concert, sync
  targetType: 'album',         // 대상 타입 (optional)
  targetId: 12,                // 대상 DB ID (optional)
  summary: '앨범 생성: 제목',   // 한 줄 요약
  details: { key: 'value' },   // 추가 정보 JSON (optional)
});

새 기능 추가 시

로그는 자동 수집이 아니므로, 새로운 라우트나 기능을 추가할 때 logActivity 호출을 직접 넣어야 합니다.

로그 대상

  • 관리자 라우트: 앨범/일정/멤버/봇/카테고리/사전/콘서트 CRUD
  • 봇 스케줄러: 동기화 완료(addedCount > 0), 동기화 에러
  • 봇 서비스: YouTube 영상 추가, X 트윗 추가

유용한 명령어

# 컨테이너 상태 확인
docker compose ps

# 완전 재시작
docker compose down && docker compose up -d --build

# Meilisearch 동기화
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
  -H "Authorization: Bearer <token>"

# Redis 확인 (SCAN 사용 권장)
docker exec fromis9-redis redis-cli SCAN 0 MATCH "*" COUNT 100