Development/Code

Spring Boot: 사용 가능한 메모리보다 더 많은 데이터 쿼리하기

Danny Seo 2024. 7. 1. 23:28

개발자로서 아직 이런 문제를 겪지 않았다면, 이 글이 도움이 될 수 있을 것 같습니다. 경력 중 어느 시점에서든, 메모리에 맞지 않는 결과를 반환하는 데이터베이스 쿼리를 수행하는 Spring Boot REST 엔드포인트를 만들어야 할 가능성이 큽니다.

Spring Boot: 사용 가능한 메모리보다 더 많은 데이터를 쿼리하는 REST 엔드포인트 처리

 

이 글에서는 메모리 한계로 인해 전통적인 방식으로 구현할 수 없는 REST 엔드포인트의 예를 살펴보겠습니다.

시나리오

이 연습에서는 Customer, Order, OrderItem, Product를 포함한 간단한 시나리오를 사용합니다:

Spring Boot: 사용 가능한 메모리를 초과하는 데이터 불러오기 (REST 엔드포인트 구현)

 

우리의 목표는 다음을 쿼리하고 반환하는 보고서를 생성하는 엔드포인트를 만드는 것입니다:

  • 백만 개의 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;
  }
}

 

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

@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를 사용하여 수동으로 분리합니다.
@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의 두 번째 버전을 참조합니다. 결과적으로 다음과 같은 출력을 얻습니다:

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);
}

 

서비스 클래스에는 몇 가지 변경 사항이 필요합니다:

  • 데이터를 반환하기 위해 스트림을 사용하므로, 트랜잭션을 수동으로 제어해야 합니다. 이를 위해 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 형식으로 반환하도록 하여 데이터베이스 쿼리의 수를 줄이는 등 쿼리 자체를 최적화함으로써 이 코드를 더욱 향상시킬 수 있습니다.

 

끝까지 읽어주셔서 정말 감사합니다.

 

꼭, 도움이 되셨으면 좋겠습니다. (_ _) !!