JPA

Springboot+JPA 사용중 발생한 N+1 문제 개선

땅콩콩 2024. 3. 14. 15:22

개인프로젝트에서 만든 조회 api에 아주 심각한 성능 문제가 있었다.
바로 조회 한번에 select쿼리가 약 10개~15개가 나가는 것이다......

무시무시한 select 덩어리 ....

 

문제의 쿼리 덩어리가 날아가는 부분을 찾기 위해 디버깅을 해보니 원인을 파악할 수 있었다.

현재 Member엔티티와 Post엔티티가 다대다여서 MemberPost라는 중간 객체를 두고 필요한 정보를 이 객체를 통해 조회하고있는데, 이를 불러오는 과정에서 문제가 발생하는 것 같았다.

 

아래는 개선 전 도메인 코드이다.

@Entity
@Getter @Setter
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String nickname;
    private String email;
    private String password;
    private String univName;
    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<MemberPost> memberPosts = new ArrayList<>();
}
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberPost {
    @Id @GeneratedValue
    @Column(name = "member_post_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    private Boolean isAuthor;

    // 마지막으로 채팅방 메시지를 읽은 시간을 저장하기 위한 필드.
    private LocalDateTime lastReadAt;

    public MemberPost(Post post, Member member, boolean isAuthor, LocalDateTime lastReadAt) {
        this.post = post;
        this.member = member;
        this.isAuthor = isAuthor;
        this.lastReadAt = lastReadAt;
    }
}
@Entity
@Getter @Setter
public class Post {
    @Id
    @GeneratedValue
    @Column(name = "post_id")
    private Long id;
    private boolean isFromSchool;
    private String depart;
    private String arrive;
    private LocalDateTime departTime;
    private LocalDateTime createdTime;
    private Integer cost;
    private Integer maxMember; //최대 인원수
    private Integer nowMember; //현재 인원수


    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    @BatchSize(size = 40)
    private List<MemberPost> memberPosts = new ArrayList<>();
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "chatRoom_id")
    private ChatRoom chatRoom;

    // 연관관계 메서드
    public void setChatRoom(ChatRoom chatRoom) {
        this.chatRoom = chatRoom;
        chatRoom.setPost(this);
    }

    public void addMember(Member member, boolean isAuthor){
        MemberPost memberPost = new MemberPost(this, member, isAuthor, LocalDateTime.now());
        memberPosts.add(memberPost);
        member.getMemberPosts().add(memberPost);
    }

    public void removeMember(Member member){
        for (MemberPost memberPost : memberPosts) {
            if(memberPost.getPost().equals(this) && memberPost.getMember().equals(member)){
                memberPost.getMember().getMemberPosts().remove(memberPost);
                memberPost.setPost(null);
                memberPost.setMember(null);
            }
        }
    }
}

 

그리고 찾아보니 일반적으로 JPA사용중 발생하는 N+1 문제를 해결할 수 있는 방법은 크게 세가지인것같다.

 

1. Lazy Loading말고 Eager Loading을 사용

2. Fetch Join으로 필요한 엔티티들을 조인해서 데이터베이스에서 한번에 검색해오는 방법 (한방 쿼리!!)

3. batch size 설정 

 

나는 이중 1, 2번은 현재 상황에 맞지 않다고 생각해서 3번 방법을 통해 개선했다.

 

  • 1번 방법을 사용하지 않은 이유

하지만 1번 Eager Loading을 사용하면 data jpa 내부에서 만드는 jpql이 sql로 번역되면서 문제가 발생한다. 

select문은 하나만 나가지만, MemberPost에 Eager Loading이 걸려있기 때문에 Post에 연결된 MemberPost들을 모두 검색한다.

그리고 이 부분에서 N+1문제가 발생하기 때문에 좋은 해결책이라고 할 수 없다.

 

그렇다고 내가 현재 사용하고 있는것처럼 Lazy Loading을 사용하면 어떻게 될까?

Lazy Loading을 사용하면 쿼리문을 날리는 것이 실제로 MemberPost를 사용하는 시점까지 지연되기 때문에 처음 find를 통해 가져올때는 문제의 쿼리 덩어리가 날아가지 않는다.

하지만 Post를 또 검색하고 MemberPost를 다시 사용하면 이미 캐싱된 데이터임에도 쿼리가 다시 발생한다.

 

  • 2번 방법을 사용하지 않은 이유

현재 상황과 같은 toMany관계에서는 join을 할 경우 데이터가 many쪽에 맞춰 불어나게 된다.

이렇게 데이터가 예측할 수 없이 증가하기때문에 이 경우엔 페이징이 불가능해진다.

아니 애초에 페이징을 하는 이유가 없어진다.. 수많은 데이터중 일부씩 가져와서 서버의 부담을 줄이고자 페이지네이션을 하는것인데, join되는대로 모든 데이터를 다 가져와버리면 페이징의 이점이 아예 없어지는 것이다.

 

그럼에도 만약 toMany관계의 컬렉션에 fetch join을 사용하면 하이버네이트의 경고로그와 함께 DB에서 모든 데이터를 읽어오고, 메모리 위에서 페이징해버린다.

이 경우 메모리에 모든 데이터를 저장하고 그걸 다시 페이지로 나눠서 반환하게 되기때문에 Out of memory가 발생할 수 있다고 한다.

 

  • 결과적으로 3번 방법 사용!

batch size 설정은 문제가 되는 컬렉션 객체만 원하는 사이즈만큼 따로 select문을 보내는 방법이다.

현재 한개의 Post에 참여할 수 있는 인원은 최대 4명이기 때문에, 하나의 Post당 할당이 가능한 MemberPost는 최대 4개였다.

그리고 페이징을 통해 가져오는 Post의 defaultSize 개수가 10개이기때문에

batch size = 10*4 = 40로 지정한다면 전체 MemberPost를 모두 조회하지않고도 내가 원하는 만큼의 데이터를 가져올 수 있겠다고 판단했다.

그래서 아래와 같이 조회되는 컬렉션의 필드에 직접 애노테이션을 사용해 batch size를 지정했다.

@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
@BatchSize(size = 40)
private List<MemberPost> memberPosts = new ArrayList<>();

 

그리고 다시 실행해보았더니 기능상 필요한 select문들만 적절하게 나가는 것을 확인할 수 있었다!!!

 

select문이 확실히 줄어들었다!!

 

이렇게 N+1문제는 해결했는데, 서비스 로직을 막상 구현하다보니 처음 생각했던것보다 도메인 구조가 조금 비효율적인 것 같기도 해서 아쉬움이 있었다. 어플리케이션의 다른 기능들에서 나가는 select문까지 확인을 한번씩 해보고, 변경해도 문제가 없을 것 같다면 도메인 구조를 개선하는 것도 도전해봐야겠다..!