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

MongoDB

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

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

개요

  • 사내에 우리가 만든 프레임워크에 대한 관심이 높아지면서, 몇몇 팀으로부터 프레임워크 적용 요청이 증가하고 있다.
  • 소규모 솔루션의 경우에 RDBMS를 사용하지 않고 MongoDB만을 사용하는 팀들에서도 적용 가능한지에 대한 기술검토를 요구해 왔다. (작은 규모의 솔루션이라 RDB까지 구축하기 힘든 경우가 있다고 한다.)
  • 그래서 기존 RDB로 짜인 로직을 어떻게 하면 MongoDB로 전환할 수 있을지를 고민하던 중 MongoDB Aggregation이라는 것을 알게 되었고, 이를 Spring Data MongoDB를 사용해서 구현할 수 있다는 사실도 알게 되었다.
  • 결론적으로는 성공적으로 전환할 수 있었고, 그 과정을 정리해 보려고 한다.

 

MongoDB는 도큐먼트 기반의 NoSQL인데 RDBMS의 쿼리를 대체할 수 있을까? MongoDB에서 제공하는 Aggregation을 활용하면 RDBMS의 쿼리를 대부분 대체할 수 있다. (성능적으로는 비효율적이다.)

 

MongoDB Aggregation이란?

  • MongoDB의 Aggregation이란 Sharding 기반으로 데이터를 효율적으로 집계하는 프레임워크다.
  • MongoDB의 db.collection.find(query) 메서드만으로 검색하기 어려운 경우에 주로 사용된다. (RDB로 생각하면 join, group by.. 등이 필요한 경우)
  • 파이프라인을 통해서 데이터를 원하는 형태로 검색할 수 있다.
  • 파이프라인에는 여러 개의 Aggregation Operation들이 포함된다.
  • 여러 개의 Aggregation Operation을 적절한 순으로 조합하면, MongoDB에서도 원하는 형태로 데이터를 조회할 수 있다.(마치 RDBMS처럼)
    • 물론, MongoDB를 RDB처럼 설계하는 것이 좋지 않지만 어쩔 수 없이 복잡한 구조로 조회해야 할 때 사용하면 좋을 것 같다.

 

Aggregation Operation

  • MongoDB 에서는 여러 가지 종류의 Aggregation Operation을 제공해 준다.
  • Aggregation Operation을 Aggregation 파이프라인 스테이지라고도 한다.
  • db.collection.aggregate(pipeline, options)
    • pipeline에는 여러 개의 스테이지(Aggregation Operation)들이 포함된다.
  • 아래와 같은 Aggregation 파이프라인 스테이지가 존재한다.

 

스테이지(Operation)기능 설명

$lookup 입력 도큐먼트와 동일 데이터베이스 내의 다른 컬렉션과 Join을 실행하고, 그 결과를 다음 스테이지에서 사용한다. (RDB의 join과 유사하다.)
$unwind 입력 도큐먼트가 배열로 구성된 필드를 가지고 있으면 이를 여러 도큐먼트로 풀어서 다음 스테이지로 전달한다. (풀어내야 다음 스테이지에서 참조하기 편하다.)
$project 입력 도큐먼트에서 필요한 필드만 선별하거나 이름을 변경해서 다음 스테이지로 넘겨주는 작업을 처리한다.(RDB의 Projection과 유사하다.)
$match 직전 스테이지에서 넘어온 도큐먼트에서 조건에 일치하는 도큐먼트만 다음 스테이지로 넘겨주는 작업을 처리한다. (RDB의 where 절과 유사하다.)
$sort 입력 도큐먼트를 정렬해서 다음 스테이지로 전달하는 작업을 처리한다. (RDB의 order by와 유사하다.)
$group 입력으로 주어진 도큐먼트를 지정된 조건에 맞게 그룹핑해서 카운트나 합계 또는 평균 등의 계산하는 작업을 처리한다. (RDB의 group by와 유사하다.)
$limit 입력 도큐먼트에서 앞에서 부터 주어진 만큼만 다음 스테이지로 전달한다. (RDB의 limit 과 유사하다.)
$skip 입력 도큐먼트에서 앞에서 부터 주어진 만큼만 버리고 나머지 도큐먼트만 다음 스테이지로 전달한다. (limit의 반대 개념인것 같다.)
$count 입력 도큐먼트의 개수를 세어서 다음 스테이지로 전달하는 작업을 처리한다.
$addField 입력 도큐먼트에 새로운 필드를 추가하는 작업을 처리한다.
$replaceRoot 입력 도큐먼트에서 특정 필드를 최상위 도큐먼트로 만든 다음 나머지는 버린다.
$redact 도큐먼트의 각 필드 또는 서브 도큐먼트의 포맷이 다양한 경우에 지정된 형태의 포맷과 일치하는 서브 도큐먼트 또는 필드만으로 도큐먼트를 재구성할 때 사용된다.
$out 처리의 결과를 컬렉션으로 저장하거나 클라이언트로 직접 전달하는 작업을 처리한다.
$sample 입력 도큐먼트 중에서 임의로 몇 개의 도큐먼트만 샘플링해서 다음 스테이지로 전달한다.
$geoNear 주어진 위치를 기준으로 위치 기반의 검색을 수행해서 일정 반경 이내의 결과만 다음 스테이지로 전달한다.
$collStats 컬렉션의 상태 정보를 조회해서 다음 스테이지로 전달한다.
$indexStats 인덱스의 상태 정보를 조회해서 다음 스테이지로 전달한다.
$merge Aggregation Pipeline의 결과 값을 컬렉션에 도큐먼트형태로 저장. $merge 스테이지는 항상 맨 마지막 위치해야 된다.
$unionWith 두 컬렉션을 하나로 결합하고, 두 컬렉션의 파이프라인 결과 값을 하나의 결과값으로 출력한다.
$facet 하나의 스테이지로 다양한 차원의 그룹핑 작업을 수행한다. $facet 스테이지는 $bucket과 $bucketAuto 그리고 $sortByCount 등의 서브 스테이지를 가진다.
$bucket 입력 도큐먼트를 여러 범위로 그룹핑한다. $group 스테이지는 유니크한 모든 값에 대해서 그룹을 생성하지만, $bucket은 사용자가 임의로 각 그룹의 범위를 설정할 수 있다.
$bucketAuto $bucket 스테이지와 동일하지만, $bucketAuto는 사용자가 아닌 MongoDB 서버가 자동으로 그룹의 범위를 설정한다.
$sortByCount 도큐먼트의 필드를 기준으로 그룹핑해서 개수의 역순으로 정렬한 결과를 다음 스테이지로 전달한다.
$graphLookup 입력 도큐먼트와 동일 데이터베이스 내 다른 컬렉션과 그래프(재귀) 쿼리를 실행한다.

나는 이 중에서 $lookup, $match, $sort, $project, $unwind를 사용했다.

 

Aggregation Options

  • db.collection.aggregate(pipeline, options)
    • options에는 aggregation을 실행하는 옵션이 들어간다.
  • 아래와 같은 Aggregation option이 존재한다.

 

options기능 설명

allowDiskUse: Aggregation() 명령은 기본적으로 정렬을 위해서 100MB의 메모리까지 사용할 수 있다. 하지만, allowDiskUse 옵션을 true로 설정하면 디스크를 이용해서 정렬을 처리할 수 있다. 데이터가 저장되는 디렉터리 밑에 \tmp_ 라는 디렉토리를 만들어서 임시 가공용 데이터를 저장합니다.
explain Aggregation() 명령의 실행 계획을 확인할 수 있다.
cursor Aggregation() 명령의 결과로 반환되는 커서의 배치 사이즈를 설정할 수 있다..
maxTimeMS Aggregation() 명령의 최대 실행시간을 설정한다.
readConcern Aggregation() 명령이 도큐먼트의 개수를 확인할 때, 사용할 readConcern 옵션을 설정할 수 있다. (default: local)
bypassDocumentValidation Aggregation() 명령의 결과를 다른 컬렉션으로 저장하는 경우에 컬렉션의 도큐먼트 유효성 체크를 무시할 것인지 설정할 수 있다.
collation Aggregation()의 결과를 collation을 설정해서 원하는 형태로 정렬할 수 있다.

나는 이 중에서 allowDiskUse를 사용해 봤다.

 

MongoDB Aggregation 예시

  • MongoDB Aggregation는 Spring Data MongoDB에서도 지원해 준다.
  • Spring Data MongoDB도 JPA와 마찬가지로, QueryDsl을 제공하지만, 복잡한 구현이 안되고, Aggregation을 사용할 수 없어서 오히려 실무에서는 잘 사용하지 않는다고 한다.
  • 그래서 나도 QueryDsl은 사용하지 않고, Spring Data MongoDB에서 제공하는 Aggregation만으로 구현하였다.
  • 그럼 지금부터 코드 예시를 살펴보자.

 

$lookup

  • $lookup는 입력 도큐먼트와 동일 데이터베이스 내의 다른 컬렉션과 Join을 실행하고, 그 결과를 다음 스테이지에서 사용한다.
  • RDB의 join과 유사하다.
db.tenant.aggregate([        
  {            
    $lookup: {                
      from: "company",                
      let: {tenant_id: "$tenant_id"},                
      pipeline:[{                    
        $match: {                        
          $expr: {                            
            $and: [                                
              {$eq: ["$tenant_id", "$$tenant_id"]}                            
            ]                        
          }                    
        }                
      }],                
      as: "company"            
    },        
  },    
])
  • Spring Data MongoDB로 구현하면 아래와 같다.
AggregationOperation lookupOperation = (context) -> new Document(
        "$lookup",
        new Document("from", "company")
                .append("let", new Document("tenant_id", "$tenant_id"))
                .append("pipeline", List.of(
                        new Document("$match",
                                new Document("$expr",
                                        new Document("$and", List.of(
                                                new Document("$eq", List.of("$tenant_id", "$$tenant_id"))
                                        ))
                                )
                        )
                ))
                .append("as", "company")
);
  • 쿼리를 비교해 보면, '{}'는 Document로 묶고, ', '는 append() 메서드로 표현할 수 있고, 배열형태는 List.of()로 표현 가능하다는 것을 알 수 있다.
  • 좀만 들여다보면, 어떤 형태의 aggregation query라도 java 코드로 구현가능하다.
  • 좀 더 편하게 구현할 수 있는 Aggregation.lookup() 메서드가 있지만, 좀 더 복잡한 join형태를 구현하기 위해서 위와 같이 직접 구현하였다.
  • 나머지 Operation들은 Spring Data MongoDB에서 제공하는 static 메서드를 사용하였다.
  • 사실 $lookup 이 가장 복잡하고, 나머지 Operation들은 비교적 단순하다.

 

$unwind

db.tenant.aggregate([        
  {            
    $unwind: {                
      path: "$customer",                
      preserveNullAndEmptyArrays: true,            
    }        
  },   
])
AggregationOperation unwindOperation1 = (context) -> new Document(
        "$unwind",
        new Document("path", "$company")
                .append("preserveNullAndEmptyArrays", true)
);
  • 위에서 언급한 대로 Spring Data MongoDB에서 제공하는 Aggregation.unwind() 메서드를 통해서 더 간단하게 구현가능하다.
UnwindOperation unwindOperation = Aggregation.unwind("company", true);

 

$match

db.tenant.aggregate([        
  {            
    $match: {                
      $expr: {                    
        $and: [                        
          {$regexMatch: {input: "$tenant_code", regex: "T", options: "i"}},                        
          {$eq: ["$company.company_id", 1]}                    
        ]                
      }            
    }        
  },     
])
AggregationOperation matchOperation = (context) -> new Document(
        "$match",
        new Document("$expr",
                new Document(
                        "$and", List.of(
                        new Document("$regexMatch", new Document("input", "$tenant_code").append("regex", "T").append("options", "i")),
                        new Document("$eq", Arrays.asList("$company.company_code", "COM_1"))
                )
                )
        )
);
  • Aggregation.match() 메소드를 사용하려면, 아래와 같이 Parameter로 Criteria 배열을 넘겨주면 된다.
List<Criteria> criteriaList = new ArrayList<>();
criteriaList.add(Criteria.where("tenant_code").regex("T", "i"));
criteriaList.add(Criteria.where("company.company_code").is("COM_1"));
MatchOperation matchOperation = Aggregation.match(new Criteria()
        .andOperator(
                criteriaList.toArray(new Criteria[0])
        )
);

 

$sort

db.tenant.aggregate([        
  {            
    $sort: {"tenant_id": 1, "company.company_name": -1}
  },            
])
  • sort Operation도 아래와 같이 Aggregation.sort() 메소드를 사용해서 쉽게 구현가능하다.
SortOperation sortOperation = Aggregation.sort(Sort.Direction.DESC, "tenant_id")
                .and(Sort.Direction.ASC, "company.company_name");

 

$project

db.tenant.aggregate([        
  {            
    $project: {                
      "tenant_id": 1,                
      "tenant_name": 1,                
      "tenant_code": 1,                
      "language": 1,                
      "company.company_name": 1,                
      "company.company_id": 1,                
      "customer.customer_name": 1,                
      "customer.login_id": 1            
    }        
  }        
])
  • project Operation도 아래와 같이 Aggregation.project() 메소드를 사용해서 구현 가능하다.
ProjectionOperation projectionOperation = Aggregation.project()
        .and("tenant_id").as("tenantId")
        .and("tenant_name").as("tenantName")
        .and("tenant_code").as("tenantCode")
        .and("language").as("language")
        .and("company.company_name").as("companyName")
        .and("company.company_id").as("companyId")
        .and("customer.customer_name").as("customerName")
        .and("customer.login_id").as("loginId");

 

MongoDB Aggregation에 대해서 알아봤으니 이제  본격적으로 MongoDB Aggregation로 QueryDSL-JPA의 로직을 대체하는 방법에 대해서 알아보자.

 

QueryDsl-JPA 예시

  • ANSI Query와 MongoDB Aggregation를 비교해 보면 아래와 같다.
  • 우선, tenant, company, customer 라는 세 개의 테이블이 있다고 가정하자.
  • company는 tenantId를 가지고 있고, customer은 companyId를 가지고 있다.
  • 먼저, 아래와 같은 쿼리가 있다고 가정하자.
select 
  tenant_id as 'tenantId',              
  tenant_name as 'tenantName'            
  tenant_code as 'tenantCode',                
  language as 'language',                
  company.company_name as 'companyName',                
  company.company_id as 'companyId',                
  customer.customer_name as 'customerName',                
  customer.login_id as 'loginId'
from tenant
left join company on tenant.tenant_id = company.tenant_id
left join customer on company.company_id = customer.company_id
where company.tenant_id = 1    
  and lower(tenant_code) like lower('%T%')    
  and company_cd = 'COM_1'
order by company.company_id;
  • 위의 쿼리를 QueryDsl 로 구현하면 아래와 같다.
@RequiredArgsConstructor
public class TenantRepositoryImpl implements TenantRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    @Override
    public List<TenantDto> searchTenantList(TenantDto tenantDto) {
        return queryFactory
                .select(Projections.fields(TenantDto.class,
                        tenant.tenantId.as("tenantId"),
                        tenant.tenantName.as("tenantName"),
                        tenant.tenantCode.as("tenantCode"),
                        tenant.language.as("language"),
                        company.companyName.as("companyName"),
                        company.companyId.as("companyId"),
                        customer.customerName.as("customerName"),
                        customer.loginId.as("loginId")
                        )
                )
                .from(tenant)
                .leftJoin(company).on(tenant.tenantId.eq(company.tenantId))
                .leftJoin(customer).on(company.companyId.eq(customer.companyId))
                .where(
                        tenantCodeContains(tenantDto.getTenantCode()),
                        companyCodeEq(tenantDto.getCompanyCode())
                )
                .orderBy(
                        tenant.tenantId.desc(),
                        company.companyName.asc()
                )
                .fetch();
    }

    private BooleanExpression tenantCodeContains(String tenantCode) {
        return StringUtils.hasText(tenantCode) ? tenant.tenantCode.containsIgnoreCase(tenantCode) : null;
    }

    private BooleanExpression companyCodeEq(String companyCode) {
        return StringUtils.hasText(companyCode) ? company.companyCode.eq(companyCode) : null;
    }
}
  • 이제 위의 쿼리를 Spring Data MongoDB 를 사용해서 변환해 보자.

 

Spring Data MongoDB

 

위에서 봤던 ANSI Query를 아래와 같은 규칙으로 변환해 보자.

  • left join -> $lookup + $unwind
  • where 절(동적 쿼리) -> $match
  • projection -> $project
  • order by -> $sort

변환 결과

db.tenant.aggregate([        
  {            
    $lookup: {                
      from: "company",                
      let: {tenant_id: "$tenant_id"},                
      pipeline:[{                    
        $match: {                        
          $expr: {                            
            $and: [                                
              {$eq: ["$tenant_id", "$$tenant_id"]}                            
              ]                        
          }                    
        }                
      }],                
      as: "company"            
    },        
  },        
  {            
    $unwind: {                
      path: "$company",                
      preserveNullAndEmptyArrays: true,            
    }        
  },        
  {            
    $lookup: {                
      from: "customer",                
      let: {company_id: "$company_id"},                
      pipeline:[{                    
        $match: {                        
          $expr: {                            
            $and: [                                
              {$eq: ["$company.company_id", "$$company_id"]}                            
            ]                        
          }                    
        }                
      }],                
      as: "customer"            
    },        
  },        
  {            
    $unwind: {                
      path: "$customer",                
      preserveNullAndEmptyArrays: true,            
    }        
  },        
  {            
    $match: {                
      $expr: {                    
        $and: [                        
          {$regexMatch: {input: "$tenant_code", regex: "T", options: "i"}},                        
          {$eq: ["$company.company_id", 1]}                    
        ]                
      }            
    }        
  },        
  {            
    $sort: {"tenant_id": 1}        
  },        
  {            
    $project: {                
      "tenant_id": 1,                
      "tenant_name": 1,                
      "tenant_code": 1,                
      "language": 1,                
      "company.company_name": 1,                
      "company.company_id": 1,                
      "customer.customer_name": 1,                
      "customer.login_id": 1            
    }        
  }    
])
  • 위의 MongoDB Aggregation을 Spring Data MongoDB로 구현하면 아래와 같다.
final List<AggregationOperation> operationList = new ArrayList<>();

AggregationOperation lookupOperation1 = (context) -> new Document(
        "$lookup",
        new Document("from", "company")
                .append("let", new Document("tenant_id", "$tenant_id"))
                .append("pipeline", List.of(
                        new Document("$match",
                                new Document("$expr",
                                        new Document("$and", List.of(
                                                new Document("$eq", List.of("$tenant_id", "$$tenant_id"))
                                        ))
                                )
                        )
                ))
                .append("as", "company")
);

AggregationOperation unwindOperation1 = (context) -> new Document(
        "$unwind",
        new Document("path", "$company")
                .append("preserveNullAndEmptyArrays", true)
);

AggregationOperation lookupOperation2 = (context) -> new Document(
        "$lookup",
        new Document("from", "customer")
                .append("let", new Document("company_id", "$company_id"))
                .append("pipeline", List.of(
                        new Document("$match",
                                new Document("$expr",
                                        new Document("$and", List.of(
                                                new Document("$eq", List.of("$company.company_id", "$$company_id"))
                                        ))
                                )
                        )
                ))
                .append("as", "customer")
);

AggregationOperation unwindOperation2 = (context) -> new Document(
        "$unwind",
        new Document("path", "$customer")
                .append("preserveNullAndEmptyArrays", true)
);

AggregationOperation matchOperation = (context) -> new Document(
        "$match",
        new Document("$expr",
                new Document(
                        "$and", List.of(
                        new Document("$regexMatch", new Document("input", "$tenant_code").append("regex", "T").append("options", "i")),
                        new Document("$eq", Arrays.asList("$company.company_code", "COM_1"))
                )
                )
        )
);

SortOperation sortOperation = Aggregation.sort(Sort.Direction.DESC, "tenant_id")
                .and(Sort.Direction.ASC, "company.company_name");

ProjectionOperation projectionOperation = Aggregation.project()
        .and("tenant_id").as("tenantId")
        .and("tenant_name").as("tenantName")
        .and("tenant_code").as("tenantCode")
        .and("language").as("language")
        .and("company.company_name").as("companyName")
        .and("company.company_id").as("companyId")
        .and("customer.customer_name").as("customerName")
        .and("customer.login_id").as("loginId");

operationList.add(lookupOperation1);
operationList.add(unwindOperation1);
operationList.add(lookupOperation2);
operationList.add(unwindOperation2);
operationList.add(matchOperation);
operationList.add(sortOperation);
operationList.add(projectionOperation);

//aggregate 실행
Aggregation aggregation = Aggregation.newAggregation(
        operationList.toArray(new AggregationOperation[0])
);

List<TenantDto> results = mongoTemplate.aggregate(aggregation, "tenant", TenantDto.class)
        .getMappedResults();
  • 위에서 $lookup Operation 다음에 $unwind Operation을 구현했다. -> 이렇게 되면 RDB의 left join과 유사하게 동작한다.
    • 만약, $unwind의 preserveNullAndEmptyArrays 값을 false 로 설정하면 inner join과 유사하게 동작한다.
    • 실무에서 QueryDsl을 변환할 때, $lookup + $unwind를 합해서 left join을 변환했다.

 

우선 MongoDB Aggregation을 사용해서 QueryDSL-JPA 로직을 변환하는 방법은 알아보았다. 하지만 보다시피 코드가 길고 복잡하다. 다음 편에서는 MongoDB Aggregation 로직을 QueryDSL 처럼 사용하기 편한 형태로 구현할 수 있는 Builder 클래스를 구현해 보자.

 

 

References

'MongoDB' 카테고리의 다른 글

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