DSL 기반으로 확장성 있는 조회 API 설계하기

이재상·2026년 1월 15일

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

서론

회사에서 내부 운영툴을 개발하면서 많이 받는 요구중 하나는 보고 싶은 데이터를 사용자가 자유롭게 조회할 수 있는 기능이다.
그래서 원래 조회 API에서 컬럼별로 파라미터를 받아서 필터링을 하거나 정렬을 하는 방식을 구현했는데 데이터의 종류가 많아지고 유지보수를 하면서 불편하다고 생각했다.

이러한 고민을 하다가 Opensearch에서 DSL을 사용하면서 이걸 활용해야겠다는 생각이 들었다.

DSL 이란?

DSL은 Domain Specific Language의 약자로 말 그대로 특정 도메인(문제 영역)을 해결하기 위해 설계된 전용 언어를 의미한다.

일반적인 프로그래밍 언어(Java, JavaScript, Python 등)가 다양한 문제를 범용적으로 해결하기 위한 언어라면, DSL은 한정된 목적을 더 명확하고 간결하게 표현하는 데 초점을 둔다.

예시로는 SQL, Regex, GraphQL이 존재한다.

GraphQL을 도입하지 않고 DSL을 사용한 이유

분명하게 현재 겪는 문제에서 GraphQL 도입도 선택지에 있었지만 선택하지 않았다.
선택하지 않은 이유는 아래와 같다.

  1. 팀원들중 GraphQL에 대한 경험이 없어서 학습이 필요했다.
  2. Data Source가 다양하기 때문에 Resolver를 하나씩 다 구현을 해야하는데, 그러면 DSL이 더 좋다고 생각을 했다.
  3. GraphQL에서 Filter/Sort 구현에 있어서 다시 정의를 해야하는데 그럴거면 처음부터 공통된 DSL을 정의해서 사용하는게 더 편리하다고 생각을 했다.
  4. Apollo Client / Server를 도입하는 것이 무겁다고 생각을 했다.

위와 같은 이유로 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;
}
  • And/Or 조건을 지원하기 위해 DslGroup을 정의했다.
  • 다양한 기능을 제공하기 위해서 DslOperator를 정의하고 조건에 필요한 데이터들을 넣었다.
  • Offset Pagination이나 추후 Cursor Pagination을 지원하기 위해서 pagelimit을 정의했다.

예시 데이터는 아래와 같다.

{
    "query": [
        {
            "key": "id",
            "op": "gt",
            "value": 3435
        }
    ],
    "page": 1,
    "limit": 10
}

Backend 구현

  • [POST] ${Domain}/search API를 생성해 DSL을 Body로 받았다.
  • 위에서 언급한대로 DSL interface를 정의한걸 기반으로 각 데이터 소스에 Builder를 만들고 내부에서 실제로 필요로 하는 쿼리를 생성했다.
  // 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,
      },
    };
  }
  • Service에서는 Repository에 있는 dsl에 접근해서 간단하게 Query만 전달한다.
  • Repository 내부에서 DSL builder를 통해 Query를 변환하고 실행한다.

구현하면서 신경써야 하는 부분

결국에는 Client에서 원하는대로 조작을 할 수 있다보니 다양한 취약점이 있어서 신경을 써야 한다.

OR를 통한 비정상 데이터 접근 시도

  • Backend에서 권한 관리를 위해 비지니스 로직에서 Client에서 온 DSL에 단순히 .push를 통해 조건을 넣는다면 문제가 될 수 있다.
  • 따라서 권한이나 특정 조건을 넣어야 한다면, OR절이 들어오는걸 감안해서 내 조건 And (Client DSL Filter)를 넣어서 해결해야한다.

Regex를 활용한 Dos 공격 위험

  • Contain과 같은 포함 여부를 판별할 때 regex를 많이 사용하게 되는데, 자칫 잘못하면 Dos 공격이 돼서 서버나 데이터베이스에 무리가 갈 수 있다.
  • 따라서 길이 제한을 하거나, 일부 패턴만 허용할 수 있도록 조절 할 수 있어야 한다.

Index가 없는 Property에 대한 제약

  • 아무래도 모든 Property에 Filter/Sort를 주기는 어렵다. (데이터가 적으면 상관없다.)
  • 데이터가 100만, 1,000만건 이상 넘어가면 Index를 걸면서 해당하는 컬럼들에 대해서만 Filter/Sort가 가능하도록 해야한다.

Frontend 구현

  • Backend API가 일관된 형태로 표준화됐기 때문에 Table Component를 따로 만들었다.
    • FilterComponent와 SortComponent도 내부적으로 만들었다.
    • Query 자체가 동일하기 때문에 Filter/Sort에 대한 비지니스 로직과 API 호출 자체를 Table Component 내부에서 수행하게 하고, Column의 대한 정의만 외부에서 주입받도록 구성했다.
  • 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에 적용할 경우에는 제약과 검증을 더 강화하는 방향의 추가 설계가 필요할 것이다.

profile
문제를 코드로만 보지 않고 구조와 흐름으로 해결하는 백엔드 개발자

0개의 댓글