Intellij Profiler와 함께하는 POI 대용량 엑셀 다운로드 성능 개선기 (1)
date
Jan 15, 2025
slug
optimize-large-excel-poi-performance-with-intellij-profiler-1
status
Published
tags
Backend
summary
인텔리제이 프로파일러와 함께하는 수천만 건의 데이터에 대한 엑셀 다운로드 성능 개선기
type
Post
들어가며
소개
이번 글에서는 Apache POI를 활용하여 대량의 데이터에 대한 엑셀 다운로드 기능에 대한 성능 개선 경험을 다뤄보겠습니다.
문제 상황
먼저 해당 기능에 대한 요구사항은 다음과 같습니다.
- 최소 220만건 ~ 최대 1100만건의 데이터에 대하여
- 1vCPU, 2GB 스펙의 인스턴스로
- 최대 3~5분내로 작업을 완료해야 합니다.
하지만 기능 개발 후 테스트 결과 개발 환경에서는 OOM이 발생했고, 로컬 환경에서도 실행에 176초가 소요되는 등 여러모로 최적화가 필요한 상황이었습니다.
참고로 로컬 개발에는 MacBook M3 Pro, 18GB를 사용 중이며 개발 서버의 스펙은 1vCPU, 1GB입니다. 메모리를 증설한다 하더라도 작업 시간 측면에서 감당이 안될 게 뻔해보였죠.
구현 설명
먼저 엑셀 로직이 어떻게 구현되어 있는지 간단하게 알아보도록 합시다.
FETCH_SIZE 의 경우 슬라이딩 윈도우의 크기와 페이징 쿼리의 크기를 지정합니다. SXSSFWorkbook 이 생성자 인자로 슬라이딩 윈도우 크기를 받고 있는 것을 보실 수 있습니다.하나의 워크북 파일은 2개의 시트로 구성되어 있습니다. bookInfoSheet가 가장 많은 리소스를 소모하고, taskInfoSheet는 그에 비하면 상대적으로 적은 리소스를 소모합니다. 실제로 쿼리를 날려서 데이터를 받아오고, 이를 시트에 쓰는 과정은
populateSheets 에서 발생합니다. populateSheets 의 구현도 같이 봅시다.먼저 도서에 대해 그룹화된 작업을 커서 기반 페이징 쿼리를 통해 가져오는 것을 볼 수 있습니다. 이때 Slice의 크기는 아까 전에 지정한 FETCH_SIZE에 해당합니다. 모든 데이터를 처리할 때까지 do-while 루프를 반복합니다.
마지막으로 페이징 쿼리가 어떻게 구현되어 있는지, 살짝만 보고 넘어갑시다.
먼저 도서를 조회합니다. 페이징을 위한
gtBookId 조건 및 검색조건에 해당하는 matchesCondition 을 적용합니다. 세부 로직은 생략하겠습니다.앞에서 작업과 작업항목이 일대다 관계를 맺고 있다고 했었죠? 작업에 작업항목을 조인하게 되면, 당연히 중복된 작업을 가진 레코드가 발생합니다. 이를 해결하기 위해
transform(groupBy(task.id).list(task)) 를 통해 하나의 작업 ID만 존재하도록 그룹화합니다.이후
Map<Long, List<Task>> 를 Map<Book, List<Task>> 으로 변환해주면 최종적으로 조회가 완료됩니다.인텔리제이 프로파일링 알아보기
개요
Intellij IDEA 2024.2 Ultimate 버전부터, 애플리케이션 실행 시 Run 도구 창 우측에 Performance 탭이 추가되었습니다. 여기서 간단한 CPU 및 Heap 메모리 사용 정보를 확인할 수 있습니다. (참고)
사용법
실제 화면은 아래와 같이 생겼습니다.

Performance 탭 상단의 레코딩 시작 및 정지 버튼을 통해, 프로파일링을 위한 스냅샷을 캡쳐할 수 있습니다. 사용법은 간단합니다. API 요청 전에 레코딩을 시작하고, 해당 요청이 끝나면 레코딩을 종료하면 됩니다. 생성된 스냅샷은 Profiler 탭에서 언제든지 다시 확인하실 수 있습니다.
스냅샷 분석
프로파일러 스냅샷은 다양한 방식으로 분석할 수 있습니다. 인텔리제이에서는 1) Flame Graph 2) Call Tree 3) Method List 4) Timeline 5) Events 6) Line Profiler를 통해 스냅샷에 대한 인사이트를 제공합니다. 개인적으로는 Flame Graph와 Line Profiler 두 가지를 가장 유용하게 사용하고 있습니다.
이 글에서도 이 둘을 메인으로 사용할 예정입니다. 따라서 간단한 사용법을 같이 알아봅시다.
플레임 그래프
플레임 그래프의 경우, 시간 경과에 따른 정보를 제공하지 않습니다. 대신 전체 리소스에 대한 점유율을 기반으로 한 정보를 제공합니다. 처음 접한다면 어떻게 그래프를 봐야 하는지 헷갈릴 수도 있기에 유의가 필요합니다.
두 가지만 숙지하면 됩니다.
- call stack을 기반으로 아래에서 → 위로 쌓이는 형태입니다.
- 같은 stack이라면, 리소스 점유율이 큰 것부터 좌측에서 → 우측으로 정렬됩니다.

이 사진을 보면서 이해해봅시다.
먼저 아래에서 → 위로 그래프를 보면, 호출 스택에 따라
main → a() → c() 순으로 메서드가 호출되고 있음을 알 수 있습니다.그리고 2번째 줄을 보면,
main 은 같은 층위에서 a(), b() 메서드를 호출하고 있다는 것 역시 알 수 있습니다. 이때, a() 가 b() 보다 많은 리소스를 사용했기에, 좌측으로 정렬되어 a() → b() 순서로 나타내어진다는 것을 알 수 있습니다. a() 가 b() 보다 먼저 실행된 것일까요? 아닙니다. 아까 말했듯 플레임 그래프는 시간 경과에 따른 정보를 제공하지 않습니다. 따라서 이 그래프만 보고서는 둘의 순서를 명확하게 알 수 없습니다.이제 실제로 플레임 그래프 기능을 사용해봅시다. 아래와 같이, 각 박스의 색상이 다른 것을 볼 수 있습니다.

다크 모드 기준으로, 파란색 → 자바 표준 라이브러리, 회색 → 외부 라이브러리, 주황색 → 프로젝트 내 메서드를 의미합니다.
플레임 그래프는 인텔리제이 뿐만 아니라 Google Cloud, Visual Studio, AWS 등 다방면에서 성능 프로파일링을 위해 범용적으로 사용되는 시각화 도구입니다.
더 자세히 알고 싶다면 플레임 그래프를 처음 제안한 Brendan Gregg님이 작성하신 소개글(링크)을 참고하시면 좋습니다.
라인 프로파일러
다음으로 라인 프로파일러입니다. 플레임 그래프를 분석하는 것이 처음에는 다소 비직관적으로 느껴질 수도 있습니다. 이때 라인 프로파일러를 사용하면, 실제 소스 코드 상에서 각 메서드 별로 사용된 리소스를 직관적으로 파악할 수 있다는 장점이 있습니다.

느린 메서드는 회색 레이블을 통해 표시되며, 각 스코프에서 가장 많이 사용된 리소는 빨간색 레이블로 표시됩니다. 이 리소스는 위 스크린샷처럼 걸린 시간(엄밀하게는 CPU Time)일 수도 있고, 메모리 사용량일 수도 있습니다.
상단 프로파일러 창 우측 보기 모드를 변경하면 어떤 리소스에 대해서 볼 것인지를 선택할 수 있습니다.


이처럼 Memory Allocations를 기준으로 변경하면, 레이블 상에 적힌 값도 바뀌는 것을 볼 수 있습니다.
정리
지금까지 인텔리제이 프로파일러 도구 6가지 중 가장 유용하게 사용되는 도구인 1) 플레임 그래프와 2) 라인 프로파일러에 대해서 알아보았습니다. 그 외 도구들에 대한 사용법은 이 링크에서 더 자세히 다루고 있으니 참고해보시길 바랍니다.
이제 프로파일러를 어떻게 사용하고 분석하는지 알았으니, 위의 엑셀 생성 로직을 어떻게 개선했는지를 알아봅시다.
SXSSF를 적용하자
소개
만약 이 글을 검색해서 보셨다면 가장 먼저 접하셨을 방법이라 생각합니다. 대용량 데이터에 대한 Apache POI 성능 개선 방법으로 가장 많이 언급되는 것이 SXSSF입니다. 이에 대해 자세히 다룬 글들이 많기도 하고, 위 문제 상황은 SXSSF를 적용한 상황에서 발생한 것이기도 하여, 소개 자체는 간단하게 언급하고 넘어가도록 하겠습니다.
SXSSF(패키지: org.apache.poi.xssf.streaming)는 매우 큰 스프레드시트를 생성해야 하고 힙 공간이 제한될 때 사용되는 XSSF의 API 호환 스트리밍 확장입니다. SXSSF는 슬라이딩 윈도우 내에 있는 행에 대한 액세스를 제한하여 낮은 메모리 공간을 달성하는 반면, XSSF는 문서의 모든 행에 대한 액세스를 제공합니다. 더 이상 창에 없는 이전 행은 디스크에 기록되므로 액세스할 수 없게 됩니다.
즉 SXSSF 구현체는 슬라이딩 윈도우 기법을 활용하여, 윈도우의 크기만큼만 데이터를 메모리에 로드하여 쓰고, 이를 (디스크의 임시 파일로) 플러시 한 뒤 윈도우를 이동시키는 방법을 사용합니다.
윈도우 크기가 핵심
기본 창 크기는 100 이며 SXSSFWorkbook.DEFAULT_WINDOW_SIZE로 정의됩니다.
OOM을 해결하기 위해서는, 당연하지만, 메모리 사용량을 줄여야 합니다. 그리고, SXSSF 방식을 사용하면 메모리 사용량을 줄일 수 있습니다. 여기에 가장 큰 영향을 주는 것이 바로 슬라이딩 윈도우의 크기입니다.
윈도우 크기의 경우 여러 성능 요소 간의 trade-off를 고려하여 적절히 결정해야 합니다. 뒤에서 설명하겠지만, 저는 엑셀에 쓸 데이터를 윈도우 크기만큼 쿼리해오도록 구현했습니다. 가령 윈도우 크기가 100이고 전체 데이터가 5만 건이라면, 쿼리 limit의 크기도 100이 되므로 총 500회의 쿼리가 발생하겠죠.
윈도우 크기를 줄인다면 힙 사용량을 작게 유지할 수 있다는 장점이 있습니다. 하지만 쿼리 횟수가 증가하기 때문에, 그로 인한 오버헤드를 감당해야 한다는 단점이 있습니다.
따라서 적절한 힙 메모리 사용량을 유지하여 OOM을 방지하면서도, 오버헤드를 줄여서 실행 시간은 단축하는 것이 이번 최적화의 핵심이라 할 수 있겠습니다.
최적의 FETCH_SIZE 값 결정하기 (1)
초기
FETCH_SIZE의 경우, 슬라이딩 윈도우의 기본값인 100으로 설정했습니다. FETCH_SIZE 가 커질수록 사용되는 힙 메모리는 증가하는 대신, IO 오버헤드가 감소할 것이므로, 소요 시간 역시 줄어들 것이라 예상했습니다.FETCH_SIZE 를 5, 10, 25, 50, 100, 200, 400, 800, 1600, 3200으로 2배씩 증가하는 시나리오를 통해 측정했습니다. 측정한 지표는 두 가지로 1) 실행 시간 2) 최대 힙 메모리이며, 측정 결과 다음과 같은 인사이트를 얻을 수 있었습니다.다음은 실행 결과를 그래프로 시각화한 것입니다.

먼저 실행 시간에 대한 분석 결과입니다.
- 먼저 fetch size가 증가하면 전반적으로 실행 시간이 감소합니다.
- 최소값 5에서 최댓값 3200으로 증가하면서, 약 92(41%)의 성능 개선이 있었습니다.
- 가장 큰 변화는 5 → 10으로 증가할 때였으며, 약 51초(23%) 개선되었습니다.
그 다음은 최대 힙 메모리, 즉 메모리 사용량에 대한 분석 결과입니다.
- fetch size가 증가하면 대체로 최대 힙 메모리 사용량도 증가합니다.
- 5 ~ 100까지는 메모리 사용량이 크게 변하지 않습니다. (2464MB → 2550MB으로 소폭 증가)
- 100 이후부터 메모리 사용량이 점차 증가하며 3200에서는 3458MB까지 도달합니다.
최적의 FETCH_SIZE 값 결정하기 (2)
테스트 결과를 종합적으로 분석했을 때, 다음과 같은 인사이트를 얻을 수 있었습니다.
- 실행 시간과 메모리 사용량을 고려할 때, 최적 지점은 fetch size가 100일 때임을 알 수 있습니다.
- fetch size가 100 이후인 시점부터는 실행 시간 대비 메모리 사용량 증가가 더 커집니다.
- 100 → 3200으로 증가할 때 실행 시간은 약 7초(10%) 감소하지만, 메모리는 908MB(36%) 증가합니다.
- 실행 시간을 약간 더 개선하고 싶다면, 100 ~ 800 사이 값도 충분히 유효합니다. 다만 800 이후부터는 개선 수치에 비해 메모리 사용량이 너무 과도하게 증가하기에 굳이… 싶긴 합니다.
추가로, 단순히 위 수치만으로는 비교할 수 없는 외부 요인들이 있습니다.
- 저희 프로젝트는 내부 사업관리를 위한 솔루션이다 보니, 실행 시간보다 메모리 사용량이 더 비싼 리소스였습니다. 너무 오래 걸리는 것도 문제이긴 하지만, 그렇다고 스펙을 무리하게 늘리거나 성급하게 최적화하면서까지 빠르게 동작해야 하는 기능은 아니었습니다.
- 거기에 엑셀 기능은 업무 특성 상 흔히 배치성 기능들과 비슷합니다. 그리고 배치성 기능들이 대개 그렇듯이, (어차피) 길게 걸릴 것을 상정하고 실행되는 작업들이다 보니 실행 시간 단축이 그렇게 중요하지는 않았습니다.
- 한편 해당 로직을 처리하는 백엔드 애플리케이션의 경우, 당시 시점에는 수 명의 어드민만이 사용하기 때문에, 적은 리소스로도 운영이 가능하다고 판단했고 실제로도 그렇게 운영 중인 상황이었습니다. 운영 서버가 1vCPU + 2GB로 t계열 micro 인스턴스와 동급의 스펙이었을 정도니까요.
- 이런 상황에서 ‘해당 기능 단 하나 때문에’ ‘크게 중요하지 않은 실행 시간 단축을 위해서’ 서버 메모리 스펙을 증설하는 것이 좋은 선택으로 보이지는 않았습니다. 오히려 약간 더 오래 걸리더라도, 최적 지점인 100에서 50으로 낮추는 것도 충분히 고려할만한 선택지로 보였습니다.
하지만 결론적으로는 100을 선택했습니다.
이유는 별 거 없는데요, 아래에서 진행할 모든 최적화를 수행한 후에는 50이나 100이나 충분히 감당 가능한 수준이라고 보았습니다. 반대로 어차피 현재 시점에서는 50이나 100이나 메모리 사용량이 2GB를 넘기 때문에 어떤 값을 고르더라도 의미가 없었습니다.
번외 - IO 오버헤드에 대한 검증
맨 위에서 실험 결과를 예상할 때, ‘fetch size가 작은 경우, 쿼리 횟수 및 엑셀 파일로의 쓰기 횟수가 증가하며, 이로 인한 IO 오버헤드로 인해 실행 시간이 오래 걸릴 것이다’ 라고 했었습니다.
실제 테스트 결과 fetch size가 작은 경우에는 더 오래 걸리긴 했지만, 그 원인이 정말로 이러한 오버헤드 때문이었는지 역시 검증이 필요하다고 생각했습니다.
여기서 플레임 그래프가 처음으로 등장합니다! 플레임 그래프를 보기 기준을 ‘CPU Time’으로 두고, fetch size가 5와 3200 양 극단인 케이스를 비교해보면, 그 차이를 확연히 볼 수 있습니다.

빨간색이 3200, 초록색이 5입니다. CPU Time 기준으로 정렬했습니다. 참고로 플레임 그래프의 단점은… 스프링 프록시 때문에 같은 메서드로 잡히지 않는다는 겁니다. 원래는 하나의 그래프에서 초록색으로 줄어들거나 / 빨간색으로 늘어나서 diff 느낌으로 나와야 하는데, 플레임 그래프는 프록시가 적용된 경우 다른 메서드로 인식을 하게 됩니다.
아무튼, 차이가 발생하는 메서드를 보면 크게 2개입니다.
workbook.write()- fetch size = 5 → 770ms
- fetch size = 3200 → 10881ms
createRow()- fetch size = 5 → 30170ms
- fetch size = 3200 → 18145ms
왜 이런 차이가 발생할까요?
- 먼저
workbook.write()의 경우 fetch size가 커질수록 한 번에 더 많은 데이터를 메모리에 로드하고 처리하기 때문에, 최종 쓰기 작업에 더 오랜 시간이 소요되는 것을 의미합니다.
createRow()의 경우 fetch size가 작아질수록 데이터베이스 통신 횟수가 증가하고, 이에 따라 row 생성 작업이 더 자주 발생하기 때문에 더 오랜 시간이 소요되는 것을 의미합니다.
이를 통해 초기 예상이 어느 정도 일치했음을 확인할 수 있습니다.
지연 로딩에서 페치 조인으로
병목 지점 찾기 (1)
위에서 우리는
FETCH_SIZE 를 5에서 3200까지 변화시켜가며 테스트를 수행했습니다. 이러한 테스트는 서로 trade-off 관계에 있는 두 리소스가 어떻게 변화하는지 볼 수 있는 좋은 사례이지만, 실제로 우리의 최적화 목표를 달성하는 데에는 큰 도움이 되지 않았습니다. 앞서 말했듯이 최적 지점을 선택하더라도 실행 시간은 2분이 넘고, 메모리 사용량도 2GB를 넘기 때문이죠. 여전히 우리의 목표치를 아득히 뛰어넘는 값을 최적화하기 위해서는,
FETCH_SIZE 를 튜닝하는 것보다, 다른 진짜 병목 지점을 찾을 필요가 있습니다. ‘번외 - IO 오버헤드에 대한 검증’ 섹션에서, 우리는 처음 플레임 그래프를 통한 분석을 시도해봤습니다. 여기서 다른 의미있는 인사이트를 얻을 수 있지 않을까요?
플레임 그래프 4번째 줄을 보겠습니다. 대부분의 처리 시간을
addTaskInfoRow 를 점유하고 있습니다. 그리고 searchBookWithTasks 를 통해 데이터를 쿼리해오는 부분이 그 다음으로 크고요, 사진에는 나와있지 않지만, 오른쪽 끝 아주 작게 addBookInfoRow 가 있습니다 (전체의 1.38%).이는 도서 정보를 쓰는 데에는 많은 시간이 걸리지 않지만, 작업 정보를 쓰는 데 대부분의 시간이 소요되고 있다는 것을 의미합니다. 전체 엑셀 쓰기 작업에 대한 메서드 별 점유율은 아래와 같습니다.
addBookInfoRow- 1.38%
searchBookWitnTasksForExcel- 12.49%- 10초 남짓한 시간이 소요되며, 대부분 querydsl
transform로직이 점유하고 있다는 것을 확인할 수 있습니다.
addTaskInfoRow- 86.12%PersistentBag.get- 59.12%SXSSFSheet.createRow- 39.37%- 각 행을 생성하는 게 뭐 이리 많이 걸려? 싶을 수도 있지만, 그 위의 콜 스택을 보면 실제로는 ‘행을 생성하는 작업’보다 ‘행을 생성하기 전에 슬라이딩 윈도우의 좌측 끝을 플러시하는’ 쓰기 작업을 수행하는 것 때문에 많은 시간이 걸린다는 것을 알 수 있습니다.
병목 지점 찾기 (2)
그렇다면 정체를 알 수 없는 바로 이것,
PersistentBag.get 의 출처를 알아내야 합니다. 여기서 라인 프로파일러의 도움을 받아봅시다.
보면
setTaskItemCellValue , 즉 셀에 데이터를 쓰는 시간은 병목 지점에서 소요되는 전체 시간인 42781ms의 1%인 540ms밖에 걸리지 않는 것을 볼 수 있습니다.대신
taskItems.get(i) 에서 나머지 99%인 42331ms가 소요되고 있는 것이겠죠. 이렇게 하나의 라인에서 두 개 이상의 메서드가 함께 호출되는 경우 리스트 뷰 형태로 각각 걸린 시간을 보여줍니다. 정 의심스러우면, taskItems.get(i) 를 분리해봅시다.
역시
taskItems.get(i) 와 해당 메서드가 호출하는 PersistentBag.get 이 병목 지점이 되고 있음을 알 수 있습니다.PersistentBag이 뭔데?
An unordered, unkeyed collection that can contain the same element multiple times.
PersistentBag 은 Hibernate에서 사용하는 컬렉션 타입에 대한 래퍼 클래스입니다. 주로 일대다 관계안 다대다 관계를 나타낼 때 List 인터페이스에 대한 구현체로 사용됩니다.즉
Task 와 TaskItem 은 일대다 관계로 매핑되어 있으며, 구체적으로 Task 엔티티는 내부에 List<TaskItem> 필드를 가집니다. 위 사진에서 볼 수 있듯이 task.getTaskItems() 를 통해 가져온 List<TaskItem> 이 내부적으로 PersistentBag 구현체를 사용한다는 것을 알 수 있습니다.PersistentBag 은 PersistentCollection 인터페이스를 구현한 AbstractPersistentCollection 을 상속받습니다. 이 PersistentCollection 은 Hibernate에서 영속성 컨텍스트 상에 있는 컬렉션을 다루기 위한 인터페이스입니다. 해당 인터페이스의 javadoc을 같이 봅시다.Hibernate "wraps" a java collection in an instance of PersistentCollection. This mechanism is designed to support tracking of changes to the collection's persistent state and lazy instantiation of collection elements. The downside is that only certain abstract collection types are supported and any extra semantics are lost.Hibernate는 Java 컬렉션을 PersistentCollection 인스턴스로 "감싸서" 사용합니다. 이 메커니즘은 컬렉션의 영속 상태 변경을 추적하고 컬렉션 요소들의 지연 인스턴스화를 지원하기 위해 설계되었습니다. 단점은 특정 추상 컬렉션 타입만 지원되며 추가적인 의미(시맨틱)는 손실된다는 것입니다.
이 중 저희에게 필요한 정보만 가져와봤습니다. 조금 더 부연 설명을 해보겠습니다.
- 자바 컬렉션을 래핑해서 사용
- 김영한 선생님의 강의를 밨다면 흔히 일대다 관계를 매핑할 때 이런 식으로, 빈 컬렉션으로 초기화하는 방식에 익숙하실 겁니다.
- 이렇게
ArrayList와 같은 자바 컬렉션으로 코드 상에서 초기화하더라도, 실제로 영속성 컨텍스트에 의해 가져와질 때 ArrayList는 PersistentBag으로 래핑됩니다.
- 컬렉션 요소들이 지연 인스턴스화
- 이 부분이 핵심입니다. 일대다 관계에 있는 엔티티들을 지연로딩 하기 위해서는 PersistentBag 구현체를 사용해야 합니다.
- 이렇게 지연 로딩된 컬렉션은, 해당 인터페이스 구현체인
AbstractPersistentCollection.initialize()를 통해서 초기화됩니다.
병목 지점 찾기 (3)
그건 그렇고 이게 왜 병목 지점이 되는 걸까요?

플레임 그래프에서 아래 → 위는 콜 스택의 순서와 일치한다고 했습니다. 콜 스택을 따라 올라가다보면,
AbstractPersistentCollection.initalize 가 존재합니다. 이는 이 컬렉션이 지연 로딩 상태에 있으며, get() 을 통해 호출되어 지연 인스턴스화가 수행되고 있음을 의미합니다. 더 위를 보시면 executeQuery 를 통해 실제 쿼리를 실행하는 것까지 확인할 수 있죠.지연 로딩의 단점은 추가적인 쿼리가 발생한다는 것인데요, 위 플레임 그래프를 보면 지연 로딩으로 발생한 이 추가적인 쿼리 때문에 많은 리소스가 사용된 것처럼 보입니다.
하지만 저희는 이를 대비해서
searchBookWithTasksForExcel 을 보면, Task 에 대하여 TaskItem 을 조인하는 식으로 이러한 문제를 해결하고 있습니다. 즉 페이지네이션된 쿼리 한 번으로 Task는 물론 TaskItem까지 가져오고 있기 때문에, 지연 로딩이라는 것 자체가 발생하면 안됩니다.지연 로딩 원인 찾기 (1)
사실은,
fetchjoin 을 누락해서 발생한 문제입니다. 우리가 원하는 것은 Task 엔티티의 List<TaskItem> 일대다 필드가 페치 조인된 상태로 영속성 컨텍스트에 로드되는 것입니다. 이를 위해서는 당연히 fetchjoin() 메서드를 사용해야 합니다.그런데
Task 를 기준으로 TaskItem 을 조인한 뒤 transform(groupBy()) 만 호출하면 알아서 일대다 필드가 초기화되겠지? 라고 착각한 것이죠. 이 방식은 DTO 프로젝션을 통해서 DTO 간의 일대다 관계를 구성한 경우에는 잘 동작합니다. 하지만 현재는 엔티티 프로젝션을 사용하고 있기에, 꼭 fetchJoin() 을 호출해줘야 합니다.페치 조인 적용, 그리고 새로운 문제
이렇게 간단히 해결될 문제였다면 이 글을 쓰지도 않았겠죠. 기존 로직에 페치 조인을 추가하고, 다시 프로파일링을 수행했습니다. 그런데 예상치 못한 결과가 나옵니다.

- 소요시간 142.770
- 최대 힙 메모리 2550MB

- 소요시간 137.695
- 최대 힙 메모리 3007MB
??? 시간은 5초밖에 안 줄었는데, 힙 메모리는 457MB나 늘어났습니다. 그 이유는 무엇일까요? 자세한 내용은 다음 편에서 다뤄보도록 하겠습니다.