💻 프로그래밍

Memory Leak

메모리 누수

사용하지 않는 메모리가 해제되지 않는 버그. 시간이 지나면 시스템 느려짐.

📖 상세 설명

메모리 누수(Memory Leak)는 프로그램이 동적으로 할당한 메모리를 더 이상 사용하지 않음에도 불구하고 해제하지 않아 발생하는 버그입니다. 이로 인해 프로그램이 실행되는 동안 사용 가능한 메모리가 지속적으로 감소하게 되며, 심한 경우 시스템 전체의 성능 저하나 크래시를 유발할 수 있습니다.

메모리 누수의 주요 발생 원인으로는 해제되지 않은 참조가 있습니다. 객체에 대한 참조가 남아있어 가비지 컬렉터가 회수하지 못하는 경우, 이벤트 리스너 미해제로 DOM 요소가 제거되었으나 이벤트 핸들러가 남아있는 경우, 클로저 내 변수 참조로 외부 스코프의 큰 객체를 불필요하게 참조하는 경우 등이 대표적입니다.

프로그래밍 언어에 따라 메모리 관리 방식이 다릅니다. JavaScript, Java, Python 같은 언어는 가비지 컬렉션(GC)을 통해 자동으로 메모리를 관리하지만, 참조가 남아있으면 GC도 메모리를 회수할 수 없습니다. 반면 C, C++ 같은 언어는 수동 메모리 관리를 요구하므로 malloc/free, new/delete를 명시적으로 호출해야 하며, 잘못된 관리 시 메모리 누수나 dangling pointer 문제가 발생합니다.

메모리 누수를 탐지하기 위한 다양한 도구들이 있습니다. Valgrind는 C/C++ 프로그램의 메모리 오류를 검출하는 강력한 도구이며, Chrome DevTools의 Memory 탭은 JavaScript 힙 스냅샷을 분석하여 누수를 찾을 수 있게 해줍니다. Node.js에서는 --inspect 플래그와 함께 Chrome DevTools를 사용하거나, clinic.js 같은 도구를 활용할 수 있습니다.

💻 코드 예제

JavaScript에서 흔히 발생하는 메모리 누수 패턴과 해결 방법입니다:

memory-leak-example.js - 메모리 누수 발생 예제
// 문제 1: 이벤트 리스너 미해제
class LeakyComponent {
    constructor() {
        this.data = new Array(10000).fill('large data');

        // 이벤트 리스너 등록 - 해제하지 않으면 누수 발생!
        window.addEventListener('resize', this.handleResize);
    }

    handleResize = () => {
        console.log('Resized!', this.data.length);
    }
}

// 문제 2: 클로저에서의 참조 유지
function createClosure() {
    const hugeArray = new Array(1000000).fill('data');

    // 클로저가 hugeArray를 참조하여 GC 불가
    return function() {
        return hugeArray[0];
    };
}

// 문제 3: setInterval 미정리
function startPolling() {
    const cache = [];

    setInterval(() => {
        // 계속 데이터가 쌓이며 메모리 증가
        cache.push(new Array(1000).fill(Math.random()));
    }, 1000);
}
memory-leak-fixed.js - 메모리 누수 해결
// 해결 1: 컴포넌트 정리(cleanup) 패턴
class SafeComponent {
    constructor() {
        this.data = new Array(10000).fill('large data');
        this.handleResize = this.handleResize.bind(this);
        window.addEventListener('resize', this.handleResize);
    }

    handleResize() {
        console.log('Resized!', this.data.length);
    }

    // 반드시 cleanup 메서드를 호출하여 리스너 해제
    destroy() {
        window.removeEventListener('resize', this.handleResize);
        this.data = null; // 명시적 참조 해제
    }
}

// 해결 2: WeakRef 또는 필요한 데이터만 참조
function createSafeClosure() {
    const hugeArray = new Array(1000000).fill('data');
    const firstItem = hugeArray[0]; // 필요한 값만 저장

    return function() {
        return firstItem; // hugeArray 참조 없음
    };
}

// 해결 3: interval ID 저장 및 정리
function startSafePolling() {
    const cache = [];
    const MAX_CACHE_SIZE = 100;

    const intervalId = setInterval(() => {
        if (cache.length >= MAX_CACHE_SIZE) {
            cache.shift(); // 오래된 데이터 제거
        }
        cache.push(new Array(1000).fill(Math.random()));
    }, 1000);

    // cleanup 함수 반환
    return () => clearInterval(intervalId);
}

🗣️ 실무 대화 예시

회의: 성능 이슈 원인 분석
PM: 프로덕션에서 서버가 점점 느려지다가 결국 크래시하는 문제가 계속 발생하고 있어요.
시니어 개발자: 전형적인 메모리 누수 증상이네요. 모니터링 대시보드에서 메모리 사용량 추이를 확인해봤어요?
DevOps: 네, 배포 후 시간이 지날수록 메모리가 계속 증가하다가 OOM(Out of Memory)으로 종료됩니다.
시니어 개발자: Node.js 서버니까 --inspect 플래그로 힙 스냅샷을 떠서 비교해봅시다. 어떤 객체가 계속 쌓이는지 확인할 수 있어요.
PM: 원인을 찾으면 재발 방지를 위해 모니터링 알람도 추가해주세요.
면접: 메모리 누수 탐지 및 해결 경험
면접관: 프로덕션에서 메모리 누수를 경험해본 적 있나요? 어떻게 찾고 해결했는지 설명해주세요.
지원자: 네, SPA에서 페이지 이동 후에도 메모리가 계속 증가하는 문제가 있었습니다. Chrome DevTools의 Memory 탭에서 힙 스냅샷을 두 번 찍어서 비교했더니, 이전 페이지의 컴포넌트 인스턴스가 계속 남아있었어요.
면접관: 원인이 무엇이었나요?
지원자: window에 등록한 resize 이벤트 리스너를 컴포넌트 언마운트 시 해제하지 않아서였습니다. useEffect의 cleanup 함수에서 removeEventListener를 추가하고, 팀 전체에 린트 규칙을 도입해서 재발을 방지했습니다.
코드 리뷰: 메모리 누수 패턴 발견
리뷰어: 이 컴포넌트에서 useEffect로 이벤트 리스너를 등록하고 있는데, cleanup 함수가 없네요.
작성자: 아, 컴포넌트가 언마운트될 때 자동으로 정리되는 줄 알았어요.
리뷰어: DOM 요소는 제거되지만, window나 document에 등록된 이벤트 리스너는 수동으로 해제해야 해요. 안 그러면 컴포넌트가 사라져도 핸들러가 메모리에 남아있어요.
작성자: 그럼 return 문에서 removeEventListener를 호출하면 되나요?
리뷰어: 네, 그리고 setInterval도 마찬가지예요. 항상 cleanup 함수에서 clearInterval 해주세요.

⚠️ 주의사항

  • 이벤트 리스너 해제 필수
    addEventListener로 등록한 핸들러는 반드시 removeEventListener로 해제해야 합니다. React에서는 useEffect의 cleanup 함수, Vue에서는 onUnmounted에서 처리하세요.
  • 클로저 참조 관리
    클로저가 외부 스코프의 큰 객체를 참조하면 GC가 회수할 수 없습니다. 필요한 값만 복사하거나, 사용 후 명시적으로 null을 할당하세요.
  • 타이머/인터벌 정리
    setInterval, setTimeout의 ID를 저장하고, 컴포넌트 해제 시 clearInterval, clearTimeout을 호출하세요. 무한 polling은 메모리 폭탄이 될 수 있습니다.
  • DOM 참조 주의
    제거된 DOM 요소에 대한 JavaScript 참조가 남아있으면 메모리가 해제되지 않습니다. DOM 조작 후 참조 변수를 정리하세요.
  • 캐시 크기 제한
    메모리 기반 캐시는 반드시 최대 크기를 설정하고, LRU 등의 정책으로 오래된 데이터를 제거하세요.

🔗 관련 용어

📚 더 배우기