Development/썬카(SunCar) - 개인 프로젝트

[썬카/백엔드] QueryDSL 도입 및 설정

양선규 2025. 3. 8. 16:02
728x90
반응형

JPA는 기본적인 CRUD 메서드들을 다양하게 지원하지만, 테이블 간 조인이 복잡하거나 동적 쿼리가 필요한 경우엔 사용하기 어렵다. @Query나 JPQL을 이용해 쿼리를 작성하면 문자열 조합이 필요하기 때문에 오류 가능성이 높아진다. 따라서 안전한 쿼리를 만들기 위해 QueryDSL을 도입했다.

 

QueryDSL

- Spring Boot에서 타입 안전한 쿼리를 생성할 수 있게 해주는 프레임워크

- Java 코드를 사용하여 SQL, JPQL, MongoDB 쿼리 등 다양한 쿼리를 작성할 수 있게 함

- 특히 JPA와 함께 사용될 때 강력한 기능을 발휘

 

QueryDSL과 타입 안전성의 관계

- 타입 안전성 : 코드(쿼리)에서 사용되는 데이터가, 그 데이터에 적합한 타입으로만 보장하는 특성. (숫자는 숫자로, 문자열은 문자열로)

- @Query 또는 JPQL의 문자열 쿼리는 컴파일 시점에서 오류를 탐지하지 못하고 DB에서 실행될 때 문제가 발생한다.

- QueryDSL로 작성된 쿼리는 컴파일 시점에 오류를 탐지하므로, 런타임 오류를 크게 줄이고 쿼리에 대한 디버깅이 용이해진다.

- 필드명 오타, 데이터 타입 불일치, 존재하지 않는 필드 참조, 메서드 오류 등 다양한 오류를 컴파일 시점에 잡아낸다.

- QueryDSL은 이런 방식으로 타입 안전성을 보장할 수 있다.

 

QueryDSL - 필요성

- 동적 쿼리 : 런타임에 쿼리 조건이 결정되는 경우 ( 사용자 입력에 기반한 검색 등 )

- 타입 안전성 : 컴파일 시점에 오류를 잡아내 안전한 쿼리 작성 가능

- 복잡한 쿼리 : 다중 조인, 서브쿼리, 그룹화 등이 필요한 경우

- 가독성 : @Query나 JPQL보다 가독성이 좋음

- 보안 : 파라미터 바인딩이 자동으로 처리되어 SQL Injection 공격을 방지

 

 

 

 

 

1. QueryDSL 의존성 추가

dependencies {
	
    // 생략..

	// Querydsl
	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
	annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
	annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    
    // 생략..
}

// Querydsl 설정
def querydslDir = layout.buildDirectory.dir('generated/querydsl').get().asFile
querydslDir.mkdirs()

sourceSets {
	main.java.srcDirs += [ querydslDir ]
}

tasks.withType(JavaCompile) {
	options.getGeneratedSourceOutputDirectory().set(querydslDir)
}

clean {
	delete querydslDir
}

 

가장 먼저 build.gradle 파일에 QueryDSL 의존성을 추가해 준다.

 

2. QuerydslConfig 클래스 작성

package com.yangsunkue.suncar.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Querydsl 설정 클래스 입니다.
 *
 * EntityManager : JPA의 핵심 클래스, 엔티티 관리와 DB작업 담당
 * JPAQueryFactory : Querydsl 쿼리를 생성하는 핵심 클래스
 *
 * EntityManager를 주입받아(@PersistenceContext),
 * JPAQueryFactory를 빈으로 등록해두면(@Bean),
 * 다른 클래스들에서 @Autowired로 JPAQueryFactory를 주입받아 QueryDSL을 사용할 수 있다
 * 또는, 생성자 호출 시 자동 주입되도록 할 수도 있다
 */
@Configuration
public class QuerydslConfig {

    @PersistenceContext // JPA의 EntityManager를 주입받기 위한 표준 어노테이션이다.
    private EntityManager entityManager;

    // JPAQueryFactory 빈을 생성하여 스프링 컨테이너에 등록한다.
    // 이 빈은 다른 컴포넌트(빈)에서 QueryDSL을 사용할 때 주입받아 사용 가능하다.
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

QueryDSL configuration 클래스를 작성한다. 설정 클래스라는 것을 나타내기 위해 @Configuration 어노테이션을 달아둔다.

 

EntityManager는 JPA의 핵심 클래스로써, 엔티티 관리와 DB 작업을 담당하며,

JPAQueryFactory는 QueryDSL 쿼리를 생성하는 핵심 클래스로써, 빈으로 등록해두면 QueryDSL이 필요한 곳에서 주입받아 사용이 가능하다.

 

3. Querydsl4RepositorySupport 추상 클래스 작성

package com.yangsunkue.suncar.repository.support;

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;

/**
 * 모든 QueryDSL 리포지터리 공통 기능을 제공하는 베이스 클래스 입니다.
 *
 * domainClass : 어떤 엔티티를 다루는지 타입 정보를 받는다
 *
 * @Repository
 * - 스프링의 컴포넌트 스캔 대상이 된다(자동으로 빈으로 등록)
 * - 데이터 액세스 계층의 빈임을 명시
 * - 트랜잭션 처리가 필요한 빈임을 명시
 * - JPA 예외를 스프링의 DataAccessException으로 변환
 */
@Repository
public abstract class Querydsl4RepositorySupport {

    private final JPAQueryFactory queryFactory;

    public Querydsl4RepositorySupport(JPAQueryFactory queryFactory, Class<?> domainClass) {
        this.queryFactory = queryFactory;
    }

    protected JPAQueryFactory getQueryFactory() {
        return queryFactory;
    }
}

 

의존성 추가와 설정 클래스 작성을 완료했으니, 실제 QueryDSL 클래스를 작성한다.

JPAQueryFactory 객체를 리턴하는 메서드를 가지고 있다.

 

이 클래스는 QueryDSL 기능을 제공하는 베이스 클래스가 될 것이다. 직접 객체화되는 클래스가 아니기 때문에 추상 클래스로 작성한다.

 

4. Custom Repository 인터페이스 작성

package com.yangsunkue.suncar.repository.user;

/**
 * User 커스텀 리포지토리 구현을 위한 인터페이스 입니다.
 * Querydsl4RepositorySupport과 함께 상속받아 Querydsl을 사용할 수 있습니다.
 */
public interface UserRepositoryCustom {

    boolean existsByUserId(String userId);
    boolean existsByEmail(String email);
}

 

나는 User 엔티티를 다루는 커스텀 쿼리를 작성할 것이기 때문에, UserRepository 뒤에 Custom을 붙여 UserRepositoryCustom로 명명한 인터페이스를 작성한다.

 

여기에 본인이 작성할 쿼리 메서드를 선언만 해 두고, Impl 구현체에서 실제 쿼리를 작성한다.

 

사실 existsByUserId()와 existsByEmail()은 이미 JPA가 제공하는 메서드이긴 한데, 이거 구현할 당시엔 제공해주는 건지 몰라서 QueryDSL로 작성했다...

 

5. Custom Repository 인터페이스 구현체 작성

package com.yangsunkue.suncar.repository.user;

import com.querydsl.jpa.impl.JPAQueryFactory;
import com.yangsunkue.suncar.entity.user.User;
import com.yangsunkue.suncar.repository.support.Querydsl4RepositorySupport;
import org.springframework.stereotype.Repository;

import static com.yangsunkue.suncar.entity.user.QUser.user;

/**
 * User 커스텀 리포지토리 구현체입니다.
 */
@Repository
public class UserRepositoryCustomImpl extends Querydsl4RepositorySupport implements UserRepositoryCustom {

    /**
     * QuerydslConfig에서 JPAQueryFactory를 스프링 컨테이너에 빈으로써 등록해 두었다.
     * 스프링은 컨테이너에서 객체를 찾아, 생성자의 인자로 자동으로 전달한다.
     *
     * 이 클래스는 @Repository 어노테이션이 달려 있기 때문에 자동으로 빈으로 등록되고 인식되는 것이다.
     * 
     * Querydsl4RepositorySupport를 상속받아 QueryDSL의 타입 안전성과
     * 편리한 쿼리 작성 기능을 활용한다.
     */



    /**
     * 실제 리파지터리(CommentRepository)에서 Impl을 상속받고 사용할 때,
     * 이 생성자가 자동으로 호출되며 JPAQueryFactory에 대한 의존성 주입이 일어난다.
     */
    public UserRepositoryCustomImpl(JPAQueryFactory queryFactory) {
        super(queryFactory, User.class);
    }

    /**
     * 해당 userId를 가진 레코드가 있는지 찾습니다.
     */
    @Override
    public boolean existsByUserId(String userId) {
         return getQueryFactory()
                 .selectOne()
                 .from(user)
                 .where(user.userId.eq(userId))
                 .fetchFirst() != null;
    }

    /**
     * 해당 email을 가진 레코드가 있는지 찾습니다.
     */
    @Override
    public boolean existsByEmail(String email) {
        return getQueryFactory()
                .selectOne()
                .from(user)
                .where(user.email.eq(email))
                .fetchFirst() != null;
    }

}

 

추상 클래스로 작성되었던 Querydsl4RepositorySupport 클래스를 상속받아 QueryDSL 기능을 활용하고, UserRepositoryCustom 인터페이스를 implement하고 메서드를 오버라이딩 하여 쿼리를 작성한다.

 

여기서는 회원가입 시 중복되는 id와 이메일을 검사하기 위한 쿼리 메서드 2개를 작성했다. 다만 동적 쿼리는 작성하지 않았는데, 동적 쿼리의 예시는 이렇다.

 

QueryDSl을 활용한 동적 쿼리 예시

public List<Member> findMembers(String name, Integer age) {
    QMember member = QMember.member;
    
    BooleanBuilder builder = new BooleanBuilder();
    
    if (name != null) {
        builder.and(member.name.eq(name));
    }
    
    if (age != null) {
        builder.and(member.age.gt(age));
    }
    
    return queryFactory
        .selectFrom(member)
        .where(builder)
        .fetch();
}

 

QueryDSL을 사용하면, 이런 식으로 사용자 입력값에 영향을 받는 동적 쿼리 작성이 가능하다.

 

Member 엔티티를 찾는 메서드인데, name, age인자가 있냐/없냐 에 따라서 where절 조건이 추가되거나 사라진다.

name, age 인자 모두 넣지 않으면 where절 없이 모든 엔티티를 찾으며, 인자를 넣으면 해당 조건을 추가해 엔티티를 찾게 된다.

 

6. Main Repository에서 QueryDSL과 Custom Repository를 상속받아 사용하기

package com.yangsunkue.suncar.repository.user;

import com.yangsunkue.suncar.entity.user.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
}

 

마지막으로, 이렇게 메인 리포지토리에서 UserRepositoryCustom 인터페이스를 상속받아 비즈니스 로직에서 활용하면 된다.

 

UserRepositoryCustomImpl 구현체가 아닌, UserRepositoryCustom 인터페이스를 상속받는 이유

- JPA는 구현체 탐색 메커니즘과 명명 규칙에 따라서 인터페이스에 대한 구현체 클래스를 자동으로 찾는다.

- 명명 규칙 : 리포지토리 인터페이스 이름 + Impl

        ex) UserRepositoryCustom + Impl -> UserRepositoryCustomImpl

- 결과적으로 메인 리포지토리에서 커스텀 인터페이스의 메서드를 호출하면, Impl 구현체의 메서드가 호출되게 된다.

 

사용 예시

 

QueryDSL과 Custom Repository를 통해 작성된 쿼리 메서드는, 비즈니스 로직에서 이렇게 간단히 사용할 수 있다.

 

 

 

흐름 정리

1. build.gradle에 QueryDSL 의존성 추가

2. QueryDSl Config 클래스 작성

3. QueryDSL Base Class 작성

4. Custom Repository 인터페이스 작성, 쿼리 메서드 선언

5. Custom Repository 구현체 클래스 작성

6. Main Repository에서 Custom Repository 인터페이스를 상속 받고 비즈니스 로직에서 활용

 

==============

 

썬카 노션

https://lava-move-d1e.notion.site/SunCar-1a754e6b788180f598cdea3bfaff3139?pvs=4

 

 

깃허브

프론트 : https://github.com/SunCar-Project/suncar-frontend

백엔드 : https://github.com/SunCar-Project/suncar-backend

728x90
반응형