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 |
공통 슬라이딩 점 인디케이터 |
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
- 바텀시트 모달: 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열 그리드 레이아웃 개선, 탭 전환 시 앨범 스택 리셋 |
주요 변경 사항
- 추천 검색어 시스템: Redis bi-gram 기반 자동완성
- 모바일 앨범 갤러리: Swiper ViewPager + LightboxIndicator
- 뒤로가기 처리: History API로 모달/라이트박스 닫기
- 카테고리 보호: 시스템 기본 카테고리 삭제 방지
- 앱 앨범 스택 리셋: 탭 전환 시 목록으로 자동 복귀