
우리 회사는 HR 채용 인적성 검사 서비스를 만든다.
지원자는 검사를 응시하고, 고객사의 인사 담당자는 그 결과를 바탕으로 지원자를 이해하고 평가한다.
이 과정에서 고객사가 최종적으로 마주하는 결과물이 바로 레포트 📊 이다.
레포트📊는 응시자의 검사 결과를 해석하고 직무 적합도나 성향을 정리해 인사 담당자가 실제 채용에 활용할 수 있는 형태로 가공한 결과물이다. 그래서 우리 서비스에서 레포트 생성 기능은 핵심 업무 중 하나로, 매출에도 직접 연결되는 중요한 기능이다.
레포트 다운로드가 지연되거나 다운로드 중 어드민 사용이 불편하다면, 고객은 단순히 “파일 다운로드가 느리다”고만 느끼지 않는다.
검사가 끝났는데도 결과를 제때 확인하지 못하면 채용 일정에 영향을 줄 수 있고, 이런 불편이 반복되면 결국 서비스 신뢰도 하락이나 고객 이탈로 이어질 수 있다.
이번 글에서는 이 레포트 다운로드 기능이 왜 느려졌는지, 그리고 HTML 레포트를 생성하고 PDF로 변환하기 까지의 과정을 어떻게 개선했는지 정리해보려 한다.
먼저 검사 공고가 등록되고 응시자의 레포트가 어드민 사용자에게 제공되기까지의 전체 흐름은 다음과 같다. (여기서 어드민 사용자는 고객사의 인사 담당자, 사내 검사 개발팀, 검사 운영팀이 속한다.)
인(적)성 채용 공고 등록 (검사 일정, 내용이 새로 등록됨)
→ 검사 당일 응시자 검사 진행
→ 응시 종료 즉시 결과 레포트 HTML 생성
→ 검사 일정 종료 후 담당자가 어드민에서 기응시자 레포트 일괄 다운로드 시도
- HTML을 PDF로 변환
- PDF 파일들을 `.zip`으로 묶어 다운로드
응시가 끝나면 우선 HTML 레포트만 생성해두고, 담당자가 어드민에서 다운로드 버튼을 누르는 시점에 HTML을 PDF로 변환한 뒤 PDF 파일들을 .zip으로 묶어 내려주고 있었다.
PDF 변환에는 puppeteer를 사용하고 있었다.
브라우저를 띄워 HTML을 렌더링한 뒤 PDF로 출력하는 방식이라 변환 작업 자체가 꽤 무겁다.
특히 여러 응시자의 레포트를 한 번에 PDF로 변환하면 CPU와 메모리 사용량이 크게 올라간다.
그래서 응시 종료 시점에는 PDF까지 만들어두지 않고, 다운로드가 요청되는 시점으로 PDF 변환을 미뤄두어 서비스 부하를 줄이고자 하는 구조였다.
그런데 최근 담당자(다행히 검사 운영팀...)로부터 불편 요청이 왔다.
"공고 안에 응시자가 많지 않은데도 레포트 다운로드가 너무 오래 걸려요ㅜㅜ"
"레포트 다운로드하는 동안 다른 사람이 어드민을 사용할 때 화면 로딩이 느려져요..."
직접 확인해보니 예상보다 심각했다.
우선 24명의 레포트를 일괄 다운로드하는 데 8분이 걸렸고, 이 작업이 실행되는 동안 서버 리소스가 PDF변환에 오래 붙잡히면서 어드민의 다른 요청까지 영향을 받는 상황이었다.
1. 담당자는 레포트를 받기까지 오래 기다려야 한다.
2. 다운로드 작업이 실행되는 동안 어드민 전체 사용성이 떨어진다.
3. 응시자 수가 많지 않은 공고에서도 문제가 재현된다.(24명에 8분..?🤕)
레포트 일괄 다운로드 로직을 처음부터 따라가 봤다.
문제는 크게 두가지였다.
기존 다운로드 로직에는 Promise.all이 두 번 중첩되어 있었다.
바깥쪽 Promise.all은 직군 목록을 순회한다. 특정 고객사의 경우 응시자의 지원 직군 하나만 가지고 레포트를 만드는 게 아니라, 고객사의 모든 직군에 대해 레포트를 각각 생성해야 한다. 이때 최대 5개 직군을 대상으로 레포트가 생성될 수 있었다.
안쪽 Promise.all은 그 직군별 작업 안에서 다시 응시자 목록을 순회한다.
// 모든 직군 목록 리스트 ( 1 ~ 5 )
const bizCodeList = [0,1,2,3,4]
await Promise.all(
bizCodeList.map(async (bizCode) => {
// 직군별 템플릿 결정, 응시자 목록 조회 ...
await Promise.all(
testerInfoList.map(async (testerInfo) => {
// 응시자별 점수 데이터 조회, 치환변수 가공, HTML 생성, HTML → PDF 변환 ...
}),
);
}),
);
이렇게 비동기로 펼쳐놓으면 얼핏 봐선 전체 다운로드 시간이 짧아질 것처럼 보인다. 직군 5개도, 응시자 N명도 모두 "동시에" 처리하니까. 처음 작성한 사람의 의도도 분명 그랬을 것이다.
하지만 응시자 한 명을 처리하는 동안 일어나는 일을 풀어보면 다음과 같다.
1. 응시자의 검사 결과 조회(DB)
2. 응답 신뢰성 데이터 조회(DB)
3. 인성검사 관련 척도, 강약점, 면접 질문 데이터 조회(DB)
4. 채점값을 레포트 치환변수에 맞게 가공
5. HTML 파일 생성(파일 쓰기)
6. puppeteer로 HTML 렌더링
7. PDF 파일 생성
DB 쿼리와 파일 쓰기까지는 그렇다 쳐도 진짜 부담은 6, 7번이다.
puppeteer는 Firefox 프로세스를 새로 fork해 브라우저 엔진을 부팅하고 Dom을 렌더링한 뒤 PDF를 떠서 종료하는 무거운 작업이다. 즉, 응시자 한 명당 브라우저 프로세스가 하나 뜨고 사라진다는 뜻이다.
이 무거운 작업을 안쪽 Promise.all이 응시자 N명에 대해 한꺼번에 펼치고, 바깥 Promise.all이 그걸 다시 직군 5개로 한 번 더 곱한다.
결국 담당자가 다운로드 버튼을 누르는 순간, 이론상 최대 5 × N개의 Firefox 프로세스가 동시에 뜨려고 경쟁한다.
동시 실행 수가 수십 개를 넘어 백 개에 가까워지는 순간, CPU와 메모리 사용량이 가파르게 치솟는 건 어쩌면 자연스러운 현상일지도..
기존 로직에서는 응시 종료 후 HTML 레포트가 이미 생성되어 있었음에도, 다운로드 요청이 들어올 때마다 전체 과정(응시자 목록 조회 → 점수 데이터 조회 → HTML 재생성(덮어쓰기) → puppeteer로 PDF 새로 변환)을 처음부터 다시 수행하고 있었다.
if (!fs.existsSync(reportResultFolder)) {
fs.mkdirSync(reportResultFolder, { recursive: true });
}
const testerInfoList = await this.modifyRepository.getReportTesterInfoList(...);
// 직군 순회
// 응시자 순회
// 점수 데이터 조회
// HTML 재생성
// PDF 재생성
// zip 생성
폴더가 이미 존재하는지는 확인하고 있었지만, 단지 “폴더가 없으면 만든다” 정도의 처리였다.
결국 사용자는 동일한 레포트를 다시 받으려고 해도 기존 생성된 파일이 바로 내려오지 않아 긴 대기 시간을 겪어야 했다.
먼저 바깥쪽 직군 루프의 Promise.all을 제거하고, 순차 처리 방식(for const of)으로 변경했다.
[수정 전]
const bizCodeList = [0,1,2,3,4]
await Promise.all(
bizCodeList.map(async (bizCode: BizCodeEnum) => { ... }),
);
[수정 후]
const bizCodeList = [0,1,2,3,4]
for (const bizCode of bizCodeList) {
...
}
직렬로 바꾸면 전체 시간이 늘어날 거라고 생각했지만, 직군 5개가 동시에 puppeteer 자원을 두고 경쟁해서 리소스를 많이 잡아먹는 걸 감안하면 직렬 처리가 오히려 더 났다고 판단했다.
안쪽 응시자 루프는 비동기 처리를 유지하되, 리소스 절약 측면에서 배치 처리 방식으로 수정했다.
한 번에 모든 응시자를 처리하지 않고, 일정 개수(40개)씩 끊어서 처리하도록 했다.
[수정 전]
await Promise.all(
// 응시자 목록을 순회하며 레포트를 생성한다
testerInfoList.map(async (testerInfo: TesterReportInfoDto) => { ... }),
);
[수정 후]
const batchSize = 40;
for (let i = 0; i < testerInfoList.length; i += batchSize) {
const batch = testerInfoList.slice(i, i + batchSize);
await Promise.all(
batch.map(async (testerInfo: TesterReportInfoDto) => { ... }),
);
}
이제 응시자 목록은 40명씩 나뉘어 처리된다.
한 배치 안에서는 여전히 Promise.all로 병렬 처리하지만, 해당 배치가 모두 끝나기 전까지 다음 배치는 시작되지 않는다.
덕분에 동시에 실행되는 puppeteer 작업 수를 최대 40개로 제한할 수 있었다.
batchSize는 실측을 기준으로 정했다.
너무 작게 잡으면 전체 처리 시간이 길어지고 너무 크게 잡으면 다시 CPU와 메모리 사용량이 급격히 올라간다. 여러 번 테스트한 결과, 현재 서버 자원에서는 40개 단위가 처리 속도와 안정성 사이에서 가장 적절했다.
기존에는 검사 직후의 부하를 줄이기 위해 PDF 생성을 다운로드 시점으로 미뤄두었지만 이 구조는 사용자 대기 시간을 길게 만드는 원인이 되었다. 그래서 PDF 변환 시점을 다운로드 요청 이전으로 옮기는 방향으로 개선하고자 했다.
다음과 같이 방향을 잡았다.
사용자가 기다리는 시간에 PDF를 만들지 말고, 사람이 거의 사용하지 않는 시간에 미리 만들어두자.
@Cron(CronExpression.EVERY_DAY_AT_2AM)
async handleConvertHtmlToPdfReport(): Promise<void> {
const lock = await this.redisService.setLock('CONVERT_PDF_REPORT');
if (!lock) return;
try {
await Promise.allSettled(
groupInfoList.map(async (groupIdx) => {
const testerList =
await this.reportSchedulerRepository.getExamDoneTesterInfoList(groupIdx);
if (testerList.length > 0) {
await this.convertHtmlToPdf(groupIdx, testerList);
}
}),
);
...
} finally {
await this.redisService.unLock('CONVERT_PDF_REPORT');
}
}
새벽 2시에 실행되는 스케줄러는 전날 종료된 공고를 조회한 뒤, 해당 공고의 HTML 레포트를 PDF로 미리 변환한다.
이제는 검사 종료 후 새벽 시간대에 PDF가 자동 생성이 되어서 담당자가 다운로드 버튼을 누를 때는 이미 준비된 파일을 바로 내려줄 수 있게 되었다.
스케줄러로 PDF를 미리 만들어두었으니, 이제 다운로드 시 현재 상황에 따라 어떻게 효율적으로 PDF를 내려받는지 로직을 재설계할 차례이다.
다운로드 요청이 들어왔을 때 바로 생성 로직으로 들어가지 않고, 현재 디스크에 결과물이 어디까지 만들어져 있는지 먼저 확인하도록 분기 로직을 정리했다.
1. PDF까지 모두 생성된 상태
→ 바로 .zip 다운로드
2. PDF가 일부만 생성된 상태
→ 누락된 파일만 PDF로 변환
3. HTML만 생성된 상태
→ HTML 생성은 건너뛰고 PDF 변환만 수행
4. HTML과 PDF가 모두 없는 상태
→ 응시자 조회부터 시작해 전체 과정 수행
async createPersonalReportAll(...) {
// 1. PDF와 HTML이 모두 있고 개수가 일치하면 바로 반환
if (isPdfComplete(reportResultFolder)) {
return { message: 'PDF 생성이 완료되었습니다.', ... };
}
// 2. PDF가 일부만 있다면 누락된 HTML만 PDF로 변환
if (hasPartialPdf(reportResultFolder)) {
await convertMissingHtmlToPdf(...);
return { ... };
}
// 3. HTML만 있다면 HTML 생성은 건너뛰고 PDF 변환만 수행
if (hasHtmlFiles(reportResultFolder)) {
await convertHtmlFilesToPdf(...);
return { ... };
}
// 4. HTML도 PDF도 없을 때만 전체 생성 로직 수행
// 응시자 조회
const testerInfoList =
await this.modifyRepository.getReportTesterInfoList(...);
// ... 이후, HTML 생성 → PDF 변환
}
정리하면,
특히 야간 스케줄러가 정상적으로 PDF와 ZIP을 만들어둔 경우에는
사용자가 다운로드 버튼을 눌렀을 때 무거운 변환 작업 없이 바로 결과물을 받을 수 있다.
개선 후 가장 크게 달라진 점은 이미 생성된 PDF 레포트를 다시 만들지 않고 그대로 재사용할 수 있게 된 것이었다.
특히 전날 종료된 공고는 새벽 스케줄러가 미리 PDF를 만들어 두기 때문에
담당자가 당일에 다운로드 버튼을 누르면 1초 이내에 파일을 받아볼 수 있게 되었다.
만약 재스코어링을 해서 HTML부터 새로 생성해야하는 경우에도, 다운로드 처리 시간이 기존보다 현저히 줄었다.
| 응시자 수 | 소요 시간 | |
|---|---|---|
| 이전 | 24명 | 약 8분 |
| 개선 후 (재스코어링 포함) | 40명 | 1분 30초 |
| 개선 후 (재스코어링 포함) | 184명 | 5분 30초 |

또 하나의 변화는 어드민 사용성이었다.
기존에는 레포트 다운로드가 진행되는 동안 puppeteer 작업이 서버 리소스를 크게 잡아먹으면서 다른 어드민 기능까지 느려지는 문제가 있었다.
개선 후에는 PDF 변환 작업의 동시 실행 수를 제한하고, 이미 만들어진 결과물을 재사용하면서 다운로드 중에도 어드민의 다른 기능이 이전처럼 먹통이 되는 현상이 사라졌다.
[ 이번 개선을 통해 배운 점은, ]
이번 개선을 하면서 가장 크게 느낀 점은
문제를 해결하기 위해 꼭 처음부터 복잡한 기술을 도입할 필요는 없다는 것이었다.
처음에는 레포트 다운로드 작업 자체가 무겁기 때문에 별도 Task Queue나 Kafka 같은 메시지 기반 구조가 필요하지 않을까 생각했지만, 먼저 기존 로직 안에서 해결할 수 있는 부분부터 정리해보기로 했다.
- 한 번에 너무 많이 실행되던 PDF 변환 작업의 동시성 제한
- 이미 생성된 HTML, PDF 결과물 재사용
- 사용자가 기다리지 않도록 PDF 변환 시점을 새벽 시간대로 이동
새로운 기술을 도입하지 않고도 기존 코드의 흐름을 다시 정리하는 것만으로 다운로드 시간과 어드민 사용성을 꽤 많이 개선할 수 있었다.
[ 아직 남아 있는 과제]
재스코어링을 진행하는 경우에는 여전히 HTML과 PDF를 다시 생성해야하는데, 개선 후 184명 기준 약 5분 30초까지 줄어들긴 했지만 사용자 입장에서는 이 시간도 충분히 길게 느껴질 수 있다.
[ 다음 단계 ]
향후에는 해당 기능을 더 고도화하기 위해 별도 Task Queue나 Kafka 같은 구조를 도입하는 것도 고려해볼 예정이다.
다운로드 요청은 빠르게 받고, 실제 PDF 생성 작업은 백그라운드에서 처리한 뒤 완료 상태를 알려주는 방식으로 바꾼다면 더 나은 사용자 경험을 만들 수 있을 것이다.