FE | 부트캠프리스트 필터링 구조 리팩토링 기술회고

안녕하세요! 부트텐트에서 프론트엔드 개발을 맡고 있는 코디입니다.
이번 글에서는 캠프 리스트 페이지의 필터링 기능을 쿼리스트링 기반으로 리팩토링한 과정을 회고해보고자 합니다.

부트텐트의 캠프 리스트 페이지에는 다양한 필터링 및 정렬 기능이 존재합니다. 사용자들이 원하는 교육 프로그램을 빠르게 찾을 수 있도록 다음과 같은 네 가지 종류의 필터와 정렬 기능을 제공하고 있습니다.
- 카테고리 필터 - 웹개발, 모바일, 데이터, AI, 디자인, 마케팅, 기획 등
- 옵션 필터 - 비용, 참여 시간, 운영 기업, 선발 절차 등
- 토글 필터 - 내가 찜한 캠프, 추천 프로그램 보기
- 검색 필터 - 유저의 키워드 기반의 텍스트 검색
- 정렬
기존에는 이 모든 필터 상태를 Zustand라는 상태관리 라이브러리를 통해 관리하고 있었습니다. 컴포넌트 간에 상태를 쉽게 공유할 수 있고, 로직도 간결했기에 빠르게 구현할 수 있었죠. 다양한 상황이 생겨나고, 필터링 기능과 그 사용성에대해 더 많은 고민을 하게 되면서 기존 방식만으로는 한계가 있다는 점을 깨닫게 되었습니다.
인지한 문제들을 해결하기 위해, 최근 캠프 리스트 필터링 기능을 Zustand 기반 상태관리 → URL 쿼리스트링 기반 상태 동기화 구조로 리팩토링하게 되었습니다. 이번 글에서는 왜 쿼리스트링 기반 필터링 방식을 선택했는지, 어떤 구조로 개선했는지, 그리고 향후 어떤 기능(특히 프리셋 필터)에 이 구조가 어떻게 기여하게 될지를 하나씩 정리해보려 합니다.
2. 문제 배경
이번 필터 구조 리팩토링의 시작점은 바로 프리셋 필터 기능 도입에 대한 기획이었습니다.
저희가 추후 도입하려는 프리셋 필터는,
사용자 상황에 맞춰 자주 쓰이는 필터 조합을 미리 제안하는 큐레이션 기능입니다. 예를 들어 직장인에게는 ‘야간 / 온라인 / 무료’ 조건의 프로그램을, 비전공자에게는 ‘입문 / 포트폴리오 제공’ 조건의 프로그램을 자동 추천하는 식이죠.
아직 뚜렷하게 기획이 정해진 부분도, 기능에대한 계획이 정해진 부분도 없지만, 이 프리셋 기능을 생각하며 고민하며 중요한 사실을 깨달았습니다.
💡 “필터는 단순한 UI 상태가 아니다”
이전까지는 필터링을 단순히 목록을 좁혀주는 인터랙션 정도로만 인식했습니다. Zustand로 상태를 관리하고, 그에 따라 캠프 리스트를 갱신하면 기능적으로는 문제가 없어 보였죠.
하지만 프리셋 필터를 기획하며, 우리가 다루고 있는 ‘필터 조합’이 단지 UI가 아니라 해당 페이지를 구성하는 핵심 조건이어야 하지 않을까 생각하게 되었습니다.
즉, 페이지의 ‘상태’는 유저가 선택한 필터 조합 자체여야 하는 것이죠.
🚧 그런데도 URL에는 아무것도 없었다
기존 코드에서는, 이러한 중요한 상태들이 모두 클라이언트 내부 상태에만 존재하고, URL에는 아무런 정보도 담기지 않는 구조였습니다.
- 유저가 “웹개발 / 온라인 / 무료 / 선발절차 없음” 필터를 적용한 화면에서 URL을 복사해 친구에게 보내도, 친구는 아무런 필터가 적용되지 않은 화면을 보게 됩니다.
- 마케팅 페이지에서 특정 필터 조합으로 유입시키고 싶어도, URL로는 해당 조합을 표현할 수 없어 별도 처리 로직이 필요합니다.
이처럼 프리셋을 공유하고, 저장하고, 재방문하게 만드는 구조 자체가 처음부터 막혀 있었던 것이죠.
🔁 새로고침도, 뒤로가기 버튼도 모두 끊긴 UX
기존 구조의 또 다른 한계는 URL과 상태가 완전히 분리되어 있었다는 점입니다.
- 사용자가 필터를 설정한 뒤 새로고침하면 모든 필터가 초기화됨
- 브라우저 ‘뒤로가기’를 눌러도 필터 조작 이력이 남아 있지 않음
- URL이 바뀌지 않으니 히스토리 관리도 안 되고, 퍼널 분석도 불가능
이런 문제들은 ‘단순한 불편을 넘어서 예상과 다르게 작동하는 서비스로 느껴질 수 있었겠다’ 생각이 들었습니다. 결국 사용자 피로도를 높일 수 있는 것이죠.
🧪 분석도, 디버깅도 어려운 구조
운영, QA, 마케팅 측면에서도 URL 기반 상태 표현의 부재는 다음과 같은 문제가 있었습니다.
- 어떤 필터 조합이 자주 사용되는지 추적 불가능
- GA같은 로그 도구에서 조건별 전환률 분석이 어려움
- 특정 조건에서만 발생하는 이슈를 재현하려면 수동으로 모든 필터를 다시 조작해야 함
즉, 필터 상태가 내부에만 갇혀 있으니 외부 시스템과 연결될 수 없었던 구조였습니다.
🧩 그래서 결론은 명확했다
- 필터 기능은 제대로된 리팩토링이 필요하다.
- 사용자에게 공유 가능한 필터 경험을 제공할 수 있도록 해야한다.
- 프리셋 필터링 기획의 기반을 다져야 한다.
- 운영과 마케팅의 확장성까지 고려해야한다.
→ 결국, 필터 상태를 URL로 표현하는 구조로 가야한다는 결론을 내렸습니다.
즉, 단순한 상태 조작을 넘어서,
“페이지 상태 = 쿼리스트링”이라는 새로운 기준이 필요한 타이밍인 것입니다.
3. 쿼리스트링 기반 구조로의 리팩토링 결정
필터는 결국, 단순한 UI 조작 도구가 아니라 서비스의 진입 경로이자 상태 표현 수단이 되어야 했습니다. 따라서, 필터 상태를 URL에 반영하는 방식으로 전환하기로 결정했고, 이 구조는 자연스럽게 쿼리스트링 기반 필터링 방식으로 이어졌습니다.
왜 쿼리스트링이었을까?
필터 상태를 URL에 반영하는 방식은 여러 가지가 있지만, 가장 직관적이고 유연한 방식이 바로 쿼리스트링(Query String)입니다. 다음과 같은 이유로 이 방식이 가장 적합하다고 판단했습니다:
- ✅ 명확한 상태 표현: ?categories=web&onoff=online&tuition=free처럼 어떤 조건이 적용됐는지를 URL만 봐도 알 수 있습니다.
- 🔁 페이지 재방문에 강함: 새로고침, 공유, 북마크, 외부 링크 진입 등 모든 경우에 일관된 결과를 보장할 수 있습니다.
- 🔍 분석/추적/디버깅의 용이함: GA같은 로그툴에서 URL 기반의 분석이 가능해져, 어떤 필터 조합에서 유저가 이탈했는지 등의 퍼널 분석이 가능해집니다.
- 🧩 SSR/SEO 에도 유리: 서버사이드 렌더링, 검색 엔진에 노출에도 URL 기반의 상태 표현은 유리한 구조입니다.
- 🎯 프리셋 필터와의 찰떡궁합: 특정 필터 조합을 미리 정의하고, 해당 URL로 바로 이동시키면 UI 전체가 반응하는 구조. 프리셋 기능을 자연스럽게 얹을 수 있습니다.
실제 적용 예시
- 카테고리 필터: ?categories=web,mobile
- 옵션 필터: ?tuition=online,free&tag=fe,be
- 검색 필터: ?search=react
- 토글 필터: ?filterType=bookmark
- 정렬: ?sort=latest
이런 URL이라면 페이지의 상태를 재현할 수 있게됩니다. 링크를 그대로 복사해도 동일한 화면이 뜨고, 광고 캠페인에서도 정확한 조건으로 유입시킬 수 있게 될 것입니다.
4. 구현 방식: Zustand와 쿼리스트링의 동기화 구조
쿼리스트링 기반 필터링 구조로 전환한다고 해서 Zustand를 완전히 버린 것은 아닙니다.
오히려 Zustand의 상태 관리 기능은 그대로 유지하면서, 쿼리스트링과 양방향으로 동기화되도록 구조를 설계했습니다.
리팩토링 방향성 설정
✅ 진입 시: URL 쿼리스트링을 Zustand 상태로 초기화
우선 사용자가 필터 페이지에 진입했을 때, URL에 포함된 쿼리스트링 값을 읽어 상태를 세팅해야 합니다.
이를 위해 SelectorFilterProvider 컴포넌트를 만들어, 최상위에서 필터 상태를 초기화하도록 했습니다.
"use client";
import React, { PropsWithChildren, useEffect } from "react";
import useSelectorState from "@/zustand/selectorState";
import { useQueryParams } from "@/hooks";
import { useIsClient } from "usehooks-ts";
export function SelectorFilterProvider({ children }: PropsWithChildren) {
const [searchParams] = useQueryParams();
const isClient = useIsClient();
const initFromQuery = useSelectorState((s) => s.initFromQuery);
useEffect(() => {
if (!isClient) return;
initFromQuery(searchParams); // ✅ URL → Zustand
}, [isClient]);
return <>{children}</>;
}
이 컴포넌트를 페이지 루트에서 감싸주기만 하면, 진입 시 URL의 필터 조건들이 자동으로 상태에 반영됩니다.
🔁 사용자 조작 시: Zustand 상태를 쿼리스트링으로 반영
유저가 필터를 선택하거나 해제할 때는, 기존처럼 Zustand 상태를 업데이트하는 동시에 쿼리스트링을 함께 변경해야 합니다. 이 역할은 useSelectorState 훅 내부에서 처리합니다.
function syncQueryFromState(state: SelectorItemType[]) {
const url = new URL(window.location.href);
// 1. 기존 필터 키 제거
Object.entries(campFilters).forEach(([key, cfg]) => {
if (cfg.isFilter) {
url.searchParams.delete(key);
}
});
// 2. 현재 상태 기준으로 쿼리스트링 재설정
const grouped = state.reduce<Record<string, string[]>>((acc, { key, value }) => {
if (!acc[key]) acc[key] = [];
acc[key].push(value);
return acc;
}, {});
Object.entries(grouped).forEach(([key, values]) => {
if (campFilters[key]?.isFilter) {
url.searchParams.set(key, values.join(","));
}
});
window.history.replaceState({}, "", url.toString());
}
이 로직은 다음과 같은 구조로 연결되어 있습니다
- updateSelectorState: 특정 필터 값을 업데이트
- addKeyState: 특정 key에 대한 값을 모두 적용
- removeKeyState: 특정 key에 대한 값을 모두 삭제
- removeAllSelector: 필터 전체 제거
- 내부적으로 모두 syncQueryFromState 호출 → URL 갱신
🧼 초기화 로직도 깔끔하게
또한 외부에서 받은 URL을 파싱하여 상태를 초기화하는 initFromQuery 메서드도 함께 구현했습니다.
initFromQuery: (params) => {
const out: SelectorItemType[] = [];
for (const [key, val] of params.entries()) {
if (!campFilters[key]?.isFilter) continue;
val
.split(",")
.filter(Boolean)
.forEach((value) => out.push({ key, value }));
}
set({ selectorState: out });
}
이러한 구조로,
- 브라우저 새로고침
- 뒤로가기/앞으로가기
- 외부 URL 직접 진입
모두에서 일관된 필터 상태를 유지할 수 있게 되었습니다.
💡 동작 흐름 요약
5. 프리셋 필터 도입을 위한 기반 마련
이번 쿼리스트링 기반 구조 리팩토링은 단순히 UX를 개선하기 위한 작업만은 아니었습니다.
앞서 언급한 것처럼, 이번 리팩토링의 주요 목적 중 하나는, 바로 프리셋 필터(preset filter) 도입을 위한 기반을 다지는 것이었습니다.
💡 프리셋 필터란?
프리셋 필터는 사용자 유형이나 상황에 맞춰, 자주 쓰이는 필터 조합을 미리 제안해주는 기능입니다.
예를 들어 다음과 같은 방식으로 제공될 수 있습니다.
이 기능은 이렇게 유저가 무언가를 일일이 고르지 않아도, 맞춤형 큐레이션을 클릭 한 번으로 제공받을 수 있도록 하는 기능입니다. 이 쿼리스트링 기반 구조는 이 프리셋 기능과 매우 잘 어울립니다. 그 이유는 다음과 같습니다.
✅ 1. 프리셋 = URL 한 줄이면 끝
프리셋은 결국 특정 필터 조합의 모음입니다.
쿼리스트링 방식에서는 이 조합을 단순히 URL로 표현할 수 있기 때문에,
/camp?categories=web&tags=online,free&onoff=night
와 같은 링크만 있으면 프리셋이 곧바로 작동합니다. 이 링크는
- 필터링된 캠프리스트 링크 공유 가능
- 마케팅 링크로 활용 가능
- 내부 QA/운영에도 유용하게 활용 가능
✅ 2. 클릭 시 URL만 바꿔주면 끝
사용자가 프리셋 필터를 클릭하면, 별도의 복잡한 상태 조작 없이 URL만 바꿔주면 전체 UI가 자동으로 반응합니다. 이는 유지보수성과 확장성을 크게 높여줍니다.
✅ 3. 개인화된 프리셋 추천도 확장 가능
나중에는 사용자 데이터를 기반으로 자동으로 맞춤형 프리셋을 보여주거나, “내가 자주 고르는 조건”을 저장하는 기능도 쉽게 구현할 수 있습니다. 모든 것이 쿼리스트링으로 표현되기 때문입니다.
✅ 4. SSR, SEO, 외부 유입 최적화에도 유리
프리셋을 정적인 URL로 관리할 수 있기 때문에, SSR 페이지에서도 해당 쿼리를 기반으로 초기 필터 상태를 세팅할 수 있고, SEO나 마케팅 퍼포먼스 페이지로도 쉽게 전환할 수 있습니다.
🛠️ 프리셋 필터 설계 예시
현재 구상 중인 프리셋 필터는 다음과 같은 형태로 구성될 예정입니다.
const presetFilters = [
{
id: "office-worker",
label: "퇴근 후 가능한 프로그램",
query: "?studyTime=partTime&onoff=online&tuition=free",
},
{
id: "student",
label: "오프라인 중심 국비과정 추천",
query: "?onoff=offline&tuition=govFree®ion=seoul",
},
];
버튼을 클릭하면 해당 query 값을 기반으로 URL을 변경하고, 자동으로 UI가 상태를 초기화하여 랜더링되게 만들 수 있습니다.
🚀 앞으로의 계획
- 프리셋 필터 UI 및 UX 정식 도입
- 유저 세그먼트 기반 자동 추천 로직 개발
- “최근 사용한 필터”, “나만의 필터 저장” 기능까지 확장 고려
쿼리스트링 구조로 바꾸면서, 필터를 단순히 ‘고르는 기능’이 아닌 추천하고, 저장하고, 공유할 수 있는 기능으로 확장할 수 있는 기반을 마련하게 되었습니다.
6. 회고 및 정리
이번 캠프 리스트 필터링 구조 리팩토링은 단순히 URL에 상태를 담는것을 넘어, 필터라는 기능을 사용자 경험의 일부로 확장한 전환점이었던 것 같다는 생각이 듭니다.
🔍 작은 불편이 쌓이면, 결국 ‘이탈’로 이어진다
“기능이 작동하는 것”보다 “기대한 대로 동작하는 것”이 더 중요하다는 말을 들어본 적 있습니다.
Zustand만으로 필터를 관리하던 당시, 필터링 기능 자체는 큰 문제 없이 작동하고 있었습니다. 하지만 앞서 살펴본 것처럼, 실제 사용자 경험에서는 생각해볼 수 있는 여러 가지 불편이 있었죠.
이전엔 대수롭지 않게 넘겼던 것들이, 프리셋 필터를 기획하면서부터 여러 고민을 거치다보니 이제야 더 크게 다가오게된 것 같습니다.
작은 불편 하나하나가 쌓이면 결국 사용자의 피로도가 높아지고, 서비스에 대한 신뢰도가 떨어지며, 이탈로 이어질 수 있다는 점을 생각하게 되었습니다. 이번 리팩토링은 그런 불편을 미리 막고자 하는 과정이기도 했습니다.
🔁 URL은 단순한 주소가 아니라, ‘상태의 기록’이다
이번 리팩토링을 통해 가장 크게 느낀 것은 바로 이 부분입니다.
URL은 단순한 경로가 아니라, 페이지 상태의 일부이며 곧 사용자 경험의 일부라는 점입니다.
이제는 필터 조건이 바뀌면 URL이 즉시 반영되고, 그 URL만 있으면 언제든 동일한 조건으로 돌아갈 수 있게 되었습니다. 그게 내부 운영자든, 마케터든, 사용자든 누구에게나 명확한 기준점이 됩니다.
🌱 다음을 위한 기반
무엇보다 의미 있었던 건 이 구조가 단단한 기반이 되었다는 점입니다. 이제 이 구조 위에 다음과 같은 기능들을 얹을 수 있게 되었습니다.
- 프리셋 필터: 유저 상황에 맞춘 추천
- 마케팅 URL 활용: 원하는 조건으로 바로 유도
- SSR & SEO 최적화: 필터 조건도 페이지 컨텐츠로 처리 가능
- 로그/분석 툴 연계: 전환률이 높은 필터 조합 분석
기능이 아니라, 경험을 설계하는 개발자가 되고 싶다는 마음으로 이번 리팩토링을 마무리해봅니다.
다음 글에서는, 프리셋 필터를 실제 도입해보며 이 구조가 실제 사용자 경험에 어떤 영향을 미치는지에대해, 프론트엔드 개발자의 시각에서 조금 더 자세히 소개해보겠습니다.
긴 글 읽어주셔서 감사합니다 🙌🏻
고민이 있으시거나 대화하고싶으신 분께서는 아래 1:1 오픈채팅 링크로 놀러오세요!
편하게 오셔서 부트캠프고민, 취업고민, 인생고민 같이 나눠봐요.