안녕하세요! 오늘은 React와 JavaScript 애플리케이션에서 발생하는 메모리 누수 문제를 깊이 있게 다뤄보겠습니다. 실제 프로젝트에서 마주칠 수 있는 다양한 시나리오와 해결 방법을 함께 알아보겠습니다.
목차
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)은 두 가지 주요 메모리 영역을 가집니다:
- Heap Memory (힙 메모리)
- 구조화되지 않은 큰 메모리 영역으로, 동적 메모리 할당에 사용됩니다.
- 객체, 배열, 함수 등 참조 타입의 데이터가 저장됨
- 크기가 동적으로 변할 수 있는 데이터를 위한 공간
- 가비지 컬렉션이 주로 이루어지는 영역
- Stack Memory (스택 메모리)
- 정적으로 할당된 데이터가 저장되는 영역
- 원시 타입(number, string, boolean, null, undefined) 값들이 직접 저장됨
- 객체에 대한 참조값(메모리 주소)이 저장됨
- 함수 호출 스택 정보가 저장됨
- LIFO(Last In First Out) 구조로 작동
- 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>;
}
해결 방법 설명:
- isMounted 플래그를 사용하여 컴포넌트의 마운트 상태 추적
- AbortController를 사용하여 진행 중인 fetch 요청을 취소
- 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>;
}
해결 방법 설명:
- cleanup 함수에서 열린 WebSocket 연결을 명시적으로 종료
- 모든 이벤트 핸들러를 제거하여 메모리 누수 방지
- 연결 상태를 확인하여 안전하게 종료
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을 사용할수 있음
해결 방법 설명:
- 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); // 저장된 값만 사용
};
}
해결 방법 설명:
- 클로저가 전체 객체 대신 필요한 값만 참조하도록 변경
- 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>;
}
해결 방법 설명:
- 함수형 컴포넌트와 useEffect를 사용하여 생명주기 관리
- cleanup 함수에서 타이머를 명시적으로 정리
- 컴포넌트 인스턴스에 대한 참조가 자동으로 정리됨
4. 성능 최적화 전략
4.1 메모리 사용량 최적화
function optimizedComponent() {
// 큰 데이터셋 처리
const [data, setData] = useState(() => {
// 초기화 함수를 통한 지연 로딩
return heavyComputation();
});
// 메모이제이션을 통한 최적화
const processedData = useMemo(() => {
return data.map(item => heavyProcess(item));
}, [data]);
}
4.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>;
}
4.3 React 특화 최적화 기법
4.3.1 상태 관리 최적화
불필요한 리렌더링은 메모리 사용량을 증가시킬 수 있습니다.
// 최적화 전
function Counter() {
const [count, setCount] = useState(0);
const [data, setData] = useState([]);
// count가 변경될 때마다 불필요하게 expensiveData 재계산
const expensiveData = data.map(item => complexCalculation(item));
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
<DataList data={expensiveData} />
</div>
);
}
// 최적화 후
function Counter() {
const [count, setCount] = useState(0);
const [data, setData] = useState([]);
// data가 변경될 때만 재계산
const expensiveData = useMemo(() => {
return data.map(item => complexCalculation(item));
}, [data]);
// 리렌더링 방지
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<div>
<p>{count}</p>
<button onClick={increment}>증가</button>
<DataList data={expensiveData} />
</div>
);
}
체크리스트
메모리 누수를 방지하기 위한 리액트 개발 체크리스트:
1. 모든 useEffect에 적절한 cleanup 함수 구현 확인
2. 비동기 작업에 AbortController 사용
3. 타이머, 이벤트 리스너, WebSocket 등 외부 리소스 정리 확인
4. 대용량 데이터 처리 시 청크 단위 처리 고려
5. useMemo와 useCallback을 통한 메모리 최적화
마치며
메모리 누수는 React와 JavaScript 애플리케이션에서 흔히 발생할 수 있는 문제지만, 적절한 이해와 대응을 통해 효과적으로 관리할 수 있습니다. 이 글에서 다룬 내용들을 실제 프로젝트에 적용하여 더 안정적이고 효율적인 애플리케이션을 만들어보세요.
참고 자료
자바스크립트(javascript) - 엔진,런타임,힙,스택,이벤트루프,프로세스
자바스크립트 엔진(javascript engine) JavaScript 엔진은 JavaScript 코드를 해석하고 실행하는 역할을 담당. 주요 JavaScript 엔진에는 V8(Chrome 및 Node.js에서 사용), SpiderMonkey(Firefox에서 사용), JavaScriptCore(Safari
covelope.tistory.com
[JavaScript] - 호출 스택, 콜백 큐, 이벤트 루프: 실행 프로세스 이해하기
호출 스택, 실행 프로세스 복습 하기 호출 스택(call stack), 콜백 큐(callback queue), 이벤트 루프(event loop) 자바스크립트 코드가 실행되고 자바스크립트 엔진은 각각의 함수 호출을 호출 스택에 추가,
covelope.tistory.com
자바스크립트 비동기(Asynchronous) 과정 .feat(AST)
자바스크립트에는 동기식과 비동기식이 있다. 동기식(Synchronous) 이란 단순하게 순서대로 실행되는데 1번이 실행되고 1번이 끝이 나면 2번이 실행되고 끝나면 그다음 작업들이 이런 과정으로 처
covelope.tistory.com
리액트(React) - useState / 클로저
리액트에서 함수형 컴포넌트 이전 클래스형 컴포넌트 사용시에는 상태를 지역변수 state에 정의하고 상태를 변경할 메소드 안에 setState 메소드를 넣어서 상태를 변경 했다. 그럼 왜 state변수를 직
covelope.tistory.com
'front_end' 카테고리의 다른 글
프론트(FE) 면접 질문 리스트 (0) | 2024.08.06 |
---|---|
멋쟁이 사자처럼 <Front_end> 과정 OT 후기 (0) | 2021.10.29 |
프론트 엔드(Front-end)란? (0) | 2021.10.05 |