본문으로 바로가기
728x90
반응형
728x170

 

Garbage Collector?

말 그대로 쓰레기 청소부 라는 의미인데, Momory에 있는 쓰레기를 청소하는 것입니다.

 

메모리에 어떤 쓰레기가 쌓이는지 알아보죠!

변수는 여러 속성을 갖지만 가장 중요한 속성은 변수는 메모리다.라는 것입니다.

 

변수는 값을 담는 그릇인데

  var a int

이런식으로 변수를 선언하게 되면 메모리에 이 그릇을 만들었다는 소리이고, 이 그릇이 있는 곳이 메모리 주소인데 그 메모리 주소를 변수 a가 나타내고 있는 것인데 이렇게 변수를 선언해서 메모리를 확보만 해놓고 쓰지 않게되고, 이러한 변수들이 많아지면 메모리만 차지하게 될 것이고, 결국에는 메모리가 부족해서 프로그램이 종료되는 상황에 이를 수 있습니다.

 

이런걸 메모리에 쌓이는 쓰레기라 해서 Momory Garbage라고 한다. 이러한 Momory Garbage가 생기는 이유는 무엇일까요?

 

만약에

var p *int
var a int
p = &a

이렇게 변수를 선언 했을 때 p도 아무도 안쓰고, a도 아무도 안 쓸 때 a의 존재는 잊혀질 것이고, 아무도 참조하지 않는 공간이 됩니다. 이 것은 지울 수가 없습니다.

 

그래서 이 공간이 Garbage로 변환되는 것이다. 사실 Go에서는 이런 경우가 생기지 않기 때문에 Go에서는 조금 어렵기 때문에 'C언어'에서 메모리 쓰레기가 어떻게 쌓이는지 알아보죠.

 

C언어에서는 변수를 위한 메모리를 스택 메모리 힙 메모리  나뉘는데 이 스택과 힙은 자료 구조를 나타내는 것입니다.

자료구조 중에 스텍이란 자료구조와 힙이라는 자료구조가 있습니다.

 

이 메모리를 할당하는 형태가 스택으로 되어있어서 스택 메모리, 형태로 되어 있어서 힙 메모리라고 부르기도 하는데

이 둘이 뭐가 다르냐면 C언어 에서는 일반적인 변수선언이 있다. 가령 int형 a를 C에서 선언하면 이렇게 선언할 수 있습니다.

int a;

이렇게 일반적인 변수는 스택 메모리에서 할당이 됩니다.

 

 
 
 
a

 

  int a;
  int b;

추가로 int 형 b를 선언하면 아래와 같이 쌓이는 구조입니다.

 

 
 
 
b
a

 

이런식으로 변수를 선언하면 스택 메모리를 사용하게 되는 것이고, 힙 메모리는 C언어에서는 힙을 프로그래머가 얼마만큼 사용하고 싶은지 할당 할 수 있습니다.

 

그 함수가 Malloc이란 함수입니다.

memory allocation의 약자인데 malloc이란 함수를 써서 사이즈를 집어넣으면 그 사이즈에 해당하는 메모리를 힙 영역에서 할당을 하고, 그 주소를 반환 합니다.

malloc(원하는 사이즈)

그래서 이 반환 주소는 여기서는 void *(포인터)라고 하는데 그냥 포인터 형태로 반환 된다고 보면 된다.

그러니까 메모리 주소 형태로 반환이 됩니다.

 

그래서 메모리 할당받은 주소를 가지고 메모리를 사용하는 것입니다.

C언어에서는 메모리를 프로그래머가 접근할 수 있는 권한이 막강하기 때문에 메모리를 크게 할당 받아서 자기 마음대로 짤라 쓸 수가 있습니다.

 

그래서 메모리 관리 할 때 자유도가 높다고 할 수 있습니다. (물론 자유가 높다고 해서 꼭 좋은건 아닙니다.)

p = malloc(100);

malloc으로 할당을 받았고 예를 들어 100byte의 메모리를 할당 받아서 p라는 포인터 변수에 저장했고,

이 메모리를 p가 가르키고 있게 될 것이고, 할당받은 메모리는 c언어에서는 반드시 지워줘야 합니다.

그래서 free()라는 함수로 할당받은 메모리를 쓰다가 더 이상 쓸모가 없어지면 지워줘야 합니다.

p = malloc(100);
free(p)

C언어에서는 항상 쌍으로 발생을 해야 합니다. 개발자가 할당을 받았으면 반드시 지워줘야 됩니다.

 

근데 버그라는 게 항상 존재하기 마련입니다. 반드시 할당을 받았으면 반드시 쓸모가 없어졌을 때 반드시 지워줘야 되는데 이걸 깜빡하거나 뭔가 버그 때문에 이걸 못 지워서 free()를 못했다하고 그리고 p값이 없어졌다해서 할당받은 메모리가 사라지지 않습니다.

이것은 남아있는데 아무도 이 메모리를 참조 할 수도, 참조 하지도, 지울 수도 없습니다.

 

그래서 이런 것들이 하나 둘 씩 쌓이면서 메모리에 쓰레기가 쌓이게 됩니다.

이런 것을 메모리 릭이라고 해서 메모리가 새고 있다는 소리인데 나중엔 메모리가 부족해져서 프로그램이 종료되는 사태가 발생합니다.

 

조금 더 설명하자면 스택 메모리가 C언어에서는 어떻게 되냐면 스택 메모리가 변수 범위를 벗어나면 스택 메모리가 사라집니다.

 

예를 들자면

if(...) {
  int a;
  ...
}

if문이 있고, 중괄호가 열리고, a변수를 선언했고, 그 밑으로 무언가를 하면서 그 아래에 if문이 끝난다 했을 때 a변수의 범위는 a가 속한 중괄호까지 입니다.

 

C언어에서는 이 범위를 벗어나면 a를 없애 버리는데

if(...) {
  p = malloc(100)
  ...
}

여기서 포인터에 malloc으로 메모리를 100byte를 받았고 p라는 포인터형 변수를 위한 메모리를 스택에 만들텐데

여기서 free()를 하지 않고 if문을 벗어나 버리면 스택 변수를 지워버렸다 했을 때 p가 없어졌기 때문에 이 바깥에서는 p를 접근할 방법이 없습니다.

 

그러면 이 malloc받은 메모리 주소를 가지고 있는 것이 없어진거고, 이 malloc을 받았던 주소는 아무도 모르게 되죠.

바깥에서 free()를 하고 싶어도 할 수가 없게 되는 것입니다.

 

이런 경우에 메모리 릭이 발생하는 흔한 경우라고 볼 수 있습니다.

그래서 메모리 릭(Memory leak)이라는 것은 버그입니다.

 

버그라는 이야기는 프로그래머가 잘 짜면 발생하지 않을 수 있는데 이게 굉장히 흔한 버그이고, 흔히 실수하는 버그입니다.

그래서 나온게 Garbage Collector라고 보면 됩니다. 그래서 사용자가 다 기억했다가 지우게 하지 말고, 어떤 로봇 같은게 돌아다니면서 쓰레기들을 수집해서 지워주는 역할을 하자 해서 만들어진 것인데 Garbage Collector가 하는 일은 단순합니다.

 

말 그대로 쓰레기 청소부인데 메모리 상에 빈 공간에 할당받고 아무것도 안하는 것들이 있는데 이것들을 GC가 돌아다니면서 이것들을 지워주는 역할을 합니다.

 

그럼 실질적으로 이걸 어떻게 하는지 알아보자 기본적으로는 쓸모가 없어지면 쓰레기인데 쓸모가 없다는 것은

func add() {

  var a int
  a = 3

}

이렇게 했을 때 마찬가지로 a의 범위는 add()이 중괄호가 끝난 시점입니다. 이 바깥에서는 a가 쓸모가 없기 때문에 a는 쓰레기가 됩니다.

 

그래서 a를 GC가 없애주는 것인데 그럼 어떻게 하면 쓸모가 있는지 없는지 알 수 있을까요?

가장 기본적인 법은 Reference Counter가 있는데 누가 이 것을 참조하고 있는지, 몇번 참조하고 있는지 횟수를 저장하는 것입니다.

 

예를 들어서

var a int
var p *
p = &a
a = 3

a변수와 포인터형 p변수가 있다고 했을 때 p는 a의 주소를가지고 있고, a값이 3이고, p값도 3이 되는데 이런 경우에 a가 나나태는 메모리는 참조 횟수가 몇일까요?

 

보면은 a자체가 참조하고 있기 때문에 기본적으로 횟수는 1입니다. 근데 포인터 p가 a의 주소를 참조 하고 있어 a를 가리키고 있기 때문에 참조횟수가 2가 됩니다.

 

그래서 이 때 a는 레퍼런스 카운트가 2인데 언제 쓸모 없어지는 순간이 되냐면 레퍼런스 카운트가 0이 되는 순간입니다.

그럼 아까 add함수를 다시 보죠!

func add() {

  var a int
  a = 3

}

여기서 a를 만들었다는 것은 메모리 상에 a를 위한 메모리가 만들어졌고, 이 메모리를 a가 가리키고 있기 때문에 참조횟수는 1입니다.

 

그 다음 중괄호로 벗어나게 되면 a가 범위를 벗어나게 되니까 a변수가 없어지게 되고, 참조했던 부분이 끊어져 참조횟수가 0이 되어 버립니다.

 

그래서 GC가 이 것을 지우게 됩니다. (참조횟수가 0이니까요)

이번에는 int형 포인터를 반환하는 함수가 있다고 가정해보죠 (다시 한번 설명하자면 언어는 Go지만 C를 예로 드는 것입니다.)

func add() *int {

  var a int
  var p *int

  a = 3
  p = &a

  return p
}

v := add()

그리고 어딘가에서 add()를 호출하고 그 호출된 결과를 변수 v에 넣었다고 가정해보죠.

이 경우에 참조횟수가 어떻게 되는지 확인해보죠.

 

처음에 a를 만들어서 a라는 메모리가 생기게 되고, a가 가리키게 됩니다. 여기서 a의 참조 횟수가 1이 됩니다.
그 다음 p가 만들어져서 마찬가지로 p의 참조횟수는 1이 되고

 

 

p의 값이 a의 주소기 때문에 a의 주소값이 들어 갈 것이고, 그러면 이것도 a를 가리키고 있기 때문에 참조횟수가 1이 되어 1+1해서(두 군데서 가리키고 있으니까) a의 참조횟수가 2가 됩니다.

 

그 다음 return p를 하면서 중괄호로 빠져나가는데 a와 p가 사라지게 되지만 아직 p값은 return p를 해서 v로 들어가니까 메모리 상에 남아있게 됩니다.


그러면 v라는 변수가 메모리에 생기게 될 것이고, 이 것이 add()의 결과 값인 p를 가지고 있으니까 p값이 복사가 돼서 결론적으로 v가 a의 주소값을 가리키게 되고

 

그 다음 범위를 벗어나게 되어 p가 없어지게 되고, 참조횟수가 0이 되면서 p는 GC가 지워버리게 될 것인데

원래 a의 메모리 주소는 v가 참조하고 있는데 a가 참조한 거 없어지고, p가 참조한 게 없어지니까 a의 참조횟수는 1이 되기 때문에 그래서 a의 메모리 값은 사라지지 않게 됩니다.

 

그래서 v도 1, a도 1이 되는데 리턴되는 순간에 바로 없어지는 것이 아니고, v := add()까지 끝나고 난 다음에 사라진다고 보면 됩니다.

 

여기까지 레퍼런스 카운트 얘기였습니다.


여기서 C언어와 Golang이 다른점인데 C언어는 힙 메모리와 스택 메모리가 있다고 했는데 C언어 같은 경우에는 메모리 릭도 많이 일어나고, 이미 사라진 메모리를 참조하는 메모리 댕글링도 많이 발생합니다.

그래서 메모리 관리가 굉장히 어렵다는 것을 알아둬야 하고, 항상 C언어로 코딩할 때는 메모리에 주위를 기울여야 합니다.

 

그런데 Go언어는 다릅니다.

Go언어에서는 레퍼런스 카운트가 있기 때문에 스택 메모리와 힙 메모리 구분이 없고, 내부적으로 구현을 해서 있을 수는 있으나 개발자 입장에서는 스택 메모리 힙 메모리는 구분이 필요없습니다.

 

다시 설명을 하자면

func add() *int {

  var a int
  var p *int

  a = 3
  p = &a

  return p
}

v := add()

이렇게 되었을 때 처음에 a가 만들어지고, p가 만들어지고, a는 a가 참조하고, p는 p가 참조하고, 다시 p가 a를 참조하게 되면 a의 메모리 참조 횟수는 2, p는 1입니다.

 

그 후 리턴하고, 리턴 하는 순간 없어지는게 아니고 리턴된 v := add() 이 부분이 끝난 다음에 없어지게 됩니다.

그 다음 v라는 변수가 만들어지고, 이것은 참조카운트가 1이 되고, v의 값은 a의 주소 값이니까 a를 참조 하는게 된다. 그러면 a는 참조횟수가 3이 됩니다.

 

그 후 그 다음줄로 넘어가 끝나니까 add()안에 있는 변수들이 정리가 됩니다.

a가 없어지게 되어 참조횟수가 끊어지게 되서 3이 2가 되고, p가 없어지게 되어 p의 참조 횟수가 0이 되고

p가 0이 되므로 GC가 p를 버려서 a의 참조횟수 값이 1이 됩니다.

그랬을 때 a의 값은 3이 되고, v는 여전히 참조횟수가 1이 되고, a도 1이 되어 남아있게 됩니다.

이렇게 GC는 참조횟수가 0이 된 변수들을 찾아서 다 지워준다고 보면 됩니다.

 

그런데 또 하나 문제가 있습니다. 레퍼런스 카운트는 0이 아닌데 아무도 쓰지 않는 것들이 있습니다.

이것을 외딴섬이라고 하는데 예를 들면 a라는 애가 b를 가리키고 있고, b라는 애가 c를 가리키고 있고, c가 a를 가리키고 있습니다.

 

이렇게 3개가 도는데 아무도 나머지 애들은 a,b,c를 가리키고 있지 않습니다.

그러면 이 a,b,c는 의미가 없지만 레퍼런스 카운트는 모두 1이지만 아무도 얘네들을 쓰고 있지 않는 상태인데 이것을 외딴섬이라고 합니다.

 

자기들 끼리만 돕는 것입니다. 이것 역시 메모리 릭이라 하고, 메모리 쓰레기입니다.

자기들 끼리만 있기 때문에 누구도 참조할 수 없습니다. 그래서 가비지 컬렉터는 이런 애들도 찾아서 지워줍니다.

 

그래서 GC가 하는 일이 많고, 그 일이 어렵다. 전수 조사를 해야합니다.

이걸 어떻게 하냐면 메모리를 만들 때 마다 GC가 일일이 관리합니다. 그 다음에 어떤 애가 메모리 참조를 바꿔서 참조 카운트가 바뀌었다 할 때도 GC는 관리를 합니다. 그래서 GC가 자기가 만들었던 모든 메모리 다 돌아다니면서 쓰레기를 찾는 것입니다. 그래서 지워줍니다.

 

그리고 전수조사를 하기 때문에 메모리가 굉장히 많이 사용되는데 문제는 속도가 느립니다. GC가 Garbage를 Collecting하는 속도가 굉장히 느립니다.

 

그래서 옛날에는 이 GC가 자바에서 먼저 나왔고, C#도 있고, Golang에도 있습니다. C와 C++만 없다고 보면 되고, 그 외에 언어들은 대부분 GC가 있다고 보면 됩니다.

 

맨 처음 나온게 자바였는데 문제는 얘가 너무 느리다는 것에 있습니다. 너무 느려서 프로그램이 실행되다가 이 GC가 돌기 시작하면 프로그램이 멈춰버리는 일이 많았습니다.

 

왜냐면 GC가 전수조사를 해야하기 때문에 모든 것을 다 조사해야 하기 때문인데 이것을 다 멈춰놓고 조사를 합니다.

그래서 프로그램이 실행되는 도중도중에 GC가 돌 때마다 2~3초씩 멈추게 되는데 프로그램이 뚝뚝 끊기고, 언제 끊길지 모르기 때문에 사용자 입장에서는 굉장히 좋지 않습니다. 그래서 처음에 자바가 나왔을 때 이 GC 때문에 C언어와 C++을 쓰는 사람들이 욕을 굉장히 많이 했습니다. 기본적으로 GC가 필요한 이유는 버그 때문입니다.

그 얘기는 "나는 버그 없이 짜면 돼", "버그없이 잘 짤 수 있어." 이런 사람들에게는 GC가 무용지물이고, GC 무용론을 많이 얘기했었습니다. 그래서 이러한 이유로 욕을 많이 했었죠.

 

그렇다면 현재는 어떨까요? GC가 굉장히 많이 발전했습니다. 특히 멀티 쓰레드가 되면서 요즘에는 프로그램이 눈치채지 못할정도로 실행되는 도중에 중간중간 짧게짧게 알아서 합니다.

 

체감속도가 늦어지진 않지만 느린건 여전히 느립니다. 하지만 많이 좋아졌습니다.

그럼에도 귀찮은 일은 GC가 알아서 하기 때문에 개발자 입장에서는 생산성에 집중할 수 있게 됩니다.

 

그래서 C언어와 C++에서 속도가 빠르지만 생산성은 Java나 Golang이 훨씬 좋습니다. 그 이유는 여러 이유가 있지만 GC가 가장 큰 이유라고 볼 수 있습니다.

 

GC가 있는 언어는 Managed언어(관리 받는 언어), GC가 없는 언어는 Unmanaged언어(관리 받지 않는 언어)라고 합니다.

그러면 Golang은 GC가 있는데 그렇다면 GC가 없는 것은 메모리 릭이 없을까요? 메모리 문제는 깔끔하게 해결이 된 것일까요? 그렇지 않습니다.

 

메모리는 한계가 있는 고정된 자원입니다. 뭐든지 아껴써야하죠. 그리고 GC도 못잡는 릭이 있습니다. 예를 들어 잔뜩 쓰겠다고 배열안에 메모리 주소들을 다 들고 있는 것인데 이것들은 레퍼런스 카운트가 모두 1이 됩니다.

이것은 외딴섬도 아닙니다 그 배열을 나타내는 변수 자체가 다른 곳에서 쓰고 있기 때문이죠.

 

내가 쓰지도 않으면서 잔뜩 쌓아두고만 있는 이런 것들은 개발자가 실수거나 모르거나 일부로거나 잘못 코딩하면 이런 상태가 발생 할 수 있습니다.

 

이런게 GC가 있는 상태에서의 메모리 릭이라고 볼 수 있습니다.

728x90
반응형
그리드형

댓글을 달아 주세요