testing-library 적용 및 Jest 성능 개선기 (2)

최준만·2025년 4월 28일
post-thumbnail

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

이슈 : 힙 메모리 폭발!

  • 대체 무엇이,,, 힙메모리를 2GB나 쓰는 것인가?
  • github action의 메모리 스펙과 힙메모리의 연관성은 어떻게 되는 것일까?

github action ci workflow에서의 힙 메모리 overflow

의심 되는 포인트 들
1. setupAfterEnvFile를 통한 setup 코드 구동
2. project 설정 나눔
3. 정리되지않는 글로벌 함수들 누적
4. 원래부터 임계점만큼 썼는데 추가 suite로 인한 한계도달

이정도로 정리하여 접근했습니다.

원인 : ts-jest...

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,  
  };  
};

그랬더니!
놀랍게도 메모리 사용량이 대폭 개선되었습니다.

  • 캐싱전
    babel-ts
  • 캐싱까지 되면
    babel-ts2

해결 : 타입체크는 테스트코드가 하는 것이 아니다.

ts-jest는 타입체크와 트랜스파일링을 모두 진행하는 엔진의 역할
ts-jest:

  • 내부적으로 TypeScript의 컴파일러 (typescript)를 직접 호출
  • 즉, type checking + transpile을 동시에 수행함
  • tsc는 원래 무겁고, 타입 정보까지 다 읽고 해석해서 메모리를 많이 씀

테스트 돌릴 때마다 거의 TS 서버 하나 띄우는 셈이라서, 1.5~2.5GB까지도 쓴다고 합니다.

관련 자료를 더 찾아봐야하지만, 대부분 ci 과정에서 tsc를 통한 타입체크는 독립된 스테이지로 구분하고 jest에 포함시키지 않는 것이 보편적으로 보입니다. (나 역시도 이전 회사에서 그렇게 구성했던거로 기억한다. 이유는 지금까지 몰랐지만...)

결론 : 타입체크와 테스트코드는 분리하자!

참고 : node js 의 힙메모리 사이즈와 관리

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

Jest 성능개선 (얘는 나중에 내용 분리,,)

이슈

문제상황 : 은 크게없지만,,,
최적화, 성능개선 더 해보고싶다!

병렬 처리를 통한 성능 개선

package.json script 분리

한번에 두개의 환경을 모두 테스트하던 상황을 분리하고자 했습니다.

  • 기존
// 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를 통과할 수도 있다는 약점이 있습니다.

maxWorkers 지정

jest 내부적으로 병렬 처리를 더 깔끔하게 할 수 있는 방법이 있습니다.
바로, maxWorkers 를 사용하는 것인데, 이 옵션이 무엇인지 알아보겠습니다.

 --maxWorkers=N (또는 --maxWorkers=50%)

  • 테스트를 N개의 worker(=child process) 로 병렬 실행
  • 기본값: CPU 코어 수의 50% (정수 내림)
  • --maxWorkers=1은 사실상 --runInBand와 동일 (거의 완전 동일한 동작)
    • 참고) --runInBand란 ? Jest의 테스트 병렬 실행을 끄고, 한 번에 하나씩 테스트를 실행

여기서 여러가지 옵션으로 테스트를 진행해 보았을때 가장 안정적이면서 좋은 성능을 내는 것은

maxWorkers : '50%'

라고 판단했습니다.

왜냐,

  • 컴퓨팅 자원의 50%를 쓰기때문에 코어수가 많을수록 빠름
    • 즉, 로컬 개발 pc는 성능 좋으니 아주 빨리 테스트 할 수 있다는 장점!
  • github action runner 스펙상 4개의 코어를 할당받으므로 2개정도로 돌리면 병렬 프로세스의 이점을 누릴 수 있으면서 시스템에 부하가 가지 않음
    즉, 안정성과 속도를 동시에 챙길 수 있는 적정 수치라고 판단 했습니다. (75%도 좋을지도?)

결과

  • 캐싱전
    max-worker1

  • 캐싱 후
    max-worker2

이전의 힙 메모리 개선 시점엔 3.8초 였던 실행시간이 2.8초로 25% 이상의 속도 개선을 보였습니다.

직렬병렬
캐싱전3.8s2.8s
캐싱후2.9s1.9s

물론, 테스트 케이스가 적어 많은 이점을 누리진 않겠지만 테스트 수가 많아지거나 개발환경보다 부족한 스펙의 CI(Continuous Integration) 환경에서 더 좋은 효과를 낼 것으로 보입니다.

병렬로 실행할 뿐인데, 메모리 사용량이 줄었다?

위 개선 결과를 잘 보면 단순히 속도만 줄은 것이 아니고, 메모리 사용량도 줄었습니다.
어떻게 이런일이 생길까요?

기존의 단일 프로세스 환경에서 10개의 서로다른 test suites 를 돌릴때, 각각의 suite 이 필요로하는 모듈/셋업/전역 객체 등이 다른데 이 모든 것이 하나의 프로세스에 적재되는 구조입니다.
하지만, N개의 프로세스가 분산하여 각각의 suite 를 담당하여 각자 필요한 모듈만 import 하기 때문에 점유 메모리가 각 프로세스 별로 감소하여 최적한 상태로 테스트를 돌리게 됩니다!

하지만, 전체 메모리 점유 계산식은 단일 프로세스와는 다르다는 점.
각각의 test 메모리 사용량은 100mb-> 50 mb 로 완벽한 개선처럼 보일지라도 동시에 동작하는 프로세스라는 점을 감안하면 실제 메모리 사용량은 50mb * N 이 됩니다.

  • 참고 : 힙메모리 사용량은 각 프로세스 별로 독립적입니다.
    • 실메모리 사용량 합계가 설정된 한계 용량을 넘는다고 해도 힙 메모리가 터지거나 하진 않음( 단일 프로세스가 한계치를 넘지않는다는 가정 )

트랜스파일러 변경(babel -> swc)

@swc/core

Rust 로 개발되어 통상적으로 (babel 대비) 10배 이상의 트랜스파일 속도를 자랑하는 swc를 사용해 성능 개선 효과를 내려고 합니다.

바벨과의 차이점은 아래와 같습니다(비교를 위해 tsc도 첨부)

TypeScript 트랜스파일러, Babel, SWC 비교

항목TypeScript (TSC)BabelSWC
주 역할타입 검사 + 트랜스파일트랜스파일트랜스파일
타입 검사 지원✅ 지원 (타입 체킹)❌ 불가능❌ 불가능
트랜스파일 속도느림보통매우 빠름 (Rust 기반)
트랜스파일러 내장 여부자체 트랜스파일러 (tsc)❌ 별도 설치 필요✅ 트랜스파일러 내장 (Rust)
데코레이터 지원최신 스펙 지원플러그인 필요 (@babel/plugin-proposal-decorators)옵션으로 지원 (legacyDecorator: true)
클래스 필드 지원최신 스펙 지원플러그인 필요 (@babel/plugin-proposal-class-properties)기본 지원

결국, 기존에 babel 설정을 이것저것 하고 플러그인 패키지를 설치했던것과 달리, 순정 @swc/corelegacyDecorator 설정만 해주면 된다는 것입니다!

적용하기

먼저, 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로 대체했습니다.

효과

는 굉장했다!

  • 캐싱 전
    swc1

  • 캐싱 후
    swc2

캐싱 전의 경우 기존대비 30% 정도의 개선 효과를 보입니다.
캐싱 후에도 25%의 개선 효과를 보였습니다.

babelswc
캐싱전2.8s1.9s
캐싱후1.9s1.4s

추가실험

워커를 하나만 설정해 싱글 프로세스로 돌리면 얼마나 차이가 날까요?

  • 캐싱 전
    swc-worker1

  • 캐싱 후
    swc-worker2

기대와 다르게 크게 성능이 개선되지 않아 보입니다만..?

babelswc
캐싱전3.8s3.5s
캐싱후2.9s2.3s
  • 참고 : swc는 rust로 개발되었고, rust는 병렬 처리의 이점을 누리도록 설계된 언어입니다.
    그래서, 1core 만 할당한다면 성능이 n코어 보다 현저히 떨어지는 것이 옳은 결과라고 합니다.

최종 결과

babel(1)swc(1)babel(50%)swc(50%)
캐싱전3.8s3.5s2.8s1.9s
캐싱후2.9s2.3s1.9s1.4s

최초 babel 1 worker 상황부터 개선해 왔으므로, 거의 50%성능 개선 수치를 도출해 낼 수 있었습니다.

현재 개발중인 Suite 혹은 test 에 대해서만 jest를 동작시키는 경우가 대다수 이기 떄문에, 로컬 환경에서는 테스트 속도 개선이 큰 의미가 없을 수 있습니다.
하지만, CI 레벨에서 최적화 되지 않은 테스트 코드를 모두 돌리는 것은 매우 큰 부담이며, 이는 곧 배포 속도 증가와 개발 경험 악화로 이어지게 됩니다.

마치며

성능 개선에 직접적으로 신경을 쓰면서 테스트 환경을 구축한 경험은 처음인데, 코드 외적인 성능 최적화를 진행하면서 jest라는 도구가 어떻게 테스트 최적화를 제공하는지에 대해서 학습하고 실제로 개선시켜볼 수 있었습니다.
또한, 업무 환경에 새로운 형태의 테스트 기법을 사용할 수 있게 적용해 내었고, 앞으로 더 유동적인 케이스에 대해 안정적인 테스트를 작성할 수 있게 되었습니다.

참고 자료

profile
junman9595@gmail.com

0개의 댓글