요약
1. DSL(Domain Specific Language)를 사용해 필터/정렬에 있어서 클라이언트에서 쿼리를 구성할 수 있도록 함.
2. Data의 Source가 다양하더라도 중간 매개체인 DSL을 통해 다양하게 활용할 수 있음.
3. 다만 Client에서 쿼리를 구성하다보니 백엔드에서 놓칠 수 있는 제약이 존재함.

회사에서 내부 운영툴을 개발하면서 많이 받는 요구중 하나는 보고 싶은 데이터를 사용자가 자유롭게 조회할 수 있는 기능이다.
그래서 원래 조회 API에서 컬럼별로 파라미터를 받아서 필터링을 하거나 정렬을 하는 방식을 구현했는데 데이터의 종류가 많아지고 유지보수를 하면서 불편하다고 생각했다.
이러한 고민을 하다가 Opensearch에서 DSL을 사용하면서 이걸 활용해야겠다는 생각이 들었다.
DSL은 Domain Specific Language의 약자로 말 그대로 특정 도메인(문제 영역)을 해결하기 위해 설계된 전용 언어를 의미한다.
일반적인 프로그래밍 언어(Java, JavaScript, Python 등)가 다양한 문제를 범용적으로 해결하기 위한 언어라면, DSL은 한정된 목적을 더 명확하고 간결하게 표현하는 데 초점을 둔다.
예시로는 SQL, Regex, GraphQL이 존재한다.
분명하게 현재 겪는 문제에서 GraphQL 도입도 선택지에 있었지만 선택하지 않았다.
선택하지 않은 이유는 아래와 같다.
위와 같은 이유로 GraphQL 대신 DSL 도입을 선택했다.
내부에서 사용하는 데이터베이스의 종류가 크게 3가지가 있었다. PostgreSQL, MongoDB, Opensearch였다.
이에 DSL의 interface를 정의하고 각각 DslBuilder를 구현해서 중간 매개체로 사용하도록 했다.
DSL Interface는 아래와 같이 정의했다.
export interface Dsl {
query?: DslFilter[];
sort?: DslSort[];
limit?: number;
page?: number;
}
export type DslFilter = DslGroup | DslCondition;
export interface DslGroup {
op: DslGroupOperator;
filters: DslFilter[];
}
export type DslCondition =
| {
key: string;
op:
| typeof DslOperator.equal
| typeof DslOperator.notEqual
| typeof DslOperator.greaterThan
| typeof DslOperator.greaterThanOrEqual
| typeof DslOperator.lessThan
| typeof DslOperator.lessThanOrEqual
| typeof DslOperator.exists;
value: unknown;
}
| {
key: string;
op: typeof DslOperator.between;
from: unknown;
to: unknown;
}
| {
key: string;
op: typeof DslOperator.in | typeof DslOperator.notIn;
values: unknown[];
}
| {
key: string;
op:
| typeof DslOperator.contains
| typeof DslOperator.notContains
| typeof DslOperator.startsWith
| typeof DslOperator.endsWith
| typeof DslOperator.regex
| typeof DslOperator.match;
value: string;
};
export interface DslSort {
key: string;
order?: DslSortOrder;
}
DslGroup을 정의했다.DslOperator를 정의하고 조건에 필요한 데이터들을 넣었다.page와 limit을 정의했다.예시 데이터는 아래와 같다.
{
"query": [
{
"key": "id",
"op": "gt",
"value": 3435
}
],
"page": 1,
"limit": 10
}

[POST] ${Domain}/search API를 생성해 DSL을 Body로 받았다. // Service 코드
public async search(dto: UserSearchDto): ServiceReturnType<UserSearchResponse> {
const result = await this.userRepo.dsl.listWithOffset(dto.query);
return {
itemList: result.itemList,
meta: result.meta,
};
}
// BaseRepository
public async run(dsl: Dsl) {
const pipeline = this.dslBuilder.build(dsl);
return await this.model.aggregate(pipeline);
}
public async listWithOffset(dsl: Dsl): Promise<PaginationResult<Schema>> {
const { limit = 10, page = 1 } = dsl;
const itemList = await this.run(dsl);
const count = await this.model.countDocuments(dsl.query);
return {
itemList,
meta: {
totalPage: Math.ceil(count / limit),
currentPage: page,
totalCount: count,
currentCount: itemList.length,
},
};
}
결국에는 Client에서 원하는대로 조작을 할 수 있다보니 다양한 취약점이 있어서 신경을 써야 한다.
DslApiTable이라는 컴포넌트를 생성하고, Props로 해당 API ReactQuery와 Column 정의를 넣으면 자동으로 생성되도록 구성했다.export function UserPage() {
const query = useUserSearchQuery();
return (
<DslApiTable<User>
query={query}
columns={[
{
key: "id",
label: "ID",
sortable: true,
},
{
key: "email",
label: "Email",
filter: {
operators: [
DslOperator.equal,
DslOperator.contains,
],
},
},
{
key: "createdAt",
label: "Created At",
filter: {
operators: [
DslOperator.between,
],
},
sortable: true,
},
]}
/>
);
}
이 구조의 핵심은 Table의 상태를 DSL 하나로 표현하는 것이다. Filter / Sort / Pagination이 모두 DSL로 귀결되기 때문에, 페이지마다 다른 조회 로직을 만들 필요가 없어졌다.
DSL 기반 조회 API를 도입한 이후, Backend와 Frontend 모두에서 개발 속도가 눈에 띄게 빨라졌다. Backend에서는 조회 로직이 표준화되면서 추가 요구 사항이나 데이터 소스 확장에 대한 부담이 줄었고, Frontend에서는 Column 정의만으로 Filter / Sort을 구성할 수 있게 되었다.
다만 조회 조건을 Client에서 구성하는 구조인 만큼 권한 조건, 성능, 악의적인 Query에 대한 방어는 반드시 Backend에서 책임져야 한다. 이번 구조는 내부 운영툴이라는 맥락에서는 매우 효과적이었지만, Public API에 적용할 경우에는 제약과 검증을 더 강화하는 방향의 추가 설계가 필요할 것이다.