레이블이 JPA인 게시물을 표시합니다. 모든 게시물 표시
레이블이 JPA인 게시물을 표시합니다. 모든 게시물 표시

10/16/2024

QueryDSL 페치 조인과 페이지네이션

포스트의 검색 API는 Elasticsearch로 구현하기로 정했지만 댓글, 사용자, 카테고리의 경우 포스트 만큼 전체 텍스트 쿼리가 필요한 경우가 상대적으로 적거나 거의 없다고 생각하여 Elasticsearch를 도입할 필요성을 못 느꼈다. 일단 JPA를 사용해서 조회와 목록은 구현했지만 N+1 문제랑 더불어 검색의 경우 마땅한 기능이 없어서 고민하던 와중 JPQL을 도와주는 QueryDSL을 발견했다! 사용자의 경우 이름만 검색하면 끝나기에 구현이 간단했지만 연관관계를 가지는 포스트, 카테고리, 댓글의 목록 기능에서 발생하는 N+1 문제와 연관관계를 가지는 경우 페치 조인과 페이지네이션에서 나오는 문제에 직면했다.


페치 조인과 페이지네이션

페치 조인을 단일 값 연관 필드(일대일, 다대일) 사용하면 페이징 API를 사용할 수 있지만 컬렉션(일대다)에 사용할 시 정상적으로 작동한다고 해도 applying in memory 즉, 모든 데이터를 메모리로 가져와 페이징 처리를 하기에 경고를 남긴다. 데이터가 많을 경우 성능 이슈와 메모리 초과 예외가 발생할 수 있다. 그렇다면 어떻게 해야할까? 한 가지 방법은 페치 조인을 적용하지 않고 엔티티의 아이디 목록을 가져온다. 아이디 목록을 In 절에 페치 조인을 적용한다. 즉, 같은 요청에 대해 같은 쿼리를 두 번 적용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public Page<PostEntity> findAll(Pageable pageable){
    JPAQuery<Long> countQuery;
    QPostEntity postEntity;
    List<PostEntity> postEntities;
    List<Long> postEntityIds;
    
    postEntity = QPostEntity.postEntity;
        
    postEntityIds = jpaQueryFactory
                        .select(postEntity.id)
                        .from(postEntity)
                        .offset(pageable.getOffset())
                        .limit(pageable.getPageSize())
                        .distinct()
                        .fetch();
            
    postEntities = jpaQueryFactory
                        .selectFrom(postEntity)
                        .leftJoin(postEntity.comments)
                        .fetchJoin()
                        .where(postEntity.id.in(postEntityIds))
                        .distinct()
                        .fetch();
        
    countQuery = jpaQueryFactory
                    .select(postEntity.countDistinct())
                    .from(postEntity);
    
    return PageableExecutionUtils.getPage(postEntities, pageable, countQuery::fetchOne);
}
cs


2개 이상의 컬렉션 페치 조인

페이지네이션과 더불어 페치 조인은 2개 이상의 컬렉션에 사용할 수 없다. 사용할 경우 MultipleBagFetchException 예외가 발생한다. 여기서 Bag는 Hibernate에서 사용하는 용어로 순서와 키가 없으며 중복을 허용한다. Java Collection은 Bag을 구현하지 않아 List를 사용한다. 따라서 1개의 Bag에 대한 페치 조인만 허락한다. 포스트는 카테고리, 댓글 그리고 파일을 가지는데 카테고리는 다대일 관계를 가지고 나머지는 일대다 관계이며 파일은 임베디드 타입이지만 조인문으로 쿼리를 수행한다. 카테고리, 댓글 그리고 파일을 동시에 페치 조인을 실행하면 위의 오류가 발생한다. 오류는 일대다 관계(컬렉션)인 댓글과 파일 때문에 발생한다. 어떻게 해야할까? 

대략적으로 3가지의 해결책이 있다. 1번 해결책은 @OrderColumn 어노테이션을 추가하는 것으로 List는 순서가 없으므로 Hibernate는 List를 Bag로 처리하는데 이를 List로 처리하도록 해주어야 한다. @OrderColumn 어노테이션이 이 역할을 수행한다. 2번 해결책은 페치 조인을 각각의 컬렉션에 수행하는 것이다. 즉, 하나의 쿼리를 여러 쿼리로 나누는 방법으로 가독성도 향상되고 쿼리를 개선할 수 있는 여지도 있다. 3번 해결책은 List를 Set으로 변경하는 것인데 엔티티가 영속성 처리가 되지 않은 상태에서 Set에 넣으면 동일한 필드 데이터 건의 경우 사라질 수 있는 문제가 있으며 카테시안 곱이 발생할 수 있다는 문제가 있다.

2번 해결책이 이상적이라는 글을 보아서 2번 해결책을 구현하기로 결정했다. 일단 댓글과 페치 조인을 실행한 후 나온 결과에 파일과 페치 조인을 적용한다. 마지막으로 여기서 카테고리와 페치 조인을 적용한다. 코드는 위의 코드에서 수정되어 다음과 같다. 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Override
public Page<PostEntity> findAll(Pageable pageable) {
    JPAQuery<Long> countQuery;
    QPostEntity postEntity;
    List<PostEntity> postEntities;
    List<Long> postEntityIds;
 
    postEntity = QPostEntity.postEntity;
    orders = getOrderSpecifier(pageable);
 
    postEntityIds = jpaQueryFactory
                        .select(postEntity.id)
                        .from(postEntity)
                        .offset(pageable.getOffset())
                        .limit(pageable.getPageSize())
                        .distinct()
                        .fetch();
 
    postEntities = jpaQueryFactory
                        .selectFrom(postEntity)
                        .leftJoin(postEntity.comments)
                        .fetchJoin()
                        .where(postEntity.id.in(postEntityIds))
                        .distinct()
                        .fetch();
 
    postEntities = jpaQueryFactory
                        .selectFrom(postEntity)
                        .leftJoin(postEntity.files)
                        .fetchJoin()
                        .where(postEntity.id.in(postEntityIds))
                        .distinct()
                        .fetch();
 
    postEntities = jpaQueryFactory
                        .selectFrom(postEntity)
                        .leftJoin(postEntity.category)
                        .fetchJoin()
                        .where(postEntity.id.in(postEntityIds))
                        .distinct()
                        .fetch();
 
    countQuery = jpaQueryFactory
                    .select(postEntity.countDistinct())
                    .from(postEntity);
 
    return PageableExecutionUtils.getPage(postEntities, pageable, countQuery::fetchOne);
}
cs

update: 2024.10.16

10/13/2024

JdbcTemplate으로 업로드 파일 Bulk Insert

포스트는 사진과 같은 파일을 등록할 수 있다. 그런데 현재 코드는 2개 이상의 파일을 업로드하면 반복문을 통해서 파일을 하나씩 데이터베이스에 저장한다. 파일이 적은 경우 문제가 없지만 만약에 개수가 많아지면 반복문을 통한 삽입문은 당연히 비효율적일 수 밖에 없다. 파일 6개를 삽입하는데 728ms 정도가 걸린다. Bulk Insert는 여러 개의 데이터를 하나의 삽입문으로 삽입한다. 개별 삽입문은 실행 후 응답을 받은 후에야 다음 삽입문을 실행하기 때문에 지연이 발생하지만 Bulk Insert는 일련의 삽입문을 하나의 트랜잭션으로 묶여 실행하기 때문에 성능이 좋다.


JPA saveAll() 메서드

JPA의 saveAll() 메서드는 이름만 보면 Bulk Insert를 실행할 것 같지만 내부 구현을 보면 그렇지 않다는 것을 알 수 있다. 반복문을 돌면서 save() 함수를 호출한다. 결론은 JPA의 아이디 생성 전략으로 GenerateType.IDENDITY 사용 시 Batch Insert가 비활성화 된다. JPA는 영속성 컨텍스트와 쓰기 지연이라는 특성을 가진다. 영속성 컨텍스트는 엔티티를 영구 저장하는 환경으로 애플리케이션과 데이터베이스 사이에서 객체를 보관하는 가상 데이터베이스 같은 역할을 한다. JPA는 영속성 컨텍스트 내부에 엔티티 타입과 기본키 값을 저장해 엔티티를 식별한다. 쓰기 지연(transaction write-behind)은 영속성 컨텍스트에 변경사항이 발생하면 SQL을 버퍼에 모아뒀다가 플러시(flush)하는 시점(트랜잭션이 커밋 시점)에 데이터베이스로 쿼리를 보내 실행한다. 

아이디 생성 전략이 IDENTITY인 경우 삽입문을 실행하기 전에는 기본키를 알 수 없기에 즉시 삽입문을 실행해서 기본키 값을 가져와야 한다. 그런데 이는 Hibernate가 채택한 쓰기 지연 플러시 전략을 방해한다. 게다가, Bulk Insert를 실행하는 경우 즉시 데이터베이스에 Bulk Insert를 실행하고 삽입한 모든 데이터를 영속화해 영속성 컨텍스트에서 관리해야 한다. 이는 너무 많은 데이터를 영속화시켜 JVM에 할당된 메모리가 부족해질 수 있다. 이러한 이유로 Hibernate는 아이디 생성 전략이 IDENTITY인 경우 JDBC 수준에서 Batch Insert를 비활성화 한다. 따라서 JPA에서 batch insert를 사용하기 위해서는 GenerateType.SEQUENCE나 GenerateType.TABLE을 사용해야 한다.


JdbcTemplate

GenerateType.IDENTITY 아이디 전략을 사용하는 경우 Batch Insert를 어떻게 실행할 수 있을까? JdbcTemplate를 사용해서 JDBC의 batchUpdate() 메서드를 통해 Batch Insert를 실행할 수 있다. MySQL에서 이 기능을 사용하려면 데이터베이스 URL 환경 변수에 rewriteBatchedStatements를 true로 설정해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 하나의 파일마다 삽입문이 실행된다.
file =  new File(fileExtension, mimeType, filename, filePath, fileSize);
postEntity.getFiles().add(file);
 
// Bulk Insert로 다수의 파일에 단일 삽입문이 실행된다.
@Override
public void bulkInsert(Long postId, List<File> files) {
    String sql = "INSERT INTO file(post_id, extension, mime_type, name, path, size) VALUES(?, ?, ?, ?, ?, ?)";
    jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
        @Override
        public void setValues(PreparedStatement ps, int i) throws SQLException {
            File file = files.get(i);
            ps.setLong(1, postId);
            ps.setString(2, file.getExtension());
            ps.setString(3, file.getMimeType());
            ps.setString(4, file.getName());
            ps.setString(5, file.getPath());
            ps.setLong(6, file.getSize());
        }

        @Override
        public int getBatchSize() {
            return files.size();
        }
    });
}
cs




성능

4개의 파일(27MB)을 업로드했는데 삽입문을 반복해서 사용하는 경우 5.18초가 걸렸고 Bulk Insert를 사용한 결과 0.742초가 걸렸다. 63개의 파일(398MB)의 경우 19.07초에서 11.17초로 확실히 성능은 향상되었다. 하지만 그래도 뭔가 좀 아쉽다... 더 빠를 수 없을까? 아니면 혹시 다른 곳에 원인이 있을까?


TODO

여러 블로그에서 40배-50배 정도 성능 차이가 난다는 것을 보았다. 입력 파일을 임베디드 타입으로 변환하는 과정은 여전히 for 반복문을 사용한다. 그래서 for 반복문과 변환 과정이 어느 정도 성능에 영향을 준다고 생각해서 여러 방안을 생각했다. 일단 요즘 대부분의 컴퓨터가 다중 프로세서와 다중 코어이다 보니 다중스레드를 사용하는 방법이 떠올랐다. 다른 방법은 Spring Batch를 사용하는 것인데 이 방법을 공부하고 난 다음 구현을 해야겠다.

update: 2024.10.15