[JPA] N + 1 문제 해결 완.

Dev_ch·2023년 6월 2일
1

어김없이 N+1 문제에 대한 포스팅을 하러 왔다. 바로 고고

1. N + 1 문제요?

N + 1 문제는 다음과 같다.

  1. 유저가 여러 연관된 엔티티를 LAZY 형태로 갖고있다.
  2. 사용자는 유저의 정보를 조회하는 쿼리를 날린다. (1)
  3. 유저와 연관된 관계들의 쿼리가 함께 나간다. (N)
  4. 난 분명 유저 조회 쿼리만 날렸는디...?

그렇다. 이 문제는 분명 유저의 정보를 조회하려고 쿼리를 날렸는데 연관된 관계 쿼리까지 전부 날라가는 것.. 아래 코드와 같이

List<User> users = userRepository.findAll();

for (User user : users) {
    List<Post> posts = user.getPosts();
}

getPosts()를 하면 지연로딩의 특성을 그대로 하나의 쿼리가 더 나간다. 결국 이 문제는 연관된 관계가 늘어날수록 (유저의 연관된 관계 조회)N + 1 (유저 엔티티 조회) 쿼리가 나가게된다.

SELECT * FROM User;

SELECT * FROM Post WHERE user_id = ?;
SELECT * FROM Post WHERE user_id = ?;
...

이는 지연 로딩이 아닌 즉시로딩의 형태를 지니고 있다고 해도 컬렉션을 가지고 있다면 분명 N + 1 문제가 발생한다. 그렇기에 지연로딩, 즉시로딩 상관없이 어디서든 N + 1 문제가 발생할 수 있다.

만약 유저가 정말 수도없이 많은데 하나 조회했다가 쿼리가 몇배가 되버린다고 생각하면.. 끔찍한 성능 이슈와 에러를 먹어버릴 수 있다. 그러니깐 이러한 문제를 꼭 해결해야 하는데 방법은..

2. Join fetch로 해결하기

JPA의 fetch join 기능을 사용하여, 한 번의 쿼리로 사용자와 사용자의 게시글을 모두 조회합니다.

        Member findMember = queryFactory
                .selectFrom(member)
                .join(member.team, team).fetchJoin()
                .where(member.username.eq("member1"))
                .fetchOne();

        boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
        assertThat(loaded).as("패치 조인 적용").isTrue();

예제는 QueryDSL로 join에 .fetchJoin()을 붙여주면 그대로 적용된다. 만약 fetchJoin이 적용이 안되어있다면 team의 경우 쿼리가 하나 더 나갈 것 이지만

		select
            member0_.member_id as member_i1_1_0_,
            team1_.id as id1_2_1_,
            member0_.age as age2_1_0_,
            member0_.team_id as team_id4_1_0_,
            member0_.username as username3_1_0_,
            team1_.name as name2_2_1_ 
        from
            member member0_ 
        inner join
            team team1_ 
                on member0_.team_id=team1_.id 
        where
            member0_.username=?

fetchJoin을 적용하면 하나의 쿼리로 전체를 select 하는 것을 볼 수 있다.

fetchJoin을 통해 가져온 데이터는 영속성 컨텍스트에 등록되고 관리되며 OneToMany 관계 에서는 fetch join 으로 데이터를 가져올 경우 데이터가 배로 늘어나게 되는 상황이 발생하여서 distinct를 넣어주어야한다.

추가적으로 컬렉션은 fetch Join으로 하나밖에 할 수 없다.

⚠️ fetchJoin을 사용하면 페이징 기능을 사용하기 어려운데, 아래 링크를 참고해보도록 하자.
JPA에서 Fetch Join과 Pagination을 함께 사용할때 주의하자

객체를 한번에 그대로 가져온다고 생각하면 되는데, 이때 select에서 컬럼을 하나씩 접근하는 것을 불가능하다. 즉, 조회한 엔티티가 있다면 그 entity를 그대로 반환해야 한다.

fetchJoin은 성능에 중대한 영향을 미치기 때문에 결국, 서비스를 설계하고 어떤식으로 데이터를 가져오는 것인지 효율적인가에 대해 판단하여 사용해주는 것이 좋다.

3. BatchSize()로 해결하기

@Entity 
public class User {
.
.
@BatchSize(size = 100)
@OneToMany(mappedBy = "users")
private List<Pet> pets;

}

만약 pets라는 리스트에서 N + 1 문제가 발생한다면 해당 컬럼에 @BatchSize 어노테이션과 한번에 가져올 사이즈를 설정하여 데이터를 가져온 크기만큼 한번에 가져오게끔 구현해주면 된다.

다만 해당 기능은 적절한 크기를 설정하는 것이 중요한데, 너무 큰 값을 설정하게되면 메모리 사용량이 늘어나고, 너무 작은 값을 설정하면 여전히 많은 수의 쿼리가 실행될 수 있다.따라서 @BatchSize의 크기는 시스템의 요구 사항과 성능을 고려하여 조정해야 한다.

@BatchSize에 대해 좋은 정보가 적힌 블로그이니 참고하는것이 좋다.
👉 JPA BatchSize에 대한 고찰

4. DTO 객체로 바로 받아오기

@Override
    public Page<BoardResponse.Detail> getPageBoardsAsDto(Pageable pageable, String arrange, String filter) {
        List<BoardResponse.Detail> content = queryFactory
                .select(Projections.fields(BoardResponse.Detail.class,
                        board.id.as("id"),
                        board.user.name.as("authorName"),
                        board.title.as("title"),
                        board.content.as("content"),
                        board.correct.as("correct"),
                        board.contentImageUrl.as("contentImageUrl"),
                        ExpressionUtils.as(getLikeCountQuery(), "likeCount"),
                        board.viewCount.as("viewCount"),
                        board.createdAt.as("createdAt"),
                        board.updatedAt.as("updatedAt"),

                        board.tag.horror,
                        board.tag.daily,
                        board.tag.romance,
                        board.tag.fantasy,
                        board.tag.sf,

                        board.hint.hintOne,
                        board.hint.hintTwo,
                        board.hint.hintThree,
                        board.hint.hintFour,
                        board.hint.hintFive))
                .from(board)
                .innerJoin(board.user, user)
                .innerJoin(board.tag, tag)
                .innerJoin(board.hint, hint)
                .orderBy(generateSortQuery(arrange, filter))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(board.count())
                .from(board);

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

위 코드는 똑같이 QueryDSL의 예시이다. 여기서 Projections를 통해 DTO 객체에 직접 접근하여 해당 필드에 값을 하나씩 꽂아준다고 생각하면 된다. as() 안에 dto 컬럼의 이름을 넣어주어 매칭 시켜주었다.

DTO로 조회하면서 페이징 처리까지 추가적으로 진행할 수 있다. 단, DTO 객체에 직접 접근하게 되면 영속성 컨텍스트에 관리되지 않는다. 즉 DTO 객체에 데이터를 직접 접근 시키는 경우는 아래의 주의사항이 따른다.

💡 DTO는 실제 엔티티가 아니므로, 반환된 DTO를 활용해 추가적인 로직을 수행하는 것이 제한적이다.

말 그대로 해당 방법은 오로지 조회만 할때 가능한 것 이다. 즉 영속성 컨텍스트에 등록되거나 관리되지 않기 때문에 로직에 영향을 미치지 않는다는 것이다.


5. 결론

결국 개발자가 서비스나 API의 상황을 고려하여 어떠한 방법을 쓰는게 좋고 효율적인지 잘 따져가면서 쿼리를 튜닝하고 성능을 최적화해야한다.

갓영한님의 말씀으로는 성능 문제의 대략 80% 가 N + 1이라 했거늘,, 개발자로서 해당 문제를 잘 인지하고 해결하도록 하자.

profile
내가 몰입하는 과정을 담은 곳

0개의 댓글