React 검색 필터 테스트: 교집합·빈 결과·입력 초기화 검증

··설명 약 9분·작성: 해비·블로그 소개

React 검색어와 상태 필터의 교집합·빈 결과·초기화는 어떻게 테스트하나요?

검색창과 상태 필터가 각각 동작해도 두 조건을 함께 쓰면 결과가 틀릴 수 있습니다. 검색과 상태의 교집합, 빈 결과, 검색어 지우기, 원본 데이터 보존을 테스트로 정하세요.

적용 조건·범위: 서버 검색을 추가한다면 요청 중·실패·응답 순서 경쟁도 검증 대상으로 늘어납니다. 현재 없는 기능을 테스트에 먼저 가정하지 않고 실제 구현 범위에 맞춰 확장하세요.

본문 근거: Testing Library: userEvent 설정 · React Testing Library 예제 · 설정 문서

테스트 대상과 준비

React·TypeScript 검색 필터 실습의 App.tsx, App.css, filterProjects.ts를 대상으로 합니다. 이 글의 전체 코드와 독립 ZIP에도 같은 App.tsx·App.css·filterProjects.ts가 포함되어 있어 이전 프로젝트 없이 시작할 수 있습니다.

이미 테스트 환경이 있다면 프로젝트의 도구를 유지하세요. 아래는 Vitest와 React Testing Library를 사용해 이 예제를 따로 실습하는 구성입니다. Jest 설정에 Vitest 문법을 그대로 섞지 않습니다.

npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom

프로젝트 루트의 vitest.config.ts에는 다음을 둡니다. 기존 Vite 설정을 쓰고 있다면 중복 파일을 무조건 추가하지 말고 기존 설정에 test 옵션을 합칩니다.

vite.config.ts 전체 코드 보기

src/test/setup.ts입니다. cleanup을 이곳 한 군데에서 등록합니다.

src/test/setup.ts 전체 코드 보기

React·TypeScript 프로젝트의 tsconfig에는 JSX 변환 설정이 있어야 합니다. 이 예제는 jsx: “react-jsx”를 사용합니다. ZIP에서는 원본 App.tsx, App.css, filterProjects.ts를 내용 변경 없이 src에 복사하고 App.test.tsx를 같은 폴더에 둡니다. 경로만 src로 옮겼으므로 React 구현 글의 데이터·label·역할·필터 계약이 같습니다.

목차

화면 동작을 테스트 항목으로 바꾸기

사용자 동작 기대 결과 놓치기 쉬운 오류
첫 화면 열기 3개 항목 초기 필터가 잘못 적용됨
공백 포함 REACT 입력 2개 항목 공백·대소문자 처리 누락
React 검색 후 완료 선택 초대장 1개 AND 대신 OR 조건
React 검색 후 기획 선택 0개 안내 이전 결과가 화면에 남음
검색어 지우기 기획 상태 유지, 사진 모음 표시 필터까지 초기화됨
필터 함수 호출 원본 배열 유지 sort·splice 등 원본 변경

테스트 이름은 컴포넌트 내부 함수보다 사용자가 기대하는 결과를 설명하도록 작성합니다. 검색창은 연결된 label로 찾으므로 접근 가능한 이름도 함께 확인됩니다.

App.test.tsx 전체 코드

src/App.test.tsx 전체 코드 보기

userEvent.setup으로 만든 user의 입력·선택 작업에는 await를 붙입니다. 존재해야 하는 요소는 getBy, 없어야 하는 요소는 queryBy로 확인합니다. 이 예제는 API 요청이 없으므로 임의의 setTimeout, fetch mock, 라우터 mock을 추가하지 않습니다.

within(table)은 검증 범위를 결과 표로 좁힙니다. 같은 프로젝트명이 다른 곳에 추가되어도 표 안의 결과를 확인할 수 있습니다. afterEach(cleanup)은 앞 테스트의 화면이 다음 테스트에 남지 않게 합니다.

초기 행 4개에는 헤더 1개와 데이터 3개가 포함됩니다. 빈 결과도 안내를 담은 행 하나가 있으므로 row 수만으로 빈 결과를 판정하지 않습니다. 결과 개수와 실제 cell의 존재를 함께 확인해야 “숫자만 바뀌고 이전 행이 남는” 버그를 구별할 수 있습니다. 원본 보존 검사는 반환 배열과 원본이 같은 참조여야 한다는 뜻이 아니라, 호출 전후 원본 데이터 내용이 같아야 한다는 뜻입니다.

실행 결과를 읽는 방법

npx vitest run src/App.test.tsx

파일을 src 아래에 둔 일반적인 프로젝트의 실행 명령입니다. 테스트 파일 위치가 다르면 경로만 맞춥니다. 하나의 테스트가 실패하면 메시지의 expected와 실제 DOM을 보고 조건 계산·label·렌더링 중 어느 부분이 달라졌는지 먼저 찾습니다.

예를 들어 필터 함수의 matchesKeyword && matchesStatus를 OR 조건으로 바꾸면 교집합과 빈 결과 테스트가 실패해야 합니다. 이처럼 실제 버그를 일부러 넣었을 때 실패하는지 확인하면 테스트가 단순히 실행만 되는지 구분할 수 있습니다.

이 테스트로 보장하지 않는 범위

jsdom에서 확인하는 것은 DOM과 사용자 상호작용입니다. 실제 모바일 화면의 너비, 가로 스크롤의 사용성, 브라우저별 렌더링, 화면 읽기 프로그램의 음성 출력까지 자동으로 보장하지 않습니다. 좁은 화면의 레이아웃은 별도 브라우저 확인이 필요합니다.

서버 검색을 추가한다면 요청 중·실패·응답 순서 경쟁도 검증 대상으로 늘어납니다. 현재 없는 기능을 테스트에 먼저 가정하지 않고 실제 구현 범위에 맞춰 확장하세요.

독립 실습 파일과 실행 순서

아래 ZIP은 이 글만 읽어도 실행할 수 있는 완성 프로젝트입니다. 압축을 풀고 package.json이 있는 폴더에서 Node 22.12 이상을 사용합니다. 처음은 npm install, 이후 생성한 잠금 파일을 보관한 환경은 npm ci를 사용하세요. test는 감시 모드이며 test:run은 한 번 실행하고 종료합니다.

터미널

npm install
npm run test:run
npm run build
npm run dev

검증 버전은 package.json에 고정되어 있습니다. npm run dev는 독자가 화면을 직접 확인하는 명령입니다. 작성 검증은 Node/jsdom 테스트와 TypeScript·Vite 빌드까지 수행했으며 실제 브라우저의 화면·키보드·레이아웃 검증은 수행하지 않았습니다.

src/vite-env.d.ts의 /// <reference types="vite/client" />는 CSS import 타입을 연결합니다. 이 파일 없이 TypeScript 7에서 CSS 부수 효과 import 오류가 나면 테스트 환경을 바꾸는 대신 타입 선언부터 복구하세요.

검증 결과: 테스트 5개 통과, TypeScript 검사 및 Vite 프로덕션 빌드 통과. README의 오류 재현 연습은 일부러 코드를 바꾸고 실패를 확인한 뒤 복원하도록 구성했습니다.

의도적인 오류 검증도 실행했습니다. AND 조건을 OR로 바꾸자 교집합·빈 결과 검증이 실패했습니다. 변경을 복원한 완성 코드를 ZIP에 제공합니다.

설정 기준은 Vitest 환경 문서와 설정 문서에서 확인할 수 있습니다. DOM 실습의 이벤트와 matcher는 user-event 소개, jest-dom의 Vitest 연결을 따릅니다.

선택 실습: 같은 프로젝트로 연습하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 앱의 동작을 자동 테스트로 확인합니다.

이번 회차 추가·수정 파일: app/lib/filter-tasks.ts , app/lib/filter-tasks.test.ts , app/ui/task-list-client.tsx

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

누적 프로젝트 적용

시작 위치: 아래 기존 환경·파일 구성에서 시작합니다. 이 글의 완료 ZIP은 결과 확인용입니다.

실습 프로젝트 ZIP 내려받기

시작 체크포인트: Jest console.error 테스트: try catch 에러 검증의 전체 파일·변경 내용에서 이어갑니다.

기존 목록의 필터·조회 식을 아래 완전한 task-list-client.tsx로 바꿉니다. 새 filter-tasks.ts와 테스트를 추가합니다. ownerId 키와 원래 q/status 필터 동작을 유지합니다. npm run test:run 뒤 실제 /tasks 검색·완료 필터도 확인합니다. 검색어를 지워도 완료 필터는 유지되어야 합니다.

이번 회차 추가·수정 파일: app/lib/filter-tasks.ts, app/lib/filter-tasks.test.ts, app/ui/task-list-client.tsx

전체 파일·코드 보기

app/lib/filter-tasks.ts — 전체 파일

import type { Task } from './types';
export function filterTasks(tasks: readonly Task[], q: string, status: string): Task[] {
  return tasks.filter(task =>
    task.title.toLowerCase().includes(q.toLowerCase()) &&
    (status === 'done' ? task.completed : status === 'active' ? !task.completed : true)
  );
}

app/lib/filter-tasks.test.ts — 전체 파일

import { expect, test } from 'vitest';
import { filterTasks } from './filter-tasks';
import type { Task } from './types';
test('검색과 완료 조건의 교집합·빈 결과·검색 초기화·원본 보존', () => {
  const rows: Task[] = [
    { id: 'a', title: 'React 읽기', completed: false, ownerId: 'demo', description: '' },
    { id: 'b', title: 'React 실습', completed: true, ownerId: 'demo', description: '' },
    { id: 'c', title: 'Expo 읽기', completed: true, ownerId: 'demo', description: '' },
  ];
  const before = structuredClone(rows);
  expect(filterTasks(rows, 'REACT', 'all').map(t => t.id)).toEqual(['a', 'b']);
  expect(filterTasks(rows, 'React', 'done').map(t => t.id)).toEqual(['b']);
  expect(filterTasks(rows, '없는 값', 'done')).toEqual([]);
  expect(filterTasks(rows, '', 'done').map(t => t.id)).toEqual(['b', 'c']);
  expect(rows).toEqual(before);
});

app/ui/task-list-client.tsx — 전체 파일

"use client";
import { taskKeys } from '@/app/lib/task-keys';
import { getTasks } from '@/app/lib/tasks-api';
import { filterTasks } from '@/app/lib/filter-tasks';
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import type { Task } from "@/app/lib/types";
export default function TaskListClient({
  ownerId,
  q,
  status,
}: {
  ownerId: string;
  q: string;
  status: string;
}) {
  const {
    data = [],
    isError,
    isFetching,
    refetch,
  } = useQuery<Task[]>({
    queryKey: taskKeys.list(ownerId),
    queryFn: ({ signal }) => getTasks(signal),
    staleTime: 60000,
  });
  const tasks = filterTasks(data, q, status);
  return (
    <section>
      {isError ? <p role="alert">목록 조회에 실패했습니다.</p> : null}
      <button disabled={isFetching} onClick={() => void refetch()}>
        목록 새로 조회
      </button>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link href={"/tasks/" + task.id}>{task.title}</Link> ·{" "}
            {task.completed ? "완료" : "진행 중"}
          </li>
        ))}
      </ul>
      {tasks.length === 0 ? <p>조건에 맞는 작업이 없습니다.</p> : null}
    </section>
  );
}

실행 결과 기록: 명령·종료 코드·테스트 개수·실패 이유를 남깁니다. 타입 검사와 jsdom 통과는 실제 브라우저·인증 서버·영속 저장소 검증이 아닙니다.

원래 제안된 확장 기준: 아래 검증 목표 중 현재 회차 파일이 다루는 범위는 위 실행 안내를 따릅니다. 이름만 적힌 testing-step 체크포인트는 다운로드 파일명이 아닙니다.

직접 확인: 5회차 테스트 기준

테스트 대상: app/lib/filter-tasks.test.ts의 검색·상태 교집합과 원본 보존. 기존 독립 예제는 그대로 연습하고, 아래 기준은 Next.js TaskFlow에 적용할 때 확인합니다. 공개 글의 코드 예시와 실제 프로젝트의 파일 경로·타입이 다른 경우 현재 소스를 먼저 대조하세요.

  1. 검색어만, 상태만, 두 조건의 교집합, 결과 0건, 검색어 초기화, 원본 배열 불변을 각기 독립 fixture로 검증합니다. AND를 잠시 OR로 바꿔 교집합 검사가 실패하는지 보고 복구합니다.
  2. 실행 명령: npm run test:run && npm run build. 실제 명령의 종료 코드와 테스트 파일·개수·실패 메시지를 기록합니다.

통과 예상: 교집합에는 두 조건을 모두 만족하는 작업만 남아야 합니다. 검색어만 지우면 선택한 상태 필터가 유지되고 그 상태의 전체 작업이 보입니다. 두 조건을 모두 전체/빈 값으로 되돌린 경우에만 원본 전체 목록이 표시되며 원본 순서·내용은 유지되어야 합니다.

실패 예상: AND를 OR로 바꾸면 한 조건만 만족하는 작업이 섞여 교집합 테스트가 실패해야 합니다.

실패 진단: 순서에 따라 결과가 달라지면 fixture 공유나 배열의 제자리 변경을 확인하고, UI만 실패하면 실제 목록 컴포넌트의 함수 연결을 확인합니다.

검증 기록: 이 글의 실습 절차는 작성했지만, 이번 개편에서 TaskFlow 원본 프로젝트의 테스트·빌드를 실행하지 않았습니다. 따라서 testing-step-05-filter 완료와 테스트 통과는 미확인입니다. 실행 로그를 확인한 뒤에만 완료로 바꾸세요.

다음 학습: 6회차 QueryClient 격리·목록 갱신

전체 코드

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>테스트 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

package.json

{
  "name": "testing-practice-7809",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite",
    "test": "vitest",
    "test:run": "vitest run",
    "typecheck": "tsc --noEmit",
    "build": "tsc --noEmit && vite build"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "typescript": "7.0.2",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7",
    "@testing-library/jest-dom": "7.0.1"
  }
}

src/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;
  }
}

src/App.test.tsx

import { describe, expect, it } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App, { projects } from './App';
import { filterProjects } from './filterProjects';

describe('검색과 상태 필터', () => {
  it('처음에는 전체 프로젝트 세 개를 보여준다', () => {
    render(<App />);
    expect(screen.getByRole('status')).toHaveTextContent('검색 결과 3개');
    expect(screen.getAllByRole('row')).toHaveLength(4);
  });
  it('검색어의 앞뒤 공백과 대소문자를 정리한다', async () => {
    const user = userEvent.setup();
    render(<App />);
    await user.type(screen.getByRole('searchbox', { name: '검색어' }), '  REACT  ');
    expect(screen.getByRole('status')).toHaveTextContent('검색 결과 2개');
    expect(screen.queryByRole('cell', { name: '사진 모음' })).not.toBeInTheDocument();
  });
  it('검색과 상태 조건을 동시에 만족하는 항목만 보여준다', async () => {
    const user = userEvent.setup();
    render(<App />);
    await user.type(screen.getByRole('searchbox', { name: '검색어' }), 'react');
    await user.selectOptions(
      screen.getByRole('combobox', { name: '진행 상태' }),
      'done',
    );
    const table = screen.getByRole('table', { name: '검색 조건에 맞는 프로젝트' });
    expect(within(table).getByRole('cell', { name: '초대장' })).toBeInTheDocument();
    expect(
      within(table).queryByRole('cell', { name: '작업 기록' }),
    ).not.toBeInTheDocument();
    expect(screen.getByRole('status')).toHaveTextContent('검색 결과 1개');
  });
  it('빈 결과를 알리고 검색어를 지우면 상태 필터는 유지한다', async () => {
    const user = userEvent.setup();
    render(<App />);
    const search = screen.getByRole('searchbox', { name: '검색어' });
    const status = screen.getByRole('combobox', { name: '진행 상태' });
    await user.type(search, 'react');
    await user.selectOptions(status, 'planning');
    expect(screen.getByText('조건에 맞는 프로젝트가 없습니다.')).toBeInTheDocument();
    expect(screen.getByRole('status')).toHaveTextContent('검색 결과 0개');
    await user.clear(search);
    expect(status).toHaveValue('planning');
    expect(screen.getByRole('cell', { name: '사진 모음' })).toBeInTheDocument();
    expect(
      screen.queryByText('조건에 맞는 프로젝트가 없습니다.'),
    ).not.toBeInTheDocument();
  });
  it('설명과 기술도 검색하며 원본 데이터를 변경하지 않는다', () => {
    const before = structuredClone(projects);
    expect(filterProjects(projects, '업무 시간', 'all').map((p) => p.id)).toEqual([2]);
    expect(filterProjects(projects, 'typescript', 'all').map((p) => p.id)).toEqual([1]);
    expect(filterProjects(projects, '', 'all')).toHaveLength(3);
    expect(projects).toEqual(before);
  });
});

src/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>
  );
}

src/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;
  });
}

src/index.css

body {
  margin: 0;
  font-family: sans-serif;
  color: #222;
  background: #fff;
}
main {
  max-width: 36rem;
  margin: 3rem auto;
  padding: 1rem;
}
form {
  display: grid;
  gap: 0.75rem;
}
input,
button {
  font: inherit;
  padding: 0.6rem;
}

src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

src/test/setup.ts

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => cleanup());

src/vite-env.d.ts

/// <reference types="vite/client" />

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src", "vite.config.ts"]
}

vite.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'],
  },
});

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

글 소개·학습 준비·전체 목차
먼저 확인할 내용

검색창과 상태 선택이 각각 동작해도 두 조건을 함께 적용하면 오류가 날 수 있습니다. 앞의 검색 필터 예제를 대상으로 교집합, 빈 결과, 검색어 지우기와 원본 보존을 테스트합니다.

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 9개 · 필수 7개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기