[TIP] JPA에서 동적 쿼리를 처리하는 방법

Sierra·2022년 11월 4일
1

Tip

목록 보기
1/2
post-thumbnail

Intro

JPA는 많이들 사용 해 보았을 것이라 생각한다. 백엔드 개발을 위해 JAVA를 자의든 타의든 사용했을 것이고 그 과정에서 직접 JDBC Connection 을 처리하는 경우도 있겠지만, 최근에는 거의 대부분은 JPA를 사용한다.

JPA를 처음 겪어보는 사람도 분명 있을것이다. JPA는 Java Persistence API로써 RDBMS를 관리할 수 있는 Java API다.

JDBC 객체를 생성해서 쿼리를 집어넣어 결과물을 가져오는 실습은 학부 시절에 많이 해 봤을 것이다. 실무에서는 소규모라면 모르겠지만, 지금까지 보아왔던 대부분의 서버 애플리케이션 코드들은 JPA를 사용했다. JDBC 객체를 직접 생성해서 지지고 볶는 것 까진 아니더라도 Mybatis 코드를 건드는 상황도 분명 생긴다.

이런 상황들이 반드시 나쁘단 것은 아니다. 한번 개발 해 둔 코드가 변화하지 않는다면 이러한 선택지라고 해서 반드시 나쁘다고 할 수는 없다.

하지만 코드의 품질은 갈 수록 변한다. 시간이 지날 수록 Deprecated된 코드들을 걷어내야 한다. ORM을 쓰는 이유가 뭘까? 날 쿼리와는 다르게 배우기 어렵지만, 객체 지향적인 코드로 데이터들을 처리할 수 있고 비즈니스 로직 그 자체에 집중하도록 도와준다. 중요한 건 우리가 개발하고자 하는 기능들이니까. 또한 재사용성과 유지보수가 편해진다. 최근에 필자는 이 유지보수의 문제로 인해 ORM을 사용하는 이유가 상당히 와 닿았다.

생각보다 기본적인 쿼리들에 대해서는 정형화 되어있는 편인데 JDBC 를 직접 쓰던 시절엔 CRUD 쿼리 조차도 직접 손으로 구현했어야 했다. 그게 나쁘단 것은 아니다. 당장 빠르게 개발할 때는 오히려 Mybatis를 쓰는 게 나을 수도 있다. 날 쿼리 그 자체를 어떻게든 집어넣으면 되니까. 문제는 엔티티에 변화가 생겼을 때 생긴다. 정말 처음에 정의 했던 테이블이 영원히 그대로일까? 쉽지 않다.

유지보수의 장점, 코드의 재사용성, DBMS에 대한 종속성에 대한 자유로움이란 여러 장점을 제외하고 ORM의 단점이 있다면, 동적 쿼리를 짜는 게 까다롭다는 것이다. 체감 상 ORM을 학습 한 노력 만큼 동적쿼리를 생성하는 법에 대해 연구해야 했다.

이번 글에서는 JPA를 사용할 때 동적 쿼리들을 어떻게 처리해야 하는 지에 대해서 내 경험을 정리 해 보도록 하겠다.

동적 쿼리?

상황에 따라 내용이 바뀌는 쿼리를 동적 쿼리라고 한다.

검색 조건이 여러개 있을 때, 예를 들어 검색 조건에 음식 종류, 나라 와 같은 것들이 있을 때 모든 조건들에 대한 값이 들어가야지만 검색을 할 수 있다면 누구도 그 서비스를 사용하지 않을 것이다. 누군가는 전 세계의 면 요리에 대해 알고 싶을테니까.

여러가지 방법들이 있다. Mybatis를 쓰던 상황에선 직접 If문을 집어넣어가며 쿼리들을 조작하였다. JDBC도 마찬가지. 하지만 이런 방법들은 앞서 말했듯 유지보수에 문제가 생길 수 있다. (잦은 if문은 읽기 좋은 코드는 아니지 않는가...당장 오늘도 소나큐브에게 한대 맞고왔다.)

기존에 JPA를 사용하는 상황을 가정해보면 과연 이러한 동적쿼리를 어떻게 짜야 하는 지 고민이 들 수 있다.

@Repository
public interface testRepository extends JpaRepository<testEntity, Long>{

}

이런 상황을 가정해보자. 여기서 쿼리 어노테이션으로 동적쿼리를 넣을 것인가? 불가능하다. Mybatis로 다시 빼자니 동적쿼리 하나 때문에 그러한 짓을 해야 하겠는가? 할 거면 아예 다 Mybatis로 하는게 더 좋은 방법 아닐까.

해결책은 필자의 머릿속엔 총 세가지가 있다. JPASpecification, JPQL, QueryDSL이다.
과거엔 주로 JPASpecification으로 해결했고 현재는 QueryDSL을 활용하고있다.

JPA Specification

따로 외부 라이브러리를 사용하지 않는 방법 중 하나다.

public interface testRepository extends JpaRepository<testEntity, Long>
, JpaSpecificationExecutor<testEntity> {
}

기존의 Repository 인터페이스에 JpaSpecificationExecutor를 상속하고 동적 쿼리를 사용할 수 있는 클래스를 따로 정의해주어야 한다.

public class testEntitySpecification {
	public static Specification<testEntity> equalTestEntityId(Long id){
    	return new Specification<testEntity>(){
        	@Override
            public Predicate toPredicate(Root<testEntity> root, CreteriaQuery<?> query, CriteriaBuilder criteriaBuilder){
            	return criteriaBuilder.equal(root.get("id"), id);
            }
        }
    }
}

현재 ID값으로 데이터를 가져오는 쿼리를 하나 추가 한 상태다. 검색조건이 늘어날수록 이러한 static 함수들을 추가해준다.

public class testEntitySpecification {
	public static Specification<testEntity> equalTestEntityId(Long id){
    	return new Specification<testEntity>(){
        	@Override
            public Predicate toPredicate(Root<testEntity> root, CreteriaQuery<?> query, CriteriaBuilder criteriaBuilder){
            	return criteriaBuilder.equal(root.get("id"), id);
            }
        }
    }
    public static Specification<testEntity> likeName(String name){
    	return new Specification<testEntity>(){
        	@Override
            public Predicate toPredicate(Root<testEntity> root, CreteriaQuery<?> query, CriteriaBuilder criteriaBuilder){
            	return criteriaBuilder.like(root.get("name"), "%" + name + "%");
            }
        }
    }
    public static Specification<testEntity> likeContent(String content){
    	return new Specification<testEntity>(){
        	@Override
            public Predicate toPredicate(Root<testEntity> root, CreteriaQuery<?> query, CriteriaBuilder criteriaBuilder){
            	return criteriaBuilder.like(root.get("content"), "%" + content + "%");
            }
        }
    }
}

조건을 두 가지 더 추가한 상황이다. 이렇게 생성한 Specification 객체를 아래와 같이 활용하면 된다.

public List<testEntity> searchTestEntityList(Long Id, String name, String content) {

    Specification<testEntity> spec = Specification.where(testEntitySpecification.equalTestEntityId(Id));
    if(name != null) {
    	spec = spec.and(testEntitySpecification.likeName(name));
    }
    if(content != null) {
    	spec = spec.and(testEntitySpecification.likeContent(content));
    }
    return testEntityRepository.findAll(spec);
}

이렇게 하면 찾고자 하는 조건에 따라, 또 조건의 유무에 따라 동적으로 쿼리가 처리되어 데이터가 전달 될 것이다.

JPA Specification은 외부 라이브러리를 활용하지 않는다면 상당히 효과적인 방법이 될 수 있다. 하지만 Specification 객체에 매번 조건에 따른 메소드들을 직접 생성해줘야 한다. 난이도 또한 있는 편이고 개인적인 의견이지만 아래에 소개 할 방법들에 비하면 직관적이지 못 하다는 단점이 있다고 생각한다.

이런 문제점들은 JOIN Query 들을 해결할 때 어려움을 겪게 만드는 원인이 될 수 있다고 본다. 사실 익숙해지면 뭘 쓰든 본인 맘대로지만, 타 개발자와 협업하는 입장이라면 모두가 편하게 쓸 수 있는 방법을 채택하는 게 팀의 생산성 향상에 기여할 수 있다.

물론 추후 소개 할 QueryDSL등의 라이브러리를 사용하지 못 하는 상황이라면 좋은 대안이 될 수 있다. 따로 셋업 할 필요는 없으니까. 즉 Dependency 문제와 같은 외부 라이브러리를 사용함에 따라 생기는 문제들에 대해 자유롭다. (Q객체 때문에 엔티티 변경 될 때마다 다시 빌드 할때마다 짜증나 죽겠다...)

JPQL

JPA를 사용 해 본 적이 있다면, 이미 JPQL을 한번은 사용 해 본 것이다. JPA는 다양한 방식의 쿼리를 지원하고 JPQL은 그 중 하나다.
객체 그 자체를 테이블이라 생각하는 대표적인 객체지향 쿼리다. 사실 JPA를 사용한다면 entity 객체에 Table이 할당되어 있다는 것을 알 수 있을 것이다. Table이라는 데이터의 특징과 객체의 특징이 비슷한 점을 활용 한 것이다.

JPQL은 현재 DB에 대한 지식이 없다는 가정하게 사용할 때 유리하다. Entity 객체 그 자체가 Table이고 우리는 DB 에 어떤 Table이 존재하는 지 일일히 분석 할 필요없이 객체들만 보고 쿼리를 짤 수 있다.

Jpa에서는 기본적으로 제공하는 다양한 Query Method들이 존재한다. Query Method들을 분석해서 JPA가 자동적으로 JPQL을 생성해서 쿼리를 실행하는 방식으로 데이터가 Select된다.

Optional<Entity> findByIdAndName();

위와 같은 Query Method가 있다면 JPQL로 표현하면 아래와 같다.

select e from Entity e where id = ? and name = ?

물론 이러한 JPQL 쿼리를 직접 @Query 어노테이션을 통해 선언 해 주어도 된다.
JPA에서는 이러한 JPQL 쿼리를 어떻게 처리할까? EntityManager를 활용한다.

여기서 EntityManager란? Entity 객체에 대해서는 여기까지 올 정도면 알 것이라 생각한다.(JPA를 쓰고있는데 모르면 안되겠죠?) Entity 객체를 관리하는 역할을 하는 클래스다. 평소에 잘 볼 일은 없지만 JPQL 쿼리를 직접 처리하려면 필요하다.

EntityManage 내부에는 Persistence Context 를 두어서 Entity 객체들을 관리한다. 영속성이라는 개념이 어려울 수 있는데 간단히 생각하면 그냥 EntityManager가 이 Entity에 대해 기억하고 있고 Entity 객체의 데이터가 불변한다는 것을 보장한다는 것이라 생각하면 편하다. (테이블의 컬럼이 변하지 않는다는 게 보장되지 않는다면 과연 믿고 데이터를 쌓을 수 있을까?) 해당 부분에 대한 글은 추후 더 자세하게 다루도록 하겠다.

우리가 직접 JPQL을 입력해서 데이터를 가져오려면 어떻게 해야 할까? 앞서 말했듯 @Query 어노테이션이라는 방법도 존재한다. 하지만 직접 EntityManager 객체에 쿼리를 집어넣고 데이터를 받아오는 방법도 존재한다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("db");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();

List<entity> result = 
em.createQuery("select e from entity e where e.id = '1'", entity.class).getResultList();

자세히 보면 JPQL 자체는 문자열이다. 즉 이 문자열을 조작함으로써 동적쿼리를 구현할 수 있다.

String jpqlQuery = "select e from entity e;

if(id != null){
	jpql += "where id = :id";
}

TypedQuery<entity> query = em.createQuery(jpqlQuery, entity.class).setMaxResults(1000);

if(id != null){
	query = query.setParameter("id", id);
}

List<entity> = query.getResultList();

이런 동적쿼리 구현방식은 직관적이지만 단점이 존재한다. JDBC로 쿼리를 짤 때와 큰 차이가 없는 문제점이지만, 결국 문자열 처리가 주가 되버린다.

이러한 문제를 해결하기 위해 Criteria 라는 게 또 존재한다. 공식적으로 javax.persistence.criteria에 존재하므로 특별히 셋업하지 않아도 사용할 수 있다. 문법 오류를 컴파일 단계에서 잡을 수 있다는 장점이 존재하고 JPQL 쿼리 문자열을 직접 조작하는 것 보다는 안전하게 쿼리를 작성할 수 있지만, 코드가 복잡하다는 단점이 존재한다.

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<entity> cq = cb.createQuery(entity.class);
Root<entity> m = cq.from(entity.class);

List<Predicate> criteria = new ArrayList<>();

if (id != null) {
	Predicate status = cb.equal(entity.get("id"), id);
    criteria.add(status);
}
//조건들 마다 추가

cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
TypedQuery<entity> query = em.createQuery(cq).setMaxResult(1000);
List<entity> result = query.getResultList();

사실 지금은 조건이 간단해서 그렇지 조건이 복잡해진다면 더욱 문제가 꼬일 수 있다. 컴파일 시점에서 쿼리의 문제를 찾을 수 있지만, 코드 자체가 이해하기 힘들다.

비슷한 기술로 QueryDSL이 존재한다.

QueryDSL

QueryDSL은 Criteria와 상당히 비슷하지만 훨씬 직관적이고 코드를 이해하기 쉽다는 장점이 존재한다. 단점은 공식적으로 Spring Framework에서 제공하는 게 아닌 외부 모듈이므로 셋업이 필요하다는 것이다. 또한 Entity 객체에 변경이 생겼을 때(Table 사양이 변경되었을 때) Q객체라는 Entity 객체에 할당 된 객체들에 대한 컴파일이 따로 이뤄져야 하므로 상당히 귀찮다는 것이다. 사실 뭐 Entity 객체도 마찬가지로 변경이 생겼을 때 그에 대한 대응을 해야 코드가 돌아가는 건 마찬가지다. QueryDSL을 쓰면 거기에 과정이 하나 더 붙는다고 생각하면 된다.

QEntity e = QEntity.entity;
List<Entity> result = queryFactory
	.select(e)
    .from(e)
    .where(e.id.like("12"))
    .fetch();

이렇게 queryFactory를 통해 직관적으로 쿼리를 짤 수도 있지만, 동적쿼리를 처리하는 데 직관적이라는 큰 장점을 가지기에 Criteria 보다는 실무에서 상당히 많이 사용한다.

보통 두 가지 방법으로 동적쿼리를 짜는데 아래와 같다.
1. BooleanBuilder

BooleanBuilder builder = new BooleanBuilder();
if(id != null){
	builder.and(entity.id.eq(id));
}
if(name != null){
	builder.and(entity.name.eq(name));
}

return queryFactory
	.selectFrom(entity)
    .where(builder)
    .fetch();
  1. BooleanExpression
    QueryDSL 은 null 값을 기본적으로 무시한다.
private List<entity> searchEntity(String id, String name){
	return queryFactory
    	.selectFrom(entity)
        .where(idEq(id), nameEq(name))
        .fetch();
}

private BooleanExpression idEq(String id){
	if(id == null){
    	return null;
    }
    return entity.id.eq(id);
}

private BooleanExpression nameEq(String name){
	if(name == null){
    	return null;
    }
    return entity.name.eq(name);
}

지금까지 본 다양한 동적쿼리 처리 법과 비교했을 때 앞서 말했듯 직관적이라는 장점을 가지는 이유를 알 수 있다. 상당히 심플하고 모두가 봐도 이해할 수 있다.

단, 처음에 셋업하는데 상당히 고생할 수 있다...상당히 많은 사람들이 실무에서 QueryDSL을 사용하지만 결국 외부 모듈이라는 한계가 존재한다는 단점이 있다.

Outro

지금까지 JPA에서 동적쿼리를 처리하는 방법들에 대해 필자의 경험을 섞어서 정리 해 보았다.
하나의 솔루션이 방법이 아니고 개발 환경, 협업 하는 환경, 기존의 코드들에 따라 다양한 방법들이 존재한다. 심지어 ORM을 긁어다 쓰는 게 반드시 명확히 정해 진 개발 방법만은 아니다. 하나의 방법만 아는 것 보다는 다양한 방법들을 알고 있다면 특정 솔루션을 사용할 수 없는 상황에 대처할 수 있지 않을까.

profile
블로그 이전합니다 : https://swj-techblog.vercel.app/

0개의 댓글