[F-Lab 모각코 챌린지 59일차] querydsl

부추·2023년 7월 29일
0

F-Lab 모각코 챌린지

목록 보기
59/66

1. QueryDSL?

# Spring Data JPA의 한계

자바 어플리케이션에서 DB에 접근할 수 있게 해주는 JDBC. JDBC를 사용해 JPA를 구현한 ORM 프레임워크인 Hibernate. 그것을 한 층 더 편리하게 쓰게 해주는 Spring Data JPA. 하나하나 SQL문을 작성할 필요 없이 일정한 규악에 따른 derivced query만 작성하면 알아서 원하는 DB 접근 쿼리문을 작성해주는데다가, object mapping까지 시켜주는 유용한 라이브러리.

.. 이지만 역시 한계는 존재한다.

1) 끝도 없이 길어지는 Derived Query

주인 - 애완동물 연관 관계에서, 특정 주인의 애완동물 중 특정 나이 이상의 애완동물을 최신 순으로 찾는 derived query이다.

public List<Pet> findByOwnerAndAgeGreaterThanEqualOrderByCreatedAtDesc(Person owner, int age);

동작이야 잘 하겠지만, 쿼리문이 너무 길고 가독성이 엉망이다.


2) 작성하기도, 읽기도 힘든 JPQL

@Query("select p from Pet p " +
        "where p.owner = :owner " +
        "and p.age > :age " +
        "order by p.createdAt desc")
    public List<Pet> findOwnerAgePet(@Param("owner") Person person,
                                      @Param("age") int age);                                      

위의 코드를 JPQL을 이용해서 작성했다. 그렇게 복잡한 쿼리도 아닌데 문자열이 길고, 읽기 그나마 쉬우라고 줄바꿈까지 했는데도 쿼리문이 한 눈에 들어오지 않는다. JAVA 언어가 아니라 JPQL 쿼리 스트링을 하나하나 작성하다 보니 의도치 않은 오타가 나기 쉬우며, 그를 발견하기 역시 매우 어렵다.


3) 동적인 쿼리 작성의 어려움

특정 유저가 팔로우하는 유저의 포스트를 최신순으로 가져와야 하는 상황이 있었다. 관련된 동적쿼리를 짜는게 머리아파서 stream을 이용해 [팔로잉 유저 목록을 가져온다 -> 목록 유저의 포스트를 전부 합친다 -> 최신순으로 정렬한다] .. 라는 끔찍한 코드를 짰다.

JPQL을 사용하면 다음과 같다. N+1 문제를 피하기 위한 join fetch 전략을 사용했다.

@Query("select l from FarmLog l " +
        "join fetch l.author a " +
        "where a in " +
        "(select f.followed from Follow f where f.following = :user) " +
        "or a = :user " +
        "order by l.createdAt desc")
List<FarmLog> getFollowingFarmLogs(@Param("user") User user);

만약? 저 6줄이 넘어가는 JPQL 쿼리에서 alias를 잘못 걸었거나 괄호를 빼먹었거나, 띄어쓰기 실수를 했다면? 프로젝트 단계에서는 각 repository에 정의된 쿼리만 수십개일 것이다. 하나하나 오타를 검수하고 쿼리를 작성하는데 시간이 굉장히 많이 들 것이다.


# QueryDsl : 자바 코드 기반으로 쿼리를 작성하게 해주는 프레임워크

간단하게 말하면 그렇다. hibernate에서 사용하는 쿼리문을 정적 타입으로 작성할 수 있게 해준다. 방금 전, 팔로잉하는 유저의 포스트를 보는 쿼리를 querydsl을 이용해서 작성하면 아래와 같다.

public List<FarmLog> findFollowingFarmLogs(User follower) {
    QFarmLog farmLog = QFarmLog.farmLog;
    QUser user = QUser.user;
    QFollow follow = QFollow.follow;
    QGood good = QGood.good;

    return jpaQueryFactory
            .selectFrom(farmLog)
            .leftJoin(farmLog.author).fetchJoin()
            .where(farmLog.author.in(
                    JPAExpressions.select(follow.followed)
                            .from(follow)
                            .where(follow.following.eq(follower))
            ).or(farmLog.author.eq(follower)))
            .orderBy(farmLog.createdAt.desc())
            .fetch();
    }

절대적인 길이만 놓고 보자면 코드가 약간 더 길어졌지만 앞선 예시들과는 다르게 querydsl을 이용한 쿼리는 자바 언어를 통해 작성되었다. "select from"이라는 문자열을 직접 작성하지 않고 jpaQueryFactory.selectFrom 이라는 method를 호출하여 프로그래밍 언어적으로 지원하는 방식을 사용한 것이다.

특정 클래스의 method를 쓴다는 점이 기존 @Query를 이용한 방법과 가장 큰 차이점이다. 그래서 querydsl의 장점은 JPQL 및 spring data jpa의 한계와 같다.

# queryDsl의 장점

자바 언어로 쿼리를 작성하여, 컴파일 때 쿼리의 문법적 오류나 오타를 확인할 수 있게 해준다.
동적인 쿼리 작성이 편하다.
쿼리가 method 자체로써 재사용하기 용이하다.
IDE의 자동완성 기능을 사용할 수 있어 생산성이 올라간다.



2. queryDsl 시작하기

0. 프로젝트 스펙

JAVA 17
gradle 7.6.1
Spring Boot 3.0.4
querydsl 5.0.0

1. build.gradle

기존 build.gradle에 추가해야 할 것들만 작성했다.

dependencies {
	// QueryDSL 관련 dependencies
	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"
}


// build.gradle 파일의 가장 밑에 아래 추가
def querydslDir = "$buildDir/generated/querydsl"

// build할 때 소스코드로 인식할 범위에 querydslDir추가
sourceSets {
	main.java.srcDirs += [ querydslDir ]
}

// compile 단계에서 Q클래스 생성 디렉토리 설정
tasks.withType(JavaCompile) {
	options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}

clean.doLast {
	file(querydslDir).deleteDir()
}

annotationProcessor 부분에서 deprecated 메세지가 뜰텐데, gradle 7에서 크게 문제가 되는 부분은 아니다.


2. Configuration

querydsl이 제공하는 queryFactory 빈을 사용하기 위해 configuration을 진행해야 한다.

package com.example.studyDB.config;

@Configuration
public class QueryDslConfig {
    @PersistenceContext
    private EntityManager entityManager;

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

내부적으로 하이버네이트의 엔티티 매니저를 사용한다. 빈으로 등록한 QueryFactory 객체는 뒤에 작성할 custom repository에서 실제 쿼리문을 작성하는데 쓰인다.

사실 필요할 때마다 각각의 method에서 JPAQueryFactory 객체를 new로 만들어 사용할 수도 있지만.. 필요할 때마다 인스턴스를 하나씩 생성하는 것보다 이렇게 빈으로 등록해두고 Repository에서 필요할 때 꺼내쓰는 것이 훨씬 효율적이다.


3. Entity

한 개의 전공엔 여러 명의 사람들이 있고, 한 명의 사람에겐 여러 마리의 pet이 있는 관계다. 일대다 연관관계가 2개 있고, 모두 양방향 참조를 하도록 했다.

3번까지의 설정을 마치고 build를 진행하면..
소스코드의 main/generated 디렉토리 하위에 querydsl의 Q클래스들이 생성되었다. Q클래스(혹은 Q타입)란, querydsl을 이용해 type-safe한 쿼리를 작성할 때 사용되는 엔티티 클래스이다. 간단하게 querydsl 쿼리를 만드는데 사용되는 클래스라고 생각하면 된다.


intellij로 빌드하느냐, gradle로 빌드하느냐, 혹은 build.gradle의 querydslDir을 어디로 뒀느냐, 소스코드의 범위가 어디냐 등등의 build configuration에 따라 Q클래스의 생성 위치 및 감지 여부, build / out 디렉토리의 생성 여부 등등이 달라진다는데 개개인마다 로컬 세팅이 달라 천차만별로 다양한 문제가 생기는 듯 하다. 혹시 무언가 잘못됐다면 열심히 구글링 하자(...)


4. Repository

querydsl로 직접 작성할 custom query method를 모아둘 custom repository가 필요하다. 시그니처를 모아둘 ~RepositoryCustom 인터페이스들을 정의한다. 예시는 일단 Person entity만 작성했다. 특정 전공을 가진 사람들을 찾는 method를 만들고 싶었다.

public interface PersonRepositoryCustom {
    public List<Person> findByMajorQueryDsl(Major major);
}

이제 실제로 querydsl의 queryFactory를 이용해서 findByMajor를 구현해보자. PersonRepositoryCustom 인터페이스를 구현한 구현체에 해당 method를 작성할 것이다. querydsl의 구문 자체는 JPQL과 매우 유사하다. SQL문을 알면 querydsl의 코드를 작성하고 이해하는데 전혀 어려움이 없을 것이다.

@RequiredArgsConstructor
public class PersonRepositoryImpl implements PersonRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<Person> findByMajorQueryDsl(Major major) {
        QPerson person = QPerson.person;
        return jpaQueryFactory
                .selectFrom(person)
                .where(person.major.eq(major))
                .fetch();
    }
}

queryFactory에서 쿼리 대상으로 Q클래스 객체(Qperson)을 사용했다.

참고로! xxxRepository - xxxRepositoryCustom - xxxRepositoryImpl 이름을 맞춘 뒤 같은 패키지에 넣어야 querydsl의 property not found exception을 예방할 수 있다.

다음은 repository이다.

@Repository
public interface PersonRepository extends JpaRepository<Person, Long>, PersonRepositoryCustom {
    public List<Person> findByMajor(Major major);
    
    @Query("select p " +
            "from Person p " +
            "where p.major = :major")
    public List<Person> findByMajorJPQL(@Param("major") Major major);
}

PersonRepositoryCustom을 상속받도록 했다. 일단 컨트롤러나 서비스 단에서는 해당 PersonRepository를 이용할 것이기 때문에 필요한 method들은 전부 한 곳에 담아둔 것이다.

그리고 querydsl의 결과, Spring Data JPA의 derived query 결과, JPQL의 결과를 비교하기 위해 같은 기능을 하는 각각의 쿼리 메쏘드들을 작성했다.


5. 테스트

"컴퓨터공학과"에 "학생0"과 "학생1"

"간호학과"에 "학생2"와 "학생3"을 넣었다.

@SpringBootTest
class StudyDbApplicationTests {
	@Autowired
	private MajorRepository majorRepository;
	@Autowired
	private PersonRepository personRepository;
	@Autowired
	private JPAQueryFactory queryFactory;

	@BeforeEach
	@Transactional
	public void setDB() {
		List<Major> majors = new ArrayList<>();
		majors.add(Major.builder().name("컴퓨터공학과").build());
		majors.add(Major.builder().name("간호학과").build());
		majorRepository.saveAll(majors);

		List<Person> people = new ArrayList<>();
		for (int i = 0; i<4; i++) {
			people.add(
					Person.builder()
							.name("학생"+i)
							.major(majors.get(i/2))
							.build());
		}
		personRepository.saveAll(people);
	}

	@Test
	public void checkEquals() {
		Major major = majorRepository.findByName("컴퓨터공학과").orElseThrow();

		List<Person> derivedQuery = personRepository.findByMajor(major);
		List<Person> JPQL = personRepository.findByMajorJPQL(major);
		List<Person> queryDsl = personRepository.findByMajorQueryDsl(major);

		System.out.println("----------derived query");
		for (Person p : derivedQuery) {
			System.out.println(p.getName());
		}

		System.out.println("----------JPQL");
		for (Person p : JPQL) {
			System.out.println(p.getName());
		}

		System.out.println("----------queryDsl");
		for (Person p : queryDsl) {
			System.out.println(p.getName());
		}
	}
}

그리고 구성한 3개 쿼리의 결과물로 나온 학생의 이름을 출력해보았다.
깔끔! hibernate에서 나가는 쿼리문도 비교해보았다.
derived query, JPQL, querydsl로 작성한 3개의 쿼리가 똑같이 나갔다.


3. 마무리

쿼리에 붙은 조건이 많거나 복잡한 동적 쿼리가 필요할 때, spring data JPA가 제공하는 기본 derived query에는 표현상의 한계가 있다.

그때 JPQL이나 native query를 쓰게 되는데, 문자열로 쿼리를 작성하는 것은 오타가 쉽게 발생하고 가독성이 떨어진다는 큰 단점이 있다.

이때 queryDsl을 사용하면 좋다. queryDsl은 자바 언어로 쿼리를 작성할 수 있게 해주기 때문에, 컴파일 시점에 문법적 오류나 오타를 발견할 수 있다. 또한 IDE의 자동완성 기능을 사용할 수 있어 생산성 향상에도 도움을 준다.


REFERENCE

https://madplay.github.io/post/introduction-to-querydsl#querydsl-%EA%B4%80%EB%A0%A8-%EC%84%A4%EC%A0%95

https://tecoble.techcourse.co.kr/post/2021-08-08-basic-querydsl/

profile
부추튀김인지 부추전일지 모를 정도로 빠싹한 부추전을 먹을래

0개의 댓글