티스토리 뷰

 

스프링 프로젝트에서 특히 관계형 DB를 사용하는 경우, 일반적으로 스프링 데이터 JPA + Querydsl의 조합을 사용한다. 사실 자바에서 관계형 데이터베이스와의 연결을 위해서는 JDBC(자바 프로그램 안에서 SQL을 실행하기 위해 데이터베이스를 연결해주는 응용프로그램 인터페이스)가 필요한데, 왜 JDBC가 아닌 JPA를 사용는지, 또한 Querydsl을 함께 사용했을 때의 이점은 무엇인지 간단히 알아보고자 한다. 단순히 Querydsl 설정이 궁금하신 분들을 스킵하고 링크로 바로 넘어가시길...

 

 

 

What is ORM?

ORM은 Object-Relational Mapping의 약어로, 말 그대로 '객체지향 패러다임을 사용해서 데이터베이스로부터 조회된 데이터를 조작할 수 있도록 하는 기술'을 의미한다. 쉽게 말해 데이터베이스에 대한 쿼리를 SQL이 아닌 객체를 조작함으로써 수행할 수 있는 것이다. 따라서 개발자는 직접 SQL을 작성할 필요없이 서비스 로직 개발에만 집중할 수 있다. 

 

뿐만 아니라 데이터베이스의 조회 결과를 특정 클래스 타입의 객체로 알아서 매핑해주어 다음과 같이 ResultSet 객체로부터 일일이 결과 데이터를 받아와 객체를 생성해줄 필요가 없다. 

public class User {

    private Long userId;
    private String name;

    public User(Long userId, String name) {
        this.userId = userId;
        this.name = name;
    }
}

//myDB 데이터베이스에 대한 연결 획득 
String sql = "SELECT * FROM user";
try (Connection con = DriverManager.getConnection("jdbc:mysql://localhost:3306/myDB", "user", "password");
     Statement stmt = con.createStatement()) {
    try (ResultSet rs = stmt.executeQuery(sql)) {
        List<User> users = new ArrayList<>();
        while (rs.next()) {
            long userId = rs.getLong("user_id");
            String name = rs.getString("name");
            users.add(new User(userId, name));
        }
    }
}
catch (SQLException e) {}

 

참고로 위 코드는 JDBC API를 이용해 데이터베이스에 쿼리를 보내고 결과 데이터를 받아와 User 객체를 직접 생성한다. 한눈에 보기에도 Connection 획득, Statement 준비 및 실행, ResultSet에 대한 루프 등 쓸데없는 반복 코드가 많다. 이러한 이유로 당연히 요즘에는 쌩 JDBC를 사용 경우는 없다고 한다. 따라서 JDBC를 편리하게 사용할 수 있도록 도와주는 스프링 JdbcTemplate이나 동적 쿼리를 편리하게 작성하기 위한 MyBatis를 사용할 수 있다. 

 

 

JdbcTemplate이나 MyBatis 같은 SQL Mapper 기술은 JDBC 보다는 편리하지만 그럼에도 불구하고 SQL을 개발자가 직접 작성해야 한다. 그러나 JPA는 자동으로 SQL을 생성해 처리해주어 개발자는 각 데이터베이스 별로 달라지는 SQL 문법을 신경쓰지 않아도 된다. JPA(Java Persistence API)는 자바의 ORM 기술 표준으로 보통 구현체로 Hibernate를 사용한다. 이 JPA는 애플리케이션과 JDBC 사이에서 동작하여 데이터베이스 종류와 상관없이 공통된 인터페이스를 제공할 수 있는 것이다. 

 

아래 코드에서 볼 수 있듯이 SQL 문법을 전혀 사용하지 않았음에도 DB에 쿼리를 보내고 결과 데이터도 알아서 User 객체에 매핑되었다. 이렇듯 반복적인 코드를 줄여 코드 가독성이 향상되는 것 외에도 JPA 기술의 이점이 많아 스프링 프로젝트에서 거의 필수적으로 사용된다. 

@Entity
static class User {

    @Id
    @Column(name = "user_id")
    private Long userId; //PK

    private String name;

    public User(Long userId, String name) {
        this.userId = userId;
        this.name = name;
    }
}

//EntityManager(em 객체) 주입
User user = em.find(User.class, 1L); //SELECT 쿼리 발생
em.persist(new User(2L, "Amy"));
em.remove(user);
em.flush(); //영속성 컨텍스트의 변경 내용을 DB에 반영 → INSERT, DELETE 쿼리 발생

 

 

 

Type-safe ORM 기술 Querydsl

기존 JPQL의 문제점 

그러다고 JPA가 SQL을 전혀 사용하지 않는 것은 아니다. 위 예제에서 보듯이 간단한 쿼리는 SQL 없이 EntityManager의 API를 통해 간단하게 처리할 수 있지만, 여러 데이터를 복잡한 조건으로 조회하고자 하는 경우에는 SQL과 같은 쿼리 언어가 필요하다. 따라서 JPA는 SQL과 거의 비슷한 문법을 가진 JPQL을 제공하는데, JPQL(Jakarta Persistence Query Language)은 간단히 말해 SQL을 추상화한 객체 지향 쿼리 언어로 테이블이 아닌 객체를 대상으로 쿼리를 수행하는 언어이다. (SQL은 데이터베이스 테이블을 대상으로 쿼리) 따라서 JPQL은 SQL과 달리 데이터베이스에 종속적이지 않고 공통된 문법을 제공한다. 

 

 

아래 코드는 JPA에서 JPQL을 사용하는 코드 예제이다. 여기서 문제점은 JPQL은 문자열 형태라는 것이다. 즉 문법을 잘못 작성하거나 대상 클래스 타입의 이름을 잘못 지정한다면 컴파일 오류가 아닌 치명적인 런타임 오류가 발생한다. 즉 서버가 실행되고 사용자가 해당 로직을 동작시켰을 때에야 에러가 발생한다는 것이다. 

List<User> users = em.createQuery("select u from User u", User.class).getResultList();

//런타임 오류 발생❗
List<User> users = em.createQuery("select u from Use u", User.class).getResultList();
//런타임 오류 발생❗
List<User> users = em.createQuery("selec u from User u", User.class).getResultList();

 

또한 동적으로 JPQL을 생성하는 경우에는 문자열 더하기 연산으로 쿼리를 생성해야 하며 이 과정에서 띄어쓰기 등을 잘못 작성한 경우에도 런타임 오류가 발생할 수 있다. 정리하자면 기존의 JPQL은 문자열이기 때문에 문법 오류 등을 컴파일 시점에 체크할 수 없어 type-safe한 쿼리가 불가능하며, 동적 쿼리를 생성하는 것 또한 번거롭다.

*type-safe: 쿼리를 모두 자바 코드로 작성할 수 있어 컴파일 시점에 문법 오류 등을 검사할 수 있는 것

 

 

대안으로서의 Criteria Query API

따라서 JPA는 type-safe 쿼리와 동적 쿼리를 편리하게? 작성할 수 있도록 Critieria Query API를 별도로 제공하고 있다. Criteria는 문자열이 아닌 자바 코드로 JPQL을 작성하는 일종의 JPQL 빌더로, 자바 코드로 쿼리문을 작성하여 컴파일 시점에 문법 오류를 찾아주고 동적 쿼리도 메소드의 인자로 변수값을 전달함으로써 보다 편리하게 작성할 수 있다. 또한 metamodel 클래스라는 것을 이용해 type-safe 쿼리도 작성 가능하다. 그러나 아래 코드를 통해 알 수 있듯이 이 Criteria는 가독성이 아주아주 떨어져 사용하기가 매우 어렵다. 아래의 아주 복잡한 코드는 단순히 SELECT * FROM user 쿼리를 생성할 뿐이다.

//Criteria 사용 준비
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);

//루트 클래스 (조회할 클래스)
Root<User> user = cq.from(User.class);

//쿼리 생성
cq.select(user);
TypedQuery<User> q = em.createQuery(cq);
List<User> users = q.getResultList();

 

이 Criteria의 기능(type-safe 쿼리와 동적 쿼리를 자바 코드로 작성)을 포함하면서 더불어 좋은 가독성도 제공하는 기술이 바로 Querydsl인 것이다.

 

 

Type-safe 쿼리가 가능한 Querydsl

Querydsl을 사용하면 type-safe 쿼리를 작성할 수 있어 잘못된 쿼리 문법으로 인한 런타임 오류가 발생하지 않는다. 만약 잘못된 코드를 작성하면 컴파일 오류가 발생하여 서비스 배포 전 이를 개발자가 빨리 인지할 수 있다. 그러나 Querydsl은 스프링이 공식적으로 지원하고 있는 기술은 아니기 때문에 설정 방법이 약간 까다롭다. 특히 스프링 부트 버전이 2.x → 3.x로 변경되면서 기존 설정 방법이 조금 변경되었다. 

 

사실 Querydsl을 스프링 부트에서 설정하는 방법만 바로 제시하면 되었지만, 그래도 최소한 왜, 어떤 이유로 Querydsl을 사용해야 하는지 이유를 알면 더 좋을 것 같아 여담을 추가해보았다. 자 그럼 본격적으로 스프링 부트에서 Querydsl을 사용해보자.

 

 

 

Spring Boot 3.x에서 Querydsl 설정 

  • OS: Windows 11
  • IDE: IntelliJ
  • Jdk: openjdk 17
  • Gradle 언어: Groovy

build.gradle 의존성 추가 

//Spring Boot 2.x
plugins {
    id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10' //Querydsl 플러그인
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
}

dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0'
}

def querydslDir = '$buildDir/generated/querydsl' //Q 타입 생성 경로
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
//Q 타입을 생성해주는 task
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

Spring Boot 2.x에서 자바 관련 API를 제공하는 Java EE(패키지명 javax.*) 환경에서 동작하는 querydsl 라이브러리(ex. javax.persistence 패키지 아래에 있는 @Entity 어노테이션을 처리)에는 컴파일 시 Q 타입을 생성해주는 task가 포함되어 있지 않다. (Q 타입은 쿼리 생성 시 필요한 .java 소스 파일로 뒤에서 자세히 알아보자.) 따라서 위와 같이 com.ewerk.gradle.plugins.querydsl 플러그인을 사용해 추가적인 설정을 해줘야 했다. 

 

//Spring Boot 3.x
dependencies {
    //...
    
    //Querydsl
    implementation 'com.querydsl:querydsl-jpa:${dependencyManagement.importedProperties['querydsl.version']}:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}

반면 Spring Boot 3.x부터는 Java EE가 아닌 Jakarta EE(패키지명 jakarta.*)만 사용할 수 있는데, 이 Jakarta에서 제공되는 querydsl 라이브러리는 compileJava task를 실행하면 Q 타입을 자동으로 생성해주어 2.x 버전에서 사용해야 했던 플러그인이 필요없게 되었다. 따라서 단순히 위의 의존성만을 추가해주면 스프링 프로젝트를 build 하고 IDE에서 실행하는데에는 전혀 문제가 없다. (참고로 스프링의 Dependency Management 플러그인이 현재 프로젝트와 호환되는 Querydsl 버전을 자동으로 지정해주도록 ${dependencyManagement.importedProperties['querydsl.version']} 이렇게 작성해주었다. 이 부분을 가장 최신 버전인 5.0.0으로 대체해도 된다.)

 

 

 

컴파일 시 생성되는 Q 타입 경로 지정 

이렇게 의존성을 추가해주면 기본적으로 build/generated/sources/annotationProcessor/java/main 패키지 아래에 Q 타입들이 생성되어 있다. 이때 Q 타입은 JPA의 @Entity 엔티티 클래스와 연관된 클래스로, Q- + 엔티티 클래스 이름으로 컴파일 시점에 자동 생성되며 엔티티 클래스의 모든 필드들은 Q 타입의 Path 필드와 대응된다. 즉 생성된 Q 타입 소스 파일을 보면 엔티티 클래스의 필드와 이름이 같으면서 StringPath, NumberPath<Long>, DateTimePath<java.time.LocalDateTime> 타입인 필드들이 포함되어 있는 것을 확인할 수있다. 이 Q 타입의 필드들은 이후 Querydsl API로 쿼리를 생성하는데 사용된다.

 

Q 타입 자동 생성 경로

 

 

그러나 이 경우 생성된 Q 타입의 소스 파일을 IntelliJ가 인식하지 못하여 다음과 같이 빨간 줄이 생길 수 있다. 이 경우 2가지 해결 방법이 존재하는데, 먼저 IntelliJ가 해당 소스 파일을 인식할 수 있도록 설정을 추가해주거나, 어플리케이션을 실행하여 src/main/generated 경로에(IntelliJ가 인식할 수 있는 경로에) Q 타입이 다시 생성되도록 하면 된다.

 

Q 타입의 소스 파일 인식 불가

 

 

첫번째 방법에서 IntelliJ의 설정은 File → Project Structure → Modules → 우측의 Add Content Root 메뉴에서 Q 타입이 생성되는 경로를 추가해주면 된다. 추가 후 IntelliJ가 build/generated/sources/annotationProcessor/java/main 경로의 Q 타입의 소스 파일을 인식해 더 이상 빨간 줄이 나타나지 않을 것이다. 그러나 이렇게 IntelliJ 설정을 추가하는 것은 개발자의 각 개발 환경마다 매번 설정해줘야 하는 번거로움이 있기 때문에 권장하지 않는다. 

 

IntelliJ Root Directory 설정

 

 

다음 방법인 Run Application 즉 main 메소드 내의 SpringApplication.run(...)을 실행시키면 src/main/generated 경로에 Q 타입이 자동 생성된다. 이 또한 사실은 IntelliJ에서 Annotation Processor의 생성 결과물을 해당 위치에 저장하기로 기본 설정되어 있기 때문이다. 참고로 이 설정은 File → Settings → Build, Execution, Deployment → Compiler → Annotation Processors에서 확인할 수 있다. 그러나 이 경우 서버를 실행한 후 엔티티 클래스를 변경하면 이 변경 내용이 반영된 Q 타입을 사용하기 위해서는 서버를 껐다 다시 켜야 한다는 문제점이 있다. 

 

Annotation Processors 설정
생성된 Q 타입 경로

 

예를 들어 스프링 어플리케이션을 실행시키고, 즉 WAS 서버를 킨 상태에서 기존 엔티티 클래스 코드를 수정한다고 가정해보자. 현재 생성되어 있는 Q 타입은 서버를 켰을 때 Annotation Processor가 동작하여 만들어 낸 것으로, 수정된 엔티티 클래스 코드가 반영된 Q 타입을 다시 생성하기 위해서는 어쩔 수 없이 서버를 다시 껐다가 켜야 한다. 

 

 

 

이러한 2가지 문제점 

1. IntelliJ가 Q 타입이 생성된 경로를 인식하지 못함 

2. 엔티티 클래스의 변경이 반영된 Q 타입을 생성하기 위해 서버를 껐다 켜야 함

을 해결하기 위해 build.gradle에 다음 설정을 추가해주면 된다. 

def querydslDir = 'src/main/generated'

//생성된 QClass gradle clean으로 제거
clean {
    delete file(querydslDir)
}
//QClass 생성 경로 변경
compileJava {
    options.generatedSourceOutputDirectory.set(file(querydslDir))
}

querydslDir 변수는 Q 타입이 생성될 경로로, 아래에서는 ./gradlew clean 명령어 실행 시 해당 경로에 생성된 Q 타입의 소스 파일들을 모두 삭제하도록 한다. 다음으로 compileJava task에서는 자바 컴파일 옵션인 generatedSourceOutputDirectory을 이용해 Annotation Processor에 의해 생성된 소스 파일들 즉 Q 타입이 저장될 경로를 지정하고 있다. (참고로 generatedSourceOutputDirectory 옵션의 기본값은 build/generated/sources/annotationProcessor/java/main이다. 따라서 Querydsl 의존성만을 추가해주었을 때 해당 경로에 Q 타입이 생성된 것이다.) 

 

Q 타입 지정 경로에 생성

 

 

이 경우에는 서버가 켜져 있는 상태에서도 compileJava를 실행함으로써 엔티티 클래스의 변경 사항이 포함된 Q 타입을 생성할 수 있다. 

 

서버를 끄지 않고 엔티티 클래스의 변경 사항이 포함된 Q 타입 생성

 

 

위 설정을 하게 되면 기존 build/generated/sources/annotationProcessor/java/main에는 Q 타입이 생성되지 않으며, src/main/generated에만 Q 타입이 생성되어 있다. 이 Q 타입은 컴파일 시점에 자동 생성되므로 Git과 같은 버전 관리에 포함하지 않는 것이 좋다. 따라서 .gitignore에 다음 경로를 추가해주어야 한다.

src/main/generated/

 

 

 

쿼리 생성하기 위한 JPAQueryFactory 빈 등록 

Querydsl도 결국은 내부적으로 JPQL을 생성한다. 따라서 Querydsl로 쿼리를 생성하기 위해서는 JPAQueryFactory 객체가 필요한데, 이 객체가 하는 역할이 바로 체이닝으로 호출된 메소드들을 기반으로 JPQL을 생성하는 것이다. 이 객체는 멀티 쓰레드 환경에서 동시성 문제가 없기 때문에 요청마다 매번 JPAQueryFactory 객체를 생성할 필요 없이, 스프링 빈으로 등록 후 각 -Repository 클래스에서 주입 받아 사용하는 것이 좋다. 다음 코드를 통해 JPAQueryFactory 객체를 빈으로 등록해준다. 가독성과 유지보수를 위해 별도의 설정 클래스로 분리해주었다.

(참고로 JPAQueryFactory의 동시성 문제는 객체 생성 시 전달되는 EntityManager에 달려있다. 여기서 스프링이 주입해주는 EntityManger는 실제 동작 시점에 진짜 EntityManger를 찾아주는 프록시용 가짜 객체로, 이 가짜 객체는 실제 동작 시점에 트랜잭션 단위로 할당되는 실제 EntityManager(영속성 컨텍스트)를 사용한다.)

@Configuration
public class QuerydslConfiguration {

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

 

 

import static com.querydsl.exam.user.entity.QUser.user;

@RequiredArgsConstructor
public class UserRepository {

    private final JPAQueryFactory queryFactory;
    
    public List<User> findByName(String name) {
        return queryFactory.selectFrom(user)
                .where(user.name.eq(name))
                .orderBy(user.id.asc())
                .fetch();
    }
}

주입 받은 JPAQueryFactory 객체를 기준으로 selectFrom, where, orderBy와 같은 SQL 문법과 비슷하게 생긴 메소드를 호출하여 쿼리를 생성할 수 있다. 만약 쌩 JPQL을 작성할 때 slect 와 같이 잘못된 문법이 있다면 런타임 오류가 발생해서야 이를 인지할 수 있으며, 요청에 따라 달라지는 name 변수값도 "select u from User u where u.name = " + name + " order by u.id asc"와 같이 매번 문자열을 더하여 동적으로 쿼리를 생성해줘야 한다. 반면 Querydsl에서는 selectFrom 메소드가 아닌 selectfrom 메소드를 호출하면 컴파일 에러가 발생해 이를 빠르게 인지하여 코드를 수정할 수 있으며, name 변수값도 단순히 eq 메소드의 인자로 전달해주기만 하면 되기 때문에 이러한 동적 쿼리를 편리하게 작성할 수 있다. 또한 Q 타입을 이용해 필드명을 지정함으로써 type-safe한 쿼리도 작성할 수 있다. 

 

 

정리하자면 Querydsl 사용 시 위와 같이 SQL 문법이 메소드로 작성되고 Q 타입을 이용하기 때문에 쿼리 문법 오류로 인한 런타임 에러가 발생할 확률이 낮아지며, 동적 쿼리도 편하게 작성할 수 있다. 또한 기존 SQL 문법과 비슷하게 작성되어 가독성도 매우 좋다.

 

 

 

끝.

 

 

 

참고 

https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping

https://blog.naver.com/varkiry05/223060542644

https://www.baeldung.com/java-jdbc

https://www.inflearn.com/course/querydsl-%EC%8B%A4%EC%A0%84

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/10   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함