(번역) 내가 작성한 Jest 테스트는 왜 이렇게 느릴까?

sehyun hwang·2023년 2월 1일
49

FE 번역글

목록 보기
16/30
post-thumbnail

Jest 성능을 저해하는 간과하기 쉬운 실수

원문 : https://blog.bitsrc.io/why-is-my-jest-suite-so-slow-2a4859bb9ac0

우리 팀은 새로운 애플리케이션을 몇 개월 동안 개발하면서 240개의 유닛 테스트를 작성했습니다. 이 테스트를 실행하는 데는 46초가 걸렸습니다. 현재로서는 그렇게 긴 시간이 아니지만, 테스트 개수가 늘어나면서 시간도 점점 증가하고 있습니다. 이대로라면 몇 달 후에는 테스트 실행에 몇 분이 걸릴 것입니다.

Jest는 빠른 성능으로 잘 알려져 있기 때문에 이런 결과는 몹시 놀라웠습니다. Jest는 각 테스트 실행에 40ms밖에 소요되지 않는다고 보고했지만, 실제로는 각 테스트 실행에 약 6초가 소요됐습니다.

심지어 레거시 애플리케이션에 작성된 통합 테스트는 한 번 실행하는 데는 약 35초가 걸렸습니다. 긴 테스트 시간은 점점 신경쓰이기 시작했고 테스트에 집중할 수 없었습니다. 각각의 테스트가 1초 정도밖에 걸리지 않는데, 나머지 시간은 어디에 쓰이고 있는 걸까요?

지난 몇 주 동안 저는 테스트가 왜 이렇게 느릴까에 대해 고민했습니다. 여러 추측되는 이유가 있었지만, 불행히도 그 중 큰 개선이 되는 이유는 거의 없었습니다. 게다가 테스트가 얼마나 빨라야 하는가에 대한 합의점이 있지도 않았습니다.

이번 조사의 결과로 우리는 유닛 테스트 시간을 46초에서 13초로 단축했습니다. 또한, 통합 테스트도 35초에서 15초로 시간이 단축했습니다. 심지어 우리의 파이프라인은 더 큰 향상을 보였는데 관련해서는 별도의 글에서 자세히 다룹니다.

이 글에서는 가장 큰 변화를 만든 개선 사항과 Jest의 성능을 저해하는 몇 가지 잘못된 구성과 사용법에 대해 공유하려고 합니다.

아래의 예제는 매우 간단하고 빠르게 실행되어야 할 것처럼 보이지만, 테스트를 상당히 지연시키는 예상치 못한 매우 일반적인 구성이 숨겨져 있습니다.

// TestComponent.tsx
import {Button} from "@mui/material";

export const TestComponent = () => {
  return <Button>Hello World!</Button>;
}

// ComponentB.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { TestComponent } from "./TestComponent";

test('TestComponent', () => {
  render(<TestComponent />);
  expect(screen.getByText("Hello World!")).toBeInTheDocument();
});

테스트를 실행하면 아래와 같은 결과를 얻게됩니다.

PASS src/components/testComponent/TestComponent.test.tsx
√ TestComponent - 1 (34 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Time: 3.497 s

테스트 실행 시간을 단축하기 이전에 Jest가 어느 부분에서 시간을 소비하는지를 이해해야 합니다. 테스트 실행에 34ms가 소요되는 것은 의심의 여지가 없지만, 나머지 3.463초가 어디로 가는지는 명확하지 않습니다.

Jest가 무엇을 하는지에 대해 이해하지 못한다면 엉뚱한 부분을 최적화하며 시간을 낭비할 수 있습니다. ts-jest또는 babel-jest를 더 빠른 컴파일러로 전환해 타입스크립트 컴파일 시간을 향상시키라고 하는 제안이 좋은 예시입니다.

하지만 Jest는 캐시를 많이 사용하기 때문에 첫 번째 실행 이후에 타입스크립트 컴파일의 영향은 미미합니다.

1. Jest 시작 시간

우리가 테스트를 실행할 때 Jest는 자체 및 테스트 환경 (일반적으로 jest-environment-jsdom)을 로드해야합니다. 파일 간의 의존성 맵을 만들고, 테스트 순서에 대해 결정을 하고, 플러그인을 로드하고, 추가 스레드를 구동합니다.

이 모든 작업은 약 1초 정도 걸리지만 전적으로 Jest에 달려 있고, 애플리케이션과는 독립적으로 이뤄지기 때문에 우리가 할 수 있는 게 거의 없습니다. 게다가 이러한 설정은 스레드마다 한 번씩 수행되기 때문에 테스트나 테스트 파일의 개수가 늘어난다고 함께 증가하지는 않습니다.

Jest가 시작할 때 무슨 일을 하는지 궁금하다면 관련 내용을 상세하게 다루는 영상을 참고 부탁드립니다.

2. 캐시 채우기

애플리케이션에서 처음으로 테스트를 실행할 때, Jest는 캐시된 데이터를 활용할 수 없어서 시간이 좀 더 걸릴 것입니다. Jest는 처음 실행할 때 대부분 시간을 타입스크립트를 트랜스파일링하는데 사용합니다.

초기 실행 후 재 트랜스파일링이 필요한 몇몇 타입스크립트 파일이 있을 수 있지만, 그렇지 않은 상황에서 Jest는 주로 캐시 된 값을 이용합니다. 캐시 되지 않는 상황이 자주 발생하지는 않기 때문에 성능 최적화에 중요한 요소는 아닙니다.

3. 테스트 파일 로드하기

Jest가 테스트 파일을 실행하기 전에 테스트 파일과 setupTests.ts에서 참조하고 있는 모든 의존성을 로드하거나 모킹해야합니다. 이 단계는 테스트 실행시간에 상당한 오버헤드를 주기 때문에, 우리가 테스트 성능 개선을 크게 기대해볼 만한 부분입니다.

4. 실제 테스트의 성능

위의 예시에서 우리의 테스트는 34ms밖에 걸리지 않았기 때문에 이를 추가로 최적화하여 얻을 수 있는 이점은 거의 없습니다.

다행히도 우리는 Jest가 위의 각각의 단계에서 얼마나 많은 시간을 소비하고 있는지 추측할 필요가 없습니다. 크롬의 개발자 도구를 활용하여 테스트 실행을 프로파일링하고 각 실행이 수행하는 작업을 확인할 수 있습니다.

먼저, 브라우저에 chrome:inspect를 입력해서 개발자 도구를 열고 "Open dedicated DevTools for Node"를 클릭합니다.

그런 다음 터미널에서 node --inspect-brk ./node_modules/jest/bin/jest.js src/components/testComponent/TestComponent.test.tsx --runInBand를 실행합니다. 크롬이 개발자 도구 기본 중단점에 도달하면 프로파일러 탭으로 이동하여 녹화를 시작합니다. 테스트가 완료되면 프로파일러를 중지하고 녹화 내용을 본 뒤 "차트"보기를 선택합니다.


(파란색) Jest와 jest-environment-dom을 로딩. (초록색) 타입스크립트 컴파일. (빨간색) SetupTests.ts와 우리의 테스트 파일을 로딩. (노란색) 테스트 실행.

이 차트를 해석할 때 몇 가지 주의할 점이 있습니다.

  • 프로파일러가 있으면 테스트의 성능이 약 30% 감소합니다. 하지만, 프로파일러는 시간이 어디에 쓰이는지에 대한 지표를 잘 나타내줍니다.
  • 종속성에 도달하는 첫 번째 파일은 항상 최악의 성능을 보여줍니다. 왜냐하면 Jest가 같은 스레드 내에서 실행되는 다른 테스트를 위해 해당 종속성을 캐시 하기 때문입니다. (비록 명백히 다른 실행 간에는 아니지만) 만약 TestComponent를 포함한 두 번째 테스트 파일을 포함하려고하면 종속성을 로드하는데 절반 이하의 시간이 소요될 것입니다. 하지만 이는 여전히 줄일 수 있는 시간입니다. 그리고 당연하게도 개발 중에 하나의 파일만 실행하는 일반적인 상황에서 첫 번째 실행 성능이 매우 중요합니다.


여기에는 setupTests.ts(초록색)과 테스트 파일 종속성(파란색)의 로딩을 비교하는 동일한 종속성을 사용하는 두 개의 개별 테스트 파일이 있습니다. 두 번째 파일이 캐시의 이점을 얻을 수 있기에 상대적으로 빠릅니다. 게다가 Jest의 셋업 시간은 한번 밖에 발생하지 않습니다.

배럴(Barrel) 파일

이제 인스펙터를 연결했기 때문에 문제를 즉시 확인할 수 있습니다. 테스트 파일을 로드하는 대부분 시간이 @mui/material라이브러리를 로드하는데 쓰였습니다. Jest는 우리에게 필요한 버튼 컴포넌트만 로드하는 대신 전체 라이브러리를 처리하고 있습니다.


모든 녹색이 스레드당 한 번씩 발생했다는 점을 고려하면 @mui/material(빨간색)로드에만 총 실행 시간이 많이 소요됩니다.

이것이 문제가 되는 이유를 이해하려면 배럴 파일에 대해 좀 더 이해해야 합니다. 배럴 파일은 여러 내보내기를 일반적으로 index.ts라고 하는 하나의 파일에 묶는 방법입니다.

컴포넌트에 대한 외부 인터페이스를 제어하고 사용자가 모듈 내부의 구조와 구현을 신경 쓰지 않게 하려고 배럴 파일을 사용합니다. 대부분 라이브러리는 일반적으로 그들이 내보내는 모든 것을 포함하는 배럴 파일을 루트 디렉토리에 포함합니다.

// @mui-material/index.ts
export * from './Accordion';
export * from './Alert';
export * from './AppBar';
...

여기서 문제점은 우리가 가져오려는 컴포넌트가 위치한 곳을 Jest가 모른다는 것입니다. 배럴 파일은 의도적으로 컴포넌트의 위치를 난독화합니다. 따라서 Jest가 배럴 파일을 마주했을 때 그 안에서 참조하는 모든 내보내기를 로드해야합니다. 이러한 동작은 @mui/material와 같은 큰 라이브러리일수록 금방 제어하기가 어려워집니다.

우리는 단일 버튼을 원했지만 결국에는 수백 개의 부가적인 파일들도 함께 로드하게 됩니다.

다행히도 Jest에게 Button 컴포넌트가 정확히 어디에 있는지 알려주는 식으로 가져오기 구조를 변경함으로써 문제를 쉽게 고칠 수 있습니다.

// 이전
import { Button } from '@mui/material';
// 이후
import Button from '@mui/material/Button';


가져오기를 다시 구성한 후에 버튼 컴포넌트를 로드하는 영향은 많이 감소했습니다.

eslint를 사용하여 추후 이러한 형태의 가져오기가 추가되는 것을 방지하도록 아래의 규칙을 설정에 추가할 수 있습니다.

rules: {
    "no-restricted-imports": [
        "error",
        {
            "name": "@mui/material",
            "message": "Please use \"import foo from '@mui/material/foo'\" instead."
        }
    ]
}

저는 여기서 @mui/material 라이브러리가 크고 유명하기 때문에 선택했습니다. 그러나 최적화되지 않은 형태로 가져오는 라이브러리는 이 외에도 존재했습니다.

저는 @mui/material-icons, lodash-es, 그리고 @mui-x-date-picker 라이브러리를 포함한 우리의 내부 라이브러리에서의 가져오기 형태를 수정했습니다. 모든 가져오기를 업데이트한 결과 테스트 시간이 약 50% 단축되었습니다.

setupTests.ts 확인해보기

jest.config.jssetupFilesAfterEnv에 대해 구성된 파일을 쓸모없게 만들려는 시도가 있었습니다. 관련하여 사람들이 테스트 파일에서 원하지 않는 모든 종류의 일회성 사례와 엣지 케이스를 상속하려는 경향을 보였습니다.

저는 이러한 시도가 setupFilesAfterEnv에서 설정된 파일은 모든 테스트 이전에 한 번만 실행된다는 오해에서 비롯된 것으로 생각합니다. 하지만 Jest가 각각의 테스트 파일을 올바르게 격리할 수 있기 때문에 이 파일의 내용은 테스트마다 실행됩니다.

우리는 setupTest.ts 파일의 영향력을 이전 단계의 플레임차트에서 확인할 수 있습니다. 관련된 테스트 파일로 다시 이동하는 등 setupTest.ts내의 비용이 많이 드는 동작이 드러날 수 있습니다.


예를 들어 testing-library/jest-dom은 Jest의 기대 동작을 확장하는 각 파일의 시작 부분에 약 300ms (100ms 캐시)를 더합니다. 이 라이브러리는 이 파일에 속하며 영향은 미미하지만, 상황이 얼마나 빨리 증가할 수 있는지를 보여줍니다.

테스트 실행에서 타입 체킹 제거하기

테스트 내의 타입스크립트를 컴파일하기 위해 ts-jest를 사용하고 있다면, 이것의 기본 동작은 테스트 실행시에 타입스크립트 컴파일러의 타입 체크도 함께 진행하는 것입니다.

타입스크립트 컴파일러가 빌드 단계에서 타입 체킹을 이미 수행했기 때문에 이 작업은 불필요합니다. 이러한 부가적인 검사는 테스트 실행에 많은 시간을 추가하며 특히 타입스크립트 컴파일러를 호출할 필요가 없을 때 더욱 그렇습니다.

이러한 동작을 막기 위해 우리는 jest.config.js 파일에 아래 속성을 추가할 수 있습니다. ts-jest문서에 설명된 isolatedModules 속성입니다.

module.exports = {
    transform: {
      "^.+\\.(ts|tsx|js|jsx)$": [
          'ts-jest', {
              tsconfig: 'tsconfig.json',
              isolatedModules: false
          },
      ]
    },
};

저는 isolatedModules에 대해 엇갈린 경험이 있습니다. 몇몇 레거시 애플리케이션에서 이 세팅을 업데이트하여 퍼포먼스가 2배 향상했지만, 작은 create-react-app 애플리케이션에서는 아무런 차이가 없었습니다. 다시 한번 플레임 차트를 통해 이 부가적인 작업의 영향을 확인할 수 있습니다.


타입 체킹의 영향은 (빨간색) @mui-material의 로딩(초록색)도 작아 보이게 만듭니다.

잘못된 구성 확인해보기

성능 향상이 코드베이스를 통해서만 이뤄지는 것은 아닙니다. 개발자가 도구를 사용하는 방식에도 일부 책임이 있습니다. package.json내의 스크립트는 타이핑을 절약하고 복잡함을 숨기고 프로젝트의 모든 사용자에게 가장 적합한 CLI 구성을 공유하는 데 도움이 됩니다.

그러나 시간이 지남에 따라 팀은 공통 도구의 CLI를 사용하는 방법을 잊어버리고 기존에 작성된 스크립트가 최적의 구성이라는 생각을 과신하게 된다는 심각한 단점이 있습니다. 제가 참여한 대부분 프로젝트에서 package.json내의 스크립트에 시간을 낭비하는 상당히 잘못된 구성들이 있었습니다.

사람들은 기존에 지속적인 통합을 의도했던 스크립트와 그들의 로컬 개발 환경에 적합한 스크립트를 혼동합니다. 도구의 새로운 기능과 변경 사항에 맞춰 스크립트가 업데이트되지 않았거나 원래부터 잘못된 것일 수 있습니다.

Jest에는 로컬에서 테스트 실행 시에 피해야 할 몇 가지 플래그가 있습니다.

  • --maxWorkers=2 : Jest가 두 개의 스레드에서 실행되도록 제한합니다. 제한된 CI 빌드 에이전트에서는 유용하지만, Jest를 5개 또는 6개의 다른 스레드에서 실행할 수 있는 강력한 개발 환경에서는 그다지 유용하지 않습니다.
  • --runInBand : 유사하게 Jest가 스레드를 전혀 사용하지 못하게 합니다. 단일 테스트 파일을 실행하는 것과 같이 스레드가 필요 없는 상황도 있지만 Jest는 이를 자체적으로 파악할 수 있을 만큼 똑똑합니다.
  • --no-cache, --cache=false, --clearCache : Jest가 각 실행 사이에 데이터를 캐시하는 것을 방지합니다. Jest 문서에 따르면 캐시를 비활성화 하는 것은 Jest를 평균적으로 최소 2배 이상 느려지게 합니다.
  • --coverage : 대부분 로컬 테스트는 코드 커버리지 리포트를 생성할 필요가 없습니다. 필요하지 않을 때 이 단계를 건너뛰면 몇 초 정도 절약할 수 있습니다.

Jest는 많은 설정값을 갖고 있지만 기본값이 대부분 제일 무난하게 작동합니다. package.json 내의 스크립트에 대한 추가 플래그의 목적을 이해하는 것이 중요합니다.

기본적으로 watch 모드 사용

우리는 모두 로컬에서 애플리케이션 실행을 위해 watch 모드를 사용하지만, 테스트를 실행할 때는 잘 사용하지 않습니다. 이러한 경향은 유감스럽습니다. 왜냐하면 앱의 빌드처럼 테스트를 watch 모드에서 실행하면 툴링이 많은 데이터를 다시 계산할 필요가 없어지기 때문입니다.

Jest가 느리게 느껴지는 이유는 대부분 테스트 실행이 아닌 시작 시간에 있으며 이는 watch 모드에서 건너뛸 수 있습니다.


Jest가 다음 테스트에 대한 종속성을 캐시하는 방법과 유사하게 watch 모드에서 같은 테스트 재실행에 대해 동일한 이점을 얻습니다.

저는 IDE의 인터페이스가 watch 모드를 부주의하게 사용하지 않도록 권장하기 때문에 개발자들이 watch 모드의 이점을 활용하지 못하는 경우가 많다고 생각합니다. 우리는 각 테스트 케이스 옆에 있는 녹색 "테스트 실행" 화살표를 클릭하여 테스트를 실행하는 데 익숙합니다. 이는 모든 테스트를 실행하거나 CLI에서 일부 테스트만을 실행하기 위해 신택스를 외우는 것보다 쉽고 빠릅니다.

게다가 IDE의 테스트 결과 패널에 테스트 결과가 표시되므로 콘솔에 덤프된 로그보다 훨씬 유용합니다.

WebStorm을 사용하면 "테스트 실행" 단축키에 사용되는 실행 구성을 업데이트하여 watch 모드에서 테스트를 실행할 수 있습니다. 심지어 Jest의 실행 템플릿을 업데이트하여 모든 "테스트 실행" 단축키를 watch 모드로 실행하도록 기본값을 설정할 수도 있습니다.

모든 테스트를 실행할 필요는 없습니다

단일 테스트 파일에서 작업하지 않는 한, 개발자는 모든 테스트를 기본적으로 실행하는 경향이 있습니다. Jest는 변경된 파일을 기반으로 실행해야 하는 테스트의 집합을 파악할 수 있으므로 이러한 행동은 불필요합니다.

테스트 개수가 늘어남에 따라 전체 테스트를 실행하는 데 불필요한 시간이 소요되지만, 이 글의 조언들이 감당할 수 없는 수준을 제한하는 데 도움이 되기를 바랍니다.

jest를 바로 사용하는 것보다 jest [--onlyChanged](https://jestjs.io/docs/cli#--onlychanged)jest [--changedSince](https://jestjs.io/docs/cli#--onlychanged)를 사용하는 게 더 좋습니다. 100% 신뢰할 수는 없지만, 우리가 마스터 브랜치에 직접 커밋하지 않는 이상 CI 파이프라인이 Jest가 포착하지 못한 드문 상황을 포착할 것입니다.


사람들의 무관심이 시작되고 관리가 소홀해짐에 따라 테스트 속도는 기하급수적으로 느려지게 됩니다.

테스트는 정적인 경우가 거의 없으며 애플리케이션의 크기에 따라서 함께 커집니다. 느린 테스트는 점점 더 느려질 뿐입니다. 다행히 작은 작업으로 각 테스트 시간을 절반 이상 단축할 수 있습니다. 이 작업을 통해 시간을 절약할 수 있을 뿐만 아니라 테스트 전체 소요 시간과 품질에 대한 방향을 바로잡을 수 있습니다.

2개의 댓글

comment-user-thumbnail
2023년 2월 7일

감사합니다~

답글 달기
comment-user-thumbnail
2023년 2월 10일

very nice information, very smartly you have shared everything here.
CheckMyRota

답글 달기