화면 캡처로 PDF 만들던 시스템을 MQ 워커로 바꾼 이야기 (9s → 1.5s)
단순 최적화가 아니라 구조 자체를 바꿔야 했던 이유. 왜 화면 캡처가 근본적으로 틀린 접근이었는지, 왜 Message Queue를 선택했는지.
화면 캡처로 PDF 만들던 시스템을 MQ 워커로 바꾼 이야기 (9s → 1.5s)
처음 받은 시스템의 구조
입사하고 리포트 생성 기능을 처음 확인했을 때의 구조는 이랬다.
사용자 → 업로드 요청
→ 클라이언트 화면 이동 (결과 화면 A → B → C → ...)
→ 각 화면마다 스크린샷 생성 → S3 업로드
→ 모든 이미지를 모아서 Python 서버가 PDF로 합침
→ 완료 응답 반환
한 건의 리포트를 만들기 위해 최대 9초 이상이 걸렸다. 검사를 마친 환자와 보호자가 기다리는 동안, 의료진은 그냥 스크린을 바라봤다.
VOC가 쌓이기 시작했다. "리포트 출력이 왜 이렇게 느리냐"는 민원이 반복됐다.
왜 "최적화"가 아니라 "구조 변경"이었나
처음에는 Python 서버 튜닝이나 이미지 압축 같은 방향을 생각할 수 있었다. 실제로 그 방향을 먼저 검토했다.
그런데 분석을 해보니 병목이 한 곳에 있는 게 아니었다.
비용이 발생하는 지점이 너무 많았다
| 단계 | 비용 |
|---|---|
| 화면 이동마다 렌더링 대기 | UI 렌더링 시간 × 화면 수 |
| 스크린샷 생성 | CPU + 메모리 |
| S3 업로드 (화면 수만큼 반복) | 네트워크 I/O × N |
| Python 서버에서 이미지 다운로드 | 네트워크 I/O × N |
| 이미지 → PDF 변환 | Python 서버 CPU |
화면이 10개라면 S3 업로드와 다운로드가 각각 10번씩 발생한다. 이걸 "최적화"한다는 건 각 단계를 조금씩 줄이는 것인데, 구조 자체가 비효율의 원인이라면 최적화의 한계가 명확하다.
근본 문제는 "화면 캡처" 방식 자체였다.
리포트에 들어가는 데이터는 이미 DB에 있었다. 그런데 그 데이터를 화면에 렌더링한 뒤, 그 화면을 다시 이미지로 찍어서 PDF로 만들고 있었다. DB → 화면 → 이미지 → PDF라는 불필요한 변환 과정이 전체 지연의 원인이었다.
데이터는 이미 있다. 굳이 화면을 경유할 이유가 없다.
왜 다른 선택지는 안 됐나
구조를 바꾸기로 했을 때 검토한 방향이 몇 가지 있었다.
선택지 1: 그냥 비동기 처리 (Async/Await)
요청 받은 뒤 백그라운드 스레드에서 처리하고 응답하는 방식.
문제는 서버 재시작이나 장애 시 처리 중인 작업이 사라진다는 것이다. 의료 플랫폼 특성상 리포트 유실은 치명적이다. 또 동시 요청이 몰릴 경우 스레드 폭발 위험도 있다.
선택지 2: 배치 스케줄러 (Cron Job)
일정 주기마다 미처리 리포트를 일괄 생성하는 방식.
리포트 생성 요청 후 다음 배치까지 기다려야 한다. "요청 즉시 리포트가 나와야 한다"는 요구사항과 맞지 않았다.
선택지 3: Message Queue + 워커 서버
요청을 큐에 넣고, 워커가 꺼내서 처리하는 방식.
- 서버 재시작 시에도 메시지는 큐에 남아 있음 → 내구성 확보
- 워커 수를 조정해 처리량 조절 가능 → 확장성
- 요청자는 즉시 응답 받음 → 응답성
세 조건을 모두 만족하는 건 MQ 방식뿐이었다.
바꾼 구조
[기존]
사용자 → 업로드 요청 → 화면 캡처 × N → S3 업로드 × N
→ Python 서버 PDF 생성 → 완료 응답 (9초+)
[변경 후]
사용자 → 리포트 생성 요청 → 즉시 응답 ("처리 중")
→ MQ에 Job 적재
워커 서버 → MQ에서 Job 꺼냄
→ DB에서 검사 데이터 조회
→ 템플릿 기반 PDF 생성
→ S3 업로드 완료
사용자 → 리포트 조회/다운로드 요청 → S3에서 직접 제공
핵심은 두 가지다.
- 리포트 생성 요청과 리포트 조회를 완전히 분리했다
- 화면 캡처를 제거하고 DB 데이터를 직접 읽어 PDF를 만들었다
트레이드오프: 즉시 응답 vs 폴링
이 구조의 트레이드오프는 명확하다. 사용자가 "리포트 생성" 버튼을 누른 직후에는 PDF가 없다. 워커가 처리를 끝낸 뒤에야 다운로드할 수 있다.
이걸 처리하는 방법은 두 가지를 검토했다.
방법 A: 폴링 (Polling)
클라이언트가 주기적으로 "리포트 준비됐어?" API를 호출하는 방식. 구현이 단순하다는 장점이 있지만, 불필요한 요청이 반복된다.
방법 B: 상태 기반 조회
"리포트 조회" 버튼을 누를 때 S3에서 파일이 있으면 다운로드, 없으면 "생성 중" 메시지를 보여주는 방식.
플랫폼 사용 패턴을 보면 리포트 생성 후 바로 다운로드하는 게 아니라, 검사 마무리 상담 후 다운로드하는 경우가 많았다. 그 사이에 워커가 처리를 끝낸다. 폴링 없이 상태 기반 조회만으로 충분했다.
결과
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 리포트 생성 체감 시간 | 최대 9초+ | 즉시 응답 (생성은 백그라운드) |
| PDF 완성까지 실제 처리 시간 | 9초+ | 약 1.5초 (83% 단축) |
| S3 업로드 횟수 | 화면 수 × N | 1회 (최종 PDF) |
| 서버 부하 | 요청마다 Python 서버 점유 | 워커가 분산 처리 |
| 피크 타임 안정성 | 동시 요청 시 지연 급증 | 큐잉으로 안정적 처리 |
그리고 VOC가 거의 사라졌다. 의료진 입장에서 "생성 버튼 누르면 즉시 다음 화면으로 넘어가고, 상담 끝나고 다운로드 누르면 바로 나온다"는 경험이 됐다.
마무리
이 개선에서 배운 건 단순한 기술 선택의 문제가 아니었다.
"왜 이렇게 느린가"를 분석하다 보면 결국 "왜 이 구조를 선택했는가"까지 거슬러 올라가야 한다. 화면 캡처 방식은 아마 초기에 빠르게 구현하려다 선택된 방식일 것이다. 당시엔 그게 합리적인 선택이었을 수도 있다.
하지만 데이터가 늘고 사용자가 늘면 그 구조의 비용이 서서히 드러난다. 그 시점에 "최적화"로 버틸지, "구조 변경"으로 가야 할지를 판단하는 게 결국 개발자의 역할이라고 생각한다.
숫자가 9초에서 1.5초로 줄어든 것보다, 화면을 경유할 이유가 없다는 걸 깨닫는 과정이 더 중요했다.