목차
“자동으로 회수해 주는 언어니까 메모리 걱정은 안 해도 된다”는 말을 자주 듣는다. 그 말이 왜 틀렸는지가 이 글의 전부다.
회수기는 “안 닿는 것”을 치운다
앞 글들에서 이미 답이 나와 있었다. 회수기가 판단하는 기준은 닿을 수 있느냐였다. 루트에서 참조를 따라 갈 수 있으면 살아 있는 것으로 본다.
그런데 우리가 원하는 건 그게 아니다. 우리는 안 쓸 것을 치워 주기를 바란다. 그리고 이 둘은 겹치기는 해도 같지 않다.
| 우리가 바라는 것 | 회수기가 하는 것 | |
|---|---|---|
| 기준 | 앞으로 쓸 것인가 | 지금 닿을 수 있는가 |
| 알 수 있나 | 사람만 안다 | 기계가 정확히 판단한다 |
회수기는 앞으로의 일을 모른다. 알 수 있는 척도 안 한다. 그래서 안 쓸 객체라도 어딘가에서 닿기만 하면 끝까지 데리고 있는다. 버그가 아니라 규칙대로 동작한 결과다.
자동 회수 언어의 메모리 누수는 전부 이 한 문장의 변주다. 남은 절은 그 변주들을 하나씩 보는 것이다.
명단에 이름이 남아 있으면 안 치운다
회의실 관리인으로 돌아가 보자. 관리인은 로비에서 출발해 닿는 방만 쓰는 중으로 본다. 여기까지가 지금까지 본 것이다.
이제 로비 벽에 예약 명단이 하나 붙어 있다고 하자. 명단에 적힌 방은 로비에서 곧장 닿는다. 그래서 관리인은 그 방들을 절대 안 치운다.
문제는 회의가 끝나도 명단에서 이름을 지우는 사람이 없다는 것이다. 방은 텅 비어 있는데 명단에 적혀 있으니 관리인 눈에는 “쓰는 중”이다. 하루가 지나면 명단이 길어지고, 빌릴 방이 없어진다.
자동 회수 언어에서 새는 메모리는 전부 이 명단이다. 아래 절들은 그 명단이 코드에서 어떤 얼굴을 하고 있는지를 하나씩 보는 것이다.
계속 쌓기만 하는 그릇
가장 흔한 얼굴이다. 컬렉션에 넣기만 하고 빼는 코드가 없다.
class OrderTracker {
// 프로그램이 사는 내내 살아 있는 목록
private static final List<Order> processed = new ArrayList<>();
void handle(Order order) {
process(order);
processed.add(order); // 넣기만 한다
}
}처리한 주문을 나중에 볼 일이 있을까 싶어 담아 뒀다. 그런데 빼는 코드는 어디에도 없다. 이 목록은 정적 필드라 프로그램이 사는 내내 루트에서 닿고, 목록이 닿으면 그 안의 주문 전부가 닿는다.
주문 하나는 작다. 그래서 처음엔 아무 문제가 없다. 하루에 수십만 건이 지나가면 그때 문제가 된다. 누수의 크기는 객체 하나의 크기가 아니라 쌓이는 속도가 정한다.
한 가지 더. 목록 안의 주문이 각각 고객 정보와 상품 목록을 들고 있다면, 그것들도 전부 같이 붙들려 있다. 하나를 붙들면 그 아래가 통째로 붙들린다. 그래서 목록 자체는 작아 보여도 실제로 붙들고 있는 양은 훨씬 클 수 있다.
구독하고 해지하지 않은 것
두 번째 얼굴은 알림을 받으려고 자기를 등록해 두는 코드다.
class OrderView {
OrderView(EventBus bus) {
bus.subscribe(this); // 등록했다
}
// 그런데 화면이 닫힐 때 해지하는 코드가 없다
}화면이 닫혀 아무도 이 OrderView를 안 쓰게 됐다. 그런데 이벤트 버스가 아직 이걸 들고 있다. 버스는 오래 사는 객체라 루트에서 닿고, 버스가 닿으면 구독자도 닿는다.
명단에 이름이 남은 딱 그 모양이다. 방(화면)은 비었는데 명단(구독자 목록)에 적혀 있으니 안 치워진다.
여기서 나쁜 점이 하나 더 붙는다. 이 객체는 살아 있기만 한 게 아니라 계속 일을 한다. 이벤트가 올 때마다 이미 닫힌 화면을 갱신하려 든다. 메모리만 새는 게 아니라 엉뚱한 동작까지 나는 것이다.
그래서 알림을 주고받는 구조에서는 구독과 해지가 짝이라는 말이 늘 따라붙는다. 이 시리즈의 눈으로 다시 읽으면 그 이유가 분명해진다. 구독은 오래 사는 쪽이 짧게 사는 쪽을 가리키게 만드는 일이고, 해지는 그 참조를 끊는 유일한 코드다.
상한 없는 캐시
세 번째 얼굴은 좋은 뜻으로 만든 캐시다.
private final Map<String, Result> cache = new HashMap<>();
Result get(String key) {
return cache.computeIfAbsent(key, this::compute);
}같은 키가 반복해서 들어오는 동안은 훌륭하게 동작한다. 문제는 키가 끝없이 늘어나는 종류일 때다. 주문번호나 세션 아이디를 키로 쓰면 새 키가 계속 들어오고, 한 번 들어온 것은 안 나간다.
캐시는 원래 한정된 자리에 자주 쓰는 것만 두는 그릇이다. 무엇을 버릴지 정해 두지 않은 캐시는 캐시가 아니라 그냥 계속 커지는 지도다.
그래서 캐시에는 셋 중 하나가 반드시 있어야 한다.
- 개수 상한 - 넘치면 안 쓰는 것부터 밀어낸다
- 시간 상한 - 넣은 지 오래된 것은 버린다
- 약한 참조 - 자리가 부족하면 회수기가 가져가도 좋다고 표시해 둔다
셋 다 없으면 그 캐시는 언젠가 힙을 채운다. 시점이 문제일 뿐이다.
오래 사는 것이 짧게 살 것을 붙든다
세 얼굴을 나란히 놓으면 같은 뼈대가 보인다. 오래 사는 무언가가 짧게 살 것을 가리키고 있다. 목록도, 이벤트 버스도, 캐시도 전부 프로그램만큼 오래 사는 것들이었다.
이 뼈대를 알면 나머지 사례도 같은 눈으로 읽힌다.
- 정적 필드 - 프로그램과 수명이 같다. 여기 담은 것은 스스로 안 나간다
- 풀에 담긴 스레드에 붙인 값 - 스레드마다 딸린 저장소에 값을 넣어 두는 기능이 있는데, 풀의 스레드는 요청이 끝나도 안 죽고 재사용된다. 그래서 요청 끝에 지우지 않으면 그 값이 다음 요청, 그다음 요청까지 남는다
- 끝나지 않는 스레드 - 살아 있는 스레드의 스택은 루트다. 멈추지 않는 스레드가 뭔가를 들고 있으면 그건 영영 안 치워진다
- 바깥을 참조하는 내부 객체 - 어떤 언어에서는 안쪽 객체가 자기를 만든 바깥 객체를 몰래 가리킨다. 작은 콜백 하나가 커다란 화면 객체를 통째로 붙들고 있을 수 있다
그래서 코드를 볼 때 던질 질문 하나로 줄어든다. 여기서 오래 사는 쪽은 무엇이고, 그게 무엇을 붙들고 있나.
새는 것과 그냥 많이 쓰는 것은 다르다
“메모리가 많이 찼다”와 “메모리가 샌다”는 다른 말이다. 그리고 힙 사용량을 시간축에 놓고 보면 둘이 눈에 띄게 다르게 생겼다.
바닥이 일정하다
힙 사용량
많이 쓰는 것뿐이다.
치우고 나면 제자리로 돌아온다
바닥이 오른다
힙 사용량
새고 있다.
힙을 키워도 터지는 날만 미룬다
톱니는 양쪽 다 똑같다. 갈리는 건 밑변을 이은 선이 수평인가 오른쪽으로 오르는가 하나뿐이다.
회수기가 도는 프로그램의 힙은 늘 톱니 모양이다. 쌓이다가 회수가 돌면 뚝 떨어지고 다시 쌓인다. 여기까지는 정상이다. 봐야 할 것은 오르내림이 아니라 회수 직후의 바닥이 어디인가다.
- 바닥이 일정하면 정상이다. 많이 쓰든 적게 쓰든, 치우고 나면 같은 자리로 돌아온다는 뜻이다
- 바닥이 조금씩 올라가면 샌다. 회수기가 치우지 못하는 것이 매번 조금씩 남고 있다는 뜻이다
이 구별이 실무에서 중요한 이유는 처방이 정반대라서다. 바닥이 일정한데 자주 차는 것이면 힙을 키우거나 쓰는 양을 줄이는 게 맞다. 바닥이 올라가고 있으면 힙을 키워 봐야 터지는 날짜만 미룬다.
한 가지 주의할 게 있다. 바닥이 오르는 게 다 누수는 아니다. 캐시를 채우는 중이거나 시작 직후에 자리를 잡는 중일 수 있다. 그래서 충분히 오래 두고 본다. 며칠을 봐도 계속 오르면 누수다.
실무에서: 어디부터 보나
메모리가 계속 오른다는 신고를 받았을 때, 순서가 있다.
먼저 새는지부터 가른다. 회수 직후 바닥을 여러 날에 걸쳐 본다. 이걸 안 하고 곧바로 힙 덤프부터 뜨면, 원래 큰 객체들을 보며 범인을 찾게 된다.
샌다면 무엇이 붙들고 있는지 본다. 힙 덤프를 시간을 두고 두 번 떠서 늘어난 것을 보고, 그 객체가 루트에서 어떤 참조를 타고 닿는지 경로를 본다. 답은 대개 그 경로의 어느 정적 필드나 컬렉션에 적혀 있다.
의심 목록은 짧다. 아래 넷 안에서 대부분 끝난다.
| 증상 | 흔한 범인 | 확인할 것 |
|---|---|---|
| 특정 컬렉션의 원소가 계속 는다 | 넣기만 하는 목록·맵 | 빼는 코드가 있나 |
| 같은 클래스의 인스턴스가 계속 는다 | 해지 안 한 구독·콜백 | 등록의 짝이 있나 |
| 맵 하나가 계속 커진다 | 상한 없는 캐시 | 개수·시간 상한이 있나 |
| 요청이 끝나도 안 준다 | 재사용되는 스레드에 붙인 값 | 요청 끝에 지우나 |
고칠 때는 참조를 끊는다. 회수기를 부르거나 힙을 키우는 것은 고치는 게 아니다. 오래 사는 쪽이 짧게 사는 쪽을 안 붙들게 바꾸는 것만이 고치는 것이다. 넣으면 빼고, 등록하면 해지하고, 캐시에는 상한을 두고, 붙였으면 지운다.
막는 편이 찾는 것보다 훨씬 싸다. 상한 있는 캐시 구현을 쓰고, 구독과 해지를 한 곳에서 짝지어 관리하고, 정적 컬렉션을 새로 만들 때 한 번 더 생각하는 것으로 대부분이 안 생긴다.
정리
- 회수기는 안 쓰는 것이 아니라 안 닿는 것을 치운다. 자동 회수 언어의 누수는 전부 이 차이에서 나온다.
- 뼈대는 하나다. 오래 사는 무언가가 짧게 살 것을 가리키고 있다.
- 얼굴은 넷이다. 넣기만 하는 컬렉션 · 해지 안 한 구독 · 상한 없는 캐시 · 재사용되는 스레드에 붙인 값.
- 새는지 아닌지는 회수 직후의 바닥으로 가른다. 오르내림이 아니라 바닥이 오르는지를 본다.
- 샌다면 힙을 키워도 터지는 날만 미룬다. 고치는 건 참조를 끊는 것뿐이다.
이 시리즈는 회수가 왜 문제인지에서 출발했다. 손으로 하면 사람이 틀리고, 세면 고리가 남고, 닿아 보면 멈춤이 생기고, 다 갖춰도 우리가 안 놓으면 안 치워진다. 어느 방식도 “메모리를 신경 쓰지 않아도 되게” 만들어 주지는 않았다. 다만 신경 쓸 자리를 옮겨 줬을 뿐이다.
그래서 마지막에 남는 질문도 처음과 같다. 이 자리는 누가 언제 놓아주는가. 언어가 답해 주는 부분이 어디까지이고 내가 답해야 하는 부분이 어디부터인지를 아는 것, 그게 이 시리즈가 남기려 한 전부다.