TypeORM QueryBuilder 활용 사례: 재사용성을 높이는 방법과 테스트 작성하기

Many(김희만) Kim
직방 기술 블로그
9 min readDec 18, 2023

안녕하세요, 백엔드 하우스팀에서 근무하고 있는 Many입니다.

저희 팀에서는 TypeORM을 활용하여 쿼리 조회를 수행하고 있습니다. 이 글에서는 특히, TypeORM의 QueryBuilder를 활용하여 쿼리를 더 재사용 가능하고 테스트 작성이 용이한 형태로 리팩토링한 내용을 소개하려 합니다.

왜 QueryBuilder의 재사용성을 높여야 하는가?

ORM을 통한 데이터 조회는 대체로 편리하고 가독성이 높은 코드를 작성할 수 있어 개발자들 사이에서 선호되는 방식입니다. 객체를 통한 조회는 테스트 코드를 작성하고 검증하기가 비교적 수월하며, 쿼리 조건을 명시적으로 나열하여 가독성을 높일 수 있어 코드로서의 명료성을 지킬 수 있습니다.

그러나 ORM이 제공하지 않는 특별한 기능이나 일부 동작을 원할 때, QueryBuilder를 통한 데이터 조회를 고려해야 하는 상황이 발생합니다. 이때 일부 쿼리문이 코드로 들어오면서 QueryBuilder를 사용하는 코드는 가독성을 낮추고, 테스트 작성이 어려워지며 코드의 재사용성이 떨어질 수 있습니다.

개선 사례: 매물 조회를 위한 QueryBuilder 활용

직방의 핵심인 매물은 여러 유형의 필터를 제공합니다.

직방에서는 사용자들이 전국 각지에 등록된 다양한 매물을 지도와 필터를 통해 쉽게 찾을 수 있는 매물 찾기 기능을 제공하고 있습니다. 사용자들은 매물 검색 시 다양한 정보와 조건을 입력할 수 있어, 이에 대응하는 복잡한 코드 로직이 매물 정보 조회 부분에서 수행되고 있습니다.

이에 기존 코드의 복잡한 부분을 알고있기에 이번에 진행한 지킴중개를 위한 별도의 페이지에서는 조금 더 유지보수가 용이한 형태로 진행한 QueryBuilder 사용 사례를 소개할까합니다.

QueryBuilder 조건 적용을 위한 모델과 클래스 작성

매물 조회를 위한 매물 조회 옵션 클래스를 정의해줍니다.

필터 기능의 경우 매번 요구되는 옵션이 아니기에 모두 optional 처리를 해줍니다.

export class ItemFindOptions {
public floorTypes?: Array<'지상' | '반지하' | '옥탑'>
public depositMax?: number
public depositMin?: number
public type?: '전세' | '월세' | '매매'
public rentMin?: number
public rentMax?: number
public bjdCodes?: string[]
}

필터 옵션에 입력된 값을 이용해서 QueryBuilder에 쿼리를 적용시켜주는 ItemFindQueryBuilder 클래스를 작성합니다.

export class ItemFindQueryBuilder {
private alias = 'item'

constructor(private qb: SelectQueryBuilder<Item>, private options: ItemFindOptions) {
this.alias = this.qb.alias
}

public build(): SelectQueryBuilder<Item> {
this.qb.andWhere(
new Brackets((innerQb) => {
this.setFloorTypes(innerQb, options)
.setDepositMax(innerQb, options)
.setDepositMin(innerQb, options)
}),
)

return this.qb
}

private setFloorTypes(qb: WhereExpressionBuilder, options: ItemFindOptions) {
if (options.floorTypes && options.floorTypes.length > 0) {
qb.andWhere(
new Brackets((innerQb) => {
options.floorTypes?.forEach((floorType) => {
switch (floorType) {
case '지상':
innerQb.orWhere(`${this.alias}.floorType = '지상'`)
break
case '반지하':
innerQb.orWhere(`${this.alias}.floorType = '반지하'`)
break
case '옥탑':
innerQb.orWhere(`${this.alias}.floorType = '옥탑'`)
break
}
})
}),
)
}

return this
}

private setDepositMin(qb: WhereExpressionBuilder, options: ItemFindOptions) {
if (options.depositMin) {
qb.andWhere(`${this.alias}.deposit >= :depositMin`, { depositMin: options.depositMin })
}

return this
}

private setDepositMax(qb: WhereExpressionBuilder, options: ItemFindOptions) {
if (options.depositMax) {
qb.andWhere(`${this.alias}.deposit <= :depositMax`, { depositMax: options.depositMax })
}

return this
}
}

Repository에 매물 조회 옵션을 이용해서 조회할 수 있는 메소드를 작성합니다.

 public async findByOptions(options: ItemFindOptions, offset = 0, limit = 20) {
const qb = await this.createQueryBuilder('item')
const itemFindBuilder = new ItemFindQueryBuilder(qb, options).build()
itemFindBuilder.skip(offset).take(limit)

return itemFindBuilder.getMany()
}

블로그 작성을 위해서 일부 필터만 구현했지만 실제 관리자 페이지에서 사용할 수 있는 필터의 수는 19개입니다. 필터 항목이 총 19개이며, 일부 필터를 여러 화면에서 사용한다면 각각의 조회를 위한 메소드를 구현한다면 findByOOO, findByOOOAndOOO, findByOOOAndOOOAndOOO와 같은 무수히 많은 메소드가 생성될 수 있습니다.

실제 사용 후 느낀 편리함

실제 수많은 코드 리뷰 과정을 통해 팀원들과 유사한 형태의 QueryBuilder 재사용을 위한 코드가 구성되었습니다. 해당 코드를 사용하면서 몇 달간의 서비스 운영, 신규 개발, 유지보수를 진행하며 겪은 장점은 다음과 같습니다.

데이터를 몰라도 필터 사용에 문제가 없다.

필터를 구현하기 위해서는 현재 데이터가 어떻게 구성되어 있고, 어떤 조건들을 사용해야 하는지 등 기존 데이터에 대한 업무 지식이 필요합니다. 하지만 구성한 빌더와 연동하는 ItemFindOptions 객체를 통해서 실제 구현부와 데이터를 알지 못하더라도 현재 어떤 조회가 구현되어 있는지 빠르게 확인할 수 있습니다. 또한, 구현된 필터는 언제라도, 누구라도 바로 사용할 수 있습니다.

이곳저곳 매물 조회를 위한 만능 메소드

테스트 코드 열심히 작성했는데 잘못 조회한 케이스 없으신가요?

가끔 테스트 코드까지 열심히 작성했는데 실제 구동 시 일부 조건에서 의도치 않게 조회되는 케이스를 가끔씩 겪게 됩니다. 특히나 조건이 복잡해지는 경우 AND, OR 조건의 괄호에 의해서 많이 발생합니다. 이미 많은 코드에서 검증되었고 또한 테스트 코드를 통한 빌더에 대한 검증이 이뤄졌기 때문에 많은 코드들에서 걱정 없이 해당 메소드를 통해 손쉽게 매물에 대한 조회를 수행합니다.

ORM 만큼이나 수월한 테스트 코드 작성

ORM과 QueryBuilder를 통한 조회 중 가장 큰 차이점이자, 저는 ORM을 통한 조회를 더 선호하는 이유는 테스트 코드 작성의 난이도가 낮아진다는 점입니다.

ORM은 객체 지향적인 접근 방식으로 데이터를 다루기 때문에 테스트 코드 작성이 자연스럽고 직관적입니다. 객체와 데이터베이스 간의 매핑이 이미 정의되어 있기 때문에 별도의 쿼리 빌딩 로직을 작성하지 않아도 됩니다. 따라서, ORM을 사용하면 쿼리 빌딩과 관련된 테스트 코드를 작성할 필요가 줄어들어 전반적인 테스트 작성이 간편해집니다.

반면에 QueryBuilder는 쿼리를 직접 작성해야 하기 때문에 쿼리 로직의 테스트 코드 작성이 필수적입니다. 특히 복잡한 쿼리에서는 AND, OR 조건 및 괄호 처리 등이 추가로 필요하므로 테스트 코드 작성이 더욱 번거로울 수 있습니다.

다음은 실제 검증하는 테스트 코드 일부입니다.

 test('[depositMin] 최소보증금이 주어지면 최소보증금 조건을 추가한다.', () => {
const itemFindBuilder = new ItemFindQueryBuilder(mockQb, {
depositMin: 5000,
}).build()

expect(mockQb.andWhere).toHaveBeenCalledWith(
expect.stringContaining(`deposit >= :depositMin`),
expect.objectContaining({ depositMin: 5000 }),
)
})

위의 내용처럼 제공하는 각 필터 항목에 해당하는 쿼리들이 잘 추가되었는지 유닛테스트를 통해서 검증하고 있습니다.

이미 제공하는 필터의 모든 기능을 해당 빌더에서 테스트하기에 각 서비스 로직에서는 조건에 맞는 ItemFindOptions 객체가 잘 생성되었는지만 확인하면 되기에 기존 결합된 코드에 비해서 테스트 코드 작성의 난이도가 낮아졌습니다.

결론

QueryBuilder를 사용해야하는 케이스에서 재사용성을 높이고 테스트 코드 작성의 편의성을 높이기 위해 다양한 빌더 클래스를 도입하였습니다. 이러한 빌더 패턴을 적용함으로써 새로운 필터의 추가나 변경이 쉬워지고, 유지보수도 간편해집니다. 또한, 각 빌더 클래스에 대한 테스트 코드 작성으로 코드 품질을 높이는데 기여하였습니다.

마지막으로

지금까지의 경험을 토대로 우리는 코드의 유연성과 확장성을 높이는 방법에 대해 고민해보았습니다. 적절한 도구와 디자인 패턴의 적용은 개발 생산성을 향상시키며, 팀 전체의 코드 품질을 향상시키는데 중요한 역할을 합니다. 저희 팀의 끊임없는 학습과 개선을 통해 더 나은 코드와 효율적인 테스트 방법을 찾아 나가는 여정은 계속될 것입니다. 함께 성장하며 더 나은 소프트웨어를 만들어 나가기를 기대합니다.

감사합니다.

--

--

Many(김희만) Kim
직방 기술 블로그

안녕하세요 일잘러가 되고싶은 개발자입니다.