본문 바로가기
[프로젝트 일지]

pdf 관련 기능 도입기

by 프롯 2025. 10. 13.

이번 글에서는 또 옛날에 진행했던 프로젝트의 pdf 관련 기능 도입기를 적어보려고 합니다. 

 

프로젝트 초기부터 pdf 생성에 대한 기능을 기획하고는 있었습니다. 다만, 우선순위에서 밀려 나중에 해야지 하다가 결국 맨 끝까지 밀렸죠. 그러다 보니 급하게 기능만 하도록 도입할 수밖에 없었고, 결국 성능면에서 상당히 비효율적인 구현이 될 수밖에 없었죠. 역으로 생각하면 이후 많은 부분에서 성능 개선을 이뤄낼 수 있었는데, 하나하나 살펴보죠.

 

1. pdf 생성하기

우선 가장 핵심적인 pdf 기능은 사용자가 생성한 이미지와 직접 작성한 글을 마치 책처럼 엮어서 pdf 형태로 만들어 주는 기능이었습니다. 사용자에게 직접 책을 만드는 듯한 경험을 주기 위해 기획되었던 기능이었죠.

 

실제로 제 친구가 만들었던 pdf의 한 장면입니다

 

먼저 요구사항을 생각해보죠. 사용자 입장에서 필요한 기능은 정말 간단합니다. 사용자가 "pdf 다운로드" 버튼을 클릭했을 때, pdf를 다운로드 받을 수 있게 해준다. 정말 단순하고 간단한 기능입니다. 따라서 사용자가 책을 완성하면 바로 pdf 생성 요청을 함께 보내고 s3에 미리 업로드해 둔 후에, 사용자가 pdf 다운로드 버튼을 클릭했을 때 s3링크를 반환하는 방식으로 구현했죠. 아주 간단하고 직관적인 설계였습니다. 완성된 pdf는 s3에 업로드하구요. 많은 것을 고려하고 최선의 설계를 선택하기에는 시간이 많지 않았기에 곧바로 진행했죠.

 

하지만 이런 직관적이고 훌륭해보이는 설계가 문제가 되었던건, pdf 하나의 크기가 너무나 컸다는 겁니다. 하나에 30~40mb의 크기를 자랑했죠. 당시 저희가 사용했던 배포 인스턴스는 2gb의 메모리를 사용했습니다. 스프링부트 서버가 1.2gb 메모리를 차지했고, 남은 메모리는 기타 여유공간을 감안하면 600mb 밖에 되지 않았는데, pdf 생성에 아무런 처리나 제한을 두지 않았으므로 동시에 20~30개의 요청이 들어오면 곧바로 서버가 내려갈 수밖에 없었죠.

 

보통 모종의 이유로 서버가 내려가면, 그냥 서버를 재시작하면 됩니다. 저희는 github action을 활용하여 ci/cd 파이프라인을 구축하고 있었으므로, 문제 발생 즉시 re-job을 시키면 간단하게 서버를 재가동 시킬 수 있었죠. 하지만 이런 Out Of Memory로 인한 서버 다운이 아주 악질인게, 서버 뿐 아니라 서버가 돌아가고 있는 인스턴스 자체가 무력화됩니다. 다시말해서 ci/cd 재실행을 하더라도 인스턴스에 접속이 불가능하기에, 아예 인스턴스 자체를 재시작해야 살릴 수 있다는거라서 정말 번거롭기 짝이없었죠. 이런 에러에 민감하게 반응할 수밖에 없었습니다.

 

/**
 * Thymeleaf 템플릿을 사용해 PDF를 메모리의 byte 배열로 생성합니다.
 */
public byte[] createPdfInMemory() throws Exception {
    // 1. Thymeleaf 컨텍스트 준비 (HTML에 전달할 데이터)
    Context context = new Context();
    context.setVariable("name", "World");

    // 2. Thymeleaf를 사용해 HTML 문자열을 메모리에서 생성
    String html = templateEngine.process("pdf_template", context); // templates/pdf_template.html

    // 3. 최종 PDF를 담을 메모리 스트림 생성
    ByteArrayOutputStream baos = new ByteArrayOutputStream();

    // 4. HTML 문자열을 PDF로 렌더링하여 메모리 스트림에 쓴다.
    PdfRendererBuilder builder = new PdfRendererBuilder();
    builder.withHtmlContent(html, "/"); // 두 번째 인자는 base URI (상대 경로 리소스 해결용)
    builder.toStream(baos);
    builder.run();

    // 5. 메모리에 생성된 PDF의 byte 배열을 반환
    return baos.toByteArray();
}

 

기존의 코드입니다. 타임리프 html 파일을 사용해서 디자인을 잡았고, PdfRendererBuilder를 활용하여 pdf 형태로 렌더링 하였죠. 하지만 메모리에 전체 pdf의 byte 배열이 반환 후 s3에 업로드 하다보니 너무나 많은 메모리 손실이 있었죠. 이런식으로 메모리를 과도하게 점유하는 요청을 처리해야 할때는 스트리밍 방식을 사용하는 것이 맞습니다. 

 

스트리밍 방식이란 데이터를 한번에 처리하는것이 아닌, 잘게 나눈 조각(chunk)들의 흐름(stream)으로 만들어 순서대로 처리하는 방식입니다. 메모리에서 한번에 처리하긴 부담스러우니 잘게 나누어서 처리하겠다는 거죠. 보통 유튜브나 넷플릭스 같은 동영상 제공 서비스들이 동영상을 한번에 처리하기에는 용량때문에 부담스럽기 때문에, 청크 단위로 쪼개어 제공합니다. 그래서 이러한 경우 스트리밍 서비스라는 단어를 많이 사용하죠.

 

저희의 프로젝트에서도 스트리밍 방식을 적용할 수 있습니다. pdf 생성 요청이 들어오면, 이를 청크 단위로 쪼개어 생성합니다. 메모리에는 버퍼라는 단위를 두고, 청크가 생성될때마다 버퍼에 저장합니다. (보통 8kb 정도의 아주 작은 크기로 설정합니다.) 정해진 버퍼의 용량이 꽉 차면 이를 s3로 업로드합니다. 그 후 pdf의 생성이 완료되었다는 표시와 함께 마지막 청크가 들어오면 그때 pdf 생성 응답을 보내게 됩니다. 결국 메모리에는 버퍼의 크기만큼만 부하가 가는거죠. 이 과정에서 임시 파일을 만들고, 스트리밍이 끝나면 해당 파일을 삭제하게 됩니다. (컴퓨터에서 대용량 파일을 다운로드 받다가 중간에 강제 종료하면 남는 파일이 이것과 같은 파일입니다.)

 

public ResponseEntity<InputStreamResource> downloadReportAsStream() {
    File tempFile = null;
    try {
        // 1. Thymeleaf를 사용해 HTML 문자열을 메모리에서 생성
        Context context = new Context();
        context.setVariable("documentTitle", "Streaming PDF Report");
        String html = templateEngine.process("report_template", context); // templates/report_template.html

        // 2. PDF 내용을 저장할 임시 파일을 디스크에 생성
        tempFile = File.createTempFile("report-", ".pdf");

        // 3. PDF 렌더링: HTML 내용을 디스크의 임시 파일에 직접 씁니다.
        try (OutputStream outputStream = new FileOutputStream(tempFile)) {
            PdfRendererBuilder builder = new PdfRendererBuilder();
            builder.withHtmlContent(html, "/");
            builder.toStream(outputStream);
            builder.run();
        }

        // 4. HTTP 응답 헤더 설정
        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=my-report.pdf");
        headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_PDF_VALUE);
        headers.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(tempFile.length()));

        // 5. InputStream을 익명 클래스로 생성하여, 스트림이 닫힐 때 파일 삭제 (핵심!)
        //    (람다/익명 클래스에서 참조하려면 사실상 final이어야 하므로 새 변수에 할당)
        File finalTempFile = tempFile;
        InputStream inputStream = new FileInputStream(finalTempFile) {
            @Override
            public void close() throws IOException {
                super.close();
                // Spring이 스트림 전송을 완료하고 스트림을 닫으면,
                // 이 close() 메서드가 호출되어 임시 파일을 삭제합니다.
                Files.deleteIfExists(finalTempFile.toPath());
            }
        };

        // 6. 생성된 스트림 리소스를 HTTP 응답 바디에 담아 반환
        return ResponseEntity.ok()
                .headers(headers)
                .body(new InputStreamResource(inputStream));

    } catch (IOException e) {
        // PDF 생성 과정에서 오류가 발생한 경우, 남아있을 수 있는 임시 파일을 삭제합니다.
        if (tempFile != null) {
            tempFile.delete();
        }
        // 서버 에러 응답을 반환합니다.
        return ResponseEntity.internalServerError().build();
    }
}

 

위는 이 과정을 구현한 코드입니다. 예제 코드인지라, 하드디스크에 임시 파일을 만드는 것으로 되어 있지만 실제 구현에서는 s3에 직접 업로드 되도록 했죠. 이제 모든 문제가 해결되었을까요?

 

아쉽게도 그렇지는 않았습니다. 또 다른 문제는 이 pdf를 생성하는 작업이 상당히 cpu 집약적이라는 것입니다. 페이지들을 정렬하고 html을 pdf로 렌더링하는 과정에서 상당히 많은 cpu 자원을 점유하고 있었습니다. 저희가 개발서버로 사용했던 컴퓨팅 자원은 aws의 t2.small 이었습니다. 

 

 

이게 아무런 요청도 들어오지 않은 상태라면

 

 

이게 pdf 생성 요청이 들어왔을 때 입니다. 물론 테스트를 위해 특히 거대한 pdf를 사용하기도 했고, 실서버는 t3.medium으로 업그레이드를 했다지만, 이정도의 부하는 운영 관점에서 무시할 수 없죠. 실서버 기준이로도 이런 요청이 동시에 10개 이상 들어왔다가는 서버에 문제가 생길것이 뻔했거든요. 스트리밍 방식으로 메모리 부하를 줄인다고 해결되는 문제는 아니었죠.

 

따라서 pdf 생성 요청을 비동기로 받아야만 했습니다. 스프링부트의 비동기는 node.js의 비동기와 뜻이 조금 다릅니다. node.js의 비동기가 싱글 스레드의 이벤트 루프 기반으로 처리해야 하는 작업을 위임하고 응답을 기다리는 역할이라면, 스프링부트의 비동기는 작업공간의 분리라고 보는것이 맞습니다. 스프링부트는 멀티스레드 기반으로 작동하고, 따로 추가적인 설정을 하지 않으면 톰켓에서 200개의 스레드를 제공합니다. pdf를 비동기로 처리하겠다는건, 오직 pdf 관련 요청을 처리하기 위한 전용 스레드 풀을 만들고 그 스레드 풀이 모두 사용중이라면 요청을 대기 큐에 넣어 스레드가 생길때까지 기다리겠다는 뜻입니다. 다시 말해서, 스레드 풀을 2개로 설정하면 동시에 처리되는 pdf 작업을 최대 2개까지로 제한할 수 있다는거죠.

 

@Bean(name = "pdfTaskExecutor")
public ThreadPoolTaskExecutor pdfTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);      // 동시 실행 가능한 PDF 작업 수
    executor.setMaxPoolSize(2);       // 최대 PDF 작업 수
    executor.setQueueCapacity(50);    // PDF 작업 큐 크기
    executor.setThreadNamePrefix("PDF-");
    executor.initialize();
    return executor;
}

 

이렇게 설정함으로써 서버 자원을 보호할 수 있습니다. 단점이라고 한다면 요청이 몰렸을 때 사용자에게 pdf 링크를 바로 전달하기 힘들다는 점이 있습니다만, 애초에 pdf 생성이 30초 ~ 1분정도 걸리는 긴 작업이기도 하고, 유저들에게도 pdf는 원래 오래 걸리는 작업이라는 인식이 있어서 괜찮은 trade-off라고 판단했습니다.

 

 

2. 표지 생성하기

책과 비슷한 pdf를 만드는게 프로젝트 목적이다 보니, 사용자가 생성한 그림에 맞춰 책 표지도 서비스 상에서 계속 업데이트 되면 좋지 않을까라는 생각을 했습니다. 그래서 사용자가 표지 이미지를 생성하고 저장할 때마다 표지를 생성하는 요청도 함께 보냈습니다. 기존에 표지를 생성할 때 사용했던 html 파일을 그대로 사용하였고, 이를 pdf 형태로 렌더링 해서 s3에 업로드 해놓은 뒤 클라이언트 요청시 표지 pdf 링크를 응답하였습니다.

 

그런데 몇가지 문제가 있었죠. 먼저 이 역시 pdf 렌더링 작업이다 보니 메모리와 cpu 점유율이 높았습니다. 물론 위에서 pdf를 생성할때 만큼 치명적으로 문제가 되진 않았지만 이번이 좀 더 빈번하게 이루어지는 요청이라는 점에서 주의가 필요했죠. 그리고 pdf를 바로 화면상에 렌더링해서 그런지 모르겠지만 화질이 별로 좋지 못해죠. 

 

이번에는 위의 경우랑 다르게 클라이언트의 요청에 따른 빠른 응답을 필요로 했기에 어떻게 해결해야 하나 고민을 했습니다만, 문득 이걸 굳이 pdf로 처리해야 하는지 의문이 들더라구요. 결국 유저에게 보일 이미지가 필요한건데, 그냥 바로 png로 생성하면 되지 않을까요? 바로 수정사항을 적용한 뒤 기존 방법과 비교해봤습니다.

 

기존 PDF 썸네일 생성

메모리 사용량: 202.28MB

총 실행시간: 4013ms (PDF생성: 1026ms, 변환: 477ms)

PDF크기: 37.94KB

결과 파일크기: 13223.06KB

 

개션 PNG 생성

메모리 사용량: 2.73MB

실행시간: 803ms

결과 파일크기: 88.57KB

 

서버 메모리 사용량 74배 절약되고 실행시간도 5배 절약되는 효과가 있었죠. 왜 진작 이렇게 안했나 싶더라구요. 한가지 특이했던 점은, 화질이 너무 좋은 이미지는 브라우저 상에서 랜더링 될 때 오히려 조금씩 깨져서 보였습니다. 특히 작은 이미지로 보일때 말이죠. 아마 실제 이미지와 브라우저에서 보여지는 이미지의 크기 차이가 크면 클라이언트에서는 안좋은 화질로 랜더링이 되는것 같았습니다. 그래서 표지 이미지를 생성할 때는 일부러 화질 낮은 작은 크기의 이미지를 생성했습니다. 무조건 고화질의 이미지를 생성하는게 최선은 아니더라구요. 

 

 

3. pdf 다운로드

마지막으로 적용한 기능은 pdf 다운로드 였습니다. 사실 이는 생성과 비슷한 결이긴 한데, 차이점은 pdf를 한번에 여러개를 zip 파일로 다운로드 받는 기능이 추가되어 있다는 점이죠. pdf 하나에 30~40mb 용량이라고 했었고, 이게 10개가 넘어가면 300~400mb에 달했죠. 이 프로젝트를 진행할 당시에는 메모리에 대한 개념이 정립되어 있지 않을 때라 테스트 도중 서버 의문사를 많이 당했습니다.

 

해결 방법은 간단합니다. 이건 cpu를 많이 점유하는 작업도 아니고, 그냥 압축 과정을 메모리에서 진행하다보니 전체 zip 파일이 메모리를 점유하여 어마무시하게 차지할 뿐이니 스트리밍 방식으로 다운로드 되도록 수정하면 될 일이죠. 변경 후 총 15개의 pdf를 다운로드 하는 비교도 진행해봤습니다.

 

구분 기존 방식 스트리밍 방식 개선 효과
메모리 사용량 1,067MB 86MB 92% 절약
처리 시간 1 14 1 15 비슷함
동시성 블로킹 비동기 향상

 

처리 시간은 비슷합니다만, 메모리 사용량에서 압도적인 이득을 보입니다. 추가로 이 과정을 비동기로 처리하도록 하여 동시 요청 처리도 더 안정적으로 진행되도록 개선할 수 있었죠.

 

 

마무리하며

이상으로 프로젝트에 pdf 관련 기능 도입기를 소개해 드렸습니다. 물론 이 외에도 폰트를 스프링 빈으로 등록하여 최초 한번만 불러오게 만든다던지, pdf 생성 요청을 이벤트리스너로 수정한다든지 등 사소한 수정 사항이 있었습니다만, 자잘한 부분이라 넘어갔습니다.

 

AI의 발전 이후로 웹 사이트의 기본적인 기능을 구현하는게 참 쉬워진것 같습니다. 저도 지금 공부도 할 겸 개인적으로 프로젝트에서 프런트엔드 팀원으로 참여하고 있습니다. cursor에서 claude 4.5 sonnet을 사용하고 있는데, 피그마에서 tailwind 코드를 입력하면 거의 정확하게 구현을 해주더라구요. 그냥 구현된걸 보고 이상한 부분만 수정해달라고 추가로 요청하면 됩니다. 저는 tailwind 문법을 전혀 모르는데 말이죠. 참 무섭습니다.

 

그런데 정말 단순한 페이지인데 이상하게 전환이 너무 늦는것 같아 코드를 확인하니까, 아니나 다를까 모든 페이지에 "use client"를 남발했더군요. 모든 페이지가 클라이언트 컴포넌트로 만들어져 풀 라우트 캐시의 이점을 전혀 사용하지 못하고 있다는 건데, 이럴거면 뭐하러 next.js를 사용합니까. 그냥 바닐라 js로 구현하고 말지.

 

그 누구보다 AI에 의존하는, 클로드 할당량 다 쓰면 바보되는 바이브 코더라고 자부합니다만, 이럴때마다 느껴지는건 결국 진짜 역량은 프레임워크와 컴퓨터에 대한 단단한 사전 지식을 기반으로 최적의 구조를 머릿속에 그릴 수 있는게 진짜 개발자의 실력이 아닐까라는 생각을 합니다. 제가 올바른 구조나, 설계 의도, 성능 개선 등에 집착하는것도 이런 이유가 큽니다.

 

오늘도 글이 참 길어졌습니다만, 이 글도 위와 같은 생각을 갖고, 단순히 "작동하니까 그대로 두자" 라는 생각에서 벗어나 최대한 어떻게 하면 요구사항에 맞추면서 최적의 설계를 할 수 있을까에 대한 고민을 했던 흐름이다 생각해주시면서 읽어주시면 감사할것 같아요.

 

오늘도 읽어주셔서 감사합니다 d^^b