front_end

React & JavaScript 메모리 누수 분석과 해결 방법 - 최적화 노하우

Chrysans 2025. 2. 17. 10:14
728x90
반응형

 

안녕하세요! 오늘은 React와 JavaScript 애플리케이션에서 발생하는 메모리 누수 문제를 깊이 있게 다뤄보겠습니다. 실제 프로젝트에서 마주칠 수 있는 다양한 시나리오와 해결 방법을 함께 알아보겠습니다.

 


목차

  1. 메모리 누수의 이해
  2. React에서 발생하는 메모리 누수 패턴
  3. JavaScript 런타임에서의 메모리 누수
  4. 실전 디버깅 가이드
  5. 성능 최적화 전략

 

1. 메모리 누수의 이해

 

1.1 메모리 관리 기본 원리

JavaScript의 메모리 관리는 가비지 컬렉션(Garbage Collection)을 통해 자동으로 이루어집니다. 하지만 특정 상황에서는 이 메커니즘이 제대로 작동하지 않을 수 있습니다.

 

가비지 컬렉션(Garbage Collection)의 이해

 

가비지 컬렉션이란?

 

메모리에 있는 '쓰레기'를 치우는 청소부라고 생각하면 쉽습니다. 여기서 '쓰레기'란 더 이상 사용되지 않는 데이터를 말합니다. JavaScript에서는 이 청소부가 자동으로 일하면서 프로그래머가 직접 메모리를 관리할 필요가 없게 해줍니다.

 

 

도달 가능성(Reachability)의 개념

 

도달 가능성(Reachability)이란 "현재 실행 중인 코드에서 어떤 방법으로든 참조하거나 접근할 수 있는 값"을 의미합니다.

// 1단계: 객체 생성
let user = { name: "John" };
// 이 시점에서 객체는 'user'를 통해 도달 가능

// 2단계: 참조 복사
let admin = user;
// 이제 두 가지 방법으로 객체에 도달 가능:
// 1. user 참조
// 2. admin 참조

// 3단계: 첫 번째 참조 제거
user = null;
// 여전히 admin을 통해 객체에 도달 가능

// 4단계: 마지막 참조 제거
admin = null;
// 이제 객체에 도달할 방법이 없음
// 가비지 컬렉션의 대상이 됨

 

루트(Root)에서의 도달

  • 전역 변수
  • 현재 함수의 지역 변수와 매개변수
  • 중첩 함수의 변수 체인

 

 

루트는 '기본적으로 접근 가능한 값들의 시작점'입니다. 마치 나무의 뿌리와 같이 여기서부터 모든 참조가 시작됩니다.

 

전역 변수

window.globalVar = "전역 변수입니다"; // 브라우저에서는 항상 접근 가능

 

현재 실행 중인 함수의 변수들

function process() {
    let localVar = "지역 변수입니다";  // 함수 실행 중에만 접근 가능
    let tempObject = { data: "임시 데이터" };
    
    return localVar;  // 함수 종료 후 tempObject는 접근 불가능해져서 가비지 컬렉션 대상이 됨
}

 

중첩 함수의 변수 체인

function outer() {
    let outerVar = "외부 변수";
    
    function inner() {
        console.log(outerVar); // 내부 함수에서 외부 변수 접근 가능
    }
    
    return inner;
}

const closureFunc = outer(); // outerVar는 closureFunc가 존재하는 한 계속 접근 가능

 

예시:

// 1. 객체 생성
let user = {
    name: "Alice",
    age: 30,
    data: {
        hobby: "reading",
        skills: ["JavaScript", "Python"]
    }
};

// 2. 다른 참조 추가
let admin = user;  // 같은 객체를 가리키는 두 개의 참조

// 3. user 참조 제거
user = null;  // admin으로 여전히 객체에 접근 가능

// 4. admin도 제거
admin = null;  // 이제 객체는 완전히 도달 불가능
               // 가비지 컬렉터가 이 시점에 객체와 
               // 그 내부의 data 객체, skills 배열을 모두 메모리에서 해제

 

이렇게 가비지 컬렉션은 더 이상 도달할 수 없는 데이터를 자동으로 찾아내고 제거함으로써, 프로그래머가 수동으로 메모리를 관리해야 하는 부담을 덜어줍니다. 특히 복잡한 애플리케이션에서 메모리 누수를 방지하고 성능을 최적화하는 데 큰 도움이 됩니다.

 

누수예시

function createLeak() {
    const hugeArray = new Array(1000000);
    window.leakedArray = hugeArray; // 전역 객체에 참조가 유지됨
}

// 함수 호출 후에도 메모리가 해제되지 않음
createLeak();

 

1.2 JavaScript 엔진과 브라우저의 메모리 구조

메모리 누수를 이해하기 위해서는 메모리 구조를 알아야 합니다:

 

JavaScript 엔진(예: V8)은 두 가지 주요 메모리 영역을 가집니다:

  1. Heap Memory (힙 메모리)
  • 구조화되지 않은 큰 메모리 영역으로, 동적 메모리 할당에 사용됩니다.
  • 객체, 배열, 함수 등 참조 타입의 데이터가 저장됨
  • 크기가 동적으로 변할 수 있는 데이터를 위한 공간
  • 가비지 컬렉션이 주로 이루어지는 영역
  1. Stack Memory (스택 메모리)
  • 정적으로 할당된 데이터가 저장되는 영역
  • 원시 타입(number, string, boolean, null, undefined) 값들이 직접 저장됨
  • 객체에 대한 참조값(메모리 주소)이 저장됨
  • 함수 호출 스택 정보가 저장됨
  • LIFO(Last In First Out) 구조로 작동

 

  1. Web APIs
  • 브라우저에서 제공하는 추가 기능들
  • DOM API (문서 조작)
  • Timer 함수들 (setTimeout, setInterval)
  • AJAX 요청 (XMLHttpRequest, fetch)
  • Event Listeners
  • Event Loop & Queue
    • JavaScript 엔진과 Web APIs 사이의 중개자 역할
    • 비동기 작업의 완료를 관리
    • 콜백 함수의 실행 순서를 조정
    • Macrotask Queue: 일반적인 비동기 작업 (setTimeout, 이벤트 등)
    • Microtask Queue: Promise 관련 작업

 

2. React에서 발생하는 메모리 누수 패턴

2.1 useEffect 관련 누수

2.1.1 비동기 작업 처리

 

비동기 작업 처리 시 발생하는 메모리 누수는 React 애플리케이션에서 매우 흔한 문제입니다.

function DataFetcher() {
    const [data, setData] = useState(null);

    useEffect(() => {
        async function fetchData() {
            const response = await api.getData();
            setData(response);
        }
        fetchData();
    }, []);

    return <div>{/* 렌더링 로직 */}</div>;
}

 

문제점:

  • 컴포넌트가 언마운트된 후에도 비동기 작업이 계속 실행될 수 있음
  • 언마운트된 컴포넌트의 상태를 업데이트하려고 시도하면 메모리 누수 발생
  • React 경고: "Can't perform a React state update on an unmounted component"

 

해결방안:

function DataFetcher() {
    const [data, setData] = useState(null);

    useEffect(() => {
        let isMounted = true;
        const abortController = new AbortController();

        async function fetchData() {
            try {
                const response = await api.getData({
                    signal: abortController.signal
                });
                if (isMounted) {
                    setData(response);
                }
            } catch (error) {
                if (error.name === 'AbortError') {
                    // 정상적인 중단
                    return;
                }
                // 다른 에러 처리
            }
        }

        fetchData();

        return () => {
            isMounted = false;
            abortController.abort();
        };
    }, []);

    return <div>{/* 렌더링 로직 */}</div>;
}

 

해결 방법 설명:

  1. isMounted 플래그를 사용하여 컴포넌트의 마운트 상태 추적
  2. AbortController를 사용하여 진행 중인 fetch 요청을 취소
  3. cleanup 함수에서 모든 상태 플래그를 리셋하고 진행 중인 요청을 중단

 

2.1.2 WebSocket 연결

 

WebSocket 연결은 지속적인 양방향 통신을 제공하지만, 제대로 관리하지 않으면 메모리 누수의 원인이 될 수 있습니다.

function WebSocketComponent() {
    useEffect(() => {
        const ws = new WebSocket('wss://api.example.com');
        
        ws.onmessage = (event) => {
            // 메시지 처리
        };
    }, []);

    return <div>{/* 렌더링 로직 */}</div>;
}

 

문제점:

  • 컴포넌트가 언마운트되어도 WebSocket 연결이 계속 유지됨
  • 메시지 핸들러가 제거되지 않아 메모리 누수 발생
  • 서버와의 불필요한 연결이 유지되어 리소스 낭비

해결방안:

function WebSocketComponent() {
    useEffect(() => {
        const ws = new WebSocket('wss://api.example.com');
        
        ws.onmessage = (event) => {
            // 메시지 처리
        };

        ws.onerror = (error) => {
            console.error('WebSocket error:', error);
        };

        return () => {
            if (ws.readyState === WebSocket.OPEN) {
                ws.close();
            }
            // 이벤트 핸들러 제거
            ws.onmessage = null;
            ws.onerror = null;
        };
    }, []);

    return <div>{/* 렌더링 로직 */}</div>;
}

 

해결 방법 설명:

  1. cleanup 함수에서 열린 WebSocket 연결을 명시적으로 종료
  2. 모든 이벤트 핸들러를 제거하여 메모리 누수 방지
  3. 연결 상태를 확인하여 안전하게 종료

 

2.2 이벤트 리스너 관리

 

2.2.1 DOM 이벤트

DOM 이벤트 리스너는 제대로 제거되지 않으면 메모리 누수의 주요 원인이 됩니다.

function ScrollTracker() {
    useEffect(() => {
        const handleScroll = () => {
            // 스크롤 위치 추적
        };
        
        window.addEventListener('scroll', handleScroll);
    }, []);

    return <div>{/* 렌더링 로직 */}</div>;
}

 

문제점:

  • 이벤트 리스너가 컴포넌트 언마운트 후에도 남아있음
  • 불필요한 이벤트 처리로 인한 성능 저하
  • 메모리 누수 발생

 

해결방안:

function ScrollTracker() {
  const [scrollPosition, setScrollPosition] = useState(0);

  useEffect(() => {
    const handleScroll = () => {
      setScrollPosition(window.scrollY);
    };

    window.addEventListener('scroll', handleScroll);
    
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  return <div>Scroll position: {scrollPosition}</div>;
}


스크롤 이벤트가 너무 자주 발생하는 것을 제어하기 위해 throttle을 사용할수 있음

 

해결 방법 설명: 

  1.   cleanup 함수에서 이벤트 리스너를 명시적으로 제거 

 

3. JavaScript 런타임에서의 메모리 누수

3.1 클로저 관련 누수

 

클로저는 외부 스코프의 변수를 참조하여 메모리 누수를 일으킬 수 있습니다.

function createClosureLeak() {
    const heavyObject = {
        data: new Array(1000000)
    };

    return function() {
        console.log(heavyObject.data.length);
    };
}

// 누수 발생
const leakedClosure = createClosureLeak();

 

문제점:

  • 반환된 함수가 heavyObject에 대한 참조를 계속 유지
  • 가비지 컬렉터가 heavyObject를 수집할 수 없음
  • 불필요한 메모리 점유

 

해결방안:

function createClosureLeak() {
    const heavyObject = {
        data: new Array(1000000)
    };
    const length = heavyObject.data.length; // 필요한 데이터만 저장
    
    return function() {
        console.log(length);  // 저장된 값만 사용
    };
}

 

해결 방법 설명: 

  1. 클로저가 전체 객체 대신 필요한 값만 참조하도록 변경
  2. heavyObject는 함수 실행 후 가비지 컬렉션의 대상이 됨

 

3.2 Timer 관련 누수

function TimerComponent() {
    useEffect(() => {
        const timer = setInterval(() => {
            // 무거운 작업
        }, 1000);

    }, []);

    return <div>{/* 렌더링 로직 */}</div>;
}

 

문제점:

  • 컴포넌트가 제거되어도 타이머가 계속 실행됨
  • 메모리 집약적 작업이 계속 수행됨

 

해결방안:

function TimerComponent() {
    useEffect(() => {
        const timer = setInterval(() => {
            // 무거운 작업
        }, 1000);

        return () => {
            clearInterval(timer);
        };
    }, []);

    return <div>{/* 렌더링 로직 */}</div>;
}

 

해결 방법 설명: 

  1. 함수형 컴포넌트와 useEffect를 사용하여 생명주기 관리
  2. cleanup 함수에서 타이머를 명시적으로 정리
  3. 컴포넌트 인스턴스에 대한 참조가 자동으로 정리됨

 

4. 실전 디버깅 가이드

4.1 Chrome DevTools 활용

4.1.1 Memory 탭 사용법

 

  1. Heap Snapshot 촬영
    • 현재 메모리 상태의 스냅샷을 찍어 분석
    • 객체의 메모리 점유율과 참조 관계 확인 가능
    • 메모리 누수 의심 지점 발견에 유용
  2. 시간 경과에 따른 메모리 변화 관찰
    • 여러 스냅샷을 비교하여 메모리 증가 패턴 파악
    • 정상적으로 해제되지 않는 메모리 식별
  3. 메모리 누수 지점 식별
    • Retainers 트리를 통해 객체가 해제되지 않는 원인 추적
    • 참조 체인 분석으로 누수 발생 지점 특정
function DebugComponent() {
    const debugId = useRef(Math.random().toString(36));
    
    useEffect(() => {
        console.log(`[DEBUG ${debugId.current}] Component mounted`);
        
        // @ts-ignore 또는 any 타입 사용
        if (performance.memory) {
            console.log(
                `[DEBUG ${debugId.current}] Memory usage:`, 
                // @ts-ignore
                performance.memory.usedJSHeapSize
            );
        }
        
        return () => {
            console.log(`[DEBUG ${debugId.current}] Component unmounted`);
        };
    }, []);

    return <div data-debug-id={debugId.current}>{/* 컴포넌트 내용 */}</div>;
}

4.1.2 Performance 탭 활용

Performance 탭을 통해 메모리 사용량과 성능 병목 현상을 분석할 수 있습니다.

  1. 타임라인 기록
    • 사용자 시나리오에 따른 메모리 사용 패턴 기록
    • JavaScript 실행, 렌더링, 메모리 할당 등의 타임라인 분석
  2. 메모리 할당 패턴 분석
    • 급격한 메모리 증가 구간 식별
    • 불필요한 메모리 할당 지점 발견
  3. 가비지 컬렉션 동작 확인
    • GC 발생 시점과 영향 분석
    • 메모리 해제가 제대로 이루어지지 않는 패턴 발견

 

4.2 React Developer Tools

React Developer Tools는 React 애플리케이션의 성능과 동작을 분석하는 전문 도구입니다.

 

React 컴포넌트 성능 분석:

 

Profiler 탭 활용

  • 컴포넌트별 렌더링 시간 측정
  • 불필요한 리렌더링 식별
  • 성능 병목 컴포넌트 발견

 

컴포넌트 리렌더링 패턴 분석

function PerformanceComponent() {
    const [data, setData] = useState([]);
    
    // 성능 측정을 위한 커스텀 훅
    useEffect(() => {
        const startTime = performance.now();
        
        return () => {
            const endTime = performance.now();
            console.log(`Render time: ${endTime - startTime}ms`);
        };
    });

    return (/* 컴포넌트 렌더링 */);
}

 

Props 변화 추적

function Parent() {
    const [count, setCount] = useState(0);
    
    // 불필요한 객체 생성으로 인한 리렌더링 발생
    const badProps = { 
        data: { value: count }    // 매 렌더링마다 새로운 객체
    };
    
    // 최적화된 방식
    const goodProps = useMemo(() => ({
        data: { value: count }
    }), [count]);

    return (
        <>
            <Child data={badProps} />  {/* 불필요한 리렌더링 발생 */}
            <OptimizedChild data={goodProps} />  {/* 필요할 때만 리렌더링 */}
        </>
    );
}

// Props 변화 추적을 위한 래퍼 컴포넌트
function PropsTracker({ Component, ...props }) {
    useEffect(() => {
        console.log('Props changed:', props);
    }, [props]);

    return <Component {...props} />;
}

 

5. 성능 최적화 전략

5.1 메모리 사용량 최적화

function optimizedComponent() {
    // 큰 데이터셋 처리
    const [data, setData] = useState(() => {
        // 초기화 함수를 통한 지연 로딩
        return heavyComputation();
    });

    // 메모이제이션을 통한 최적화
    const processedData = useMemo(() => {
        return data.map(item => heavyProcess(item));
    }, [data]);
}

5.2 리소스 관리

function ResourceManager() {
    useEffect(() => {
        // 1. 이벤트 리스너
        const handleScroll = () => {
            console.log(window.scrollY);
        };
        window.addEventListener('scroll', handleScroll);

        // 2. WebSocket 연결
        const ws = new WebSocket('wss://example.com');
        ws.onmessage = (event) => {
            console.log('Message:', event.data);
        };

        // 3. 타이머
        const timer = setInterval(() => {
            console.log('Timer tick');
        }, 1000);

        // cleanup 함수에서 모든 리소스 정리
        return () => {
            // 이벤트 리스너 제거
            window.removeEventListener('scroll', handleScroll);
            
            // WebSocket 연결 종료
            ws.close();
            
            // 타이머 정리
            clearInterval(timer);
        };
    }, []);

    return <div>Resource Manager</div>;
}

 

마치며

메모리 누수는 React와 JavaScript 애플리케이션에서 흔히 발생할 수 있는 문제지만, 적절한 이해와 대응을 통해 효과적으로 관리할 수 있습니다. 이 글에서 다룬 내용들을 실제 프로젝트에 적용하여 더 안정적이고 효율적인 애플리케이션을 만들어보세요.

 


 

참고 자료

 

https://covelope.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8javascript-%EC%97%94%EC%A7%84%EB%9F%B0%ED%83%80%EC%9E%84%ED%9E%99%EC%8A%A4%ED%83%9D%EC%9D%B4%EB%B2%A4%ED%8A%B8%EB%A3%A8%ED%94%84%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4

 

자바스크립트(javascript) - 엔진,런타임,힙,스택,이벤트루프,프로세스

자바스크립트 엔진(javascript engine) JavaScript 엔진은 JavaScript 코드를 해석하고 실행하는 역할을 담당. 주요 JavaScript 엔진에는 V8(Chrome 및 Node.js에서 사용), SpiderMonkey(Firefox에서 사용), JavaScriptCore(Safari

covelope.tistory.com

https://covelope.tistory.com/entry/javascript-call-stack-%ED%98%B8%EC%B6%9C-%EC%8A%A4%ED%83%9D-%EC%BD%9C%EB%B0%B1-%ED%81%90-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84

 

[JavaScript] - 호출 스택, 콜백 큐, 이벤트 루프: 실행 프로세스 이해하기

호출 스택, 실행 프로세스 복습 하기 호출 스택(call stack), 콜백 큐(callback queue), 이벤트 루프(event loop) 자바스크립트 코드가 실행되고 자바스크립트 엔진은 각각의 함수 호출을 호출 스택에 추가,

covelope.tistory.com

https://covelope.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EB%B9%84%EB%8F%99%EA%B8%B0Asynchronous-%EA%B3%BC%EC%A0%95-featAST

 

자바스크립트 비동기(Asynchronous) 과정 .feat(AST)

자바스크립트에는 동기식과 비동기식이 있다. 동기식(Synchronous) 이란 단순하게 순서대로 실행되는데 1번이 실행되고 1번이 끝이 나면 2번이 실행되고 끝나면 그다음 작업들이 이런 과정으로 처

covelope.tistory.com

https://covelope.tistory.com/entry/%EB%A6%AC%EC%95%A1%ED%8A%B8React-useState-%ED%81%B4%EB%A1%9C%EC%A0%80

 

리액트(React) - useState / 클로저

리액트에서 함수형 컴포넌트 이전 클래스형 컴포넌트 사용시에는 상태를 지역변수 state에 정의하고 상태를 변경할 메소드 안에 setState 메소드를 넣어서 상태를 변경 했다. 그럼 왜 state변수를 직

covelope.tistory.com

 

728x90
반응형

'front_end' 카테고리의 다른 글

프론트(FE) 면접 질문 리스트  (0) 2024.08.06
멋쟁이 사자처럼 <Front_end> 과정 OT 후기  (0) 2021.10.29
프론트 엔드(Front-end)란?  (0) 2021.10.05