[TIL] 20241010 JPA 심화 + QueryDSL

ekkkyo_j·2024년 10월 10일

[내배캠 Java 6기] TIL

목록 보기
31/38

JPA(껍데기)

java에서 ORM 기술 표준으로 사용되는 인터페이스
= 껍데기

ORM

= Object Relation Mapping
객체와 관계형 DB의 데이터를 자동으로 매핑해주는 Tool

Hibernate(JPA의 구현체)

Java 언어를 위한 ORM 프레임워크
JPA의 구현체로, JPA 인터페이스를 구현하고 내부적으로 JDBC API를 사용한다.

Spring Data JPA(고수준의 JPA)

가장 위의 추상 레벨

Low Level JPA(저수준의 JPA)

Entity LifeCycle

엔티티 객체가 생성, 관리, 소멸 되기까지의 데이터베이스와 상호작용하는 모든 과정

New 단계에 있는 새로운 엔티티를 persist() 해주면(= 엔티티를 jPA 영속성 컨텍스트에서 영속화), JPA 연속성 컨텍스트에서 알아서 엔티티의 상태 관리를 해줌.

Entity Manager

클라이언트가 요청(post, get, delete, patch...)을 보낼때, Entity Manager Factory 객체로 부터 할당을 받은 각각의 Entity Manager가 Conncection Pool에 있는 DB와 연결된 실제 자원을 사용해 결과 정보를 처리하고 다시 클라이언트에게 보내준다.

Entity Manager Factory

JPA에서 EntityManger를 생성하고, 관리하는 팩토리 객체
애플리케이션 실행 시 한번만 생성, 애플리케이션 전체에서 공유, thread-safe함
EntityManager 생성, DB 연결, 트랜잭션 관리 등의 역할 수행

Entity Manager

DB와 상호작용하는 객체로, 엔티티의 생명 주기를 관리하며, thread-safe 하지못함.
=> 각각의 트랜잭션으로 묶이고, 끝나면 다시 반납됨.
엔티티 객체의 라이프 사이클 및 영속성 관리
엔티티 객체에 대해 DB에서 CRUD 연산 수행(persist(), merge(), remove())

@Service
@RequiredArgsConstructor
public class TodoService {
	private final EntityManagerFactory emf;
    
    @Transactional
    public void saveTodo(TodoRequest todoRequest) {
    	EntityManger em = emf.createEntityManager(); // Transaction 단위마다 주입해야함.
        em.persist(new Todo(todoRequest)); // JPA 영속성 컨텍스트에 넣어줌. => 영속화함.
        em.flush(); // DB에 넣어줌   
    }
}

JPA는 인터페이스 => 껍데기이다.
EntityManager, EntityManagerFactory는 jakarta.persistence => JPA에서 제공하는 인터페이스이다.
그러면, EntityManager는 어떻게 persist를 사용하는 것일까?
persist의 구현체는 Hibernate 내에 존재한다.
Hibernate => JPA의 구현체

🔥 문제 발생
jakarta.persistence.TransactionRequiredException: no transaction is in progress
=> JPA에 대한 트랜젝션 처리가 되지 않고 있다.
왜?
@Transactional을 붙여줬잖아.
No!
@Transactional은 Spring에서 제공해주는 것이다.
즉, 고수준의 추상화된 모듈TodoRepository와 같은 곳에서 사용하도록 만들어진 것!
우리는 현재 날것의 JPA를 사용하고 있기 때문에, Transaction을 직접 구현해주어야한다.

데이터를 입력하는 날것의 JPA

@Service
@RequiredArgsConstructor
public class TodoService {
	private final EntityManagerFactory emf;
    
    public void saveTodo(TodoRequest todoRequest) {
    	EntityManger em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        
        try { // Transaction은 성공이거나 실패하면 롤백을 해야한다. 
  			tx.begin(); // Transaction 시작
        
        	em.persist(new Todo(todoRequest));
        	em.flush();
        
        	tx.commit(); // Transaction 끝      	
        } catch (Exception e) { // 에러 발생시 롤백
        	tx.rollback();
        }
        
        em.close(); // EntityManger는 Transaction이 끝나면 반납되어야한다.
        
    }
}

데이터를 조회하는 날것의 JPA

public Todo getTodoEntity(long todoId) {
	EntityManager em = emf.createEntityManger();
    
    return em.find(Todo.class, todoId);
}

N+1 문제

연관관계가 설정된 엔티티 조회시, 관계 데이터갯수(n)만큼 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상

원인
: 한쪽 테이블만 먼저 조회하고 연관 관계가 있는 다른 테이블은 따로 조회하기 때문
- 1은 부모 테이블이나 메인 엔티티 데이터를 조회하는 쿼리 수
- N은 그 후 각각의 자식 엔티티를 가져오기 위한 쿼리 수

향후 문제점
: 관계가 많아지고 데이터가 커지면 N+1의 N이 데이터베이스 및 애플리케이션 전체 성능에 영향을 끼칠 수 있다.

N+1 해결 방안

1. Fetch Join

fetch join은 실제 sql에 있는 join의 종류가 아니며, JPQL에서 성능 최적화를 하기 위해 제공하는 join의 기능이다.

연관된 엔티티나 컬렉션을 한 번에 같이 조회할 수 있도록 지원한다.

JPQL에서 JOIN FETCH를 붙여준다.

public interface TodoRepository extends JpaRepository<Todo, Long> {
	@Query("SELECT DISTINCT t FROM Todo t JOIN FETCH t.managers JOIN FETCH t.comments")
    List<Todo> findAllByJpqlFetch();

}
    public List<TodoResponse> getTodos() {
        List<Todo> todos = todoRepository.findAllByJpqlFetch();

        return todos.stream()
        		.map(todo ->
                		new TodoResponse(
                        	todo.getId(),
                            todo.getTitle(),
                            todo.getContents(),
                            todo.getComments().stream.map(comment -> new CommentResponse(comment))
                            todo.getManagers().stream.map(manager -> new ManagerResponse(manager))
                        )
             	).toList();
    }

일단 JOIN FETCH를 사용해서 manager, comment 모두 불러왔다.

둘중 하나만 한 경우에는 쿼리가 1번만 나가서 성공한다.

그런데 위처럼 2개를 다 JOIN FETCH를 하면 N+1 해결?

NO!
MultipleBagFetchException!

MultipleBagFetchException

ToMany를 여러개 Fatch하면 문제가 발생한다. (ToOne은 여러개 해도 됨!)
중복을 방지하기 위해 Hibernate 상에서 아예 막아버린다.

그럼 둘중 하나를 지워야되는데,,,

public interface TodoRepository extends JpaRepository<Todo, Long> {
	@Query("SELECT DISTINCT t FROM Todo t JOIN FETCH t.managers JOIN t.comments")
    List<Todo> findAllByJpqlFetch();

}

manager에 대한 N+1은 해결되었는데, comment에 대한 N+1은 해결되지 않는다.

=> 어떻게 해결할까?
Batch Size

2. Batch Size 조정

In Query!!!

public interface TodoRepository extends JpaRepository<Todo, Long> {
	@Query("SELECT DISTINCT t FROM Todo t JOIN FETCH t.managers JOIN t.comments")
    List<Todo> findAllByJpqlFetch();

}

Comments의 N+1 문제를 해결하고 싶은 것이기 때문에, Todo entity로 가보자.

@Getter
@Entity
@NoArgsConstructor
@Table(name = "todos")
public class Todo extends Timestamped {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String contents;
    private String weather;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @OneToMany(mappedBy = "todo", cascade = CascadeType.REMOVE)
    @BatchSize(size=10) // Batch Size 설정!!!!!!!
    private List<Comment> comments = new ArrayList<>();

	...
}

Batch size를 10개 주어서 in query에 10개 자리가 존재한다.
이는 comment에만 batch size를 적용한 건데
application.yml에서 global로 설정하는 방법도 있다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 10  # 배치 크기를 10으로 설정
        order_inserts: true  # insert 쿼리 배치 허용
        order_updates: true  # update 쿼리 배치 허용

N+1 문제를 완전히 해결한다고 할 수는 없지만, 추가적으로 발생하는 쿼리의 수를 줄일수 있다.

SELECT 뒤에 DISTINCT를 붙인 이유는 중복하여 오는 데이터가 있을 수 있기 때문에 중복을 줄여주는 특성이다.

📌 Batch Size 정리
1️⃣ 지정된 수만큼 in절에 부모 Key를 사용하게 해줌으로써, 1개씩 사용되는 조건문을 in절로 묶어서 조회하는 방법
➡️ MultipleBagFetchException의 해결방안
2️⃣ 다중 관계에서 fetch join 및 batch size 설정 대상을 정하는 기준
➡️ 호출량의 차이가 있다면? 호출량이 많은 대상에 fetch로 한번에 가져오기
➡️ 호출이 가변적이라면? 호출이 적은 대상에 batch size로 필요할 때 적절한 크기만큼 가져오기
➡️ 데이터양 차이가 난다면? 데이터가 적은 대상은 fetch로 한번에 가져오고, 많은 대상은 batch size로 적절한 크기만큼 가져오기

👉🏻 도메인의 특성, 비지니스 로직 구성에 따라 다르다!!!

예를 들어, 위에서 우리는 manager를 fetch join으로, comment를 batch size를설정하여 불러왔다. 위의 기준에 따라 적절히 선택하여 가져와야한다.

QueryDSL

Query Domain Specific Language 도메인 특화 언어
타입 안전한 동적 쿼리를 직관적으로 작성할 수 있게 도와주는 java기반 쿼리 라이브러리

config

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

QueryDslConfig.java

@Configuration
class QueryDslConfig {

    @PersistenceContext
    private EntityManager em;

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

해주고 나면, entity마다 Qclass가 생성된다.
EntityMangaer를 Bean으로 등록하여 QueryDSL에서 사용할 수 있도록 해줌.
entity를 영속성 컨텍스트에 영속해주기 위해서이다.

Qclass

1️⃣ Entity 클래스의 메타 정보를 담고 있는 클래스
=> 영속성 컨텍스트를 사용할 수 있도록 정리된 QueryDsl용 entity

2️⃣ 타입 안정성(Type safe)을 보장하면서 쿼리를 작성할 수 있다.
llklkyhu 3️⃣ 컴파일 시점에 쿼리 오류를 확인할 수 없는 JPQL의 단점을 보완한다.
=> JPQL의 경우 돌려봐야 오류가 나면 오류 발생을 알 수 있는데, QueryDsl은 Qclass를 사용하기 때문에, 쿼리를 작성하면서 오류 발생시 오류를 알 수 있다.

BooleanExpression

QueryDSL에서 where 뒤의 조건을 생성해주기 위한 클래스
동적 쿼리 작성

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

where절 안에 todoIdEq(todoId)를 보면 이걸 따로 만들어줌

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

todoIdEq의 반환값이 BooleanExpression이다.
todo는 Qclass임!
QueryDSL에서는 Qclass를 사용해서 Query를 만든다.

QueryRepository

TodoQueryRepository.java

public interface TodoQueryRepository {
    Todo findByIdWithUserByDsl(Long todoId);

인터페이스 = 껍데기

TodoQueryRepositoryImpl.java

@Repository
@RequiredArgsConstructor
public class TodoQueryRepositoryImpl implements TodoQueryRepository {

    private final JPAQueryFactory q;
    
    @Override
    public Todo findByIdWithUserByDsl(Long todoId) {
        return q
                .select(todo)
                .from(todo)
                .where(
                        todoIdEq(todoId)
                ).fetchOne();
    }
    

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

TodoQueryRepository를 구현하는 구현체.
QueryDslConfig에서 JPAQueryFactory를 Bean으로 등록해주었기 때문에, 바로 사용할 수 있다.
todo는 Qclass Qtodo임!!!

.fetch() : 다건 처리
.fetchOne() : 한 건 조회

eq의 반환값이 BooleanExpression임.

TodoRepository.java

public interface TodoRepository extends JpaRepository<Todo, Long>, TodoQueryRepository {

}

TodoQueryRepository를 상속받음.

트러블슈팅

Todo entity는 comments, managers가 있다.
그런데 TodoResponse에는 이 둘을 넣지 않고 생성할 수 있도록 하였다.
즉, Todo에서 comments와 managers는 있어도 되고, 없어도 된다.
그런데, TodoQueryRepositoryImpl에서

        List<Todo> todos = queryFactory
                .select(todo)
                .distinct()
                .from(todo)
                .join(todo.managers, manager).fetchJoin() // Fetch Join N+1 문제 해결
                .join(todo.comments, comment) // Batch Size N+1 문제 해결 in Todo Entity
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch(); // 여러개 가져오기

이렇게 해주면, 조회가 안된다.

💡 .leftJoin()을 사용하자!!!!
JOIN을 통해 manager와 comment와 조인했지만, 해당 엔티티에 데이터가 없으면 조인 결과가 없을 수 있다. 만약 manager나 comment가 없는 Todo도 포함해야 한다면 LEFT JOIN을 사용해야한다.

Projection

select(*) from table하면 조인관계의 모든 데이터를 가져오기때문에, 메모리 낭비가 있을 수 있다.

entity 전체의 값을 가져오는 것이 아닌 조회 대상을 지정해 원하는 값만 조회할 수 있도록 지원하는 클래스이다.

  1. 생성자 방식
@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();
}
  1. @QueryProjection 방식
@Override
public TodoProjectionDto findByIdFromProjection(long todoId) {
    return q
        .select(
            new QTodoProjectionDto(
                todo.title,
                todo.contents,
                max(todo.id)
            )
        )
        .from(todo)
        .where(
            todoIdEq(todoId)
        ).fetchOne();
}

TodoProjectionDto.java의 생성자에 어노테이션 붙이기

@QueryProjection
public TodoProjectionDto(String title, String contents, Long max) {
    this.title = title;
    this.contents = contents;
    this.max = max;
}
profile
게으르지만 성실히 공부하기^_^

0개의 댓글