Sorting & Filters

정렬과 필터는 URL 쿼리 파라미터(예: ?q=...&status=OPEN&sort=updatedAt,desc)로 상태를 표현하고, 서비스 계층에서 화이트리스트유효성 검사를 거쳐 안전하게 적용합니다. Kosmos DSL 컴포넌트는 테이블/페이지네이션과 매끄럽게 결합됩니다.

개념 개요

요소 설명 비고
SortSpec field,dir 문자열을 파싱하여 안전한 정렬 사양으로 변환 허용 필드 화이트리스트 필요
Filter Params q / 상태 / 날짜 범위 / 태그 등 GET 기반, 북마크/공유 가능
ItemsSearchBar 검색어/필터/사이즈 선택 UI Table Setup과 연동
ItemsTable 정렬 가능한 헤더 링크 현재 정렬 반영
CompPagination 페이지 이동 시 필터/정렬 보존 상태 유지

코드 심화: SortSpec 파서

public record SortSpec(String field, String dir) {
  public static SortSpec parse(String sort, String defaultSort) {
    String val = (sort == null || sort.isBlank()) ? defaultSort : sort;
    String[] parts = val.split(",", 2);
    String f = parts.length > 0 ? parts[0].trim() : "updatedAt";
    String d = parts.length > 1 ? parts[1].trim().toLowerCase(Locale.ROOT) : "desc";
    if (!List.of("asc","desc").contains(d)) d = "desc";
    // 화이트리스트
    if (!List.of("id","name","category","updatedAt","createdAt").contains(f)) {
      f = "updatedAt"; d = "desc";
    }
    return new SortSpec(f, d);
  }

  public String toSql() { // 예시: JPA/QueryDSL이면 별도 방식 사용
    return field + " " + ("asc".equals(dir) ? "ASC" : "DESC");
  }
}

코드 심화: 필터 UI 컴포넌트

public class ItemsSearchBar implements HtmlComponent {
  private final PageMeta meta;
  private final String basePath;
  private final String q;
  private final String status;  // OPEN/CLOSED/ALL
  private final String from;    // YYYY-MM-DD
  private final String to;

  public ItemsSearchBar(PageMeta meta, String basePath, String q, String status, String from, String to) {
    this.meta = meta; this.basePath = basePath; this.q = q; this.status = status; this.from = from; this.to = to;
  }

  @Override public String render(RenderContext ctx) {
    var form = El.form().attr("method","GET").attr("action", basePath).css("row g-2 align-items-end mb-3");

    form.child(El.div().css("col-12 col-md").children(
      El.label().css("form-label").attr("for","q").text("검색어"),
      El.input().css("form-control").attr("type","search").attr("id","q").attr("name","q")
        .attr("placeholder","이름/키워드").attr("value", q == null ? "" : q)
    ));

    form.child(El.div().css("col-6 col-md-2").children(
      El.label().css("form-label").attr("for","status").text("상태"),
      El.select().css("form-select").attr("id","status").attr("name","status").children(
        opt("ALL","전체"), opt("OPEN","열림"), opt("CLOSED","닫힘")
      )
    ));

    form.child(El.div().css("col-6 col-md-2").children(
      El.label().css("form-label").attr("for","from").text("시작일"),
      El.input().css("form-control").attr("type","date").attr("id","from").attr("name","from")
        .attr("value", from == null ? "" : from)
    ));
    form.child(El.div().css("col-6 col-md-2").children(
      El.label().css("form-label").attr("for","to").text("종료일"),
      El.input().css("form-control").attr("type","date").attr("id","to").attr("name","to")
        .attr("value", to == null ? "" : to)
    ));

    form.child(El.div().css("col-6 col-md-auto").child(
      El.button().css("btn btn-primary w-100").attr("type","submit").text("검색")
    ));

    // 페이지 초기화 / 기존 sort 유지 / size 유지
    form.child(El.input().attr("type","hidden").attr("name","page").attr("value","1"));
    if (meta.sort() != null) form.child(El.input().attr("type","hidden").attr("name","sort").attr("value", meta.sort()));
    if (meta.size() > 0)     form.child(El.input().attr("type","hidden").attr("name","size").attr("value", String.valueOf(meta.size())));

    return form.render(ctx);
  }

  private HtmlComponent opt(String value, String label) {
    var o = El.option().attr("value", value).text(label);
    boolean selected = Objects.equals(value, status) || (status == null && "ALL".equals(value));
    if (selected) o.attr("selected","selected");
    return o;
  }
}

테이블 헤더 정렬 링크

// ItemsTable의 정렬 헤더 (발췌)
// sortHref("name")가 "name,asc/desc" 토글을 생성
var trh = El.tr().children(
  El.th().child(El.a().href(sortHref("id")).text("ID")),
  El.th().child(El.a().href(sortHref("name")).text("이름")),
  El.th().child(El.a().href(sortHref("category")).text("분류")),
  El.th().child(El.a().href(sortHref("updatedAt")).text("수정일")),
  El.th().css("text-end").text("동작")
);

Controller: 파라미터 수집 & 서비스 호출

@GetMapping("/items")
public ResponseEntity<String> list(@RequestParam(defaultValue="1") int page,
                                    @RequestParam(defaultValue="20") int size,
                                    @RequestParam(required=false) String q,
                                    @RequestParam(required=false) String status,
                                    @RequestParam(required=false) String from,
                                    @RequestParam(required=false) String to,
                                    @RequestParam(required=false) String sort,
                                    RenderContext ctx) {

  ItemsResult result = service.searchItems(page, size, q, status, from, to, sort);

  Map<String,String> params = new LinkedHashMap<>();
  if (q != null) params.put("q", q);
  if (status != null) params.put("status", status);
  if (from != null) params.put("from", from);
  if (to != null) params.put("to", to);
  if (sort != null) params.put("sort", sort);

  HtmlComponent searchBar = new ItemsSearchBar(result.meta(), "/items", q, status, from, to);
  HtmlComponent table     = new ItemsTable(new ItemsTable.Model(result.rows(), result.meta()), "/items", params);
  HtmlComponent pager     = new CompPagination(result.meta(), "/items", params);

  var layout = El.div().css("container").children(
    searchBar,
    table,
    El.div().css("d-flex justify-content-between align-items-center mt-3").children(
      new CompPageSizer(result.meta(), "/items", params),
      pager
    )
  );
  return ResponseEntity.ok(layout.render(ctx));
}

Service: 안전한 필터/정렬/페이징

public ItemsResult searchItems(int page, int size, String q, String status, String from, String to, String sort) {
  int pageSafe = Math.max(page, 1);
  int sizeSafe = List.of(10,20,50,100).contains(size) ? size : 20;

  SortSpec spec = SortSpec.parse(sort, "updatedAt,desc");

  // 필터 sanitize
  String qSafe = (q == null || q.isBlank()) ? null : q.trim();
  String statusSafe = List.of("OPEN","CLOSED").contains(status) ? status : null;
  LocalDate fromD = parseDate(from);
  LocalDate toD   = parseDate(to);
  if (fromD != null && toD != null && toD.isBefore(fromD)) {
    // 범위 교차 방지: 필요한 경우 swap 하거나 무시
    LocalDate tmp = fromD; fromD = toD; toD = tmp;
  }

  // 저장소 조회
  List<Item> found = repo.find(qSafe, statusSafe, fromD, toD, spec, pageSafe, sizeSafe);
  long total = repo.count(qSafe, statusSafe, fromD, toD);

  List<ItemsTable.Row> rows = found.stream()
    .map(it -> new ItemsTable.Row(it.getId(), it.getName(), it.getCategory(), it.getUpdatedAt().toString()))
    .toList();

  int totalPages = (int) Math.ceil((double) total / sizeSafe);
  PageMeta meta = new PageMeta(pageSafe, sizeSafe, total, totalPages,
    pageSafe > 1, pageSafe < totalPages, spec.field() + "," + spec.dir(), qSafe);

  return new ItemsResult(rows, meta);
}

private LocalDate parseDate(String s) {
  try { return (s == null || s.isBlank()) ? null : LocalDate.parse(s); }
  catch (Exception e) { return null; }
}

UX 가이드: 필터 배치/라벨링

  • 상단 고정: 검색·필터 바는 목록 상단에 배치하고, 제출 시 page=1로 초기화합니다.
  • 레이블/placeholder: 레이블은 명시적으로 제공하고, placeholder는 보조 설명에만 사용합니다.
  • 선택값 유지: 사용자가 돌아왔을 때 기존 값이 유지되어야 합니다(쿼리 파라미터 반영).

접근성 & 보안

주제 권장 지양
폼 연결 label[for]input[id] 연결 placeholder만 제공
쿼리 검증 화이트리스트/범위 검사 미검증 파라미터를 그대로 쿼리에 주입
정렬 안전 SortSpec.parse로 필드 제한 임의 컬럼/표현식 허용
날짜 범위 역전 방지/자동 보정 from>to를 그대로 사용

성능 팁

  • 인덱스: 정렬/필터 컬럼에 적절한 인덱스를 둡니다(updatedAt, status 등).
  • 선택적 COUNT: 대용량에서는 총 건수 계산을 지연/캐시하거나 근사값을 사용할 수 있습니다.
  • 드릴다운: 복잡한 필터는 단계적으로(상위 검색 → 하위 정밀) 도입하세요.

주의사항

상태 누락: 페이지네이션/정렬 링크에 필터 파라미터를 포함하지 않으면 사용자가 맥락을 잃습니다.
SQL 인젝션 위험: 정렬 필드/방향을 반드시 화이트리스트로 제한하세요.
과도한 필터: 실사용 시나리오에 근거해 항목을 최소화하고, 고급 필터는 접어두기(accordion)를 고려하세요.

다음으로