NestJS에서 MongoDB의 Pipeline Builder를 만들어보자!

윤학·2023년 9월 4일
0

Nestjs

목록 보기
9/12
post-thumbnail

현재 Nest를 사용하면서 데이터베이스로 MongoDB를 사용하여 처리하는 모듈들이 있다.

기존에 MySQL의 쿼리문들을 ORM의 메서드로 편하게 사용할 수 있게 해주어서 이번엔 Node진영에서 유명한 ODM인 Mongoose를 사용할까 생각했는데
SQL 모르고 ORM 사용하면 어렵듯이 일단 Mongo Query문에 익숙해지자 했다.

하지만 몇가지 불편한 점이 있어 간단한 연산에만 사용할 수 있는 Pipeline BuilderQuery Factory를 만들어봤다.

들어가기 전

그럼 먼저 Aggregation Pipeline은 뭘까?

MongoDB에서 Aggregation Pipeline은 여러 Stage로 구성되는데 각각의 Stage에서 데이터를 가공하여 다음 Stage로 넘기며 결과를 추출해내는 일종의 도구라고 생각하면 될 것 같다.

각 Stage에는 문서를 필터링하고 변형할 수 있는 $match, $sort등이 올 수 있으며, 예시로 파이프라인 내에 $match, $sort Stage가 있고 Document들이 파이프라인을 거친다고 생각해보자.

각 Stage의 결과는 다음 Stage에 넘겨진다고 했으니 처음으로 $match Stage를 거치며 조건과 일치하는 Document들만 다음 Stage인 $sort로 넘어가 정렬이 될 것이다.

위와 같은 상황에서 알 수 있듯 $sort Stage가 $match Stage 뒤에 온다면 $match Stage에서 이미 걸러지기 때문에 정렬해야 할 Document수가 줄어들어 성능에 이점을 가져갈 수 있어 Stage의 배치 순서는 MongoDB에서 중요하다고 말한다.

불편했던 점

그럼 어떤점이 불편해서 더 복잡하게 이런 클래스들을 만들었을까

가독성 및 동적 쿼리

커서 기반 페이징을 사용하여 무한 스크롤로 구현된 프로필 리스트를 조회하는 Api에 필터들이 어느정도 있었다.

정렬 조건, 일당, 나이, 성별, 지역등등...

항상 같은 요청이 들어오면 문제가 없지만 매번 조건들이 달라지기 때문에 동적 쿼리가 필수였다.

    /* 프로필 리스트 조회하는 Stage */
    private profileListPipelineStage = (lastProfileId: string) => [
        { $match: 
            {
            ...this.ltProfileId(lastProfileId),
            isPrivate: false
            }
        },
        { $sort: { _id: -1 }},
        { $project: {
            userId: 1,
            career: 1,
            pay: 1,
            notice: 1,
            possibleDate: 1,
            possibleAreaList: 1,
            tagList: 1
        }}
    ]

    /* 마지막 프로필 아이디보다 오래된 프로필들 받아오기 위해 */
    private ltProfileId(profileId: string): null | { _id: { $lt: ObjectId }} {
        if( !profileId ) return null;

        return { _id: { $lt: new ObjectId(profileId) } };
    }

위의 코드는 가장 이전 요청의 마지막 프로필 이후의 프로필들을 찾는 코드다.

처음 리스트 페이지에 들어가 로딩이 될 때는 다음에 조회해야 할 프로필 아이디의 정보가 없는 상태로 넘어오기에 조건에서 제외해야 해서 ltProfileId()를 만들었다.

하지만 동시에 모든 조건에 대해서 null 조건을 제외시키는 메서드를 만들어야 된다는 생각을 했다.

그럼 필터링 조건이 추가된다면...? 다른 클래스에서도 해당 메서드를 사용해야 된다면...?

가독성도 문제지만 모든 필드에 대해 조건들이 똑같을 수도 없고 계속해서 메서드를 추가 할수도 없었다.

일관성

MongoDB에서 제공하는 연산자들도 많기에 같은 조건을 다르게 표현할 수도 있어 통일되게 하고 싶었다.(빈 배열을 찾을 때도 $size를 이용하는 방법도 있으니)

강제로 하나의 메서드를 제공한다면 팀끼리 같이 개발해도 통일된 개발을 할 수 있을 것 같았다.

시작

먼저 조건이 null이면 null을 반환하고 아니면 쿼리를 생성해주는 Query Factory 클래스를 만들기로 했다.

mongo-query.factory.ts

export abstract class MongoQueryFactory {
  
  /* 다른 연산자들 생략 */
  
    /**
     * 파라미터로 넘긴 값보다 작은 값을 찾는 Query 생성
     * 없으면 null 해당 조건 무시
     * @param field 찾고자 하는 필드
     * @param value 기준 값
     */
    ltThan(field: string, value: number | string | undefined): 
        MongoQuery<number | string | ObjectId> | null 
    {
        if ( !value ) return null;

        return {
            [field]: {
                $lt: field === '_id' ? new ObjectId(value) : value
            }   
        }
    };
	
  	/**
     * 저장 값 중 빈 배열이 
     * 아닌 Document만 찾아주는 Query 생성
     * @param field 찾고자하는 필드
     */
    notEmptyArray (field: string): MongoQuery<[]> {
        return {
            [field] : { $ne: [] }
        }
    }
}

----> 결과 { _id: { $lt:} }

생략된 다른 메서드들도 마찬가지지만 MongoDB에서 문서가 저장이 될 때는 _id필드가 ObjectId타입으로 저장이 되는데 애플리케이션 레벨에서는 String으로 변환된 값을 사용하고 있어 _id필드에 대해 들어오면 내부적으로 ObjectId타입으로 변경해주었다.

또한, 추상클래스로 선언한 이유는 MongoDB를 사용하는 Repository들이 각자가 원하는 파이프라인을 조합하여 관리할 수 있도록 하기 위해서이다.

아래는 Factory를 이용한 코드이다.

    /* 프로필 리스트 조회하는 Stage */
    private profileListPipelineStage = (lastProfileId: string) => [
        { $match: 
            {
            ...this.ltThan('_id', lastProfileId),
            isPrivate: false
            }
        },
        { $sort: { _id: -1 }},
        { $project: {
            userId: 1,
            career: 1,
            pay: 1,
            notice: 1,
            possibleDate: 1,
            possibleAreaList: 1,
            tagList: 1
        }}
    ]

그럼 이제 각 Stage들을 추가해줄 Pipeline Builder를 만들어보자.

aggregrate-pipeline.builder.ts

import { MongoQuery } from "./mongo-query.factory";

export class AggregratePipelineBuilder {
    private pipeline: any[];

    constructor() { this.pipeline = []; };

    static initialize() {
        return new AggregratePipelineBuilder();
    };

    /**
     * 연산 쿼리들을 합쳐 Match 스테이지를 생성
     * @param operationQueries 연산 쿼리들
     */
    match(...operationQueries: any[]): this {
        const matchQuery = this.toCombinedQuery(
            operationQueries.filter( query => query != null )
        )
        this.addQueryToStage('match', matchQuery);         
        return this;
    };

    /**
     * 정렬 쿼리들을 합쳐 Sort 스테이지를 생성
     * @param sortQueries 정렬 쿼리들
     */
    sort(...sortQueries: any[]): this {
        const sortQuery = this.toCombinedQuery(sortQueries);
        this.addQueryToStage('sort', sortQuery);
        return this;
    };

    /**
     * 선택 쿼리들을 합쳐 Project 스테이지를 생성
     * @param selectQueries select 쿼리들
     */
    project(...selectQueries: any[]): this {
        const selectQuery = this.toCombinedQuery(selectQueries);
        this.addQueryToStage('project', selectQuery)
        return this;
    };

    build(): any[] { return this.pipeline; };

    /* 하나의 조건으로 만들어주는 메서드 */
    private toCombinedQuery<T>(queryArr: any []): MongoQuery<T>[] {
        return queryArr.reduce((combinedQuery, query) => ({
            ...combinedQuery,
            ...query
        }), {});
    };

    private addQueryToStage<T>(stage: string, query: MongoQuery<T>[]) {
        const eachStage = { [`\$${stage}`]: { ...query } };
        this.pipeline.push(eachStage);
    }
}

MongoDB 드라이버에서 이용할 수 있는 find()와 같은 메서드에서는 조건이 위의 Factory에서 null로 반환되면 제외되겠지만,
Pipeline을 조합할 때는 인자로 각 Stage에 추가할 쿼리들을 받기 때문에 null은 제거해줘야 한다.

이러면 Pipeline을 구성하기 위해서는 Builder를 이용해 Stage를 채워주면 되고, 각각의 Stage에 연산 조건들을 구성하기 위해서는 MongoFactory를 이용하면 된다.

물론 기본으로 제공하는 find*같은 메서드들의 조건에 Factory의 메서드들을 사용할 수 있다.

다음 글에서는 실제로 위의 클래스들을 활용하여 필터 조건들을 받아 아래와 같은 Pipeline을 생성해보자.

const expectedPipeline = [
                {
                    $match: {
                        isPrivate: false,
                        $or: [
                            { pay: { $gt: nextPay } },
                            { pay: nextPay, _id: { $lt: new ObjectId(nextProfileId) }}
                        ]
                    },
                },
                {
                    $sort: { pay: 1, _id: -1 }
                },
                {
                    $project: {
                        _id: 0,
                        age: 1,
                        career: 1,
                        id: { $toString: "$_id" },
                        name: 1,
                        notice: 1,
                        pay: 1,
                        possibleAreaList: 1,
                        possibleDate: 1,
                        sex: 1,
                        tagList: 1,
                        userId: 1,
                    },
                }
            ];

마치면서

Aggregation Pipeline은 복잡한 연산들을 수행할 때 사용한다고 알고 있어 위의 클래스들은 간단하게 사용해보려고 만들어서 적합하지는 않은 것 같다.

Mongoose의 쿼리빌더가 훨씬 직관적이긴하지만 Pipeline은 내부 연산자들은 직접 작성해야 하는것으로 알고 있어 만드는 것도 괜찮아 보인다.

참고

MongoDB Aggregation Pipeline

profile
해결한 문제는 그때 기록하자

0개의 댓글