안녕하세요, Devloo입니다 🙂 . 여러분은 혹시 대용량 데이터를 반환하려다 메모리 부족 문제를 겪어보신 적이 있으신가요? 만약 서비스 운영 중에 이런 문제가 발생한다면 정말 난감할 것입니다. 아직 이런 문제를 겪지 않으셨더라도 이 글을 읽어두면 유익할 것입니다. 커리어 중 어느 순간 Spring Boot를 사용해 메모리에 적재할 수 없는 대용량 데이터를 처리하는 REST 엔드포인트를 만들어야 할 상황이 생길 수 있기 때문입니다.
이번 글에서는 메모리 사용 문제로 인해 전통적인 방식으로는 구현할 수 없는 REST 엔드포인트의 예를 살펴보겠습니다.
시나리오
이번 예제에서는 Customer
, Order
, OrderItem
, 그리고 Product
를 포함한 간단한 시나리오를 다룹니다.
목표는 다음과 같은 데이터를 조회하고 반환하는 보고서 생성 엔드포인트를 만드는 것입니다:
- 100만 개의 주문
- 500만 개 이상의 주문 항목
전통적인 구현
우선 몇 가지 필드를 가진 DTO를 정의해 보겠습니다:
public record ReportDto(Long orderId, LocalDate date, String customerName, List<Item> items) {
public static record Item(Long productId, String productName, 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() {
return orderRepository.findAll().stream()
.map(this::mapToOrder)
.collect(Collectors.toList());
}
private ReportDto mapToOrder(Order order) {
// Order를 ReportDto로 매핑하는 로직 구현
return new ReportDto(
order.getId(),
order.getDate(),
order.getCustomerName(),
order.getItems().stream()
.map(item -> new ReportDto.Item(item.getProductId(), item.getProductName(), item.getQuantity()))
.collect(Collectors.toList())
);
}
}
컨트롤러는 단순히 서비스를 호출해 그 결과를 반환합니다.
@RestController
@RequiredArgsConstructor
public class ReportController {
private final ReportService reportService;
@GetMapping("/v1/report")
public ResponseEntity<List<ReportDto>> report() {
List<ReportDto> 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
를 사용해 수동으로 분리합니다.
@Service
@RequiredArgsConstructor
public class ReportService {
private final OrderRepository orderRepository;
private final EntityManager entityManager;
@Transactional(readOnly = true)
public List<ReportDto> getResult2() {
List<ReportDto> result = new ArrayList<>();
try (Stream<Order> orderStream = orderRepository.findAllBy()) {
orderStream.forEach(order -> {
result.add(mapToOrder(order));
entityManager.detach(order);
});
}
return result;
}
private ReportDto mapToOrder(Order order) {
// Order를 ReportDto로 매핑하는 로직 구현
return new ReportDto(
order.getId(),
order.getDate(),
order.getCustomerName(),
order.getItems().stream()
.map(item -> new ReportDto.Item(item.getProductId(), item.getProductName(), item.getQuantity()))
.collect(Collectors.toList())
);
}
}
컨트롤러는 그대로 유지하지만 이제 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
}
]
},
...
]
JPA 메모리 문제는 해결되었으나, 결과를 반환하는 데 42분이 걸렸습니다. 더 나은 방법이 있을 것입니다.
결과 스트리밍
많은 데이터를 처리하고 호출자에게 반환하는 데 시간이 너무 오래 걸리므로, 데이터를 스트리밍 방식으로 반환하는 것이 해결책입니다. 호출자는 서버가 청크 단위로 결과를 전송하는 방식으로 데이터를 받게 됩니다.
컨트롤러는 이제 StreamingResponseBody
를 반환합니다:
@GetMapping("/v3/report")
public ResponseEntity<StreamingResponseBody> report3() {
var body = reportService.getResult();
return ResponseEntity.ok(body);
}
서비스 클래스도 몇 가지 변경이 필요합니다:
- 스트림을 사용해 데이터를 반환하므로
TransactionTemplate
을 사용해 트랜잭션을 수동으로 제어합니다. 이를 인스턴스화하려면PlatformTransactionManager
가 필요하며, 이는 생성자에 전달됩니다. - 트랜잭션 템플릿을 사용해
fillStream
메서드에서 핵심 실행을 캡슐화합니다.
fillStream
메서드는 ObjectMapper
를 사용해 결과를 JSON으로 변환합니다. 데이터베이스에서 가져온 각 주문을 DTO로 매핑하고 JSON으로 변환해 StreamingResponseBody
에 씁니다.
@Service
@RequiredArgsConstructor
public class ReportService {
private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;
private final EntityManager entityManager;
private final ObjectMapper objectMapper;
public ReportService(PlatformTransactionManager platformTransactionManager, OrderRepository orderRepository, EntityManager entityManager) {
this.transactionTemplate = new TransactionTemplate(platformTransactionManager);
this.orderRepository = orderRepository;
this.entityManager = entityManager;
this.objectMapper = new ObjectMapper();
this.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 (Stream<Order> orderStream = orderRepository.findAllBy()) {
orderStream.forEach(order -> processOrder(outputStream, order));
} catch (IOException e) {
throw new RuntimeException("Error processing stream", e);
}
}
private void processOrder(OutputStream outputStream, Order order) {
try {
String json = objectMapper.writeValueAsString(mapToOrder(order));
outputStream.write((json + "\n").getBytes(StandardCharsets.UTF_8));
entityManager.detach(order);
} catch (IOException e) {
throw new RuntimeException("Error writing order to output stream", e);
}
}
private ReportDto mapToOrder(Order order) {
return new ReportDto(
order.getId(),
order.getDate(),
order.getCustomerName(),
order.getItems().stream()
.map(item -> new ReportDto.Item(item.getProductId(), item.getProductName(), item.getQuantity()))
.collect(Collectors.toList())
);
}
}
이 변경 후 엔드포인트를 호출하면 몇 초 내에 응답을 받기 시작합니다. 결과가 스트리밍되므로 Java가 이를 처리하는 데 사용하는 메모리가 거의 없어져 성능이 크게 향상됩니다. 실제로 성능 개선이 상당하여 실행 시간이 42분에서 30초로 줄어들었습니다!
여기서, 쿼리 자체를 최적화하여 더욱 개선할 수 있습니다. 예를 들어, DTO 형식으로 직접 결과를 반환하는 특정 쿼리를 사용해 데이터베이스 쿼리 횟수를 줄일 수 있습니다.
지금까지 메모리를 초과하는 대용량 데이터를 불러오는 방법에 대해 알아보았습니다.
혹시 궁금하신 사항은 편하게 댓글로 남겨주세요 !! ㅎㅎ 감사합니다. 🙂