fromis_9/docs/PROJECT_STRUCTURE.md

27 KiB

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. 시스템 아키텍처 개요

graph TB
    subgraph "클라이언트"
        PC[PC 브라우저]
        Mobile[모바일 브라우저]
    end

    subgraph "Caddy 역방향 프록시"
        Caddy[fromis9.caadiq.co.kr<br/>500MB 업로드 허용]
    end

    subgraph "Docker 컨테이너"
        Frontend[fromis9-frontend:80<br/>React + Express]
        Meili[fromis9-meilisearch:7700<br/>검색 엔진]
    end

    subgraph "외부 서비스"
        MariaDB[(MariaDB<br/>fromis9 DB)]
        RustFS[RustFS S3<br/>이미지 스토리지]
        YouTube[YouTube API]
        Nitter[Nitter<br/>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 분기)

// App.jsx - react-device-detect 사용
<BrowserView>  {/* PC 환경 */}
    <PCLayout>
        <Route path="/" element={<PCHome />} />
        <Route path="/members" element={<PCMembers />} />
        <Route path="/album" element={<PCAlbum />} />
        <Route path="/album/:name" element={<PCAlbumDetail />} />
        <Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
        <Route path="/schedule" element={<PCSchedule />} />
    </PCLayout>
</BrowserView>

<MobileView>  {/* Mobile 환경 */}
    <MobileLayout>
        <!-- 동일한 라우트, 다른 컴포넌트 -->
    </MobileLayout>
</MobileView>

관리자 페이지 (/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시간 주기 전체 동기화

스케줄러 동작 흐름

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가지 해상도로 변환:

// 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

인덱스 설정

// 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)

# /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. 개발 환경 시작

# 개발 모드 (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 스타일 라이트박스

import { Swiper, SwiperSlide } from "swiper/react";
import { Virtual } from "swiper/modules";

<Swiper
  modules={[Virtual]}
  virtual
  initialSlide={selectedIndex}
  onSwiper={(swiper) => {
    swiperRef.current = swiper;
  }}
  onSlideChange={(swiper) => setSelectedIndex(swiper.activeIndex)}
  slidesPerView={1}
  resistance={true}
  resistanceRatio={0.5}
>
  {photos.map((photo, index) => (
    <SwiperSlide key={index} virtualIndex={index}>
      <img src={photo.medium_url} />
    </SwiperSlide>
  ))}
</Swiper>;

LightboxIndicator 사용법

import LightboxIndicator from '../../../components/common/LightboxIndicator';

// PC (기본 width 200px)
<LightboxIndicator
    count={photos.length}
    currentIndex={selectedIndex}
    goToIndex={(i) => swiperRef.current?.slideTo(i)}
/>

// 모바일 (width 120px로 축소)
<LightboxIndicator
    count={photos.length}
    currentIndex={selectedIndex}
    goToIndex={(i) => swiperRef.current?.slideTo(i)}
    width={120}
/>

2열 지그재그 Masonry 그리드

// 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 };
};

뒤로가기 처리 패턴

// 모달/라이트박스 열 때 히스토리 추가
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() 호출
<button onClick={() => window.history.back()}>
  <X size={24} />
</button>;

바텀시트 (정보 표시)

<motion.div
  initial={{ y: "100%" }}
  animate={{ y: 0 }}
  exit={{ y: "100%" }}
  drag="y"
  dragConstraints={{ top: 0, bottom: 0 }}
  dragElastic={{ top: 0, bottom: 0.5 }}
  onDragEnd={(_, info) => {
    if (info.offset.y > 100 || info.velocity.y > 300) {
      window.history.back();
    }
  }}
  className="bg-zinc-900 rounded-t-3xl"
>
  {/* 드래그 핸들 */}
  <div className="flex justify-center pt-3 pb-2">
    <div className="w-10 h-1 bg-zinc-600 rounded-full" />
  </div>
  {/* 내용 */}
</motion.div>

14. Redis 기반 Bi-gram 추천 검색어 시스템

아키텍처 개요

graph LR
    User[사용자 검색] --> API[/api/schedules/suggestions]
    API --> Redis[(Redis)]
    Redis --> |bi-gram 매칭| Results[추천 검색어]

    Admin[관리자 일정 CRUD] --> Extract[키워드 추출]
    Extract --> Redis

주요 파일

파일 설명
backend/routes/schedules.js 추천 검색어 API (/api/schedules/suggestions)
backend/scripts/extract-keywords.js 기존 일정에서 키워드 일괄 추출 스크립트
frontend/src/pages/pc/admin/Schedule.jsx 관리자 검색창 드롭다운
frontend/src/pages/pc/public/Schedule.jsx PC 검색 추천 UI
frontend/src/pages/mobile/public/Schedule.jsx 모바일 유튜브 스타일 추천 리스트

Redis 데이터 구조

fromis9:search:suggestions (Sorted Set)
├── "쇼케이스" → score: 15
├── "팬미팅" → score: 12
├── "라디오" → score: 8
└── ...

fromis9:search:bigrams (Hash)
├── "쇼케" → "쇼케이스,쇼케이스투어"
├── "케이" → "쇼케이스,케이팝"
└── ...

API 엔드포인트

// GET /api/schedules/suggestions?q=쇼케
// Response: ["쇼케이스", "쇼케이스 투어", ...]

router.get("/suggestions", async (req, res) => {
  const query = req.query.q?.trim();
  if (!query || query.length < 2) return res.json([]);

  // bi-gram 매칭
  const bigram = query.slice(0, 2);
  const cached = await redis.hget("fromis9:search:bigrams", bigram);

  if (cached) {
    const keywords = cached
      .split(",")
      .filter((k) => k.toLowerCase().includes(query.toLowerCase()))
      .slice(0, 10);
    return res.json(keywords);
  }

  res.json([]);
});

키워드 추출 로직 (일정 저장 시)

// admin.js - 일정 저장 시 키워드 추출
const extractKeywords = (title) => {
  // 특수문자 제거, 공백으로 분리
  const words = title.replace(/[^\w\s가-힣]/g, " ").split(/\s+/);
  return words.filter((w) => w.length >= 2);
};

// Redis에 저장
for (const keyword of keywords) {
  await redis.zincrby("fromis9:search:suggestions", 1, keyword);

  // bi-gram 인덱스
  for (let i = 0; i < keyword.length - 1; i++) {
    const bigram = keyword.slice(i, i + 2);
    const existing = await redis.hget("fromis9:search:bigrams", bigram);
    const set = new Set(existing ? existing.split(",") : []);
    set.add(keyword);
    await redis.hset("fromis9:search:bigrams", bigram, [...set].join(","));
  }
}

프론트엔드 UI

PC 관리자/공개 페이지 - 드롭다운

// 검색창 아래 드롭다운
{
  suggestions.length > 0 && (
    <div className="absolute top-full left-0 right-0 bg-white border rounded-lg shadow-lg z-50">
      {suggestions.map((s, i) => (
        <button
          key={i}
          onClick={() => handleSuggestionClick(s)}
          className="w-full px-4 py-2 text-left hover:bg-gray-100"
        >
          {s}
        </button>
      ))}
    </div>
  );
}

모바일 - 유튜브 스타일 리스트

// 검색창 아래 전체 화면 리스트
{
  showSuggestions && suggestions.length > 0 && (
    <div className="absolute inset-x-0 top-12 bottom-0 bg-white z-50">
      {suggestions.map((s, i) => (
        <button
          key={i}
          onClick={() => handleSuggestionClick(s)}
          className="w-full px-4 py-3 flex items-center gap-3 border-b"
        >
          <Search size={16} className="text-gray-400" />
          <span>{s}</span>
        </button>
      ))}
    </div>
  );
}

키워드 일괄 추출 스크립트

# 기존 일정에서 키워드 추출하여 Redis에 저장
cd /docker/fromis_9/backend
node scripts/extract-keywords.js

15. 모바일 앱 (/app)

기술 스택

계층 기술
프레임워크 Expo (React Native)
언어 TypeScript
네비게이션 React Navigation (Tab + Stack)
UI 효과 expo-blur, expo-linear-gradient, react-native-color-matrix-image-filters
미디어 expo-file-system, expo-media-library, react-native-pager-view

디렉토리 구조

app/src/
├── api/                    # API 호출 함수
│   ├── albums.ts           # 앨범 API (AlbumPhoto에 width/height 포함)
│   ├── members.ts          # 멤버 API
│   └── schedules.ts        # 일정 API
├── components/             # 공통 컴포넌트
│   └── common/
│       └── Header.tsx      # 공통 헤더 (title, showBack, rightElement)
├── constants/
│   └── colors.ts           # 테마 색상 (primary: #FF4D8D)
├── navigation/
│   └── AppNavigator.tsx    # 탭 + 스택 네비게이션
└── screens/
    ├── HomeScreen.tsx      # 홈 (멤버, 앨범, 일정 요약)
    ├── MembersScreen.tsx   # 멤버 목록 + 바텀시트 상세
    ├── AlbumScreen.tsx     # 앨범 목록 (2열 그리드)
    ├── AlbumDetailScreen.tsx   # 앨범 상세 (트랙, 티저, 포토)
    ├── AlbumGalleryScreen.tsx  # 컨셉포토 갤러리 (라이트박스)
    └── ScheduleScreen.tsx  # 일정 목록

네비게이션 구조

graph TB
    TabNav[TabNavigator 하단 탭]
    TabNav --> HomeTab[홈]
    TabNav --> MembersTab[멤버]
    TabNav --> AlbumTab[앨범]
    TabNav --> ScheduleTab[일정]

    AlbumTab --> AlbumStack[AlbumStackNavigator]
    AlbumStack --> AlbumList[AlbumScreen]
    AlbumStack --> AlbumDetail[AlbumDetailScreen]
    AlbumStack --> AlbumGallery[AlbumGalleryScreen]

주요 기능

탭 전환 시 앨범 스택 리셋

// AppNavigator.tsx
<Tab.Screen
  name="AlbumTab"
  component={AlbumStackNavigator}
  listeners={({ navigation }) => ({
    tabPress: (e) => {
      // 앨범 탭 클릭 시 스택을 루트(목록)으로 리셋
      navigation.navigate("AlbumTab", { screen: "AlbumList" });
    },
  })}
/>

AlbumGalleryScreen (컨셉포토 라이트박스)

  • PagerView: 스와이프로 이미지 탐색
  • 페이지 인디케이터: n / total 형식
  • 다운로드 기능: expo-file-system + expo-media-library
  • 웹 버전과 1:1 동일한 UI

MembersScreen (멤버 상세)

  • 바텀시트 모달: PanResponder 드래그로 닫기
  • 전 멤버 흑백 처리: Grayscale 필터
  • 글래스모피즘: BlurView (intensity 30, dimezisBlurView)

개발 명령어

# 개발 서버 실행
cd /docker/fromis_9/app
npx expo start --lan

# Android APK 빌드
npx expo run:android --variant release

# 로컬 네이티브 빌드 (android/ 폴더에서)
./gradlew assembleDebug

웹-앱 동기화 체크리스트

화면 웹 경로 앱 파일
mobile/public/Home.jsx HomeScreen.tsx
멤버 mobile/public/Members.jsx MembersScreen.tsx
앨범 목록 mobile/public/Album.jsx AlbumScreen.tsx
앨범 상세 mobile/public/AlbumDetail.jsx AlbumDetailScreen.tsx
앨범 갤러리 mobile/public/AlbumGallery.jsx AlbumGalleryScreen.tsx
일정 mobile/public/Schedule.jsx ScheduleScreen.tsx

16. 오늘 작업 요약 (2026-01-11 ~ 2026-01-12)

커밋 히스토리

커밋 설명
727b05f Redis 기반 bi-gram 추천 검색어 시스템 구현
9c2ff74 Admin Schedule 추천 검색어 연동 + 빈 상태 드롭다운 숨김
2ad5341 Mobile Schedule 추천 검색어 API 연동
8e3cab9 기본 카테고리 보호 및 ID 재정렬
de2e02f 모바일 앨범 상세 UI 개선
d6bc8d7 모바일 앨범 갤러리 UI 대폭 개선 (Swiper, 뒤로가기 등)
bf6b7f7 앱: 앨범 화면 2열 그리드 레이아웃 개선, 탭 전환 시 앨범 스택 리셋

주요 변경 사항

  1. 추천 검색어 시스템: Redis bi-gram 기반 자동완성
  2. 모바일 앨범 갤러리: Swiper ViewPager + LightboxIndicator
  3. 뒤로가기 처리: History API로 모달/라이트박스 닫기
  4. 카테고리 보호: 시스템 기본 카테고리 삭제 방지
  5. 앱 앨범 스택 리셋: 탭 전환 시 목록으로 자동 복귀