MongoDB DSL을 만들다

짱구·2024년 8월 12일
2

소개

안녕하세요! 2년차 백엔드 개발자 정철희입니다.
저희 팀은 관계성이 없는 로그성 결제 데이터를 MongoDB에 저장하고 있습니다.

QueryDSL Mongo의 설정 문제

통계 처리를 할 때 필요한 많은 조건의 동적 쿼리를 작성함에 있어 spring-data-mongo로는 한계점이 있었습니다.
그래서 선택한 첫번째 방식은 'QueryDSL mongo를 사용하자' 였습니다.
하지만 이미 Spring-data-jpa와 QueryDSL을 이미 사용중이었고 QueryDSL mongo와 충돌이 났습니다.
dependency issue에 많은 공수를 두기 싫어 Critera, Bson을 사용하여 동적 쿼리를 만들기로 했습니다.

Criteria, Bson의 문제점

  1. mongoDB의 Query를 생성할 때 Criteria, Bson을 사용하면 타입 안정성이 떨어지며 코드가 지저분해져 가독성이 떨어지는 문제가 있습니다.

  2. 저를 포함한 저희 팀원 모두 mongoDB에 대해 높은 이해도를 가지지고 있지 않았기에 RDBMS를 사용하듯이 코드 작성을 하고 싶었습니다.

저는 위 두 문제를 'DSL 형태로 개선할 수 있겠다' 생각하여 일주일이라는 시간동안 만들어보았습니다.

Criteria, QueryDSL Mongo와 Custom MongoDB DSL의 비교

아래는 Author의 name을 in 연산, nickname을 like 연산, age는 between 연산하는 코드입니다.

Criteria

fun findAuthors(
    names: List<String>,
    minAge: Int?,
    maxAge: Int?,
    nickname: String?,
): List<Author> {
    val criteriaList = mutableListOf<Criteria>()

    criteriaList.add(Criteria.where("name").`in`(it))

    if (minAge != null && maxAge != null) {
        criteriaList.add(Criteria.where("age").gt(minAge).lt(maxAge))
    } else {
        minAge?.let {
            criteriaList.add(Criteria.where("age").gt(it))
        }
        maxAge?.let {
            criteriaList.add(Criteria.where("age").lt(it))
        }
   }

   nickname?.let {
	   criteriaList.add(Criteria.where("nickname").regex(it, "i"))
   }
    
   val query = if (criteriaList.isNotEmpty()) {
        val criteria = Criteria().andOperator(*criteriaList.toTypedArray())
        Query(criteria)
    } else {
        Query()
    }
    
    return mongoTemplate.find(query, Author::class.java)
}

QueryDSL Mongo

private lateinit var authorRepository: JpaRepository<Author, Long>
private val author = QAuthor.author

fun findAuthors(
    names: List<String>,
    minAge: Int?,
    maxAge: Int?,
    nickname: String?,
): List<Author> {
    var predicate = author.name.`in`(names)

    if (minAge != null && maxAge != null) {
        predicate = predicate.and(author.age.gt(minAge).and(author.age.lt(maxAge)))
    } else {
        minAge?.let {
            predicate = predicate.and(author.age.gt(it))
        }
        maxAge?.let {
            predicate = predicate.and(author.age.lt(it))
        }
    }

    nickname?.let {
        predicate = predicate.and(author.nickname.contains(it))
    }

    return authorRepository.findAll(predicate) as List<Author>
}

Custom Mongo DSL

fun findAuthors(
        names: List<String>,
        nickname: String?,
        minAge: Int?,
        maxAge: Int?,
): List<Author> {
    val document = document {
        field(Author::name) `in` names
        field(Author::age) between (minAge to maxAge)
        nickname?.let { field(Author::nickname) contains it }
    }

    return mongoTemplate.find(document, Author::class)
}

위와 같이 동일한 결과를 반환하는 코드지만 Custom Mongo DSL은 가독성과 오타로 인한 런타임 문제, 타입 안정성까지 챙기게 됩니다.

MongoDB DSL

장점

  • QueryDSL과 다르게 별도의 Q파일이 생기지 않는다.
  • readonly dependency가 아니라 우리 service, domain에 맞는 customizing이 가능하다.
  • 가독성, 코드의 양, 직관성, 타이핑 오류(리터럴로 필드명을 넘기는 문제), 타입 안정성이 많이 올라간다.

단점

  • 예상치 못한 에러가 발생할 수 있기 때문에 위험 부담이 있다.
  • aggregate에 대한 부족한 지원

사용 예시

document scope 에서 and, or, nor, not을 사용하면 field를 함수 형태로 넘길 수 있습니다.


val basicQuery = document {
	field(Author::name) eq "정철희"
    field(Author::age) ne 25
}

mongoTemplate.find(basicQuery, Author::class)

or 연산

and, or, nor, not 인자 안에 함수 scope 내부는 and 연산으로 처리됩니다.

"( 정철희 and 25 and 010-1234-5678 ) or ( 정원희 and 30 and 010-5678-1234 )" {
	val basicQuery = document(OR) {
    	and {
	        field(Author::name) eq "정철희"
    	    field(Author::age) eq 25
            field(Author::phone) eq "010-1234-5678"
        },
        and {
			field(Author::name) eq "정원희"
            field(Author::age) eq 30
            field(Author::phone) eq "010-5678-1234"
		},
	}

group 연산

grouping을 사용하면 간단한 통계 쿼리를 생성할 수 있습니다.

"이름이 정철희인 사람들의 나이의 합" {
	val basicQuery = document {
    	field(Author::name) eq "정철희" },
	} sum {
    	field(Author::money) alias "total"
    }
}

"만약 mongodb에 field가 string 타입이라면 sumOfNumber를 사용하여 합을 구한다." {
	val basicQuery = document {
		field(Author::name) eq "정철희"
	} group {
    	field(Author::status) by SINGLE
    } average {
   	 	field(Author::money) alias "total"
    }
}

후기

항상 공부할 때 나만의 DSL을 만들고 싶었다는 욕구가 있었는데 이번 기회에 DSL을 만들어본게 정말 좋은 경험이라고 생각합니다.

kotlin에 대해 공부하면서 확장 함수, 중위 함수, 함수형 프로그래밍, 추상화를 응용하며 생각하면서 만들었으며, 완벽하다 생각하진 않지만 실무에 적용하여 잘 사용하고 있습니다.

개발자들이 편하게 쓸 수 있는 라이브러리, 프레임워크를 만들어서 배포하는 것이 저의 꿈이자 목표기에 이번 도전을 통해 꿈에 첫번째 걸음을 디뎠다고 생각하고 꾸준히 업데이트 하겠습니다.

Github 주소입니다 :)

profile
코드를 거의 아트의 경지로 끌어올려서 내가 코드고 코드가 나인 물아일체의 경지

0개의 댓글