검색어와 진행 상태를 동시에 적용하는 React·TypeScript 목록을 만듭니다. 입력 상태 두 개만 저장하고 결과는 계산하며, 검색 결과가 없을 때의 안내와 모바일 표 스크롤까지 연결합니다.
완성 결과와 실습 범위
이 글은 가상의 프로젝트 세 개를 사용하는 학습용 예제입니다. 실제 서비스의 성과 분석이 아닙니다. 이름·설명·기술에서 검색하고, 진행 상태 조건을 동시에 적용합니다. 서버 요청, 로그인, 정렬, 페이지네이션은 넣지 않아 상태와 결과의 연결에 집중합니다.
| 검색어 | 상태 | 결과 |
|---|---|---|
| 비어 있음 | 전체 | 초대장·작업 기록·사진 모음 |
| React | 전체 | 초대장·작업 기록 |
| React | 완료 | 초대장 |
| React | 기획 | 0개 안내 |
| react | 전체 | 앞뒤 공백·대소문자를 정리해 2개 |
Vite의 React·TypeScript 프로젝트에서 src/filterProjects.ts, src/App.tsx, src/App.css를 아래 코드로 준비합니다. 기존 main.tsx의 App 렌더링은 유지합니다.
데이터 타입과 순수 필터 함수
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
App.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #fff;
font-family: sans-serif;
}
.project-page {
width: min(100% - 32px, 960px);
margin: 40px auto;
}
.filters {
display: grid;
grid-template-columns: auto 1fr auto 160px;
gap: 12px;
align-items: center;
}
input,
select {
width: 100%;
min-width: 0;
min-height: 44px;
padding: 10px;
border: 1px solid #777;
font: inherit;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 540px;
border-collapse: collapse;
}
caption {
text-align: left;
padding: 12px 0;
}
th,
td {
padding: 12px;
border: 1px solid #aaa;
text-align: left;
}
th {
background: #f3f3f3;
}
input:focus-visible,
select:focus-visible,
.table-scroll:focus-visible {
outline: 2px solid #222;
outline-offset: 2px;
}
@media (max-width: 640px) {
.filters {
grid-template-columns: 1fr;
}
}
App.test.tsx
import { expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App, { projects } from './App';
import { filterProjects } from './filterProjects';
test('normalization, AND condition, description and empty results preserve source', () => {
const before = JSON.stringify(projects);
expect(filterProjects(projects, ' rEaCt ', 'all')).toHaveLength(2);
expect(filterProjects(projects, 'React', 'done').map((p) => p.id)).toEqual([1]);
expect(filterProjects(projects, 'React', 'planning')).toEqual([]);
expect(filterProjects(projects, '여행', 'all').map((p) => p.id)).toEqual([3]);
expect(filterProjects(projects, '', 'all')).toHaveLength(3);
expect(JSON.stringify(projects)).toBe(before);
});
test('typing and status selection update the visible rows', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByLabelText('검색어'), 'React');
expect(screen.queryByRole('cell', { name: '사진 모음' })).toBeNull();
await user.selectOptions(screen.getByLabelText('진행 상태'), 'done');
expect(screen.getByRole('cell', { name: '초대장' })).toBeTruthy();
expect(screen.queryByRole('cell', { name: '작업 기록' })).toBeNull();
await user.selectOptions(screen.getByLabelText('진행 상태'), 'planning');
expect(screen.queryByRole('cell', { name: '초대장' })).toBeNull();
expect(
screen.getByRole('cell', { name: '조건에 맞는 프로젝트가 없습니다.' }),
).toBeTruthy();
await user.clear(screen.getByLabelText('검색어'));
expect(screen.getByRole('cell', { name: '사진 모음' })).toBeTruthy();
});
App.tsx
import { useState } from 'react';
import { filterProjects } from './filterProjects';
import type { Project, StatusFilter } from './filterProjects';
import './App.css';
export const projects: Project[] = [
{
id: 1,
name: '초대장',
description: '행사 안내',
status: 'done',
stack: ['React', 'TypeScript'],
},
{
id: 2,
name: '작업 기록',
description: '업무 시간 기록',
status: 'in-progress',
stack: ['React'],
},
{
id: 3,
name: '사진 모음',
description: '여행 사진 정리',
status: 'planning',
stack: ['JavaScript'],
},
];
const statusLabels = {
planning: '기획',
'in-progress': '진행 중',
done: '완료',
};
function isStatusFilter(value: string): value is StatusFilter {
return (
value === 'all' ||
value === 'planning' ||
value === 'in-progress' ||
value === 'done'
);
}
export default function App() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const filteredProjects = filterProjects(projects, searchTerm, statusFilter);
return (
<main className="project-page">
<h1>프로젝트 목록</h1>
<div className="filters">
<label htmlFor="project-search">검색어</label>
<input
id="project-search"
type="search"
value={searchTerm}
placeholder="이름, 설명, 기술 검색"
onChange={(event) => setSearchTerm(event.target.value)}
/>
<label htmlFor="project-status">진행 상태</label>
<select
id="project-status"
value={statusFilter}
onChange={(event) => {
const value = event.target.value;
if (isStatusFilter(value)) setStatusFilter(value);
}}
>
<option value="all">전체</option>
<option value="planning">기획</option>
<option value="in-progress">진행 중</option>
<option value="done">완료</option>
</select>
</div>
<p role="status" aria-live="polite">
검색 결과 {filteredProjects.length}개
</p>
<div
className="table-scroll"
role="region"
aria-label="프로젝트 결과 표"
tabIndex={0}
>
<table>
<caption>검색 조건에 맞는 프로젝트</caption>
<thead>
<tr>
<th scope="col">이름</th>
<th scope="col">설명</th>
<th scope="col">상태</th>
<th scope="col">기술</th>
</tr>
</thead>
<tbody>
{filteredProjects.length === 0 ? (
<tr>
<td colSpan={4}>조건에 맞는 프로젝트가 없습니다.</td>
</tr>
) : (
filteredProjects.map((project) => (
<tr key={project.id}>
<td>{project.name}</td>
<td>{project.description}</td>
<td>{statusLabels[project.status]}</td>
<td>{project.stack.join(', ')}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</main>
);
}
filterProjects.ts
export type ProjectStatus = 'planning' | 'in-progress' | 'done';
export type StatusFilter = 'all' | ProjectStatus;
export type Project = {
id: number;
name: string;
description: string;
status: ProjectStatus;
stack: string[];
};
export function filterProjects(
projects: readonly Project[],
searchTerm: string,
statusFilter: StatusFilter,
): Project[] {
const keyword = searchTerm.trim().toLowerCase();
return projects.filter((project) => {
const matchesKeyword = [project.name, project.description, ...project.stack].some(
(value) => value.toLowerCase().includes(keyword),
);
const matchesStatus = statusFilter === 'all' || project.status === statusFilter;
return matchesKeyword && matchesStatus;
});
}
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
package.json
{
"name": "react-practice-7770",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 0.0.0.0",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"typescript": "7.0.2",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7"
}
}
test-setup.ts
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["*.ts", "*.tsx"]
}
vite-env.d.ts
/// <reference types="vite/client" />
vite.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'jsdom', setupFiles: ['./test-setup.ts'] },
});
filterProjects는 입력 배열을 바꾸지 않고 조건에 맞는 새 배열을 반환합니다. 이름·설명·기술 중 하나가 일치하면 검색 조건을 만족하고, 그 결과와 상태 조건을 AND로 연결합니다. 빈 문자열은 모든 문자열에 포함되므로 검색어가 비어 있으면 상태 필터만 남습니다.
상태 필터는 all과 실제 상태 값으로만 제한합니다. 화면에 표시할 한글 문구와 데이터 값은 분리합니다. 조건이 늘어나도 함수 입력과 반환값을 보면 역할을 파악할 수 있습니다.
App.tsx 전체 코드
state에는 searchTerm과 statusFilter만 저장합니다. 결과 목록까지 state로 저장하면 검색 조건과 결과를 따로 갱신해야 해서 서로 어긋날 수 있습니다. 이 예제는 작은 고정 배열이므로 렌더링 중 직접 계산합니다. useMemo는 화면이 느린지 측정한 다음 검토할 최적화 도구이며, 파생 데이터를 표현하기 위해 꼭 필요한 문법은 아닙니다.
select의 값은 문자열이므로 isStatusFilter로 허용된 값인지 확인합니다. 단순 타입 단언으로 아무 문자열이나 상태 값이라고 취급하지 않습니다. key는 순서가 바뀌어도 유지되는 project.id를 사용합니다.
App.css와 모바일 대응
작은 화면에서는 입력 요소를 세로로 쌓습니다. 표는 열의 관계를 유지하도록 표 영역 안에서 가로로 스크롤됩니다. 페이지 전체가 가로로 밀리는 것과 표 안에서만 스크롤되는 것은 다릅니다. 스크롤 영역은 키보드로도 진입할 수 있습니다.
직접 확인할 동작
- 처음에는 결과 3개와 세 프로젝트가 표시됩니다.
- 검색어 react를 입력하면 2개로 줄어듭니다.
- 완료를 선택하면 초대장만 남습니다.
- 기획을 선택하면 0개 안내가 표시됩니다.
- 검색어를 지우면 기획 상태의 사진 모음이 나타납니다.
- Tab 키로 검색창·상태 선택·표 영역에 접근하고 포커스 표시를 확인합니다.
검색과 상태 필터를 따로 시험하는 것만으로는 부족합니다. 두 조건을 동시에 적용했을 때 결과가 교집합인지 확인해야 합니다. 이를 자동으로 검증하는 과정은 아래 테스트 보강 글에서 이어집니다.
서버 목록으로 확장하기 전 판단
이 코드는 브라우저에 이미 있는 데이터를 검색합니다. 서버에서 페이지별로 일부만 받은 데이터라면 화면에 로드된 항목만 필터링됩니다. 전체 자료 검색이 필요할 때는 서버 검색 API와 요청 조건을 먼저 설계해야 합니다. 구현하지 않은 서버 검색이나 성능 개선을 이 예제의 결과로 설명하지 않습니다.
자동 테스트로 검색과 상태 조건을 함께 검증하기
본문의 filterProjects.ts, App.tsx, App.css를 그대로 실행하는 ZIP을 제공합니다. 입력은 searchTerm과 statusFilter만 저장하고 목록·개수는 계산합니다. 추가 API 없이 실행되며 서버 데이터 처리까지 구현한 예제가 아닙니다.
- npm install 뒤 npm run dev로 실행합니다.
- 검색어에 앞뒤 공백이 있는 rEaCt를 넣으면 두 개가 남아야 합니다.
- 완료를 선택하면 초대장 한 개, 기획을 선택하면 빈 결과 안내가 보여야 합니다.
- 검색어만 지우면 기획 필터가 유지되어 사진 모음이 표시되어야 합니다.
- npm run typecheck, npm test, npm run build를 실행합니다.
순수 함수 테스트는 대소문자·공백 정리, 이름 외 설명 검색, AND 조건, 빈 검색어, 원본 불변을 확인합니다. 화면 테스트는 라벨로 입력을 찾아 타이핑·선택 후 실제 행과 빈 결과를 확인합니다. 내부 state 값을 읽는 대신 사용자 결과를 검사합니다. jsdom 테스트는 모바일 레이아웃을 보증하지 않으므로 좁은 화면에서 표 스크롤과 키보드 초점을 직접 확인하세요.
아래 ZIP은 이 글의 핵심 동작을 직접 확인하는 독립 실습입니다. 압축을 풀고 README의 순서대로 실행하세요. 실습 코드 ZIP 다운로드
이 글이 도움이 되었나요?
React 학습 순서
필수 19개 · 전체 23개
읽음 기록 관리
전체 과정 목차 (23개)
- 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
- 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
- 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
- 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
- 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
- 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
- 선택 참고 · React state 업데이트 안됨 문제 해결
- 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
- 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
- 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
- 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
- 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
- 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
- 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
- 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
- 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
- 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
- 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
- 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
- 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결 현재 글
- 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
- 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
- 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.