사내 서비스 개선 보고서 중 Node.js 기반으로 구동하고 있는 애플리케이션의 안정화에 대한 내용을 보게 되었다.
해당 서비스는 끊김 현상이 지속적으로 있었고 이는 곧 연결 재시도 폭주로 인해 장애 전파, 트래픽 유실 등의 문제를 야기하였다. 이 방법을 해결하기 위해서 스케일업, 스케일아웃을 진행하였으나 고질적인 프로세스 중단 문제는 해결되지 않았고 민감도를 낮추지 못했다.
이 보고서에서는 Node.js 런타임 상의 메모리 사용에 대한 내용을 다루어 해결하려는 시도가 있었으며 이 과정에서 "--max-old-space-size 옵션"에 대한 내용이 등장한다.
단순히 "old space가 뭘까? 그럼 new space도 있나? 껄껄 (진짜 있었다.)" 하는 궁금증과 Node.js를 주로 다루고 있는 나로서 놓치고 있었던 배경 지식을 다시 점검하기 위해 Node.js의 메모리 사용법 중 중요한 Node.js의 가비지 컬렉션의 동작에 대해 알아보려고 한다.
가비지 컬렉션에 대해 다루기 전에 V8엔진의 메모리 사용에 대한 간단한 내용을 안 짚고 넘어갈 수 없을 것 같다.
그래서 해당 글은 Node.js V8엔진의 메모리 구조와 간단한 동작에 대한 내용을, 다음 글에서 가비지 컬렉션에 대한 내용을 본격 다루겠다.
# V8 엔진
V8 엔진은 Google에서 개발한 오픈소스 JavaScript 및 WebAssembly 엔진으로, 다음과 같은 주요 역할을 수행하면서 고성능 실행과 메모리 관리를 가능하게 한다.
## V8 엔진의 주요 기능
1. JavaSCript와 WebAssembly 코드 컴파일 및 실행
2. 메모리 할당 및 관리
3. 최적화
4. 추상 구문 트리(AST) 생성
5. 크로스 플랫폼 지원 등
# V8 엔진의 메모리 구조
JavaScript는 단일 스레도 기반 언어로, V8엔진도 각 JavaScript 컨텍스트마다 단일 프로세스를 사용한다. 만약 다중으로 서비스 워커를 사용한다면, 각 워커마다 새로운 V8 프로세스가 생성된다. 실행 중인 프로그램은 상항 V8 프로세스에 할당된 메모리 형태 (아래 이미지)로 표현이 되며, 이를 Resident Set이라고 한다. 이 메모리 구조는 아래와 같이 구분되며, 가비지 컬렉션 주제를 다룰 때는 크게 New Space (Yong generation)와 Old Space (Old generation)을 다룬다.
그전에 Stack Memory와 Heep Memory로 나누어 역할과 구성에 대해 설명하겠다.
## Heep Memory
Heep Memory는 가장 큰 메모리 영역으로, 동적으로 데이터를 저장하며 가비지 컬렉션이 발생하는 곳이다. Heep의 세부 영역들은 아래와 같다.
[ New Space (New Generation) ]
- 새로운 객체가 저장되는 공간으로, 대부분의 객체가 짧은 수명을 가짐
- 크기가 작은 공간을 가지며 두 개의 빈 공간 (semi-space)으로 나뉨
- Minore GC(Scavenger)에 의해 관리됨 (아래에서 다룰 거임)
- 크기는 V8 플래그 "--min_semi_space_size"와 "--max_semi_space_size"로 조정할 수 있음
[ Old Space (Old Generation) ]
- New Space에서 두 번의 Minor GC를 거친 후에도 살아남은 객체가 저장되는 공간
- Major GC(Mark-Sweep 및 Mark-Compact)에 의해 관리됨
- 크기는 V8 플래그 "--initial_old_space_size"와 "--max_old_space_size"로 조정할 수 있음
- Old Space는 다시 두 가지로 나뉨
- Old Pointer Space: 다른 객체를 참조하는 포인터를 포함한 객체가 저장되는 장소.
- Old Data Space: 데이터를 포함하지만 다른 객체를 참조하지 않는 객체가 저장됨. 예를 들어, 문자열, 숫자, 또는 배열 등이 여기에 해당됨
[ Large Object Space ]
- 크기가 다른 공간의 제한을 초과하는 큰 객체가 저장됩니다
- 각 객체는 독립된 mmap 메모리 영역에 저장됩니다.
- 가비지 컬렉터는 Large Object Space의 객체를 이동시키지 않습니다.
[ Code Space ]
- Just In Time(JIT) 컴파일러가 컴파일된 코드 블록을 저장하는 공간입니다.
- 실행 가능한 메모리를 가지는 유일한 공간입니다. (단, Large Object Space에 저장된 코드도 실행 가능합니다.)
[ Cell Space, Property Cell Space, Map Space ]
- 각각 Cell, PropertyCell, Map 객체를 저장하는 공간입니다.
- 동일한 크기의 객체만 저장되며, 이를 통해 가비지 컬렉션이 간소화됩니다.
## Stack Memory
Stack Memory는 V8 프로세스마다 하나씩 존재하며, 정적 데이터를 저장한다. 여기에는 함수/메서드 프레임, 기본값(primitive values), 객체에 대한 포인터가 저장되며, 운영 체제에 의해 관리된다. Stack Memory의 크기는 V8 플래그 `--stack_size`를 통해 설정할 수 있습니다.
# V8 엔진의 메모리 사용 (Stack Memory와 Heep Memory의 상태 변화)
위에서 메모리의 구조를 간단하게 확인했다.
사실 동적 데이터니, 정적 데이터니, 함수/메서드 프레임이니, 포인터니 등등 크게 와닿지 않는다.
그래서 간단한 JavaScript 코드를 살펴보면서 Stack과 Heep이 어떻게 사용되는지 확인하려고 한다.
class Employee {
constructor(name, salary, sales) {
this.name = name;
this.salary = salary;
this.sales = sales;
}
}
const BONUS_PERCENTAGE = 10;
function getBonusPercentage(salary) {
const percentage = (salary * BONUS_PERCENTAGE) / 100;
return percentage;
}
function findEmployeeBonus(salary, noOfSales) {
const bonusPercentage = getBonusPercentage(salary);
const bonus = bonusPercentage * noOfSales;
return bonus;
}
let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);
위 코드는 JavaScript의 아주 간단한 코드이다. 아래 코드를 전차적 확인하면서 각각 코드가 어떻게 메모리의 상태 변화를 발생시키는지 아래 타임라인 테이블을 확인하여 확인할 수 있다.
실행 순서 | 코드 동작 설명 | Heep Memory 상태 변화 | Stack Memory 상태 변화 |
1 | Class와 Constants 정의 - `Employee` 클래스가 정의 - `BONUS_PERCENTAGE` 라는 상수가 전역으로 설정 - `getBonusPercentage`와 `findEmployeeBonus` 함수가 전역에 정의 |
- `Employee` 클래스, `getBonusPercentage` 함수, `findEmployeeBonus` 함수가 `Heap Memory`에 저장됨 | - 글로벌 실행 컨텍스트 생성 - `Employee`, `getBonusPercentage`, `findEmployeeBonus`가 글로벌 컨텍스트의 참조로 등록 |
2 | `let john = new Employee(...)` 실행 - `john`이라는 객체가 생성되며, `Employee` 클래스의 `constructor` 호출. - `name`, `salary`, `sales` 값이 `this`에 바인딩 |
- `john` 객체가 `Heap Memory`에 생성되고, `name`, `salary`, `sales` 속성 초기화 | - `constructor` 호출 시 새로운 함수 실행 컨텍스트가 `Stack Memory` 에 push - `this`, `name`, `salary`, `sales` 등이 `constructor`의 로컬 변수로 `Stack Memory`에 생성 - `constructor` 실행이 종료되면, 해당 컨텍스트는 pop |
3 | `john.bonus = findEmployeeBonus(...) 실행` - `findEmploueeBouns` 함수 호출 - 내부에서 `getBonusPercentage` 함수가 호출되고, 반환된 결과로 보너스를 계산 |
- 변화 없음 (이미 정의된 함수와 john 객체를 재사용) | - `findEmployeeBonus` 함수 실행 컨텍스트가 `Stack Memory`에 push - `salary`, `noOfSales`, `bonusPercentage`, `bonus`가 로컬 변수로 `Stack Memory`에 저장 - `findEmployeeBonus` 실행 중 `getBonusPercentage`가 호출되면, 새로운 실행 컨텍스트가 `Stack Memory`에 추가 - `getBonusPercentage` 실행이 종료되면 컨텍스트는 pop되고, 반환 값은 `findEmployeeBonus`의 `bonusPercentage` 계산에 사용 - 최종적으로 `findEmployeeBonus` 실행이 종료되면 해당 컨텍스트도 pop |
4 | `console.log(john.bonus)` 실행 - `john.bornus` 값이 출력 |
- 변화 없음 | - `console.log`의 실행 컨텍스트가 push되었다가, 실행 종료 후 pop |
흐름을 글로 작성하다 보니 이해하는데 어려움이 있을 수 있다. 아래 링크에는 위 내용을 이해하기 쉽게 애니메이션으로 작성해 뒀다. 참고하면 좋을 것 같다.
참고 - https://deepu.tech/memory-management-in-v8/
위의 내용을 이해했다면, 다시 Stack Memory와 Heep Memory를 예시를 바탕으로 정리해 보자.
# 요약
Stack Memory는 함수 호출과 실행 컨텍스트를 관리하는 공간으로, 전역 스코프는 "Global frame"이라는 구조로 스택에 저장된다. 함수가 호출될 때마다 Stack에 새로운 프레임 블록이 추가되며, 함수 가 반환되면 해당 블록은 Stack에서 제거된다.
각 함수의 프레임 블록에 저장되면, 숫자와 문자열 같은 원시 타입 데이터도 Stack에 직접 저장되는 것이다.
반면, Heep Memory는 객체와 함수 같은 동적 데이터를 관리한다. 객체는 Heep에 생성되며, Stack에는 Heep 데이터를 가리키는 포인터가 저장된다. 함수 역시 객체로 간주되므로 힙에서 관리된다. Heep에 저장된 데이터는 Stack 포인터를 통해 참조되며, Stack에서 해당 포인터가 제거되면 Heep 데이터는 고아 객체가 된다.
Stack은 위에서 언급했듯이 운영체제가 자동으로 관리하기 때문에 개발자가 신경 쓸 필요가 거의 없다.
그러나 Heep은 동적 데이터를 저장하므로 메모리가 점차 증가하거나 단편화를 이루게 되면 성능 저하를 야기한다. 이를 해결하기 위해 JavaScript의 V8 엔진은 가비지 컬렉션을 사용해 메모리를 정리한다.
원래 V8 엔진과 가비지 컬렉션 내용을 한 글로 마무리하려고 했으나 이해력의 부족과 귀찮음이 더해져 공부하기와 남기기를 둘로 나누기로 하였다. (사실 미루기)
Node.js를 주 무기로 사용하고 있었으면서 공부 중 참고 자료들을 보며 신기해하고 있는 나의 모습을 보니 좀 이상했다. 이러한 부분들은 미리 관심을 가지고 알아두는 것이 좋지 않을까 생각하였다.
언젠가는 무조건 메모리 leak 현상을 직접 마주하게 될 것이고 이 전에 위와 같은 내용을 알고 모르고의 차이에서 대응 방법과 시간이 많이 달라지지 않을까 생각이 든다.
다음글
참고
https://v8.dev/blog/trash-talk
https://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
'Backend > Node.js' 카테고리의 다른 글
[Node.js] Node.js의 가비지 컬렉션 (Garbage Collection, GC) 2 _ Minor GC, Major GC 와 알고리즘 (1) | 2024.12.09 |
---|---|
[Node.js] Util 모듈의 promisify() 함수 사용법 (0) | 2023.02.21 |
[Node.js] RxJS란 (0) | 2023.02.18 |
[Node.js] 스트림이란 (Stream) ② (0) | 2023.02.18 |
[Node.js] 스트림이란 (Stream) ① (0) | 2023.02.18 |