본문 바로가기

Spring

[Spring] Specification vs QueryDSL

 

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user WHERE t.id = :todoId")
    Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
}

JPA를 사용하여 명명 규칙만 지킨다면 자동으로 메서들이 정의되어 실행된다. 그러나 복잡한 조건의 경우에는 @Query를 사용하여 JPQL을 직접 작성해야한다.

 

위의 경우는 매개변수로 전달받은 todoId와 일치하는 Todo을 조회하는 쿼리문이다. 이때 연관된 user도 함께 조회한다.

 

이는 간단하고 직관적이지만, 컴파일 시점에 문법 오류를 잡기 어렵고 유지보수가 어렵다는 단점이 있다.

 

Specification

Specification는 별다른 의존성 추가 없이 JpaRepository와 함께 JpaSpecificationExecutor<T>를 상속함으로써 구현 할 수 있다. 이때, Specification의 메서드들은 별로의 클래스로 선언하여 관리하는 것이 일반적이다.

public interface LogRepository extends JpaRepository<Todo, Long>, JpaSpecificationExecutor<Todo> {}
public class TodoSpecification {
    public static Specification<Todo> findByIdWithUser(Long todoId) {
        return (root, query, builder) -> {
            if (Todo.class.equals(query.getResultType())) {
                root.fetch("user", JoinType.LEFT);
                query.distinct(true);
            }
            return builder.equal(root.get("id"), todoId);
        };
    }
}
//TodoService에서 사용
Specification<Todo> spec = TodoSpecification.findByIdWithUser(todoId);
Todo todo = todoRepository.findOne(spec);
이름 역할 설명
root FROM절 엔티티 루트 객체 FROM절에 해당하는 엔티티 객체로, 필드 접근과 조인에 사용
query 쿼리 전체 대표 객체 SELECT, DISTINCT, 정렬, 그룹핑 등 쿼리 전반을 조작 가능
builder WHERE절 생성 객체 WHERE 조건 생성, 비교·논리 연산 등을 담당
  • JPA Criteria API 기반
  • 코드로 조건 조립 가능해 유연
  • 문법이 다소 복잡하고 가독성 떨어질 수 있음

 

+) 동적 쿼리 조립

user를 검색할 때 id와 name을 받는다고 가정해보자. 이때, id와 name은 검색 조건에 있을 수도 있고 없을 수도 있다. 그러나, 모든 경우에서 정상적으로 검색이 되어야한다. 이럴때 필요한 것이 동적 쿼리이다.

public class UserSpecification {

    public static Specification<User> hasUserId(Long userId) {
        return (root, query, builder) -> 
            userId == null ? null : builder.equal(root.get("id"), userId);
    }

    public static Specification<User> hasUserName(String name) {
        return (root, query, builder) -> 
            name == null ? null : builder.equal(root.get("name"), name);
    }
}
// UserService에서 사용
Specification<User> spec = Specification.where(UserSpecification.hasUserId(userId))
                                        .and(UserSpecification.hasUserName(name));

List<User> users = userRepository.findAll(spec);

 

QueryDSL

1. build.gradle 의존성 추가

dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

 

2. JPAConfiguration 생성

JPAQueryFactory 에 entityManager 를 주입해서 Bean 으로 등록해줘야 합니다.

@Configuration
public class JPAConfiguration {

  @PersistenceContext
  private EntityManager entityManager;

  @Bean
  public JPAQueryFactory jpaQueryFactory() {
    return new JPAQueryFactory(entityManager);
  }
}

 

3. QueryDSL 작성

public interface TodoRepository extends JpaRepository<Todo, Long>, JpaRepositoryCustom {}
public interface JpaRepositoryCustom {
	Optional<Todo> findByIdWithUser(Long todoId);
}
@RequiredArgsConstructor
public class JpaRepositoryCustomImpl implements JpaRepositoryCustom{

	private final JPAQueryFactory jpaQueryFactory;
	
	@Override
	public Optional<Todo> findByIdWithUser(Long todoId) {
		QTodo todo = QTodo.todo;
		QUser user = QUser.user;
		
		Todo result = jpaQueryFactory
			.selectFrom(todo)
			.leftJoin(todo.user, user).fetchJoin()
			.where(todo.id.eq(todoId))
			.fetchOne();

		return Optional.ofNullable(result);
	}
}
  • Q클래스를 통한 타입 안전 쿼리
  • IDE 자동완성으로 편리하고, 복잡한 조건도 쉽게 작성 가능
  • 초기 설정 및 Q클래스 생성 필요

 

+) 동적 쿼리 조립

public class UserPredicate {

    public static BooleanExpression hasUserId(Long userId) {
        return userId == null ? null : QUser.user.id.eq(userId);
    }

    public static BooleanExpression hasUserName(String name) {
        return name == null ? null : QUser.user.name.eq(name);
    }
}
@RequiredArgsConstructor
public class UserRepositoryCustomImpl implements UserRepositoryCustom {

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<User> findByCondition(Long userId, String name) {
        QUser user = QUser.user;

        BooleanBuilder builder = new BooleanBuilder();
        builder.and(UserPredicate.hasUserId(userId));
        builder.and(UserPredicate.hasUserName(name));

        return jpaQueryFactory
            .selectFrom(user)
            .where(builder)
            .fetch();
    }
}

 

cf.)

더보기

✔️ Specification와 QueryDSL의 동적 쿼리 조립

Specificatin : 각 조건 메서드들을 Specification<T>형의 변수로 조립 후 리포지토리로 넘김

QueryDSL : 각 조건 메서드들을 BooleanBuilder형의 변술 조립하는 것을 리포지토리 안에서 수행

❓Specification처럼 QueryDSL도 서비스에서 builder을 만든 후 넘기면 안될까?

// UserService에서 사용
Specification<User> spec = Specification.where(UserSpecification.hasUserId(userId))
                                        .and(UserSpecification.hasUserName(name));

List<User> users = userRepository.findAll(spec);

위의 Specification처럼 QueryDSL도 서비스에서 조립한다면...

BooleanBuilder builder = new BooleanBuilder();
builder.and(UserPredicate.hasUserId(userId));
builder.and(UserPredicate.hasUserName(name));

public List<User> = userRepository.findByCondition(builder)

안된다. (가능은 하지만 권장 ❌)

 

왜냐하면 계층 분리 원칙 위반이기 때문이다.

  • BooleanBuilder는 QueryDSL 라이브러리에 종속 (QueryDSL만의 구현체)
  • Service가 QueryDSL에 직접 의존하면 기술 종속이 심해짐
  • Service → Repository 명확히 분리해야 유지보수 쉬움

 

❓ 그렇다면 Specification은 왜 넘겨도 될까?

  • Specification은 Spring Data JPA 공식 제공
  • Repository 인터페이스에 findAll(Specification spec) 명시적 제공
  • Service에서 Specification 조립해 Repository로 넘기는 게 공식 흐름
  • 기술적 의존은 있지만, 이미 Spring 생태계 내에서 안전하게 통합

 

@Query vs Specification vs QueryDSL 최종 비교

  장점 단점 특징
@Query (JPQL) 직관적, 간단한 쿼리 작성 가능 실행 시 오류 발견, 복잡한 쿼리 불편 소규모/단순 쿼리에 적합
Specification 동적 쿼리 조립, 재사용 용이 문법 복잡, Criteria API 가독성 낮음 조건 메서드 분리로 깔끔한 관리 가능
QueryDSL 타입 안전, IDE 자동완성, 동적 쿼리 쉬움 초기 설정 필요, Q클래스 생성 필요 실무에서 가장 많이 사용, 유지보수 편리