프로그래밍 언어/JavaScript

가비지 컬렉션(Garbage Collection)

hyuga_ 2024. 3. 24. 16:18

최근 면접에서 가비지 컬렉션에 대한 질문을 받았다. 동적 메모리 할당을 공부할 적에 잠깐 본 적이 있는 주제라 기초적인 수준의 답변은 했으나, 짧은 경험상 GC에 대해서 제대로 들여다본 적이 없었다는 생각이 들어 아쉬웠다. 

 

특히 '자바스크립트에서도 GC가 존재하는가?' 라는 질문을 주셨을 때, 자바스크립트를 다뤘으면서도 한번도 이를 고민해본 적이 없다는 점을 반성하게 되었다. '코드를 짜면서 free를 해준 기억은 없기에 엔진 내부적으로 GC가 동작하지 않을까 생각한다' 정도의 답변을 했으나, 이 기회에 제대로 알아야겠다는 생각을 하게 되었다. 해당 내용은 아래 포스팅에 마저 기록!

 


1. 가비지 컬렉션이란?

가비지 컬렉션(GC)은 동적 메모리 관리의 한 형태로, 프로그램이 할당했던 메모리 영역 중에서 더 이상 사용되지 않는 부분을 자동으로 찾아내서 해제하는 기능이다. Java, JavaScript, Python 등 고수준 언어들은 기본적으로 GC를 지원한다. 

 

가비지 컬렉션이 없는 언어(예를 들어 C)에서는 동적 메모리 할당을 해주고 더 이상 접근할 필요가 없을 때는 꼭 free를 해주어야 한다. 그렇지 않으면 점차 memory leak이 발생하고 런타임 동안 점차 애플리케이션 성능이 저하되기 때문이다. (TMI 지만 컴퓨터 재부팅은 이렇게 누수된 자원들을 다시 회수하기 때문에 실제로 효과있는 방법이다.)

 


2. 가비지 컬렉션은 어떻게 이루어지는가?

가비지 컬렉션 방식의 대표적인 방식으로는 1) 마크-앤-스위프, 2) 레퍼런스 카운팅이 있다. 

 

1) 마크-앤-스위프(Mark-and-Sweep)

가비지 컬렉션의 가장 기본적인 알고리즘 중 하나는 '마크-앤-스위프(Mark-and-Sweep)'이다.

 

출처: https://www.youtube.com/watch?v=1BoJZqxFYfQ

 

마크(Mark) 단계

'마크' 단계는 프로그램의 메모리에서 사용 중인 객체를 식별하는 과정이다.

 

이 과정은 다음과 같이 진행된다: 루트 식별 -> 탐색 & 마킹

  1. 루트 집합(Root Set) 식별: 루트 집합을 식별한다.
  2. 가능한 모든 객체에 대한 접근: 루트 집합에서 시작하여, 참조를 따라가며 접근할 수 있는 모든 객체를 탐색한다. 이 과정에서 객체에 도달할 수 있는 모든 경로를 추적한다. 각 객체에 대해 방문 표시를 하게 되며, 이 표시는 해당 객체가 여전히 사용 중임을 의미한다.
  3. 마킹: 접근 가능한 모든 객체에 마킹을 한다. 마킹은 객체가 활성 상태임을 나타내는데, 이는 객체가 아직 유효하게 사용되고 있거나, 다른 활성 객체로부터 참조되고 있음을 의미한다. 이 마킹 과정을 통해 어떤 객체가 가비지인지를 판별할 수 있다.
  4. 참조 그래프의 탐색: 이 과정은 사실상 메모리 내의 참조 그래프를 탐색하는 것과 유사하다고 한다. 모든 객체들은 노드로, 객체 간의 참조는 엣지로 표현될 수 있으며, 가비지 컬렉터는 이 그래프를 따라가며 마킹 작업을 수행한다.

 

Q. 루트 집합?

더보기

[루트 집합]

GC 과정에서 '루트(Root)'는 GC가 메모리에서 어떤 객체들을 살리고 어떤 객체들을 가비지로 간주해야 할지 판단하는 데 있어 시작점으로 삼는 객체들을 의미한다. 가비지 판별 여부는 '루트 객체부터 시작해서 해당 객체까지 접근 가능한가? '이다. 

루트에 속하는 객체의 예시
전역 변수: 전역 스코프에 선언된 변수 또는 객체는 어디서든 접근할 수 있으므로, GC에 의해 루트로 간주된다.
- 현재 호출 스택에 있는 함수의 지역 변수와 매개변수는 실행 컨텍스트가 활성화되어 있는 동안에는 루트로 간주된다. 이는 함수 내부에서 생성하거나 사용하는 객체들이 가비지 컬렉션의 대상이 되지 않도록 보호하기 위함이다.
- 내부 시스템에서 참조하는 객체: 자바스크립트 엔진이나 호스팅 환경(예: 웹 브라우저)에서 내부적으로 사용하는 객체도 루트에 포함될 수 있다. 예를 들어, 웹 API 호출에 의해 생성된 콜백 함수나 이벤트 리스너 객체들이 이에 해당될 수 있다. 

이러한 루트 집합을 시작점으로 하여, 가비지 컬렉터는 참조 체인을 따라가며 접근 가능한 모든 객체를 탐색하고 마크한다. 이 과정에서 루트로부터 직접적이거나 간접적으로 접근할 수 없는 객체들은 더 이상 사용되지 않는 것으로 간주되어 가비지 컬렉션의 대상이 된다.

루트 집합이 크고 복잡할수록 마킹 과정에서 검사해야 할 객체의 수가 늘어나므로, 가비지 컬렉션의 오버헤드가 증가할 수 있다. 따라서, 가비지 컬렉션 최적화의 한 방법은 루트 집합의 크기와 복잡성을 최소화하는 것이다.

 

 

이 '마크' 단계를 통해, 가비지 컬렉터는 사용 중인 객체와 사용되지 않는 객체(가비지)를 구분할 수 있게 된다. 이후 '스위프(Sweep)' 단계에서는 마킹되지 않은 객체들, 즉 사용되지 않는 객체들을 메모리에서 해제한다.

 

그 다음 '스위프' 단계에서는 힙 메모리 전체를 스캔하여 마킹되지 않은 객체들을 가비지로 간주하고 메모리를 해제한다.

 

스위프(Sweep) 단계

스위프 단계의 작업은 다음 단계를 거친다:

  1. 마킹된 객체 검사: 힙 메모리(Heap Memory) 내의 모든 객체들을 검사하여, 마킹되지 않은 객체들을 찾아낸다.
  2. 메모리 해제: 이러한 객체들이 차지하고 있는 메모리 영역을 해제한다. 마킹되지 않은 객체들은 더 이상 애플리케이션 코드에서 접근할 수 없는 객체들이다. 
  3. Fragmentation 처리: 메모리 해제 과정에서 Fragmentation(메모리 내에 연속되지 않은 빈 공간들)이 발생할 수 있다. 따라서 일부 GC는 스위프 단계에서 Fragmentation 문제를 처리하기도 한다. 이는 메모리를 재구성하여 빈 공간들을 합치고, 메모리 사용 효율을 높이는 작업을 포함할 수 있다.

 

 

마크-앤-스위프 장단점

  • 장점
    • 순환 참조 해결: 마크 앤 스위프 방식은 레퍼런스 카운팅 방식에서 발생할 수 있는 순환 참조 문제를 자연스럽게 해결한다. 객체 간 순환 참조가 있더라도, 더 이상 접근할 수 없는 객체들은 마킹되지 않기 때문에 가비지로 간주되고 메모리에서 해제될 수 있다.
    • 메모리 관리 효율성: 사용되지 않는 모든 객체를 정확히 식별하고 해제할 수 있으므로, 메모리 관리의 효율성이 높다. 프로그램의 메모리 사용량을 최적화하고, 메모리 누수를 방지하는 데 효과적이다.
  • 단점
    • 가비지 컬렉션 중단(Garbage Collection Pause): 마크 앤 스위프 가비지 컬렉션을 수행하는 동안 프로그램의 실행이 일시적으로 중단될 수 있다. 이 "중단 시간"(Stop the world 라고 부른다)은 가비지 컬렉션을 수행하는데 필요한 시간에 비례한다. 큰 메모리 풀을 관리해야 하는 애플리케이션의 경우, 이 중단 시간이 사용자 경험에 부정적인 영향을 줄 수 있다.
    • Fragmentation: 연속적이지 않은 메모리 공간이 해제될 때 Fragmentation이 발생할 수 있다. 위에서 언급했듯 스위프 과정에서 이를 처리해주는 경우가 있다.
    • 오버헤드: 모든 객체를 마킹하고, 접근할 수 없는 객체를 식별하는 과정에서 추가적인 컴퓨팅 자원이 필요하다. 대규모 애플리케이션에서는 이 오버헤드가 성능 저하로 이어질 수 있다.

 

 

 

2) 레퍼런스 카운팅(Reference Counting)

Reference counting은 객체가 다른 객체로부터 몇 번 참조되는지 카운트하는 방식이다. 어떤 객체의 참조 횟수가 0이 되면, 즉 다른 어떤 객체도 그 객체를 참조하지 않게 되면 그 객체는 가비지로 간주되어 메모리가 해제된다.

 

이 방식의 핵심 원리는 간단하다. '객체에 대한 참조가 생성될 때마다 참조 카운트를 증가, 참조가 제거될 때마다 카운트를 감소 -> 객체의 참조 카운트가 0이 되면, 그 객체는 더 이상 필요하지 않은 것으로 간주되어 메모리 해제'

출처: https://www.youtube.com/watch?v=1BoJZqxFYfQ

 

작동 과정은 다음과 같다:

  1. 참조 카운팅 초기화: 새로운 객체가 생성될 때, 그 객체의 참조 카운트는 0으로 초기화된다.
  2. 참조 추가: 객체를 참조하는 변수나 다른 객체, 함수 등이 생성될 때, 해당 객체의 참조 카운트를 1 증가시킨다.
  3. 참조 제거: 객체에 대한 참조가 더 이상 사용되지 않게 되면 해당 객체의 참조 카운트를 1 감소시킨다.
    (ex. 변수가 다른 값으로 덮어쓰여지거나, 함수 실행이 종료되어 지역 변수가 사라지는 경우)
  4. 메모리 해제: 어떤 객체의 참조 카운트가 0이 되면, 해당 객체는 더 이상 애플리케이션에서 필요로 하지 않는 것으로 판단하고, 메모리 해제된다.

 

참조 카운팅의 장단점

  • 장점
    1. 실시간성: 객체의 참조 상태가 변경될 때 즉각적으로 카운트를 업데이트하기 때문에, 메모리가 필요할 때 바로 해제될 수 있다.
  • 단점
    1. 순환 참조 문제: 두 객체가 서로를 참조하는 경우(순환 참조), 이들의 참조 카운트가 절대로 0이 되지 않아 메모리 누수가 발생할 수 있다.
    2. 오버헤드: 모든 참조 연산에 대해 카운트를 갱신해야 하므로, 참조 변경이 빈번한 시스템에서는 성능 저하가 발생할 수 있다.

출처: https://www.youtube.com/watch?v=1BoJZqxFYfQ

순환 참조 해결 방안

순환 참조 문제를 해결하기 위한 일반적인 방법 중 하나는 약한 참조(Weak Reference)를 사용하는 것이다. 약한 참조는 참조 카운트에 영향을 주지 않으므로, 순환 참조가 발생해도 하나의 객체가 가비지 컬렉션의 대상이 될 수 있게 한다. 

 


 

 

3. 가비지 컬렉션에도 단점이 있을까?

위 내용들을 종합해서 보면, 확실히 가비지 컬렉션에도 유의해야할 단점들이 있다.

  1. Garbage Collection Pause
  2. 순환 참조 문제 (레퍼런스 카운팅 방식)