JavaScript heap out of memory
안녕하세요, 에이슬립에서 Quality Engineering Lead를 담당 중인 지원입니다. 최근 Allure Report 개선 간에 24시간 이상 수면 측정 자동화 테스트 환경을 구축하면서 JavaScript heap out of memory 문제를 해결했고 이번 글에서 그 과정을 공유하려고 합니다. 글은 먼저 작업 배경과 환경을 소개하고 문제가 발생한 상황을 살펴본 뒤 원인을 분석하고 해결 방법을 찾는 과정을 공유하는 순서로 진행하겠습니다.
에이슬립에서는 E2E 테스트 레이어의 수면 측정 앱의 품질을 보증하기 위해 WebdriverIO와 Appium 기반의 대규모 모바일 자동화 테스트를 운영하고 있습니다. 특히 수면 측정 기능은 실제 사용자가 자는 동안(6~8시간) 동작해야 하기 때문에 장시간 안정성 검증이 필수입니다. 하지만 단순히 평균 수면 시간만 검증해서는 충분하지 않았습니다. 사용자가 개발팀이 의도한 패턴을 벗어나 10시간, 12시간, 혹은 24시간 이상 측정을 종료하지 않고 유지하는 경우가 발생할 수 있기 때문입니다. 이러한 예외적인 상황에서도 앱이 강제 종료되거나 오작동하지 않도록 방어 코드를 검증하고, 잠재적인 문제를 사전에 예방해야 했습니다. 그리고 매일 E2E 테스트 레이어에서 진행되는 모바일 전수검사 모니터링 기능 테스트 코드를 통해 검증하기 위한 자동화 테스트 개발이 필요했습니다.
테스트 프레임워크: WebdriverIO v9 + Appium 3.x
언어: JavaScript (ES6 modules)
Node.js: v22.21.1
@wdio/cli: v9.20.1
@wdio/appium-service: v9.20.1
appium: v3.1.1
appium-uiautomator2-driver: v6.3.0
appium-xcuitest-driver: v10.8.1
테스트 대상: Android iOS 실제 디바이스
테스트 영상 녹화 시스템: Appium의 startRecordingScreen API 활용하여 자체 구현한 VideoRecorder 클래스
자동화 테스트에서 실패 원인을 빠르게 파악하는 것이 중요한데요. 특히 장시간 테스트는 재현이 어렵기 때문에 전체 과정을 영상으로 기록하는 블랙박스 시스템을 구축했습니다. Appium은 모바일 화면 녹화 기능을 제공하지만 30분 제한이 있습니다. 클라우드 인프라 또한 수면측정 SDK의 테스트 요구사항을 대응하기 위한 옵션이 제공되지 않은 상태였기에 클라우드 인프라에서 온프레미스 환경으로 전환한 상태였습니다.
이를 해결하기 위해 28분마다 녹화를 자동으로 갱신하는 VideoRecorder 클래스를 만들었습니다. 모든 base64 데이터를 메모리에 저장하고 테스트 종료 시점에 한 번에 모든 파일 저장하고 Allure Report에 첨부하는 방식입니다. 이렇게 구현한 녹화 시스템은 단시간 테스트(10시간 미만)에서는 문제없이 작동했습니다.
문제 발생
사용자가 개발팀이 의도한 패턴을 벗어나 10시간, 12시간, 혹은 24시간 이상 측정을 종료하지 않고 유지하는 예외적인 상황에서도 앱이 강제 종료되거나 오작동하지 않도록 방어 코드를 검증하고, 잠재적인 문제를 사전에 예방하기 위해 24시간 이상 측정 테스트를 구현하고 코드 검증 간에 예상치 못한 문제가 발생했습니다.
테스트는 19시간 40분 동안 정상 실행되다가 42번째 세그먼트를 저장하는 시점에 메모리 부족으로 크래시 되었고 모든 녹화 영상이 저장되지 않았습니다.
JavaScript heap out of memory 에러가 있긴 했지만 WebdriverIO가 Selenium/Appium을 High Level로 추상화한 프레임워크다 보니 단순히 프레임워크 레벨의 문제로 판단하고 처음에는 타임아웃 문제를 의심했고 WebdriverIO와 Appium에서의 세 가지 레벨의 타임아웃 설정을 검토했습니다.
1. newCommandTimeout (Appium 서버 레벨)
Appium 서버가 클라이언트로부터 명령을 받지 못하면 세션을 자동 종료합니다. 마지막 command 이후 타이머가 시작되고 새로운 command가 오면 리셋됩니다. 만약 driver.pause()에서 새로운 command가 주기적으로 발생하도록 구현하지 않고 실행 시간이 해당 값을 초과할 경우 세션이 종료될 수 있습니다.
2. connectionRetryTimeout (WebdriverIO 클라이언트 레벨)
WebdriverIO가 Appium 서버에 명령을 보냈는데 응답이 없을 때 재시도하는 최대 대기 시간입니다. HTTP 요청/응답 타임아웃으로 네트워크 지연이나 서버 부하 시 재시도합니다.
3. mochaOpts.timeout (테스트 프레임워크 레벨)
Mocha가 단일 테스트(it 블록) 실행에 허용하는 최대 시간입니다. 테스트 시작 시 타이머가 시작되고 이 시간을 초과하면 강제 종료합니다.
세 가지 타임아웃 중 하나라도 24시간보다 짧으면 테스트가 실패합니다. 대규모 병렬 테스팅 환경으로 Appium Device Farm 서버 또는 클라우드 인프라를 활용하지 않고 로컬 자동화 메인 머신에서 실행하는 서버를 사용하고 있기 때문에 타임아웃에 큰 제한이 없었고 관련 설정에 더 이상 신경 쓰지 않기 위해 세 가지 타임아웃을 모두 30시간으로 증가시켰습니다.
타임아웃을 증가시켰음에도 동일한 메모리 오류가 19시간 40분 시점에 발생했습니다. 타임아웃은 문제의 원인이 아니었습니다.
에러 로그를 다시 분석한 결과 V8 엔진의 힙 메모리가 약 4GB에 도달했을 때 크래시가 발생한 것을 확인했습니다.
스택 트레이스를 보니 JSON 파싱 중에 메모리 부족이 발생했습니다.
V8 엔진이 약 4.1GB의 힙 메모리를 사용하다가 한계에 도달했습니다.
Appium 세션이 실행 중인 터미널에서 Node.js의 V8 힙 메모리 제한을 확인했습니다.
이 명령어는 Node.js의 V8 엔진 힙 메모리 제한을 MB 단위로 출력합니다. 결과는 4144MB였습니다. 에러 로그의 4132.4MB와 거의 일치하는 수치입니다.
이 문제를 해결하기 위해 Node.js의 메모리 관리에 대한 자료를 찾아보던 중 JavaScript heap out of memory 오류의 일반적인 원인을 정리한 글을 발견했습니다. 해당 글에서 언급하는 일반적인 원인은 다음과 같습니다. 장시간 녹화 시스템을 위해 설계된 VideoRecorder 클래스는 결과적으로 모든 비디오 세그먼트를 메모리에 로드하고 있었습니다.
Processing Large Data: 대용량 파일이나 데이터셋을 전체를 메모리에 로드하는 경우
Memory Leaks: 필요 이상으로 오래 객체를 메모리에 보관하여 가비지 컬렉션이 비효율적으로 작동하는 경우
Inefficient Algorithms: 대량의 배열을 처리하는 루프 같은 특정 작업이 기본 설정보다 많은 메모리를 요구하는 경우
Multiple Concurrent Operations: 상당한 메모리를 요구하는 많은 작업을 병렬로 실행하는 경우
기존 VideoRecorder 클래스는 모든 비디오 세그먼트를 메모리에 보관하고 있었습니다. 이로 인해 특정 시점에 JavaScript heap out of memory가 발생하게 되었습니다.
당시 자동화 테스트는 대부분 10시간 내에 완료되었고 이 정도 시간이면 메모리에 보관하는 것이 효율적이었습니다.
파일 I/O 최소화
로직 단순화
파일명 생성 효율성
Allure 첨부 간편
임시 파일 관리 불필요
특히 측정 기능 테스트 시나리오가 아닌 다른 테스트 스위트에서는 대부분 2시간 이내로 완료되기 때문에 1~2시간 테스트의 메모리 사용량에도 문제가 없을 거라 판단했습니다.
1~2시간 테스트의 메모리 사용량
2시간 = 120분 ÷ 28분 = 4~5개 세그먼트
5개 × 50MB = 250MB
250MB는 Node.js 애플리케이션에서 충분히 감당할 수 있는 수준이었습니다. 실제로 1~2시간 테스트에서는 메모리 문제가 발생하지 않았습니다.
초기 설계 당시 "이 정도면 충분하다"라고 생각했던 것이 문제였습니다. "테스트 시간이 무한정 길어질 수 있다"는 가능성을 크게 고려하지 않았고 24시간 측정 시나리오도 문제없이 동작하지 않을까라는 추측으로 구현되었습니다. 이로 인해 메모리 한계(4.1GB)에 도달하는 순간 크래시가 발생했습니다.
실패 시점의 메모리 사용량을 계산하면 다음과 같습니다.
계산 결과가 실제 힙 제한(4.1GB)과 비슷한 수치가 나왔습니다.
JavaScript heap out of memory 문제를 해결하기 위해 관련 기술 아티클과 레퍼런스를 조사했고
VideoRecorder 클래스 최적화 방식을 선택했습니다. 메모리 제한을 늘리는 방법은 적용하기 가장 쉽지만 데이터 양이 더 늘어나면 언젠가는 다시 한계에 부딪힐 수밖에 없는 임시방편이라고 판단했습니다. 24시간을 넘어 48시간, 72시간 테스트로 확장될 가능성까지 고려한다면 코드를 메모리 효율적으로 재설계하는 것이 근본적인 해결책이었습니다. 따라서 비디오 세그먼트를 메모리에 쌓아두지 않고 받는 즉시 디스크에 저장하는 방식으로 구조를 변경했습니다.
시간에 따라 계속 생성되는 데이터를 메모리에 누적하지 말고 디스크에 저장하도록 처리했습니다. 일반적인 백엔드 아키텍처에서 디스크 I/O는 성능 병목이 되기 때문에 권장되지 않는 방법입니다. 하지만 Appium 자동화 테스트 환경은 대규모 트래픽을 처리하는 서버와는 요구사항이 다릅니다. 모든 비디오 세그먼트를 나중에 리포트에 첨부해야 하므로 버릴 수 없고 메모리에 누적하면 한계 시점에 크래시가 발생합니다. 성능보다 안정성이 우선인 장시간 자동화 테스트 환경에서는 디스크 저장도 좋은 방법이라 생각했습니다.
기존 방식에서는 저장 시점이 테스트 종료 시 일괄 저장하는 방식이었다면 세그먼트 생성 즉시 저장하도록 처리했습니다. 또한 메모리 절약을 위해 저장 데이터를 base64 비디오 데이터에서 파일 경로만 저장하도록 했고 그 결과 장시간 측정 상황에서도 메모리 사용량에 문제가 없도록 개선되었습니다.
이로 인해 장시간 측정 테스트 디버깅 환경도 잘 갖춰지게 되었습니다.
이번 작업을 통해 Node.js의 메모리 관리에 대해 배웠습니다. 특히 모바일 테스트 자동화처럼 모든 데이터를 보관해야 하지만 즉시 사용하지 않는 경우 메모리에 누적하지 말고 디스크를 활용하는 것이 효과적이라는 것을 알게 되었습니다. 일반적으로 디스크 I/O는 성능 병목이 되지만 모바일 장시간 자동화 테스트의 디버깅 환경을 위해 데이터를 나중에 사용하고 다음 테스트 진행 전에 제거하는 상황에서는 메모리 부족 문제를 해결하는 방법 중 하나임을 배웠습니다. 이 글이 장시간 테스트 자동화 환경을 구축하시는 분들께 도움이 되었으면 좋겠습니다.
감사합니다.