refactor: useScheduleSearch 훅 분리 - 검색 로직 캡슐화

- useScheduleSearch.js 생성 (217줄)
  - 검색어 자동완성 API 호출 (debounce)
  - 무한 스크롤 검색 결과 (useInfiniteQuery)
  - 키보드 네비게이션 핸들러
- Schedules.jsx: 1139줄 → 1009줄 (130줄 감소)
  - 검색 관련 상태/로직을 훅으로 이동
  - 컴포넌트 복잡도 감소

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-23 10:00:50 +09:00
parent 81a2112b59
commit 08d704da5c
3 changed files with 251 additions and 161 deletions

View file

@ -1,2 +1,5 @@
// 관리자 인증
export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth';
// 일정 검색
export { useScheduleSearch } from './useScheduleSearch';

View file

@ -0,0 +1,217 @@
/**
* 일정 검색 관련 커스텀
* - 검색어 자동완성
* - 무한 스크롤 검색 결과
*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import useScheduleStore from '@/stores/useScheduleStore';
import * as schedulesApi from '@/api/admin/schedules';
const SEARCH_LIMIT = 20;
export function useScheduleSearch() {
// Zustand 스토어에서 검색 상태 가져오기
const {
searchInput,
setSearchInput,
searchTerm,
setSearchTerm,
isSearchMode,
setIsSearchMode,
} = useScheduleStore();
// 검색 추천 관련 상태
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
// Intersection Observer for infinite scroll
const { ref: loadMoreRef, inView } = useInView({
threshold: 0,
rootMargin: '100px',
});
// useInfiniteQuery for search
const {
data: searchData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: searchLoading,
} = useInfiniteQuery({
queryKey: ['adminScheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0 }) => {
return schedulesApi.searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
},
getNextPageParam: (lastPage) => {
if (lastPage.hasMore) {
return lastPage.offset + lastPage.schedules.length;
}
return undefined;
},
enabled: !!searchTerm && isSearchMode,
});
// Flatten search results
const searchResults = useMemo(() => {
if (!searchData?.pages) return [];
return searchData.pages.flatMap((page) => page.schedules);
}, [searchData]);
// Auto fetch next page when scrolled to bottom
const prevInViewRef = useRef(false);
useEffect(() => {
if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
fetchNextPage();
}
prevInViewRef.current = inView;
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
// 검색어 자동완성 API 호출 (debounce 적용)
useEffect(() => {
if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
setSuggestions([]);
return;
}
const timeoutId = setTimeout(async () => {
setIsLoadingSuggestions(true);
try {
const response = await fetch(
`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`
);
if (response.ok) {
const data = await response.json();
setSuggestions(data.suggestions || []);
}
} catch (error) {
console.error('추천 검색어 API 오류:', error);
setSuggestions([]);
} finally {
setIsLoadingSuggestions(false);
}
}, 200);
return () => clearTimeout(timeoutId);
}, [originalSearchQuery]);
// 검색 실행
const handleSearch = useCallback((query) => {
const trimmedQuery = query?.trim() || '';
if (trimmedQuery) {
setSearchTerm(trimmedQuery);
setIsSearchMode(true);
}
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}, [setSearchTerm, setIsSearchMode]);
// 검색 모드 종료
const exitSearchMode = useCallback(() => {
setIsSearchMode(false);
setSearchTerm('');
setSearchInput('');
setShowSuggestions(false);
setSuggestions([]);
setSelectedSuggestionIndex(-1);
setOriginalSearchQuery('');
}, [setIsSearchMode, setSearchTerm, setSearchInput]);
// 검색어 입력 핸들러
const handleSearchInputChange = useCallback((value) => {
setSearchInput(value);
setOriginalSearchQuery(value);
setSelectedSuggestionIndex(-1);
if (value.trim()) {
setShowSuggestions(true);
} else {
setShowSuggestions(false);
}
}, [setSearchInput]);
// 추천 검색어 선택
const handleSuggestionSelect = useCallback((suggestion) => {
setSearchInput(suggestion);
handleSearch(suggestion);
}, [setSearchInput, handleSearch]);
// 키보드 네비게이션 핸들러
const handleKeyDown = useCallback((e) => {
if (!showSuggestions || suggestions.length === 0) {
if (e.key === 'Enter') {
handleSearch(searchInput);
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedSuggestionIndex((prev) => {
const next = prev < suggestions.length - 1 ? prev + 1 : prev;
if (next >= 0 && suggestions[next]) {
setSearchInput(suggestions[next]);
}
return next;
});
break;
case 'ArrowUp':
e.preventDefault();
setSelectedSuggestionIndex((prev) => {
const next = prev > 0 ? prev - 1 : -1;
if (next === -1) {
setSearchInput(originalSearchQuery);
} else if (suggestions[next]) {
setSearchInput(suggestions[next]);
}
return next;
});
break;
case 'Enter':
e.preventDefault();
if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
handleSuggestionSelect(suggestions[selectedSuggestionIndex]);
} else {
handleSearch(searchInput);
}
break;
case 'Escape':
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
break;
}
}, [showSuggestions, suggestions, selectedSuggestionIndex, searchInput, originalSearchQuery, handleSearch, handleSuggestionSelect, setSearchInput]);
return {
// 상태
searchInput,
searchTerm,
isSearchMode,
setIsSearchMode,
showSuggestions,
setShowSuggestions,
selectedSuggestionIndex,
suggestions,
isLoadingSuggestions,
searchResults,
searchLoading,
hasNextPage,
isFetchingNextPage,
// 핸들러
handleSearch,
exitSearchMode,
handleSearchInputChange,
handleSuggestionSelect,
handleKeyDown,
// refs
loadMoreRef,
};
}
export default useScheduleSearch;

View file

@ -14,15 +14,14 @@ import {
ArrowLeft,
Book,
} from 'lucide-react';
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInView } from 'react-intersection-observer';
import { Toast } from '@/components/common';
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
import { ScheduleItem, getEditPath } from '@/components/pc/admin/schedule';
import useScheduleStore from '@/stores/useScheduleStore';
import { useAdminAuth } from '@/hooks/pc/admin';
import { useAdminAuth, useScheduleSearch } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common';
import { getTodayKST, formatDate } from '@/utils';
import { getCategoryId, getScheduleDate } from '@/utils/schedule';
@ -33,14 +32,8 @@ function Schedules() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// Zustand
// Zustand ( )
const {
searchInput,
setSearchInput,
searchTerm,
setSearchTerm,
isSearchMode,
setIsSearchMode,
selectedCategories,
setSelectedCategories,
selectedDate,
@ -53,95 +46,36 @@ function Schedules() {
const { user, isAuthenticated } = useAdminAuth();
// ( )
const {
searchInput,
searchTerm,
isSearchMode,
setIsSearchMode,
showSuggestions,
setShowSuggestions,
selectedSuggestionIndex,
suggestions,
isLoadingSuggestions,
searchResults,
searchLoading,
hasNextPage,
isFetchingNextPage,
handleSearch,
exitSearchMode,
handleSearchInputChange,
handleSuggestionSelect,
handleKeyDown: handleSearchKeyDown,
loadMoreRef,
} = useScheduleSearch();
// ( )
const { toast, setToast } = useToast();
const scrollContainerRef = useRef(null);
const searchContainerRef = useRef(null); // ( )
//
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const [originalSearchQuery, setOriginalSearchQuery] = useState(''); //
const [suggestions, setSuggestions] = useState([]); //
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const SEARCH_LIMIT = 20; // 20
const ESTIMATED_ITEM_HEIGHT = 100; // ( )
// Intersection Observer for infinite scroll
const { ref: loadMoreRef, inView } = useInView({
threshold: 0,
rootMargin: '100px',
});
// useInfiniteQuery for search
const {
data: searchData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: searchLoading,
} = useInfiniteQuery({
queryKey: ['adminScheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0 }) => {
return schedulesApi.searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
},
getNextPageParam: (lastPage) => {
if (lastPage.hasMore) {
return lastPage.offset + lastPage.schedules.length;
}
return undefined;
},
enabled: !!searchTerm && isSearchMode,
});
// Flatten search results
const searchResults = useMemo(() => {
if (!searchData?.pages) return [];
return searchData.pages.flatMap((page) => page.schedules);
}, [searchData]);
// Auto fetch next page when scrolled to bottom
// inView true fetch ( )
const prevInViewRef = useRef(false);
useEffect(() => {
// inView falsetrue fetch
if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
fetchNextPage();
}
prevInViewRef.current = inView;
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
// API (debounce )
useEffect(() => {
//
if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
setSuggestions([]);
return;
}
// debounce: 200ms API
const timeoutId = setTimeout(async () => {
setIsLoadingSuggestions(true);
try {
const response = await fetch(
`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`
);
if (response.ok) {
const data = await response.json();
setSuggestions(data.suggestions || []);
}
} catch (error) {
console.error('추천 검색어 API 오류:', error);
setSuggestions([]);
} finally {
setIsLoadingSuggestions(false);
}
}, 200);
return () => clearTimeout(timeoutId);
}, [originalSearchQuery]);
// selectedDate
useEffect(() => {
if (!selectedDate) {
@ -843,14 +777,7 @@ function Schedules() {
className="flex items-center gap-3 flex-1"
>
<button
onClick={() => {
setIsSearchMode(false);
setSearchInput('');
setOriginalSearchQuery('');
setSearchTerm('');
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}}
onClick={exitSearchMode}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft size={20} className="text-gray-500" />
@ -864,65 +791,13 @@ function Schedules() {
placeholder="일정 검색..."
value={searchInput}
autoFocus
onChange={(e) => {
setSearchInput(e.target.value);
setOriginalSearchQuery(e.target.value);
setShowSuggestions(true);
setSelectedSuggestionIndex(-1);
}}
onChange={(e) => handleSearchInputChange(e.target.value)}
onFocus={() => setShowSuggestions(true)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const newIndex =
selectedSuggestionIndex < suggestions.length - 1
? selectedSuggestionIndex + 1
: 0;
setSelectedSuggestionIndex(newIndex);
if (suggestions[newIndex]) {
setSearchInput(suggestions[newIndex]);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const newIndex =
selectedSuggestionIndex > 0
? selectedSuggestionIndex - 1
: suggestions.length - 1;
setSelectedSuggestionIndex(newIndex);
if (suggestions[newIndex]) {
setSearchInput(suggestions[newIndex]);
}
} else if (e.key === 'Enter') {
if (
selectedSuggestionIndex >= 0 &&
suggestions[selectedSuggestionIndex]
) {
setSearchInput(suggestions[selectedSuggestionIndex]);
setSearchTerm(suggestions[selectedSuggestionIndex]);
} else if (searchInput.trim()) {
setSearchTerm(searchInput);
}
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
} else if (e.key === 'Escape') {
setIsSearchMode(false);
setSearchInput('');
setOriginalSearchQuery('');
setSearchTerm('');
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
}}
onKeyDown={handleSearchKeyDown}
className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400"
/>
<button
onClick={() => {
if (searchInput.trim()) {
setSearchTerm(searchInput);
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
}}
onClick={() => handleSearch(searchInput)}
disabled={searchLoading}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
>
@ -938,12 +813,7 @@ function Schedules() {
{suggestions.map((suggestion, index) => (
<button
key={suggestion}
onClick={() => {
setSearchInput(suggestion);
setSearchTerm(suggestion);
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}}
onClick={() => handleSuggestionSelect(suggestion)}
className={`w-full px-4 py-2 text-left text-sm flex items-center gap-3 transition-colors ${
index === selectedSuggestionIndex
? 'bg-primary/10 text-primary'