Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
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
Tags
more
Archives
Today
Total
관리 메뉴

이지은님의 블로그

250313 - Java Spring N+1 문제와 해결, QueryDSL의 사용 방법(JOIN FETCH, Entity Graphs, QueryDSL, QClass) 본문

TIL

250313 - Java Spring N+1 문제와 해결, QueryDSL의 사용 방법(JOIN FETCH, Entity Graphs, QueryDSL, QClass)

queenriwon3 2025. 3. 13. 17:37

 오늘 배운 

과제에서 사용된 N+1문제와 그 해결방법들을 비교정리하고, QueryDSL 사용방법에 대해 정리해보고자 한다.

 

<<목차>>

1. N+1 문제

    1) N+1 문제 정의

    2) N+1 문제 해결방법

    3) N+1 문제 해결 비교

2. Query DSL

3. Query DSL 적용

    1) 의존성 추가

    2) QClass

    3) JPAQueryFactory를 Bean으로 등록

    4) Repository에서 사용하기

    5) Query DSL 문법

 

 


1. N+1 문제

1) N+1 문제 정의

N+1 문제는 ORM(Object-Relational Mapping)을 사용할 때 발생하는 퍼포먼스 문제

하나의 쿼리로 N개의 객체를 로딩한 후, 각 객체에 연관된 데이터를 추가로 조회하는 개별 쿼리가 N번 실행되면서 총 N+1번의 쿼리가 발생하는 문제

성능이점을 가져가기 위해 필요한 데이터만 가져오는 FetchType.LAZY로 설정된 경우에 주로 발생한다.

 

 

2) N+1 문제 해결방법

5가지 해결방법이 있다.

1️⃣ JOIN FETCH 사용

2️⃣ 배치 사이즈 설정

3️⃣ DTO(Data Transfer Object) 사용

4️⃣ Entity Graphs 사용

5️⃣ FetchType.EAGER 사용

 

 

1️⃣ JOIN FETCH 사용

  • 관계가 있는 엔티티를 한 번의 쿼리로 함께 로드해야 할 때 사용
  • JOIN FETCH를 사용하면 한 번의 쿼리로 연관된 엔티티들을 함께 로드할 수 있음
  • 방법은 FetchType.EAGER 설정된 것과 유사한 효과를 내지만, 쿼리를 명시적으로 제어할 있다는 장점
  • FetchType.LAZY로 설정되어 있어 프록시 객체를 사용할 것이라고 생각하기 쉽지만, JOIN FETCH는 프록시객체가 아닌 실제 값을 가져온다.
public interface BookRepository extends JpaRepository<Book, Long> {
    @Query("SELECT b FROM Book b JOIN FETCH b.reviews WHERE b.id = :id")
    Book findBookWithReviewsById(Long id);
}

 

 

 

2️⃣ 배치 사이즈 설정

  • 대량의 연관 데이터를 로드할 때 N+1 쿼리 수를 줄이기 위해 사용(일부만 줄일 수 있음)
  • 완전한 해결책은 아님 → 배치 사이즈는 쿼리의 수를 줄이지만, 여전히 여러 쿼리가 필요. 완벽한 해결을 위해서는 JOIN FETCH나 다른 데이터 로딩 전략을 고려
  • ORM 설정에서 @BatchSize 어노테이션을 사용하여 한 번에 로드할 연관 엔티티의 수를 조정할 수 있음
  • 방법은 많은 수의 엔티티를 처리할 유용하며, 너무 많은 쿼리가 발생하는 것을 줄일 있음
@Entity
@Table(name = "books")
public class Book {
    @OneToMany(mappedBy = "book", fetch = FetchType.LAZY)
    @BatchSize(size = 10)
    private List<Review> reviews;
}

 

 

 

3️⃣ DTO(Data Transfer Object) 사용

  • 뷰나 API 응답으로 필요한 데이터만 선택적으로 전달
  • 필요한 데이터만 선택적으로 로드하기 위해 DTO를 사용
  • 불필요한 데이터를 로드하지 않아 성능을 향상
public class BookDetailDto {
    private String title;
    private String author;
    private List<String> reviewContents;

    public BookDetailDto(String title, String author, List<String> reviewContents) {
        this.title = title;
        this.author = author;
        this.reviewContents = reviewContents;
    }
}

public interface BookRepository extends JpaRepository<Book, Long> {
    @Query("SELECT new com.example.dto.BookDetailDto(b.title, b.author, r.content) FROM Book b JOIN b.reviews r")
    List<BookDetailDto> findAllBookDetails();
}

 

 

 

4️⃣ Entity Graphs 사용

  • Entity Graph 사용하면 특정 쿼리에 대한 엔티티의 로딩 전략을 세밀하게 제어
public interface BookRepository extends JpaRepository<Book, Long> {
    @EntityGraph(attributePaths = {"reviews"})
    List<Book> findAll();
}

 

 

 

5️⃣ FetchType.EAGER 사용

  • 연관된 엔티티가 항상 필요한 경우, 미리 로드하여 지연이 발생하지 않도록 함
  • 권장 여부 → 일반적으로 권장하지 않음. EAGER 로딩은 너무 많은 데이터를 불필요하게 로드할 수 있으며, 특히 많은 연관 관계가 있는 경우 성능 저하를 초래

 

 

3) N+1 문제 해결 비교

해결 방법 사용 사례 권장 여부 주요 특징 주의점
JOIN FETCH 관계가 있는 엔티티를 번의 쿼리로 함께 로드할 매우 추천 번의 쿼리로 필요한 모든 데이터 로드 반환되는 데이터의 양이 많아질 있음
배치 사이즈 설정 대량의 연관 데이터를 로드할 상황에 따라 선택 N+1 쿼리 수를 줄임, 근본적 해결법은 아니고 그냥 성능 향상법 적절한 배치 크기를 설정해야
DTO 사용 뷰나 API 응답으로 필요한 데이터만 전달할 매우 추천 불필요한 데이터 로드 방지 데이터 변환 과정이 필요함
Entity Graphs 특정 쿼리에서 필드 로드 방식을 제어할 추천 쿼리 세밀 제어 가능 복잡한 설정이 필요할 있음
FetchType.EAGER 연관된 엔티티가 항상 필요한 경우 권장하지 않음 연관 엔티티를 미리 로드하여 지연 없음 불필요한 데이터 로드로 성능 저하 가능성

 

 

 

 

2. Query DSL

복잡한 로직을 JPA로 작성하려고 하면 JPQL을 사용할 수 밖에 없다. 하지만 JPQL에서도 문자열로 쿼리를 작성해야한다는 한계가 있기 마련이다. 이런 한계를 해결하고자 Query DSL을 사용한다.

 

QueryDSL의 장점은 다음과 같다.

  1. 문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문법 오류를 쉽게 확인할 수 있다.
  2. 자동 완성 등 IDE의 도움을 받을 수 있다.
  3. 동적인 쿼리 작성이 편리하다.
  4. 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.
  5. QClass를 사용하여 쿼리를 타입 안정성있게 작성할 수 있다.

 

 

3. Query DSL 적용

1) 의존성 추가

// 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"

 

의존성을 추가하고 [build > clean] ->[other > compileJava] 과정을 차례로 수행하면 Query DSL에서 지원하는 QClass를 생성한다.

 

 

2) QClass

@Entity를 탐색하여 QEntity class를 생성한다.

클래스는 Todo 엔티티의 필드를 QueryDSL에서 타입 안정적으로 접근할 있도록 지원한다.

 

public class QTodo extends EntityPathBase<Todo> {
    private static final long serialVersionUID = -1664369315L;
    private static final PathInits INITS;
    public static final QTodo todo;
    public final QTimestamped _super;
    public final ListPath<Comment, QComment> comments;
    public final StringPath contents;
    public final DateTimePath<LocalDateTime> createdAt;
    public final NumberPath<Long> id;
    public final ListPath<Manager, QManager> managers;
    public final DateTimePath<LocalDateTime> modifiedAt;
    public final StringPath title;
    public final QUser user;
    public final StringPath weather;

    public QTodo(String variable) {
        this(Todo.class, PathMetadataFactory.forVariable(variable), INITS);
    }

    public QTodo(Path<? extends Todo> path) {
        this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
    }

    public QTodo(PathMetadata metadata) {
        this(metadata, PathInits.getFor(metadata, INITS));
    }

    public QTodo(PathMetadata metadata, PathInits inits) {
        this(Todo.class, metadata, inits);
    }

    public QTodo(Class<? extends Todo> type, PathMetadata metadata, PathInits inits) {
        super(type, metadata, inits);
        this._super = new QTimestamped(this);
        this.comments = this.createList("comments", Comment.class, QComment.class, PathInits.DIRECT2);
        this.contents = this.createString("contents");
        this.createdAt = this._super.createdAt;
        this.id = this.createNumber("id", Long.class);
        this.managers = this.createList("managers", Manager.class, QManager.class, PathInits.DIRECT2);
        this.modifiedAt = this._super.modifiedAt;
        this.title = this.createString("title");
        this.weather = this.createString("weather");
        this.user = inits.isInitialized("user") ? new QUser(this.forProperty("user")) : null;
    }

    static {
        INITS = PathInits.DIRECT2;
        todo = new QTodo("todo");
    }
}

 

간단하게 분석해보자면, 

 

1️⃣ QueryDSL의 EntityPathBase<Todo>를 상속하여 Todo 엔티티의 경로를 생성

2️⃣ 각 필드를 Path 타입으로 선언하여 타입 안정적으로 접근

3️⃣ 생성자를 사용하여 동적으로 경로를 생성할 수 있다.

 

 

3) JPAQueryFactory Bean으로 등록

@Configuration
public class QueryDslConfig {
    
    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager); // 스레드 safe 스프링이라서 가능
    }
}

QueryDSL에서 엔티티를 조회할 때 JPAQueryFactory가 필요하다. 따라서 이를 쉽게 사용하기 위해 Spring Bean으로 등록하는 과정이 필요하다. 매번 객체를 생성해서 사용하는 것보다 Bean으로 등록해서 필요할 때마다 사용하는 것이 효율적이다.

 

1️⃣ EntityManager는 JPA에서 엔티티를 관리하고, 데이터베이스와 상호작용하는 주요 객체이므로, Query DSL에서 EntityManager를 사용한다.

2️⃣ JPAQueryFactory을 @Bean으로 등록해서 여러곳에서 사용이 가능하도록 설정할 수 있다.(싱글톤으로 관리)

 

 

4) Repository에서 사용하기

위에서 등록한 JPAQueryFactory를 사용하려면 인터페이스가 아닌 클래스에서 사용할 수 있다는 점을 파악해야한다.

 

따라서 JPQL 사용했던 repository 구분하기 위해 다른 레포지토리 인터페이스를 생성한다.

public interface TodoQueryDslRepository {

    Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
}

 

그리고 이를 상속받은 클래스를 만들어 query DSL 적용한다.

@RequiredArgsConstructor
public class TodoQueryDslRepositoryImpl implements TodoQueryDslRepository{

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public Optional<Todo> findByIdWithUser(Long todoId) {
        return Optional.ofNullable(
                jpaQueryFactory.selectFrom(todo)
                        .leftJoin(todo.user).fetchJoin()
                        .where(todo.id.eq(todoId))
                        .fetchFirst()
        );
    }
}

 

위의 인터페이스를 JpaRepository 상속받은 인터페이스에 상속하면 하위 인터페이스에서 Qurty DSL 사용할 있다.

public interface TodoRepository extends JpaRepository<Todo, Long>, TodoQueryDslRepository {
}

 

 

 

5) Query DSL 문법

작성하는 쿼리에 따라 QueryDSL로 작성하는 문법이 존재한다.

이번 예제는 다음과 같은 쿼리를 QueryDSL 변환했다. 이에 대해 분석해보자면,

 

@Override
public Optional<Todo> findByIdWithUser(Long todoId) {
    return Optional.ofNullable(
            jpaQueryFactory.selectFrom(todo)
                    .leftJoin(todo.user).fetchJoin()
                    .where(todo.id.eq(todoId))
                    .fetchFirst()
    );
}

먼저 Optional로 리턴하고 싶으니 Optional로 Null이 될 수 있는 쿼리를 작성한다.

 

1️⃣ .selectFrom(): from에 해당하는 테이블을 찾는다. 이때 파라미터는 QClass에서 static으로 가져온 테이블이다.

2️⃣ .leftJoin().fetchJoin(): user를 join fetch()하여 가져완다.

3️⃣ .where(todo.id.eq()): todo.id와 같은 id를 조건으로 한다.

4️⃣ .fetchFirst(): 결과값의 제일 처음을 가져온다. 아무 것도 없을 경우 null을 리턴한다.

 

이때 만약 .fetchOne()은 데이터가 여러개가 나왔을경우 NonUniqueResultException를 반환한다. 따라서 .fetchFirst()를 이용해야한다.

 

그 외에도 QueryDSL에는 다양한 쿼리 문법을 지원한다.

전부 정리할 수 없을 정도로 양이 많으므로 이용할때마다 블로그를 찾아보는 것이 좋을 것 같다.

 

 

문법

https://sjh9708.tistory.com/175

https://sjh9708.tistory.com/178

https://sjh9708.tistory.com/180

https://sjh9708.tistory.com/181

https://sjh9708.tistory.com/182

https://velog.io/@eunhye_/Querydsl-%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C

 

 

 

 레퍼런스

https://sjh9708.tistory.com/174#google_vignette

 

[Spring Boot/JPA] QueryDSL 설정과 Repository에서의 사용

이번 포스팅에서는 Spring Boot에서 이전에 사용했던 JPQL와 JpaRepository 보다 조금 더 객체지향스럽고 유동적인 동적 쿼리를 작성할 수 있도록 QueryDSL 사용을 위한 설정을 해보도록 하자. QueryDSL에 사

sjh9708.tistory.com

 

https://velog.io/@eunhye_/Querydsl-%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C

 

Querydsl? 동적쿼리?

QueryDsl은 정적 타입을 이용해서 SQL과 같은 쿼리를 생성할 수 있도록 해주는 프레임워크이다.

velog.io

 

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

 

Spring Boot에 QueryDSL을 사용해보자

1. QueryDSL PostRepository.java Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL…

tecoble.techcourse.co.kr