사진 여러 장을 PDF 한 장으로 합치는 게 왜 이렇게 오래 걸릴까 싶어서 만든 변환 서비스입니다.
파일 스튜디오는 사용자가 업로드한 이미지 파일(PNG, JPG, BMP)을 PDF 문서로 변환해주는 서비스입니다. 처음엔 동기 처리로 짰는데, 변환이 오래 걸리는 요청 하나가 응답 지연은 물론 장애까지 메인 서버로 그대로 퍼뜨리는 구조라 이벤트 기반으로 다시 짰습니다. RabbitMQ로 비동기 처리를 하고 Debezium CDC를 붙여서 변환 작업을 메인 서버와 완전히 떼어냈고, 사용자는 요청하자마자 바로 응답을 받고 SSE로 변환 진행 상태를 실시간으로 확인할 수 있습니다.
GitHub 저장소 보기
동기 처리 구조라 사용자는 변환이 완전히 끝날 때까지 응답을 기다려야 했다.
변환 처리에 장애가 나면 그게 메인 서버까지 그대로 전파되는 구조였다.
도메인 간 결합도가 높아서, 트래픽이 급증해도 필요한 부분만 따로 확장할 수가 없었다.
전체 프로세스를 비동기 이벤트 기반으로 바꿔서 시스템 처리 성능을 끌어올리는 게 목표였다.
사용자에게 작업 진행 상태를 실시간으로 전달할 수 있는 환경도 같이 만들어야 했다.
무거운 파일 변환 작업을 RabbitMQ로 비동기 분리했다. 수동 Ack와 DLQ(Dead Letter Queue)를 같이 설정해서 처리 안정성도 챙겼다.
Transactional Outbox Pattern과 Debezium(CDC)을 같이 적용해서 DB 업데이트와 도메인 이벤트 발행이 따로 놀지 않도록 묶었다.
역할별로 결합도를 낮추도록 모듈을 나눴다.
무거운 파일 변환 작업을 별도 컨슈머 서버로 떼어놓으니, 사용자는 요청하자마자 바로 응답을 받고 변환은 뒤에서 순차적으로 안정적으로 처리된다.
도메인 간 결합도를 낮춘 덕분에, 변환 모듈에 장애가 나도 메인 서버로는 전파되지 않는다.
메인 서버와 변환 작업이 완전히 분리돼서, 트래픽이 급증해도 컨슈머 서버만 유연하게 늘리면 되는 구조가 됐다. 그만큼 인프라 비용도 줄었다.
사용자가 올리는 이미지의 해상도와 크기를 미리 알 수 없어서, PDF 규격을 고정해두면 이미지가 깨지거나 여백이 이상하게 생길 수 있었다.
변환 요청이 몰리는 순간(트래픽 스파이크)에 파일 전체를 메모리에 올려서 처리하면, JVM 힙 사용량이 급증해서 서버가 다운될 수 있었다.
원본 파일 크기나 해상도와 상관없이, ByteArrayOutputStream 안에서 chunk 단위로 유연하게 흘려보낼 수 있는 스트림 파이프라인이 필요했다.
ByteArrayOutputStream을 try-with-resources로 감싸서 자원 누수를 막았다. PdfWriter와 PdfDocument를 파이프라인으로 연결해서 데이터가 순차적으로 flush되게 했다.
ImageDataFactory로 이미지 원본 가로·세로 픽셀을 런타임에 뽑아서, PDF PageSize를 이미지 비율에 1:1로 맞췄다. 이렇게 하니 여백 때문에 문서 용량이 커지거나 레이아웃이 깨지는 일이 없어졌다.
비표준 해상도나 파노라마처럼 긴 이미지도 원본 왜곡이나 화질 저하 없이 그대로 변환된다.
파일 크기가 들쭉날쭉해도 메모리 점유율은 일정하게 유지된다.
주기적인 HTTP Polling 스케줄러가 새 데이터가 있든 없든 테이블을 계속 조회하고 있었다. 데이터가 쌓일수록 CPU 부하가 늘고 디스크 I/O도 그만큼 낭비되고 있었다.
Polling으로 인한 디스크 I/O 낭비를 없애려면, 실제로 데이터가 생성될 때만 테이블을 스캔하는 구조로 바꿔야 했다.
다양한 데이터베이스를 지원하는 Debezium을 골라서, sink(목적지)를 RabbitMQ로 지정했다.
사용자 요청 테이블 기록과 Outbox 테이블 기록을 하나의 트랜잭션으로 묶었다. outbox 테이블에는 생성 명령어만 들어가도록 설계했다.
독립 서버로 따로 구성해서 나중에 확장하기 쉽게 만들었다.
DB의 바이너리 로그를 실시간으로 캡처해서 변경이 감지되면 바로 메시지 큐로 이벤트를 발행하는 구조로 바꾸니, 폴링으로 점유하던 CPU 사용률이 80% 줄었고 디스크 I/O 부하도 사라졌다.
처리 성능을 기준으로 25만 건을 실제로 적재해서 검증했다.
독립적인 CDC 서버가 메시지 발행 역할을 가져가면서, API 서버에 장애가 나도 메시지 발행 쪽은 영향을 받지 않는다.
컨슈머 서버의 CPU, Memory, GC 상태를 확인할 방법이 없다 보니, 병렬 처리나 스케일아웃이 필요한 시점을 그냥 감으로 판단해야 했다.
로컬과 운영 환경에서 다르게 나타나는 요소들을 그냥 느낌으로만 알았지, 숫자로 명확히 잡아낼 방법이 없었다.
독립된 서버를 새로 만들어서 모니터링에 맞는 환경을 구성해야 했다.
Warm up부터 Traffic spike까지 5단계 정도로 시나리오를 나눠 짰다.
SpringBoot Actuator와 Micrometer로 JVM, MQ 메트릭을 모았다.
rampTo로 트래픽을 서서히 늘리면서, 랜덤 UUID와 서비스 기준 최대 크기의 테스트 이미지 파일을 실제로 바인딩해서 돌렸다.
모은 데이터를 가지고 메시지 개당 평균 Ack 처리시간, 최대 지연시간, 처리량 같은 지표를 PromQL로 패널에 구성했다.
실시간 트래픽과 큐 대기열이 눈에 보이니, 트래픽이 급증할 때 컨슈머 서버를 언제 늘려야 할지 판단할 수 있게 됐다. 리소스 낭비도 줄고 운영도 더 안정적이다.
가상 uuid와 임의 이미지 파일로 API·S3 업로드 부하 테스트를 돌려서, 오류 없이 처리되는 걸 직접 확인했다.
변환 진행 상태를 확인하려고 프론트가 계속 폴링 요청을 보내다 보니, 서버 자원과 네트워크 대역폭이 그만큼 낭비되고 있었다.
변환 진행률을 가볍고 빠르게 전달하고, 사용자가 따로 요청하지 않아도 진행 상태를 알 수 있게 만들어야 했다.
SSE(Server-Sent Events)로 단방향 실시간 채널을 만들어서 불필요한 HTTP 오버헤드를 줄였다. 클라이언트의 UUID와 파일명을 매칭해서 정확히 그 화면에만 실시간으로 전달되게 했다.
Redis Pub/Sub으로 변환이 진행되는 동안 API 서버에 상태 메시지를 계속 전달하도록 했다.
상태 조회 트래픽이 90% 이상 줄었고, 사용자는 변환 진행 상태를 바로바로 볼 수 있게 됐다.
막혔던 문제를 어떻게 정의하고, 원인을 찾아 풀었는지 정리했습니다.
변환이 실패했는데 메시지가 이미 사라져서 재처리를 할 수가 없었다. Consumer의 Ack 설정이 기본값인 Auto라, 메시지를 받는 순간 큐에서 바로 소진돼버리고 있었던 것이다.
변환 프로세스 전체 지연시간을 뜯어보니 S3 네트워크 구간이 44% 가량을 차지하고 있었다. 이 구간이 길어질수록 Throughput이 떨어지고 병목이 심해졌다.
API 서버 로직과 워커 변환 로직이 한 모듈 안에 같이 있다 보니 배포 단위를 나누기가 어려워서, api-app / worker-app / infra-s3 / infra-redis / infra-producer-rmq / common-core로 쪼개는 작업을 진행했다. 그런데 파일만 옮기고 나니 여기저기서 빌드가 깨졌다. settings.gradle에는 이미 없어진 file-domain, infra-rabbit 모듈이 그대로 남아있었고, infra-producer-rmq는 Spring Boot 플러그인도 @SpringBootApplication 진입점도 없어서 독립 실행이 안 되는 상태였다. api-app에는 JPA를 쓰면서 정작 spring-boot-starter-data-jpa가 빠져있는 식으로, 모듈별로 실제 쓰는 의존성이 자기 build.gradle에는 없는 경우가 여러 군데 있었다.