Backend Tests 3.5x Faster: Migrating from ts-jest to @swc/jest Backend 테스트 3.5배 빠르게: ts-jest에서 @swc/jest로 전환하기
Background
The backend tests in our monorepo were slow. Running 78 test suites with 643 tests took 45 seconds locally, and even longer in CI, where --runInBand forced everything to run serially.
Slow tests slow down the development loop. Whether you’re doing TDD or just checking a change, 45 seconds is more than enough to break your focus.
Finding the Bottleneck
1. The cost of ts-jest’s transform
Jest has to convert .ts files to JavaScript before running them. ts-jest uses the TypeScript compiler (tsc) for this conversion. Because tsc also performs type checking, it carries significant overhead compared to a plain transform.
A large chunk of test execution time was going to code transformation.
Measuring individual suite times with --verbose showed that most suites finished quickly, while time was concentrated in transform initialization. Among individual tests, the content-settings warmup retry test was using a real setTimeout of 2 seconds × 3 retries = 4 seconds, and an encryption key generation test was eating 1.3 seconds. Those could be improved separately with jest.useFakeTimers(), but the transform cost dwarfed everything else, so that’s where I focused.
2. —runInBand in CI
The CI Dockerfile ran the tests like this:
CMD ["npx", "jest", "--runInBand", "--detectOpenHandles", "--config", "..."]
--runInBand runs every test sequentially in a single process. It completely disables Jest’s default worker-based parallel execution. Why was it set up this way?
- To avoid shared-state problems between tests
- To prevent memory issues inside the Docker container
- “Because it works, so leave it”
But when I actually checked, all the tests passed when run in parallel. There were no shared-state issues.
The Fix: ts-jest → @swc/jest
What is SWC?
SWC is a JavaScript/TypeScript compiler written in Rust. Unlike tsc, it only transforms code, without type checking. Type checking isn’t needed to run tests — that’s the job of tsc --noEmit or your IDE.
Trade-off: Since SWC doesn’t look at types, tests can pass even when there are type errors. You need either a separate tsc --noEmit step in the CI pipeline or to confirm that type checking happens during the build step (pnpm turbo run build). In our project, the build step runs before the tests, so type safety was already guaranteed.
Rollback is simple: If something goes wrong, revert the jest config’s transform to "ts-jest" and remove @swc/core and @swc/jest. It’s a change to a single config file, so the risk is low.
Installation
pnpm add -D @swc/jest @swc/core --filter backend
Updating the Jest config
{
"transform": {
"^.+\\.ts$": ["@swc/jest", {
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"target": "es2021"
},
"module": {
"type": "commonjs"
}
}]
}
}
NestJS relies heavily on decorators, so decorators: true and decoratorMetadata: true are mandatory. Leave them out and DI breaks.
Results
Before (ts-jest): 45s
After (@swc/jest): 13s → 3.5x faster
All 78 suites and 643 tests passed. No behavioral differences.
The Circular Dependency Problem
After the switch, one test failed.
ReferenceError: WorldAssetResourceType is not defined
The cause was a circular import between two DTO files:
sandbox-public.dto.ts ──import──> sandbox-user.dto.ts (WorldAssetResourceType)
↑ |
└──────────import───────────────────┘ (SandboxWorldAssetDto)
ts-jest (tsc) handles circular imports with lazy loading at runtime, so they mostly work. SWC, however, initializes modules in a different order, so when the value is used at runtime by a decorator — as in @ApiProperty({ enum: WorldAssetResourceType }) — it ends up referencing a value that hasn’t been initialized yet.
The fix
I broke the cycle by extracting WorldAssetResourceType into its own file:
// sandbox.types.ts (newly created)
export const WorldAssetResourceType = {
MODEL: 'MODEL',
STATIC_MESH: 'STATIC_MESH',
// ...
} as const
// sandbox-user.dto.ts
import { WorldAssetResourceType } from '../sandbox.types'
export { WorldAssetResourceType }
// sandbox-public.dto.ts
import { WorldAssetResourceType } from '../sandbox.types'
A caveat when re-exporting: a barrel export of the form export { X } from './file' can cause problems in SWC if the value is also used at runtime in the same file. Splitting it into an import followed by a separate export solves this.
Why CI Didn’t Get Faster
After confirming the 3.5x improvement locally, I also tried parallelizing CI by changing --runInBand → --maxWorkers=50%. Local measurements:
| Setting | Time |
|---|---|
--runInBand (original) | 28.7s |
--maxWorkers=50% | 22s |
| No limit | 13s |
But when I applied it to the actual CI pipeline, total CI time barely changed.
The reason was simple. Tests made up only a small share of total CI time:
Full Docker build: ~5 min
├── pnpm install: ~1 min 30 s
├── turbo build: ~2 min
├── Run tests: ~30 s ← the only part that improved
└── Image build: ~1 min
Cutting 30 seconds down to 15 isn’t noticeable out of 5 minutes. On top of that, @swc/core is a native binary (~50MB), so install time went up slightly, partially offsetting the gains in test time.
In the end, I rolled back the --runInBand change in the CI Dockerfile. To reduce overall CI time, the answer isn’t test parallelization — it’s Docker build caching or improving the pipeline structure itself.
Restructuring the CI Pipeline: Separating Build and Test
If test execution speed has little impact on total CI time, the pipeline structure itself has to change.
Before: build + test in a single job
Previously, tests ran sequentially inside the Docker build. Tests couldn’t start until the build finished, and the image couldn’t be pushed until the tests finished. Because everything was serial, all the times added up.
After: build and test as parallel jobs
I split the GitHub Actions workflow into two independent jobs:
jobs:
test:
name: Unit Test
steps:
- # checkout
- docker build --target test-runner
- docker run eterno-backend-test:latest
build-push:
name: Build and Push ECR Image
# needs: test ← intentionally omitted
steps:
- # checkout
- docker build --target server-runner
- docker push
The key point is that build-push does not depend on test. Both jobs start at the same time.
Why do it this way?
- Faster test feedback. The test job doesn’t do heavy work like building the production image, pushing to ECR, or uploading version metadata. It only builds up to the
test-runnerstage and runs immediately, so results come back sooner. - Build failures and test failures are identified independently. When build failures and test failures are mixed in a single job, it’s hard to tell what broke. Once they’re separated, Slack notifications come in separately too.
- Tests act as a merge gate. If passing the
testjob is required for PR merges, a PR won’t be merged when tests fail, even if the build succeeds. Because it’s independent of the build job, a test failure doesn’t block the build, yet the code quality gate stays in place.
Trade-offs
- It uses twice the CI runner resources. Since both jobs run concurrently, two runners are needed.
- Git checkout and the early Docker build stages (
pruner,builder) run redundantly in both jobs. Due to the Dockerfile’s multi-stage structure, bothtest-runnerandserver-runnergo through thebuilderstage, so no time is saved there. - Build/push continues even when tests fail, so unnecessary images may end up in ECR. However, deployment is a separate pipeline, so this isn’t a real problem in practice.
Summary
| Environment | Before | After | Improvement |
|---|---|---|---|
| Local (parallel) | 45s (ts-jest) | 13s (@swc/jest) | 3.5x |
| CI test speed | No change | No change | - |
| CI pipeline | Build + test in serial | Build/test as parallel jobs | Faster test feedback |
Files changed:
- 3
jest.config.jsonfiles (backend root, eterno-backend/test, hiker-backend/test) - 1
sandbox.types.ts(circular dependency fix) package.json+pnpm-lock.yaml(@swc/jest, @swc/core dependencies)eterno-backend-ci-v2.yaml(split into parallel build/test jobs)
Lessons Learned
-
Type checking during tests is waste. tsc’s value lies in type checking, but running tests only requires a plain transform. That’s exactly what SWC does.
-
Local and CI have different bottlenecks. Being 3.5x faster locally doesn’t mean CI gets faster too. If tests are a small share of total CI time, optimizing only the tests won’t be noticeable. To decide what to optimize, look at the time distribution across the whole pipeline first.
-
Circular dependencies will blow up eventually. It was fine under ts-jest but broke under SWC. Changing tools exposes code smells that were hiding all along. That’s a good thing.
-
If you can’t make it faster, change the structure. Test execution was such a small part of total CI time that speeding it up was meaningless. In that situation, the real improvement is restructuring the pipeline to run things in parallel. Splitting build and test into independent jobs speeds up test feedback without affecting build time.
-
Measure first, optimize later. Don’t go by a gut feeling that “it’s slow” — measure actual times with
--verboseand thetimecommand and find the bottleneck. The fix is completely different depending on whether the bottleneck is the transform, I/O, or individual slow tests. This time I also found a 4-second warmup test, but because the transform cost was overwhelming, focusing there was the right call.
배경
모노레포의 백엔드 테스트가 느렸다. 78개 test suite, 643개 테스트를 돌리는 데 로컬에서 45초, CI에서는 --runInBand 강제 직렬 실행으로 더 오래 걸렸다.
테스트가 느리면 개발 루프가 늦어진다. TDD를 하든, 변경 후 확인을 하든, 45초는 집중이 끊기기 충분한 시간이다.
병목 분석
1. ts-jest의 transform 비용
Jest는 .ts 파일을 실행 전에 JavaScript로 변환해야 한다. ts-jest는 이 변환에 TypeScript 컴파일러(tsc)를 사용한다. tsc는 타입 체크까지 수행하므로 단순 변환 대비 오버헤드가 크다.
테스트 실행 시간의 상당 부분이 코드 변환에 쓰이고 있었다.
--verbose 옵션으로 개별 suite 시간을 측정해보니, 대부분의 suite가 빠르게 끝나는데 transform 초기화에 시간이 쏠리고 있었다. 개별 테스트 중에는 content-settings warmup retry 테스트가 실제 setTimeout 2초 × 3회 = 4초를 소비하고 있었고, 암호화 키 생성 테스트도 1.3초를 먹고 있었다. 이런 것들은 jest.useFakeTimers()로 별도 개선 가능하지만, transform 비용이 압도적이었으므로 거기에 집중했다.
2. CI의 —runInBand
CI Dockerfile에서 테스트를 이렇게 실행하고 있었다:
CMD ["npx", "jest", "--runInBand", "--detectOpenHandles", "--config", "..."]
--runInBand은 모든 테스트를 단일 프로세스에서 순차 실행한다. Jest의 기본 동작인 worker 기반 병렬 실행을 완전히 끈다. 왜 이렇게 했을까?
- 테스트 간 상태 공유 문제를 피하려고
- Docker 컨테이너에서 메모리 이슈를 방지하려고
- “일단 돌아가니까”
하지만 실제로 확인해보니 병렬 실행해도 테스트가 전부 통과했다. 상태 공유 문제는 없었다.
해결: ts-jest → @swc/jest
SWC란
SWC는 Rust로 작성된 JavaScript/TypeScript 컴파일러다. tsc와 달리 타입 체크 없이 순수 변환만 수행한다. 테스트 실행에서 타입 체크는 불필요하다 — 그건 tsc --noEmit이나 IDE가 할 일이다.
트레이드오프: SWC는 타입을 보지 않으므로, 타입 에러가 있어도 테스트가 통과할 수 있다. CI 파이프라인에 tsc --noEmit 단계를 별도로 두거나, 빌드 단계(pnpm turbo run build)에서 타입 체크가 수행되는지 확인해야 한다. 우리 프로젝트에서는 빌드 단계가 테스트 전에 실행되므로 타입 안전성은 이미 보장되고 있었다.
롤백이 간단하다: 문제가 생기면 jest config의 transform을 "ts-jest"로 되돌리고 @swc/core, @swc/jest를 제거하면 끝이다. 설정 파일 하나의 변경이므로 리스크가 낮다.
설치
pnpm add -D @swc/jest @swc/core --filter backend
Jest 설정 변경
{
"transform": {
"^.+\\.ts$": ["@swc/jest", {
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"target": "es2021"
},
"module": {
"type": "commonjs"
}
}]
}
}
NestJS는 데코레이터를 많이 쓰므로 decorators: true와 decoratorMetadata: true가 필수다. 이걸 빠뜨리면 DI가 깨진다.
결과
Before (ts-jest): 45s
After (@swc/jest): 13s → 3.5배 개선
78 suites, 643 tests 전부 통과. 동작 차이 없음.
Circular Dependency 문제
전환 후 1개 테스트가 실패했다.
ReferenceError: WorldAssetResourceType is not defined
원인은 두 DTO 파일 간의 circular import였다:
sandbox-public.dto.ts ──import──> sandbox-user.dto.ts (WorldAssetResourceType)
↑ |
└──────────import───────────────────┘ (SandboxWorldAssetDto)
ts-jest(tsc)는 circular import를 런타임에 지연 로딩으로 처리해서 대부분 동작한다. 하지만 SWC는 모듈 초기화 순서가 달라서, @ApiProperty({ enum: WorldAssetResourceType })처럼 데코레이터의 런타임 값으로 사용되는 경우 아직 초기화되지 않은 값을 참조하게 된다.
해결
WorldAssetResourceType을 별도 파일로 추출하여 circular을 끊었다:
// sandbox.types.ts (새로 생성)
export const WorldAssetResourceType = {
MODEL: 'MODEL',
STATIC_MESH: 'STATIC_MESH',
// ...
} as const
// sandbox-user.dto.ts
import { WorldAssetResourceType } from '../sandbox.types'
export { WorldAssetResourceType }
// sandbox-public.dto.ts
import { WorldAssetResourceType } from '../sandbox.types'
re-export 시 주의할 점: export { X } from './file' 형태의 barrel export는 SWC에서 같은 파일 내 런타임 사용 시 문제가 될 수 있다. import 후 별도 export로 분리하면 된다.
CI에서는 왜 빨라지지 않았나
로컬에서 3.5배 개선을 확인하고, CI에서도 --runInBand → --maxWorkers=50%로 병렬화를 시도했다. 로컬에서의 실측:
| 설정 | 시간 |
|---|---|
--runInBand (기존) | 28.7s |
--maxWorkers=50% | 22s |
| 제한 없음 | 13s |
하지만 실제 CI 파이프라인에 적용해보니 전체 CI 시간은 거의 변하지 않았다.
이유는 단순했다. CI 전체 시간에서 테스트가 차지하는 비중이 작았다:
Docker 빌드 전체: ~5분
├── pnpm install: ~1분 30초
├── turbo build: ~2분
├── 테스트 실행: ~30초 ← 여기만 개선됨
└── 이미지 빌드: ~1분
30초를 15초로 줄여도 전체 5분에서 체감이 안 된다. 게다가 @swc/core는 네이티브 바이너리(~50MB)라 install 시간이 소폭 증가하여 테스트 시간 단축분을 일부 상쇄했다.
결국 CI Dockerfile의 --runInBand 변경은 롤백했다. CI 전체 시간을 줄이려면 테스트 병렬화가 아니라 Docker 빌드 캐싱이나 파이프라인 구조 자체를 개선해야 한다.
CI 파이프라인 구조 개선: Build와 Test 분리
테스트 실행 속도 자체가 CI 전체 시간에 미치는 영향이 작다면, 파이프라인 구조를 바꿔야 한다.
기존: 하나의 Job에서 빌드 + 테스트
기존에는 Docker 빌드 안에서 테스트가 순차적으로 실행됐다. 빌드가 끝나야 테스트가 시작되고, 테스트가 끝나야 이미지가 푸시됐다. 전체가 직렬이므로 모든 시간이 합산된다.
변경: Build와 Test를 병렬 Job으로 분리
GitHub Actions 워크플로우를 두 개의 독립된 job으로 나눴다:
jobs:
test:
name: Unit Test
steps:
- # checkout
- docker build --target test-runner
- docker run eterno-backend-test:latest
build-push:
name: Build and Push ECR Image
# needs: test ← 의도적으로 없음
steps:
- # checkout
- docker build --target server-runner
- docker push
핵심은 build-push가 test에 의존하지 않는다는 점이다. 두 job이 동시에 시작된다.
왜 이렇게 했나
- 테스트 피드백이 빨라진다. 테스트 job은 프로덕션 이미지 빌드, ECR 푸시, 버전 메타데이터 업로드 같은 무거운 작업을 하지 않는다.
test-runner스테이지까지만 빌드하고 바로 실행하므로 결과가 더 빨리 나온다. - 빌드 실패와 테스트 실패를 독립적으로 파악한다. 하나의 job에서 빌드 실패와 테스트 실패가 섞이면 뭐가 깨졌는지 구분하기 어렵다. 분리하면 Slack 알림도 각각 온다.
- 테스트가 merge gate 역할을 한다. PR merge 조건에
testjob 통과를 필수로 걸면, 빌드가 성공해도 테스트가 실패하면 머지되지 않는다. 빌드 job과 독립적이므로 테스트 실패가 빌드를 블로킹하지 않으면서도, 코드 품질 게이트는 유지된다.
트레이드오프
- CI 러너 리소스를 2배 사용한다. 두 job이 동시에 돌아가므로 runner가 2개 필요하다.
- Git checkout과 Docker 빌드의 초기 단계(
pruner,builder)가 양쪽에서 중복 실행된다. Dockerfile의 multi-stage 구조상test-runner와server-runner모두builder스테이지를 거치므로, 이 부분의 시간은 절약되지 않는다. - 테스트가 실패해도 빌드/푸시는 계속 진행된다. 불필요한 이미지가 ECR에 올라갈 수 있다. 다만 배포 자체는 별도 파이프라인이므로 실질적인 문제는 없다.
최종 정리
| 환경 | Before | After | 개선 |
|---|---|---|---|
| 로컬 (병렬) | 45s (ts-jest) | 13s (@swc/jest) | 3.5x |
| CI 테스트 속도 | 변경 없음 | 변경 없음 | - |
| CI 파이프라인 | 빌드+테스트 직렬 | 빌드/테스트 병렬 job | 테스트 피드백 단축 |
변경한 파일:
jest.config.json3개 (backend root, eterno-backend/test, hiker-backend/test)sandbox.types.ts1개 (circular dep 해결)package.json+pnpm-lock.yaml(@swc/jest, @swc/core 의존성)eterno-backend-ci-v2.yaml(build/test 병렬 job 분리)
배운 것
-
테스트에서 타입 체크는 낭비다. tsc의 가치는 타입 검사이지만, 테스트 실행 시에는 순수 변환만 필요하다. SWC가 정확히 그 역할을 한다.
-
로컬과 CI는 병목이 다르다. 로컬에서 3.5배 빨라졌다고 CI도 빨라지는 건 아니다. CI 전체 시간에서 테스트가 차지하는 비중이 작으면 테스트만 최적화해도 체감이 없다. 어디를 최적화할지는 전체 파이프라인의 시간 분포를 먼저 봐야 한다.
-
Circular dependency는 언젠가 터진다. ts-jest에서는 괜찮았지만 SWC에서 터졌다. 도구를 바꾸면 기존에 숨어있던 코드 스멜이 드러난다. 이건 좋은 일이다.
-
속도를 못 줄이면 구조를 바꿔라. 테스트 실행 자체가 CI 전체에서 차지하는 비중이 작아 속도 개선이 무의미했다. 이럴 때는 파이프라인 구조를 바꿔서 병렬로 돌리는 게 실질적인 개선이다. 빌드와 테스트를 독립 job으로 분리하면, 테스트 피드백은 빨라지고 빌드 시간에 영향을 주지 않는다.
-
측정 먼저, 최적화 나중. 감으로 “느리다”가 아니라
--verbose와time명령어로 실제 시간을 재고 병목을 찾아야 한다. transform이 병목인지, I/O가 병목인지, 개별 테스트가 느린지에 따라 해법이 완전히 다르다. 이번에는 4초짜리 warmup 테스트도 발견했지만, transform 비용이 압도적이라 거기에 집중한 것이 옳은 판단이었다.