힙 메모리의 단편화 fragmentation 발생 이유와 해결방법
2025-07-20 11:45

시작하기
개발을 하다보면, 메모리 누수, GC, 힙 메모리 등의 용어를 들어본 적이 있을 것이다. 단순히 메모리가 부족하거나 프로그램이 무거우면 느려진다고 생각할 수 있겠지만, 사실 힙(Heap) 메모리 영역의 단편화 문제 때문에 발생할 수 있다. 힙 메모리가 단편화 문제가 왜 발생하고 어떻게 GC가 이 문제를 해결하고 있는지 작동 방식에 대해 알아보자.
단편화(fragmentation)란 왜 발생하는 것일까?
힙 메모리 구조

운영체제가 프로그램을 실행할때 힙 영역은 런타임에 필요한 크기의 메모리를 동적으로 할당하고 해제하는 메모리를 위한 공간이다. 개발자가 malloc(C 언어) 또는 new(c++, Java 등)과 같은 함수를 사용해서 메모리를 요청하면 힙에서 공간이 할당된다. 자바스크립트에서는 객체를 생성하거나 클로저를 만들면 대부분 Heap에 저장된다. 수동 또는 자동으로 메모리 해제가 필요하고, 개발자가 명시적으로 해제하지 않으면 메모리 누수(Memory leak)가 발생할 수 있다.
참고로, 스택 영역은 함수 호출과 지역 변수를 저장하는 영역이고, 자동으로 관리된다.
단편화(fragmentation) 문제
프로그램이 실행되는 동안, 다양한 크기의 메모리 블록이 필요에 따라 할당되고 해제되는 과정을 거친다. 예를 들어 웹 브라우저는 페이지를 로드할 때마다 이미지를 위한 메모리, 텍스트를 위한 메모리 등 다양한 크기의 공간을 요청하고, 페이지를 닫거나 다른 페이지로 이동했을때 이 공간들을 해제한다. 이렇게 메모리 공간이 할당되고 해제될 때, 사용 가능한 빈 공간이 조각 조각 흩어져 할당이 실패하게 되는 메모리 단편화가 발생된다.
메모리 단편화 과정
단편화란 크게 외부 단편화와 내부 단편화로 나뉜다.
외부 단편화 (External Fragmentation)
- 할당되지 않은 메모리 공간들이 존재하지만, 모두 작게 흩어져 있어서 실제로는 전체 여유 공간의 합이 충분하더도 큰 블록의 요청을 만족 시킬 수 없는 현상이다
- 다양한 크기의 요청과 할당 예시)
- 프로세스 A가 10KB, 프로세스 B가 50KB, 프로세스 C가 20KB를 요청해서 할당받는다
-
- 시간이 지나면서 중간에 할당되었던 프로세스 B의 메모리 블록들이 해제된다
- 프로세스 D가 30KB 메모리 요청을 요청해서 50KB의 빈 공간이 있었지만 이 공간 중간에 메모리를 할당한다
-
- 이후 프로세스 A와 C도 종료되어서 각각 10KB와 20KB의 메모리가 해제된다
-
- 이제 총 50KB (10KB + 20KB + 20KB)의 빈공간이 있지만, 이 공간들은 모두 연속적이지 않고 흩어져 있어서 만약 40KB의 연속된 메모리가 필요한 새로운 프로세스가 나타난다면 충분한 메모리가 있음에도 불구하고 메모리 할당에 실패하게된다. 이것이 바로 외부 단편화 과정이다.
-
내부 단편화 (Internal Fragmentation)
- 내부 단편화는 할당된 메모리 블록 내부에 실제 사용되지 않고 남는 공간이 발생하는 현상이다. 주로 고정된 크기의 블록 단위로 메모리를 할당하거나 메모리 관리 시스템이 특정 크기 단위로만 할당할 수 있도록 설계되었을 때 발생한다.
- 운영체제가 메모리를 4KB와 같이 고정된 페이지 혹은 블록 단위만 할당한다고 가정한다. 프로세스 P1이 3KB의 메모리를 요청하면 운영체제는 최소 단위인 4KB 페이지를 P1에게 할당한다. 이때 3KB는 사용되지만, 할당받은 4KB 중 1KB는 사용되지 않고 남게 된다. 여기서 사용되지 않은 1KB가 내부 단편화이다. 이 공간은 P1 외의 다른 프로세스는 사용할 수 가 없다
- 다른 프로세스도 비슷한 방식으로 메모리를 할당받게 되면, 각 블록 내부에 사용되지 않은 작은 공간들이 쌓여 전체적으로 비효율적인 메모리 사용을 초래하게 된다
메모리 단편화는 동적 메모리 할당의 결과물로 메모리 자원의 효율성을 떨어뜨리고 때로는 충분한 메모리가 있음에도 불구하고 프로그램이 더 이상 메모리를 할당받지 못하게 만드는 원인이 된다
단편화를 해결하는 전략들
운영체제나 런타임 환경에서는 이러한 단편화 문제를 줄이기 위한 여러 전략들을 사용한다.
1. 메모리 풀 (Memory Pool)
- 메모리 풀은 특정 크기나 타입의 객체를 미리 대량으로 할당해두고 필요할 때마다 이 할당된 공간을 가져다 쓰고 반납하는 방식이다. 할당/해제 비용이 적고 단편화 방지에 효과적이다. 단점은 미리 할당해둔 풀의 크기가 실제 필요한 양보다 크다면 메모리 낭비가 될 수 있다. 주로 게임 엔진, 프론트엔드에서도 Object Pooling 기법으로 활용 가능하다.
2. 버디 시스템 (Buddy Allocation)
버디 시스템은 메모리 할당 요청을 2의 거듭제곱 단위로 메모리를 나누고 병합해서 관리하는 방식이다.
- 전체 메모리 공간을 2의 거듭제곱 크기의 가장 큰 블록으로 시작한다.(예.128KB)
- 메모리 요청이 들어오면 해당 요청을 만족시킬 수 있는 최소 2의 거듭제곱 크기의 블록을 찾는다
- 만약 적합한 블록이 없다면, 현재 사용 가능한 큰 블록을 동일한 크기의 두 버디(Buddy) 블록으로 나눈고 이 과정은 요청된 크기보다 작아질 때까지 재귀적으로 반복된다
- 메모리 해제 시에는 해제된 블록의 버디 블록이 비어있으면, 두 버디 블록을 합쳐서 더 큰 블록으로 만든다(병합). 이 과정도 재귀적으로 반복될 수 있다.
- 장점은 연속된 블록 병합이 쉬워어 외부 단편화에 강하지만, 내부 단편화가 발생할 수 있고 2의 거듭제곱 크기만 사용하므로 다양한 크기의 요청에 유연하게 대응하기 어렵다는 단점이 있다.
3. GC + Compaction (가비지 컬렉션 + 압축)
- 주로 Java, JavaScipt, C# 등과 같은 자동 메모리 관리 언어의 런타임 환경에서 단편화를 GC가 직접 해결한다. 가비지 컬렉션이 사용되지 않은 메모리를 회수하고, 압축은 회수된 메모리 공간을 재배치해서 외부 단편화를 해결한다.
- Mark(표시) - 가비지 컬렉터가 메모리 힙을 스캔해서 현재 사용 중인(reachable)객체들을 표시한다.
- Sweep (스윕) - 더 이상 사용되지 않는 객체들의 메모리를 해제한다
- Compaction (압축) - 해제된 공간들을 제거하고, 사용 중인 객체들을 메모리의 한쪽 끝으로 밀어 넣어서 연속적인 하나의 큰 빈 공간을 만든다.
장점
- 개발자가 직접 메모리 해제할 필요가 없기 때문에 메모리 누수 위험이 줄어든다
- 압축을 통해 외부 단변화를 근본적으로 해결하고 연속적인 큰 메모리 블록을 확보할 수 있다
- 개발자가 메모리 관리에 대한 부담을 덜 수 있다
단점
- 가비지 컬렉션과 특히 압출과정은 많은 CPU 자원과 시간을 소모한다
- 압출 과정 중에서 애플리케이션의 실행이 일시적으로 중단되는 Stop-The-World 현상이 발생하여, 실시간 응답이 중요한 애플리케이션에는 문제가 될 수가 있다. (최근 GC는 이 시간들을 최소화하기 위한 다양한 기술을 제공해준다)
V8 엔진의 Scavenger
자바스크립트 엔진인 V8 엔진은 Scavenger로 가비지 컬렉션(GC) 역할을 한다. 주로 새로 생성된(young) 객체가 저장되는 신생 영역(New Space)을 관리한다. Minor GC 라고도 부른다.
- Cheney’s Algorithm이라는 복사(Copying) GC 알고리즘을 기반으로 동작하며, New Space를 To-Space와 From-Space라는 두 개의 세미 스페이스(semi-space)로 나눈다
- Scavenger는 신생 영역의 객체들을 자주 빠르게 정리해서 GC 작업의 효율성을 크게 높인다
- 메모리 영역의 크기가 작고 살아있는 객체만 옮기는 복사GC을 사용하기 때문에 Stop-The-World 시간이 매우 짧다 (보통 1ms 미만)
- 객체를 복사하는 과정에서 메모리가 자동으로 압축되기 때문에 신생 영역에서는 외부 단편화가 발생하지 않는다
JS와 브라우저 메모리 모델
브라우저에서 메모리 누수가 발생할 수 있는 경우가 어떤 경우일까?
전역 변수 및 암시적 전역변수 오남용할 경우
setInterval
이나setTimeout
으로 설정된 타이머 함수 내에서 해제하지 않은 경우clearInterval
또는clearTimeout
로 반드시 타이머를 해제시켜줘야 한다
function pingServer() {
setTimeout(() => {
console.log('서버에 핑 보내기');
pingServer(); // 재귀 호출
}, 3000);
}
이벤트 리스너를 해제하지 않을 경우
function addListener() {
const button = document.getElementById('myButton');
button.addEventListener('click', function handleClick() {
console.log('clicked!');
});
}
addListener()
를 여러 번 호출되면 리스너가 계속 쌓이게 되고 button 요소를 DOM에서 제거해도 리스너 함수가 참조하고 있으면 GC가 회수하지 못하는 문제가 생기게 된다. 특히 클로저 안에 외부 데이터나 큰 객체를 참조하고 있으면 메모리 누수 가능성이 증가한다.removeEventListener
를 사용해서 추가한 리스너를 반드시 해제해줘야 한다. 보통 React, Vue 등 프레임워크는 컴포넌트 생명주기 훅에서 자동으로 처리해주지만, 바닐라 JavaScript에서는 주의해야한다.
클로저 (Closures)의 잘못된 사용
- 자바스크립트의 클로저는 외부 스코프의 큰 객체를 참조할 수 있는 강력한 기능이다. 하지만 다음 코드를 보면
handleClick
이largeData
를 계속 참조하기 때문에largeData
는 메모리에서 해제되지 않고 버튼이 DOM에서 제거되어도 이벤트 리스너가 살아 있으면 메모리 누수가 발생하게 된다.
function setup() {
const largeData = new Array(1000000).fill('😵');
document
.getElementById('myBtn')
.addEventListener('click', function handleClick() {
console.log(largeData[0]); // 클로저로 largeData를 계속 참조함
});
}
요약하기
- 힙 메모리는 동적 객체를 저장하는 공간이다
- 힙 메모리의 할당과 해제가 반복되면 단편화 문제가 발생한다
- 이 문제를 해결하기 위해 운영체제는 다양한 전략을 사용한다
- 브라우저의 메모리 누수를 해결하기 위해 GC가 자동으로 해결하고 단편화를 관리해준다