Code Don
코드돈
Code Don

GITHUB
전체 방문자
오늘
어제
  • 분류 전체보기
    • Java
    • Spring
    • JPA
    • Database
    • 개발론?
    • C.S
    • DevOps
    • 알고리즘
    • 잡담과 고민

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • spring boot
  • compositebuild
  • Hibernate
  • static
  • spring
  • Java
  • n+1
  • Comparable
  • spring data JPA
  • QueryDSL
  • gradle
  • JPQL
  • criteria
  • mariadb
  • enum
  • Qclass
  • Monorepo
  • JPA
  • 자바
  • comparator
  • JPA MetaModel
  • 정렬

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
Code Don

CODE DON - WOKI TOKI :)

JPA N + 1, FetchJoin, EntityGraph
JPA

JPA N + 1, FetchJoin, EntityGraph

2022. 3. 18. 11:45

필자는 Spring Data JPA 를 많이 사용 중에 있다.

초반에 해당 문제로 골머리를 앓았던 기억이 있는데 그때 기억도 나는 겸 다시 정리도 하는 겸하여 정리해보고자 한다.

준비

우선 테스트를 진행해 볼 엔티티를 작성하자.

책과 작가를 엔티티로 삼았으며 책 → 작가를 의존하는 ManyToOne 관계를 맺어주었다.

코드는 아래와 같다.

Entity

@Entity
@Getter
@NoArgsConstructor
@ToString
public class Book {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne
    private Author author;

    public Book(String title, Author author) {
        this.title = title;
        this.author = author;
    }
}
@Entity
@Getter
@NoArgsConstructor
@ToString
public class Author {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public Author(String name) {
        this.name = name;
    }
}

위와 같이 코드를 작성하고 Test Code 를 작성해보자.

Test Code

@DataJpaTest
public class FetchTest {

    @Autowired
    AuthorRepository authorRepository;

    @Autowired
    BookRepository bookRepository;

    @BeforeEach
    void init () {
        Author author1 = new Author("name1");
        Author author2 = new Author("name2");

        authorRepository.save(author1);
        authorRepository.save(author2);

        Book book1 = new Book("book1", author1);
        Book book2 = new Book("book2", author2);

        bookRepository.save(book1);
        bookRepository.save(book2);
    }

    @Test
    void test() {
        List<Book> all = bookRepository.findAll();
        all.forEach(System.out::println);
    }

}

이 테스트를 실행하면 쿼리가 1번 나가고 문제가 없이 Book에 대한 콘솔이 찍힐 것이다.

JPA 를 조금이나마 공부를 해봤을 경우

@ManyToOne // DEFAULT FETCH_TYPE = EAGER
private Author author;

ManyToOne의 기본 Fetch 타입이 Eager 라는 것을 알 수 있는데.

쿼리가 왜 1번이 나갈까?

이유는 @DataJpaTest에 있다.

DataJpaTest

해당 어노테이션에 들어가보면

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional // 주의 ######
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest { 
// ...
}

@Transactional 이라는 어노테이션이 들어가 있으므로 테스트 코드 상의 init메서드와 test메서드가 트랙잭션으로 묶여 있기 때문이다.

더욱 정확하게 말하자면 Spring Data JPA 에서 트랙잭션에 Persistence Context 라는 게 있다. 같은 트랜잭션에서 save 할 경우 1차 캐시에 해당 정보가 캐싱 됨으로 같은 트랜잭션에서 Author를 가져오기위해 캐싱된 데이터를 가져오지 굳이 쿼리를 통해 가져올 필요가 없기 때문이다.

그렇다면 별도의 Controller 를 생성해서 확인해보자.

Transaction 분리

@RestController
@RequiredArgsConstructor
public class BookController {

    private final BookRepository bookRepository;

    @GetMapping
    public List<Book> findAll() {
        return bookRepository.findAll(); // 이 부분 만 Transaction
    }

}

// Hibernate: select book0_.id as id1_1_, book0_.author_id as author_i3_1_, book0_.title as title2_1_ from book book0_
// Hibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_ from author author0_ where author0_.id=?
// Hibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_ from author author0_ where author0_.id=?

해당 메서드를 실행하면 로그창에 쿼리가 3번 나가는 것을 볼 수 있다.

이유는 ManyToOne 의 기본 Fetch 속성이 EAGER 인데 Book의 엔티티 2개를 쿼리 1개로 가져왔고, 해당 Book 객체를 가져오고 해당 객체 내부에 Author 를 가져와라 라는 속성의 EAGER 속성에 의해 객체 2개 별로 Author 를 가져와야 하기에 추가적으로 쿼리 2번이 나갔다.

 

이는 만약에 가져오는 Book 갯수가 100개라면 ?

1 (Book List 쿼리) + 100 (Book의 Author를 가져오는 쿼리) = 101 개의 쿼리가 나가게 된다.

이것이 바로 그 유명한 N+1 문제이다.

해결방안

그렇다면 EAGER로 인한 문제라면 우선 Fetch 속성을 LAZY 로 바꾸면 어떨까?

@Entity
@Getter
@NoArgsConstructor
@ToString
public class Book {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

		@ManyToOne(fetch = FetchType.LAZY) // EAGER -> LAZY 변경 
    private Author author;

    public Book(String title, Author author) {
        this.title = title;
        this.author = author;
    }
}

이렇게 다시 호출을 하면

No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor 
and no properties discovered to create BeanSerializer 
(to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) 
(through reference 
chain: java.util.ArrayList[0]->
com.github.mdjoo0810.fetch_join.entity.Book["author"]->
com.github.mdjoo0810.fetch_join.entity.Author$HibernateProxy$Bu50F9Zf["hibernateLazyInitializer"])

위와 같은 프록시 에러가 발생하는 것을 볼 수 있다.

왜? Persistence Context에 Author에 대한 정보가 없기에 에러가 발생한다.

DTO 활용

일단 Author 가 필요 없고 Book 만 필요하다면?

이런 상황에서는 DTO 사용으로 해결이 가능하다.

우선 DTO 를 생성

@Getter @Setter
public class BookDTO {

    private Long id;
    private String title;

}

Controller 부분에 객체 컨버팅

@RestController
@RequiredArgsConstructor
public class BookController {

    private final BookRepository bookRepository;

    @GetMapping
    public List<Book> findAll() {
        return bookRepository.findAll().stream()
            .map(b -> modelMapper.map(b, BookDTO.class))
            .collect(Collectors.toList());
    }

}
// Hibernate: select book0_.id as id1_1_, book0_.author_id as author_i3_1_, book0_.title as title2_1_ from book book0_

호출해보면 쿼리가 한번 나가는 것 확인 가능하다.

대신 기존은 Response 가 아래와 같았다면

[
	{
		"id": 1,
		"title": "title1",
		"author": {
			"id": 1,
			"name": "author1"
		}
	},
	{
		"id": 2,
		"title": "title2",
		"author": {
			"id": 2,
			"name": "author2"
		}
	},
]

DTO 를 사용하면

[
	{
		"id": 1,
		"title": "title1"
	},
	{
		"id": 2,
		"title": "title2"
]

로 변한 것을 확인할 수 있다.

그런데 만약 모든 데이터가 필요하다면?

EntityGraph

public interface BookRepository extends JpaRepository<Book, Long> {

		// ...

    @EntityGraph(attributePaths = {"author"})
    List<Book> findAll();

}
@RestController
@RequiredArgsConstructor
public class BookController {

    private final BookRepository bookRepository;

    @GetMapping
    public List<Book> findAll() {
        return bookRepository.findAll();
    }

}

// select book0_.id as id1_1_0_, author1_.id as id1_0_1_, book0_.author_id as author_i3_1_0_, book0_.title as title2_1_0_, author1_.name as name2_0_1_ from book book0_ 
// left outer join author author1_ on book0_.author_id=author1_.id

위의 코드를 적용하고 호출하면 쿼리 1개가 나가는 것을 볼 수 있다.

여기서 EntityGraph를 사용하면 선언한 Attributes에 대해서는 EAGER 속성을 넣어준다.

실제로 나가는 쿼리를 살펴보면 left outer join을 통해 가져온다.

 

 

하지만 이 EntityGraph는 카테시안 곱의 문제가 별도로 발생한다.

이 부분은 연관관계를 재 정의해야 하기 때문에 별도로 정리하도록 하겠다.

FetchJoin

public interface BookRepository extends JpaRepository<Book, Long> {

    @Query("SELECT distinct b FROM Book b JOIN FETCH b.author")
    List<Book> findAllFetchJoin();

}
@RestController
@RequiredArgsConstructor
public class BookController {

    private final BookRepository bookRepository;

    @GetMapping
    public List<Book> findAll() {
        return bookRepository.findAllFetchJoin();
    }

}

// Hibernate: select distinct book0_.id as id1_1_0_, author1_.id as id1_0_1_, book0_.author_id as author_i3_1_0_, book0_.title as title2_1_0_, author1_.name as name2_0_1_ from book book0_ 
// inner join author author1_ on book0_.author_id=author1_.id

가장 유명한 Fetch Join 방식이다.

 

inner join 을 통한 구문으로 쿼리 1번에 모든 데이터를 가져올 수 있다.

 

추가적으로 Properties 에 hibernate.default_batch_fetch_size 를 통해 전역적으로 n + 1쿼리에 대해 in 쿼리를 통해 줄여주는 방법도 있습니다.

실무 활용법

그래서 저는 실무에서는 아래와 같이 사용하고 있습니다.

  • 혹시 모를 상황에 대비해 hibernate.default_batch_fetch_size 를 통해 전역으로 관리
  • @OneToOne, @ManyToOne와 같은 관계에서 자식 엔티티에 대해서는 Fetch Join을 적용하여 관리
  • @OneToMany, @ManyToMany와 같이 N 관계의 자식 엔티티에 관해서는 데이터가 많은 자식쪽에서 Fetch Join 사용
  • 추가적으로 직접적으로 JPQL을 작성하는 것 보단 조회에 대한 쿼리는 QueryDSL을 통해 작성하고 있습니다.

모든 코드는 아래 링크에 있습니다.

https://github.com/mdjoo0810/java-study-all/tree/main/fetch_join

'JPA' 카테고리의 다른 글

QueryDSL을 쓰다보니 Q-Class가 뭘까 싶네?  (0) 2022.03.23
Spring JPA - Hibernate 에서 field가 Enum일때 @ColumnDefault는 어떻게?  (0) 2021.04.22
    'JPA' 카테고리의 다른 글
    • QueryDSL을 쓰다보니 Q-Class가 뭘까 싶네?
    • Spring JPA - Hibernate 에서 field가 Enum일때 @ColumnDefault는 어떻게?
    Code Don
    Code Don
    Backend Developer

    티스토리툴바