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)를 고려하세요.
다음으로
- Table Setup — 테이블/정렬/필터/페이징 결합
- Data Pagination — 페이지네이션 전략과 UI
- Components: Pagination — 페이지 네비게이션 UI 상세
- CRUD 3종 — 리스트/상세/폼 통합 레시피
- Conditional Rendering — 필터 유무/권한별 UI 분기