
지난편에 이어서 이번에는 jest 성능 개선을 이루어 내는 과정을 담았습니다.
예고편에 이어서...

의심 되는 포인트 들
1. setupAfterEnvFile를 통한 setup 코드 구동
2. project 설정 나눔
3. 정리되지않는 글로벌 함수들 누적
4. 원래부터 임계점만큼 썼는데 추가 suite로 인한 한계도달
이정도로 정리하여 접근했습니다.
setup코드를 주석처리 해봐도, node 혹은 jsdom 환경만 각각 실행시켜 보아도, 테스트코드를 지워 보아도 jest는 여전히 1.5GB 이상의 힙메모리를 사용하였습니다.
그래서 결국 타입스크립트 트랜스파일 과정에서 문제가 있다고 직감이 왔고 이부분을 babel로 타입체크 없는 트랜스파일만 하도록 수정하여 돌려봤습니다.
// bable.config.cjs
]const browserslist = require('browserslist');
module.exports = (api) => {
const targets = api.env('test') ? { node: 'current' } : browserslist(null, { mobileToDesktop: true });
const presets = [
[
'@babel/preset-env',
{
targets,
useBuiltIns: 'usage',
corejs: '3.23.2',
},
],
'@babel/preset-typescript',
];
const plugins = [
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
];
return {
presets,
plugins,
};
};
그랬더니!
놀랍게도 메모리 사용량이 대폭 개선되었습니다.


ts-jest는 타입체크와 트랜스파일링을 모두 진행하는 엔진의 역할
ts-jest:
테스트 돌릴 때마다 거의 TS 서버 하나 띄우는 셈이라서, 1.5~2.5GB까지도 쓴다고 합니다.
관련 자료를 더 찾아봐야하지만, 대부분 ci 과정에서 tsc를 통한 타입체크는 독립된 스테이지로 구분하고 jest에 포함시키지 않는 것이 보편적으로 보입니다. (나 역시도 이전 회사에서 그렇게 구성했던거로 기억한다. 이유는 지금까지 몰랐지만...)
결론 : 타입체크와 테스트코드는 분리하자!
nodejs 의 기본 힙메모리 사이즈는 버전별로 상이하지만 1.5GB ~ 1.7GB 내외로 설정되어 있습니다.
이부분을 노드 실행시 옵션을 통해서 늘려줄 수 있다.
--max-old-space-size=<메가바이트>
node --max-old-space-size=4096 your-script.js
하지만, 이는 임시 방편에 불과하며 github action의 최대 메모리 사용량은 7GB이기 때문에 성능 최적화를 통해서 해결해야 합니다.(위에 처럼,,,)
만일 4GB로 설정하고 2개이상의 프로세스를 돌린다면 out of memory 될 것이 뻔하겠죠.
참고 : Standard Github-hosted runner spec
문제상황 : 은 크게없지만,,,
최적화, 성능개선 더 해보고싶다!
한번에 두개의 환경을 모두 테스트하던 상황을 분리하고자 했습니다.
// package.json
"test": "jest",
// package.json
"test": "jest",
"test:node": "jest --selectProjects node",
"test:jsdom": "jest --selectProjects jsdom",
jest 메모리 사용량도 개선 했겠다,
분리된 스크립트를 병렬 프로세스로 돌리면 아주 좋겠다 싶은데요!
// package.json
"test": "npm run test:node & npm run test:jsdom",
"test:node": "jest --selectProjects node",
"test:jsdom": "jest --selectProjects jsdom",
바로 macOS 와 linux 에서는 & 명령어로 병렬로 스크립트를 돌릴수 있다는 것입니다.( 참고 : &&은 순차실행 )
하지만! 이경우에 큰 문제가 있습니다.
바로 테스트 종료 상태에 대한 리턴을 받는 것이 매우 까다로워 진다는 것입니다.
또, 제대로 처리되지 않았을경우(ex: node 환경 성공 / jsdom 환경 실패) 성공으로 간주되고 ci를 통과할 수도 있다는 약점이 있습니다.
jest 내부적으로 병렬 처리를 더 깔끔하게 할 수 있는 방법이 있습니다.
바로, maxWorkers 를 사용하는 것인데, 이 옵션이 무엇인지 알아보겠습니다.
--maxWorkers=N (또는 --maxWorkers=50%)
여기서 여러가지 옵션으로 테스트를 진행해 보았을때 가장 안정적이면서 좋은 성능을 내는 것은
maxWorkers : '50%'
라고 판단했습니다.
왜냐,
결과
캐싱전

캐싱 후

이전의 힙 메모리 개선 시점엔 3.8초 였던 실행시간이 2.8초로 25% 이상의 속도 개선을 보였습니다.
| 직렬 | 병렬 | |
|---|---|---|
| 캐싱전 | 3.8s | 2.8s |
| 캐싱후 | 2.9s | 1.9s |
물론, 테스트 케이스가 적어 많은 이점을 누리진 않겠지만 테스트 수가 많아지거나 개발환경보다 부족한 스펙의 CI(Continuous Integration) 환경에서 더 좋은 효과를 낼 것으로 보입니다.
위 개선 결과를 잘 보면 단순히 속도만 줄은 것이 아니고, 메모리 사용량도 줄었습니다.
어떻게 이런일이 생길까요?
기존의 단일 프로세스 환경에서 10개의 서로다른 test suites 를 돌릴때, 각각의 suite 이 필요로하는 모듈/셋업/전역 객체 등이 다른데 이 모든 것이 하나의 프로세스에 적재되는 구조입니다.
하지만, N개의 프로세스가 분산하여 각각의 suite 를 담당하여 각자 필요한 모듈만 import 하기 때문에 점유 메모리가 각 프로세스 별로 감소하여 최적한 상태로 테스트를 돌리게 됩니다!
하지만, 전체 메모리 점유 계산식은 단일 프로세스와는 다르다는 점.
각각의 test 메모리 사용량은 100mb-> 50 mb 로 완벽한 개선처럼 보일지라도 동시에 동작하는 프로세스라는 점을 감안하면 실제 메모리 사용량은 50mb * N 이 됩니다.
Rust 로 개발되어 통상적으로 (babel 대비) 10배 이상의 트랜스파일 속도를 자랑하는 swc를 사용해 성능 개선 효과를 내려고 합니다.
바벨과의 차이점은 아래와 같습니다(비교를 위해 tsc도 첨부)
| 항목 | TypeScript (TSC) | Babel | SWC |
|---|---|---|---|
| 주 역할 | 타입 검사 + 트랜스파일 | 트랜스파일 | 트랜스파일 |
| 타입 검사 지원 | ✅ 지원 (타입 체킹) | ❌ 불가능 | ❌ 불가능 |
| 트랜스파일 속도 | 느림 | 보통 | 매우 빠름 (Rust 기반) |
| 트랜스파일러 내장 여부 | 자체 트랜스파일러 (tsc) | ❌ 별도 설치 필요 | ✅ 트랜스파일러 내장 (Rust) |
| 데코레이터 지원 | 최신 스펙 지원 | 플러그인 필요 (@babel/plugin-proposal-decorators) | 옵션으로 지원 (legacyDecorator: true) |
| 클래스 필드 지원 | 최신 스펙 지원 | 플러그인 필요 (@babel/plugin-proposal-class-properties) | 기본 지원 |
결국, 기존에 babel 설정을 이것저것 하고 플러그인 패키지를 설치했던것과 달리, 순정 @swc/core 에 legacyDecorator 설정만 해주면 된다는 것입니다!
먼저, jsc 명세를 .swcrc 파일에 작성했습니다.
{
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
}
}
}
jest config 파일에도 적용을 해 주어야 합니다.
const fs = require('fs');
const config = JSON.parse(fs.readFileSync(`${__dirname}/.swcrc`, 'utf-8'));
const baseTransform = {
'^.+\\.(t|j)sx?$': [
'@swc/jest',
{
...config,
},
],
};
기존에 babel을 transform 속성에서 지워주고 swc로 대체했습니다.
는 굉장했다!
캐싱 전

캐싱 후

캐싱 전의 경우 기존대비 30% 정도의 개선 효과를 보입니다.
캐싱 후에도 25%의 개선 효과를 보였습니다.
| babel | swc | |
|---|---|---|
| 캐싱전 | 2.8s | 1.9s |
| 캐싱후 | 1.9s | 1.4s |
워커를 하나만 설정해 싱글 프로세스로 돌리면 얼마나 차이가 날까요?
캐싱 전

캐싱 후

기대와 다르게 크게 성능이 개선되지 않아 보입니다만..?
| babel | swc | |
|---|---|---|
| 캐싱전 | 3.8s | 3.5s |
| 캐싱후 | 2.9s | 2.3s |
| babel(1) | swc(1) | babel(50%) | swc(50%) | |
|---|---|---|---|---|
| 캐싱전 | 3.8s | 3.5s | 2.8s | 1.9s |
| 캐싱후 | 2.9s | 2.3s | 1.9s | 1.4s |
최초 babel 1 worker 상황부터 개선해 왔으므로, 거의 50% 의 성능 개선 수치를 도출해 낼 수 있었습니다.
현재 개발중인 Suite 혹은 test 에 대해서만 jest를 동작시키는 경우가 대다수 이기 떄문에, 로컬 환경에서는 테스트 속도 개선이 큰 의미가 없을 수 있습니다.
하지만, CI 레벨에서 최적화 되지 않은 테스트 코드를 모두 돌리는 것은 매우 큰 부담이며, 이는 곧 배포 속도 증가와 개발 경험 악화로 이어지게 됩니다.
성능 개선에 직접적으로 신경을 쓰면서 테스트 환경을 구축한 경험은 처음인데, 코드 외적인 성능 최적화를 진행하면서 jest라는 도구가 어떻게 테스트 최적화를 제공하는지에 대해서 학습하고 실제로 개선시켜볼 수 있었습니다.
또한, 업무 환경에 새로운 형태의 테스트 기법을 사용할 수 있게 적용해 내었고, 앞으로 더 유동적인 케이스에 대해 안정적인 테스트를 작성할 수 있게 되었습니다.