이번글에서는 프로젝트 진행중에 세미나의 참여인원의 목록을 페이징하는 작업 중에 문제를 마주하고 결국에는 어떻게 처리했는지에 대한 고민을 담아보았습니다. 

이번글은 단순한 호기심에 시작하였으며, 아마 이런 기능을 만들어야하는 개발자는 없을것 같지만, 그래도 글을 남겨보겠습니다.

 

해당 내용을 시작하게 된 이유

특정 세미나와 연관된 세미나에 대한 페이지네이션은 쉽게 진행할 수 있습니다.

하지만, 개발을 진행해보며 갑자기 모든 Seminar 정보와 연관된 데이터 전체를 페이지네이션하면 어떻게 될까? 라는 궁금증이 생겼습니다. 

 

결론부터 말하면, 대용량 데이터 기준으로 모든 Semianr에(특정 Seminar 아님) 대한 Pagination을 JPA N+1 문제를 사용하며 해결하는것은 제가 이해한바로는 불가능합니다.

 

이유는 다음과 같습니다. 우리가 일반적으로 JPA N+1 문제를 해결하기 위해 사용하는 fetchjoin 사용시 Seminar와 @OneToMany 관계인 Member_Seminar는 데이터가 매칭되면서 메모리 초과가 발생하기 떄문입니다.

자세한 사항은 아래에 있습니다.

 

글 시작

우선 목표는 다음과 같습니다.

1. 저는 Seminar 200080번쨰에 대한 세미나의 참여인원을 Paging하여 1번부터 10번까지의 정보를 알고싶습니다. 한번에 너무 많은 데이터를 Load하여 서버에 부담을 주고싶지 않았기 때문입니다.

2. 그래서 저는 @OneToMany 관계에서 @One인 Seminar 입장에서 페이지네이션을 시도합니다.

3. 하지만, @OneToMany 관계에서는 RDBMS 특성상 하나의 데이터에 여러개의 Row가 매칭되어 hibernate에서는 이것을 어떻게 페이징할지 결정하지 못합니다.

 

현재로써는 문제사항을 위와 같이 정의할 수 있습니다.

즉, 정리해보면 @One 의 입장에서는 Many범위를 단순히 Query 만으로는 컨트롤할 수 없었고,

결국 제가 해결한방안은, @Many 의 입장으로 가서 범위를 처리하여 해결했는데요. 

단순히 @Many 입장에서 해결한 과정이 아닌 @One 입장에서 실패햇던 과정도 같이 담아보았습니다.

 

사실 알아볼수록 @ManyToOne 관계에서 작업하면 손쉽게 가능하다는 것을 알았지만 @OneToMany 입장에서도 페이지네이션이 가능한가? 라는 궁금증이 생겨 계속해서 관련내용을 알아보게 되었습니다.

Entity 입니다.

1. Seminar.class

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_seminar_list"})
public class Seminar extends BaseEntity {

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

    @Column(length = 100, nullable = false, unique = true)
    private String seminar_name;

    @Column(length = 500)
    private String seminar_explanation;

    @Column
    private Long seminar_price;

    @Column
    private Long seminar_maxParticipants;
    
    @Column(nullable=true)
    private LocalDateTime del_dt;

    @OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
    private List<Member_Seminar> member_seminar_list;

    public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }

    public void setSeminar_name(String seminar_name){
        this.seminar_name = seminar_name;
    }
}

 

2. Member_Seminar.class

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member", "seminar", "payment"})
public class Member_Seminar extends BaseEntity {

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

    @ManyToOne(targetEntity = Member.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "member_no")
    private Member member;

    @ManyToOne(targetEntity = Seminar.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "seminar_no")
    private Seminar seminar;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "payment_no", referencedColumnName = "payment_no")
    private Payment payment;

    @Column(nullable=true)
    private LocalDateTime del_dt;

    public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }

    public void setPayment(Payment payment){
        this.payment = payment;
    }

}

 

Pagination에 JPA N+1 해결을 위해 FetchJoin 도입해보기

1. 페이징을 하면서 fetchjoin을 사용해봅니다.

페이징을 위해서 limit와 offset을 활용했습니다.

public List<Seminar> getListSeminarWithFetchJoin(int pageNo, int pageSize){
    QSeminar seminar = QSeminar.seminar;
    QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;
    QPayment payment = QPayment.payment;
 
    List<Seminar> seminarEntity = queryFactory
            .select(seminar)
            .from(seminar)
            .leftJoin(seminar.member_seminar_list, member_seminar)
            .fetchJoin()
            .leftJoin(member_seminar.payment, payment)
            .fetchJoin()
            .orderBy(seminar.seminar_no.desc())
            .offset(pageNo * pageSize)
            .limit(pageSize)
            .fetch();
    return seminarEntity;
}

2. Test코드 작성

@Transactional //Proxy 유지
@DisplayName("test getListForMember_SeminarAndPayment")
@Test
public void testgetListSeminarWithFetchJoin() throws InterruptedException {
    Long member_no = 1L;
    Long seminar_no = 2000028L;
    int pageNo = 0;
    int pageSize = 10;
    List<Seminar> seminar = seminarQuerydslRepository.getListSeminarWithFetchJoin(pageNo, pageSize);
    System.out.println("CNT:"+seminar.size());
    for(int i=0;i<seminar.size();i++){
        System.out.println("---------------------------------------");
        System.out.println("Seminar:"+seminar.get(i).toString());
        for(int j=0;j<seminar.get(i).getMember_seminar_list().size();j++){
            System.out.println(seminar.get(i).getMember_seminar_list().get(j).toString());
            System.out.println(seminar.get(i).getMember_seminar_list().get(j).getPayment().toString());
        }
    }
}

우선 테스트는 Out of Memory, JVM Memory Heap Space가 부족하여 실패합니다.

현재 테스트 환경은 Seminar는 200만건, member_Seminar는 800만건인 상태입니다.

로그를 확인해보겠습니다.

sql을 보면 한번에 모든 데이터를 잘 가져오고는 있습니다. 하지만, 왜 타임아웃이 발생하는것인지 확인해보겠습니다.

 

우선 첫번쨰 알아야할것은 모든 데이터를 메모리에 가져온뒤에 조작한다입니다.

Warning 메세지를 보면, firstResult/maxResults specified with collection fetch; applying in memory 라는 메세지가 있습니다. JPA에서는 fetch join을 활용하여 limit, offset을 활용할시, JPA는 fetch join을 통해 들어오는 중복되는 데이터를 받아올것을 염려하여 메모리에 해당 데이터를 모두 가져온뒤 Memory에 데이터를 올려두고 처리하기 때문에 위와 같은 사항이 발생합니다.

 

두번쨰로, 로그를 확인해보면 분명 limit와 offset을 적용했는데 limit와 offset 쿼리가 없습니다.

이는 첫번째 Warning 메세지에서 설명한듯이 모든 정보를 Memory에 올려놓고 그 안에서 Limit offset 쿼리를 처리합니다.

즉, 모든 데이터를 다 가져온뒤 limit, offset을 Application Level에서 처리합니다.

 

그렇기에, 우리는 fetchjoin을 활용해서는 Pagination을 활용할 경우 Out of Memory 가 발생합니다. 

BatchSize 활용해보기

BatchSize란 데이터베이스에서 데이터를 읽거나 쓸 때 한 번에 가져오거나 쓰는 데이터의 양을 지정하는 설정입니다

우리의 프로젝트에서는 Seminar에서 Member_Seminar 정보를 호출할떄 batchSize = 100 으로 설정하여서 100개의 데이터를 한번에 가져오거나 써보겠습니다.
1. Entity 에 @BatchSize 어노테이션을 추가합니다.
Seminar Entity에서 member_seminar_list에 batchsize를 100으로 선언합니다.

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_seminar_list"})
public class Seminar extends BaseEntity {

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

    @Column(length = 100, nullable = false, unique = true)
    private String seminar_name;

    @Column(length = 500)
    private String seminar_explanation;

    @Column
    private Long seminar_price;

    @Column
    private Long seminar_maxParticipants;

    @Column(nullable=true)
    private LocalDateTime del_dt;

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
    private List<Member_Seminar> member_seminar_list;

    public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }

    public void setSeminar_name(String seminar_name){
        this.seminar_name = seminar_name;
    }
}

2. SeminarQuerydslRepository.class 입니다.
@BatchSize를 활용할경우 fetchJoin이 적용되지 않습니다.

public List<Seminar> getListSeminarWithFetchJoin(int pageNo, int pageSize){
    QSeminar seminar = QSeminar.seminar;
    QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;
    QPayment payment = QPayment.payment;

    List<Seminar> seminarEntity = queryFactory
            .select(seminar)
            .from(seminar)
            .leftJoin(seminar.member_seminar_list, member_seminar)
            .orderBy(seminar.seminar_no.desc())
            .offset(pageNo * pageSize)
            .limit(pageSize)
            .fetch();

    return seminarEntity;
}

3. 테스트를 진행합니다.

@Transactional //Proxy 유지
@DisplayName("test getListForMember_SeminarAndPayment")
@Test
public void testgetListSeminarWithFetchJoin() throws InterruptedException {
    Long member_no = 1L;
    Long seminar_no = 2000028L;
    int pageNo = 0;
    int pageSize = 10;
    List<Seminar> seminar = seminarQuerydslRepository.getListSeminarWithFetchJoin(seminar_no, pageNo, pageSize);
    System.out.println("CNT:"+seminar.size()+" ");
    for(int i=0; i<seminar.size();i++){
        System.out.println(seminar.get(i).toString());
        System.out.println(seminar.get(i).getMember_seminar_list().toString());
    }
}

로그를 확인해봅니다.

442ms가 실행되었습니다.

batchSize가 작동하는것처럼 보이지만, 결국에는 각 세미나의 입장에서 보면 모든 member_seminar를 다시 호출하고있습니다. 우리가 원하는것처럼 Seminar_no를 가지고서 한번에 member_no를 다 불러들여서 JPA N+1 문제를 해결할 수 없었습니다.

 

여기까지 전체Seminar를 한번에 페이지네이션 해볼려는 작업이었습니다.

아래부터는 특정 Seminar를 Pagination하는 방식과 Member_Seminar 입장에서 페이징하는 방식을 담아보았습니다.

특정 Seminar입장(@One) 에서 Pagination 하기

위에서는 지금까지 전체 Seminar에 대하여 Pagination을 진행하였는데요, 만약에 특정 Seminar를 기준으로 Pagination Fetchjoin 한다면 손쉽게 가능합니다.

1. SeminarQuerydslRepository.java

public List<Seminar> getParticularListSeminarWithFetchJoin(Long seminar_no, int pageNo, int pageSize){
        QSeminar seminar = QSeminar.seminar;
        QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;
        QPayment payment = QPayment.payment;

        List<Seminar> seminarEntity = queryFactory
                .select(seminar)
                .from(seminar)
                .leftJoin(seminar.member_seminar_list, member_seminar)
                .fetchJoin()
                .where(seminar.seminar_no.eq(seminar_no))
                .orderBy(seminar.seminar_no.desc())
                .offset(pageNo * pageSize)
                .limit(pageSize)
                .fetch();

        return seminarEntity;
    }

 

2. SeminarReposotryTests.java

@Transactional // Proxy 유지
@DisplayName("test getListForMember_SeminarAndPayment")
@Test
public void testgetParticularListSeminarWithFetchJoin() throws InterruptedException {
    // Given
    Long seminar_no = 2000028L;
    int pageNo = 0;
    int pageSize = 50;

    // When
    List<Seminar> seminar = seminarQuerydslRepository.getParticularListSeminarWithFetchJoin(seminar_no, pageNo, pageSize);
    System.out.println("CNT:" + seminar.size() + " ");

    // Then
    for (int i = 0; i < seminar.size(); i++) {
        System.out.println(seminar.get(i).toString());
        System.out.println(seminar.get(i).getMember_seminar_list().toString());
    }

}

 

3.로그를 확인해봅니다. fetch join을 활용하여 완료했습니다.

실행시간은 390ms입니다.

fetch join을 활용해 N+1문제 또한 해결합니다.

Member_Seminar 입장(@Many) 에서 Pagination 하기

@Many 입장에서 Paging하는것은 일반적인 과정입니다. 그래도 한번 정리해보았습니다.

1. Member_SeminarQuerydslRepository.class 입니다.

public List<Member_Seminar> getMember_SeminarBySeminar_noPagination(Long seminar_no, int pageNo, int pageSize){
    QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;
    QSeminar seminar = QSeminar.seminar;

    List<Member_Seminar> list = queryFactory
            .select(member_seminar)
            .from(member_seminar)
            .where(member_seminar.seminar.seminar_no.eq(seminar_no))
            .leftJoin(member_seminar.seminar, seminar)
            .orderBy(member_seminar.member_seminar_no.desc())
            .offset(pageSize * pageNo)
            .limit(pageSize)
            .fetch();
    return list;
}

2. 테스트코드입니다.

@DisplayName("getMember_SeminarBySeminar_noPagination test")
@Test
public void getMember_SeminarBySeminar_noPagination(){
    Long member_no = 1L;
    Long seminar_no = 2000028L;
    int pageNo = 0;
    int pageSize = 10;
    List<Member_Seminar> list = memberSeminarQuerydslRepository.getMember_SeminarBySeminar_noPagination(seminar_no, pageNo, pageSize);
    list.stream().forEach(list2 -> System.out.println(list2));

}

로그를 확인해봅니다.

실행시간은 383ms입니다.

@ManyToOne으로 페이징의 방향을 바꿔서 진행해보았습니다. 

하지만, 이렇게 할경우에는 Seminar의 정보를 가지고 있지는 않습니다. 

그래도 위와 같이 단순하게 방향성을 바꾸는것만으로 문제를 단순화시킨다는 점을 기억해야할 것 같습니다.

마무리

지금까지 제가 이해한바로는, @OneToMany 방식의 Pagination은 RDBMS의 특성상 힘든 부분인 것 같습니다.

또한 hibernate에서도 해당 사항을 지원하지 않고 있습니다.

그렇기에 이러한 사항에서는 특정 세미나의 목록을 호출하거나 @ManyToOne 관계로 생각하여 진행하고, 필요한 세미나 정보는 DTO로 묶어서 조회성데이터로 연관시켜 반환하는 방법으로 진행하면 됩니다.

+ Recent posts