QueryDSL을 효과적으로 실무에 적용하기 위한 고민

by Devellany

Java /

이 글의 예제는 팀블로그인 토이프로젝트를 기반으로 합니다.

   사내 담당 서비스는 MSA로 분리된 Java repository로 운영합니다. 그 안에서도 세부적인 역할이 나누어지지만, 사용하는 기술은 동일하죠. 이 중에서 데이터베이스로 데이터를 처리할 때 문제라 느낀 부분이 있습니다. 바로 MyBatis와 JPA interface, JPQL, QueryDSL이 뒤섞여 있다는 점입니다. 외부 업체를 통한 구조개선으로 만들어진 코드가 가진 한계로 인수인계 받은 소스는 이 모든게 특별한 규칙 없이 사용되고 있었습니다. 그동안 개선할 여건이 만들어지지 않아 아직까지 유지되는 실정이죠.

   잠시 다른 이야기로 빠져볼게요. 재작년 Modern PUG에서 ORM에 대해 준비하여 세미나 발표한 적 있습니다. ORM은 분명 객체지향을 자키기 위한 획기적인 방식이지만 장점만 존재하지 않아요. ORM 만으로 기존 SQL이 처리한 모든 문제를 해결할 수 없기에 경우에 따라 native query를 쓰지 않을 수 없습니다. 사람마다 도메인에 따라 체감되는 건 다르지만, ORM을 써본 분들이라면 누구나 알고 있는 내용이기에 이를 세미나 장표에 담았습니다.

   실무를 하면 통계와 같이 복잡한 수식이 필요할 일도 있고, 성능을 높이기 위한 쿼리 튜닝도 필요하거든요. ORM은 제아무리 잘 만들어진 쿼리빌더가 존재해도 한계는 존재합니다. 그렇기에 JPA와 MyBatis 혼용은 어느정도 이해할 수 있는 영역입니다. 특히 빌링서비스는 통계가 많거든요. JPQL이 만능도 아닐 뿐더러, JPA native query는 조건절에 대한 동적 할당이 불가능합니다. 하나하나 별도로 만들어줘야 하죠.

   기술 혼용에 대하여 문제라 느낀건 JPA와 MyBatis가 아니라 바로 QueryDSL과 JPQL입니다. QueryDSL는 JPQL 빌더로 동적 쿼리를 메소드로 구조화하여 관리할 수 있도록 돕는 쿼리빌더 라이브러리죠. 자체적인 빌드를 통해 생성한 Qclass 덕분에 IDE 자동완성 기능을 십분 활용 가능합니다. 즉, JPQL 사용성을 높이기 위해 확장한 라이브러리가 QueryDSL이기에 이 두 가지를 굳이 혼용할 이유는 없습니다.

   한 편, JPA repository interface 또한 Entity를 직접적으로 호출하는 findById()save()와 같은 기본적인 인터페이스가 아니면 가독성이 떨어집니다. 코드를 작성하는 당사자는 가이드대로 메소드명만 만들면 되니 편리할지 몰라도 추가적인 조건이 둘만 넘어가도 이 쿼리를 호출하는 목적이 직관적으로 파악 되지 않습니다. 메소드명을 통한 코드 자동 생성은 분명 좋은 기능이지만, 카멜케이스로만 이루어진 장황한 이름을 보고 있노라면 일반적인 코드보다 가독성이 떨어질 때가 많습니다.

List<User> findByNameOrBirthDateAndActive(String name, ZonedDateTime birthDate, Boolean active);

조건이 많으면 많아질수록 가독성이 떨어지는 JPA interface

   그동안 빌링서비스 운영한 경험과 제가 가진 개발 신념에 의거하여 더 나은 코드가 무엇일지 고민한 끝에 JPA와 QueryDSL의 장점을 가진 무언가를 만들어보기로 결정하였습니다. 토이프로젝트를 통해 실무에서 쓰일 수 있을지 계속 테스트를 하고 있는 단계네요. 이러한 고민에서 시작한 QueryDSL 시행착오를 기록하기 위해 타닥타닥 키보드를 두드립니다.

기존 코드가 가진 두 가지 문제점

동일한 역할을 수행하지만, 다른 형태를 가진 Repository를 둘 이상 만들어야 했다.

    JPA와 QueryDSL을 혼용하여 사용하다보면 동일한 역할을 수행하는 repository를 각각 생성해야 합니다. JPA는 인터페이스만 만들어서 빌드를 하면 구현체를 자동으로 생성하기에 별도로 구현체를 만들지 않습니다. 필요에 따라 구현체를 추가로 만들어도 되지만, 그조차 관리 포인트입니다.

   반면 QueryDSL은 본격적으로 활용하기 위해 구현체를 요구합니다. 물론 Java 8에서 추가된 디폴트 메소드를 통해 코드를 구현할 수 있어 인터페이스로도 만들 수 있지만, 이렇게 사용하라고 제공하는 기능이 아닙니다. 하나의 객체가 수많은 역할을 수행해서도 안 되지만, 동일한 역할을 수행하는 클래스가 여러 가지라도 문제입니다.

QuerydslRepositorySupport가 가진 문제

   JPA로 유명한 김영한님 강의에서도 나오는 내용으로 QuerydslRepositorySupport는 3.x 버전에 만들어졌습니다. QuerydslRepositorySupport를 사용하면 4.x에서 추가된 select()메소드로 시작할 수가 없죠. QueryDSL 버전이 올라간지 꽤 많은 시간이 흘렀지만 여전히 지원하지 않습니다. 다행스럽게도 QueryDSL은 SQL과 유사한 방식으로 문법이 만들어져 특별한 러닝커브가 존재치 않지만, SQL은 from절부터 시작하지 않습니다. select()로 시작하고 싶다면 JPAQueryFactory를 사용해야 합니다.


   이와 함께 앞서 언급하였듯이 JPA interface, JPQL, QueryDSL이 명확한 규칙 없이 혼용되고 있었습니다. 신규 입사자나 해당 기술에 익숙치 않은 개발자들에게 불필요한 러닝커브를 야기하여 이를 해결할 방법을 찾기 시작했습니다.

두 이슈를 해결하는 방법이란?!

   취하고자 하는건 간단했습니다. JPA interface가 지원하는 Entity 기반 영속성 관리와 함께 QueryDSL에서 자주 쓰이게 될 select 메소드를 지원하는 추상 클래스를 만들고 싶었습니다. 이를 위해 JPA interface 구현체인 simpleJpaRepository일부 메소드를 차용하고, querydslFactory를 리매핑했습니다. 단순한 코드죠.

   추상클래스가 생성자를 갖게 되면 부모 클래스에 대한 자식클래스의 의존성이 지나치게 강해져서 autowiredJPAQueryFactory를 불렀습니다. 권장사항이 아니라도 해당 구현체가 별도로 주입될 일은 없을 것이며, 부모-자식 간 의존성을 줄이는게 더 중요하다 여겼습니다. 물론 save()/delete()메소드가 public인 것도 의존성을 증폭시키지만, 기존 JPA repository 구조를 맞추다보니 어쩔 수 없었습니다.

import com.querydsl.core.types.Expression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.repository.support.JpaEntityInformation;
import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport;
import org.springframework.data.util.ProxyUtils;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Transactional(readOnly = true)
public abstract class BaseRepository<E, ID> {
    @PersistenceContext private EntityManager em;
    @Autowired private JPAQueryFactory queryFactory;
    private JpaEntityInformation<E, ID> entityInformation;

    protected <DTO> JPAQuery<DTO> select(Expression<DTO> expr) {
        return queryFactory.select(expr);
    }

    protected JPAQuery<E> selectFrom(EntityPath<E> from) {
        return queryFactory.selectFrom(from);
    }

    @Transactional
    public E save(E entity) {
        if (isNewEntity(entity)) {
            em.persist(entity);
            return entity;
        }

        return em.merge(entity);
    }

    @Transactional
    @SuppressWarnings("unchecked")
    public void delete(E entity) {
        JpaEntityInformation<E, ID> entityInformation = this.getJpaEntityInformation(entity.getClass());
        if (isNewEntity(entity)) {
            return;
        }

        E existing = (E) em.find(ProxyUtils.getUserClass(entity), entityInformation.getId(entity));
        if (existing == null) {
            return;
        }

        em.remove(em.contains(entity) ? entity : em.merge(entity));
    }

    @SuppressWarnings("unchecked")
    private JpaEntityInformation<E, ID> getJpaEntityInformation(Class<?> clazz) {
        if (this.entityInformation == null) {
            this.entityInformation =
(JpaEntityInformation<E, ID>) JpaEntityInformationSupport.getEntityInformation(clazz, em);
} return this.entityInformation; } private Boolean isNewEntity(E entity) { return this.getJpaEntityInformation(entity.getClass()).isNew(entity); } }

BaseRepository 코드

   이대로 끝나면 아쉽지 않을까요? 데이터 특성 상 자주 요청하게 될 페이지네이션 기능도 통합하고 싶었습니다. spring-data에서 제공하는 PageablePage가 있지만, 불필요한 데이터가 많이 넘어가는게 마음에 들지 않았습니다. 클라이언트가 그 많은 데이터를 전부 활용하지 않으니 별도로 Pagenation클래스를 생성하였습니다. 여기까지는 돌아다니는 코드와 크게 다르지 않을 겁니다.

@Transactional(readOnly = true)
public abstract class BaseRepository<E, ID> {

// ...

    protected <DTO> Pagination<DTO> applyPagination(JPAQuery<DTO> query, Pageable pageable) {
        return getDtoPagination(query, pageable, query.fetchCount());
    }

    protected <DTO> Pagination<DTO> applyPagination(JPAQuery<DTO> contentQuery, JPAQuery<?> countQuery, Pageable pageable) {
        return getDtoPagination(contentQuery, pageable, countQuery.fetchCount());
    }

    private <DTO> Pagination<DTO> getDtoPagination(JPAQuery<DTO> contentQuery, Pageable pageable, long totalCount) {
        List<DTO> result = this.pagingFetch(contentQuery, pageable);
        if (result.size() > 0) {
            return new Pagination<>(result, totalCount, pageable);
        }

        Integer totalPage = (int)Math.floor((double) totalCount / pageable.getPageSize()) + 1;
        return new Pagination<>(this.pagingFetch(contentQuery, totalCount, pageable.getPageSize()),
                totalCount, totalPage, pageable.getPageSize());
    }

    private <DTO> List<DTO> pagingFetch(JPAQuery<DTO> query, Pageable pageable) {
        if (pageable.isUnpaged()) {
            return query.fetch();
        }

        return query.offset(pageable.getOffset()).limit(pageable.getPageSize()).fetch();
    }

    private <DTO> List<DTO> pagingFetch(JPAQuery<DTO> query, Long totalCount, Integer pageSize) {
        long offset = (long)Math.floor((double)totalCount / pageSize) * pageSize;

        return query.offset(offset).limit(pageSize).fetch();
    }
}

BaseRepository 추가 코드

   QueryDSL은 순수 Entity를 반환하지 못 할 때 자체적으로 만들어낸 Tuple로 반환합니다. Tuple은 사실 그대로 쓰기엔 사용성이 좋지 않습니다. 이를 해결하기 위해 Projections을 제공하지만, 이를 매번 타이핑하기란 여간 귀찮은게 아니죠. 수십 번, 경우에 따라 수천 번 넘게 반복하며 코드를 작성하는건 리소스 낭비일 뿐더러 중복 코드입니다. 중복 코드를 제거하기 위해 한 번 더 감싸주었습니다.

@Transactional(readOnly = true)
public abstract class BaseRepository<E, ID> {

// ...

    protected <DTO> JPAQuery<DTO> select(Class<DTO> clazz, Expression<?>... exprs) {
        return queryFactory.select(Projections.fields(clazz, exprs));
    }
}

BaseRepository 추가 코드

   이 코드를 토이프로젝트로 테스트하다가 SQL 일부 함수를 QueryDSl에서 지원하지 않는 걸 깨닫습니다. 이를 해결하기 위해 추가적인 코드가 필요합니다. group_concat도 기본 지원되지 않는 함수입니다. QueryDSL은 최대한 Entity로 해결하기를 권장하지만, 경우에 따라 사용해야만 하는 DB 함수들이 존재합니다. 특히 이미 갖추어진 코드를 마이그레이션하는 상황이라면 더더욱 그렇겠죠. 이를 보완하는 방법을 찾다가 다음과 같이 공용 메소드를 추가하였습니다.

jpa:
    database: mysql
    database-platform: me.dico.infra.config.MysqlCustomDialect
    hibernate:
      ddl-auto: validate

application.yml 설정 database-platform 추가

import org.hibernate.dialect.MySQL8Dialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StringType;

public class MysqlCustomDialect extends MySQL8Dialect {
    public MysqlCustomDialect() {
        super();
        this.registerFunction("group_concat",
                new StandardSQLFunction("group_concat", new StringType())
        );
    }
}

CustomDialect 코드

@Transactional(readOnly = true)
public abstract class BaseRepository<E, ID> {

// ...

    protected <C extends Expression<?>> JPAQuery<String> selectGroupConcat(C column) {
        return select(Expressions.stringTemplate("group_concat({0})", column));
    }
}

BaseRepository 추가 코드

@Repository
@Transactional(readOnly = true)
public class ArticleRepository extends BaseRepository<ArticleInfo, Long> {
    QArticleInfo articleInfo = QArticleInfo.articleInfo;
    QCategory category = QCategory.category;
    QMemberInfo memberInto = QMemberInfo.memberInfo;

    public Pagination<ArticleListInfo> findArticleList(Long categoryIdx, Pageable pageable) {
        return applyPagination(
                select(ArticleListInfo.class,
                        articleInfo.articleIdx,
                        category.urlCode,
                        category.name,
                        articleInfo.title,
                        articleInfo.memIdx,
                        memberInto.nickname,
                        articleInfo.publishDtm)
                .from(articleInfo)
                    .join(category).on(category.categoryIdx.eq(articleInfo.categoryIdx))
                    .join(memberInto).on(memberInto.memIdx.eq(articleInfo.memIdx))
                .where(articleInfo.status.in(ArticleStatus.SHOW, ArticleStatus.NOTICE))
                .where(articleInfo.categoryIdx.eq(categoryIdx))
                .orderBy(articleInfo.publishDtm.desc()),
         pageable);
    }

    public ArticleInfo findById(Long articleIdx) {
        return selectFrom(articleInfo)
                .where(articleInfo.articleIdx.eq(articleIdx))
                .fetchOne();
    }
}

BaseRepository 사용 코드

한 가지 아쉬운건 매끄럽지 않은 NPE 처리

   JPAQuery를 활용해서 단일 데이터를 가져오고자 할 때 존재하지 않는 값은 null로 반환합니다. 이는 매우 아쉬운 부분입니다. List는 비어있는 객체를 반환하여 list.isEmpty()로 빈값 여부를 확인하면 되는 것과는 대조적입니다. Optional이 Java 8에서 추가되었음에도 불구하고 fetchOne()과 이를 확장한 fetchFirst()null이 반환됩니다. 메소드명만으로는 null이 리턴될 수 있다는 추론을 할 수 없습니다. 데이터가 없는 경우 빈 객체를 주거나, Optional로 담는게 Java에서 추구하는 이상향이잖아요? 리턴 타입으로 예측할 수 없는 null을 반환하는건 지양해야 합니다.

   아쉽게도 BaseRepository만으로 Optional로 담아 보낼 수 없었습니다. BaseRepositoryJPAQuery를 효과적으로 사용하기 위한 매퍼이기 때문이죠. 따라서 구현체에서 직접 Optional를 적용하는 방법 밖에 없습니다. 혹시라도 제가 코드를 제대로 못 읽은건가 싶었지만, JPAQuery에 깊게 관여하지 않는 이상 실마리를 찾을 수 없었습니다. 컨트리뷰트가 아닌 이상에야 라이브러리를 깊숙하게 컨트롤하는건 좋지 않기에 아쉬운 부분이었습니다. 보다 매끄러운 해결책을 찾는다면 공유드리고 싶네요.

마치며

   ORM은 장점만 가지고 있지 않습니다. 개발자가 편리해지는 만큼 잃어버리는 영역도 존재합니다. JPA는 겪어본 ORM 중에서도 상위 티어지만, 그 또한 QueryDSL과 같은 JPQL 빌더가 잘 만들어져있기에 가능한 이야기입니다. 그럼에도 불구하고 여전히 성능이 강조되는 미세한 쿼리튜닝이나 복잡한 표현식에는 한계가 존재합니다. 이는 데이터베이스 스키마가 ORM에 걸맞은 형태가 아닐 가능성도 높지만, 단점임에는 분명합니다. DBA와 백앤드 엔지니어의 관점은 다르니까요.

   JPA는 단점을 극복하기 위해 native query로 조회 가능합니다. 경우에 따라 JPA specification로 일부 동적 쿼리를 만들 수도 있어요. 하지만 아쉽게도 실무에서 사용하기엔 다소 부족한 기능입니다. 복잡한 쿼리는 수용할 수 없습니다. QueryDSL도 JPQL에 의존하기에 한계가 명확합니다. 그렇기에 SQL Mapper인 MyBatis와 혼용은 납득이 가능합니다.

   Read가 복잡한 도메인은 이 두 가지를 차용하여 CQRS로 방법을 모색할 수 있겠죠. ORM의 장점을 극대화할 수 있는 CUD와 단점으로 지적받는 Read를 분리하는 겁니다. 개발 속도는 SQL Mapper보다 JPA가 더 뛰어나기에 ORM을 포기할 수는 없습니다. 규모가 크지 않다면 CQRS와 Event driven처럼 거창한 방식이 아니더라도 얼마든지 해법은 존재합니다.

   우리는 상황에 따라 필요한 기술을 익히고, 적시적소에 어울리는 기술을 사용할 줄 알아야 합니다. 이번에는 'JPA와 QueryDSL를 실무에서 보다 효율적으로 사용할 수 없을까?'라는 고민에서 시작하였지만, 시간이 흐르면 언젠가 이 코드도 더이상 유의미해지지 않겠죠. 그 때는 무얼 공부하고 있을까요? 더 나은 기술을 실무에 적용하기 위한 고민은 개발을 그만둘 때까지 멈출 수 없을 겁니다.

Author

Devellany

Devellany

back-end Developer

PHP, Java, JavaScript, MySQL, Redis, Ubuntu, Nginx
Codeigniter, Laravel, Zend, Phalcon, Spring Boot, JPA
PHPStorm, IntelliJ, Upsource, SVN, Git, Telegram Bot

로그인

디코에 오신 것을 환영해요!
전문가들의 수많은 아티클 창고 🤓