
검색 화면을 만들다 보면 생각보다 상태가 단순하지 않다.
처음에는 useState 로 검색어와 필터만 관리하면 될 것처럼 보이지만, 조건이 늘어날수록 문제가 생긴다.
예를들어 이런 것들이다.
이번 글에서는 개인 프로젝트에서 검색 기능을 구연하며
검색 조건, URL 상태, 서버 상태를 각각 어떻게 나눠서 관리했는지 정해보려 한다.
처음에는 검색 조건을 모두 하나의 state 로 관리하면 된다고 생각했다.
하지만 실제로 구현하다 보니 이 방식은 금방 한계가 드러났다.
검색 화면에는 크게 세 종류의 상태가 섞여 있었다.
이 셋을 하나로 묶어버리면 문제가 생긴다.
예를 들어 사용자가 드롭다운에서 악기를 이것저것 선택하는 순간마다 바로 서버 요청이 나가면 UX가 산만해진다.
반대로 적용 버튼을 두면, 지금 화면에 보이는 선택값과 실제 결과를 만든 조건이 달라질 수 있다,
즉, 검색 화면은 단순한 입력 폼이 아니라 "편집 중인 조건" 과 "적용된 조건" 이 공존하는 구조였다.
그래서 나는 상태를 다음처럼 분리했다.
핵심은 "이 값은 누구의 책임인가?"를 먼저 정하는것이다.
검색창의 입력값, 드롭다운에서 선택 중인 값, 날짜 범위 등은 사용자가 아직 조정 중일 수 있다.
이 값들은 바로 서버 요청으로 이어질 필요가 없다.
그래서 로컬 상태로 두고, 사용자가 검색 버튼을 누르거나 적용 액션을 했을 때만 실제 검색 조건으로 반영되도록 했다.
const [draft, setDraft] = useState({
keyword: "",
instrumentCategory: "ALL",
instIds: [],
styleTagIds: [],
});
이 draft는 말 그대로 편집 중인 임시 상태다.
검색 버튼을 눌렀을 때만 draft를 applied로 복사한다.
이 applied state가 실제 검색 결과를 만들 기준이 된다.
const [applied, setApplied] = useState({
keyword: "",
instrumentCategory: "ALL",
instIds: [],
styleTagIds: [],
});
const onSearch = () => {
setApplied(draft);
};
이렇게 하면 사용자가 조건을 바꾸는 동안에는 결과가 흔들리지 않고,
원하는 시점에서만 검색을 다시 실행할 수 있다.
검색 결과는 프론트 로컬 상태가 아니라 서버 상태다.
직접useState로 들고 있을 필요가 없다.
이런 데이터는 TanStack Query가 더 잘한다.
예를 들어 검색 결과는 applied state를 기준으로 쿼리 키를 구성할 수 있다.
const { data, isLoading, isError } = useQuery({
queryKey: ["lessonSearch", applied],
queryFn: () => getLessonsReq(applied),
});
이렇게 하면 "무슨 조건으로 조회한 결과인지"가 queryKey에 그대로 남는다.
즉, 결과 데이터의 책임은 TanStack Query가 가지게 된다.
여기서 끝내면 아쉬운 문제가 남는다. 사실 해당 글을 작성하게 된 가장 큰 요인이다.
검색 조건을 로컬 상태에만 두면 이런 일이 발생한다.
검색 페이지라면 이런 UX는 피할 수 있으면 피해야 한다고 생각한다.
사용자는 검색 결과를 하나의 "상태"가 아니라 거의 "페이지"처럼 인식하기 때문이다.
그래서 적용된 검색 조건은 URL 에도 반영하도록 했다.
?keyword=flute&instrumentCategory=WOODWIND&instIds=1&instIds=2
이렇게 하면 URL만 봐도 현재 어떤 조건으로 검색 중인지 알 수 있고,
브라우저 히스토리와도 자연스럽게 연결된다.
여기서 중요한 포인트는
모든 상태를 URL에 넣지 않았다는 점이다.
사용자가 드롭다운을 열고 이것저것 만지는 순간마다 URL이 계속 바뀌면
히스토리도 지저분해지고 UX도 좋지 않다.
그래서 기준을 이렇게 잡았다.
즉, 사용자가 "검색 실행"을 한 시점의 조건만 URL에 반영했다.
흐름은 대략 이렇다.
이 구조 덕분에 입력 중 상태와 적용된 상태를 깔끔하게 분리할 수 있었다.
문자열 검색이 하나만 다루면 쉬운데,
실제 검색에서는 배열 조건이 꼭 등장한다.
내 경우에는 이런 값들이 있었다.
instIds: number[]styleTagIds: number[]이런 배열 조건은 URL에 어떻게 표현할지부터 정해야 한다.
나는 반복 키 방식으로 맞췄다.
instIds=1&instIds=2&instIds=3
이 방식은 프론트에서도 직관적이고, 백엔드에서 @RequestParam List<Long> 혹은 비슷한 구조로 받기도 편하다.
예를들어 qs 같은 라이브러리를 사용하면 이런 식으로 다룰 수 있다.
qs.stringify(
{ instIds: [1, 2, 3] },
{ arrayFormat: "repeat" }
);
배열 필터를 다루는 순간 URL 설계는 부가 기능이 아닌 검색 API와 프론트 상태 구조를 연결하는 인터페이스가 된다.
1. 검색 실행 시점이 명확해졌다
사용자가 입력하는 순간마다 서버 요청이 나가지 않고,
적용된 조건만 기준이 되니 동작이 안정적이었다.
2. 새로고침/뒤로가기/공유에 대응할 수 있었다
검색 조건이 URL에 남기 때문에
브라우저 히스토리와 사용 경험이 자연스럽게 연결되었다.
3. 상태의 책임이 분리되었다
이 구분 덕분에 코드의 역할도 선명해졌다.
검색 화면은 생각보다 상태가 많은 UI다.
특히 필터가 여러 개 붙고, URL 공유와 브라우저 히스토리까지 고려하기 시작하면 단순히 useState 몇 개로 끝나지 않는다.
이번 구현에서 내가 정리한 기준은
결국 중요한 건 상태를 많이 쓰느냐 적게 쓰느냐가 아니라,
각 상태의 책임을 어디까지로 볼 것인가였다.
검색 기능은 이전에도 구현해봤지만,
막상 구조를 제대로 생각해서 나누려고 하니 지저분해지는 영역이라는 생각이 들었다.
그래서 처음부터 역할을 나눠두고 상황을 고려하는것이 중요하다고 느껴졌다.