대량 데이터를 엑셀로 다운로드하는 API가 있다.
몇백 건 정도일 경우 엑셀 다운로드 하는 데에 오랜 시간이 걸리지 않지만, 수십만~수백만 건의 데이터를 엑셀로 다운로드하는 경우 수십초를 기다려야 했었다.
또한 Java heap 메모리도 굉장히 많이 차지했다.
보통의 경우 JVM의 GC가 알아서 잘 동작하기 때문에 Heap 메모리를 걱정할 이유가 없지만, 해당 api를 사용하면 heap usage가 한계치까지 도달하는 모습을 확인할 수 있었다. G1 Eden, Survivor 영역에서 나아가 G1 Old Gen이 1GB를 넘기는 현상까지 발생했다.
따라서 엑셀에서 csv로 변경하였는데 이번에는 이러한 일대기를 작성해보려 한다.
Apache POI 라이브러리
엑셀 다운로드가 어떠한 로직으로 작성돼있었는지부터 확인해보자.
라이브러리 버전은 가장 많이 사용되고 있는 4.1.2 버전을 사용하고 있었다.
build.gradle
1
2
3
4
5
|
dependencies {
// 회원정보 excel 다운로드
implementation 'org.apache.poi:poi:4.1.2'
implementation 'org.apache.poi:poi-ooxml:4.1.2'
}
|
cs |
POI 라이브러리들을 build.gradle에 삽입해주었다.
MemberController
1
2
3
4
5
6
7
8
9
10
11
12
|
@GetMapping("/members/excel")
public void excelDownLoadBy(HttpServletResponse response) throws IOException {
response.setHeader("Content-Disposition", "attachment; filename=\"member-excel" + "\".xlsx");
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
ServletOutputStream outputStream = response.getOutputStream();
memberService.excelDownLoadBy(outputStream);
outputStream.flush();
outputStream.close();
}
|
cs |
엑셀 다운로드를 위한 HttpServletResponse 헤더와 contentType 세팅을 해주었다.
MemberService
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
49
50
51
52
53
|
public void excelDownLoadBy(OutputStream outputStream) {
try (SXSSFWorkbook wb = new SXSSFWorkbook()) {
SXSSFSheet sheet = wb.createSheet();
int rowIdx = 1;
createHeaderRow(sheet);
CellStyle cellStyle = wb.createCellStyle();
CreationHelper createHelper = wb.getCreationHelper();
cellStyle.setDataFormat(createHelper.createDataFormat().getFormat("yyyy.MM.dd HH:mm"));
List<Member> members = memberRepository.findAll();
for (Member member : members) {
Row row = sheet.createRow(rowIdx);
row.createCell(0).setCellValue(member.getNickname());
row.createCell(1).setCellValue(member.getNickname().substring(1));
row.createCell(2).setCellValue(member.getNickname().substring(0, 1));
row.createCell(3).setCellValue(member.getPhone());
row.createCell(4).setCellValue(member.getGender());
row.createCell(5).setCellValue(member.getEmail());
row.createCell(6).setCellValue(member.getCode());
row.createCell(7).setCellValue(Date.from(member.getCreatedAt().toInstant(ZoneOffset.ofHours(9))));
rowIdx = rowIdx + 1;
}
// 메모리 사용량 기록
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
Row row = sheet.createRow(rowIdx);
row.createCell(0).setCellValue("사용메모리");
row.createCell(1).setCellValue(String.valueOf(usedMemory));
wb.write(outputStream);
} catch (IOException e) {
log.error("excel 파일 작성 중 에러가 발생했습니다.");
throw new RuntimeException(e);
}
}
private void createHeaderRow(SXSSFSheet sheet) {
SXSSFRow row = sheet.createRow(0);
row.createCell(0).setCellValue("닉네임");
row.createCell(1).setCellValue("이름");
row.createCell(2).setCellValue("성");
row.createCell(3).setCellValue("휴대전화");
row.createCell(4).setCellValue("성별");
row.createCell(5).setCellValue("이메일");
row.createCell(6).setCellValue("난수");
row.createCell(7).setCellValue("생성일시");
}
|
cs |
코드가 상당히 길다.
try-with-resource로 SXSSWorkbook을 생성해준 후, excel row를 만들어주는 부분이다.
메모리 자원을 효율적으로 아끼기 위해 try-with-resource를 사용해주었다.
CellStyle을 이용하여 데이터 format을 지정해줄 수 있다.
Grafana 적용이 귀찮아서(...) Runtime.getRuntime() 메서드로 로그를 찍어서 메모리를 확인해보았다.
opencsv 라이브러리
build.gradle
1
2
3
4
|
dependencies {
// 회원정보 csv 다운로드
implementation("com.opencsv:opencsv:5.7.1")
}
|
cs |
opencsv 라이브러리 역시 가장 많이 사용되면서 최신인 5.7.1 버전을 이용했다.
MemberController
1
2
3
4
5
6
7
8
9
10
11
12
|
@GetMapping("/members/csv/write-next")
public void csvDownLoadByWriteNext(HttpServletResponse response) throws IOException {
response.setHeader("Content-Disposition", "attachment; filename=\"member-csv" + "\".csv");
response.setContentType("text/csv; charset=MS949"); // UTF-8 저장 시, CSV의 경우 한글 깨짐 이슈
ServletOutputStream outputStream = response.getOutputStream();
memberService.csvDownloadByWriteNext(outputStream);
outputStream.flush();
outputStream.close();
}
|
cs |
ApachePOI와 다른 점은 contentType이다.
charset이 UTF-8이 아닌 MS949이며, "text/csv"도 명시를 해주었다.
MemberService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public void csvDownloadByWriteNext(OutputStream outputStream) {
try (
OutputStreamWriter sw = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
CSVWriter csvWriter = new CSVWriter(sw)
) {
csvWriter.writeNext(new String[]{"닉네임", "이름", "성", "휴대전화", "성별", "이메일", "난수", "생성일시"});
memberRepository.findAll()
.forEach(member -> csvWriter.writeNext(MemberDto.getMemberDetailToConvertCsvRows(member)));
// 메모리 사용량 기록
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
csvWriter.writeNext(new String[]{"사용메모리", String.valueOf(usedMemory)});
} catch (IOException e) {
log.error("csv 파일 작성 중 에러가 발생했습니다.");
throw new IllegalStateException("csv 파일 작성 중 에러가 발생했습니다.", e);
}
}
|
cs |
opencsv 라이브러리의 csvWriter를 이용했으며, 마찬가지로 try-with-resource 내에서 생성해주었다.
csvWriter에는 writeNext, writeAll 메서드들이 존재한다. 각각 new String[], List<String[]> 꼴로 넣어주면 된다.
성능 비교
환경
- AWS EC2: t2.xlarge (4vCPU, RAM 16GiB)
- AWS RDS: MySQL - t3.small (2vCPU, RAM 2GiB)
우리가 확인하려는 것은 첫째로 응답시간, 둘째로 메모리이다.
그렇기 때문에 RDS 스펙보다는 EC2 스펙이 중요하다고 판단해 EC2는 t2.xlarge를 이용했다.
(물론 내 돈으로는 못하고... 사내 aws 놀이터를 이용했다.)
또한, EC2 vCPU 수가 4대이기 때문에, parallel 병렬처리를 이용할 때에도 성능개선 효과가 있으리라 기대했다.
따라서 아래와 같이 parallel() 코드를 넣은 로직과도 비교해보았다.
결과 (3번 수행 평균값)
1,000건 | 50,000건 | 500,000건 | |
Apache POI 엑셀 | 137 ms | 1929 ms | 16129 ms |
csv (writeNext 메서드) | 87 ms | 950 ms | 9410 ms |
csv (writeAll 메서드) | 97 ms | 1141 ms | 10612 ms |
csv (writeNext parallel) | 89 ms | 978 ms | 8911 ms |
csv (writeAll parallel) | 92 ms | 1085 ms | 9453 ms |
엑셀 다운로드가 csv 다운로드보다 오래 걸리는 것은 확실하다.
한가지 더 신기했던 것은, 대체로 writeNext() 가 writeAll() 보다 빠르다는 점.
그리고 더 신기했던 것은 parallel()을 사용해도 크게 성능 차이는 없다는 점이었다. 특히 데이터 수가 소량일 때에는 아예 성능 차이가 없다시피 했다.
4vCPU 이기 때문에 스레드풀 core size를 4로 설정했다.
core size가 1은 아니기 때문에, 병렬로 처리하면 최소 2~3배는 빨라질 것이라 예상했다.
하지만 결과는? parallel()은 그렇게 빠르지 않았다. 특히 소량의 데이터의 경우 거의 차이가 없었다.
그 이유를 고민해봤는데, 아래 원인이 가장 크지 않을까 싶다.
Sequential streams outperformed parallel streams when the number of elements in the collection was less than 100,000. (컬렉션의 요소 수가 100,000개 미만일 때 순차 스트림이 병렬 스트림보다 성능이 뛰어났습니다.)
A parallel stream has a much higher overhead compared to a sequential stream. (병렬 스트림은 순차 스트림에 비해 오버헤드가 훨씬 높습니다.)
출처: https://dzone.com/articles/should-i-parallalise-streams
parallelStream() 의 경우 i/o 비용이 발생하여 소량 데이터의 경우 오히려 느려질 수 있다는 글이다.
심지어 parallelStream() 을 사용할 경우, 순서 보장 또한 되지 않는다.
물론 위 데이터의 경우 회원정보를 다운로드 받을 때 순서가 크게 중요하진 않지만... 보통 엑셀이나 csv로 통계를 낼 때 순서는 굉장히 중요할 것이다. 따라서 데이터 수가 소량인 경우가 많거나, 순서가 보장이 돼야 한다면 parallelStream은 자제하는 것이 좋아보인다.
여담으로, apache poi가 성능이 비교적 느리기 때문에 fastexcel 이라는 것도 생겨났다고 한다.
https://stackoverflow.com/questions/69914445/apache-poi-large-excel-export-is-slow
하지만 기획 상 csv가 가능하다면 csv가 짱인 듯 하다.
코드도 간단해지고, 속도도 fastexcel, apache poi보다 훨씬 빠르니 말이다.
aws를 맘편하게 이용할 수 있는 환경이라 위와 같은 실험을 할 수 있었다.
다음에는 한번 EC2 스펙에 따른 성능 비교도 해보아야겠다 ㅎㅎ
'JAVA > JAVA | Spring 학습기록' 카테고리의 다른 글
[231023] 흔한 백엔드 개발자 모임 컨퍼런스 후기 (29) | 2023.10.26 |
---|---|
[Spring] @async 로직 실패 일대기, ThreadPoolTaskExecutor의 awaitTerminate와 Async (44) | 2023.09.23 |
[230826] 유스콘 2023 컨퍼런스 후기 (51) | 2023.08.27 |
[JUnit5] Archunit 라이브러리를 이용한 아키텍처 테스트 (0) | 2023.07.26 |
[Spring] Elasticache Redis 캐싱과 테스트 코드를 이용한 성능 개선 (2) | 2023.05.29 |