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"
>
{/* 드래그 핸들 */}
{/* 내용 */}
```
---
## 14. Redis 기반 Bi-gram 추천 검색어 시스템
### 아키텍처 개요
```mermaid
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 엔드포인트
```javascript
// 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([]);
});
```
### 키워드 추출 로직 (일정 저장 시)
```javascript
// 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 관리자/공개 페이지 - 드롭다운
```jsx
// 검색창 아래 드롭다운
{
suggestions.length > 0 && (
{suggestions.map((s, i) => (
))}
);
}
```
#### 모바일 - 유튜브 스타일 리스트
```jsx
// 검색창 아래 전체 화면 리스트
{
showSuggestions && suggestions.length > 0 && (
{suggestions.map((s, i) => (
))}
);
}
```
### 키워드 일괄 추출 스크립트
```bash
# 기존 일정에서 키워드 추출하여 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 # 일정 목록
```
### 네비게이션 구조
```mermaid
graph TB
TabNav[TabNavigator 하단 탭]
TabNav --> HomeTab[홈]
TabNav --> MembersTab[멤버]
TabNav --> AlbumTab[앨범]
TabNav --> ScheduleTab[일정]
AlbumTab --> AlbumStack[AlbumStackNavigator]
AlbumStack --> AlbumList[AlbumScreen]
AlbumStack --> AlbumDetail[AlbumDetailScreen]
AlbumStack --> AlbumGallery[AlbumGalleryScreen]
```
### 주요 기능
#### 탭 전환 시 앨범 스택 리셋
```tsx
// AppNavigator.tsx
({
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)
### 개발 명령어
```bash
# 개발 서버 실행
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. **앱 앨범 스택 리셋**: 탭 전환 시 목록으로 자동 복귀