본문 바로가기
개발/Spring

[N+1 문제] 일반조인, fetch조인으로 OSIV, LAZY/EAGER, N+1문제 확실히 이해하기

by meanjung 2023. 8. 16.

차례

  1. 일반조인 이해하기
  2. 일반조인에서 LazyInitializationException가 발생하는 상황 이해하기(feat. LAZY/EAGER, OSIV ON/OFF)
  3. 준영속 상태란?
  4. fetch조인 이해하기

일반조인

public interface BookRepository extends JpaRepository<Book, Long> {
    @Query("SELECT b from Book b join b.bookCategory where b.bookPublisher = :bookPublisher")
    List<Book> findBooks(@Param("bookPublisher") String bookPublisher);
}

 

@Entity
@Table(name = "book_info")
@Getter
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "b_id")
    private Long bookId;

    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "c_id")
    private Category bookCategory;

}
  • 조인 조건을 제외하고 실제 질의하는 대상 entity에 대한 컬럼만 조회한다.
    • 즉, 여기서는 Category와 조인을 하지만 Book에 대해서만 조회한다는 의미이다.
  • 일반 조인은 실제 쿼리에 조인을 걸어주기는 하지만, 조인 대상의 영속성까지는 관여하지 않는다.
    • 대상 entity(Book) 값만 영속성 컨텍스트에 가져오고, 조인한 Category는 영속성 컨텍스트에 가져오지 않는다.
    • 따라서 나중에 category 정보가 필요하다면 따로 select 문이 나가게 된다. 
  • OSIV 설정에 따라 LazyInitializationException이 발생한다. 
    • 영속성 컨텍스트에서 벗어난 detach(준영속) 상태가 되었을 때 지연 로딩을 할 때 자주 발생하는 예외이다.

 

일반조인 - 테스트

@Slf4j
@Service
@RequiredArgsConstructor
public class BookService {
    private final BookRepository bookRepository;

    @Transactional
    public List<Book> getBooks(String publisher){
        List<Book> books = bookRepository.findBooks(publisher);
        return books;
    }

}
@GetMapping("/test")
public void getBookTest(){
    try{
        List<Book> books = bookService.getBooks("민음사");
        log.info("====NEXT====");
        for(Book book: books){
            log.info("{}",book.getBookCategory().getCategoryName());
        }
    }catch (Exception e){
        e.printStackTrace();
    }
}

 

    select
        b1_0.b_id,
        b1_0.b_author,
        b1_0.c_id,
        b1_0.b_isbn13,
        b1_0.b_picture,
        b1_0.b_year,
        b1_0.b_publisher,
        b1_0.b_name 
    from
        book_info b1_0 
    join
        book_category b2_0 
            on b2_0.c_id=b1_0.c_id 
    where
        b1_0.b_publisher=?


c.b.b.d.books.controller.BookController  : ====NEXT====

    select
        c1_0.c_id,
        c1_0.c_name 
    from
        book_category c1_0 
    where
        c1_0.c_id=?

 

c.b.b.d.books.controller.BookController  : 소설
c.b.b.d.books.controller.BookController  : 소설
c.b.b.d.books.controller.BookController  : 소설

지금 나는 "민음사"로 조회한 데이터가 모두 카테고리가 같기 때문에 Category 쿼리문이 하나만 나갔다. 
만약 조회된 책 3권이 모두 카테고리가 달랐다면 3개의 쿼리가 날라갔을 것이다.
이를 N+1 문제라고 한다. 

 

이렇게 book.getBookCategory().getCategoryName()을 호출할 때 두 번째 select 문이 나가는 것을 확인할 수 있다. 

 

그런데 여기서 LazyInitializationException이 발생하지 않는다.

 

왜 LazyInitializationException이 발생하지 않았을까?

OSIV가 default로 켜져있기 때문이다. 

spring.jpa.open-in-view=false

application.properties에 위와 같이 명시하지 않으면 true로 설정되어 영속성 컨텍스트가 Service, Repository를 넘어 Controller, View까지 살아있다. 

그렇기 때문에 해당 예외가 발생하지 않았다. 

 

 

만약 다음과 같이 false로 설정하고 같은 테스트를 실행해보면 LazyInitializationException을 확인할 수 있다. 

spring.jpa.open-in-view=false

 

만약 EAGER라면 어떨까?

TLDR; OSIV가 false여도 LazyInitializationException은 발생하지 않는다.

 

EAGER도 "조인 조건을 제외하고 실제 질의하는 대상 entity에 대한 컬럼만 조회한다." 라는 사실에는 변함이 없다.

하지만 LAZY와 다른 점은 나눠진 쿼리가 한 번에 질의된다는 것이다.

 

EAGER는 OSIV가 꺼져서 Controller 단에서 준영속 상태가 되어도 이미 필요한 데이터는 다 메모리에 있기 때문에 Exception이 발생하지 않는 것이다. 

 

    select
        b1_0.b_id,
        b1_0.b_author,
        b1_0.c_id,
        b1_0.b_isbn13,
        b1_0.b_picture,
        b1_0.b_year,
        b1_0.b_publisher,
        b1_0.b_name 
    from
        book_info b1_0 
    join
        book_category b2_0 
            on b2_0.c_id=b1_0.c_id 
    where
        b1_0.b_publisher=?

 

    select
        c1_0.c_id,
        c1_0.c_name 
    from
        book_category c1_0 
    where
        c1_0.c_id=?

 

c.b.b.d.books.controller.BookController  : ====NEXT====

c.b.b.d.books.controller.BookController  : 소설

c.b.b.d.books.controller.BookController  : 소설

c.b.b.d.books.controller.BookController  : 소설

 

준영속 상태란?

  • 영속성 컨텍스트가 관리하는 영속 상태의 엔티티가 영속성 컨텍스트에서 분리된 상태
  • 준영속 상태의 엔티티는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다. 

 

Fetch 조인

  • 조회의 주체가 되는 엔티티 외에 fetch join이 걸린 연관 entity도 함께 select하여 함께 영속화한다.
  • fetch join이 걸린 entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않을 채로 N+1 문제가 해결된다.

 

Fetch 조인 - 테스트

다음 네가지 경우 모두에서 로그가 찍힌다.

  • LAZY + OSIV ON
  • LAZY + OSIV OFF
  • EAGER + OSIV ON
  • EAGER + OSIV OFF

원래 우리가 알고 있던 조인으로 조회하여 가져오는 것이다. 

이렇게 하면 추가 쿼리가 발생하지 않기 때문에 N+1 문제도 발생하지 않는다.

 

    select
        b1_0.b_id,
        b1_0.b_author,
        b2_0.c_id,
        b2_0.c_name,
        b1_0.b_isbn13,
        b1_0.b_picture,
        b1_0.b_year,
        b1_0.b_publisher,
        b1_0.b_name 
    from
        book_info b1_0 
    join
        book_category b2_0 
            on b2_0.c_id=b1_0.c_id 
    where
        b1_0.b_publisher=?

 

c.b.b.d.books.controller.BookController  : ====NEXT====

c.b.b.d.books.controller.BookController  : 소설

c.b.b.d.books.controller.BookController  : 소설

c.b.b.d.books.controller.BookController  : 소설

 

 


참고

https://cobbybb.tistory.com/18

 

[JPA] 일반 Join과 Fetch Join의 차이

JPA를 사용하다 보면 바로 N+1의 문제에 마주치고 바로 Fetch Join을 접하게 됩니다. 처음 Fetch Join을 접했을 때 왜 일반 Join으로 해결하면 안되는지에 대해 명확히 정리가 안된 채로 Fetch Join을 사용했

cobbybb.tistory.com

 

댓글