# fromis_9 팬사이트 프로젝트 분석 결과 > **분석일**: 2026-01-11 > **프로젝트 경로**: `/docker/fromis_9` > **사이트 URL**: `https://fromis9.caadiq.co.kr` --- > [!IMPORTANT] > **현재 개발 환경 활성화 상태** > `docker-compose.dev.yml`로 실행 중이며, 프론트엔드는 Vite HMR, 백엔드는 Node.js로 분리 운영됩니다. > > - **프론트엔드**: `fromis9-frontend` (Vite dev server, 포트 80) > - **백엔드**: `fromis9-backend` (Express, 포트 3000) > - 파일 수정 시 **자동 반영** (빌드 불필요) ## 1. 시스템 아키텍처 개요 ```mermaid graph TB subgraph "클라이언트" PC[PC 브라우저] Mobile[모바일 브라우저] end subgraph "Caddy 역방향 프록시" Caddy[fromis9.caadiq.co.kr
500MB 업로드 허용] end subgraph "Docker 컨테이너" Frontend[fromis9-frontend:80
React + Express] Meili[fromis9-meilisearch:7700
검색 엔진] end subgraph "외부 서비스" MariaDB[(MariaDB
fromis9 DB)] RustFS[RustFS S3
이미지 스토리지] YouTube[YouTube API] Nitter[Nitter
X/Twitter 브릿지] end PC --> Caddy Mobile --> Caddy Caddy --> Frontend Frontend --> MariaDB Frontend --> Meili Frontend --> RustFS Frontend --> YouTube Frontend --> Nitter ``` ### 기술 스택 | 계층 | 기술 | | ------------------- | ----------------------------- | | **프론트엔드** | React 18 + Vite + TailwindCSS | | **백엔드** | Node.js (Express) | | **데이터베이스** | MariaDB (`fromis9` DB) | | **검색 엔진** | Meilisearch v1.6 | | **미디어 스토리지** | RustFS (S3 호환) | | **역방향 프록시** | Caddy (SSL 자동화) | --- ## 2. 디렉토리 구조 ``` /docker/fromis_9 ├── .env # 환경 변수 (DB, S3, API 키) ├── docker-compose.yml # 프로덕션 오케스트레이션 ├── docker-compose.dev.yml # 개발 환경 (HMR 지원) ├── Dockerfile # 빌드 정의 │ ├── backend/ # Express API 서버 │ ├── server.js # 진입점, 라우팅, Meilisearch 초기화 │ ├── routes/ │ │ ├── admin.js # 관리자 CRUD (60KB, 핵심 로직) │ │ ├── albums.js # 앨범 조회 API │ │ ├── members.js # 멤버 조회 API │ │ ├── schedules.js # 일정 조회/검색 API │ │ └── stats.js # 통계 API │ ├── services/ │ │ ├── meilisearch.js # 검색 인덱스 관리 │ │ ├── meilisearch-bot.js # 1시간 주기 동기화 봇 │ │ ├── youtube-bot.js # YouTube API 수집 봇 │ │ ├── youtube-scheduler.js # Cron 스케줄러 │ │ └── x-bot.js # X(Nitter) 수집 봇 │ └── lib/ │ ├── db.js # MariaDB 커넥션 풀 │ └── date.js # Day.js 기반 날짜 유틸리티 │ └── frontend/ # React SPA ├── vite.config.js # 빌드 및 프록시 설정 ├── tailwind.config.js # 테마 (Primary: #FF4D8D) └── src/ ├── App.jsx # 라우팅 (PC/Mobile 분기) ├── main.jsx # 진입점 ├── index.css # 글로벌 스타일 ├── pc.css # PC 전용 스타일 ├── mobile.css # Mobile 전용 스타일 ├── pages/ │ ├── pc/public/ # PC 공개 페이지 │ ├── pc/admin/ # PC 관리자 페이지 │ ├── mobile/public/ # Mobile 공개 페이지 │ └── mobile/admin/ # Mobile 관리자 페이지 ├── components/ # 재사용 컴포넌트 ├── api/ # API 호출 유틸리티 ├── stores/ # Zustand 상태 관리 └── utils/ # 공통 유틸리티 ``` --- ## 3. 데이터베이스 스키마 (MariaDB `fromis9`) ### 테이블 목록 (14개) ``` admin_users # 관리자 계정 members # 그룹 멤버 프로필 albums # 앨범 메타데이터 tracks # 앨범 트랙 목록 album_photos # 앨범 컨셉 포토 album_photo_members # 포토-멤버 매핑 album_teasers # 티저 미디어 schedules # 일정/활동 schedule_categories # 일정 카테고리 schedule_members # 일정-멤버 매핑 schedule_images # 일정 이미지 bots # 자동화 봇 설정 bot_youtube_config # YouTube 봇 설정 bot_x_config # X 봇 설정 ``` ### 주요 테이블 상세 #### `members` - 멤버 프로필 | 필드 | 타입 | 설명 | | ---------- | ------------ | ------------- | | id | int | PK | | name | varchar(50) | 이름 | | birth_date | date | 생년월일 | | position | varchar(100) | 포지션 | | image_url | varchar(500) | 프로필 이미지 | | instagram | varchar(200) | 인스타그램 | | is_former | tinyint | 전 멤버 여부 | #### `albums` - 앨범 정보 | 필드 | 타입 | 설명 | | ------------------ | -------------------------- | -------------------- | | id | int | PK | | title | varchar(200) | 앨범명 | | album_type | varchar(100) | 전체 타입명 | | album_type_short | enum('정규','미니','싱글') | 축약 타입 | | release_date | date | 발매일 | | cover_original_url | varchar(500) | 원본 커버 (lossless) | | cover_medium_url | varchar(500) | 중간 커버 (800px) | | cover_thumb_url | varchar(500) | 썸네일 (400px) | | folder_name | varchar(200) | S3 폴더명 | | description | text | 앨범 설명 | #### `schedules` - 일정 | 필드 | 타입 | 설명 | | ------------------ | ------------ | ---------------------------- | | id | int | PK | | title | varchar(500) | 일정 제목 | | category_id | int | FK → schedule_categories | | date | date | 날짜 | | time | time | 시간 | | end_date, end_time | date, time | 종료 시간 | | description | text | 상세 설명 | | location\_\* | various | 위치 정보 (이름, 주소, 좌표) | | source_url | varchar(500) | 출처 URL | | source_name | varchar(100) | 출처명 | --- ## 4. API 라우트 구조 ### 공개 API (`/api/*`) | 엔드포인트 | 메서드 | 설명 | | --------------------------- | ------ | ------------------------ | | `/api/health` | GET | 헬스체크 | | `/api/members` | GET | 멤버 목록 | | `/api/albums` | GET | 앨범 목록 (트랙 포함) | | `/api/albums/by-name/:name` | GET | 앨범명으로 상세 조회 | | `/api/albums/:id` | GET | ID로 앨범 상세 조회 | | `/api/schedules` | GET | 일정 목록 (검색, 필터링) | | `/api/schedules/categories` | GET | 카테고리 목록 | | `/api/schedules/:id` | GET | 개별 일정 조회 | | `/api/stats` | GET | 사이트 통계 | ### 관리자 API (`/api/admin/*`) | 엔드포인트 | 메서드 | 설명 | | ----------------------------------- | --------------- | ----------------- | | `/api/admin/login` | POST | 로그인 (JWT 발급) | | `/api/admin/verify` | GET | 토큰 검증 | | `/api/admin/albums` | POST/PUT/DELETE | 앨범 CRUD | | `/api/admin/albums/:albumId/photos` | POST/DELETE | 컨셉 포토 관리 | | `/api/admin/schedules` | POST/PUT/DELETE | 일정 CRUD | | `/api/admin/bots` | GET/POST/PUT | 봇 관리 | --- ## 5. 프론트엔드 라우팅 (PC/Mobile 분기) ```jsx // App.jsx - react-device-detect 사용 {/* PC 환경 */} } /> } /> } /> } /> } /> } /> {/* Mobile 환경 */} ``` ### 관리자 페이지 (/admin/\*) - `/admin` - 로그인 - `/admin/dashboard` - 대시보드 - `/admin/members` - 멤버 관리 - `/admin/albums` - 앨범 관리 - `/admin/schedule` - 일정 관리 - `/admin/schedule/bots` - 봇 관리 --- ## 6. 자동화 봇 시스템 ### 봇 유형 및 동작 | 봇 타입 | 수집 대상 | 동작 방식 | | --------------- | ------------------ | ---------------------------------------------- | | **YouTube** | 채널 영상 | YouTube API로 최근 영상 수집, Shorts 자동 판별 | | **X** | @realfromis_9 트윗 | Nitter 브릿지 → RSS 파싱 | | **Meilisearch** | 일정 데이터 | 1시간 주기 전체 동기화 | ### 스케줄러 동작 흐름 ```mermaid sequenceDiagram participant Server as server.js participant Scheduler as youtube-scheduler.js participant Bot as youtube-bot.js / x-bot.js participant DB as MariaDB participant Meili as Meilisearch Server->>Scheduler: initScheduler() Scheduler->>DB: SELECT * FROM bots WHERE status='running' Scheduler->>Scheduler: node-cron 등록 loop 매 N분 (cron_expression) Scheduler->>Bot: syncNewVideos() / syncNewTweets() Bot->>DB: 중복 체크 (source_url) Bot->>DB: INSERT INTO schedules Bot->>Meili: addOrUpdateSchedule() end ``` --- ## 7. 이미지 처리 파이프라인 ### Sharp 3단계 변환 모든 업로드 이미지는 자동으로 3가지 해상도로 변환: ```javascript // admin.js 에서 처리 const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ sharp(buffer).webp({ lossless: true }).toBuffer(), // original/ sharp(buffer).resize(800, null).webp({ quality: 80 }), // medium_800/ sharp(buffer).resize(400, null).webp({ quality: 80 }), // thumb_400/ ]); ``` ### RustFS 저장 구조 ``` s3.caadiq.co.kr/fromis-9/ ├── albums/{folder_name}/ │ ├── original/cover.webp │ ├── medium_800/cover.webp │ └── thumb_400/cover.webp └── photos/{album_id}/ ├── original/{filename}.webp ├── medium_800/{filename}.webp └── thumb_400/{filename}.webp ``` --- ## 8. 검색 시스템 (Meilisearch) ### 검색 특징 - **영한 자판 변환**: Inko 라이브러리로 영문 자판 입력 → 한글 변환 - **유사도 임계값**: 0.5 미만 결과 필터링 - **검색 대상 필드**: title, member_names, description, source_name, category_name ### 인덱스 설정 ```javascript // meilisearch.js await index.updateSearchableAttributes([ "title", "member_names", "description", "source_name", "category_name", ]); await index.updateRankingRules([ "words", "typo", "proximity", "attribute", "exactness", "date:desc", // 최신 날짜 우선 ]); ``` --- ## 9. 네트워크 설정 (Caddy) ```caddyfile # /docker/caddy/Caddyfile fromis9.caadiq.co.kr { import custom_errors # 대용량 업로드 허용 (500MB) - 컨셉 포토 일괄 업로드용 request_body { max_size 500MB } reverse_proxy fromis9-frontend:80 } ``` --- ## 10. 환경 변수 (.env) | 변수 | 용도 | | ------------------------ | -------------------- | | `DB_HOST=mariadb` | MariaDB 컨테이너 | | `DB_NAME=fromis9` | 데이터베이스명 | | `DB_USER/PASSWORD` | DB 접속 정보 | | `RUSTFS_ENDPOINT` | RustFS S3 엔드포인트 | | `RUSTFS_PUBLIC_URL` | 공개 S3 URL | | `RUSTFS_BUCKET=fromis-9` | S3 버킷명 | | `YOUTUBE_API_KEY` | YouTube Data API | | `KAKAO_REST_KEY` | 카카오 API (지도) | | `MEILI_MASTER_KEY` | Meilisearch 인증 | | `JWT_SECRET` | 관리자 JWT 서명 | --- ## 11. 개발 환경 시작 ```bash # 개발 모드 (HMR 활성화) cd /docker/fromis_9 docker compose -f docker-compose.dev.yml up -d # 프론트엔드: fromis9-frontend (Vite dev server) # 백엔드: fromis9-backend (Express) # 검색: fromis9-meilisearch ``` > **참고**: Vite HMR이 활성화되어 있으므로 파일 수정 시 자동 반영됩니다. --- ## 12. 주요 파일 크기 참고 | 파일 | 크기 | 비고 | | ----------------------------------------------- | -------------- | -------------------- | | `backend/routes/admin.js` | 60KB (1,986줄) | 핵심 CRUD 로직 | | `frontend/src/pages/pc/public/Schedule.jsx` | 62KB | 일정 페이지 (가상화) | | `frontend/src/pages/mobile/public/Schedule.jsx` | 52KB | 모바일 일정 | | `backend/services/youtube-bot.js` | 17KB | YouTube 수집 | | `backend/services/x-bot.js` | 16KB | X 수집 | --- ## 13. 모바일 앨범 갤러리 UI ### 주요 컴포넌트 | 파일 | 설명 | | ------------------------------------------------------ | ----------------------------- | | `frontend/src/pages/mobile/public/AlbumGallery.jsx` | 모바일 앨범 갤러리 (전체보기) | | `frontend/src/pages/mobile/public/AlbumDetail.jsx` | 모바일 앨범 상세 | | `frontend/src/components/common/LightboxIndicator.jsx` | 공통 슬라이딩 점 인디케이터 | ### Swiper ViewPager 스타일 라이트박스 ```jsx import { Swiper, SwiperSlide } from "swiper/react"; import { Virtual } from "swiper/modules"; { swiperRef.current = swiper; }} onSlideChange={(swiper) => setSelectedIndex(swiper.activeIndex)} slidesPerView={1} resistance={true} resistanceRatio={0.5} > {photos.map((photo, index) => ( ))} ; ``` ### LightboxIndicator 사용법 ```jsx import LightboxIndicator from '../../../components/common/LightboxIndicator'; // PC (기본 width 200px) swiperRef.current?.slideTo(i)} /> // 모바일 (width 120px로 축소) swiperRef.current?.slideTo(i)} width={120} /> ``` ### 2열 지그재그 Masonry 그리드 ```jsx // 1,3,5번 → 왼쪽 열 / 2,4,6번 → 오른쪽 열 const distributePhotos = () => { const leftColumn = []; const rightColumn = []; photos.forEach((photo, index) => { if (index % 2 === 0) leftColumn.push({ ...photo, originalIndex: index }); else rightColumn.push({ ...photo, originalIndex: index }); }); return { leftColumn, rightColumn }; }; ``` ### 뒤로가기 처리 패턴 ```jsx // 모달/라이트박스 열 때 히스토리 추가 const openLightbox = useCallback((images, index, options = {}) => { setLightbox({ open: true, images, index, ...options }); window.history.pushState({ lightbox: true }, ""); }, []); // popstate 이벤트로 닫기 useEffect(() => { const handlePopState = () => { if (showModal) setShowModal(false); else if (lightbox.open) setLightbox((prev) => ({ ...prev, open: false })); }; window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); }, [showModal, lightbox.open]); // X 버튼도 history.back() 호출 ; ``` ### 바텀시트 (정보 표시) ```jsx { if (info.offset.y > 100 || info.velocity.y > 300) { window.history.back(); } }} className="bg-zinc-900 rounded-t-3xl" > {/* 드래그 핸들 */}
{/* 내용 */} ```