이 글은 누구를 위한 것인가
- 데이터가 많아지면서 필터가 복잡해진 관리자 화면을 개선하려는 팀
- 필터 선택 상태를 URL에 반영해서 공유 가능하게 만들고 싶은 개발자
- 검색과 필터를 어떻게 조합해야 할지 모르는 UX 디자이너
들어가며
필터가 3개 이하면 인라인으로 다 보여줘도 된다. 5개 이상이면 사이드 패널이나 팝오버로 숨겨야 한다. 10개 이상이면 "활성화된 필터만 칩으로 표시" 패턴이 필요하다.
이 글은 bluefoxdev.kr의 대시보드 UX 설계 를 참고하여 작성했습니다.
1. 필터 패턴 선택 가이드
[필터 복잡도별 패턴]
단순 (1-3개 필터):
패턴: 인라인 세그먼트 컨트롤 or 드롭다운
예: 기간(오늘/주/월), 상태(전체/활성/비활성)
중간 (4-8개 필터):
패턴: 필터 바 + 팝오버
예: 주문 목록 (기간 + 상태 + 결제수단 + 채널)
복잡 (9개 이상 필터):
패턴: 사이드 필터 패널 (Faceted Search)
예: 상품 목록 (카테고리+브랜드+가격+사이즈+색상+...)
[필터 상태 표시]
활성 필터: 칩(Chip)으로 표시
"기간: 최근 7일 ×" "상태: 결제완료 ×"
전체 초기화: "필터 초기화" 링크
[URL 동기화 원칙]
필터 선택 → URL 파라미터 업데이트
URL 공유 → 동일한 필터 상태 복원
뒤로가기 → 이전 필터 상태 복원
2. 필터 상태 관리
import { useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
interface FilterState {
dateRange: "today" | "7d" | "30d" | "custom";
status: string[];
channel: string[];
minAmount?: number;
maxAmount?: number;
search: string;
}
function useFilters() {
const searchParams = useSearchParams();
const router = useRouter();
const filters: FilterState = {
dateRange: (searchParams.get("dateRange") as FilterState["dateRange"]) || "30d",
status: searchParams.getAll("status"),
channel: searchParams.getAll("channel"),
minAmount: searchParams.get("minAmount") ? Number(searchParams.get("minAmount")) : undefined,
maxAmount: searchParams.get("maxAmount") ? Number(searchParams.get("maxAmount")) : undefined,
search: searchParams.get("q") || "",
};
const setFilter = (key: keyof FilterState, value: unknown) => {
const params = new URLSearchParams(searchParams.toString());
if (Array.isArray(value)) {
params.delete(key);
value.forEach((v) => params.append(key, v));
} else if (value === undefined || value === "" || value === null) {
params.delete(key);
} else {
params.set(key, String(value));
}
// 필터 변경 시 페이지 리셋
params.delete("page");
router.push(`?${params.toString()}`, { scroll: false });
};
const clearAllFilters = () => {
router.push("", { scroll: false });
};
const activeFilterCount = [
filters.dateRange !== "30d" ? 1 : 0,
filters.status.length,
filters.channel.length,
filters.minAmount || filters.maxAmount ? 1 : 0,
].reduce((a, b) => a + b, 0);
return { filters, setFilter, clearAllFilters, activeFilterCount };
}
// 활성 필터 칩 컴포넌트
function ActiveFilterChips({ filters, onRemove }: FilterChipsProps) {
const chips = [];
if (filters.dateRange !== "30d") {
chips.push({ key: "dateRange", label: `기간: ${getDateRangeLabel(filters.dateRange)}` });
}
filters.status.forEach((s) => {
chips.push({ key: `status:${s}`, label: `상태: ${getStatusLabel(s)}` });
});
if (!chips.length) return null;
return (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-gray-500">적용된 필터:</span>
{chips.map((chip) => (
<span
key={chip.key}
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-50 text-blue-700 rounded-full text-sm"
>
{chip.label}
<button onClick={() => onRemove(chip.key)} className="ml-1 hover:text-blue-900">
×
</button>
</span>
))}
<button onClick={() => onRemove("all")} className="text-sm text-gray-500 underline">
전체 초기화
</button>
</div>
);
}
마무리
필터 UX에서 가장 중요한 것은 "적용된 필터를 항상 보여주는 것"이다. 사용자가 필터를 적용하고 다른 탭을 봤다가 돌아왔을 때 어떤 필터가 적용됐는지 파악할 수 있어야 한다. 칩 패턴과 URL 동기화를 조합하면 필터 상태가 항상 투명하게 보이고, 링크로 공유도 가능해진다.