서론
개발을 하면서 Application Code와 Database Query 둘 중 어느 것을 사용하여 비즈니스 로직을 처리해야 할지 고민했던 적이 있었는가. 그런 상황이 적지 않았을 것이라고 생각한다.
필자는 꽤나 궁금해서 과연 Application Code와 Database Query 사이의 패러다임을 어느정도 감안하고 Trade Off 해야 할지, 더 나아가선 성능은 얼마나 차이가 나는지 측정해보려고 한다.
구현 내용
우선 요구 사항은 필터링 기능이 주를 이룬다. 애플리케이션 코드로 처리하든, 쿼리로 처리하든 개발자에게는 꽤나 많은 리소스가 들 것이라고 예상되기 때문이다.
필터링 요구사항은 다음과 같다. 카테고리별 복수 선택이 가능하며, 같은 카테고리에서 복수 선택 시 or 조건 발생, 다른 카테고리들끼리는 and 조건으로 처리하도록 한다.
- 브랜드 이름
- 영양기준(미국 기준, 유럽 기준)
- 주원료
- 기능성
위는 필터링을 할 수 있는 속성들이다. 속성들에 대해 자세하게 이해하려면 도메인을 이해해야 하기 때문에 느낌만 알면 된다. 또한 아래 코드는 위 조건들을 만족시키기 위한 코드일 뿐이다. 너무 유심히 볼 필요는 없다.
조회(SELECT) 조건
- 데이터는 10만건 기준이다.
- 100만 건~1000만 건 정도로 측정하려고 했으나 애플리케이션으로 처리하는 양이 많을 때 heap memory out bound 문제가 지속적으로 발생하였다.
- 물론 heap dump를 분석해서 코드를 수정할 수도 있겠지만, 이것은 같은 조건의 비교가 아니라고 생각하기 때문에 10만으로 테스트한다.
- 더 많은 데이터 수에는 결과가 더욱 유의미 해질 수 있다.
- 100만 건~1000만 건 정도로 측정하려고 했으나 애플리케이션으로 처리하는 양이 많을 때 heap memory out bound 문제가 지속적으로 발생하였다.
- pk 외의 컬럼에 별도의 인덱스 처리가 되어있지 않다.
- 처음에 모든 pet_foods를 조회하는 로직은 fetch join을 적용한다.
- 연관된 엔티티의 개수는 1000건이 넘지 않는다.
- 조건절에는 2개의 xToMany 관계를 필요로 하지만 MultipleBagFetchException으로 인해 default_batch_fetch_size=1000으로 최적화를 진행했다.
- 애플리케이션에서 연관관계가 있는 엔티티를 지연로딩으로 조회하더라도 N+1이 발생하지 않는다.
- 필터링 조건은 다음과 같다.
- 브랜드 : 오리젠
- 영양기준 : 미국
- 기능성 : 벌크업
- 주원료 : 치킨
Application Code
PetFoodQueryService
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PetFoodQueryService {
private final BrandRepository brandRepository;
private final PetFoodRepository petFoodRepository;
private final FunctionalityRepository functionalityRepository;
private final PrimaryIngredientRepository primaryIngredientRepository;
public List<PetFood> getPetFoodsByFilters(
List<String> brandsName,
List<String> nutritionStandards,
List<String> primaryIngredientList,
List<String> functionalityList
) {
List<String> functionalities = functionalityRepository.findDistinctFunctionalities();
List<String> primaryIngredients = primaryIngredientRepository.findDistinctPrimaryIngredients();
return petFoodRepository.findAll().stream()
.filter(petFood -> isValidStandard(nutritionStandards, petFood.getHasStandard())
&& isContainBrandsName(brandsName, petFood.getBrand().getName())
&& isContainMainIngredients(primaryIngredients, primaryIngredientList)
&& isContainFunctionalities(functionalities, functionalityList))
.collect(Collectors.toList());
}
private boolean isContainBrandsName(List<String> brandsName, String brandName) {
if (brandsName.isEmpty()) {
return true;
}
return brandsName.contains(brandName);
}
private boolean isValidStandard(List<String> nutrientStandards, HasStandard hasStandard) {
if (nutrientStandards.isEmpty()) {
return true;
}
boolean isValid = true;
for (String nutrientStandard : nutrientStandards) {
if (nutrientStandard.equals("유럽") && !hasStandard.getEurope()) {
isValid = false;
}
if (nutrientStandard.equals("미국") && !hasStandard.getUnitedStates()) {
isValid = false;
}
}
return isValid;
}
private boolean isContainMainIngredients(List<String> primaryIngredients, List<String> primaryIngredientList) {
if (primaryIngredientList.isEmpty()) {
return true;
}
return primaryIngredientList.stream()
.anyMatch(it -> primaryIngredients.contains(it));
}
private boolean isContainFunctionalities(List<String> functionalities, List<String> functionalityList) {
if (functionalityList.isEmpty()) {
return true;
}
return functionalities.stream()
.anyMatch(it -> functionalities.contains(it));
}
}
이렇게 애플리케이션으로 모든 조건을 만족시키려면 위와 같은 매우 길고 복잡한 코드를 작성해야 한다. 이는 코드를 작성한 사람은 물론, 추후 이 코드를 봐야 하는 개발자들도 상당히 불편할 것이다.
Application Code 성능
약 5초 만에 조회 쿼리가 실행된다. 꽤나 적은 데이터임에도 불구하고 인덱스나 쿼리 최적화를 진행하지 않았기 때문에 상당히 오래 걸린다. 참고로 Application Code에서 검증을 진행할 때는, findAll로 모든 레코드를 조회한 후 필터링을 진행한다.
Application Code 장단점
장점
- 쿼리에 익숙하지 않은 개발자에게 직관적이다.
- IDE(인텔리제이)가 문법 오류를 잡아준다.
- 디버깅을 통해 로그 추적을 쉽게 할 수 있다.
- 복잡한 쿼리가 필요하지 않다.
- 필터링 작업을 병렬적으로 수행할 수 있다.
단점
- 필터링 과정에서 중복되는 코드가 발생할 수 있다.
- 서비스 레이어에 많은 코드가 생긴다.
- 위 경우에서는 데이터베이스 테이블에 대해 풀스캔(full scan)이 일어나기 때문에 성능 저하가 발생할 수 있다.
Database Query
PetFoodRepository
@RequiredArgsConstructor
public interface PetFoodQueryRepository extends JpaRepository<PetFood, Long> {
@Query("SELECT pf FROM PetFood pf " +
"JOIN FETCH pf.brand b " +
"JOIN FETCH pf.primaryIngredients pi " +
"JOIN FETCH pf.functionalities f " +
"WHERE (:brandsName IS NULL OR b.name IN :brandsName) " +
"AND (:standards IS NULL OR " +
"(:unitedStates = true AND pf.hasStandard.unitedStates = true) "
"OR (:europe = true AND pf.hasStandard.europe = true)) " +
"AND (:primaryIngredientList IS NULL OR pi.name IN :primaryIngredientList) " +
"AND (:functionalityList IS NULL OR f.name IN :functionalityList) "
)
List<PetFood> findPetFoods(
@Param("brandsName") List<String> brandsName,
@Param("standards") Boolean standards,
@Param("unitedStates") Boolean unitedStates,
@Param("europe") Boolean europe,
@Param("primaryIngredientList") List<String> primaryIngredientList,
@Param("functionalityList") List<String> functionalityList
);
}
관련 쿼리를 jpql로 작성해 보았다. 훨씬 짧아지긴 했지만, 솔직히 애플리케이션 코드보다 나은지 모르겠다. embedded 타입을 따로 처리하여 받아야 하기 때문에 파라미터도 무려 6개나 된다.
애플리케이션도 절대 클린코드라고 볼 수는 없지만, 쿼리에서 클린코드가 있다면 이건 클린 코드의 정반대 되는 개념이 될 것 같을 정도로 더럽고 복잡하고 알아보기 힘들게 만들어졌다.
Database Query 성능
하지만 절대 무시할 수 없는 것이 있다. 바로 성능이다.
같은 조건으로 조회했을 때 애플리케이션 코드에서 조건을 처리하는 것보다 무려 약 200%의 성능 개선을 기대할 수 있었다. 위에서 잠깐 언급하긴 했지만, Application Code로 조건을 처리할 땐 findAll 후 맞는 조건들만 필터링하기 때문에 더 오래 걸리는 것도 있다.
하지만 그게 Application Code로 모든 조건을 처리했을 때의 단점이기 때문에 때문에 더 감안해서 판단해야 한다거나, 페널티라고 생각하진 않는다.
Database Query 장단점
장점
- Application Code에 비해 조회 속도가 매우 빠르다.
- Application Code에 비해 코드가 짧아진다.
- (jpql 사용 시) IDE가 어느 정도 문법 오류는 잡아준다.
단점
- 디버그가 쉽지만은 않다.
- 쿼리가 복잡해진다.
- 데이터베이스의 의존도가 상승한다.
Application Code vs Database Query
그렇다면 우리는 성능 저하를 감수하더라도 Application Code로 필터를 처리함으로써 읽기 편한 코드를 작성하는 것이 좋을까, 아니면 약간은 생소하고 복잡하게 느껴질 수 있지만 성능을 확실하게 챙길 수 있는 Database Query를 사용해야 할까?
예상했겠지만, 사실 답은 없다. 이 글에서는 Applicaiton Code와 Database Query로만 모든 조건들을 처리한다는 극단적인 가정하에 테스트를 진행했기 때문이다.
어느 정도의 Application Code와 Database Query를 혼용해서 사용하는 것도 좋은 방법이다. 예를 들어 위에 예시를 들었던 Application Code의 findAll 쿼리에서 약간의 조건을 추가한 find문으로 바꾼 다음, 나머지 필터들을 처리한다던가 하는 것 말이다.
Querydsl 도입
또 한 가지 좋은 방법이 있다. 이건 Application Code와 Database Query를 혼용해서 단점들을 무마하기보단, Querydsl이라는 외부 라이브러리를 이용하여 Database Query의 단점을 줄여주는 방법이다.
예를 들어 위의 어마무시한 쿼리가 다음과 같이 자바 코드로 작성이 가능해진다. 이는 쿼리 구문을 자바 언어로 작성할 수 있게 해 주며, 기본적인 네이티브 쿼리나 jqpl보다 가독성도 좋고 성능은 성능대로 query처럼 나온다.
@Repository
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PetFoodQueryRepository {
private final JPAQueryFactory queryFactory;
public List<PetFood> findPetFoods(
List<String> brandsName,
List<String> standards,
List<String> primaryIngredientList,
List<String> functionalityList
) {
return queryFactory
.selectFrom(petFood)
.join(petFood.brand, brand)
.fetchJoin()
.join(petFood.primaryIngredients, primaryIngredient)
.fetchJoin()
.join(petFood.functionalities, functionality)
.where(
isContainBrand(brandsName),
isMeetStandardCondition(standards),
isContainPrimaryIngredients(primaryIngredientList),
isContainFunctionalities(functionalityList)
)
.fetch();
}
private BooleanExpression isContainBrand(List<String> brandsName) {
if (brandsName.isEmpty()) {
return null;
}
return petFood.brand.name.in(brandsName);
}
private BooleanExpression isMeetStandardCondition(List<String> standards) {
if (standards.isEmpty()) {
return null;
}
for (String standard : standards) {
if (standard.equals("미국")) {
return petFood.hasStandard.unitedStates.isTrue();
}
if (standard.equals("유럽")) {
return petFood.hasStandard.europe.isTrue();
}
}
return null;
}
private BooleanExpression isContainPrimaryIngredients(List<String> primaryIngredientList) {
if (primaryIngredientList.isEmpty()) {
return null;
}
return petFood.primaryIngredients.any().name.in(primaryIngredientList);
}
private BooleanExpression isContainFunctionalities(List<String> functionalityList) {
if (functionalityList.isEmpty()) {
return null;
}
return petFood.functionalities.any().name.in(functionalityList);
}
}
실제로 해당 메서드로 동일한 조회 메서드를 실행했을 시 아래와 같은 결과가 나온다.
위의 쿼리보다 약간 더 빨라지긴 했지만 절대로 해당 결과만 보고 무조건 Querydsl을 쓰는 것이 네이티브 쿼리와 jpql보다 빠르다고 생각하지 말고, Querydsl을 사용하면 쿼리의 단점인 가독성과 복잡도에 대한 갈증을 해소시켜주면서, 성능은 기존의 쿼리처럼 가져갈 수 있다고만 기억하자.
정리
내용을 정리하면 다음과 같은 결론을 낼 수 있다.
- Application Code
- 장점
- 쿼리에 익숙하지 않은 개발자에게 직관적이다.
- IDE(인텔리제이)가 문법 오류를 잡아준다.
- 디버깅을 통해 로그 추적을 쉽게 할 수 있다.
- 복잡한 쿼리가 필요하지 않다.
- 필터링 작업을 병렬적으로 수행할 수 있다.
- 단점
- 필터링 과정에서 중복되는 코드가 발생할 수 있다.
- 서비스 레이어에 많은 코드가 생긴다.
- 데이터베이스 테이블에 대해 풀스캔(full scan)이 일어날 수 있기 때문에 성능 저하가 발생할 수 있다.
- 장점
- Database Query
- 장점
- Application Code에 비해 조회 속도가 매우 빠르다.(위 예시에서는 200% 상승)
- Application Code에 비해 코드가 짧아진다.
- (jpql 사용 시) IDE가 어느 정도 문법 오류는 잡아준다.
- 단점
- 디버그가 쉽지만은 않다.
- 쿼리가 복잡해진다.(가독성이 떨어진다.)
- 데이터베이스의 의존도가 상승한다.
- 장점
- Application Code와 Database Query의 혼용 또는 Querydsl을 사용함으로써, Application Code와 Database Query의 장점을 합치고 단점을 보완할 수 있다.