검색 조건
목록 화면의 검색은 조건 트리(condition tree) 로 표현합니다. 프론트가 트리 JSON 을 만들어 q 파라미터로 보내면, 서버가 스키마로 검증한 뒤 WHERE 절로 조립합니다.
구버전(v1) 문서를 보고 계신다면
_d 네임스페이스, {"필드": {"op": ..., "val": ...}} 평면 맵, JSP buildQ(), NvKeyword / NvPeriod / NvCond / {X}SearchForm 은 전부 삭제되었습니다. 현재 계약은 이 문서의 조건 트리입니다.
왜 트리인가
구버전은 검색 조건을 평면 맵(필드 → 값)으로 다뤘습니다. 그런데 조건식은 본래 트리입니다.
(나이 > 50 OR 나이 < 10 OR 나이 IN (15,20,25))이런 식은 평면 맵으로 표현할 수 없습니다. 같은 컬럼을 두 번 이상 쓸 수 없고, 괄호도 만들 수 없기 때문입니다. 그래서 구버전은 표현이 안 되는 경우마다 특수 타입(NvKeyword, NvPeriod…)을 하나씩 추가해왔고, 결국 프론트 컴포넌트 구조가 서버 코드에 박히는 결합이 생겼습니다.
현재 구조는 방향을 뒤집습니다 — 서버가 조건 문법을 정의하고, 프론트가 거기에 맞춰 JSON 을 만듭니다.
빠른 예제
① 프론트가 보내는 것 (q 파라미터, URL 인코딩됨)
{
"ao": "and",
"conds": [
{ "field": "b.name", "op": "lf", "val": "공지" },
{ "field": "b.enabled", "op": "eq", "val": true }
]
}② 서버가 받는 것
@GetMapping("/board")
public ResponseEntity<List<BoardDto>> listBoard(
@RequestParam(value = "q", required = false) NvQuery q) {
if (q == null) {
q = NvQuery.empty();
}
return ResponseEntity.ok(boardService.pageBoardDto(q));
}③ 실행되는 SQL
WHERE board_name LIKE '%공지%' AND board_enabled = true문자열을 직접 조립하는 곳은 어디에도 없습니다. 값은 전부 바인딩 파라미터로 나갑니다.
와이어 문법
q 는 루트 그룹 객체입니다. 루트도 자식 노드와 같은 모양이라 타입이 하나로 통일됩니다.
{
"ao": "and",
"conds": [
{ "field": "acnt.name", "op": "eq", "val": "guava" },
{ "type": "group", "ao": "and", "conds": [
{ "field": "acnt.age", "op": "gt", "val": 50 },
{ "field": "acnt.age", "ao": "or", "op": "lt", "val": 10 },
{ "field": "acnt.age", "ao": "or", "op": "in", "val": [15, 20, 25] }
]}
]
}→ acnt_name = 'guava' AND (acnt_age > 50 OR acnt_age < 10 OR acnt_age IN (15,20,25))
노드의 종류
노드는 조건(cond) 아니면 그룹(group) 입니다.
| 키 | 조건 | 그룹 | 설명 |
|---|---|---|---|
field | 필수 | — | 대상 경로 "별칭.Dsl프로퍼티" (예: "acnt.age") |
op | 선택 (기본 eq) | — | 연산자 코드 → 연산자 |
val | 선택 | — | 값. 스칼라 또는 배열(in/ni) |
ao | 선택 (기본 and) | 선택 (기본 and) | 앞 조건과의 결합자 and / or |
conds | — | 필수 | 자식 노드 배열 |
type | — | 선택 | "group" — 가독성용. 실제 판별은 conds 유무로 합니다 |
val 과 conds 는 같이 쓰지 않습니다
conds 가 있으면 그룹, 없으면 조건입니다. op:"in" 의 값도 배열이라 겸용하면 의미가 충돌합니다.
결합 규칙 — 왼쪽부터 순차 결합
연산자 우선순위가 없습니다. 배열 순서대로 왼쪽부터 묶습니다.
[a, b(ao=or), c(ao=and)] → ((a OR b) AND c)SQL 의 우선순위와 다릅니다
SQL 텍스트에서는 AND 가 OR 보다 강하게 묶입니다(a OR (b AND c)). 조건 트리는 그렇지 않습니다. 서버 바인더가 이 순서대로 명시적으로 괄호를 씌워 렌더링하므로 DB 의 우선순위 규칙에 의존하지 않습니다.
우선순위가 필요한 식은 프론트가 그룹으로 명시해 보내야 합니다.
(A AND B) OR (C AND D) 를 표현하려면:
{
"ao": "and",
"conds": [
{ "conds": [ {"field":"a", "val":1}, {"field":"b", "ao":"and", "val":2} ] },
{ "ao": "or",
"conds": [ {"field":"c", "val":3}, {"field":"d", "ao":"and", "val":4} ] }
]
}조건을 안 쓰면 노드를 안 보냅니다
체크박스 on/off 같은 UI 상태는 와이어에 넣지 않습니다. 조건을 적용하지 않을 거면 그냥 그 노드를 빼면 됩니다.
연산자
| 코드 | 의미 | 값 |
|---|---|---|
eq | = (값이 배열이면 IN) | 스칼라 / 배열 |
not | <> | 스칼라 |
gt lt gte lte | > < >= <= | 스칼라 |
in ni | IN / NOT IN | 배열 |
is isnot | IS NULL / IS NOT NULL | 값 무시 |
lk | LIKE <val> — 와일드카드를 직접 넣습니다 | 문자열 |
lf | LIKE %v% (like full) | 문자열 |
la | LIKE v% (like after) | 문자열 |
lb | LIKE %v (like before) | 문자열 |
d_gt d_lt | 날짜 >= / <= | 날짜 문자열 |
날짜 검색 (d_gt / d_lt)
날짜는 전용 연산자를 씁니다. 값 문자열의 길이로 포맷을 판별합니다.
d_lt 는 그날 끝까지 포함합니다
사용자가 "~2026.08.20" 을 고르면 8월 20일 23:59:59 까지 포함되어야 합니다. 그래서 d_lt 에 날짜만 오면 서버가 익일 0시 미만으로 해석합니다.
d_lt: "2026-08-20" → createdDt < 2026-08-21T00:00:00시각까지 지정하면 그 시각 기준으로 비교합니다.
LIKE 의 % _ 는 이스케이프되지 않습니다
사용자가 % 를 입력하면 와일드카드로 동작합니다. 값 자체는 바인딩 파라미터로 나가므로 SQL 인젝션 위험은 없지만, 검색 결과가 의도와 다를 수 있습니다.
검색 스키마 선언
여기가 백엔드의 핵심입니다. 엔터티마다 검색 가능한 경로 + 값 타입 을 enum 으로 선언합니다. 이 선언이 화이트리스트입니다.
package com.unvus.iflex.core.modules.board.dto;
import com.unvus.iflex.core.platform.query.frag.Oper;
import com.unvus.iflex.core.platform.query.tree.NvSearchSchema;
import java.time.Instant;
import java.util.Set;
public enum BoardSearchSchema implements NvSearchSchema {
// 경로 값 타입
ID ("b.id" , Long.class),
NAME ("b.name" , String.class),
ENABLED ("b.enabled" , Boolean.class),
CREATED_DT("b.createdDt" , Instant.class),
;
// b.deleted 는 선언하지 않는다 → 서버 강제조건. 클라이언트가 건드릴 수 없다.
private final String path;
private final Class<?> valueType;
private final Set<Oper> ops;
BoardSearchSchema(String path, Class<?> valueType, Oper... ops) {
this.path = path;
this.valueType = valueType;
this.ops = NvSearchSchema.opsOrDefault(valueType, ops); // 연산자는 타입에서 유도
}
@Override public String path() { return path; }
@Override public Class<?> valueType() { return valueType; }
@Override public boolean allows(Oper op) { return ops.contains(op); }
}한 줄에 경로와 타입만 적으면 됩니다. 허용 연산자는 타입에서 자동으로 결정됩니다.
허용 연산자는 값 타입이 결정합니다
문자열에 LIKE, 날짜에 기간 검색, 불리언에 대소 비교 — 이런 조합은 필드마다 고민할 일이 아니라 타입만 보면 정해집니다. 그래서 기본값을 두고, 예외인 필드만 명시합니다.
| 값 타입 | 기본 허용 연산자 |
|---|---|
String | eq not in ni is isnot lk lf la lb |
Instant / LocalDate | d_gt d_lt eq gt lt gte lte |
Long / Integer / 그 밖의 Number | eq not in ni is isnot gt lt gte lte |
Boolean | eq not is isnot |
왜 기본값을 두었나
실제 프로젝트의 229개 선언 중 227개(99%)가 타입만으로 결정되고 있었습니다. 타입에서 유도 가능한 정보를 229번 손으로 적는 셈이었고, 전부 길다 보니 정작 진짜 예외가 묻혔습니다.
지금은 연산자가 적혀 있으면 그것 자체가 "이 필드는 특별하다" 는 신호입니다.
기본값과 달라야 할 때
뒤에 연산자를 나열하면 그것만 허용됩니다(기본값을 덮어씁니다).
public enum BatchHistorySearchSchema implements NvSearchSchema {
ID ("bh.id" , Long.class), // 기본값
TASK_NAME ("bh.taskName" , String.class), // 기본값
// 측정값이라 범위 비교만 의미가 있다 — IN/IS NULL 은 허용하지 않는다
DURATION ("bh.duration" , Double.class , EQ, NOT, GT, LT, GTE, LTE),
RESULT_CNT("bh.resultCnt", Integer.class, EQ, NOT, GT, LT, GTE, LTE),
;좁히는 방향으로만 쓰세요
연산자를 명시하면 화이트리스트가 그 목록으로 고정됩니다. 기본값에 있던 연산자를 빠뜨리면 그 검색은 조용히 동작하지 않습니다(드롭).
반대로 민감한 필드를 EQ 하나로 좁히는 것은 좋은 사용법입니다.
SECRET_CODE ("b.secretCode", String.class, EQ), // LIKE 프로빙 차단기본값이 없는 타입은 기동 시 실패합니다
String / Boolean / 날짜 / Number 외의 타입(예: enum, 커스텀 클래스)은 기본값이 없어 클래스 로딩 시점에 예외가 납니다.
기본 허용 연산자가 정의되지 않은 값 타입입니다: com.example.Foo
— 스키마 선언에서 연산자를 직접 명시하세요의도적인 fail-fast 입니다 — 조용히 "아무 연산자도 허용 안 함" 이 되면 검색이 통째로 안 먹는데 원인을 찾기 어렵기 때문입니다.
선언하지 않으면 검색할 수 없습니다
이것이 이 구조의 요점입니다.
| 선언 안 한 것 | 결과 |
|---|---|
비밀번호 컬럼 (acnt.loginPwd) | 클라이언트가 어떤 JSON 을 보내도 WHERE 에 들어가지 않음 |
서버 강제조건 (b.deleted) | 클라이언트가 뒤집을 수 없음 |
| 오타 난 경로 | 조용히 드롭 (warn 로그) |
민감 컬럼을 선언하지 마세요
비밀번호 해시 컬럼을 LIKE 로 열어두면 한 글자씩 프로빙해 값을 알아낼 수 있습니다. "어차피 해시니까" 가 아니라, 애초에 조건에 넣을 수 없어야 합니다.
fail-closed 원칙
잘못된 입력은 조건을 넓히는 방향으로 실패하지 않습니다.
| 상황 | 처리 |
|---|---|
| 선언 안 된 경로 | 드롭 (warn 로그) |
| 허용 안 된 연산자 | 드롭 |
값 타입 불일치 (Boolean 컬럼에 문자열 등) | 드롭 |
ao 에 정의 안 된 코드 | 예외(400) |
ao 만 예외인 이유는, 조용히 OR 이 되면 필터가 넓어져 보이면 안 되는 데이터가 노출될 수 있기 때문입니다.
안전장치 — 조건 개수 한도
OR LIKE 를 수천 개 보내 DB 를 마비시키는 것을 막습니다.
| 항목 | 기본값 |
|---|---|
| 최대 중첩 깊이 | 5 |
| 그룹당 최대 조건 수 | 50 |
| 전체 최대 노드 수 | 200 |
서버에서 조건 다루기
조건이 데이터(트리) 이므로 서비스가 읽고·지우고·바꿀 수 있습니다.
public class NvQuery {
public static NvQuery empty();
public boolean isEmpty();
public Optional<NvNode> find(String field); // 첫 조건 찾기
public List<NvNode> findAll(String field); // 전부 찾기
public NvQuery removeIf(Predicate<NvNode> p); // 조건 제거 (빈 그룹 정리까지)
public NvQuery replace(String field, UnaryOperator<NvNode> fn); // 치환
public NvQuery and(String field, String op, Object val); // 최상위 AND 추가
public NvQuery and(NvNode node);
}실제 사용 예:
// 조건 유무로 분기
if (q.find("b.enabled").isEmpty()) {
q.and("b.enabled", "eq", true); // 기본값 주입
}
// 로그인 사용자 소속으로 강제 치환
q.replace("acnt.deptId", n -> n.withVal(SecurityUtils.getCurrentDeptId()));
// 특정 조건 제거
q.removeIf(n -> "acnt.name".equals(n.getField()) && "guava".equals(n.getVal()));⚠️ 두 종류의 "서버 조건" 을 구분하세요
이걸 헷갈리면 보안 구멍이 생깁니다.
| 종류 | 어디에 두나 | 예 |
|---|---|---|
| 불변식 — 클라이언트가 절대 뒤집으면 안 됨 | Repository 의 private 헬퍼 | type='a', deleted=false, 테넌시 |
| 업무 로직 조건 조작 | Service — NvQuery 조작 | 기본값 주입, 역할별 범위 제한 |
불변식을 서비스에서 q.and(...) 로 넣으면 안 됩니다
q.and("b.deleted", "eq", false) 를 쓰려면 deleted 를 스키마에 선언해야 하는데, 선언하는 순간 클라이언트도 그 경로를 쓸 수 있게 됩니다 — deleted=true 로 뒤집을 수 있습니다.
불변식은 스키마에 없는 채로 repository 가 붙입니다.
// AdminRepository — 강제조건이 여기 한 곳에만 존재. private 이라 서비스가 우회할 수 없다
private QueryBuilder adminQuery(NvQuery q, AccountDsl u) {
return searchQuery(q, dsl -> dsl.and(u.type, isEqualTo(AccountType.ADMIN)), u);
}판별 기준: 안전하려면 모든 호출부에서 조건을 넣어줘야 한다면, 그건 검색 조건이 아니라 불변식입니다.
실행 경로
q=<JSON> ──► Converter ──► NvQuery (트리)
│
Resource: @RequestParam("q") NvQuery
│
Service: 업무 로직에 따라 트리 조작 (선택)
│
Repository: list{X}Provider(q, sortModel)
├─ searchQuery(q, dsl) ← @TableInfo 의 스키마로 검증 + WHERE 조립
└─ private {x}Query 의 서버 강제조건 AND 결합 ← 불변식
│
SelectStatementProvider ──► InPagination.withRequestDsl정렬은 트리에 섞지 않습니다. SortModel 로 별도 전달합니다 → 목록과 페이징.
프론트엔드
프론트는 평면 모델로 입력을 받고, 전송 직전에 트리로 변환합니다. 검색 컴포넌트를 쓰면 이 변환은 자동입니다.
검색 컴포넌트
<template>
<nv-search-form :nv-filter="query" @search="load">
<nv-search-text :nv-filter="query" :nv-field="{label:'이름', key:'b.name'}" nv-oper="lf" />
<nv-search-select :nv-filter="query" :nv-field="{label:'사용', key:'b.enabled'}" :options="enabledOptions" />
<nv-search-period :nv-filter="query" :nv-field="{label:'등록일', key:'b.createdDt'}" />
</nv-search-form>
</template>
<script setup lang="ts">
import useQuery from 'src/components/composables/useQuery';
const query = useQuery(); // { _usePaging: true, q: {}, sortBy: null }
</script>| 컴포넌트 | 용도 | 생성되는 조건 |
|---|---|---|
nv-search-text | 텍스트 입력 | nv-oper 로 지정 (기본 eq) |
nv-search-select | 단일 선택 | eq |
nv-search-checkbox | 다중 선택 | in |
nv-search-period | 기간 | d_gt + d_lt 두 조건 |
nv-search-dynamic | 여러 필드 동시 검색 | 선택 필드들의 OR 그룹 |
nv-search-toggle | on/off | eq |
nv-search-code | 코드 선택 | eq |
nv-field.key 는 스키마의 path() 와 같아야 합니다
{label:'이름', key:'b.name'} 의 key 가 백엔드 BoardSearchSchema.NAME 의 경로 "b.name" 과 일치해야 조건이 적용됩니다. 다르면 조용히 드롭되므로, 검색이 안 먹으면 여기부터 확인하세요.
변환은 언제 일어나나
reduceQ() 가 전송 직전에 평면 모델 → 트리 변환을 수행합니다. nv-pagination 이나 엑셀 서비스가 내부에서 호출하므로 보통은 신경 쓰지 않아도 됩니다.
query.q = { "b.name": "공지" } ← 컴포넌트가 쓰는 평면 모델
│ reduceQ()
▼
{ "conds": [ {"field":"b.name", "op":"eq", "val":"공지"} ] } ← 실제 전송되는 트리기간·다중필드 키워드처럼 한 입력이 여러 조건이 되는 경우도 reduceQ 가 처리합니다.
{ "b.createdDt": { from: {enabled:true, value:"2026-01-01"},
to: {enabled:true, value:"2026-12-31"} } }
│
▼
[ {"field":"b.createdDt","op":"d_gt","val":"2026-01-01"},
{"field":"b.createdDt","op":"d_lt","val":"2026-12-31"} ]조건을 직접 만들기
컴포넌트를 쓰지 않고 값을 직접 세팅할 수도 있습니다.
const query = useQuery();
query.q['b.enabled'] = true; // → {field:'b.enabled', op:'eq', val:true}
query.q['b.id'] = [1, 2, 3]; // → {field:'b.id', op:'eq', val:[1,2,3]} → INeq 이외의 연산자가 필요하면 트리 노드를 직접 넣습니다.
자주 겪는 문제
검색을 해도 조건이 적용되지 않습니다
서버가 조건을 조용히 드롭했을 가능성이 큽니다. 순서대로 확인하세요.
- 서버 로그에
미선언 검색 경로 드롭: xxx또는허용되지 않은 연산자 드롭이 있는지 {X}SearchSchema에 해당 경로가 선언돼 있는지- 프론트의
nv-field.key와 스키마의path()문자열이 정확히 같은지 (별칭 포함) - 쓰려는 연산자가 그 필드의 허용 목록에 있는지
(A OR B) AND C 로 만들고 싶은데 A OR (B AND C) 가 됩니다
조건 트리는 왼쪽부터 순차 결합합니다. 배열 순서를 바꾸거나 그룹으로 명시하세요.
{"conds": [
{"conds": [ {"field":"a", …}, {"field":"b", "ao":"or", …} ]},
{"field":"c", "ao":"and", …}
]}날짜 검색에서 마지막 날이 빠집니다
d_lt 대신 lt 를 쓰면 그날 0시 기준으로 비교되어 하루가 통째로 빠집니다. 날짜 조건에는 반드시 d_gt / d_lt 를 쓰고, 스키마에도 DATE_FROM / DATE_TO 를 허용해 두세요.
관련 문서
- 목록과 페이징 — 조건을 실제 SQL 로 실행하는 부분
- MyBatis Dynamic SQL — DSL 로 쿼리 작성하기