FAQ 화면을 예제로 정리한 React 입문자용 작업순서 가이드
처음부터 useState, filter, 이벤트 함수를 한 번에 만들려고 하지 않는다.
먼저 화면을 그냥 HTML 화면으로 분석한다.
처음에는 React 문법보다 화면 덩어리를 나누는 것이 먼저다.
const categories = [
'전체',
'회원',
'우리아파트',
'공동현관문'
]
const faqs = [
{
id: 1,
subject: '회원',
question: '회원 비밀번호를 잊어버렸어요',
answer: '...'
},
{
id: 2,
subject: '회원',
question: '회원 인증 번호가 오지 않습니다',
answer: '...'
}
]
질문은 하나다.
const [activeCategory, setActiveCategory] = useState('전체')
const [searchInput, setSearchInput] = useState('')
const [submittedSearch, setSubmittedSearch] = useState('')
searchInput
→ 지금 input에 입력 중인 값
submittedSearch
→ 검색 버튼을 눌러 확정된 값
예를 들어 사용자가 아직 검색 버튼을 누르지 않았다면:
searchInput === '비밀번호'
submittedSearch === ''
검색 버튼을 누르면:
setSubmittedSearch(searchInput)
처음부터 map, filter까지 넣지 않아도 된다.
function Feature() {
return (
<main>
<section className="search-area">
<input type="text" />
<button type="button">x</button>
<button type="button">검색</button>
</section>
</main>
)
}
이 단계의 목표는 단 하나다.
{categories.map((category) => (
<button key={category}>
{category}
</button>
))}
해석은 간단하다.
{faqs.map((faq) => (
<details key={faq.id}>
<summary>{faq.question}</summary>
<div>{faq.answer}</div>
</details>
))}
const [searchInput, setSearchInput] = useState('')
<input
value={searchInput}
onChange={(event) => {
setSearchInput(event.target.value)
}}
/>
<button
onClick={() => setActiveCategory(category)}
>
{category}
</button>
className={
activeCategory === category
? 'chip active'
: 'chip'
}
또는:
className={`chip${activeCategory === category ? ' active' : ''}`}
const visibleFaqs = faqs.filter((faq) => {
return (
activeCategory === '전체' ||
faq.subject === activeCategory
)
})
예를 들어 activeCategory가 회원이면 회원 FAQ만 남는다.
사용자가 직접 바꾸는 원본 값
→ useState
원본 값으로 계산 가능한 값
→ 일반 변수
예:
activeCategory → state
searchInput → state
submittedSearch → state
visibleFaqs → 계산값
visibleFaqs.length → 계산값
keyword → 계산값
const keyword = submittedSearch.trim()
const visibleFaqs = faqs.filter((faq) => {
const matchesCategory =
activeCategory === '전체' ||
faq.subject === activeCategory
const matchesSearch =
keyword === '' ||
faq.subject.includes(keyword) ||
faq.question.includes(keyword) ||
faq.answer.includes(keyword)
return matchesCategory && matchesSearch
})
처음부터 이 완성형으로 가지 말고 단계적으로 키우는 게 좋다.
const handleCategoryClick = (category) => {
setSearchInput('')
setSubmittedSearch('')
setActiveCategory(category)
}
처음에는 JSX 안에 직접 써도 된다.
onClick={() => {
setSearchInput('')
setSubmittedSearch('')
setActiveCategory(category)
}}
길어지면 handleCategoryClick 같은 함수로 분리한다.
{!submittedSearch && (
<>
<h1>자주 묻는 질문</h1>
<div>카테고리</div>
</>
)}
{submittedSearch && (
<div>
"{submittedSearch}" 검색 결과
</div>
)}
{visibleFaqs.length > 0 ? (
<div>FAQ 목록</div>
) : (
<div>검색결과가 없습니다.</div>
)}
const handleSearchKeyDown = (event) => {
if (event.key === 'Enter') {
handleSearch()
}
}
const searchInputRef = useRef(null)
<input ref={searchInputRef} />
searchInputRef.current?.focus()
핵심 검색/필터 기능 없이도 화면은 만들 수 있으므로, DOM 직접 접근이 필요한 기능은 뒤로 미뤄도 된다.
현재 규모에서는 먼저 일반 변수 방식으로 이해하는 편이 더 좋다.
const visibleFaqs = faqs.filter(...)
성능 최적화가 필요할 때 useMemo를 추가한다.
| 순서 | 생각할 것 | 실제 코드 |
|---|---|---|
| 1 | 기존 HTML 확인 | 구조 분석 |
| 2 | JSX로 이동 | class → className |
| 3 | 반복 요소 찾기 | categories, faqs |
| 4 | 반복 데이터 배열화 | const 배열 |
| 5 | 반복 렌더링 | map() |
| 6 | 변하는 값 찾기 | state 후보 |
| 7 | state 선언 | useState() |
| 8 | state와 UI 연결 | value, className |
| 9 | 이벤트 연결 | onClick, onChange |
| 10 | 계산값 만들기 | filter() |
| 11 | 상황별 화면 | &&, ? : |
| 12 | 이벤트 코드 정리 | handleXXX |
| 13 | 편의기능 | Enter, clear |
| 14 | DOM 직접 접근 | useRef |
| 15 | 성능 최적화 | useMemo |
| 16 | 코드가 커짐 | 컴포넌트 분리 |
const categories = [...]
const faqs = [...]
function Feature() {
// 1. 변하는 값
const [activeCategory, setActiveCategory] = useState('전체')
const [searchInput, setSearchInput] = useState('')
const [submittedSearch, setSubmittedSearch] = useState('')
// 2. 화면에 보여줄 데이터 계산
const visibleFaqs = faqs.filter(...)
// 3. 사용자 행동
const handleCategoryClick = () => {}
const handleSearch = () => {}
const handleClear = () => {}
// 4. 화면
return (
<>
검색창
categories.map(...)
visibleFaqs.map(...)
</>
)
}
React 파일이 복잡해 보여도 우선 이 네 덩어리로 나눠서 보면 훨씬 쉽다.
완성 코드를 외우는 것보다 아래 순서로 직접 다시 복원하는 편이 훨씬 좋다.
검색창 + categories.map + faqs.map까지만 직접 작성한다.
activeCategory와 active class 처리만 완성한다.
searchInput과 onChange까지만 연결한다.
visibleFaqs를 만든다.
submittedSearch, 검색 결과, empty 화면을 붙인다.
Enter, clear, focus, useRef 등을 추가한다.
import { useState, useMemo, useRef } from 'react'
const categories = ['전체', '회원', '우리아파트', '공동현관문']
const faqs = [
{
id: 1,
subject: '회원',
question: '회원 비밀번호를 잊어버렸어요',
answer:
'로그인 화면에서 "비밀번호 찾기"를 클릭하면 가입한 이메일이나 휴대폰 번호로 임시 비밀번호를 발급받을 수 있습니다.',
},
{
id: 2,
subject: '회원',
question: '회원 인증 번호가 오지 않습니다',
answer: '스팸 문자함 또는 스팸 메일함을 확인해 주세요. 070 번호가 차단되어 있다면 해제한 뒤 다시 시도해 주세요.',
},
{
id: 3,
subject: '회원',
question: '회원 탈퇴하고 싶습니다',
answer: '[마이페이지] > [회원 정보 수정] 맨 아래에 있는 "회원 탈퇴" 버튼을 통해 진행할 수 있습니다.',
},
{
id: 4,
subject: '우리아파트',
question: '우리 아파트 메뉴에서는 무엇을 확인할 수 있나요?',
answer: '아파트 공지, 일정, 게시글, 관리사무소 정보 등 우리 단지 관련 정보를 확인할 수 있습니다.',
},
{
id: 5,
subject: '공동현관문',
question: '공동현관문 출입은 어떻게 이용하나요?',
answer: '단지에서 제공하는 인증 및 출입 설정에 따라 공동현관문 출입 기능을 사용할 수 있습니다.',
},
{
id: 6,
subject: '공동현관문',
question: '공동현관문이 고장 나서 열리지 않아요',
answer: '자동문 안쪽이나 측면에 있는 "정지(수동) 버튼"을 누르면 모터 작동이 멈춰 수동으로 문을 열 수 있습니다.',
},
]
function Feature() {
const [activeCategory, setActiveCategory] = useState('전체')
const [searchInput, setSearchInput] = useState('')
const [submittedSearch, setSubmittedSearch] = useState('')
const searchInputRef = useRef(null)
// 20260909: useMemo 사용 예시는 비교용으로 주석 처리했습니다.
// const visibleFaqs = useMemo(() => {
// const keyword = submittedSearch.trim()
// return faqs.filter((faq) => {
// const matchesCategory = activeCategory === '전체' || faq.subject === activeCategory
// const matchesSearch =
// keyword === '' ||
// faq.subject.includes(keyword) ||
// faq.question.includes(keyword) ||
// faq.answer.includes(keyword)
// return matchesCategory && matchesSearch
// })
// }, [activeCategory, submittedSearch])
// 20260909: useMemo 없이 렌더링 때마다 일반 변수로 FAQ 목록을 계산합니다.
const keyword = submittedSearch.trim()
const visibleFaqs = faqs.filter((faq) => {
const matchesCategory = activeCategory === '전체' || faq.subject === activeCategory
const matchesSearch =
keyword === '' ||
faq.subject.includes(keyword) ||
faq.question.includes(keyword) ||
faq.answer.includes(keyword)
return matchesCategory && matchesSearch
})
const handleCategoryClick = (category) => {
setSearchInput('')
setSubmittedSearch('')
setActiveCategory(category)
}
const handleSearch = () => {
setActiveCategory('전체')
setSubmittedSearch(searchInput)
searchInputRef.current?.focus()
}
const handleSearchKeyDown = (event) => {
if (event.key === 'Enter') {
handleSearch()
}
}
const handleClear = () => {
setSearchInput('')
setSubmittedSearch('')
searchInputRef.current?.focus()
}
return (
<main>
<section className="search-area">
<div className="search-box">
<input
id="searchInput"
type="text"
placeholder="질문을 입력해주세요"
ref={searchInputRef}
autoComplete="off"
onChange={(event) => setSearchInput(event.target.value)}
onKeyDown={handleSearchKeyDown}
value={searchInput}
/>
<button
id="clearBtn"
className={`clear-btn${searchInput ? ' show' : ''}`}
type="button"
aria-label="검색어 지우기"
onClick={handleClear}
>
x
</button>
<button id="searchBtn" className="search-btn" type="button" aria-label="검색" onClick={handleSearch}>
<span></span>
</button>
</div>
</section>
{/* 질문을 입력박스로 검색할때는 숨겨야하는데... */}
<section id="faqHome" className="faq-home">
{!submittedSearch && (
<>
<h1>자주 묻는 질문</h1>
<div className="category-list" role="tablist" aria-label="FAQ 카테고리">
{categories.map((category) => (
<button
className={`chip${activeCategory === category ? ' active' : ''}`}
key={category}
onClick={() => handleCategoryClick(category)}
type="button"
>
{category}
</button>
))}
</div>
</>
)}
{submittedSearch && (
<div id="resultSummary" className="result-summary">
<span>i</span> "<b>{submittedSearch}</b>"에 대한 결과가 {visibleFaqs.length}개 있습니다.
</div>
)}
{visibleFaqs.length > 0 ? (
<div id="faqList" className="faq-list">
{visibleFaqs.map((faq, index) => (
<details className="faq-item" key={faq.id} name="faq" open={index === 0}>
<summary className="faq-question">
<span>{faq.question}</span>
<i></i>
</summary>
<div className="faq-answer">
<div>{faq.answer}</div>
</div>
</details>
))}
</div>
) : (
<div id="emptyState" className="empty-state">
<div className="face">:)</div>
<strong>검색결과가 없습니다.</strong>
<p>
원하는 결과가 없다면
<br />
1:1 문의를 이용해주세요.
</p>
<button type="button">1:1 문의하기</button>
</div>
)}
</section>
</main>
)
}
export default Feature