내가 개발한 모의고사 성적표 웹뷰 출력< 기능 관련해

학원기획팀에서 들어온 요구사항이 있었다.

 

> 학생 전체 성적표를 한 번에 출력할 수 있게 해주세요.<

 

학원 한 곳당 약 200~300명, 많은곳은 500명까지 요구사항이 도착하였는데

기존 구현했던 성적표 서비스는 HTML 기반 단 1개의 출력물이었다. 

문제는 이 요청을 기존 구조 그대로 확장할 수 없었다는 점이였고..

(현재 프로젝트에서 구현하고 있는 스펙은 java springboot + thymeleaft 기반으로 구현 되어 있음)

대용량 처리로 인해 발생한 문제점들

성적표 HTML의 평균 크기는 약 2MB...

 

  • 1명: 약 2MB
  • 300명: 약 600MB
  • 서버 Heap 메모리: 512MB

 

처음 접근한 방식은

300명의 성적표 HTML을 한 번에 생성 → 메모리에 적재 → 응답 으로

단순하게 기존 성적표 1개를 생성하는 로직에서 반복문을 돌리는.. (제일 공수가 적게 들것이므로..) 방식이였는데 사실 말이 안되긴 했다;;

 

  1. 학생별 성적 데이터 조회
  2. 템플릿에 모델로 전달
  3. TemplateEngine.process();
  4. 이걸 반복문으로 돌려 HTML을 생성해서 response함

 

실제로 구현 시 OutOfMemoryError가 발생....

이를 해결하기 위해서 여러가지 방법을 생각해봤는데 

 

① 배치 처리로 부하를 쪼개기

일단 처음 접근한 방법은 서버 부하 관리에 있어서 매우 기본적이고 단순한 방법으로,

기존 요청수에 맞게 반복문을 돌리는 방식에서 수를 분할해 부하를 쪼개는 방식을 사용했다.

(전체 데이터를 한 번에 처리하는 대신, 예측 가능한 단위로 나누어 처리하는 방식을 선택)

 

  • 전체 학생 300명
  • 10명 단위로 분할 → 30개 배치
  • 배치 간: 순차 처리
  • 배치 내: 병렬 처리

 

List batches = Lists.partition(students, 10); 
for (List batch : batches) { 
	processBatchAsync(batch).join(); // 배치 단위 순차 처리 
}

 

 

②: CompletableFuture 기반 병렬 처리

배치 내부에서는 CompletableFuture를 활용해 병렬로 성적표를 생성했다.

Executor executor = threadPoolTaskExecutor; 
List futures = batch.stream()
	.map(student -> CompletableFuture.runAsync(() -> generateReport(student), executor) 
    ) 
    .toList(); 
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

 


서버가 감당 가능한 수준에서 동시에 여러 작업을 처리하되 리소스를 예측 가능하게 사용하기 위해 비동기 전략을 사용했는데,

사실 비동기 처리를 하지 않아도 OutOfMemory는 피할 수 있었고 시스템 안정성도 충분히 확보할 수 있었다

그럼에도 비동기 처리 로직을 추가한건... 처리시간 감축을 위해서였는데

배치 크기를 10명으로 설정했을 때, 한 배치 처리시간은 3초, 100배치가 돈다면 300초 => 5분까지 확장되므로,

출력 버튼을 누르고 5분 가까이 기다려야 결과가 나오는 구조였다.

거기다가 출력 요청 중에는 서버 리소스가 그 작업에 계속 묶여있으므로 요청 스레드가 불필요하게 오래 점유되지 않고, 서버 응답성 유지 기대효과를 위해 비동기 로직까지 추가하기로 했다.

결과적으로 전체 처리 시간은 100명 학생의 성적표 화면을 출력할 때, 약 300초 → 30초로 감소할 수 있었다

 

 ③: StreamingResponseBody로 메모리 사용 최소화

배치 + 비동기를 적용했음에도 여전히 메모리 사용량이 부담스러웠는데,

모든 HTML을 다 만든 뒤 응답하는 구조이다보니 HTML 렌더링 결과가 생각보다 오래 메모리에 남아 있을 수 밖에 없었다.

처음 구조는 단순하게..

 

1. 학생 전체 성적표 HTML 생성

2. List<String>에 저장

3. 컨트롤러에서 한 번에 Response로 반환

 

구조였는데 이거의 문제는 HTML 용량이 커질수록 메모리 점유가 증가하고, GC가 돌떄까지 해제가 되지 않아 보관시간이 선형적으로 증가한다는 것에 있었다

그래서 저장하는 게 아니라 응답을 바로 보내는 스트리밍 방식을 생각해보았다

 

@GetMapping("/print/reports")
public StreamingResponseBody printReports(HttpServletResponse response) {
    response.setContentType("text/html;charset=UTF-8");

    return outputStream -> {
        for (List<Student> batch : batches) {
            processBatchAndStream(batch, outputStream);
            outputStream.flush();
        }
    };
}
private void processBatchAndStream(List<Student> batch, OutputStream os) {
    batch.parallelStream().forEach(student -> {
        String html = generateReportHtml(student);
        os.write(html.getBytes(StandardCharsets.UTF_8));
    });
}

 

이 방식의 핵심은 다음과 같다.

 

  • Thymeleaf는 한 명씩 렌더링
  • 렌더링 결과를 즉시 OutputStream으로 전송
  • HTML을 리스트나 컬렉션에 쌓아두지 않음

사실 스트리밍 처리까지 할 필요가 있을까?싶긴 했는데 메모리 모니터링 해보면서 코드를 간소화하든가 해야겠음

 

 

무튼 이 작업을 통해 얻은 결론은 다음과 같다

 

  • 배치처리는 당연히
  • 대용량 처리 문제는 대부분 처리 흐름 설계의 문제
  • OOM은 메모리 증설로 해결할 대상이 아님
  • 한 번에 처리해야 하는가? < 를 먼저 의심

 

비슷한 문제를 겪고 있다면, 배치 + 스트리밍 구조는 충분히 고려해볼 만한 선택지라고 생각한다.

+ Recent posts