JPA의 시작부터 QueryDSL까지

이상민·2024년 10월 2일
4

JPA

  • Java에서 ORM(Object-Relational Mapping) 기술 표준으로 사용되는 인터페이스다. 객체와 데이터베이스 간의 매핑을 담당하며, 직접 SQL 쿼리를 작성하지 않고도 데이터베이스와 상호작용할 수 있다.

  • 쉽게 말해, 객체 지향적으로 데이터를 다루면서도, 데이터베이스에 있는 정보를 간편하게 가져오고 저장할 수 있게 도와주는 역할이다.

  • JPA를 사용하면 SQL을 몰라도 객체에 대해 CRUD(Create, Read, Update, Delete) 작업을 쉽게 수행할 수 있다.

ORM

  • ORM은 객체(Object)와 관계형 데이터베이스(Relational Database)의 데이터를 자동으로 매핑해주는 방법이다.

  • SQL 쿼리를 직접 작성하지 않아도, 코드 상에서 객체와 데이터베이스의 정보를 연동할 수 있다.

  • 예를 들어, Java 객체가 User라는 엔티티라면, 데이터베이스에서 그와 일치하는 users 테이블에 데이터를 저장하거나 조회하는 작업을 자동으로 수행할 수 있다.

  • 하지만 모든 상황에서 ORM이 완벽한 해답은 아니다. 복잡한 쿼리가 필요하거나 성능에 민감한 작업에서는 직접 SQL을 작성하는 것이 더 효율적일 수 있다.

Hibernate

  • 자바에서 가장 많이 사용되는 ORM 프레임워크이며, JPA의 구현체다.

  • Hibernate를 사용하면 JPA 표준을 따르면서도, 더 많은 기능을 사용할 수 있다.

  • 내부적으로 JDBC API를 사용해 데이터베이스와 상호작용하며, JPA 인터페이스를 구현하여 객체 지향적으로 데이터를 다룰 수 있다.

  • 쉽게 말해, Hibernate는 JPA의 실제 동작을 구현한 도구라고 할 수 있다.

Spring Data JPA

  • Spring Data JPA는 JPA를 더 쉽게 사용할 수 있도록 추상화한 Spring의 모듈이다.

  • 복잡한 SQL 쿼리를 작성하지 않고도 기본적인 CRUD 작업을 처리할 수 있는 인터페이스(JpaRepository)를 제공한다.

  • JpaRepository를 사용하면 개발자는 데이터베이스 연동 코드에 대해 신경 쓰지 않고 비즈니스 로직에 집중할 수 있다.

  • 예를 들어, findAll(), findById(), save() 같은 메서드를 자동으로 제공해주기 때문에, 기본적인 데이터베이스 연산을 더 간편하게 처리할 수 있다.

Low-Level JPA

Entity LifeCycle

  • 엔티티 객체는 생성, 관리, 소멸되기까지의 전체 과정에서 데이터베이스와 상호작용한다.

  • 예를 들어, 사용자가 새로운 User 객체를 생성하면, 해당 객체는 JPA 영속성 컨텍스트에 추가되고(persist), 그 후 JPA가 해당 객체의 상태를 관리한다.

  • 이를 통해 개발자는 객체의 상태에 집중할 수 있고, JPA는 자동으로 데이터베이스와의 상호작용을 처리해준다.

  • 쉽게 말해, 개발자는 도서관에 새로운 책을 추가하고 관리하는 일만 신경 쓰면, 책을 어디에 놓을지나 어떻게 기록할지는 도서관 시스템이 알아서 처리하는 것과 비슷하다.

Entity Manager

  • Entity Manager Factory: JPA에서 EntityManager를 생성하고 관리하는 팩토리 객체다. 애플리케이션이 시작될 때 한 번만 생성되며, 전체 애플리케이션에서 공유된다.

  • Entity Manager: 데이터베이스와 상호작용하는 핵심 객체다. 엔티티의 생명 주기를 관리하고, CRUD 작업을 처리한다. 하지만 EntityManager는 thread-safe하지 않기 때문에 여러 스레드에서 공유하면 안 된다.

  • 쉽게 말해, EntityManager는 데이터를 관리하고 데이터베이스와의 소통을 담당하는 관리자 역할을 한다.

thread-safe: 여러 스레드가 동시에 객체나 메서드에 접근할 때, 데이터의 일관성을 보장할 수 있는 상태

N+1 Problem

  • N+1 문제는 연관된 엔티티를 조회할 때 발생하는 성능 문제다. 예를 들어, 부모 엔티티를 조회할 때마다 자식 엔티티를 조회하는 N개의 추가 쿼리가 발생한다.

  • 쉽게 말해, 부모 엔티티를 한 번 조회했는데, 자식 엔티티를 조회하기 위해 추가로 N개의 쿼리가 발생해 성능에 큰 영향을 미치게 된다.

  • 데이터가 많아질수록 N이 커지고, 이는 결국 애플리케이션 성능 저하로 이어진다.

Solution:

Fetch Join

  • Fetch Join은 연관된 엔티티를 한 번에 조회할 수 있도록 지원하는 JPQL 성능 최적화 기능이다.

  • JOIN FETCH를 사용해 부모와 자식 데이터를 동시에 가져올 수 있다. 이를 통해 N+1 문제를 해결할 수 있다.

  • 하지만 MultipleBagFetchException이라는 예외가 발생할 수 있다. 이는 여러 개의 ToMany 관계에서 Fetch Join을 사용하려 할 때 발생하며, ToOne 관계는 여러 개의 Fetch Join이 가능하지만, ToMany 관계는 1개만 가능하다.

@Query("SELECT t FROM Todo t JOIN FETCH t.managers")
List<Todo> findAll();

Batch Size

  • Batch Size는 in절을 사용하여 한 번에 여러 데이터를 조회할 수 있는 기능이다.

  • N+1 문제를 해결하는 또 다른 방법으로, 부모 키를 사용해 다수의 데이터를 한 번에 조회할 수 있다.

  • MultipleBagFetchException을 해결하는 방법 중 하나이며, fetch join과 함께 사용하면 성능 최적화에 도움이 된다.

public class Todo {
    @OneToMany(mappedBy = "todo")
    @BatchSize(size = 10)
    private List<Comment> comments = new ArrayList<>();
}

QueryDSL

  • QueryDSL은 Java에서 동적 쿼리를 타입 안전하게 작성할 수 있는 라이브러리다.

  • 기존 JPQL과 달리, QueryDSL은 코드 기반으로 쿼리를 작성할 수 있어 가독성과 유지보수성이 높다.

  • 복잡한 SQL 쿼리를 간결하게 작성할 수 있으며, 컴파일 시점에 오류를 발견할 수 있어 더욱 안전하다.

Config

  • QueryDSL을 사용하기 위해서는 build.gradle 파일에 다음과 같이 설정을 추가해야 한다.
dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
}
  • QueryDslConfig.java 파일에서 JPAQueryFactory를 빈으로 등록해야 한다.
@Configuration
class QueryDslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(JPQLTemplates.DEFAULT, em);
    }
}

QClass

QueryDSL 설정 후 gradle build를 실행하면, 엔티티 클래스의 메타 정보를 담은 QClass가 자동으로 생성된다.

  • QClass는 JPQL과 달리 타입 안전한 쿼리 작성을 가능하게 하며, 컴파일 시점에 쿼리 오류를 확인할 수 있게 도와준다.

  • 쉽게 말해, QClass는 엔티티 클래스의 쿼리 버전이라고 생각하면 된다.

BooleanExpression

  • QueryDSL에서 where 조건을 생성할 때 사용하는 클래스다.

  • BooleanExpression을 사용하면 조건을 조합하고 관리하기 쉬워진다.

@Override
public List<Todo> findById(long todoId) {
    return q
            .select(todo)
            .from(todo)
            .where(todoIdEq(todoId))
            .fetch();
}

private BooleanExpression todoIdEq(Long todoId) {
    return todoId != null ? todo.id.eq(todoId) : null;
}

Projections

  • Projections는 데이터베이스에서 필요한 필드만 선택적으로 가져올 수 있도록 도와주는 기능이다.

  • 엔티티 전체를 가져오지 않고 필요한 부분만 조회함으로써 불필요한 메모리 사용을 줄이고, 성능을 최적화할 수 있다.

Projections 사용법

1. bean: 필드명이 일치해야 하며 setter로 값을 주입한다.

2. field: setter없이 필드명을 통해 값을 주입한다.

3. constructor: 생성자를 통해 값을 주입하며, 필드 순서에 맞게 바인딩된다.

@Override
public TodoProjectionDto findByIdFromProjection(long todoId) {
    return q
        .select(Projections.constructor(TodoProjectionDto.class, todo.title, todo.contents, max(todo.id)))
        .from(todo)
        .where(todoIdEq(todoId))
        .fetchOne();
}

@QueryProjection

  • QueryDSL에서 DTO 클래스를 Projection할 때, @QueryProjection 어노테이션을 사용하면 컴파일 시점에 QClass가 생성되어 오류를 미리 확인할 수 있다.

  • 이를 통해 더 안정적인 쿼리 작성을 보장받을 수 있으며, QueryDSL을 사용할 때 성능과 타입 안정성을 더욱 강화할 수 있다.

@Override
public TodoProjectionDto findByIdFromProjection(long todoId) {
    return q
        .select(
            new QTodoProjectionDto(
                todo.title,
                todo.contents,
                max(todo.id)
            )
        )
        .from(todo)
        .where(
            todoIdEq(todoId)
        ).fetchOne();
}

@QueryProjection
public TodoProjectionDto(String title, String contents, Long max) {
    this.title = title;
    this.contents = contents;
    this.max = max;
}

마무리

QueryDSL은 단순히 동적 쿼리를 안전하게 작성하는 데 도움을 주는 도구일 뿐이다. 하지만 이를 잘 활용하려면 JPA의 작동 원리를 정확히 이해하고 있어야 한다. 특히 JPA는 성능 최적화를 고려하지 않으면 쉽게 N+1 문제나 성능 저하를 일으킬 수 있다. 따라서 JPA의 기본적인 동작 원리를 이해하고, Fetch Join이나 Batch Size와 같은 최적화 방법을 적극 활용하는 것이 중요하다.

또한, 모든 상황에서 QueryDSL을 사용할 필요는 없다. 간단한 CRUD 작업이나 기본적인 조회 쿼리의 경우 Spring Data JPA가 제공하는 기능으로도 충분히 처리할 수 있다. 하지만 복잡한 동적 쿼리가 필요한 상황에서는 QueryDSL을 활용하면 가독성과 유지보수성을 높이고 성능 최적화에 도움을 줄 수 있다.

profile
안녕하세요

0개의 댓글