대시보드 필터·검색 UX: 복잡한 데이터를 탐색하는 인터페이스 설계

UX 디자인

대시보드 UX필터 UI검색 인터페이스데이터 탐색관리자 도구

이 글은 누구를 위한 것인가

  • 데이터가 많아지면서 필터가 복잡해진 관리자 화면을 개선하려는 팀
  • 필터 선택 상태를 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 동기화를 조합하면 필터 상태가 항상 투명하게 보이고, 링크로 공유도 가능해진다.