본문 바로가기

Back-End 공부/Spring

[스프링] 지연로딩, 즉시로딩, N+1문제 제대로 이해하기

1:N 관계의 테이블들이 얽혀 있을 때

게시글 조회같이 여러 건의 데이터를 전체 조회할 때 주로 발생한다.

 

 

✔️ N+1문제

게시글 목록같은 경우

전체 게시글 개수인 9개의 데이터를 가져오기 위해 쿼리문을 1번 날리고,

➡️ select * from post;

 

그 후에 각 게시글의 작성자를 찾기 위해 쿼리문이 n번만큼 날린다.

이때, 중복된 작성자가 있다면 1번만 쿼리문이 나가고, 익명유저는 null이니 제외된다.

➡️  select * from author where id = "213322132"

➡️  select * from author where id = "yj@naver.com"

 

 

총 3개의 쿼리문이 나간 것을 확인할 수 있다.

 

 

💡 JPA의 두 가지 로딩방식

JPA는 기본적으로 위 방식인 지연로딩 "LAZY" 방식을 지향한다.

왜냐면 "그때그때 데이터가 필요한 상황이 발생하면 데이터를 가져와, 메모리&작업 효율성을 지키겠다"는 것이다.

 

 

하지만 위의 경우처럼 게시글마다 작성자 정보를 무조건 가져와 화면에 뿌려줘야 하는 경우,

= 개발자가 봤을 때, n+1번 호출해 데이터가 모두 필요한 화면이라고 생각되면

즉시 로딩 방식을 사용하여 쿼리문 1개를 통해 한 번에 모든 데이터를 가져오는 것이 더 효율적이다.

select a.* p.* from author a left join post p on a.id = p.author_id;  

 

 

즉, n+1 문제는 즉시 로딩 방식을 통해 해결할 수 있다는 뜻이 된다.

한 번 더 정리하자면, 한 번에 관련된 모든 데이터를 같이 가져오는 것을 즉시로딩 "EAGER" 방식이라 한다.

  • 1:N 관계에서 N쪽은 즉시 로딩이 디폴트
  • 단 건 Post객체는 항상 한 명의 작성자를 가지기 때문에 디폴트가 즉시로딩이다.
  • 하지만 즉시로딩은 자원을 사용하지 않을 때 낭비가 발생할 수 있으므로 지연 로딩 방식이 권장된다.

 

- Post 테이블

현재 N쪽 관계를 가지기 때문에 즉시로딩이 디폴트이며, 지연로딩을 걸어줘야 한다.

즉시로딩은 로그 1개가 남고, 지연로딩은 2개가 남는다.

@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch=FetchType.LAZY) //Post 객체에 있는 변수명을 적어 매핑관계 표현
private List<Post> posts;

 

 

- Author 테이블

지연로딩은 지연로딩이 디폴트이지만, 가시적으로 코드에 작성해 JPA에게 알려주는 것이 일반적이다.

@ManyToOne(fetch = FetchType.LAZY) // 관계성 JPA에게 알리기
@JoinColumn(name = "author_id")
//@JoinColumn(nullable=false, name = "author_email", referencedColumnName = "email")
private Author author;

 

 

 

 

JOIN vs FETCH JOIN

 

N+1 문제를 해결하기 위해 즉시로딩 방식을 사용할 경우,

FETCH JOIN문을 사용하면 1번의 쿼리문으로 데이터를 모두 가져다 놓을 수 있다.

 

이때, JOIN문과 FETCH JOIN문의 차이를 살펴보자.

JPQL로 join과 fetch join을 사용한 두 개의 쿼리 메세지를 작성했다.

 

 

@Query("select p from Post p left join p.author")

의미 : select p.* from post p inner join author a on p.author_id = a.id whiere a.name like 'hong%';

 

author 데이터를 같이 가지고 오지 않아, p.author를 접근하고 싶다면 다시 n번만큼의 쿼리를 보내야 한다.

위 코드처럼 where문에 a의 특정 속성을 사용해 해당 기준을 만족하는 row만 가져오고 싶을 경우 사용.

 

 

@Query("select p from Post p left join fetch p.author")

의미 : select p.* a.* from post p inner join author a on p.author_id = a.id whiere a.name like 'hong%';

post와 author를 한 번에 같이 가지고 오기 때문에 p.author에 바로 접근할 수 있다.

 

 

 

 

fetch join을 할 경우 쿼리의 개수가 줄어드는 것을 확인해보자.

먼저 PostRepository에 JPQL을 사용해 findAllJoin()과 findAllFetchJoin() 메서드를 만들어준다.

 

[ PostRepository.interface ]

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    // select p.* from post p left join author a on p.author_id = a.id;
    // 아래 JPQL의 join문은 quthor 객체를 통해 post를 스크리닝(필터링)하고 싶은 상황에 적합
    @Query("select p from Post p left join p.author") //JPQL문 : 객체 지향의 raw query. Jpa와 다르게 컴파일 타임에서 오류를 잡아준다.
    List<Post> findAllJoin();

    // select p.* a.* from post p left join author a on p.author_id = a.id;
    @Query("select p from Post p left join fetch p.author")
    List<Post> findAllFetchJoin();
}

 

 

쿼리의 수를 세고자 하는 화면은 아래와 같다.

 

 

case 1. 일반 join

public List<PostListResDto> findAll(){
        List<Post> Posts = postRepository.findAllJoin();
//        List<Post> Posts = postRepository.findAll();
        List<PostListResDto> PostListResDtos = new ArrayList<>();
        for(Post post : Posts){
            PostListResDto postListResDto = new PostListResDto();
            postListResDto.setId(post.getId());
            postListResDto.setTitle(post.getTitle());
            postListResDto.setAuthor_email(post.getAuthor()==null?"익명유저":post.getAuthor().getEmail());
//            postListResDto.setAuthor_email(post.getAuthor().getEmail()); //⭐ post객체에 있는 author_id로 Author 테이블에서 꺼내온 autohr 객체
            PostListResDtos.add(postListResDto);
        }
        return PostListResDtos;
    }

일반 join의 경우 n+1 문제가 발생해, 총 5개의 쿼리가 나가고 있다.

 

 

case 2. fetch join

public List<PostListResDto> findAll(){
        List<Post> Posts = postRepository.findAllFetchJoin();
//        List<Post> Posts = postRepository.findAll();
        List<PostListResDto> PostListResDtos = new ArrayList<>();
        for(Post post : Posts){
            PostListResDto postListResDto = new PostListResDto();
            postListResDto.setId(post.getId());
            postListResDto.setTitle(post.getTitle());
            postListResDto.setAuthor_email(post.getAuthor()==null?"익명유저":post.getAuthor().getEmail());
//            postListResDto.setAuthor_email(post.getAuthor().getEmail()); //⭐ post객체에 있는 author_id로 Author 테이블에서 꺼내온 autohr 객체
            PostListResDtos.add(postListResDto);
        }
        return PostListResDtos;
    }

fetch join의 경우 한 번에 데이터를 끌고 오기 때문에, 총 1개의 쿼리가 나가고 있다.