Spring

Spring Boot 메모리 걱정 없이 대용량 데이터 다루기 #1 — JPA 사용

Written by 개발자서동우 · 2 min read >
Spring Boot JDBC 메모리 걱정 없이 대용량 데이터 다루기

안녕하세요, Devloo입니다 🙂 . 여러분은 혹시 대용량 데이터를 반환하려다 메모리 부족 문제를 겪어보신 적이 있으신가요? 만약 서비스 운영 중에 이런 문제가 발생한다면 정말 난감할 것입니다. 아직 이런 문제를 겪지 않으셨더라도 이 글을 읽어두면 유익할 것입니다. 커리어 중 어느 순간 Spring Boot를 사용해 메모리에 적재할 수 없는 대용량 데이터를 처리하는 REST 엔드포인트를 만들어야 할 상황이 생길 수 있기 때문입니다.

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

시나리오

이번 예제에서는 Customer, Order, OrderItem, 그리고 Product를 포함한 간단한 시나리오를 다룹니다.

Spring Boot, 메모리를 초과하는 대용량 데이터 가져오기
예제 시나리오

목표는 다음과 같은 데이터를 조회하고 반환하는 보고서 생성 엔드포인트를 만드는 것입니다:

  • 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> {
}

서비스 클래스는 다음과 같은 작업을 수행합니다:

  1. 결과를 담을 ArrayList 생성
  2. 리포지토리의 findAll 메서드를 호출해 주문 데이터를 조회
  3. 쿼리 결과를 루프 돌며 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

쿼리 결과가 메모리를 초과하여 데이터베이스에서 데이터를 조회하지 못한 것입니다.

쿼리 해결

대용량 데이터를 효율적으로 처리하기 위해 쿼리 프로세스를 개선해 보겠습니다.

우선, 리포지토리에서 ListIterable 대신 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 형식으로 직접 결과를 반환하는 특정 쿼리를 사용해 데이터베이스 쿼리 횟수를 줄일 수 있습니다.

지금까지 메모리를 초과하는 대용량 데이터를 불러오는 방법에 대해 알아보았습니다.

혹시 궁금하신 사항은 편하게 댓글로 남겨주세요 !! ㅎㅎ 감사합니다. 🙂

Written by 개발자서동우
안녕하세요! 저는 기술 분야에서 활동 중인 개발자 서동우입니다. 명품 플랫폼 (주)트렌비의 창업 멤버이자 CTO로 활동했으며, AI 기술회사 (주)헤드리스의 공동 창업자이자 CTO로서 역할을 수행했습니다. 다양한 스타트업에서 일하며 회사의 성장과 더불어 비즈니스 상황에 맞는 기술 선택, 개발팀 구성 및 문화 정착에 깊은 경험을 쌓았습니다. 개발 관련 고민은 언제든지 편하게 연락주세요 :) https://linktr.ee/dannyseo Profile

Leave a Reply

Your email address will not be published. Required fields are marked *