[번역] CI 환경에서 maxWorkers로 불안정한 Jest 테스트 해결하기 (그리고 그 원인)

I
Inkyu Oh

Front-End2026.04.04

Tyler - 2026-03-18


어릴 적 저는 길 건너편에 있는 조부모님 댁에서 많은 시간을 보냈습니다. 할아버지가 사과나무에서 사과를 따서 주머니칼로 깎아주시던 모습이나, 작업실에서 도구들이 어떻게 작동하는지 보여주시던 기억이 납니다(비록 제가 손재주가 좋은 어른으로 성장하지는 못했지만요). 아침 식사 때 그릇에 프로스티드 플레이크(Frosted Flakes)를 담으시던 모습이나, 농구 경기 광고 시간에 나오는 토니 더 타이거(Tony the Tiger)의 노래를 따라 부르시던 조용하고 평범한 순간들도 있었습니다. 그런 소소한 일상들이 기억에 남았고, 저는 프로스티드 플레이크를 계속 좋아하게 되었습니다.
하지만 모든 '플레이크(flake)'가 매력적인 것은 아닙니다. 사실 저는 아주 사악한 종류의 플레이크가 있다고 생각합니다. 바로 불안정한 테스트(Flaky tests)입니다. 이는 코드에 아무런 변화가 없음에도 불구하고 실행할 때마다 성공과 실패를 반복하는 자동화된 테스트를 말합니다. React Native에서 우리는 보통 이러한 불안정한 테스트를 DetoxMaestro를 사용하는 엔드 투 엔드(E2E) 테스트 스위트에서 해결해야 할 문제로 생각합니다. 클라우드에서 에뮬레이터와 시뮬레이터를 실행하면 변동성이 발생하고, 완전히 결정론적인 테스트 케이스를 실행하기 어렵기 때문입니다.
불행히도 Jest 유닛 테스트 역시 불안정해질 수 있습니다. 그리고 이런 종류의 불안정함은 저를 더욱 좌절하게 만듭니다. Jest 테스트는 Node에서만 실행되며 수많은 불확정 요소를 제거합니다. 빠르고 단순하며 매우 신뢰할 수 있어야 하죠. 그런데 어떻게 이런 단순한 테스트가 우리를 배신할 수 있을까요?
2주 동안 이 문제와 씨름하며 좌절한 끝에, 저는 흔한 범인을 찾아냈습니다. 바로 Jest의 기본 병렬화 방식이 머신 리소스를 공격적으로 소비한다는 점입니다. 테스트 러너가 가능한 한 많은 메모리를 점유하면 결정론적이지 않은 속도 저하가 발생할 수 있습니다. 이러한 조건에서 타임아웃 기반의 단언문(Assertion)이 포함된 테스트는 불안정한 동작을 보이기 시작합니다.
요약: CI/CD에서 불안정한 Jest 테스트를 해결하려면 다음을 추가해 보세요.
--maxWorkers='50%'
이 옵션을 Jest 명령어에 추가하면 메모리 압박을 줄일 수 있으며, 병렬 워커(Worker) 수를 줄였음에도 불구하고 오히려 테스트 속도가 빨라질 수 있습니다.

병렬화가 잘못될 때

우리는 흔히 병렬화를 쉬운 성능 개선 방법으로 생각합니다. 이론적으로 테스트 파일을 격리하면 병렬 프로세스에서 동시에 실행할 수 있습니다. 작업 부하를 나누는 것은 대개 전체 테스트 실행 시간을 줄여줍니다. 이는 테스트 스위트가 소규모이거나 중간 규모일 때, 또는 테스트를 실행하는 머신의 리소스가 충분할 때만 해당되는 이야기입니다. 하지만 Jest 러너는 가능한 한 많은 메모리를 사용하려는 경향이 있습니다. 따라서 더 많은 워커를 생성할수록 더 많은 메모리를 할당하게 됩니다. 만약 머신의 가용 메모리가 부족해지기 시작하면, 운영체제는 메모리 회수(Memory reclaim)를 시도합니다. RAM 페이지(Page)스왑 메모리(Swap memory)로 옮기거나, 스왑 메모리가 없다면 스래싱(Thrashing)이 발생하며 페이지를 재할당해야 합니다. 이러한 작업은 프로세스 속도를 늦추며, 성능 저하는 테스트 실패로 이어질 수 있습니다. 항상 그런 것은 아니지만요.
이것이 바로 불안정한 테스트의 정체입니다.
다음과 같은 컴포넌트가 있다고 가정해 봅시다.
import { useEffect, useState } from "react"
import { View } from "react-native"
import { Text } from "./Text"

export function DelayedContent() {
const [showContent, setShowContent] = useState(false)

useEffect(() => {
const timer = setTimeout(() => {
setShowContent(true)
}, 4000)
return () => clearTimeout(timer)
}, [])

return (
<View>
<Text>Loading...</Text>
{showContent && <Text>Content loaded!</Text>}
</View>
)
}
그리고 다음과 같은 테스트가 있습니다.
import { render } from "@testing-library/react-native"
import { DelayedContent } from "./DelayedContent"
import { ThemeProvider } from "../theme/context"

describe("DelayedContent", () => {
it("should show content after delay", async () => {
const { findByText } = render(
<ThemeProvider>
<DelayedContent />
</ThemeProvider>,
)

const content = await findByText("Content loaded!", {}, { timeout: 5000 })
expect(content).toBeDefined()
})
})
이는 설명을 돕기 위해 고안된 예시입니다. DelayedContent는 4초 후에 Content Loaded! 문자열을 보여줍니다. 하지만 JavaScript에서 setTimeout은 함수를 실행하기 전의 최소 시간을 설정할 뿐, 실행 시간을 보장하지 않습니다. 따라서 JavaScript 프로세스가 느려지고 setTimeout이 테스트 단언문의 타임아웃보다 오래 걸리면, 테스트는 다음과 같이 실패하게 됩니다.
FAIL app/components/DelayedContent.test.tsx (31.954 s, 87 MB heap size)
DelayedContent
✕ should show content after delay (5059 ms)

● DelayedContent › should show content after delay

thrown: "Exceeded timeout of 5000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."
게다가 이 테스트는 기저의 시스템 조건이 원인이 될 때만 실패합니다. 이러한 조건은 비결정론적입니다. 따라서 실패가 간헐적으로 발생하게 됩니다. 이것이 바로 '플레이크'입니다.
앞서 언급했듯이, Jest에 최대 워커 제한을 강제함으로써 이러한 실패 조건을 우회할 수 있습니다. 메모리가 부족해질 때 운영체제가 느려지는 것을 방지하는 것이죠. --maxWorkers 플래그가 여기서 도움이 됩니다. --maxWorkers='50%'로 설정하면 Jest가 시스템의 CPU 코어 수의 절반만 워커로 사용하도록 강제합니다.

해결책: 우리는 이미 본 적이 있습니다

이것은 Jest에서 잘 알려진 문제이자 해결 방법입니다. 다른 사람들도 이에 대해 글을 쓴 적이 있습니다.

더 깊이 파고들기

좋습니다. "CI/CD에서 Jest의 불안정함을 고치는 기묘한 비결"은 알게 되었지만, 마법 같은 설정 하나만으로는 만족스러운 답변이 되지 않았습니다. 저는 이 동작을 더 철저하게 이해하고 싶었습니다. 클라이언트 프로젝트에서 이런 종류의 불안정함을 겪고 설정을 적용한 후에도 여전히 의문이 남았습니다.
  1. 워커가 많을수록 테스트가 더 불안정해진다는 것을 증명하기 위해 이 시나리오를 안정적으로 재현할 수 있는가?
  1. 이런 조건에서 운영체제 내부에서는 실제로 어떤 일이 벌어지고 있는가? 측정할 수 있는가?
  1. 스왑 메모리가 없는 머신에서는 어떤 일이 벌어지는가? 그런 시나리오에서 메모리 부족(Out of Memory) 오류는 보지 못했지만, 테스트 불안정함은 관찰했습니다. 왜 메모리 부족이 발생하지 않고 느려지기만 할까요?
전반적으로 저는 이 해결책이 완전히 검증 가능한지 확인하고 싶었습니다. 그래야 클라이언트에게 권장 사항을 제안하고, 변경이 필요한지 이해하며, 팀이 나중에 필요할 때 설정을 조정할 수 있도록 도울 수 있기 때문입니다.

Jest 메모리 소비 실험

Docker를 사용하여 실험을 수행하고, 일반적인 CI 러너와 유사한 재현 가능한 시스템에서 메모리 압박을 직접 관찰할 수 있습니다. 저는 Ignite를 기반으로 한 예제 프로젝트를 만들었습니다. Ignite에는 Jest와 React Native Testing Library로 구축된 샘플 테스트 스위트가 포함되어 있는데, 이는 React Native 및 Expo 애플리케이션에서 꽤 흔한 구성입니다. 여기에 앞서 설명한 DelayedContent 컴포넌트를 추가했습니다.
import { useEffect, useState } from "react"
import { View } from "react-native"
import { Text } from "./Text"

export function DelayedContent() {
const [showContent, setShowContent] = useState(false)

useEffect(() => {
const timer = setTimeout(() => {
setShowContent(true)
}, 4000)
return () => clearTimeout(timer)
}, [])

return (
<View>
<Text>Loading...</Text>
{showContent && <Text>Content loaded!</Text>}
</View>
)
}
그리고 이에 대응하는 테스트입니다.
import { render } from "@testing-library/react-native"
import { DelayedContent } from "./DelayedContent"
import { ThemeProvider } from "../theme/context"

describe("DelayedContent", () => {
it("should show content after delay", async () => {
const { findByText } = render(
<ThemeProvider>
<DelayedContent />
</ThemeProvider>,
)

const content = await findByText("Content loaded!", {}, { timeout: 5000 })
expect(content).toBeDefined()
})
})
또한 Alpine 기반의 Node 이미지를 생성하는 Dockerfile을 만들고, 다양한 조건에서 Jest 스위트를 실행하고 시스템 메모리 동작을 검사하는 스크립트를 가져왔습니다. 이 글의 나머지 부분에서는 Jest가 시스템 메모리 사용량에 어떤 영향을 미치는지, 그리고 그것이 테스트 안정성에 무엇을 의미하는지에 대한 세부적인 실험 과정과 결과를 안내해 드리겠습니다.

샘플 프로젝트 실행하기

작동 방식은 다음과 같습니다. 저장소를 내려받고 의존성을 설치합니다.
yarn
그런 다음 Docker 이미지를 빌드합니다.
yarn docker:build
이 명령은 node:24-alpine 이미지를 내려받고, 작업 디렉토리를 컨테이너로 복사한 후, 테스트를 실행하고 백그라운드에서 메모리 사용량을 모니터링하는 명령을 설정합니다.

기준점(Baseline): 그냥 테스트 실행하기

기준점으로서 Docker 컨테이너에서 Jest 명령어를 그냥 실행해 볼 수 있습니다. 다음을 실행하면:
yarn docker:test
스크립트는 다음을 수행합니다.
  1. 테스트 명령어에 할당된 리소스를 제어하기 위해 제어 그룹(Control group, cgroup)을 설정합니다.
  1. 실행할 명령어를 설정합니다. 단순히 yarn test이며, 타이밍 정보를 비교하고 테스트 파일의 메모리 사용량을 확인하기 위해 --verbose--logHeapUsage 플래그를 사용합니다.
  1. 메모리 제한이 없는 상태로 cgroup을 구성합니다.
  1. 백그라운드에서 scripts/monitor-memory.sh 스크립트를 시작합니다. 이 스크립트는 cgroup의 memory.stat 파일에서 메모리 사용량 통계를 폴링합니다. 이 파일은 cgroup의 메모리 풋프린트를 다양한 통계로 세분화하여 보여줍니다.
  1. 마지막으로, 앞서 설정한 Jest 명령어를 실행합니다.
Jest가 실행되는 동안 scripts/monitor-memory.sh 스크립트는 다음 정보를 폴링합니다.
  1. 전체 페이지 폴트(Page faults) 횟수. 이는 일상적인 메모리 관리 작업입니다. 페이지 폴트는 약간의 오버헤드를 의미하지만 큰 속도 저하를 일으키지는 않습니다.
  1. 전체 메이저 페이지 폴트(Major page faults). 이는 대개 운영체제가 스왑 메모리에서 페이지를 읽어와야 했음을 나타냅니다. 메이저 페이지 폴트는 속도 저하를 일으킬 가능성이 큽니다.
  1. 페이지 스캔(Page scans). 이는 전반적인 메모리 사용량을 나타낼 수 있습니다. 시스템이 메모리를 회수하려고 시도할 때 페이지 스캔 횟수가 올라가는 경향이 있습니다.
  1. 페이지 스틸(Page steal). 시스템에 의해 회수된 메모리 페이지 수를 알려주며, 스래싱의 지표가 됩니다.
기본 설정으로 Jest 테스트 스위트를 실행했을 때의 결과는 다음과 같습니다.
yarn docker:test 실행 결과 스크린샷. 모든 테스트가 총 7.10초 만에 통과했으며, 메이저 페이지 폴트 0, 페이지 스캔 0, 페이지 회수 0을 기록했습니다.

정상적인 조건에서 테스트는 약 8초 만에 실행되었으며, 메모리 압박의 징후는 기록되지 않았습니다.

메모리가 제한된 환경에서 테스트 실행하기

MEMORY_LIMIT_MB 플래그를 사용하여 제어 그룹에 메모리 제한을 적용할 수 있습니다. 이전 실행에서 확인한 테스트 파일들의 메모리 사용량 중간 정도인 200MB로 제한을 설정하는 편리한 스크립트가 있습니다. Jest는 가능한 한 많은 워커를 실행하려 할 것이고, cgroup은 해당 워커들을 위한 가용 메모리가 빠르게 부족해질 것입니다. memory.stat에서 테스트 속도가 느려지고 메모리 압박 징후가 늘어나는 것을 볼 수 있을 것입니다.
다음을 실행하면 해당 플래그들이 설정됩니다.
yarn docker:test:constrained
결과는 다음과 같습니다.
yarn docker:test:constrained 실행 결과. 이 명령어는 타임아웃 테스트 실패를 발생시켰으며(강조 표시됨), 전체 테스트 실행 시간은 18.862초로 늘어났고, 642,166회의 페이지 폴트, 3,025,671회의 페이지 스캔, 918,041회의 페이지 회수가 발생했습니다.

테스트 실행 시간이 거의 3배나 늘어났고, DelayedContent 컴포넌트 테스트에서 타임아웃 오류가 발생한 것을 확인할 수 있습니다. 또한 memory.stat을 통해 메모리 압박의 증거도 확보했습니다. 이는 우리의 원래 가설을 뒷받침합니다. Jest 성능은 메모리가 제한된 환경에서 저하되며, 일부 테스트에서 타임아웃 오류를 일으킬 수 있습니다.

maxWorkers로 메모리 압박 해결하기

이제 --maxWorkers가 상황을 어떻게 바꾸는지 관찰해 봅시다. 동일한 200MB 메모리 제한 환경에서 --maxWorkers=2로 실행하는 스크립트를 사용합니다.
yarn docker:test:constrained:workers
결과는 다음과 같습니다.

테스트 실행 시간이 원래의 실행 시간에 가까워졌고, 통과할 확률이 더 높아 보입니다. 여전히 약간의 메모리 압박은 보이지만, 페이지 폴트 횟수가 이전보다 훨씬 적습니다.
직접 실행해 보면 가끔 타임아웃 실패가 발생할 수도 있습니다. 제 실험에서는 이전 명령어보다 훨씬 안정적이었습니다. maxWorkers나 메모리 제한을 조정해 보면 이러한 변수들이 결과와 어떻게 상호작용하는지 금방 감을 잡으실 수 있을 것입니다. 전반적으로 maxWorkers는 메모리 압박 문제를 방지하는 데 효과적일 수 있지만, 이는 균형의 문제입니다. 적절한 값은 가용 시스템 리소스와 테스트 스위트의 메모리 소비량에 따라 달라집니다.

스왑 메모리가 없는 상태에서 테스트 실행하기

자, 이제 메이저 페이지 폴트(스왑 메모리에서 읽기)가 Jest 테스트 스위트의 속도를 늦추고 불안정한 타임아웃 실패를 일으킨다는 설득력 있는 근거를 확보했습니다. 그렇다면 스왑 메모리가 아예 없는 CI 머신은 어떨까요? 제 경험상 스왑이 할당되지 않은 CI 머신에서도 불안정한 타임아웃이 발생하며, 메모리 부족 오류는 발생하지 않았습니다. 따라서 이런 시나리오에서 시스템이 느려지는 다른 이유가 분명히 있을 것입니다.
실험용 저장소에서 이 질문을 모델링해 볼 수 있습니다.
yarn docker:test:constrained:noswap
이 명령어는 다음을 수행합니다.
  1. 메모리 제한을 800MB로 약간 높게 설정합니다. 모든 테스트가 이론적으로 올바르게 실행될 수 있는 여유 공간을 주되, Jest 스위트가 대부분을 소비하여 운영체제의 메모리 회수 동작을 유도할 만큼 낮은 값입니다.
  1. cgroup의 memory.maxmemory.high로 변경합니다. 이는 제한에 도달했을 때 OOM으로 프로세스를 죽이는 대신 메모리 사용을 억제(Throttle)합니다.
  1. 또한 스왑 메모리 제한을 0 바이트로 설정하여 cgroup이 스왑 메모리를 절대 사용하지 못하게 합니다.
이 시나리오의 테스트 실행 결과는 다음과 같습니다.

여기서 메이저 페이지 폴트가 거의 발생하지 않는 것을 볼 수 있습니다. 프로세스가 스왑 메모리를 사용하지 않고 있습니다. 하지만 페이지 스캔 횟수는 원래의 메모리 제한 환경에서 보았던 것보다 두 자릿수나 더 높습니다. 그리고 테스트 스위트는 원래보다 거의 8배나 오래 걸려 타임아웃 오류를 발생시킵니다.
이는 Jest 성능 문제가 스왑 메모리 때문일 수도 있고, 어떤 경우에는 단순히 메모리 스래싱 때문일 수도 있음을 알려줍니다. 두 경우 모두 테스트 스위트의 속도를 늦추고 불안정한 타임아웃 오류를 일으킬 수 있습니다.

다시 maxWorkers로 해결하기

이 역시 --maxWorkers로 해결할 수 있습니다. 저장소의 편리한 스크립트를 사용하여 --maxWorkers=2로 동일한 명령을 실행해 봅니다.
yarn docker:test:constrained:noswap:workers
결과는 다음과 같습니다.
yarn docker:test:constrained:noswap:workers 실행 결과. 다시 7초의 실행 시간을 기록하며 테스트 스위트가 통과했고, 메이저 페이지 폴트는 단 1회 발생했습니다(페이지 회수를 위한 스캔은 없음).


요약

실험의 주요 결과를 요약한 표입니다.
시나리오
시간
메이저 페이지 폴트
페이지 스캔
결과
제한 없음
7s
0
0
✅ 통과
200MB 제한
19s
642,166
3,025,671
❌ 타임아웃
200MB + maxWorkers=2
9s
72,219
674,557
✅ 통과
800MB 소프트 제한, 스왑 없음
51s
2
16,740,650
❌ 타임아웃
800MB 소프트 제한, 스왑 없음 + maxWorkers=2
7s
1
0
✅ 통과
CI에서 Jest 타임아웃 불안정함을 겪고 있다면, 병렬화로 인한 메모리 압박이 원인일 가능성이 큽니다. Jest 워커가 메모리 리소스를 잡아먹으면서 CI 러너가 스왑 메모리를 사용하거나 가용 RAM에서 스래싱을 일으킬 수 있습니다. 이는 속도 저하를 유발하고 타임아웃 기반 단언문에서 간헐적인 실패를 일으킵니다. 적절한 maxWorkers 수를 설정하여 이를 해결할 수 있습니다.
이 문제는 테스트 스위트의 규모가 커질 때 발생하기 때문에 특히 더 고약합니다. 대규모 코드베이스에서 높은 테스트 커버리지를 유지하는 것에 대한 벌을 받는 것처럼 느껴지기도 하죠.
테스트 스위트에서 많은 Jest 테스트를 실행하려면 머신에 추가 메모리를 할당하거나, maxWorkers를 미세 조정하거나, 두 방법 사이의 균형을 찾아야 합니다. 저는 이 플래그를 --maxWorkers='50%'로 설정하여 성공적인 결과를 얻었지만, 여러분에게 가장 적합한 설정은 직접 실험을 통해 찾아야 할 것입니다. 이 글이 여러분만의 구체적인 조사를 진행하는 데 도움이 되는 도구가 되었기를 바랍니다.
물론 React Native CI/CD에 도움이 필요하시다면 Infinite Red에 문의해 주세요. 저희는 단순히 테스트 문제를 해결하는 데 그치지 않고, 근본적인 수준에서 문제를 이해할 수 있도록 함께 노력하겠습니다.
0
2

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글