
poiemaweb - 자바스크립트 엔진
toast ui - 자바스크립트의 가비지 컬렉션
JavaScript Info - 원시값의 메서드
자바스크립트의 콜스택, 메모리 힙 구조 - charming-kyu
JavaScript Info - 가비지 컬렉션
학습 목표
- 데이터 타입
- Wrapper Objects
- 콜 스택과 메모리 힙
- 메모리 생명주기를 통한 메모리 할당과 해제
- 힙과 스택을 통해 메모리 할당
- 가비지 컬렉션을 통해 메모리 해제
자바스크립트의 모든 값은 데이터 타입(data type)을 갖는다.
JavaScript에서, 원시 값(primitive, 또는 원시 자료형)이란 객체가 아니면서 메서드 또는 속성도 가지지 않는 데이터를 말한다. 원시 값에는 7가지의 종류가 있다.
| 데이터 타입 | 설명 |
|---|---|
| 문자열(string) | 문자열 |
| 숫자(number) | 숫자, 정수와 실수 구분없이 하나의 숫자 타입만 존재 |
| Bigint | BigInt 는 Number 원시 값이 안정적으로 나타낼 수 있는 최대치인 2^53 - 1보다 큰 정수를 표현할 수 있는 내장 객체 |
| 불리언(boolean) | 논리적 참(true)과 거짓(false) |
| undefined | var 키워드로 선언된 변수에 암묵적으로 할당되는 값 |
| null | 값이 없다는 것을 의도적으로 명시할 때 사용하는 값 |
| Symbol | ES6에서 추가된 7번재 타입 |
let string = 'hello javascript';
string = 'test code'; // 재할당 가능
string[0] = 'T'; // 원시 타입이기 때문에 Test code로 변경이 불가능하다. 조용히 실패함(strict mode에서도 오류가 발생하지 않음)
console.log(string); // test code
let stringCopy = string; // string 값이 stringCopy에 그대로 복사되어 'test code'가 할당된다.
console.log(stringCopy); // test code
string = 100;
console.log(string); // 100
console.log(stringCopy); // test code
| 구분 | 설명 |
|---|---|
| 객체타입 | 객체, 함수, 배열 등 |
메모리 구조에서 자세하게 살펴보도록 하자.
자바스크립트는 원시값을 마치 객체처럼 다룰 수 있게 해준다.
원시값에서도 객체에서처럼 메서드를 호출할 수 있다.
자바스크립트는 날짜, HTML element 등을 다룰 수 있게 해주는 다양한 내장 객체를 제공하고 이 객체들은 고유한 프로퍼티와 메서드를 가진다.
하지만, 이런 기능을 사용하면 시스템 자원이 많이 소모된다는 단점이 있다.
객체는 원시값보다 "무겁고", 내부 구조를 유지하기 위해 추가 자원을 사용하기 때문이다.
자바스크립트 창안자(creator)는 다음과 같은 모순적인 상황을 해결해야만 했다.
"래퍼 객체"는 원시 타입에 따라 종류가 다양하다. 각 래퍼 객체는 원시 자료형의 이름을 그대로 차용해, String, Number, Boolean, Symbol라고 부른다. 래퍼 객체마다 제공하는 메서드 역시 다르다.
인수로 받은 문자열의 모든 글자를 대문자로 바꿔주는 메서드 str.toUpperCase()를 예로 살펴보기
// 원시 타입
let primitiveString = "Hello";
// 원시 타입에 메서드 호출 시 내부적으로 발생하는 과정
primitiveString.toUpperCase(); // HELLO
primitiveString.toUpperCase()가 호출될 때 내부에서 일어나는 일toUpperCase()와 같은 유용한 메서드를 가지고 있다.// 원시 타입에 대한 풍부한 기능 제공
const str = "hello world";
// 문자열 조작 메서드
console.log(str.toUpperCase()); // "HELLO WORLD"
console.log(str.slice(0, 5)); // "hello"
console.log(str.split(" ")); // ["hello", "world"]
// 숫자 형식화
const num = 12345.6789;
console.log(num.toLocaleString()); // "12,345.679"
console.log(num.toExponential(2)); // "1.23e+4"
// 다양한 타입 변환 메서드 제공
const num = 123;
console.log(String(num)); // "123"
console.log(Boolean(num)); // true
const str = "456";
console.log(Number(str)); // 456
console.log(parseInt(str, 10)); // 456
// 프로토타입을 통한 기능 확장
String.prototype.reverse = function() {
return this.split('').reverse().join('');
};
const str = "hello";
console.log(str.reverse()); // "olleh"
// 불필요한 래퍼 객체 생성은 성능에 영향을 줄 수 있음
function badPerformance() {
let str = "hello";
for (let i = 0; i < 1000000; i++) {
// 매 반복마다 새로운 래퍼 객체 생성
str = new String(str).toString();
}
}
// 더 나은 방법
function goodPerformance() {
let str = "hello";
for (let i = 0; i < 1000000; i++) {
str = str.toString();
}
}
// 원시 타입과 래퍼 객체의 비교
const str1 = "hello";
const str2 = new String("hello");
console.log(typeof str1); // "string"
console.log(typeof str2); // "object"
console.log(str1 === str2); // false
console.log(str1 == str2); // true
// instanceof 연산자의 혼란
console.log("hello" instanceof String); // false
console.log(new String("hello") instanceof String); // true
// 명시적 래퍼 객체 생성의 문제
const numbers = [];
for (let i = 0; i < 1000000; i++) {
// 불필요한 메모리 사용
numbers.push(new Number(i));
}
// 더 나은 방법
const betterNumbers = [];
for (let i = 0; i < 1000000; i++) {
betterNumbers.push(i);
}
// 권장되는 사용법
const str = "hello";
console.log(str.toUpperCase());
const num = 123.456;
console.log(num.toFixed(2));
// 피해야 할 사용법
const strObj = new String("hello");
const numObj = new Number(123.456);
// 권장되는 타입 변환,
const num = Number("123"); // 123
const str = String(123); // "123"
const bool = Boolean(1); // true
// 피해야 할 타입 변환
console.log(typeof 0); // number
const num = new Number("123"); // Number {123}
const str = new String(123); // String {'123'}
const bool = new Boolean(1); // Boolean {true}
let zero = new Number(0);
if (zero) { // 변수 zero는 객체이므로, 조건문이 참이 된다.
console.log("zero가 참이라는 것에 동의하시나요?!");
}
두 자료형에 속한 값의 프로퍼티에 접근하려 하면 에러가 발생한다.
alert(null.test); // error
원시 타입 데이터가 저장되는 공간이다.
고정된 메모리를 할당하기 때문에 정적 메모리 할당이라고도 한다.
소스코드(전역 코드나 함수 코드 등) 평가 과정에서 생성된 실행 컨텍스트(Execution Context)가 추가되고 제거되는 스택 자료구조인 실행 컨택스트 스택이 바로 콜 스택이다.
함수를 호출하면 함수 실행 컨텍스트가 순차적으로 콜 스택에 푸시되어 순차적으로 실행된다.
자바스크립트 엔진은 단 하나의 콜 스택을 사용하기 때문에 최상위 실행 컨텍스트(실행 중인 실행 컨텍스트)가 종료되어 콜 스택에서 제거되기 전까지는 다른 어떤 태스크도 실행되지 않는다.
힙은 객체가 저장되는 메모리 공간이다.
참조값을 필요한 만큼 많은 메모리를 할당한다. 동적 메모리할당이라고 한다.
콜 스택의 요소인 실행 컨텍스트는 힙에 저장된 객체를 참조한다.
메모리에 값을 저장하려면 먼저 값을 저장할 메모리 공간의 크기를 결정해야 한다.
객체는 원시 값과는 달리 크기가 정해져 있지 않으므로 할당해야 할 메모리 공간의 크기를 런타임에 결정(동적 할당)해야 한다.
따라서 객체가 저장되는 메모리 공간인 힙은 구조화 되어있지 않다는 특징이 있다.

let a = 100;
let b = 200;

a = 200;

b = 500;


더이상 참조되지 않는 데이터는 가비지 컬렉터에 의해 적절한 시점에 메모리에서 해제된다.
let arr = [];

arr 변수를 선언하고 [ ] 빈 배열 참조 타입을 할당했을 때 메모리에서 일어나는 일
일반적으론 가능한 한 const를 사용해야 하며 변수가 변경될거란 사실을 알 때만 let을 사용해야 된다.
변경은 값(value)의 변경이 아니라 메모리 주소의 변경을 의미한다.
// 1. 참조가 변경되지 않는 객체
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
};
// 2. 배열
const items = ['item1', 'item2'];
// 3. 함수 표현식
const calculateTotal = (items) => {
return items.reduce((sum, item) => sum + item.price, 0);
};
// 4. 모듈 imports
const React = require('react');
// 1. 반복문의 카운터
for (let i = 0; i < array.length; i++) {
// ...
}
// 2. 누적 값
let sum = 0;
array.forEach(item => {
sum += item.value;
});
// 3. 상태 변경이 필요한 변수
let isLoading = true;
fetchData().then(() => {
isLoading = false;
});
// 4. 재할당이 필요한 참조
let currentUser = null;
function updateUser(user) {
currentUser = user;
}
// const: 할당 후 메모리 위치 고정
const fixedArray = [1, 2, 3]; // 메모리 위치 변경 불가
// let: 재할당 시 새로운 메모리 위치 사용 가능
let dynamicArray = [1, 2, 3]; // 초기 메모리 위치
dynamicArray = [4, 5, 6]; // 새로운 메모리 위치
function example() {
// const: 스코프를 벗어나면 즉시 GC 대상
const tempData = { large: new Array(10000) };
// let: 재할당으로 인한 이전 값도 GC 대상
let data = { large: new Array(10000) };
data = null; // 이전 데이터는 GC 대상이 됨
}
// 좋은 예시
const STATIC_CONFIG = {
maxRetries: 3,
timeout: 5000
};
let currentState = 'idle';
// 피해야 할 예시
let config = { // const를 사용하는 것이 더 적절
maxRetries: 3,
timeout: 5000
};
const state = 'idle'; // 변경이 필요하다면 let을 사용하는 것이 더 적절
// ✅ 메모리 누수 방지를 위한 올바른 사용
function createCache() {
const cache = new Map(); // 참조가 변경되지 않으므로 const
let size = 0; // 값이 변경되므로 let
return {
add(key, value) {
size++;
cache.set(key, value);
},
clear() {
size = 0;
cache.clear();
}
};
}
SessionStack의 블로그 글에 따르면,
메모리 관리의 효율성을 위해서는 변수의 생명주기와 스코프를 명확히 하는 것이 중요하다.
const를 사용하면 개발자의 의도를 더 명확히 표현할 수 있고, 예기치 않은 재할당을 방지할 수 있다.
결론
이러한 접근은 코드의 가독성을 높이고 메모리 관리를 더 예측 가능하게 만든다.
쓰레기 수집(garbage collection 가비지 컬렉션, GC)은 메모리 관리 기법 중의 하나로, 프로그램이 동적으로 할당했던 메모리 영역 중에서 필요없게 된 영역을 해제하는 기능이다.
필요없는 메모리를 해제하는 알고리즘을 만들기 어려운데 이러한 메모리 해제 방식을 최대한 구현한 것이 Reference Counting 방식이다.
Reference Counting과 Mark and Sweep에 관한 정리가 잘 된글이다.
1 const referenceCountExam = () => {
2 let a = { name: 'hoho' };
3 let b = {};
4
5 a = [];
6 b = a;
7 }
2 Line에서 지역 변수 a에 { name: 'hoho' } 객체가 할당된다. 이때 { name: 'hoho' }에 대한 Reference Count는 1이다.
3 Line에서 지역 변수 b에 {} 객체가 할당된다. 이때 {}에 대한 Reference Count는 1이다.

5 Line에서 a에 새로운 배열 []을 할당한다. 이때 { name: 'hoho' }의 Reference Count는 0이 된다.

6 Line에서 b에 a를 재할당 하면서 []에 대한 Reference Count는 1이 더 증가하여 2가 되고, 이와 동시에 {}는 Reference Count가 0이 된다.
프로그램이 끝나는 시점(가비지 컬렉션(GC)은 특정 시점이나 조건에 따라 자동으로 실행된다.)에서 메모리에 선언되었던 참조 값들중 Reference Count가 1 이상인 것은 []이고, 나머지는 모두 Reference Count가 0이 된다.

Reference Count가 0인 객체는 Garbage Collection의 대상이 된다.
2개 이상의 객체가 서로를 참조하는 경우가 있는데 이러한 경우를 순환 참조라고 부른다.
1 const referenceCountExam = () => {
2 let a = { name: 'hoho' };
3 let b = {};
4
5 // 순환 참조 생성
6 a.otherObj = b;
7 b.otherObj = a;
8
9 // 참조 해제
10 a = null;
11 b = null;
12 // 순환 참조된 객체들은 GC 대상이 됨
13 }
2, 3 Line에서 지역 변수 a, b에는 각각 { name: 'hoho' }, {}가 할당되어
{ name: 'hoho' }, {} 모두 Reference Count가 1이 된다.

6 Line에서 a의 otherObj라는 키에 { name: 'hoho' }를 할당하고,
7 Line에서 b의 ohterObj라는 키에 {}를 할당하면서 { name: 'hoho' }, {}는 모두 Reference Count가 2가 된다.

10, 11 Line에서 지역 변수 a, b에 null을 할당하면서 참조를 해제할 때 { name: 'hoho' }, {} 객체는 Reference Count가 2에서 1로 줄어든다.

사실상 코드 내에서 { name: 'hoho' }, {}에 접근할 방법은 없지만 Reference Count가 1이기 때문에 GC의 수거 대상이 되지 않는다.
이렇게 객체가 서로를 참조하는 현상을 Circular Referencing(순환 참조)이라고 하고, 이러한 문제점을 해결하기 위해 Mark and Sweep이라는 기법이 만들어졌다.
이 두 객체는 변수에서 접근할 수 없기 때문에 메모리 힙 영역에 영원히 남게되어 메모리 누수의 원인이 된다.
자바스크립트는 도달 가능성(reachability) 이라는 개념을 사용해 메모리 관리를 수행한다.
‘도달 가능한(reachable)’ 값은 쉽게 말해 어떻게든 접근하거나 사용할 수 있는 값을 의미하고 도달 가능한 값은 메모리에서 삭제되지 않는다.

// user엔 객체 참조 값이 저장
let user = {
name: "John"
};

이 그림에서 화살표는 객체 참조를 나타낸다. 전역변수 "user"는 { name: "John" } 객체를 참조한다.
user의 값을 다른 값으로 덮어쓰면 참조(화살표)가 사라진다.
user = null;

변수, 함수, 객체 등을 만들 때 메모리를 할당
변수를 읽거나 쓸 때 할당된 메모리를 사용
더 이상 필요가 없어지면 메모리에서 해제
메모리 누수는 부주의 또는 일부 프로그램 오류로 인해 더 사용되지 않는 메모리를 해제하지 못하는 것이다.
어떤 변수가 100M의 메모리를 점유한다 할 때, 이 변수가 사용되지 않더라도 수동 or 자동으로 해제되지 않아 계속 메모리를 점유하는 것을 말한다.
메모리 누수의 정의에 따르면 변수 or 데이터가 더 필요하지 않을 때 가비지 변수 or 가비지 데이터가 된다.
만약 그런 데이터가 메모리에 계속 쌓인다면, 결국엔 메모리 사용량을 초과하게 되는데 이 시점에서 가비지 데이터를 정리해야 한다.
가비지 컬렉션 메커니즘은 수동과 자동 두 가지 범주로 나뉜다.
일반적으로 전역 변수는 자동으로 정리되지 않는다. 그래서 로컬 스코프 메모리 수집에 초점을 맞출 것이다.
function fn1 () {
let a = {
name: 'hoho'
};
let b = 3;
function fn2 () {
let c = [1, 2, 3];
}
fn2();
return a;
}
let res = fn1();
위 코드의 호출 스택은 아래 그림과 같다.

그림의 왼쪽은 스택 영역으로 실행 컨텍스트와 원시 타입의 데이터를 저장하는 데 사용되고, 오른쪽은 힙 영역으로 객체를 저장하는 데 사용된다.
fn2()가 실행될 때 콜 스택 안 실행 컨텍스트는 위에서 아래로 다음과 같이 존재한다.
fn2 함수 실행 컨텍스트 -> fn1 함수 실행 컨텍스트 -> 전역 실행 컨텍스트
함수 fn2가 실행을 완료할 때 화살표가 아래로 이동하며 fn2 실행 컨텍스트를 종료하게 된다.
그럼 아래 그림과 같이 fn2 실행 컨텍스트가 지워지고 스택 메모리 공간이 해제된다.

함수 fn1의 실행이 완료된 후 fn1 실행 컨텍스트를 종료할 때, 화살표가 다시 아래로 이동하며 fn1 실행 컨텍스트가 지워지고 해당 스택 메모리 공간이 해제된다.

이 시점에서 프로그램은 전역 실행 컨텍스트에 있다.
자바스크립트의 가비지 컬렉션은 가끔 호출 스택을 탐색하고 가비지를 수집한다.
이 시점에서 가비지 수집 메커니즘이 수행된다고 가정할 때, 가비지 수집기가 호출 스택을 순회하면서 변수 b, c가 사용되지 않음을 발견하여 가비지 데이터임을 확인하고 표시한다.

fn1 함수는 실행 후 변수 a를 반환하고, 전역 변수 res에 저장하므로 활성 데이터로 식별되고 그에 따라 표시한다.
유휴 시간(컴퓨터가 작동 가능한데도 작업을 하지 아니하는 시간. 주로 컴퓨터의 입력ㆍ출력을 위한 대기 시간을 이른다.)에 가비지 데이터로 표시된 모든 변수는 그림과 같이 해당 메모리를 해제하기 위해 지워진다.
자바스크립트의 가비지 수집기 메커니즘은 자동으로 실행되고 태그는 가비지 데이터를 식별하고 정리하는 데 사용된다.
로컬 스코프를 떠난 후 해당 스코프의 변수가 외부 스코프에서 참조되지 않으면 나중에 지워진다.