효율적인 Fetch 방법

효율적인 Fetch 방법

JPA의 기본 사항을 정리한다.

Entity + Lazy 연관관계 조회시 고려해야 할 점

우리는 Entity 연관관계를 조회할 때, LAZY로 설정하는 것이 일반적이다.
이후 조회시에 재정의하여 함께 가져올지 말지 결정하는 방식이 보편적인 좋은 방법으로 알려져 있다.
추가적으로 엔티티를 수정할 계획이 있는가 없는가에 따라 효율적인 조회 방법이 달라진다.

수정할 계획이 없다면?

SQL JOIN + DTO로 조회하는 것이 가장 효율적이다.

수정할 계획이 있다면?

JOIN FETCH로 조회하는 것이 가장 효율적이다.

상황 별로 가장 효율적인 조회 방법을 알아보자


*-to-One 연관관계 포함시

예를 들면 Author(1) - Books(N) 관계에서 Book의 정보를 조회할 때 Author를 함께 조회해야 하는 상황이라면 어떻게 될까

수정할 계획이 없다면?

읽기 전용 결과 세트 DTO를 사용하는 것이 가장 효율적이다.
여기서 또 고려해볼 사항이 있다.

편리함, DTO 구조 유지 우선

//file: `읽기 전용 결과 세트 DTO`
public interface BookDTo {
    String getTitle();//Book의 Title
    AuthorDto getAuthor();//Author의 정보
    
    interface AuthorDto{
    String getName();//Author의 Name
    String getGenre();
    }

}

호출부에서는 다음과 같이 사용할 수 있다.

//file: `방법 1. 쿼리빌더 메커니즘 or JPQL`
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
    
    List<BookDto> findBy();
    
    or
    
    @Query("SELECT b.title AS title, a AS author FROM Book b LEFT JOIN b.author a")
    List<BookDto> findByViaQuery();
}

편리하고 DTO의 구조 그대로 가져온다.
단점으로는 영속성 컨텍스트에 읽기 전용이지만 Entity가 메모리에 올라가게 된다.
가비지 컬렉터는 영속성 컨텍스트가 닫힌 후 여러 인스턴스를 수집해야 한다.

성능 우선

//file: `단순 닫힌 프로젝션 DTO`
public interface SimpleBookDto{
    String getTitle();
    String getAuthorName();
    String getAuthorGenre();
}
//file: `방법 2. 명시적 JPQL 사용`
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
    
    @Query("SELECT b.title AS title, a.name AS authorName, a.genre AS authorGenre FROM Book b LEFT JOIN b.author a")
    List<SimpleBookDto> findByViaQuerySimpleDto();
}

영속성 컨텍스트를 통하지 않고, 성능상 가장 우수하다.
그러나 필요하다면 DTO의 구조를 가공해야하는 상황이 온다.

수정할 계획이 있다면?

//TODO Entity 가져오는 좋은 방법


*-to-Many 연관관계 포함시

예를 들면 Author(1) - Books(N) 관계에서 Author의 정보를 조회할 때 List의 Title도 함께 조회해야 하는 상황이라면 어떻게 될까

수정할 계획이 없다면?

편리함 , DTO 구조 우선

JPA JOIN FETCH

부모-자식 트리 구조는 유지하지만 전체 컬럼을 가져온다.
영속성 컨텍스트에 Read-Only로 Entity가 올라가게 된다.

성능 우선

//file: `단순 닫힌 프로젝션 DTO`
public interface SimpleAuthorDto{
    String getName();
    String getGenre();
    String getTitle();
}
//file: `방법 2. 명시적 JPQL 사용`
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
    
    @Query("SELECT a.name AS name, a.genre AS genre, b.title AS title FROM Author a LEFT JOIN a.books b")
    List<SimpleAuthorDto> findByViaQuerySimpleDto();
}

영속성 컨텍스트를 통하지 않고, 성능상 가장 우수하다.
그러나 부모 - 자식 트리 구조를 유지하지 않는다.
구조가 필요하다면 추후 DTO의 구조를 가공해야하는 상황이 온다.

성능 우선 + 부모 - 자식 트리 구조 유지

List<Object[]> 로 받아서 바로 Converting 해주는 로직까지 짜주는 방법도 있다.

//file: `트리구조 DTO`
public class AuthorDto {
    private Long authorId;
    private String name;
    private String genre;
    
    private List<BookDto> books = new ArrayList<>();
}

public class BookDto {
    private Long bookId;
    private String title;
}
//file: `방법 3. 명시 JPQL & Object[] 사용`
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
    
    @Query("SELECT a.id AS authorId, a.name AS name, a.genre AS genre,b.id AS bookId, b.title AS title FROM Author a LEFT JOIN a.books b")
    List<Object[]> findByViaArrayOfObjectsWithIds();
}
//file: `DtoTransfer`
@Component
public class AuthorTransformer {
    public List<AuthorDto> transform(List<Object[]> rs){
        final Map<Long, AuthorDto> authorsDtoMap = new HashMap<>();
        
        for(Object[] o : rs){
            Long authorId = ((Number) o[0]).longValue();
            
            AuthorDto authorDto = authorsDtoMap.get(authorId);
            if(authorDto == null){
                authorDto = new AuthorDto();
                authorDto.setId(((Number) o[0]).longValue());
                authorDto.setName((String) o[1]);
                authorDto.setGenre((String) o[2]);
            }
            
            BookDto bookDto = new BookDto();
            bookDto.setId(((Number) o[3]).longValue());
            bookDto.setTitle((String) o[4]);
            
            authorDto.addBook(bookDto);
            authorsDtoMap.putIfAbsent(authorDto.getId(),authorDto);
        }
        return new ArrayList<>(authorsDtoMap.values());
    }
}

//file: `Service 사용 부분`

List<Object[]> authors = authorRepository.findByViaArrayOfObjectsWithIds();
List<AuthorDto> authorDtos = authorTransformer.transform(authors);

하나의 SELECT와 영속성 컨텍스트에 바이패스 되는 장점이 있고 성능상 Raw Data 추출 이외에 좋다.
데이터 구조를 지키며 읽기만 하고 싶다면 좋은 방법이다.

수정할 계획이 있다면?

//TODO Entity 가져오는 좋은 방법


단일 엔티티 전체 컬럼 조회시

예를 들면 Author의 id,age,genre,name 전체 컬럼을 가져온다고 생각해보자. (부분도 상관 없음)

수정할 계획이 없다면?

//file: `닫힌 프로젝션 DTO`
public interface AuthorDto{
    Long getId();
    int getAge();
    String getName();
    String getGenre();
}

편의성, 효율성

//file: `방법 1. 쿼리빌더 메커니즘 사용`
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
    
    List<AuthorDto> findBy();
}

영속성 컨텍스트 바이패스와 효율적인 SELECT 쿼리를 사용한다.

편의성, 효율성, 극한의 성능

//file: `방법 2. 명시적 JPQL 사용`
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
    
    @Query("SELECT a.id AS id, a.age AS age, a.name AS name, a.genre AS genre FROM Author a")
    List<AuthorDto> findByViaQuery();
}

영속성 컨텍스트 바이패스와 효율적인 SELECT 쿼리를 사용한다.
컴파일 시에 미리 쿼리를 확인가능하기 때문에 성능상 가장 우수하다.


여러 Entity 참조하여 컬럼 조회시 (Join) 가져오는 방법

두 Entity가 비연결관계일 때, 공통 컬럼으로 읽어야 하는 상황의 경우, 생성자를 통해 DTO를 생성하는 방법은 N + 1 문제에 취약하다.
단일 SELECT로 가져오기 위해서는 스프링 프로젝션, JPA Tuple을 사용하는 것이 좋다.

[BAD] 생성자 표현식 N + 1 발생

//file: `생성자 표현식 N + 1 취약 사용법`
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
    
    //장르가 같은 책을 쓴 저자의 정보와 책 제목을 가져온다. N + 1 문제 발생
    @Query("SELECT new com.example.BookstoreDto(a, b.title) FROM Author a JOIN a.books b ON a.genre = b.genre ORDER BY a.id")
    List<AuthorDto> findByViaQuery();
}

[GOOD] 동일한 상황 스프링 프로젝션 사용

//file: `생성자 표현식을 쓰지 않은 좋은 방법`
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
    
    //장르가 같은 책을 쓴 저자의 정보와 책 제목을 가져온다.
    @Query("SELECT a AS author, b.title AS title FROM Author a JOIN a.books b ON a.genre = b.genre ORDER BY a.id")
    List<AuthorDto> findAll();
}

[GOOD] Object보다 좋은 스칼라 프로젝션 처리에 Tuple

스칼라 프로젝션은 SELECT 절을 통해 조회하는 데이터 타입에 상관 없이 여러 개를 가져오는 방법. 조회되는 데이터 타입을 규정할 수 없다.

//file: `스칼라 프로젝션위해 Tuple 사용`
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
    
    //장르가 같은 책을 쓴 저자의 정보와 책 제목을 가져온다.
    @Query("SELECT a.id AS authorId, a.name AS authorName, b.title AS title FROM Author a")
    List<Tuple> findAll();
}
//file: `Tuple 사용`
List<Tuple> authors = bookstoreService.fetchAuthors();
for (Tuple author : authors) {
    Long authorId = author.get("authorId");
    String authorName = author.get("authorName");
    String title = author.get("title");
}

Projection 재사용 방법

수정의 가능성 없이 읽기 전용인 경우, Projection을 사용하는 것이 좋다는 점은 알았다.
실무에서는 상황마다 다른 Projection을 사용하는 경우를 추천한다. 그래도 공통 사용 Dto처럼 Projection을 재사용하는 방법과 상황마다 다른 Projection 생성시 좋은 방법을 알아보자.

공통 사용 Projection

//file: `공통 사용 Projection`
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public interface AuthorDto{
 Integer getAge();
 String getName();
 String getGenre();
 String getEmail();
 String getAddress();
}
  • @JsonInclude(JsonInclude.Include.NON_DEFAULT) : 현재 쿼리에서 가져오지 않은 값(NULL) 직렬화 방지용이다.
    • 결과 JSON에서 Null 값을 건너뛰도록 Jackson 직렬화 메커니즘에 선언
//file: `단일 재활용 Projection 사용법`
List<AuthorDto> findBy();//전체 가져오는 쿼리빌더 메커니즘

@Query("SELECT a.age AS age, a.name AS name FROM Author a")        
List<AuthorDto> fetchAgeName();//Age와 Name만 가져옴

상황마다 다른 Projection 생성

//file: `상황마다 생성된 여러 Projection`
public interface  AuthorGenreDto {
    String getGenre();
}
public interface AuthorNameEmailDto{
    String getName();
    String getEmail();
}
//file: `상황마다 다른 Projection 사용법`
//Repository 선언
<T> T findById(Long id, Class<T> type);

@Query("SELECT a.genre AS genre FROM Author a WHERE a.name=?1 AND a.age=?2")
<T> T findByNAmeAndAge(String name, int age, Class<T> type);
//file: `Service 사용 부분`

Author author = authorRepository.findById(1L, Author.class);

AuthorGenreDto authorGenreDto = authorRepository.findByNAmeAndAge("name", 20, AuthorGenreDto.class);

AuthorNameEmailDto authorNameEmailDto = authorRepository.findByNAmeAndAge("name", 20, AuthorNameEmailDto.class);

능동적으로 사용가능하다.