2026-10-11
일반 문서 ZIP 다운로드: 15초 병목을 줄이고 직접 스트리밍의 대가를 검증하다
로컬 공사대장 200건의 PDF 반복 비용과 DB 연결 점유를 분리해 개선했다. 메모리·디스크·동시성·느린 전송·취소를 차례로 측정하고, 150번째 생성 실패에서 HTTP 200 미완성 ZIP을 확인해 prepared 기본값을 유지했다.
일반 문서 ZIP 다운로드: 15초 병목에서 2초대까지, 그리고 직접 전송을 기본값으로 두지 않은 이유
2026년 10월 11일, GET /api/v1/projects/{projectId}/documents/zip을 로컬 합성 데이터로 조사했다. 한 요청은 같은 양식의 2페이지 공사대장 200건이며 결과 ZIP은 약 6.53MiB다. 처음에는 ZIP 다운로드가 느린 이유를 DB 조회에서 찾을 수도 있었다. 하지만 200건의 SQL 실행 합계는 5.358ms인 반면 전체 수신은 중앙값 15.416초였다. 이 차이를 출발점으로 PDF 생성, 트랜잭션, ZIP 저장 방식, 동시 요청, 전송 정책을 한 단계씩 검증했다. 아래 수치는 운영 성과나 실제 사용자 트래픽이 아니라 각각 명시한 로컬 조건에서 얻은 결과다.
첫 진단: 조회는 짧았고, PDF를 만드는 동안 연결이 열려 있었다
변경 전 코드는 읽기 트랜잭션 안에서 문서를 조회한 뒤 PDF를 전부 만들어 리스트에 모으고, 메모리에서 ZIP byte 배열까지 완성해 응답했다. 1·10·50·100·200건을 앱 재시작을 포함한 세 라운드에서 측정한 200건 중앙값은 전체 수신 15.416초, 첫 바이트 약 15초, DB 연결 점유 15.385초였다. SELECT는 요청당 세 번이고 문서 조회는 한 번에 정확히 200행을 가져왔다. N+1도 관찰되지 않았다. SQL 합계가 수 ms라는 사실과 idle in transaction 표본은 쿼리 최적화보다 트랜잭션 안의 파일 생성이 문제임을 가리켰다.
별도 JFR 진단에서는 PDF 렌더링과 FontBox 경로가 CPU·할당 표본에 자주 나타났다. 일반 문서 생성기가 각 PDF마다 Regular/Bold 폰트를 파일에서 읽고 해석하고 있었다. 이 표본을 실제 CPU 시간 비율로 환산하지 않고 다음 변경의 가설로 삼았다. 첫 JFR 수집에서는 다운로드와 파일 검사는 성공했지만 JFR.stop 도중 1,200MiB 컨테이너가 OOM 종료됐다. 원인 경로만 확인하려고 2GiB로 다시 수집했으며, 그때 GC도 Serial에서 G1으로 바뀌어 성능 비교에는 쓰지 않았다.
폰트 재사용: 시간을 줄였지만 연결 경계는 그대로였다
TTF 두 개를 빈 생성 때 한 번 해석하고 공유했다. PDF 문서와 임베딩 폰트 객체는 매번 새로 만들어 문서별 상태를 분리했다. 같은 2CPU·1,200MiB·Serial GC 조건에서 200건 전체 수신 중앙값은 15.416→1.896초, 약 87.7% 줄었다. 관측 JVM 사용 힙 최대는 659.1→278.1MiB, GC 정지 합계는 5.461→0.077초였다. 누적 할당 추정치도 15.11→2.80GiB로 줄었다. 누적 할당은 동시에 점유한 메모리가 아니며, 힙 최대는 프로세스 전체 표본이다. 컨테이너 메모리 한계와 회수 비용의 기여를 분리하지 못했으므로 시간 차이 전체를 폰트의 순수 CPU 절감으로 설명하지 않는다.
빠른 PDF 생성 덕분에 DB 연결 점유도 15.385→1.865초로 줄었지만, 트랜잭션은 여전히 파일 생성까지 이어졌다. idle in transaction이 다시 관찰됐다. 그래서 속도 개선과 연결 경계 개선을 다른 단계로 나눴다. 폰트 변경 뒤 생성한 ZIP 15개와 PDF 1,083개를 검사했고, 공유 생성기 8개 스레드에서 만든 64개 PDF의 텍스트·픽셀도 단독 생성 기준과 비교했다. 이 범위에서 출력 차이와 공유 오류는 없었다.
트랜잭션 단축: 다운로드 속도 대신 연결 점유를 고쳤다
인가와 조회를 읽기 트랜잭션 안에서 끝내고, 값으로 복사한 DTO 스냅샷을 이용한 PDF·ZIP 생성은 트랜잭션 종료 뒤 같은 HTTP 요청에서 실행하도록 했다. PDF 생성 실패 시 되돌릴 DB 쓰기는 이 경로에 없고, 응답도 시작 전이어서 500 JSON을 반환할 수 있었다.
폰트 재사용 단계와 같은 1,200MiB·Serial GC 조건의 세 라운드를 비교하면, 200건 DB 연결 점유 중앙값은 1.865초→29ms로 약 98.4% 줄었다. 전체 수신은 1.896→1.901초로 사실상 비슷했다. 변경 후 idle in transaction 표본은 0이었지만 약 200ms 간격 표본이 짧은 트랜잭션을 놓칠 수 있다. Hikari 카운터에는 실제 27~29ms의 연결 점유가 남아 있다. 이는 연결 반환 시점을 확인한 결과이지, 단일 요청 실험만으로 DB 풀 고갈이나 다른 API 지연이 해결됐다는 증거는 아니다.
순차 압축: 첫 후보의 힙 결과를 인정하고 저장 방식을 바꿨다
그다음 목표는 모든 PDF 원본과 커지는 ZIP을 함께 힙에 두는 구조를 줄이는 것이었다. 먼저 PDF를 한 건씩 ByteArrayOutputStream에 압축했다. 원본 PDF 리스트는 없어졌지만 200건 관측 힙 최대 중앙값은 트랜잭션 단계의 280.1MiB보다 높은 299.1MiB였다. 이 후보를 메모리 개선으로 발표할 수 없어 원본 측정은 보존하고 ZIP 출력을 요청별 임시 파일로 바꿨다.
임시 ZIP 방식에서는 PDF를 생성하자마자 압축해 이전 PDF 배열 참조를 남기지 않았다. 같은 조건에서 200건 관측 힙 최대는 280.1→264.5MiB, 약 5.6% 줄었고 전체 수신은 1.901→2.138초, 약 12.5% 늘었다. 시간 범위는 겹쳐 증가분을 전부 디스크 I/O로 돌릴 수 없지만, 빨라졌다고도 말할 수 없다. ZIP 처리만 따로 떼어 실제 PDF 배열을 재생한 참조 진단에서는 마지막 PDF 직전 이전 배열이 200건 기준 199개에서 0개로 줄었다. 이 진단은 HTTP 전체 힙 측정이 아니다. 생성 중 실패하면 부분 임시 ZIP을 삭제하고 응답 전 500 JSON을 유지했다. 임시 디스크 용량과 비정상 종료 뒤 정리라는 새 운영 비용이 생겼다.
완성 ZIP 전송: 전송 단계의 배열은 없앴지만 전체 힙 감소는 없었다
세 번째 단계까지는 완성된 임시 ZIP을 Files.readAllBytes로 다시 읽어 응답했다. 다음 변경에서는 파일을 완성한 뒤 16KiB 버퍼로 응답 스트림에 복사하고 정리했다. 여전히 완성 ZIP 전송이다. PDF별로 즉시 네트워크에 내보내는 방식은 아니므로 생성 실패 시 500 JSON과 완성 파일을 확인한 뒤 성공 응답하는 의미를 지켰다.
200건 ZIP 6,851,309 bytes의 전송 단계만 별도로 재면 호출 스레드 할당이 6,851,808→16,800 bytes로 줄었다. 그러나 실제 API 200건의 관측 최대 힙 중앙값은 264.5→272.2MiB로 오히려 높았고 전체 수신은 2.138→2.063초로 범위가 겹쳤다. 전체 힙이 크게 줄었다거나 응답 속도가 확실히 개선됐다고 주장하지 않는다. 현재 PDF, 조회 DTO, 렌더러 객체는 여전히 힙에 있다. 느린 클라이언트에는 완성 임시파일과 요청 스레드가 오래 남는다.
동시 요청: 정상 완료와 리소스 한계를 함께 봤다
완성 ZIP 전송 코드를 2CPU·2GiB·G1의 로컬 환경에서 200건 ZIP 요청 1·4·8·16개로 시험했다. 16개 동시 요청도 모두 완료됐지만 전체 수신 중앙값은 34.70초, 관측 JVM 힙 최대 873.5MiB(설정 최대 922MiB의 약 95%), 앱 임시 ZIP 최대 94.1MiB였다. GC 정지 합계는 8개 동시 1.43~1.89초에서 16개 동시 9.77초로 커졌고, 병행 호출한 단건 조회의 최대 지연은 약 408ms였다. Hikari 대기 표본 최대는 0이어서 이 조건의 압박은 DB 조회보다 동시 PDF 생성과 CPU·GC 쪽이었다. 같은 코드를 1,200MiB·Serial GC로 돌린 결과를 코드 변경 효과로 비교하지 않았다. 그 환경은 부하 뒤 진단 명령을 실행할 여유도 부족해 OOM 종료됐다.
8개를 각각 128KiB/s로 느리게 읽으면 앱 직접 연결의 전체 수신 중앙값은 65.77초였고, 그 구간에 관측한 앱 임시 ZIP 최대는 52.3MiB였다. Nginx 기본 버퍼링 경유에서는 앱이 먼저 파일을 정리했지만 프록시가 임시파일 최대 51.0MiB를 보관했다. 저장 공간 부담이 앱에서 프록시로 이동한 것이다. 전송 중 취소에는 파일이 정리됐으나, 생성 시작 0.5초 뒤 취소하면 서버는 클라이언트 종료 후에도 약 12.4초 더 작업하고 파일을 만들었다. 성공·취소 뒤 최종 잔여 파일은 없었지만, 취소된 생성의 CPU 낭비는 남았다. 이는 정상 완료 검사와 별개다.
같은 작업량을 비교하니 동시 2건이 기본 후보가 됐다
앞 실험은 동시 수를 올릴수록 총 작업량도 늘었다. 고정 자원에서의 효율을 보려면 항상 ZIP 16개, PDF 3,200개를 처리해야 했다. 클라이언트에 16건을 등록하고 활성 HTTP 요청만 1·2·4·8·16개로 제한했다. 대기시간을 포함해 마지막 ZIP까지 받은 시간을 비교했다. 2CPU·2GiB·G1의 같은 JAR에서 두 라운드를 역순으로 실행한 결과, 동시 16건은 47.10/36.02초가 걸렸다. 동시 2건은 15.94/23.02초, 4건은 25.22/16.77초로 두 조건의 범위가 겹쳤다. 예열 뒤 같은 JVM에서 2·4건을 추가 교차 시험해도 총 완료 시간은 2건 19.52/14.95초, 4건 19.16/17.08초로 겹쳤다.
따라서 2건이 항상 더 빠르다는 결론 대신, 비슷한 처리 속도에서 2건의 임시 ZIP 최대가 13.013.1MiB로 4건의 25.926.1MiB보다 작고 GC 정지도 0.540.84초 대 1.451.47초였다는 근거로 동시 생성 2건을 기본 후보로 잡았다. 이 대기열은 클라이언트에만 있었다. 서버의 동시 생성 제한, 대기 상한, 거절 응답은 아직 구현하지 않았으므로 서버에 옮긴 뒤 같은 처리 시간이 나온다고 보장할 수 없다. 생성 슬롯과 느린 전송이 유지하는 임시파일의 수명도 따로 제한해야 한다.
직접 스트리밍: 얻은 응답성과 취소 대응, 잃은 실패 의미
마지막으로 같은 JAR·2CPU·2GiB·G1에서 설정만 바꿔 prepared(완성 ZIP 전송)와 direct(PDF 하나를 ZIP entry로 보낸 뒤 flush)를 비교했다. 요청당 200건, 동시 1·8건을 두 라운드로 측정했다. 동시 8건 첫 바이트는 prepared 11.7114.46초에서 direct 0.0940.205초로 빨라졌다. 전체 수신은 각각 12.2214.96초와 11.5414.29초로 범위가 겹친다. 앱 임시 ZIP 최대는 47.549.2MiB에서 0이 됐다. 반면 관측 힙 최대는 624.6743.9MiB와 644.9734.9MiB, 앱 CPU 합계는 24.0229.76 CPU초와 23.68~29.27 CPU초로 범위가 겹친다. 직접 전송이 정상 요청의 전체 JVM 힙이나 PDF 생성 CPU를 대폭 줄였다는 결과는 없다.
느린 8개 클라이언트에서는 앱 직접 전체 수신이 prepared 64.05초, direct 52.46초였다. PDF 생성과 느린 수신이 겹친 효과로 해석했다. 앱 직접 힙 최대는 770.9MiB와 772.8MiB로 거의 같았다. Nginx 경유 direct도 앱 임시 ZIP은 0이었지만 프록시 임시파일 최대 49.0MiB는 남았다. 0.5초 뒤 연결을 끊은 8개 요청에서는 prepared가 클라이언트 종료 후 약 11.72초 더 작업한 반면 direct는 다음 출력에서 종료를 감지해 후속 PDF 생성을 멈췄다. 단, 진행 중인 PDF 렌더링을 즉시 인터럽트하지는 않는다.
결정적인 검증은 150번째 PDF 생성 실패였다. 정렬 순서상 150번째인 한 문서의 JSONB만 격리 DB에서 깨뜨려 실제 생성 예외를 일으켰다. prepared는 HTTP 500·161-byte 오류 JSON을 반환했다. direct는 앱 직접 연결에서 HTTP 200, application/zip, 약 4.86MiB의 본문을 보냈고 chunked HTTP 전송도 네트워크 관점에서는 오류 없이 끝났다. 앞의 149개 PDF는 local entry와 CRC를 읽어 복구할 수 있었지만, ZIP 중앙 디렉터리가 없어 Java ZipFile과 unzip -t가 정상 ZIP으로 인정하지 않았다. Nginx 경유도 같은 500 대 200 결과였다. 일부 PDF를 복구할 수 있다는 사실은 사용자에게 완성 ZIP이 도착했다는 뜻이 아니다.
현재 웹은 Blob 전체를 받은 뒤 다운로드를 시작하며 ZIP 중앙 디렉터리나 entry 수를 검증하지 않는다. 모바일도 파일 수신 뒤 HTTP 2xx를 확인해 연다. 따라서 서버 첫 바이트 개선이 곧바로 웹 저장 UI의 조기 시작을 뜻하지 않으며, 위와 같은 HTTP 200·미완성 ZIP을 성공 경로에서 다룰 위험이 있다. 이 프론트 동작은 현재 코드와 실제 HTTP 결과를 결합한 판단이며 브라우저 UI에서 해당 실패를 직접 재현한 것은 아니다.
지금의 판단
폰트 재사용은 PDF 반복 비용을 줄였고, 짧은 트랜잭션은 파일 생성 중 이어지던 DB 연결 점유를 없앴다. 순차 생성·임시 ZIP·완성 파일 전송은 PDF 원본과 최종 ZIP 배열의 보유 방식을 바꾸면서 생성 실패를 응답 전에 알리는 계약을 지켰다. 직접 전송은 첫 바이트·취소 대응·앱 임시파일에서 분명한 이점이 있지만, 150번째 실패에서 드러난 HTTP 200 미완성 ZIP의 의미가 크다. 기본 모드는 prepared로 유지한다. direct는 클라이언트의 ZIP 완결성 검증과 실패 정책을 마련한 뒤에야 기본 전환을 검토할 수 있다. 서버 동시 생성 제한도 아직 구현하지 않았으며, 2건은 구현·재검증을 위한 로컬 출발값이다.
모든 결론은 동일 양식의 합성 문서와 짧은 로컬 반복에 한정된다. 초기 단계의 1,200MiB·Serial GC 수치를 후반의 2GiB·G1 수치와 하나의 개선율로 이어 붙이지 않았다. 관측 힙은 프로세스 전체이며 순간 최대를 놓칠 수 있고, 전송 단계 할당량은 전체 요청 메모리가 아니다. 운영 배포, 실제 이미지가 큰 문서, 장기 부하, 디스크 부족, 강제 종료, 운영 SLO는 확인하지 않았다.
원문과 재현 자료: research/experiments/document-zip-baseline-2026-10-11/README.md 및 같은 디렉터리의 font-cache-results.md, transaction-results.md, memory-results.md, streaming-results.md, concurrency/README.md, concurrency/efficiency.md, direct-stream/README.md. 각 문서에 결과 JSON·측정 코드·검증 범위를 연결했다.
구현·검증 상태
폰트 재사용(78aefe34), 트랜잭션 분리(e37b8f2b), 임시 ZIP(820bd583), 완성 ZIP 전송(20e3122b)은 단계별 로컬 커밋으로 보존했다. 이후 직접 스트리밍 비교 구현은 현재 작업 트리에 있고 아직 커밋하지 않았다. push와 운영 배포는 수행하지 않았다.
직접 전송 비교는 28개 구간·140개 요청(정상 ZIP 72개, 의도적 취소 64건, 의도적 생성 실패 4건)이다. 예상하지 못한 오류·부하 중 OOM·마지막 앱/프록시 잔여 임시파일은 0이었다. 관련 테스트 59개와 bootJar 빌드가 통과했다. 오류 fixture는 원복 후 원본 체크섬과 두 모드의 정상 200건 다운로드로 확인했다. 정상 ZIP의 모든 entry CRC·이름·PDF 헤더와 대표 PDF를 검사했으며, 모든 정상 PDF의 화면을 렌더링해 비교한 시험은 아니다.
후속 판단 기준
- 클라이언트에서 ZIP 끝 구조와 중앙 디렉터리를 검사하면 이번처럼 생성 중단으로 끝이 없는 파일은 실패로 처리할 수 있다. 다만 유효한 ZIP에서 문서 일부가 빠진 경우까지 잡으려면 기대 문서 수와 실제 entry 수도 비교해야 한다. 이 검증은 아직 구현하지 않았다.
- 검증을 추가해도 이미 받은 바이트는 회수하지 못하고, HTTP 성공 상태를 되돌리지는 못한다. 다운로드 실패를 사용자에게 정확히 알리는 기능과 서버 자원 효율을 함께 평가해야 한다.
- 서버 동시 생성 제한을 구현할 때는 대기 중 DB 연결을 잡지 않는지, 느린 전송이 생성 슬롯을 묶는지, 대기까지 포함한 전체 완료 시간이 개선되는지 다시 측정한다.