Skip to content

검색 조건

목록 화면의 검색은 조건 트리(condition tree) 로 표현합니다. 프론트가 트리 JSON 을 만들어 q 파라미터로 보내면, 서버가 스키마로 검증한 뒤 WHERE 절로 조립합니다.

이 문서를 읽는 순서

  1. 빠른 예제 로 전체 흐름을 봅니다
  2. 백엔드 담당이면 검색 스키마 선언 이 핵심입니다
  3. 프론트 담당이면 프론트엔드 절만 봐도 됩니다

구버전(v1) 문서를 보고 계신다면

_d 네임스페이스, {"필드": {"op": ..., "val": ...}} 평면 맵, JSP buildQ(), NvKeyword / NvPeriod / NvCond / {X}SearchForm전부 삭제되었습니다. 현재 계약은 이 문서의 조건 트리입니다.

왜 트리인가

구버전은 검색 조건을 평면 맵(필드 → 값)으로 다뤘습니다. 그런데 조건식은 본래 트리입니다.

(나이 > 50 OR 나이 < 10 OR 나이 IN (15,20,25))

이런 식은 평면 맵으로 표현할 수 없습니다. 같은 컬럼을 두 번 이상 쓸 수 없고, 괄호도 만들 수 없기 때문입니다. 그래서 구버전은 표현이 안 되는 경우마다 특수 타입(NvKeyword, NvPeriod…)을 하나씩 추가해왔고, 결국 프론트 컴포넌트 구조가 서버 코드에 박히는 결합이 생겼습니다.

현재 구조는 방향을 뒤집습니다 — 서버가 조건 문법을 정의하고, 프론트가 거기에 맞춰 JSON 을 만듭니다.

빠른 예제

① 프론트가 보내는 것 (q 파라미터, URL 인코딩됨)

json
{
  "ao": "and",
  "conds": [
    { "field": "b.name", "op": "lf", "val": "공지" },
    { "field": "b.enabled", "op": "eq", "val": true }
  ]
}

② 서버가 받는 것

java
@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

sql
WHERE board_name LIKE '%공지%' AND board_enabled = true

문자열을 직접 조립하는 곳은 어디에도 없습니다. 값은 전부 바인딩 파라미터로 나갑니다.

와이어 문법

q루트 그룹 객체입니다. 루트도 자식 노드와 같은 모양이라 타입이 하나로 통일됩니다.

json
{
  "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 유무로 합니다

valconds 는 같이 쓰지 않습니다

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) 를 표현하려면:

json
{
  "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 niIN / NOT IN배열
is isnotIS NULL / IS NOT NULL값 무시
lkLIKE <val> — 와일드카드를 직접 넣습니다문자열
lfLIKE %v% (like full)문자열
laLIKE v% (like after)문자열
lbLIKE %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 으로 선언합니다. 이 선언이 화이트리스트입니다.

java
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, 날짜에 기간 검색, 불리언에 대소 비교 — 이런 조합은 필드마다 고민할 일이 아니라 타입만 보면 정해집니다. 그래서 기본값을 두고, 예외인 필드만 명시합니다.

값 타입기본 허용 연산자
Stringeq not in ni is isnot lk lf la lb
Instant / LocalDated_gt d_lt eq gt lt gte lte
Long / Integer / 그 밖의 Numbereq not in ni is isnot gt lt gte lte
Booleaneq not is isnot

왜 기본값을 두었나

실제 프로젝트의 229개 선언 중 227개(99%)가 타입만으로 결정되고 있었습니다. 타입에서 유도 가능한 정보를 229번 손으로 적는 셈이었고, 전부 길다 보니 정작 진짜 예외가 묻혔습니다.

지금은 연산자가 적혀 있으면 그것 자체가 "이 필드는 특별하다" 는 신호입니다.

기본값과 달라야 할 때

뒤에 연산자를 나열하면 그것만 허용됩니다(기본값을 덮어씁니다).

java
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 하나로 좁히는 것은 좋은 사용법입니다.

java
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

서버에서 조건 다루기

조건이 데이터(트리) 이므로 서비스가 읽고·지우고·바꿀 수 있습니다.

java
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);
}

실제 사용 예:

java
// 조건 유무로 분기
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()));

⚠️ 두 종류의 "서버 조건" 을 구분하세요

이걸 헷갈리면 보안 구멍이 생깁니다.

종류어디에 두나
불변식 — 클라이언트가 절대 뒤집으면 안 됨Repositoryprivate 헬퍼type='a', deleted=false, 테넌시
업무 로직 조건 조작ServiceNvQuery 조작기본값 주입, 역할별 범위 제한

불변식을 서비스에서 q.and(...) 로 넣으면 안 됩니다

q.and("b.deleted", "eq", false) 를 쓰려면 deleted스키마에 선언해야 하는데, 선언하는 순간 클라이언트도 그 경로를 쓸 수 있게 됩니다deleted=true 로 뒤집을 수 있습니다.

불변식은 스키마에 없는 채로 repository 가 붙입니다.

java
// 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 로 별도 전달합니다 → 목록과 페이징.

프론트엔드

프론트는 평면 모델로 입력을 받고, 전송 직전에 트리로 변환합니다. 검색 컴포넌트를 쓰면 이 변환은 자동입니다.

검색 컴포넌트

vue
<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-toggleon/offeq
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"} ]

조건을 직접 만들기

컴포넌트를 쓰지 않고 값을 직접 세팅할 수도 있습니다.

ts
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]} → IN

eq 이외의 연산자가 필요하면 트리 노드를 직접 넣습니다.

자주 겪는 문제

검색을 해도 조건이 적용되지 않습니다

서버가 조건을 조용히 드롭했을 가능성이 큽니다. 순서대로 확인하세요.

  1. 서버 로그에 미선언 검색 경로 드롭: xxx 또는 허용되지 않은 연산자 드롭 이 있는지
  2. {X}SearchSchema 에 해당 경로가 선언돼 있는지
  3. 프론트의 nv-field.key 와 스키마의 path() 문자열이 정확히 같은지 (별칭 포함)
  4. 쓰려는 연산자가 그 필드의 허용 목록에 있는지
(A OR B) AND C 로 만들고 싶은데 A OR (B AND C) 가 됩니다

조건 트리는 왼쪽부터 순차 결합합니다. 배열 순서를 바꾸거나 그룹으로 명시하세요.

json
{"conds": [
  {"conds": [ {"field":"a", }, {"field":"b", "ao":"or", } ]},
  {"field":"c", "ao":"and", }
]}
날짜 검색에서 마지막 날이 빠집니다

d_lt 대신 lt 를 쓰면 그날 0시 기준으로 비교되어 하루가 통째로 빠집니다. 날짜 조건에는 반드시 d_gt / d_lt 를 쓰고, 스키마에도 DATE_FROM / DATE_TO 를 허용해 두세요.

관련 문서