모르지 않다는 것은 아는것과 다르다.

MongoDB

MongoDB Aggregation를 활용해서 QueryDSL-JPA 대체하기 #2

채마스 2023. 2. 10. 23:50

개요

  • 1,2 편에서는 MongoDB Aggregation에 대해서 알아보고, Spring Data MongoDB가 제공해 주는 기능도 알아보았다.
  • 나는 실무에서 실제로 QueryDsl-JPA로 구현된 로직을 Spring Data MongoDB로 변환하는 표준을 만들었다.
  • 나의 목표는 MongoDB Aggregation 쿼리 문법를 알지 못하는 개발자도 QueryDsl로 짜인 모든 로직을 손쉽게 MongoDB Aggregation으로 바꿀 수 있는 방법을 제시하는 것이었다.
  • 하지만, 2편에서 소개했듯이 MongoDB Aggregation으로 코드를 짜면, 코드가 너무 길어지고, 복잡했다.
  • 나는 위의 문제를 해결하기 위해서 AggregationBuiler 클래스를 구현했고, 모든 QueryDsl 로직을 MongoDB Aggregation으로 변환할 수 있었다.
  • 그 덕에 이제는 MongoDB만을 사용하는 솔루션에서도 우리의 프레임워크를 사용할 수 있게 되었다.
  • 물론 실무에서 사용된 코드는 아래의 내용보다 고려할 점이 많지만, 구현 원리를 간단하게나마 정리해 보려고 한다.

 

Spring Data MongoDB Aggregation의 문제점

  • 만약 순수하게 Spring Data MongoDB에서 제공하는 Aggregation 메서드만으로 구현하면 아래와 같다.

  • 2편에서 언급한 대로 MongoDB Aggregation을 Java 코드로 구현하면 위와 같이 복잡하고, 실수할 여지가 많다.
  • 그리고 쿼리를 읽는 것이 직관적이지 않다.
  • 또한, MongoDB aggregation에 대한 지식이 없으면, 코드를 짜기 어려울 것이다.
  • 나의 목표는 MongoDB Aggregation에 대해서 몰라도 다른 개발자 분들이 JPA QueryDsl 코드를 MongoDB Aggregation 코드로 전환하는 것이었다.
  • 이제부터, 위의 코드를 AggregationBuiler를 통해서 좀 더 쉽게 전환해 보자.

 

AggregationBuilder

  • 먼저, 두 가지 빌더 클래스를 만들었다. 클래스 구조는 아래와 같다.

  • AggregationBuilder는 operationList를 가지고 있다.
  • 아래 보이는 leftJoin(), innerJoin(), project(), match(), sort()가 실행되면서, operationList에 각각의 AggregationOperation을 담는다.
  • 그리고 최종적으로 aggregate() 메서드를 통해서 쿼리가 실행된다.
  • 이제부터 각각의 메소드를 좀 더 자세히 살펴보자

 

leftJoin(String from, DocumentBuiler leftBuilder, DocumentBuiler onBuilder)

public AggregationBuilder leftJoin(String from, DocumentBuilder letBuilder, DocumentBuilder onBuilder) {
    Document let = letBuilder.getDocument();
    Document letByOn = onBuilder.getDocument();
    letByOn.forEach(let::append);
    List<Document> match = onBuilder.getDocumentList();

    lookup(from, let, match);
    unwind(from, true);
    return this;
}

public void lookup(String from, Document let, List<Document> match) {
    AggregationOperation lookupOperation = makeLookupOperation(from, let, match);
    this.operationList.add(lookupOperation);
}

public AggregationOperation makeLookupOperation(String from, Document let, List<Document> match) {
    return context -> new Document(
            "$lookup",
            new Document("from", from)
                    .append("let", let)
                    .append("pipeline", Arrays.<Object> asList(
                            new Document("$match",
                                    new Document("$expr",
                                            new Document("$and", match)
                                    )
                            )
                    ))
                    .append("as", from)
    );
}

public void unwind(String field, boolean preserveNullAndEmptyArrays) {
    UnwindOperation unwindOperation = Aggregation.unwind(field, preserveNullAndEmptyArrays);
    this.operationList.add(unwindOperation);
}
  • leftJoin() 메소드를 보면 내부적으로 lookup(), unwind() 메소드를 가지고 있다.
  • 2편에서 언급했듯이 RDB의 left join은 MongoDB Aggregation의 $lookup + $unwind을 한 것과 유사하다.
  • 그렇기 때문에 leftjoin() 메서드 안에 lookup(), unwind()를 넣었고, 각각의 메서드에서 lookupOperation, unwindOperation를 만들어서 operationList에 추가하는 것을 확인할 수 있다.
  • 그리고, letBuilder와 onBuilder가 있는데, 이건 후반부에 정리하도록 하자.

 

innerJoin

public AggregationBuilder innerJoin(String from, DocumentBuilder letBuilder, DocumentBuilder onBuilder) {
    Document let = letBuilder.getDocument();
    Document letByOn = onBuilder.getDocument();
    letByOn.forEach(let::append);
    List<Document> match = onBuilder.getDocumentList();

    lookup(from, let, match);
    unwind(from, false);
    return this;
}
  • innerJoin은 leftJoin과 다른 부분은 동일하고, unwind 메서드의 preserveNullAndEmptyArrays만 false로 설정하면 된다.

preserveNullAndEmptyArrays는 단어 그래도 null인 데이터를 표시할지 말지를 선택하는 옵션이다.

 

project(ProjectionOperation projectionOperatioin)

public AggregationBuilder project(ProjectionOperation projectionOperation) {
    this.operationList.add(projectionOperation);
    return this;
}
  • project() 메서드는 projectionOperation을 파라미터로 받아서 operationList에 추가한다.
  • projectionOperation는 뒤에서 최종 결과 코드를 보면 알 수 있다.

 

match(List creteriaList)

public AggregationBuilder match(List<Criteria> criteriaList) {
    MatchOperation matchOperation = Aggregation.match(new Criteria()
            .andOperator(
                    criteriaList.toArray(new Criteria[0])
            )
    );
    operationList.add(matchOperation);
    return this;
}
  • match() 메소드는 criteriaList를 받아서 Aggregation.match() 메소의 파라미터로 넘겨준다.
  • 그 결과물로 matchOperation를 받아서 operationList에 추가한다.

 

sort(SortOperation sortOperation)

public AggregationBuilder sort(SortOperation sortOperation) {
    operationList.add(sortOperation);
    return this;
}
  • sort() 메소드는 sortOperation을 파라미터로 받아서 operationList에 추가한다.
  • sortOperation는 뒤에서 최종 결과 코드를 보면 알 수 있다.

 

aggregate(String collectionName, Clazz entityClazz)

public <T> List<T> aggregate(String collectionName, Class<T> entityClass) {

    List<AggregationOperation> operations = new ArrayList<>();
    ProjectionOperation projectionOperation = null;

    // ProjectionOperation 맨 뒤로 이동
    for (AggregationOperation operation : operationList) {
        if (operation instanceof ProjectionOperation) projectionOperation = (ProjectionOperation)operation;
        else operations.add(operation);
    }
    operations.add(projectionOperation);

    Aggregation aggregation = Aggregation.newAggregation(operations.toArray(new AggregationOperation[0]));

    log.debug("{}", aggregation);
    return mongoTemplate.aggregate(aggregation, collectionName, entityClass)
            .getMappedResults();
}
  • ProjectionOperation 은 가장 마지막에 실행되어야 하기 때문에 맨 뒤로 보내준다.
  • 그다음, operationList에 담긴 여러 Operation을 Aggregation.newAggregation() 메서드를 통해서 실행시킨다.

 

aggregate(Pageable pageable, String collectionName, Clazz entityClazz)

public <T> PageImpl<T> aggregate(Pageable pageable, String collectionName, Class<T> entityClass) {

    List<T> result = aggregate(collectionName, entityClass);
    final int start = (int) pageable.getOffset();
    final int end = Math.min((start + pageable.getPageSize()), result.size());
    return new PageImpl<>(result.subList(start, end), pageable, result.size());
}
  • Paging 처리가 하고 싶다면, 이전에 aggregate() 메소드를 실행시킨 뒤, PageImpl <>로 감싸주면 된다.

 

변환 결과

  • 위의 과정대로 AggregationBuilder 클래스를 이용하면, 아래와 같이 변환할 수 있다.

 

DocumentBuilder

  • 위에서 lookupOperation을 설명할 때, DocumentBuilder를 설명한다고 언급했다.
  • 나는 아래와 같은 lookupOperation을 구성하기 위해서 DocumentBuilder를 구현했다.

  • DocumentBuilder를 사용하면 아래와 같이 구현이 가능하다.

  • DocumentBuilder를 사용하면 RDB에서 여러 개의 중첩된 join 도 간단하게 구현이 가능하다.

 

결론

  • JPA QueryDsl와 Spring Data MongoDB Aggregation를 비교해 보면 아래와 같다.

  • 실무에서는 이 구조보다 더 복잡하게 구현했지만, 기본 동작과정은 위와 같다.
  • 위의 방식을 정리해서 가이드를 만들어서 배포했고, 팀원분들은 MongoDB Aggregation에 대한 개념이 없이도, QueryDsl 로직을 MongoDB Aggregation으로 변환할 수 있었다.
  • 하지만, MongoDB의 Aggregation만 공부했지, MongoDB에 대해서는 많이 공부하지 못했다.
  • Spring Data MongoDB를 사용해 보면서 MongoDB도 깊게 공부해 보고 싶다는 생각이 들었다. ㅎㅎ

'MongoDB' 카테고리의 다른 글

MongoDB Aggregation를 활용해서 QueryDSL-JPA 대체하기 #1  (0) 2023.02.10