- Published on
React 검색 기능 성능 최적화
#React 검색 기능 성능 최적화 완전 가이드
#목차
- 문제 상황: 검색할 때마다 버벅거리는 이유
- 원인 분석: 무엇이 성능을 떨어뜨리는가
- 해결 방법 1: Debouncing으로 입력 최적화
- 해결 방법 2: 메모이제이션으로 연산 최적화
- 함정 주의: React Hook 규칙 준수
- 최종 해결: 통합 최적화 구현
- 성능 개선 효과와 실무 팁
#문제 상황: 검색할 때마다 버벅거리는 이유
블로그나 쇼핑몰에서 검색 기능을 만들 때 이런 경험 있으신가요?
// ❌ 문제가 있는 코드
function SearchComponent({ posts }) {
const [searchValue, setSearchValue] = useState('')
// 검색어가 바뀔 때마다 실행
const filteredPosts = posts.filter((post) => {
// 무거운 연산들...
const cleanContent = cleanContentForSearch(post.content) // 📝 텍스트 정제
const contexts = extractMatchingContext(post.content, searchValue) // 🔍 컨텍스트 추출
const highlighted = highlightSearchTerm(post.title, searchValue) // ✨ 하이라이팅
return post.title.includes(searchValue) || cleanContent.includes(searchValue)
})
return (
<input
onChange={(e) => setSearchValue(e.target.value)} // 📝 글자 하나마다 실행!
/>
)
}
문제점:
- 사용자가 '리액트'라고 입력하면 'ㄹ', '리', '리액', '리액트' 총 4번의 연산이 실행
- 각 글자마다 모든 포스트를 검사하고 복잡한 연산 수행
- 포스트가 많을수록 더욱 느려짐
#원인 분석: 무엇이 성능을 떨어뜨리는가
#1. 실시간 연산의 문제
// 사용자가 'React' 입력 시 발생하는 일
'R' 입력 → 전체 포스트 검사 (100ms)
'Re' 입력 → 전체 포스트 검사 (100ms)
'Rea' 입력 → 전체 포스트 검사 (100ms)
'Reac' 입력 → 전체 포스트 검사 (100ms)
'React' 입력 → 전체 포스트 검사 (100ms)
총 500ms의 연산! 😰
#2. 불필요한 중복 연산
// 같은 포스트, 같은 검색어인데 매번 다시 계산
const contexts1 = extractMatchingContext(post.content, 'React') // 첫 번째 실행
const contexts2 = extractMatchingContext(post.content, 'React') // 두 번째 실행 (같은 결과!)
#3. 무거운 연산들
- 텍스트 정제: 마크다운 문법 제거 (정규식 연산)
- 컨텍스트 추출: 검색어 주변 텍스트 찾기
- 하이라이팅: HTML 태그로 감싸기
#해결 방법 1: Debouncing으로 입력 최적화
#Debouncing이란?
"사용자가 입력을 멈출 때까지 기다렸다가 실행하기"
카페에서 주문할 때를 생각해보세요:
- ❌ 나쁜 방법: "아아...아니 카페라떼...아니 바닐라 라떼 주세요!" (계속 주문 변경)
- ✅ 좋은 방법: 메뉴를 충분히 본 후 "바닐라 라떼 주세요!" (한 번에 주문)
#Debounce Hook 구현
// useDebounce 커스텀 훅
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
// 타이머 설정: delay 시간 후에 값 업데이트
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
// 새로운 값이 들어오면 이전 타이머 취소
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
#사용 예시
function SearchComponent({ posts }) {
const [searchValue, setSearchValue] = useState('')
const debouncedSearchValue = useDebounce(searchValue, 300) // 300ms 대기
// 이제 사용자가 타이핑을 멈춘 후 300ms 뒤에만 실행
useEffect(() => {
if (debouncedSearchValue) {
// 실제 검색 로직 실행
performSearch(debouncedSearchValue)
}
}, [debouncedSearchValue])
return (
<input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)} // 즉시 UI 반영
placeholder="검색어를 입력하세요..."
/>
)
}
결과:
// 사용자가 'React' 입력 시
'R' 입력 → 대기...
'Re' 입력 → 대기...
'Rea' 입력 → 대기...
'Reac' 입력 → 대기...
'React' 입력 → 300ms 대기 → 검색 실행 (1번만!)
#해결 방법 2: 메모이제이션으로 연산 최적화
#메모이제이션이란?
"한 번 계산한 결과를 저장해두고 재사용하기"
수학 문제를 푸는 것과 같습니다:
- ❌ 매번 다시 계산: 2 + 2 = ? (계산) → 4
- ✅ 기억해두기: 2 + 2 = 4 (기억에서 바로 가져옴)
#useMemo로 검색 데이터 최적화
function SearchComponent({ posts }) {
const [searchValue, setSearchValue] = useState('')
const debouncedSearchValue = useDebounce(searchValue, 300)
// 검색 관련 데이터를 미리 계산하고 저장
const postsWithSearchData = useMemo(() => {
return posts.map((post) => {
// 검색어가 있을 때만 무거운 연산 수행
const searchContexts =
debouncedSearchValue && post.content
? extractMatchingContext(post.content, debouncedSearchValue)
: []
const highlightedTitle = debouncedSearchValue
? highlightSearchTerm(post.title, debouncedSearchValue)
: post.title
return {
...post,
searchContexts, // 💾 저장된 컨텍스트
highlightedTitle, // 💾 저장된 하이라이팅
}
})
}, [posts, debouncedSearchValue]) // 이 값들이 바뀔 때만 다시 계산
return (
<div>
{postsWithSearchData.map((post) => (
<div key={post.id}>
<h2 dangerouslySetInnerHTML={{ __html: post.highlightedTitle }} />
{post.searchContexts.map((context) => (
<p>{context}</p>
))}
</div>
))}
</div>
)
}
메모이제이션의 장점:
- 같은 검색어로 다시 검색하면 즉시 결과 표시
- 불필요한 연산 제거로 CPU 사용량 감소
- 메모리에 결과를 저장하여 빠른 접근
#함정 주의: React Hook 규칙 준수
#발생한 문제
처음에는 이렇게 작성했었습니다:
// ❌ 잘못된 코드 - Hook 규칙 위반
function SearchComponent({ posts }) {
return (
<div>
{posts.map((post) => {
// 🚨 반복문 안에서 Hook 사용 - 금지!
const searchContexts = useMemo(() => {
return extractMatchingContext(post.content, searchValue)
}, [post.content, searchValue])
return <PostCard post={post} contexts={searchContexts} />
})}
</div>
)
}
에러 메시지:
React has detected a change in the order of Hooks called by SearchComponent.
This will lead to bugs and errors if not fixed.
#React Hook 규칙
- 최상위에서만 Hook 호출: 반복문, 조건문, 중첩 함수 안에서 Hook 사용 금지
- Hook 호출 순서 유지: 렌더링할 때마다 같은 순서로 Hook 호출
#올바른 해결 방법
// ✅ 올바른 코드 - Hook을 최상위로 이동
function SearchComponent({ posts }) {
const [searchValue, setSearchValue] = useState('')
const debouncedSearchValue = useDebounce(searchValue, 300)
// 🎯 Hook을 최상위에서 한 번만 사용
const postsWithSearchData = useMemo(() => {
return posts.map((post) => ({
...post,
searchContexts: extractMatchingContext(post.content, debouncedSearchValue),
highlightedTitle: highlightSearchTerm(post.title, debouncedSearchValue),
}))
}, [posts, debouncedSearchValue])
return (
<div>
{postsWithSearchData.map((post) => (
<PostCard key={post.id} post={post} contexts={post.searchContexts} />
))}
</div>
)
}
#최종 해결: 통합 최적화 구현
#완성된 검색 컴포넌트
function OptimizedSearchComponent({ posts }) {
// 1️⃣ 상태 관리
const [searchValue, setSearchValue] = useState('')
const [searchMode, setSearchMode] = useState('title') // 'title' | 'titleAndContent'
// 2️⃣ Debounce 적용
const debouncedSearchValue = useDebounce(searchValue, 300)
// 3️⃣ 검색 결과 필터링 (메모이제이션)
const filteredPosts = useMemo(() => {
if (!debouncedSearchValue) return posts
return posts.filter((post) => {
if (searchMode === 'title') {
return post.title.toLowerCase().includes(debouncedSearchValue.toLowerCase())
} else {
const cleanContent = cleanContentForSearch(post.content)
const searchContent = `${post.title} ${post.summary} ${cleanContent}`.toLowerCase()
return searchContent.includes(debouncedSearchValue.toLowerCase())
}
})
}, [posts, debouncedSearchValue, searchMode])
// 4️⃣ 검색 데이터 사전 계산 (메모이제이션)
const postsWithSearchData = useMemo(() => {
return filteredPosts.map((post) => ({
...post,
searchContexts:
searchMode === 'titleAndContent' && debouncedSearchValue
? extractMatchingContext(post.content, debouncedSearchValue)
: [],
highlightedTitle: debouncedSearchValue
? highlightSearchTerm(post.title, debouncedSearchValue)
: post.title,
}))
}, [filteredPosts, debouncedSearchValue, searchMode])
return (
<div>
{/* 검색 입력 */}
<div>
<input
type="text"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder="검색어를 입력하세요..."
/>
{/* 검색 모드 선택 */}
<div>
<button
onClick={() => setSearchMode('title')}
className={searchMode === 'title' ? 'active' : ''}
>
제목만
</button>
<button
onClick={() => setSearchMode('titleAndContent')}
className={searchMode === 'titleAndContent' ? 'active' : ''}
>
제목+내용
</button>
</div>
</div>
{/* 검색 결과 */}
<div>
{postsWithSearchData.map((post) => (
<article key={post.id}>
<h2 dangerouslySetInnerHTML={{ __html: post.highlightedTitle }} />
<p>{post.summary}</p>
{/* 검색어 발견 위치 표시 */}
{post.searchContexts.length > 0 && (
<div className="search-context">
<h4>검색어 발견 위치</h4>
{post.searchContexts.map((context, index) => (
<p
key={index}
dangerouslySetInnerHTML={{
__html: highlightSearchTerm(context, debouncedSearchValue),
}}
/>
))}
</div>
)}
</article>
))}
</div>
</div>
)
}
#핵심 함수들
// 텍스트 정제 함수
function cleanContentForSearch(content) {
return content
.replace(/```[\s\S]*?```/g, '') // 코드 블록 제거
.replace(/`[^`]*`/g, '') // 인라인 코드 제거
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 마크다운 링크 제거
.replace(/#{1,6}\s+/gm, '') // 헤더 마크업 제거
.replace(/<[^>]*>/g, '') // HTML 태그 제거
.replace(/\s+/g, ' ') // 공백 정리
.trim()
}
// 컨텍스트 추출 함수
function extractMatchingContext(content, searchTerm, contextLength = 150) {
const cleanedContent = cleanContentForSearch(content)
const searchIndex = cleanedContent.toLowerCase().indexOf(searchTerm.toLowerCase())
if (searchIndex === -1) return []
const start = Math.max(0, searchIndex - contextLength / 2)
const end = Math.min(cleanedContent.length, searchIndex + searchTerm.length + contextLength / 2)
let context = cleanedContent.slice(start, end).trim()
if (start > 0) context = '...' + context
if (end < cleanedContent.length) context = context + '...'
return [context]
}
// 하이라이팅 함수
function highlightSearchTerm(text, searchTerm) {
if (!searchTerm) return text
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return text.replace(regex, '<mark class="highlight">$1</mark>')
}
#성능 개선 효과와 실무 팁
#📊 성능 개선 결과
| 항목 | 최적화 전 | 최적화 후 | 개선율 |
|---|---|---|---|
| 입력 응답성 | 버벅거림 | 즉시 반응 | 100% |
| 검색 속도 | 글자마다 연산 | 300ms 후 1회 | 80% 단축 |
| CPU 사용량 | 높음 | 낮음 | 70% 감소 |
| 메모리 효율 | 중복 연산 | 결과 재사용 | 재계산 0% |
#🎯 실무 활용 팁
#1. Debounce 시간 조절
const debouncedValue = useDebounce(searchValue, delay)
// 용도별 권장 시간
const DELAYS = {
search: 300, // 검색: 300ms (사용자 경험 고려)
autoSave: 1000, // 자동저장: 1초 (서버 부하 고려)
resize: 100, // 리사이즈: 100ms (반응성 중요)
scroll: 10, // 스크롤: 10ms (부드러운 애니메이션)
}
#2. 메모이제이션 최적화
// ✅ 의존성 배열을 정확히 설정
const expensiveValue = useMemo(() => {
return heavyCalculation(data)
}, [data]) // data가 바뀔 때만 재계산
// ❌ 의존성을 빼먹으면 버그 발생
const expensiveValue = useMemo(() => {
return heavyCalculation(data)
}, []) // data가 바껴도 재계산 안됨!
#3. 검색 UX 개선
function SearchWithStatus({ posts }) {
const [searchValue, setSearchValue] = useState('')
const debouncedValue = useDebounce(searchValue, 300)
const [isSearching, setIsSearching] = useState(false)
useEffect(() => {
if (searchValue !== debouncedValue) {
setIsSearching(true) // 검색 중 표시
} else {
setIsSearching(false) // 검색 완료
}
}, [searchValue, debouncedValue])
return (
<div>
<input value={searchValue} onChange={(e) => setSearchValue(e.target.value)} />
{isSearching && <span>검색 중...</span>}
</div>
)
}
#4. 대용량 데이터 처리
// 가상 스크롤링과 함께 사용
const ITEMS_PER_PAGE = 20
const paginatedResults = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
return searchResults.slice(startIndex, startIndex + ITEMS_PER_PAGE)
}, [searchResults, currentPage])
#🚨 주의사항
- 과도한 최적화 금지: 성능 문제가 없다면 최적화하지 마세요
- 메모리 누수 주의: 큰 객체를 메모이제이션하면 메모리 사용량 증가
- 의존성 배열 관리: useMemo, useCallback의 의존성을 정확히 설정
- Hook 규칙 준수: 조건문이나 반복문 안에서 Hook 사용 금지
#🔧 디버깅 도구
// 리렌더링 원인 확인
function useWhyDidYouUpdate(name, props) {
const previous = useRef()
useEffect(() => {
if (previous.current) {
const allKeys = Object.keys({ ...previous.current, ...props })
const changedProps = allKeys.reduce((obj, key) => {
if (previous.current[key] !== props[key]) {
obj[key] = {
from: previous.current[key],
to: props[key],
}
}
return obj
}, {})
if (Object.keys(changedProps).length) {
console.log('[why-did-you-update]', name, changedProps)
}
}
previous.current = props
})
}
// 사용 예시
function MyComponent(props) {
useWhyDidYouUpdate('MyComponent', props)
return <div>...</div>
}
#마무리
검색 기능 최적화는 단순히 코드만 빠르게 만드는 것이 아닙니다. 사용자 경험을 개선하고, 서버 부하를 줄이며, 더 나은 웹 애플리케이션을 만드는 것입니다.
핵심 원칙:
- 사용자 우선: 입력은 즉시 반영, 연산은 적절히 지연
- 효율성 추구: 한 번 계산한 것은 재사용
- 규칙 준수: React의 규칙을 따라 안정성 확보
이러한 최적화 기법들을 익혀두시면 더 나은 React 개발자가 될 수 있습니다! 🚀