소개에 앞서 Github 레포지토리에 코드가 올라가 있습니다.
Querydsl 라이브러리를 사용하였고, Spring Boot 2.7.10 입니다.

오늘은 Spring Data Jpa를 다루면서 발생한 N+1 문제에 대해 적어보려한다.


JPA N+1 문제란?

연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(N)만큼 연관관계의
조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상을 얘기한다.

JPA Repository로 실행하는 첫 쿼리에서 하위 엔티티까지 한번에 가져오지 않고
하위 엔티티를 사용할 때 추가로 조회하기 때문에 발생한다.

Fetch Type이 EAGER(즉시로딩), LAZY(지연로딩) 둘 다 발생하고
쿼리가 발생하는 시점만 다를뿐이지 N+1문제는 여전하다.


문제 발생 테스트

테이블 설명

문제 발생 테스트를 위해 3개의 테이블을 만들었다.
POSTS(게시글) - POSTS_TAG(게시글 태그 관리) - TAG(태그)

1:N:1 관계를 가지며, POSTS쪽에서 TAG의 데이터를 가져오기 위해서
LEFT JOIN으로 접근하는 과정에서 발생하는 N+1 문제를 체크해볼 생각이다.

image


코드 준비

🔖 프로젝트 구조

Github 레포지토리를 참고하시면 더 도움이 됩니다!

├── main
│   ├── java
│   │   └── com
│   │       └── jpa
│   │           └── jpaoneplusn
│   │               ├── JpaoneplusnApplication.java
│   │               └── domain
│   │                   ├── config
│   │                   │   └── QuerydslConfig.java
│   │                   ├── entity
│   │                   │   ├── Posts.java
│   │                   │   ├── PostsTag.java
│   │                   │   └── Tag.java
│   │                   └── repository
│   │                       ├── PostsCustomRepository.java
│   │                       ├── PostsCustomRepositoryImpl.java
│   │                       ├── PostsRepository.java
│   │                       └── TagRepository.java
│   └── resources
│       ├── application.yml
│       ├── static
│       └── templates
└── test
    └── java
        └── com
            └── jpa
                └── jpaoneplusn
                    ├── JpaoneplusnApplicationTests.java
                    └── test
                        ├── PostTest.java
                        └── PostsTest.java


우선 모든 클래스의 코드를 올리기에는 이해하는데 복잡할 수 있기 때문에
주요 코드인 EntityPostsCustomRepositoryImpl만 올리도록 하겠습니다.

Posts.java

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Posts {

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

    private String title;

    @OneToMany(mappedBy = "posts", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) // LAZY, EAGER 변경후 쿼리 시점변경
    private List<PostsTag> postsTags = new ArrayList<>();

    @Builder
    public Posts(Long id, String title) {
        this.id = id;
        this.title = title;
    }

    public void addPostsTag(PostsTag postsTag) {
        this.postsTags.add(postsTag);
    }
}


PostsTag.java

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostsTag {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "posts_id")
    private Posts posts;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "tag_id")
    private Tag tag;

    @Builder
    public PostsTag(Long id, Posts posts, Tag tag) {
        this.id = id;
        this.posts = posts;
        this.tag = tag;
    }
}


Tag.java

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Tag {

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

    private String name;

    private String type;

    @OneToMany(mappedBy = "tag", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
    private List<PostsTag> postsTags = new ArrayList<>();

    @Builder
    public Tag(Long id, String name, String type) {
        this.id = id;
        this.name = name;
        this.type = type;
    }

    public void addPostsTag(PostsTag postsTag) {
        this.postsTags.add(postsTag);
    }
}


PostsCustomRepositoryImpl

@Repository
@RequiredArgsConstructor
public class PostsCustomRepositoryImpl implements PostsCustomRepository{

    private final JPAQueryFactory queryFactory;

    @Override
    public Posts findPost_noFetchJoin(Long id) {
        return queryFactory
                .selectFrom(posts)
                .leftJoin(posts.postsTags, postsTag)
                .leftJoin(postsTag.tag, tag)
                .where(posts.id.eq(id))
                .fetchOne();
    }

    @Override
    public Posts findPost_useFetchJoin(Long id) {
        return queryFactory
                .selectFrom(posts)
                .leftJoin(posts.postsTags, postsTag).fetchJoin()
                .leftJoin(postsTag.tag, tag).fetchJoin()
                .where(posts.id.eq(id))
                .fetchOne();
    }
}


N+1 문제 테스트

우선 application.yml파일에 sql을 출력할 수 있도록 설정하였다.
그리고 Test 코드를 통해 쿼리를 보면서 검증을 해보았다.

PostTest

@SpringBootTest
@Transactional
public class PostTest {

    @Autowired
    EntityManager em;
    
    JPAQueryFactory queryFactory;
    
    @Autowired
    PostsRepository postsRepository;
    
    @Autowired
    TagRepository tagRepository;

    private static Long POST_ID;


    @BeforeEach
    public void setUp() {
        queryFactory = new JPAQueryFactory(em);
        Posts post = Posts.builder().title("계절별 강수량 조사 게시글 입니다.").build();
        Posts savedPost = postsRepository.save(post);
        POST_ID = savedPost.getId();

        // 1개의 게시글, 4개의 태그
        // 중간테이블 postTag = 1개의 게시글에 각각 태그를 연결해놓은 상황
        for (long i = 1; i <= 4; i++) {
            String[] seasons = new String[]{"봄", "여름", "가을", "겨울"};
            String season = seasons[(int) (i - 1L)];
            Tag tag = Tag.builder().name(season).type("계절").build();
            PostsTag postTag = PostsTag.builder().tag(tag).posts(post).build();
            tag.addPostsTag(postTag);
            tagRepository.save(tag);
        }
    }
}

가독성을 위해 코드를 분리해서 설명하겠다.
첫번쨰로 @BeforeEach를 통해서 Posts, Tag, PostsTag에 정보를 넣어 주었다.
총4개씩 넣어 주었고 Repository에 .save()를 이용해 테이블에 저장해주었다.
여기서 PostsTag는 casecade를 이용해 저장되기 때문에 Repository를 따로 만들지 않았다.


본격적인 테스트는 아래에서 부터 설명하겠다.

1. N+1 문제 테스트

첫번째로 영속성 컨텍스트를 초기화 해주어야한다.
초기화 시켜주지 않는다면 이미 영속화되어 있기 때문에
문제 발생상황이 제대로 체크가 되지 않을 수 있다.

    @Test
    @DisplayName("N+1 문제 발생하는 테스트 케이스")
    public void jpaProblem() {
    
        // 영속성 컨텍스트 초기화
        em.flush();
        em.clear();

        System.out.println("============= 쿼리 시작 =============");
        Posts findPost = postsRepository.findPost_noFetchJoin(POST_ID);


        System.out.println("============= PostsTag 가져오기 =============");
        for (PostsTag postsTag : findPost.getPostsTags()) {
            System.out.println("PostsTag id = " + postsTag.getId());
        }

        // Tag정보를 가져올때 N+1문제가 발생하는 모습!!
        System.out.println("============= Tag 가져오기 =============");
        for (PostsTag postsTag : findPost.getPostsTags()) {
            System.out.println("Tag Name = " + postsTag.getTag().getName());
        }
    }

그리고 프린트출력문으로 3가지 파트로 나누었고
For문을 Post의 PostTags, Tag를 순회하도록하였다.
처음에 레포지토리에서 쿼리를 가져오고 다른 테이블에 접근하도록 나누었다.

postsRepository.findPost_noFetchJoin(Long id);를 호출하여 테스트하였다.
(위에 Querydsl 코드로 작성된 클래스를 참조)

현재 구분하기 쉽게 Posts 엔티티나 PostsTag엔티티나 FetchType을 LAZY로 설정했다.
(EAGER로 하여도 쿼리 출력 시점만 다르고 N+1문제는 발생합니다.)

LAZY 설정

============= 쿼리 시작 =============
Hibernate: 
    select
        posts0_.id as id1_0_,
        posts0_.title as title2_0_ 
    from
        posts posts0_ 
    left outer join
        posts_tag poststags1_ 
            on posts0_.id=poststags1_.posts_id 
    left outer join
        tag tag2_ 
            on poststags1_.tag_id=tag2_.id 
    where
        posts0_.id=?
============= PostsTag 가져오기 =============
Hibernate: 
    select
        poststags0_.posts_id as posts_id2_1_0_,
        poststags0_.id as id1_1_0_,
        poststags0_.id as id1_1_1_,
        poststags0_.posts_id as posts_id2_1_1_,
        poststags0_.tag_id as tag_id3_1_1_ 
    from
        posts_tag poststags0_ 
    where
        poststags0_.posts_id=?
PostsTag id = 1
PostsTag id = 2
PostsTag id = 3
PostsTag id = 4
============= Tag 가져오기 =============
Hibernate: 
    select
        tag0_.id as id1_2_0_,
        tag0_.name as name2_2_0_,
        tag0_.type as type3_2_0_ 
    from
        tag tag0_ 
    where
        tag0_.id=?
Tag Name = 봄
Hibernate: 
    select
        tag0_.id as id1_2_0_,
        tag0_.name as name2_2_0_,
        tag0_.type as type3_2_0_ 
    from
        tag tag0_ 
    where
        tag0_.id=?
Tag Name = 여름
Hibernate: 
    select
        tag0_.id as id1_2_0_,
        tag0_.name as name2_2_0_,
        tag0_.type as type3_2_0_ 
    from
        tag tag0_ 
    where
        tag0_.id=?
Tag Name = 가을
Hibernate: 
    select
        tag0_.id as id1_2_0_,
        tag0_.name as name2_2_0_,
        tag0_.type as type3_2_0_ 
    from
        tag tag0_ 
    where
        tag0_.id=?
Tag Name = 겨울

실제 쿼리를 확인해보면 Hibernate로 구분을 하면 되는데
Post와 PostTags Select하는 쿼리는 1개만 출력되었지만
PostTags에서 Tag로 접근을할때 보면 4개의 쿼리가 발생한 것을 볼 수 있다.
즉, 하위 엔티티까지 하번에 가져오지 않고 조회할때 추가로 조회 쿼리가 발생하고
이것이 태그의 수량 4개만큼 발생하여 N+1문제가 발생한 것을 확인할 수 있다.


만약 EAGER로 변경하게될 경우

EAGER 설정

============= 쿼리 시작 =============
Hibernate: 
    select
        posts0_.id as id1_0_,
        posts0_.title as title2_0_ 
    from
        posts posts0_ 
    left outer join
        posts_tag poststags1_ 
            on posts0_.id=poststags1_.posts_id 
    left outer join
        tag tag2_ 
            on poststags1_.tag_id=tag2_.id 
    where
        posts0_.id=?
Hibernate: 
    select
        poststags0_.posts_id as posts_id2_1_0_,
        poststags0_.id as id1_1_0_,
        poststags0_.id as id1_1_1_,
        poststags0_.posts_id as posts_id2_1_1_,
        poststags0_.tag_id as tag_id3_1_1_,
        tag1_.id as id1_2_2_,
        tag1_.name as name2_2_2_,
        tag1_.type as type3_2_2_ 
    from
        posts_tag poststags0_ 
    left outer join
        tag tag1_ 
            on poststags0_.tag_id=tag1_.id 
    where
        poststags0_.posts_id=?
============= PostsTag 가져오기 =============
PostsTag id = 1
PostsTag id = 2
PostsTag id = 3
PostsTag id = 4
============= Tag 가져오기 =============
Tag Name = 봄
Tag Name = 여름
Tag Name = 가을
Tag Name = 겨울

현재 위의 예제에서는 POST를 1개만 조회하기 때문에
EAGER로 변경시 JOIN이 이루어져 1번만 더 쿼리가 발생한 것을 볼 수있다.

예를들어 List로 여러개의 POST를 받게될 경우 N+1 문제가 발생할 것이다.


2. N+1 해결 테스트

이렇게 콘솔을 통해서 N+1문제가 발생하고 있다는걸 확인했다.
그러면 해결하려면 어떻게해야하는가?

해결방법으로는 fetchJoin을 사용하는 방법이 있다.
findPost_useFetchJoin(Long id) 현재 프로젝트에서 메서드를 참고하면된다.

    @Override
    public Posts findPost_useFetchJoin(Long id) {
        return queryFactory
            .selectFrom(posts)
            .leftJoin(posts.postsTags, postsTag).fetchJoin()
            .leftJoin(postsTag.tag, tag).fetchJoin()
            .where(posts.id.eq(id))
            .fetchOne();
    }

findPost_useFetchJoin(Long id) 코드에서 fetchJoin()을 붙여준게 끝이다.

그럼 이메서드를 호출한 테스트 코드를 확인해보자

    @Test
    @DisplayName("N+1 문제 해결한 테스트 케이스")
    public void jpaProblemSolve() {

        // 영속성 컨텍스트 초기화
        em.flush();
        em.clear();

        System.out.println("============= 쿼리 시작 =============");
        Posts findPost = postsRepository.findPost_useFetchJoin(POST_ID);

        System.out.println("============= PostsTag 가져오기 =============");
        for (PostsTag postsTag : findPost.getPostsTags()) {
            System.out.println("PostsTag id = " + postsTag.getId());
        }

        System.out.println("============= Tag 가져오기 =============");
        for (PostsTag postsTag : findPost.getPostsTags()) {
            System.out.println("Tag Name = " + postsTag.getTag().getName());
        }
    }

똑같은 테스트 코드에서 postsRepository.findPost_useFetchJoin(Long id);만 변경되었다.

실제 SQL문을 확인해보면서 차이점을 느껴보자
FetchType 설정은 LAZY, EAGER 똑같이 출력된다.

============= 쿼리 시작 =============
Hibernate: 
    select
        posts0_.id as id1_0_0_,
        poststags1_.id as id1_1_1_,
        tag2_.id as id1_2_2_,
        posts0_.title as title2_0_0_,
        poststags1_.posts_id as posts_id2_1_1_,
        poststags1_.tag_id as tag_id3_1_1_,
        poststags1_.posts_id as posts_id2_1_0__,
        poststags1_.id as id1_1_0__,
        tag2_.name as name2_2_2_,
        tag2_.type as type3_2_2_ 
    from
        posts posts0_ 
    left outer join
        posts_tag poststags1_ 
            on posts0_.id=poststags1_.posts_id 
    left outer join
        tag tag2_ 
            on poststags1_.tag_id=tag2_.id 
    where
        posts0_.id=?
============= PostsTag 가져오기 =============
PostsTag id = 1
PostsTag id = 2
PostsTag id = 3
PostsTag id = 4
============= Tag 가져오기 =============
Tag Name = 봄
Tag Name = 여름
Tag Name = 가을
Tag Name = 겨울

객체를 가져오는 순간 쿼리가 1개만 출력된 모습을 확인할 수 있다.
실제로 N+1 문제가 발생한 쿼리랑 차이를 느낄 수 있고
해당 API를 만번 조회한다치면 쿼리의 양도 N배나 차이가 나게되는 것이다.



오늘은 이렇게 N+1 문제가 발생하는 이유와 해결방법을 알아보았는데
fetchJoin()외에도 EntityGraph, Batch Size를 통해 해결이 가능하다고한다.
해당 방법에 대해서는 추후 포스팅을 해보도록하려하고, fetchJoin에 대한 원리 공부도
필요하다고 느껴진다..