Project

파일 스튜디오

사진 여러 장을 PDF 한 장으로 합치는 게 왜 이렇게 오래 걸릴까 싶어서 만든 변환 서비스입니다.

프로젝트 소개

파일 스튜디오는 사용자가 업로드한 이미지 파일(PNG, JPG, BMP)을 PDF 문서로 변환해주는 서비스입니다. 처음엔 동기 처리로 짰는데, 변환이 오래 걸리는 요청 하나가 응답 지연은 물론 장애까지 메인 서버로 그대로 퍼뜨리는 구조라 이벤트 기반으로 다시 짰습니다. RabbitMQ로 비동기 처리를 하고 Debezium CDC를 붙여서 변환 작업을 메인 서버와 완전히 떼어냈고, 사용자는 요청하자마자 바로 응답을 받고 SSE로 변환 진행 상태를 실시간으로 확인할 수 있습니다.

GitHub 저장소 보기
요청 처리 (이벤트 기반)
Client API 서버 MySQL 저장 →(CDC) Debezium RabbitMQ Worker 서버 (변환) S3
실시간 진행 상태
Worker 서버 SSE Client

실제 화면
파일 추가 및 삭제 데모
파일 추가 및 삭제
파일 업로드 및 실시간 변환 현황 데모
업로드 및 실시간 변환 현황

상세 다이어그램
파일 스튜디오 시퀀스 다이어그램(UML)
시퀀스 다이어그램 (UML)
파일 스튜디오 클라우드 아키텍처(AWS)
클라우드 아키텍처 (AWS)
2026.01 ~ 2026.04 (4개월) 2명
핵심 지표
장애 전파 차단 이벤트 기반으로 전환
CPU 80% 절감 Debezium 실시간 감지
정확도 100% 스트림 기반 변환 최적화
핵심 임팩트
Java Spring Boot RabbitMQ Debezium MySQL Redis AWS +4
My Contributions
SITUATION상황
어떤 크기의 이미지가 올지 알 수가 없다

사용자가 올리는 이미지의 해상도와 크기를 미리 알 수 없어서, PDF 규격을 고정해두면 이미지가 깨지거나 여백이 이상하게 생길 수 있었다.

파일 전체를 메모리에 올리면 위험하다

변환 요청이 몰리는 순간(트래픽 스파이크)에 파일 전체를 메모리에 올려서 처리하면, JVM 힙 사용량이 급증해서 서버가 다운될 수 있었다.

TASK과제
파일 크기와 무관하게 메모리 사용량을 일정하게

원본 파일 크기나 해상도와 상관없이, ByteArrayOutputStream 안에서 chunk 단위로 유연하게 흘려보낼 수 있는 스트림 파이프라인이 필요했다.

ACTION실행
스트림으로 버퍼링하고 자원은 자동으로 닫히게

ByteArrayOutputStream을 try-with-resources로 감싸서 자원 누수를 막았다. PdfWriter와 PdfDocument를 파이프라인으로 연결해서 데이터가 순차적으로 flush되게 했다.

이미지 크기에 맞춰 페이지 사이즈를 그때그때 잡았다

ImageDataFactory로 이미지 원본 가로·세로 픽셀을 런타임에 뽑아서, PDF PageSize를 이미지 비율에 1:1로 맞췄다. 이렇게 하니 여백 때문에 문서 용량이 커지거나 레이아웃이 깨지는 일이 없어졌다.

RESULT결과
레이아웃 깨짐 없이 변환된다

비표준 해상도나 파노라마처럼 긴 이미지도 원본 왜곡이나 화질 저하 없이 그대로 변환된다.

힙 메모리도 안정적으로 유지된다

파일 크기가 들쭉날쭉해도 메모리 점유율은 일정하게 유지된다.

SITUATION상황
할 일이 없어도 계속 테이블을 조회하고 있었다

주기적인 HTTP Polling 스케줄러가 새 데이터가 있든 없든 테이블을 계속 조회하고 있었다. 데이터가 쌓일수록 CPU 부하가 늘고 디스크 I/O도 그만큼 낭비되고 있었다.

TASK과제
필요할 때만 테이블을 보게 하자

Polling으로 인한 디스크 I/O 낭비를 없애려면, 실제로 데이터가 생성될 때만 테이블을 스캔하는 구조로 바꿔야 했다.

ACTION실행
Debezium(CDC)으로 바꾸기

다양한 데이터베이스를 지원하는 Debezium을 골라서, sink(목적지)를 RabbitMQ로 지정했다.

Outbox 패턴으로 이벤트 발행 신뢰성 확보

사용자 요청 테이블 기록과 Outbox 테이블 기록을 하나의 트랜잭션으로 묶었다. outbox 테이블에는 생성 명령어만 들어가도록 설계했다.

Docker로 독립 CDC 서버 분리

독립 서버로 따로 구성해서 나중에 확장하기 쉽게 만들었다.

RESULT결과
CPU 사용률 80% 절감

DB의 바이너리 로그를 실시간으로 캡처해서 변경이 감지되면 바로 메시지 큐로 이벤트를 발행하는 구조로 바꾸니, 폴링으로 점유하던 CPU 사용률이 80% 줄었고 디스크 I/O 부하도 사라졌다.

처리 성능을 기준으로 25만 건을 실제로 적재해서 검증했다.

메시지 발행이 API 서버에서 완전히 독립됐다

독립적인 CDC 서버가 메시지 발행 역할을 가져가면서, API 서버에 장애가 나도 메시지 발행 쪽은 영향을 받지 않는다.

메시지 정상 적재 CPU 현황
SITUATION상황
서버가 지금 힘든지 아닌지 알 방법이 없었다

컨슈머 서버의 CPU, Memory, GC 상태를 확인할 방법이 없다 보니, 병렬 처리나 스케일아웃이 필요한 시점을 그냥 감으로 판단해야 했다.

처리량과 S3 네트워크 속도도 숫자로 못 봤다

로컬과 운영 환경에서 다르게 나타나는 요소들을 그냥 느낌으로만 알았지, 숫자로 명확히 잡아낼 방법이 없었다.

TASK과제
모니터링 서버부터 따로 만들어야 했다

독립된 서버를 새로 만들어서 모니터링에 맞는 환경을 구성해야 했다.

부하테스트 시나리오도 짜야 했다

Warm up부터 Traffic spike까지 5단계 정도로 시나리오를 나눠 짰다.

ACTION 실행
Prometheus로 메트릭 수집 환경 구성

SpringBoot Actuator와 Micrometer로 JVM, MQ 메트릭을 모았다.

Artillery로 배포 서버에 실제 부하를 걸었다

rampTo로 트래픽을 서서히 늘리면서, 랜덤 UUID와 서비스 기준 최대 크기의 테스트 이미지 파일을 실제로 바인딩해서 돌렸다.

RESULT 결과
Grafana 대시보드로 성능 지표를 한눈에

모은 데이터를 가지고 메시지 개당 평균 Ack 처리시간, 최대 지연시간, 처리량 같은 지표를 PromQL로 패널에 구성했다.

이제 숫자를 보고 스케일링 시점을 판단할 수 있다

실시간 트래픽과 큐 대기열이 눈에 보이니, 트래픽이 급증할 때 컨슈머 서버를 언제 늘려야 할지 판단할 수 있게 됐다. 리소스 낭비도 줄고 운영도 더 안정적이다.

5단계 부하 테스트로 안정성을 직접 확인

가상 uuid와 임의 이미지 파일로 API·S3 업로드 부하 테스트를 돌려서, 오류 없이 처리되는 걸 직접 확인했다.

프로메테우스 메트릭 수집 구조 S3 및 RabbitMQ 모니터링 그래프 부하테스트 결과
SITUATION상황
진행 상태를 보려면 계속 물어봐야 했다

변환 진행 상태를 확인하려고 프론트가 계속 폴링 요청을 보내다 보니, 서버 자원과 네트워크 대역폭이 그만큼 낭비되고 있었다.

TASK과제
폴링 없이도 진행 상태가 보이게

변환 진행률을 가볍고 빠르게 전달하고, 사용자가 따로 요청하지 않아도 진행 상태를 알 수 있게 만들어야 했다.

ACTION실행
SSE로 단방향 실시간 채널 구축

SSE(Server-Sent Events)로 단방향 실시간 채널을 만들어서 불필요한 HTTP 오버헤드를 줄였다. 클라이언트의 UUID와 파일명을 매칭해서 정확히 그 화면에만 실시간으로 전달되게 했다.

가볍고 빠르게 상태만 전달

Redis Pub/Sub으로 변환이 진행되는 동안 API 서버에 상태 메시지를 계속 전달하도록 했다.

RESULT결과
트래픽 90% 이상 줄고, 체감 속도도 좋아졌다

상태 조회 트래픽이 90% 이상 줄었고, 사용자는 변환 진행 상태를 바로바로 볼 수 있게 됐다.

Technical Case Studies

문제 해결 사례

막혔던 문제를 어떻게 정의하고, 원인을 찾아 풀었는지 정리했습니다.

01. 재처리 전략 및 메시징 트랜잭션 구현

Transaction Event-Driven-Architecture
Resolved

Root Cause

변환이 실패했는데 메시지가 이미 사라져서 재처리를 할 수가 없었다. Consumer의 Ack 설정이 기본값인 Auto라, 메시지를 받는 순간 큐에서 바로 소진돼버리고 있었던 것이다.

Final Action & Impact

  • Ack를 Manual로 바꾸고 재처리 로직 추가 Ack/Nack을 직접 제어하도록 바꾸고, RetryTemplate으로 재시도와 DLQ를 구성했다
  • 성공했을 때만 Ack, 실패는 DLQ로 격리 비즈니스 로직이 성공한 경우에만 Ack를 보내고, 실패하면 최대 3회 재시도한 다음 최종 실패 시 DLQ로 라우팅해서 관리자가 에러를 따로 모니터링할 수 있게 했다

02. 워커 서버와 S3 간의 Network Latency 개선

Performance VPC Endpoint
Resolved

Root Cause

변환 프로세스 전체 지연시간을 뜯어보니 S3 네트워크 구간이 44% 가량을 차지하고 있었다. 이 구간이 길어질수록 Throughput이 떨어지고 병목이 심해졌다.

Final Action & Impact

  • VPC Endpoint로 경로를 바꿨다 기존에 NAT Gateway와 인터넷을 거치던 경로를 VPC Endpoint(AWS 내부망 전용 경로)로 대체했다
  • Network Latency 10~15% 감소 최대 지연 시간이 0.3s에서 0.2s로 줄고 Throughput은 5~10% 늘었다. 내부망을 타니 처리 성능도 더 안정적으로 나왔다

03. 단일 모듈 → 멀티 모듈 전환 중 의존성이 꼬였다

Gradle Multi-Module Refactor
Resolved

Root Cause

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에는 없는 경우가 여러 군데 있었다.

Final Action & Impact

  • settings.gradle을 실제 모듈 구성과 맞췄다 사라진 file-domain, infra-rabbit 대신 common-core / infra-producer-rmq / infra-s3 / infra-redis / api-app / worker-app을 include하도록 정리했다
  • infra-producer-rmq를 독립 실행 가능한 모듈로 완성 Spring Boot 플러그인과 @SpringBootApplication 진입점을 추가해서, CDC 이벤트를 RabbitMQ로 발행하는 프로듀서가 별도 서버로 뜰 수 있게 했다
  • 모듈별로 빠진 의존성을 하나씩 채워서 빌드 정상화 api-app의 spring-boot-starter-data-jpa, worker-app의 spring-boot-starter-data-redis처럼 실제로 쓰는데 선언이 안 돼있던 의존성을 모듈마다 확인해서 채웠다. 그 결과 api-app / worker-app / infra-producer-rmq가 각자 독립적으로 빌드·배포되는 멀티 모듈 구조로 정리됐다