목차
메모리를 얻는 코드는 한 줄이다. 그런데 그 자리를 언제 돌려주는지는 코드 어디에도 안 적혀 있다.
얻는 쪽은 한 줄, 돌려주는 쪽은 문제
객체를 하나 만든다.
Order order = new Order();이 한 줄이 하는 일은 둘이다. 주문 하나를 담을 만큼의 메모리를 얻고, 그 자리를 가리키는 이름을 order에 붙인다. 얻는 일은 이렇게 쉽다. 필요할 때 적으면 그만이다.
문제는 반대편이다. 이 주문을 다 쓰고 나면 그 자리는 어떻게 되나. 코드에는 안 적혀 있다. 그런데 안 적혀 있다고 해서 자리가 저절로 없어지지는 않는다. 얻은 자리는 누군가 돌려줘야 하고, 그 “누군가”가 누구냐가 언어마다 다르다.
이 시리즈가 처음부터 끝까지 붙드는 질문은 그거 하나다. 다 쓴 메모리를 누가 언제 어떻게 알아보고 돌려주는가.
스택은 저절로 치워진다
다행히 절반은 이미 풀려 있다. 프로그램이 쓰는 메모리는 사는 동네가 둘로 갈리는데, 그중 한쪽은 정리가 공짜다.
지역변수처럼 함수 안에서만 사는 값은 스택에 놓인다. 스택은 함수를 부르면 그 함수 몫의 칸이 위에 쌓이고, 함수가 끝나면 그 칸이 통째로 걷힌다. 걷히면서 안에 있던 지역변수도 같이 사라진다.
여기서 중요한 건 “언제 치우나”에 답이 이미 있다는 점이다. 함수가 끝나는 순간이다. 그 시점은 누가 판단할 필요가 없다. 프로그램의 흐름 자체가 알려 준다. 스택이 자동으로 정리되는 구조는 이 시리즈가 다시 손댈 데가 없다.
그래서 문제는 힙 하나다
나머지 한쪽이 힙이다. new로 얻은 객체가 여기 산다. 힙에 놓인 것은 함수가 끝나도 안 사라진다. 그게 힙을 쓰는 이유다. 함수 밖으로 들고 나가야 하니까 힙에 두는 것이다.
그런데 그 장점이 그대로 문제가 된다. 함수가 끝나는 시점이 정리 시점을 알려 주지 못한다. 그럼 언제 치우나. 알려 주는 사건이 없다.
같은 흐름을 지나왔는데 스택은 제자리로 돌아왔고 힙은 한 칸도 안 비었다. 이 아래 줄을 되돌리는 일이 회수다.
같은 흐름을 지나왔는데 위 줄은 찼다가 제자리로 돌아왔고 아래 줄은 한 칸도 안 비었다. 이 아래 줄을 되돌리는 일이 이 시리즈의 전부다. 그러니 이 뒤로 “메모리”라고 하면 전부 힙 이야기다.
회의실을 빌리는 일과 같다
이 시리즈는 처음부터 끝까지 공용 회의실을 따라간다.
건물에 회의실이 여러 개 있고, 필요한 사람이 하나를 빌려 쓴다. 빌리는 건 쉽다. 비어 있는 방을 하나 잡으면 된다. 다 쓰면 돌려놔야 다음 사람이 그 방을 쓴다.
| 회의실 | 메모리 |
|---|---|
| 빈 방을 하나 잡는다 | 힙에서 자리를 얻는다 |
| 방을 다 쓰고 반납한다 | 그 자리를 회수한다 |
| 반납 안 한 방이 쌓인다 | 메모리 누수 |
| 빌릴 방이 하나도 없다 | 힙이 꽉 찼다 |
건물에 방이 무한하다면 아무도 반납을 신경 쓰지 않을 것이다. 방이 유한하니까 반납이 문제가 된다. 메모리도 똑같다.
안 돌려주면 이렇게 죽는다
반납 안 한 방이 하나 둘 쌓이면 처음에는 티가 안 난다. 방은 아직 많이 남았으니까. 그래서 메모리 누수는 초반에 안 보이고 나중에 한꺼번에 드러나는 성질이 있다.
쌓이다 보면 순서대로 이런 일이 난다.
- 여유가 줄어든다. 아직은 돈다. 다만 새 방을 잡을 때마다 빈 방을 찾는 데 시간이 더 걸린다
- 자동 회수가 자주 돈다. 자동으로 치우는 언어라면 청소가 점점 잦아지고 길어진다. 프로그램이 일하는 시간보다 치우는 시간이 늘어난다
- 더 얻을 수 없다. 결국 빌릴 방이 없다. 새 객체를 만들려는 자리에서 프로그램이 죽는다
마지막 증상이 흔히 보는 “메모리 부족” 오류다. 그런데 죽은 자리가 원인이 있는 자리가 아니다. 방을 다 써버린 건 몇 시간 전부터 쌓인 누군가이고, 죽는 건 그 뒤에 방을 빌리려 한 애먼 코드다. 이게 메모리 문제를 잡기 어려운 첫 번째 이유다.
어려운 질문은 “언제”다
“다 쓰면 돌려준다”는 말은 쉽다. 어려운 건 다 썼다는 걸 어떻게 아느냐다.
주문 객체 하나를 만들어 화면에 그려 주는 코드를 떠올려 보자. 화면을 그리는 함수가 끝났으니 이제 그 주문은 버려도 될까. 그럴 수도 있고 아닐 수도 있다. 같은 주문을 로그를 남기는 쪽이 아직 들고 있을 수 있고, 나중에 쓰려고 목록에 넣어 뒀을 수도 있다.
즉 판단해야 할 것은 “이 함수가 끝났나”가 아니라 **“이 자리를 아직 쓸 사람이 하나라도 있나”**다. 그리고 그 답은 이 코드 한 군데를 봐서는 안 나온다. 프로그램 전체를 봐야 나온다.
이 한 문장이 남은 이야기의 씨앗이다. 회수를 자동으로 하는 방법들은 전부 이 질문에 답하는 서로 다른 방식이다.
눈에 안 보인다는 게 전부다
여기서 회의실 비유가 처음 깨진다. 그리고 이 깨지는 자리가 문제의 핵심이라 짚고 가야 한다.
회의실은 문을 열어 보면 사람이 있는지 없는지 보인다. 비었으면 치우면 된다. 메모리는 안 그렇다. 어떤 자리를 누가 아직 가리키고 있는지는 어디에도 표시가 없다. 힙에 놓인 바이트들만 봐서는 그게 아직 쓰이는 값인지 버려진 값인지 구별할 방법이 없다.
그래서 회수는 “빈 방을 눈으로 찾는 일”이 될 수가 없다. 어떻게든 안 보이는 것을 알아내는 일이 된다. 세거나, 따라가 보거나 둘 중 하나다.
돌려주는 방법은 두 갈래다
답은 크게 둘로 갈린다.
사람이 적는다. 개발자가 “이제 이 자리 반납”을 코드에 직접 쓴다. C 계열이 이 쪽이다. 언제 반납할지를 사람이 판단하니 프로그램은 아무 부담이 없다. 대신 사람이 틀린다.
프로그램이 알아서 한다. 언어가 딸린 회수기가 안 쓰는 자리를 찾아 치운다. Java·Go·파이썬·자바스크립트가 이 쪽이고, 흔히 가비지 컬렉션(GC) 이라 부른다. 사람은 안 틀리는 대신 그 회수기가 어떻게 알아내느냐가 새로운 문제가 된다.
두 번째 갈래는 다시 둘로 나뉜다. 방에 든 사람 수를 세는 방식과 로비에서 출발해 닿아 보는 방식이다. 이 시리즈는 그 순서로 하나씩 연다.
실무에서: 회수는 메모리만의 일이 아니다
메모리 회수를 배우면 그 틀이 통째로 다른 데 쓰인다. 한정된 것을 빌려 쓰고 돌려주는 자원은 전부 같은 모양의 문제를 낸다.
- 커넥션 풀 - 빌린 연결을 안 돌려주면 풀이 마르고, 그다음 요청은 영영 기다린다. 빌렸으면 반납해야 한다는 규칙이 메모리와 글자 그대로 같다
- 파일과 소켓 - 열어 둔 번호를 안 닫으면 그 번호가 새고, 상한에 부딪히면 새 파일도 새 연결도 못 연다
- 스레드와 잠금 - 붙잡아 두면 그 자원을 기다리는 쪽이 멈춘다
메모리만 자동 회수를 갖췄고 나머지는 대개 사람이 닫아야 한다는 점도 알아 둘 만하다. 그래서 “GC 언어니까 자원 관리는 신경 안 써도 된다”는 말은 처음부터 틀렸다. 회수기가 맡아 주는 건 메모리 한 가지다.
한 가지 더. 프로그램이 보는 메모리는 운영체제가 프로세스마다 따로 준 주소공간 위에 있어서, 힙이 자랐다고 물리 메모리를 곧바로 그만큼 쓰는 것도 아니다. 그래서 “메모리를 얼마나 쓰나”는 어디서 재느냐에 따라 다른 수가 나온다.
정리
- 메모리를 얻는 코드는 한 줄이고, 돌려주는 시점은 코드에 안 적혀 있다.
- 스택은 함수가 끝나면 저절로 걷힌다. 회수가 문제인 곳은 힙 하나다.
- 힙은 함수 수명에 안 매여 편한 대신 정리할 시점을 알려 주는 사건이 없다.
- 안 돌려준 자리가 쌓이면 여유가 줄고, 회수가 잦아지고, 결국 더 얻지 못해 죽는다. 죽은 자리가 원인 자리가 아니다.
- 어려운 질문은 “다 썼나”가 아니라 **“아직 쓸 사람이 하나라도 있나”**이고, 그건 눈에 안 보인다.
- 답은 사람이 적거나, 프로그램이 알아내거나 둘이다.
다음 글은 사람이 적는 쪽부터 연다. 왜 이 단순해 보이는 일을 사람이 자꾸 틀리는지를 세 가지 실패로 갈라 본다.