[Spring Boot] 대용량 데이터 쿼리 REST 엔드포인트 처리

이 글에서는 메모리 소비 문제로 인해 기존 방식으로는 구현할 수 없는 REST 엔드포인트의 예를 살펴보겠습니다.

시나리오

이번 예제에서는 Customer, Order, OrderItem, 그리고 Product로 구성된 간단한 시나리오를 사용합니다.

우리의 목표는 보고서를 생성하는 엔드포인트를 만드는 것입니다. 이 엔드포인트는 다음 데이터를 쿼리하고 반환해야 합니다.

  • 백만 개의 주문(Orders)
  • 500만 개 이상의 주문 항목(OrderItems)

기존 구현

몇 가지 필드를 가진 DTO를 정의해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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 메서드를 사용하여 데이터를 반환합니다.

1
2
3
@Repository
public interface OrderRepository extends CrudRepository<Order, Long> {
}

서비스 클래스는 다음 작업을 수행합니다.

  • 결과를 저장할 ArrayList를 생성합니다.
  • 리포지토리의 findAll 메서드를 호출해 주문 데이터를 가져옵니다.
  • 쿼리 결과를 반복하여 DTO로 매핑합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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;
}
}

컨트롤러는 단순히 서비스의 메서드를 호출하고 결과를 반환합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@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분 후 다음과 같은 오류가 발생했습니다.

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을 사용하면 데이터베이스에서 데이터를 한꺼번에 가져오지 않습니다. 대신 스트림을 소비하면서 청크 단위로 반환됩니다.

1
2
3
4
@Repository
public interface OrderRepository extends CrudRepository<Order, Long> {
Stream<Order> findAllBy();
}

서비스 클래스도 수정해야 합니다.

  • 리포지토리가 스트림을 반환하고 데이터가 데이터베이스에서 필요할 때만 가져오므로, 전체 실행 동안 트랜잭션을 열어 두어야 합니다. 읽기 전용 트랜잭션을 사용하기 위해 @Transactional(readOnly = true) 어노테이션을 사용합니다.
  • 데이터베이스에서 데이터를 가져오는 스트림을 처리할 때 스트림을 올바르게 닫아야 하므로, try-with-resources 구문을 사용합니다.
  • JPA가 엔티티를 메모리에 계속 유지하지 않도록 EntityManager를 사용해 수동으로 분리(detach)합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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의 두 번째 버전을 참조합니다. 이를 통해 다음과 같은 응답을 얻을 수 있습니다.

컨트롤러는 동일하지만 이제 API 버전 2를 참조합니다. 이를 통해 다음과 같은 응답을 얻을 수 있습니다.

1
curl -w "\n" -X GET http://localhost:8000/v2/report

응답

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
[
{
"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를 반환합니다

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에 씁니다.
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
@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 형식의 결과를 직접 반환하는 특정 쿼리를 사용하면 이 코드를 더욱 개선할 수 있습니다.

Share