public List<ReportDto> getResult(){ var result = new ArrayList<ReportDto>(); for (var order : orderRepository.findAll()) { result.add(mapToOrder(order)); }
@GetMapping("/v1/report") public ResponseEntity<List<ReportDto>> report() { var result = reportService.getResult(); return ResponseEntity.ok(result); } }
curl을 사용해 엔드포인트를 테스트한 결과, 45분 후 다음과 같은 오류가 발생했습니다.
1 2
curl -w "\n" -X GET http://localhost:8000/v1/report {"timestamp":"2024-06-21T19:50:05.720+00:00","status":500,"error":"Internal Server Error","path":"/v1/report"}
서비스 출력에서 다음과 같은 로그를 확인할 수 있었습니다.
1 2
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "http-nio-8000-Poller" Exception in thread "mysql-cj-abandoned-connection-cleanup" java.lang.OutOfMemoryError: Java heap space
데이터베이스 쿼리 결과가 사용 가능한 메모리보다 커서 데이터 쿼리에 실패했습니다.
쿼리 해결
첫 번째 단계는 대용량 데이터를 효율적으로 처리하기 위해 쿼리 프로세스를 개선하는 것입니다.
우선, 리포지토리에서 List이나 Iterable 대신 Stream을 반환하는 메서드를 정의해 보겠습니다. 반환 유형으로 Stream을 사용하면 데이터베이스에서 데이터를 한꺼번에 가져오지 않습니다. 대신 스트림을 소비하면서 청크 단위로 반환됩니다.
@Transactional(readOnly = true) public List<ReportDto> getResult2(){ var result = new ArrayList<ReportDto>(); try (var orderStream = orderRepository.findAllBy()) { orderStream.forEach( order -> { result.add(mapToOrder(order)); entityManager.detach(order); }); }
return result; } }
컨트롤러는 동일하지만 이제 API의 두 번째 버전을 참조합니다. 이를 통해 다음과 같은 응답을 얻을 수 있습니다.
컨트롤러는 동일하지만 이제 API 버전 2를 참조합니다. 이를 통해 다음과 같은 응답을 얻을 수 있습니다.
1
curl -w "\n" -X GET http://localhost:8000/v2/report
JPA 메모리 문제를 해결했지만, 결과를 반환하는 데 42분이 소요되었습니다. 더 나은 방법이 필요해 보입니다.
결과 스트리밍
Java가 대량의 데이터를 처리하는 데 시간이 오래 걸리는 이유는 데이터 구조가 커질수록 성능이 저하되기 때문입니다. 해결책은 스트림을 사용해 데이터를 반환하는 것입니다. 클라이언트 측에서는 파일을 다운로드하는 것과 유사하게 서버가 데이터를 청크 단위로 전송합니다.
컨트롤러는 이제 StreamingResponseBody를 반환합니다
1 2 3 4 5
@GetMapping("/v3/report") public ResponseEntity<StreamingResponseBody> report3(){ var body = reportService.getResult(); return ResponseEntity.ok(body); }
서비스 클래스도 몇 가지 변경 사항이 필요합니다.
스트림을 사용해 데이터를 반환하므로, TransactionTemplate을 사용해 트랜잭션을 수동으로 제어해야 합니다. 이를 위해 PlatformTransactionManager가 필요하며, 생성자에서 전달받습니다.
TransactionTemplate을 사용해 핵심 실행 로직을 캡슐화하고, fillStream 메서드가 이를 수행합니다.
fillStream 메서드는 ObjectMapper를 사용해 결과를 JSON으로 변환합니다. 데이터베이스에서 각 주문을 가져와 DTO로 매핑하고, JSON으로 변환한 후 StreamingResponseBody에 씁니다.
이러한 변경 사항을 적용한 후, 엔드포인트를 호출하면 몇 초 후부터 응답을 받을 수 있습니다. 데이터가 스트리밍 방식으로 반환되므로, Java가 이를 처리하는 데 사용하는 메모리 양은 매우 적어져 성능이 크게 향상됩니다. 실제로, 성능 개선 효과는 매우 커서 실행 시간이 42분에서 단 30초로 줄어들었습니다!
결론
쿼리 자체를 최적화해, 데이터베이스 조회 횟수를 줄이는 방식으로 DTO 형식의 결과를 직접 반환하는 특정 쿼리를 사용하면 이 코드를 더욱 개선할 수 있습니다.