일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 스택
- APOLLO
- C
- 웹팩
- 코딩테스트
- 프로세스
- 컨테이너
- vue3
- 연결리스트
- Machine Learning
- 알고리즘
- GraphQL
- RxJS
- cors
- 브라우저
- 프로그래머스
- 프론트엔드
- RT scheduling
- 큐
- 이진탐색
- 해시테이블
- 연결 리스트
- 자바스크립트
- 배열
- 포인터
- 릿코드
- 자료구조
- 타입스크립트
- alexnet
- pytorch
- Today
- Total
프린세스 다이어리
가비지콜렉터(Garbage Collector)가 무엇인가요? 필요한 이유와 동작 방식 정리 본문
1. 가비지가 무엇이고, 왜 가비지 컬렉터가 필요한가요?
가비지 콜렉터(Garbage Collector)는 애플리케이션이 실행될 때, 메모리에 저장은 되어 있지만 어디에서도 사용되지 않는 변수를 알아서 메모리에서 치워 주는 관리 방법이다. 제대로 이해하기 위해서는 먼저 가비지, 즉 쓰레기 메모리가 무엇이고 왜 생기는지를 알아야 한다. 이전에 업로드한 동적 메모리 할당의 개념을 정리한 글과 내용이 일부 중복되어 있다.
일반적으로 C에서 배열을 선언할 때, 사전에 적절한 크기만큼 할당해 준다.
int a[20] = "hello world";
그런데 항상 사전에 메모리를 할당해주는 게 아니라, 프로그램 실행 도중에 필요시 메모리를 할당해야 할 때가 있다. C에서는 malloc() 함수를 사용해, 원하는 만큼의 메모리 공간을 확보해 준다.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *a = malloc(sizeof(int));
printf("%d\n", a);
a = malloc(sizeof(int));
printf("%d\n", a);
}
int만큼의 4bytes 메모리를 어딘가에 할당한 후, 할당에 성공하면 메모리 주소를 포인터 a에 넣고, 그렇지 못하면 null을 출력하는 코드다. 포인터 a에는 처음에 메모리 주소가 담겨 출력될 것이고, 이후에는 또 다른 4 bytes 만큼의 메모리를 어딘가에 할당해주고, 그 주소를 다시 출력해줄 것이다. 실행하면 다음과 같이 출력된다.
-1010804512
-1010804496
처음 코드를 실행했을 때는 각각 -1010804512, -1010804496 주소가 출력이 됐다. 그리고 다시 프로그램을 실행해 보면,
1178622176
1178622192
처음과 아예 다른 주소를 출력함을 알 수 있다. 이렇게 C에서 malloc() 함수로 동적 메모리 할당을 하면, 운영체제가 그때그때 남아 있는 메모리를 할당해주고 있음을 알 수 있다.
그런데, 이렇게 메모리가 할당된 변수들은 그냥 내버려 두면 사용하지 않을 때도 계속 메모리의 한 영역을 차지하는 쓰레기에 비유된다. 당장 위의 코드만 봐도, 한 main() 함수 내에서 주소값을 다시 할당해주고 있는데도 각각 다른 주소가 출력이 된다. 여기서 말하는 컴퓨터의 메모리의 영역을 정리해 보면 다음과 같다.
코드 영역 | 한 줄 한 줄 실행할 수 있는 소스코드 |
데이터 영역 | 변수 중에서 전역 변수와 정적 변수를 담고 있다. |
힙 영역 | 동적 할당 변수를 담는다. |
스택 영역 | 함수마다 담고 있는 지역변수, 매개변수 등을 담고 있다. |
위에서 int a[20] 으로 정의해 준 변수는 static allocation으로 정적 메모리이기 때문에 스택 영역에 저장이 되어, 따로 메모리 해제를 해 주지 않아도 해당 블록이 종료가 되는 동시에 비워진다. 예를 들어 한 함수 안에서 선언된 a 배열의 경우에는, 함수의 실행이 끝나면 저절로 메모리 해제가 된다는 의미다. 하지만, malloc() 함수를 이용해 동적으로 메모리 할당을 해 준 포인터 변수는 dynamic allocation으로, 얼마의 공간이 필요한지 런타임에 판단이 되어야 하므로 힙 영역에 저장이 되고, 반드시 free() 함수로 메모리 해제를 해 주지 않으면 영원히 메모리를 잡아먹고 있게 된다. 이렇게 조금씩 쌓인 메모리는 나중에 점점 쌓여, 프로세스 무게를 늘려 결국 메모리 누수까지 이어지게 된다.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *a = malloc(sizeof(int));
printf("%d\n", a);
free(a);
a = malloc(sizeof(int));
printf("%d\n", a);
free(a);
}
메모리 해제 함수를 적용한 후 다시 코드를 실행해 보면 다음과 같이 출력된다.
-1556064032
-1556064032
위에서 메모리 해제를 해 주고 나서, 다시 메모리를 할당해주니, 위에서 할당했던 메모리 주소가 비워진 상태이기 때문에 아래에서는 같은 메모리 주소를 가리키는 것을 확인할 수 있다. 일반적으로 이렇게 간단한 코드의 경우 프로그램이 종료되는 순간 메모리가 해제되기 때문에 굳이 free() 함수를 써 줄 필요가 없었지만, 상용 프로그램을 짤 때 지속적으로 힙 영역에 동적 메모리 할당이 일어나는 상황에서는 꼭 필요없는 쓰레기 메모리를 해제해주어야 함을 보여주는 예제 코드였다.
위에서 살펴본 것과 같이 C나 C++의 경우, 동적 메모리 할당 후 필요 없어진 변수는 꼭 프로그래머가 수동으로 메모리 해제를 해 주어야 한다. 직접 메모리 해제를 해 주어야 한다는 점에서 이미 해제한 메모리를 다시 해제하려 한다던지, 해제를 잊는다던지, 해제한 변수에 또 다른 값을 참조하도록 한다던지 하는 실수를 일으키기 딱 좋은 상황이었다.
하지만 C#, 자바, 자바스크립트 등 고수준 언어는 기본적으로 메모리 해제를 해 주는 기능이 있어서 프로그램 실행 중간에 메모리가 수집된다. 이 역할을 해 주는 것을 가비지 콜렉터라고 하고, 이런 언어를 managed language라고 한다. 가비지 콜렉터는 해당 블록의 실행이 종료되면 블록 안의 변수가 참조하고 있던 메모리를 자동으로 해제해주기 때문에 프로그래머의 수고로움을 덜 수는 있지만 항상 믿음직스러운 결과를 나타내지는 못하기 때문에 메모리 누수에 대해 명확히 인지하고 환경별로 적절한 방식을 채택하는 것이 중요하다. 가비지 콜렉터의 한계점이 왜 발생하는지를 가비지 콜렉터의 주요 동작 방식을 정리하며 알아보려 한다.
2. 가비지 콜렉터의 동작 방식
2-1. 참조 횟수 카운팅 기반(Reference Counting)
레퍼런스 카운팅이라는 개념은 내부적으로 레퍼런스 카운트를 유지하고 있다가 이 값이 0이 되면 그 메모리는 더 이상 쓰이지 않는다고 판단하고 가비지 콜렉팅을 하는 개념이다.
let object1 = { object2: { value: 2 } };
object1 = 2;
자바스크립트에서 원시타입인 string, number, boolean, null 등은 스택 메모리에 저장되며, 참조 데이터 타입인 object, array, function 등은 힙 메모리에 저장이 된다. 위의 코드에서는 첫째 줄에서 object1이 초기화되면서 가리키던 메모리는 2번째 줄에서 더 이상 필요 없게 되었다. 따라서 해당 메모리는 참조 카운트가 0이 되어 가비지 컬렉션이 대상이 된다.
자바스크립트에서 참조는 두가지가 있는데, 하나는 implicit 참조고, 다른 하나는 explicit 참조가 있다. 위의 첫째줄 코드에서는 명시적으로 객체를 선언했지만, 암묵적으로 참조하고 있는 프로퍼티도 가지고 있다.
function a() {};
a.prototype // {constructor: f}
이렇게 명시적으로 선언하지 않았지만 기본적으로 참조하고 있는 프로퍼티도 있다. 이러한 것들로 가비지 콜렉터는 메모리를 참조하고 있는지 아닌지 판별한 후, 아무데서도 참조하고 있지 않은 메모리를 수집해 준다. 이 방법은 한계가 있는데, 어디에서도 사용되고 있지 않지만 동시에 참조 횟수는 0이 아닌 경우도 존재한다는 점이다. 바로 순환 참조하는 경우를 말하는데 코드로 나타내면 다음과 같다.
let a = {};
let b = {};
a.other = b;
b.other = a;
서로 참조를 하게 되면 변수의 레퍼런스 카운팅이 0이 되지 않으므로 가비지로 인식할 수 없어 가비지 콜렉팅이 일어나지 않는다.
2-2. 추적 기반(Mark-and-Sweep)
mark-and-sweep 방식은 위의 참조 카운팅 방식의 한계를 보완한다. 어떤 것이 가비지가 아닌지 마크한 후, 마크되지 않은 메모리는 수집하는 방법이다.
이 방법에서는 먼저 모든 글로벌 변수 목록을 작성한 후, 그 루트의 children을 전부 다 돌면서 자식의 자식으로 연결되어 닿을 수 있는 메모리는 모두 마크한다. 그리고 검사가 끝나면 마크되지 않은 메모리를 정리해서 OS로 리턴해주는 것이다. 참조 카운팅 방식에서는 서로 참조하는 값이 있을 경우 가비지로 인식하지 못하는 문제가 있었는데, 이 방법을 이용하면 루트에서는 참조할 수 없기 때문에 가비지로 인식이 된다.
이 방식에도 한계점은 있다. 표시 단계에서 메모리 참조 내용이 변경되지 않아야 하기 때문에, 전체 시스템의 실행이 정지된다는 점이다. 어떤 해외 블로그에서는 이를 식당에서 반찬을 집어먹으려고 할 때 직원분이 반찬 접시를 치워버리는 일이 발생하지 않기 위해 프로그램이 정지된다고 비유를 한다. 애플리케이션이 지속적으로 동작해야 할 때 잠깐씩 동작이 멈춘다면 사용자들의 눈에 띄고 결국 사용자 경험이 저하되는 결과를 일으킬 수 있다.
'운영체제, 컴퓨터 구조' 카테고리의 다른 글
[OS] 멀티 프로세스와 멀티 스레드, 둘 중에 뭐가 좋아? (1) | 2021.10.05 |
---|---|
[OS] 프로세스 구조와 레지스터 알아보기 (0) | 2021.10.05 |
[OS] 인터럽트란? 인터럽트 발생 상황과 내부 동작 방식 (0) | 2021.10.05 |
[OS] 프로세스 상태(Ready, Running, Blocked)와 선점형/비선점형 스케줄러 (0) | 2021.10.04 |
[OS] 프로세스 스케줄러(Process Scheduler) 알고리즘 정리 (0) | 2021.10.04 |