대규모 Insert 방식 선택 및 구현
먼저 Spring Data JPA + PostgreSQL을 사용하여 데이터 5천만건 페이징을 구현하기 위해서는 더미 데이터를 Insert 해야했습니다. 각종 자료를 리서치해보니 JPA로 데이터를 밀어 넣는 것보다 JDBC를 사용하는 것이 훨씬 성능이 좋다는 것을 알게 됐습니다.
순수 JDBC Insert 시 장단점
- 장점:
- 최적화 시 삽입속도가 가장 빠름
- 단점:
- SQL을 직접 관리해야한다.
- 직렬화/재시도 로직 직접구현
Spring Batch(JPA)
- 장점:
- 추상화된 API를 제공
- Chuck 처리, Skip 로직 내장
- 스케줄링 가능
- 단점:
- ORM 오버헤드 때문에 삽입 속도가 느림
저는 성능최적화가 가장 큰 목적이라 JDBC를 선택했습니다. reWriteBatchedInserts=true을 적용했을 때 1만건 삽입기준 4.3초가 걸리고 JPA를 최적화 했을 때 9초가 걸립니다. 거의 2배이상 차이가나서 빠른 테스트를 위해 JDBC를 선택했습니다.
jdbc:postgresql://localhost:5432/postgres?reWriteBatchedInserts=true&prepareThreshold=3
URL 주소를 위와 같이 설정해주고, postgresql.conf 파일도 최적화 해주었습니다.
postgresql.conf
shared_buffers = 4GB # 전체 메모리의 25% (16GB → 4GB)
work_mem = 2MB # 정렬/해시 작업 메모리
maintenance_work_mem = 1GB # 인덱스 생성 시 메모리
max_wal_size = 4GB # WAL 크기 증가
checkpoint_timeout = 30min # 체크포인트 간격 확장
synchronous_commit = off # 비동기 커밋
wal_buffers = 16MB
max_connections = 50 # 연결 수 제한
여기서 최적화 포인트는 비동기 커밋으로 I/O 부하를 감소시키고 WAL 파일의 크기를 조정하는 것입니다.
제 여유 메모리는 4GB라서 공유버퍼의 크기도 제한합니다.
저의 노트북 사양은 램16GB 여유램 4GB, cpu 4core, SSD 용량이 30gb 밖에 남지 않았기 때문에 메모리를 최적화 해주고 인덱스, 메타데이터, WAL, 행크기를 고려해서 SSD 용량이 충분확인 했어야 했습니다.
데이터를 삽입하고 평균 행크기를 측정해보니 대략 100바이트정도였습니다.
이를 계산해보니
30GB의 용량이면 데이터 3억건은 넣을 수 있다는 계산이 나왔습니다.
다만 wal, 인덱스, 메타데이터는 정확히 크기를 측정하기 어려워서 대략적으로 2억건까지는 넣을 수 있다고 가정하고 테스트에 들어갔습니다.
Gradle 프로젝트 생성
순수자바로 postgre에 바로 Insert 넣을거라서 Gradle 프로젝트를 간단히 생성해주고 아래와 같이 의존성을 설정해줍니다.
dependencies {
implementation 'org.postgresql:postgresql:42.7.5'
}
main.java
package org.example;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Random;
public class Main {
public static void main(String[] args) {
String url = "jdbc:postgresql://localhost:5432/postgres?reWriteBatchedInserts=true";
String user = "user01";
String password = "password";
int BATCH_SIZE = 1000;
try (Connection conn = DriverManager.getConnection(url, user, password)) {
String sql = "INSERT INTO post (post_title, post_content, place_name, likes_count, thumbnail, latitude, longitude, created_at, updated_at, users_id) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, now(), now(), ?)";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
// 대량 작업이므로 autoCommit을 false로 설정
conn.setAutoCommit(false);
Random random = new Random();
// 외래키 users_id에 해당하는 값 (미리 생성된 사용자가 있어야 함)
long dummyUserId = 1L;
for (int i = 1; i <= 50000000; i++) {
// 각 컬럼에 랜덤 데이터를 설정
pstmt.setString(1, "제목 " + i);
pstmt.setString(2, "내용 " + i);
pstmt.setString(3, "서울 " + random.nextInt(1000));
pstmt.setInt(4, random.nextInt(10000)); // 0 ~ 9999 사이의 랜덤 likes_count
pstmt.setString(5, null);
// 위도: -90 ~ 90, 경도: -180 ~ 180 사이의 랜덤 값
pstmt.setDouble(6, random.nextDouble() * 180 - 90);
pstmt.setDouble(7, random.nextDouble() * 360 - 180);
pstmt.setLong(8, dummyUserId);
pstmt.addBatch();
// 배치 사이즈마다 실행 및 커밋
if (i % BATCH_SIZE == 0) {
pstmt.executeBatch();
conn.commit();
System.out.println("Inserted " + i + " rows.");
}
}
// 남은 배치 처리
pstmt.executeBatch();
conn.commit();
System.out.println("데이터 삽입 완료.");
}
} catch (SQLException e) {
System.out.println("Insertion failed!");
e.printStackTrace();
}
}
}
이렇게 코드를 돌려보니
천만건 데이터 삽입에 들어간 시간은 약 5분44초가 걸렸습니다. 무사히 cpu, memory 사용량은 그렇게 크지 않았지만 나중에 노트북을 만져보니 상당히 뜨거웠습니다..
SSD를 확인해보니 여유용량 30GB에서 24GB로 약 6GB를 차지했습니다. postgre는 10개의 컬럼을 가진 하나의 테이블에 데이터 5천만건을 넣으면 약 6GB의 저장공간을 잡아먹습니다! 물론 컬럼마다 들어갈 데이터의 길이에 따라 다르긴하지만 더미데이터로 성능 최적화를 연습한다는 가정하에 대략적인 수치입니다.
서버 구동하기
원래 잘 뜨던 게시물과 회원정보가 나오지 않았다.
분명히 구현한 서비스는 SNS서비스라서 무한스크롤 페이징 처리를 하는데 처음은 1번부터 20번까지만 가져오니까 무사히 첫페이지가 잘 나올줄 알았습니다. 그런데 N+1 문제와 컴퓨터 CPU,Memory가 거의 100%를 차지하고 있는 것을 발견했습니다....
대용량 데이터를 다루면서 무조건 맞닥뜨릴 각오는 했지만 실제로 만나보니까 노트북 고장날까봐 조마조마 했습니다.
성능 최적화
먼저 크게 2가지 원인을 먼저 해결해야 했습니다.
- 인덱스 생성 및 쿼리실행 계획 분석
- N+1 문제 해결하기
인덱스 생성 및 쿼리실행 계획 분석
인덱스를 생성하기 전에 먼저 제 코드를보니 Page 객체가 있었습니다. Page 객체는 인덱스의 존재여부 따질 거 없이 전체 페이지의 개수를 카운트 해야하기에 count() 함수를 무조건 실행시켜서 5천만건의 데이터를 풀스캔 시키는 것이었습니다.
당연히 한번에 많은 데이터를 로드하려니까 제 cpu, memory는 부하가 올 수 밖에 없었습니다.
문제코드
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
Page<Post> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
최적화 코드
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
Slice<Post> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
Page 객체 대신에 Slice 객체를 사용해야합니다. Slice 객체는 전체 개수를 세어주지 않기 때문에 count()함수를 실행하지 않습니다. 때문에 테이블 풀스캔도 일어나지 않아서 성능저하가 없습니다.
전체 게시물 조회 API에도 똑같이 Slice 사용으로 변경해주었고, findAllByOrderByCreatedAtDesc(); 메서드도 최적화 해주어야했습니다.
// 모든 게시물 조회
@Transactional
@Override
public PostPagingResponse getAllPosts(String uuid, PostPagingRequest postPagingRequest) {
Page<Post> postPageList = postRepository.findAllByOrderByCreatedAtDesc(
PageRequest.of(postPagingRequest.getPage(), postPagingRequest.getCount()));
List<PostPreviewResponse> postPreviewResponses = new ArrayList<>();
postPageList.stream().forEach(post -> {
List<PostHashtag> postHashtag = postHashtagRepository.findByPostId(post.getId());
List<String> hashtags = new ArrayList<>();
for (PostHashtag hashtag : postHashtag) {
hashtags.add(hashtag.getHashtag().getHashtagName());
}
List<Image> images = imageRepository.findByPostId(post.getId());
postPreviewResponses.add(PostPreviewResponse.of(post, getPostImages(images), hashtags));
});
return PostPagingResponse.builder()
.page(postPagingRequest.getPage())
.postPreviewResponses(postPreviewResponses)
.isEnd(postPageList.isLast())
.build();
}
여기서 findAllByOrderByCreatedAtDesc(); 메서드는 페이지네이션을 지원하지만, findAll() 메서드 때문에 메모리에 전체 데이터를 로드한 후 페이지네이션을 지원합니다. Order By Desc는 정렬까지 수행해야해서 인덱스가 없으면 성능이 마찬가지로 급격히 저하됩니다. 정리하자면 Page 객체의 count() 테이블 풀스캔, findAllByOrderByCreatedAtDesc(); 메서드의 테이블 풀스캔 때문에 성능이 저하됐습니다.
이제 인덱스를 만들어 주어야합니다.이러면 findAllByOrderByCreatedAtDesc();를 실행했을 때 테이블 풀스캔이 아닌 인덱스 스캔을 하게 되어 성능이 개선됩니다.
CREATE INDEX idx_post_created_at ON post(created_at);
그리고 쿼리 실행 계획을 돌려보니 9분동안 Insert된 데이터는 약 2천만건이고, 9초가 걸리네요. Index Scan using인걸 보니 인덱스도 잘 태운거 같습니다.
서버를 다시 실행보니 그 결과는
페이징이 잘 되는 것으로 확인됩니다. 원래는 cpu 100%에 메모리도 죽을뻔 했는데 너무 빠르게 잘 나왔습니다.
API 응답속도를 확인해보니
하나의 페이지를 가져오는데 254ms 가 걸렸습니다. N+1 문제를 해결해주면 더욱 최적화 될 것이 분명합니다.
**참고자료**
https://www.javaguides.net/2020/02/java-jdbc-postgresql-batch-insert.html
https://www.timescale.com/blog/13-tips-to-improve-postgresql-insert-performance
https://persi0815.tistory.com/25
https://iwillcomplete.tistory.com/83
https://www.postgresguide.com/performance/explain/
--2탄--
https://weight-devlog.tistory.com/57
[Spring Boot] Spring 대용량 데이터 페이징 처리하기 2탄(Spring Data JPA + PostgreSQL)
[이전 편]- 1편 : [Spring Boot] Spring 대용량 데이터 페이징 처리하기 1탄(Spring Data JPA + PostgreSQL)이전편을 보고 오시면 N+1 문제 이외에 Page 객체를 사용함으로 발생한 테이블 풀스캔 문제와 인덱스 생
weight-devlog.tistory.com
'Spring' 카테고리의 다른 글
[Spring Boot] Spring 대용량 데이터 페이징 처리하기 2탄(Spring Data JPA + PostgreSQL) (0) | 2025.02.20 |
---|---|
[Spring Boot]Gradle 의존성 implementation vs runtimeOnly vs api 차이 (0) | 2025.02.17 |
[Spring] WebClient를 사용 해야하는 이유 [RestTemplate vs WebClient] 성능비교 (0) | 2024.06.08 |
[Spring Boot] Response Entity를 사용하는 이유와 잘 사용하는 법 (0) | 2024.06.01 |
[Spring Boot] @PostConstruct 사용법 (0) | 2024.05.23 |