이 글에서는 메모리 소비 문제로 인해 기존 방식으로는 구현할 수 없는 REST 엔드포인트의 예를 살펴보겠습니다.
시나리오
이번 예제에서는 Customer, Order, OrderItem, 그리고 Product로 구성된 간단한 시나리오를 사용합니다.
우리의 목표는 보고서를 생성하는 엔드포인트를 만드는 것입니다. 이 엔드포인트는 다음 데이터를 쿼리하고 반환해야 합니다.
- 백만 개의 주문(Orders)
- 500만 개 이상의 주문 항목(OrderItems)
기존 구현
몇 가지 필드를 가진 DTO를 정의해보겠습니다.
@Data
public class ReportDto {
private final Long orderId;
private final LocalDate date;
private final String customerName;
...
private final List<Item> items;
@Data
public static class Item {
private final Long productId;
private final String productName;
private final Integer quantity;
...
}
}
리포지토리는 Order 엔티티를 위한 CrudRepository
이며, JPA 관계를 통해 다른 데이터를 모두 가져올 수 있습니다. 여기서는 단순함을 위해 findAll
메서드를 사용하여 데이터를 반환합니다.
@Repository
public interface OrderRepository extends CrudRepository<Order, Long> {
}
서비스 클래스는 다음 작업을 수행합니다.
- 결과를 저장할
ArrayList
를 생성합니다. - 리포지토리의
findAll
메서드를 호출해 주문 데이터를 가져옵니다. - 쿼리 결과를 반복하여 DTO로 매핑합니다.
@Service
@RequiredArgsConstructor
public class ReportService {
private final OrderRepository orderRepository;
public List<ReportDto> getResult() {
var result = new ArrayList<ReportDto>();
for (var order : orderRepository.findAll()) {
result.add(mapToOrder(order));
}
return result;
}
}
컨트롤러는 단순히 서비스의 메서드를 호출하고 결과를 반환합니다.
@Controller
@RestController
@RequiredArgsConstructor
public class ReportController {
private final ReportService reportService;
@GetMapping("/v1/report")
public ResponseEntity<List<ReportDto>> report() {
var result = reportService.getResult();
return ResponseEntity.ok(result);
}
}
curl
을 사용해 엔드포인트를 테스트한 결과, 45분 후 다음과 같은 오류가 발생했습니다.
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"}
서비스 출력에서 다음과 같은 로그를 확인할 수 있었습니다.
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
을 사용하면 데이터베이스에서 데이터를 한꺼번에 가져오지 않습니다. 대신 스트림을 소비하면서 청크 단위로 반환됩니다.
@Repository
public interface OrderRepository extends CrudRepository<Order, Long> {
Stream<Order> findAllBy();
}
서비스 클래스도 수정해야 합니다.
- 리포지토리가 스트림을 반환하고 데이터가 데이터베이스에서 필요할 때만 가져오므로, 전체 실행 동안 트랜잭션을 열어 두어야 합니다. 읽기 전용 트랜잭션을 사용하기 위해
@Transactional(readOnly = true)
어노테이션을 사용합니다. - 데이터베이스에서 데이터를 가져오는 스트림을 처리할 때 스트림을 올바르게 닫아야 하므로,
try-with-resources
구문을 사용합니다. - JPA가 엔티티를 메모리에 계속 유지하지 않도록
EntityManager
를 사용해 수동으로 분리(detach)합니다.
@Service
@RequiredArgsConstructor
public class ReportService {
private final OrderRepository orderRepository;
@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 버전 2를 참조합니다. 이를 통해 다음과 같은 응답을 얻을 수 있습니다.
curl -w "\n" -X GET http://localhost:8000/v2/report
응답
[
{
"orderId":1,
"date":"2022-08-25",
"customerName":"Booker",
"totalAmount":19104.36,
"currency":"CDF",
"status":"Shipped",
"paymentMethod":"Credit Card",
"items": [
{
"productId":93,
"productName":"Rustic Bronze Bag",
"quantity":41,
"price":465.96,
"totalAmount":19104.36
}
]
},
{
"orderId":2,
"date":"2022-03-29",
"customerName":"Danielle",
"totalAmount":14685.35,
"currency":"MUR",
"status":"Processing",
"paymentMethod":"Credit Card",
"items": [
{
"productId":52,
"productName":"Mediocre Copper Bench",
"quantity":98,
"price":46.02,
"totalAmount":4509.96
},
{
"productId":71,
"productName":"Fantastic Bronze Hat",
"quantity":31,
"price":233.61,
"totalAmount":7241.91
},
{
"productId":3,
"productName":"Mediocre Silk Bottle",
"quantity":22,
"price":133.34,
"totalAmount":2933.48
}
]
},
...
]
JPA 메모리 문제를 해결했지만, 결과를 반환하는 데 42분이 소요되었습니다. 더 나은 방법이 필요해 보입니다.
결과 스트리밍
Java가 대량의 데이터를 처리하는 데 시간이 오래 걸리는 이유는 데이터 구조가 커질수록 성능이 저하되기 때문입니다. 해결책은 스트림을 사용해 데이터를 반환하는 것입니다. 클라이언트 측에서는 파일을 다운로드하는 것과 유사하게 서버가 데이터를 청크 단위로 전송합니다.
컨트롤러는 이제 StreamingResponseBody
를 반환합니다
@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
에 씁니다.
@Service
public class ReportService {
private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;
private final EntityManager entityManager;
private final ObjectMapper objectMapper = new ObjectMapper();
public ReportService(
PlatformTransactionManager platformTransactionManager,
OrderRepository orderRepository,
EntityManager entityManager) {
this.transactionTemplate = new TransactionTemplate(platformTransactionManager);
this.orderRepository = orderRepository;
this.entityManager = entityManager;
objectMapper.registerModule(new JavaTimeModule());
}
public StreamingResponseBody getResult() {
return outputStream -> transactionTemplate.execute(
new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
fillStream(outputStream);
}
});
}
private void fillStream(OutputStream outputStream) {
try (var orderStream = orderRepository.findAllBy()) {
orderStream.forEach(order -> {
try {
var json = objectMapper.writeValueAsString(mapToOrder(order));
outputStream.write(json.getBytes(StandardCharsets.UTF_8));
entityManager.detach(order);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
}
}
이러한 변경 사항을 적용한 후, 엔드포인트를 호출하면 몇 초 후부터 응답을 받을 수 있습니다. 데이터가 스트리밍 방식으로 반환되므로, Java가 이를 처리하는 데 사용하는 메모리 양은 매우 적어져 성능이 크게 향상됩니다. 실제로, 성능 개선 효과는 매우 커서 실행 시간이 42분에서 단 30초로 줄어들었습니다!
결론
쿼리 자체를 최적화해, 데이터베이스 조회 횟수를 줄이는 방식으로 DTO 형식의 결과를 직접 반환하는 특정 쿼리를 사용하면 이 코드를 더욱 개선할 수 있습니다.
'Backend > Spring' 카테고리의 다른 글
Spring Boot에서 API 응답을 구조화하는 가장 좋은 방법 (0) | 2024.11.24 |
---|---|
Spring Boot에서 데이터 캐싱 방법 (0) | 2024.11.13 |
[Spring Boot] MultipartFile transferTo() 사용 파일 저장시 주의사항 (0) | 2023.01.10 |
[Spring Boot] MultipartFile + Modal 사용 파일 업로드 (0) | 2023.01.06 |
[Spring Boot] WebSocket & STOMP 사용 방법 (0) | 2023.01.02 |
IT 기술과 개발 내용을 포스팅하는 블로그
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!