관리 메뉴

bright jazz music

자바스크립트와 메모리 본문

Language/Javascript

자바스크립트와 메모리

bright jazz music 2024. 11. 11. 23:46

1. 메모리 생존주기

자바스크립트에서의 메모리 생존주기는 대체로 아래와 같은 과정을 거친다.

 

  1. 할당: 필요한 메모리를 할당받는다. 이는 변수를 초기화 할 때 ('let a = 10;'), 객체를 만들 때('let obj = {}'), 함수를 호출할 때 등 다양한 상황에서 발생한다.
  2. 사용: 할당받은 메모리를 읽거나 쓰는 작업을 수행한다. 예를 들어, 변수의 값을 변경하거나 객체의 속성에 접근하는 등의 작업이 여기에 해당한다.
  3. 해제: 프로그램이 더이상 해당 메모리를 필요로 하지 않을 때, 메모리는 해제되어 시스템에 반환된다. 자바스크립트에서는 가비지 콜렉터가 이 작업을 자동으로 수행한다.

 

자바스크립트에서는 자바스크립트 엔진의 가비지 콜렉터가 불필요한 메모리를 자동으로 해제한다. 이는 Mark-and-Sweep(표시하고 쓸기) 알고리즘 등을 사용하여 수행된다.

 

가비지 콜레거는 메모리를 해제하는 기준으로 '도달 가능성(reachability)'을 사용한다.

  • 즉, 어떤 값이 도달 가능하다면 그 값은 아직 필요한 값이라고 판단하고 메모리를 유지한다.
  • 도달할 수 있지 않다면 그 값은 불필요하다고 판단하고 메모리를 해제한다.

 

2. 가비지 콜렉션

가비지 콜렉터는 가비지 콜렉션이라는 작업을 통해 메모리를 관리한다. 이는 프로그래머가 직접 메모리를 할당하거나 해제할 필요 없이, 더 이상 필요 없는 메모리를 자동으로 해제하는 방식이다. 그러나 이러한 자동화에도 불구하고 효율적인 메모리 관리는 성능 최적화에 중요한 역할을 한다.

 

메모리 누수는 프로그램이 필요하지 않은 메모리를 계속 점유하고 있어 시스템 성능을 저해하는 현상을 의미한다.

let arr = [];
for(let i = 0; i < 1000000; i++) {
    // 현재 코드는 메모리를 많이 사용하지 않음
    // arr.push(new Array(100));
    // arr은 100개의 empty slot을 가진 배열들의 참조값을 100만개 저장
    // 각 new Array(100)은 length: 100이라는 속성만 가진 희소 배열 객체를 생성
    // 즉 arr은 100만개의 포인터를 갖고, 각 포인터는 실제 메모리 할당 없이 
    // length 속성만 가진 배열 객체를 가리킴. 따라서 예시에 어울리지 않음
    // e.g. console.dir(new Array(100)) // [ <100 empty items> ]
    
    // 많은 메모리를 사용하는 예시를 들고 싶다면 아래 코드가 적절함
    arr.push(new Array(100).fill(undefined));
    // 각 배열이 실제로 100개의 undefined 값을 저장하는 메모리를 할당받음
    // 100만 개의 배열 * 100개의 실제 메모리 공간 할당
}

 

 

이렇게 되면 백 만 개의 배열을 생성하여 arr에 추가한다. 이 메모리는 arr이 존재하는 동안 해제되지 않는다.

이런 상황에서는 메모리를 효율적으로 관리하기 위해 필요한 데이터만 남기고 나머지는 해제하는 방법을 사용할 수 있다.

 

아래와 같이도 가능하다고 생각할 수 있지만,

 

let arr = [];
for(let i = 0; i < 1000000; i++) {
    let subArr = new Array(100).fill(undefined);
    
    // subArr 사용하는 코드...
    
    subArr = null; // 이렇게 해도 가비지 컬렉션이 즉시 일어나지 않음
    // 또한 arr.push(subArr)같은 코드가 있었다면
    // arr가 여전히 해당 배열을 참조하고 있어 메모리가 해제되지 않음
}

 

 

  • 가비지 컬렉션이 자동으로 수행됨
  • null 할당이 직접적인 메모리 해제를 보장하지 않음
  • 실제 메모리 해제 시점은 가비지 컬렉터가 결정

 

즉, 명시적으로 null을 할당한다고 해서 메모리가 즉시 해제되는 것은 아니라는 것이다. 물론 가비지 컬렉터의 수거 대상을 개발자가 명확화 한다는 점에서는 긍정적이다. 그러나 가비지 콜렉터는 자동으로 실행되며, 개발자가 임의로 컨트롤 할 수 없다. 불필요한 객체에 null을 할당한다고 해제되지는 않으므로, 필요하지 않는 객체가 더이상 참조되지 않도록 스코프 관리를 잘 하는 것이 더욱 의미있다. 

 

 

 

// 1. null 할당이 즉시 메모리 해제로 이어지지 않는 예
function example1() {
    let obj = new Array(10000);
    obj = null;  // 이 시점에서 메모리는 즉시 해제되지 않음
    // GC가 실행될 때까지 메모리는 계속 존재
}

// 2. 스코프를 통한 더 나은 메모리 관리
function example2() {
    {
        const arr = new Array(10000);
        // arr 사용
    }  // 블록을 벗어나면 arr는 자연스럽게 GC 대상이 됨
    // null 할당 불필요
}

// 3. 잘못된 참조 유지의 예
let globalArr;  // 전역 변수는 주의

function example3() {
    const arr = new Array(10000);
    globalArr = arr;  // 전역 변수로 참조가 유지됨
    // 스코프가 끝나도 arr은 GC 대상이 되지 않음
}

 

 

3. 가비지 콜렉터의 동작 조건

 

JavaScript의 가비지 콜렉터(GC)는 주로 두 가지 기준으로 동작한다

1. 도달 가능성(Reachability):

let obj1 = { data: "some data" };
let obj2 = obj1;      // obj2도 같은 객체 참조

obj1 = null;          // 아직 obj2가 참조 중이라 수거 대상 아님
obj2 = null;          // 이제 아무도 참조하지 않음 -> 수거 대상이 됨

// 전역 객체(window/global)에서 시작해서
// 참조를 따라가서 도달할 수 없는 객체들이 수거 대상



2. 세대별 수집(Generational Collection):

// 젊은 세대 (새로 생성된 객체들)
function createTemporary() {
    let temp = new Array(100);  // 새로운 객체
    // 함수 종료 후 temp는 수거 대상
}  // 젊은 세대는 더 자주 검사됨

// 오래된 세대
const longLived = new Array(1000);  // 오래 살아있는 객체
// 오래된 세대는 덜 자주 검사됨



GC 실행 시점:

// 1. 메모리 임계치에 도달
let arrays = [];
for(let i = 0; i < 1000000; i++) {
    arrays.push(new Array(100));
    // 메모리 사용량이 임계치에 도달하면 GC 실행될 수 있음
}

// 2. 일정 시간 간격
setInterval(() => {
    let tempData = processData();
    // 주기적으로 GC가 실행될 수 있음
}, 1000);

// 3. 브라우저 유휴 상태
// 페이지가 활성화되지 않은 상태에서 GC가 실행될 수 있음



좋은 코드 패턴:

// 1. 명확한 스코프
function processInScope() {
    const heavyData = new Array(10000);
    processData(heavyData);
    // 함수 종료 시 heavyData는 명확하게 수거 대상
}

// 2. 클로저 주의
function createCounter() {
    let count = 0;
    // 이 클로저는 count를 계속 참조
    return () => count++;
}

// 3. 순환 참조 주의
function createCircular() {
    let obj1 = {};
    let obj2 = {};
    obj1.ref = obj2;
    obj2.ref = obj1;
    // 순환 참조는 현대 GC가 처리 가능하지만
    // 명시적으로 끊어주는 것이 좋음
}

// 4. 큰 데이터는 청크로 처리
function processLargeData(data) {
    const CHUNK_SIZE = 1000;
    for(let i = 0; i < data.length; i += CHUNK_SIZE) {
        const chunk = data.slice(i, i + CHUNK_SIZE);
        processChunk(chunk);
        // 각 청크 처리 후 자동으로 수거 대상
    }
}



핵심 포인트:
1. GC는 자동으로 실행됨
   - 메모리 압박이 있을 때
   - 주기적으로
   - 시스템 유휴 시
   
2. 개발자가 할 일
   - 명확한 스코프 관리
   - 불필요한 참조 제거
   - 큰 데이터는 청크 단위로 처리
   - 메모리 누수 패턴 피하기 (잘못된 클로저, 이벤트 리스너 미제거 등)

3. `null` 할당의 의미
   - 즉시 메모리 해제가 아님
   - GC에게 "이제 이 참조 필요없음" 알림
   - 도달 가능성 체크에 영향

 

4. Mark-and-sweep 알고리즘

마크앤스윕 알고리즘은 가비지 콜렉션에서 일반적으로 사용되는 알고리즘이다.이 알고리즘은 사용되지 않는 메모리를 식별하고 이를 해제하여 시스템에 반환하는 역할을 한다. 자바스크립트 엔진은 메모리 관리를 자동으로 수행하며 이 과정에서 마크앤스윕 알고리즘을 활용한다.

 

마크앤 스윕 알고리즘은 아래와 같은 과정을 거친다.

 

  1. 표시(Mark): 가비지 콜렉터는 모든 객체를 순회하며 접근할 수 있는 (reachable) 객체를 표시한다. 접근 가능한 객체란 어떤 방식으로든 접근할 수 있는 객체를 의미한다. 이는 전역 객체에서 시작하여 참조를 따라가며 표시하는 작업을 수행한다.
  2. 쓸기:(Sweep): 가비지 컬렉터는 다시 모든 객체를 순회하며 표시되지 않는 객체, 즉 접근이 불가능한(unreachable)한 객체를 메모리에서 제거한다. 이 과정에서 메모리가 해제되며 이 메모리는 다시 사용할 수 있는 상태가 된다.

*가비지 콜렉션은 CPU자원을 사용하므로 과도한 콜렉션은 성능에 부정적 영향을 끼칠 수 있다.

 

// Mark-and-Sweep 예시

// 1. Reachable 객체들
let root = {     // root는 전역 스코프에서 접근 가능
    a: {         // root.a로 접근 가능
        b: {     // root.a.b로 접근 가능
            data: "some data"
        }
    }
};

// 2. Unreachable이 되는 상황
root.a.b = null;  // b 객체는 여전히 메모리에 있지만 접근 불가
                  // 다음 GC 사이클에서 수거 대상

// 3. 순환 참조의 경우
let obj1 = { name: "obj1" };
let obj2 = { name: "obj2" };
obj1.ref = obj2;
obj2.ref = obj1;

obj1 = null;
obj2 = null;
// 순환 참조지만 root에서 접근 불가능하므로
// Mark-and-Sweep이 둘 다 수거 가능

 

 

씨피유를 고려한 코드 작성

// 성능에 영향을 줄 수 있는 패턴
function badPattern() {
    while(true) {
        let arr = new Array(1000000);  // 매 반복마다 대량 객체 생성
        // arr 사용
    }  // 잦은 GC 발생 -> 성능 저하
}

// 더 나은 패턴
function betterPattern() {
    const arr = new Array(1000000);  // 한 번만 생성
    while(true) {
        // arr 재사용
    }  // GC 부담 감소
}

 

추가

  1. 최신 자바스크립트 엔진들은 세대별 GC도 함께 사용
  2. Mark-and-Sweep 과정 중에는 JavaScript 실행이 일시 중지될 수 있음 (Stop-the-world)
  3. 최신 엔진들은 증분 GC(Incremental GC)를 통해 일시 중지 시간을 최소화

 

5.  메모리 누수 시나리오

 

클로저를 사용할 때 메모리 누수가 일어나는 경우가 많다.

 

클로저는 내부 함수가 외부 함수의 변수에 접근할 수 있도록 하는 기능이다. 하지만 클로저를 사용하면 외부 함수의 변수가 내부 함수에 의해 계속 참조되므로 메모리가 해제되지 않는 문제가 발생할 수 있다. 따라서 클로저를 적절히 사용하거나 필요한 경우에만 사용하여 메모리 누수를 방지해야 한다.

 

아래와 같은 경우에도 메모리 관리가 필요하다.

 

  • 글로벌 변수 사용 제한: 글로벌 변수는 애플리케이션이 종료될 때까지 메모리에 남아 있다. 따라서 글로벌 변수는 목적에 따른 사용이 완료된 후에도 해제되지 않기 때문에 메모리 누수를 초래할 수 있다. 필요한 경우에만 제한적으로 사용하는 것이 좋다.
  • 이벤트 리스너 제거: 이벤트 리스너는 DOM 요소에 연결돼 사용자의 행동을 감지한다. 그러나 목적에 따른 사용 후에도 제거하지 않는다면 메모리 누수를 초래할 수 있다.
//이벤트 리스너 제거 예시

let btn = document.getElementById('myButton');
btn.addEventListener('click', function(
	// 버튼 클릭시 동작
));

// 불필요한 시점
btn.removeEventListener('click', function() {
	// 이벤트 제거 후 실행할 코드
})

 

  • 타이머 제거: 'SetInterval'이나 'SetTimeout'과 같은 타이머 함수는 지정된 시간이 지나면 특정 코드를 실행한다. 그러나 타이머가 불필요해 진 경우에도 제거하지 않는다면 메모리 누수를 초래할 수 있다. 불필요한 타이머는 'clearInterval', 'clearTimeout'을 사용해 제거한다.
// 타이머 제거 예시


// 타이머 설정
let timerId = setInterval(() => {
	// 시간이 경과했을 때 실행할 코드
}, 1000)	// 주기

//타이머 제거
clearInterval(timerId);

 

  • 큰 데이터의 처리: 매우 큰 데이터를 다루는 경우 메모리 사용량이 급증할 수 있다. 데이터를 적절히 분할하거나 스트림을 사용하여 메모리 사용량을 줄일 수 있다.
  • 객체 참조 제거: 객체를 비사용 할 때에는 참졸르 모두 제거해야 한다. 그렇지 않으면 가비지 콜렉션에서 객체의 메모리를 해제하지 않는다.

 

 

 

 

 

참고: new Array(100).fill(undefined) 이것과 new Array(100).fill().map(() => undefined)의 차이

 

두 방식은 결과적으로는 같은 배열을 만들지만, 동작 과정이 다르다.

// 1. fill(undefined) 방식
let arr1 = new Array(100).fill(undefined);
// 1단계: new Array(100) -> [empty × 100]
// 2단계: fill(undefined) -> 바로 undefined로 채움

// 2. fill().map(() => undefined) 방식
let arr2 = new Array(100).fill().map(() => undefined);
// 1단계: new Array(100) -> [empty × 100]
// 2단계: fill() -> [undefined × 100] (인자없는 fill은 undefined로 채움)
// 3단계: map(() => undefined) -> 각 요소를 순회하며 다시 undefined 할당

// 결과는 동일
console.log(arr1.length === arr2.length); // true
console.log(arr1[0] === arr2[0]); // true



주요 차이점:
1. 성능

   console.time('fill');
   new Array(100000).fill(undefined);
   console.timeEnd('fill'); // 더 빠름

   console.time('fillMap');
   new Array(100000).fill().map(() => undefined);
   console.timeEnd('fillMap'); // 더 느림 (추가 순회 필요)



2. 용도의 차이

   // fill은 단순히 같은 값으로 채울 때 적합
   let simpleArr = new Array(3).fill(undefined);

   // map은 각 인덱스별로 다른 처리가 필요할 때 적합
   let indexedArr = new Array(3).fill().map((_, i) => `item${i}`);
   console.log(indexedArr); // ['item0', 'item1', 'item2']



3. 실행 컨텍스트

   // fill은 단순 할당
   new Array(3).fill(Math.random());
   // [0.123, 0.123, 0.123] (같은 값이 복사됨)

   // map은 각 요소마다 함수를 실행
   new Array(3).fill().map(() => Math.random());
   // [0.123, 0.456, 0.789] (각각 다른 값)



따라서:
- 단순히 undefined로 채우는 것이 목적이라면 `fill(undefined)`가 더 효율적
- 각 요소별로 특별한 처리나 계산이 필요하다면 `fill().map()`이 적절

메모리 사용량을 보여주는 예제로는 둘 다 적합하지만, 여기서는 단순히 배열을 채우기가 필요한 것이므로 `fill(undefined)`가 더 직관적이고 효율적이다.

'Language > Javascript' 카테고리의 다른 글

자바스크립트에서의 타입 확인  (0) 2024.12.03
자바스크립트의 실행 컨텍스트  (0) 2024.11.13
자바스크립트 기본 문법  (0) 2024.02.26
이벤트 루프와 비동기 통신  (0) 2024.01.03
클로저(closure)  (0) 2024.01.03
Comments