CS · Memory · Backend

GC가 있어도 샌다 - 치워지지 않는 것들

회수기는 안 쓰는 것을 치우지 않는다. 안 닿는 것을 치운다. 이 한 마디 차이에서 자동 회수 언어의 메모리 누수가 통째로 나온다. 어떤 코드가 안 쓸 것을 붙들고 있는지, 그리고 새는 것과 그냥 많이 쓰는 것을 어떻게 가르는지 본다.

·메모리의 수명 6편
목차
  1. 회수기는 “안 닿는 것”을 치운다
  2. 명단에 이름이 남아 있으면 안 치운다
  3. 계속 쌓기만 하는 그릇
  4. 구독하고 해지하지 않은 것
  5. 상한 없는 캐시
  6. 오래 사는 것이 짧게 살 것을 붙든다
  7. 새는 것과 그냥 많이 쓰는 것은 다르다
  8. 실무에서: 어디부터 보나
  9. 정리

“자동으로 회수해 주는 언어니까 메모리 걱정은 안 해도 된다”는 말을 자주 듣는다. 그 말이 왜 틀렸는지가 이 글의 전부다.

회수기는 “안 닿는 것”을 치운다

앞 글들에서 이미 답이 나와 있었다. 회수기가 판단하는 기준은 닿을 수 있느냐였다. 루트에서 참조를 따라 갈 수 있으면 살아 있는 것으로 본다.

그런데 우리가 원하는 건 그게 아니다. 우리는 안 쓸 것을 치워 주기를 바란다. 그리고 이 둘은 겹치기는 해도 같지 않다.

우리가 바라는 것회수기가 하는 것
기준앞으로 쓸 것인가지금 닿을 수 있는가
알 수 있나사람만 안다기계가 정확히 판단한다

회수기는 앞으로의 일을 모른다. 알 수 있는 척도 안 한다. 그래서 안 쓸 객체라도 어딘가에서 닿기만 하면 끝까지 데리고 있는다. 버그가 아니라 규칙대로 동작한 결과다.

자동 회수 언어의 메모리 누수는 전부 이 한 문장의 변주다. 남은 절은 그 변주들을 하나씩 보는 것이다.

명단에 이름이 남아 있으면 안 치운다

회의실 관리인으로 돌아가 보자. 관리인은 로비에서 출발해 닿는 방만 쓰는 중으로 본다. 여기까지가 지금까지 본 것이다.

이제 로비 벽에 예약 명단이 하나 붙어 있다고 하자. 명단에 적힌 방은 로비에서 곧장 닿는다. 그래서 관리인은 그 방들을 절대 안 치운다.

문제는 회의가 끝나도 명단에서 이름을 지우는 사람이 없다는 것이다. 방은 텅 비어 있는데 명단에 적혀 있으니 관리인 눈에는 “쓰는 중”이다. 하루가 지나면 명단이 길어지고, 빌릴 방이 없어진다.

자동 회수 언어에서 새는 메모리는 전부 이 명단이다. 아래 절들은 그 명단이 코드에서 어떤 얼굴을 하고 있는지를 하나씩 보는 것이다.

계속 쌓기만 하는 그릇

가장 흔한 얼굴이다. 컬렉션에 넣기만 하고 빼는 코드가 없다.

java
class OrderTracker {
    // 프로그램이 사는 내내 살아 있는 목록
    private static final List<Order> processed = new ArrayList<>();

    void handle(Order order) {
        process(order);
        processed.add(order);   // 넣기만 한다
    }
}

처리한 주문을 나중에 볼 일이 있을까 싶어 담아 뒀다. 그런데 빼는 코드는 어디에도 없다. 이 목록은 정적 필드라 프로그램이 사는 내내 루트에서 닿고, 목록이 닿으면 그 안의 주문 전부가 닿는다.

주문 하나는 작다. 그래서 처음엔 아무 문제가 없다. 하루에 수십만 건이 지나가면 그때 문제가 된다. 누수의 크기는 객체 하나의 크기가 아니라 쌓이는 속도가 정한다.

한 가지 더. 목록 안의 주문이 각각 고객 정보와 상품 목록을 들고 있다면, 그것들도 전부 같이 붙들려 있다. 하나를 붙들면 그 아래가 통째로 붙들린다. 그래서 목록 자체는 작아 보여도 실제로 붙들고 있는 양은 훨씬 클 수 있다.

구독하고 해지하지 않은 것

두 번째 얼굴은 알림을 받으려고 자기를 등록해 두는 코드다.

java
class OrderView {
    OrderView(EventBus bus) {
        bus.subscribe(this);   // 등록했다
    }
    // 그런데 화면이 닫힐 때 해지하는 코드가 없다
}

화면이 닫혀 아무도 이 OrderView를 안 쓰게 됐다. 그런데 이벤트 버스가 아직 이걸 들고 있다. 버스는 오래 사는 객체라 루트에서 닿고, 버스가 닿으면 구독자도 닿는다.

명단에 이름이 남은 딱 그 모양이다. 방(화면)은 비었는데 명단(구독자 목록)에 적혀 있으니 안 치워진다.

여기서 나쁜 점이 하나 더 붙는다. 이 객체는 살아 있기만 한 게 아니라 계속 일을 한다. 이벤트가 올 때마다 이미 닫힌 화면을 갱신하려 든다. 메모리만 새는 게 아니라 엉뚱한 동작까지 나는 것이다.

그래서 알림을 주고받는 구조에서는 구독과 해지가 짝이라는 말이 늘 따라붙는다. 이 시리즈의 눈으로 다시 읽으면 그 이유가 분명해진다. 구독은 오래 사는 쪽이 짧게 사는 쪽을 가리키게 만드는 일이고, 해지는 그 참조를 끊는 유일한 코드다.

상한 없는 캐시

세 번째 얼굴은 좋은 뜻으로 만든 캐시다.

java
private final Map<String, Result> cache = new HashMap<>();

Result get(String key) {
    return cache.computeIfAbsent(key, this::compute);
}

같은 키가 반복해서 들어오는 동안은 훌륭하게 동작한다. 문제는 키가 끝없이 늘어나는 종류일 때다. 주문번호나 세션 아이디를 키로 쓰면 새 키가 계속 들어오고, 한 번 들어온 것은 안 나간다.

캐시는 원래 한정된 자리에 자주 쓰는 것만 두는 그릇이다. 무엇을 버릴지 정해 두지 않은 캐시는 캐시가 아니라 그냥 계속 커지는 지도다.

그래서 캐시에는 셋 중 하나가 반드시 있어야 한다.

  • 개수 상한 - 넘치면 안 쓰는 것부터 밀어낸다
  • 시간 상한 - 넣은 지 오래된 것은 버린다
  • 약한 참조 - 자리가 부족하면 회수기가 가져가도 좋다고 표시해 둔다

셋 다 없으면 그 캐시는 언젠가 힙을 채운다. 시점이 문제일 뿐이다.

오래 사는 것이 짧게 살 것을 붙든다

세 얼굴을 나란히 놓으면 같은 뼈대가 보인다. 오래 사는 무언가가 짧게 살 것을 가리키고 있다. 목록도, 이벤트 버스도, 캐시도 전부 프로그램만큼 오래 사는 것들이었다.

이 뼈대를 알면 나머지 사례도 같은 눈으로 읽힌다.

  • 정적 필드 - 프로그램과 수명이 같다. 여기 담은 것은 스스로 안 나간다
  • 풀에 담긴 스레드에 붙인 값 - 스레드마다 딸린 저장소에 값을 넣어 두는 기능이 있는데, 풀의 스레드는 요청이 끝나도 안 죽고 재사용된다. 그래서 요청 끝에 지우지 않으면 그 값이 다음 요청, 그다음 요청까지 남는다
  • 끝나지 않는 스레드 - 살아 있는 스레드의 스택은 루트다. 멈추지 않는 스레드가 뭔가를 들고 있으면 그건 영영 안 치워진다
  • 바깥을 참조하는 내부 객체 - 어떤 언어에서는 안쪽 객체가 자기를 만든 바깥 객체를 몰래 가리킨다. 작은 콜백 하나가 커다란 화면 객체를 통째로 붙들고 있을 수 있다

그래서 코드를 볼 때 던질 질문 하나로 줄어든다. 여기서 오래 사는 쪽은 무엇이고, 그게 무엇을 붙들고 있나.

새는 것과 그냥 많이 쓰는 것은 다르다

“메모리가 많이 찼다”와 “메모리가 샌다”는 다른 말이다. 그리고 힙 사용량을 시간축에 놓고 보면 둘이 눈에 띄게 다르게 생겼다.

바닥이 일정하다

힙 사용량

힙 상한
시간 →회수마다 같은 자리로

많이 쓰는 것뿐이다.
치우고 나면 제자리로 돌아온다

바닥이 오른다

힙 사용량

힙 상한
시간 →회수해도 조금씩 남는다

새고 있다.
힙을 키워도 터지는 날만 미룬다

톱니는 양쪽 다 똑같다. 갈리는 건 밑변을 이은 선이 수평인가 오른쪽으로 오르는가 하나뿐이다.

회수기가 도는 프로그램의 힙은 늘 톱니 모양이다. 쌓이다가 회수가 돌면 뚝 떨어지고 다시 쌓인다. 여기까지는 정상이다. 봐야 할 것은 오르내림이 아니라 회수 직후의 바닥이 어디인가다.

  • 바닥이 일정하면 정상이다. 많이 쓰든 적게 쓰든, 치우고 나면 같은 자리로 돌아온다는 뜻이다
  • 바닥이 조금씩 올라가면 샌다. 회수기가 치우지 못하는 것이 매번 조금씩 남고 있다는 뜻이다

이 구별이 실무에서 중요한 이유는 처방이 정반대라서다. 바닥이 일정한데 자주 차는 것이면 힙을 키우거나 쓰는 양을 줄이는 게 맞다. 바닥이 올라가고 있으면 힙을 키워 봐야 터지는 날짜만 미룬다.

한 가지 주의할 게 있다. 바닥이 오르는 게 다 누수는 아니다. 캐시를 채우는 중이거나 시작 직후에 자리를 잡는 중일 수 있다. 그래서 충분히 오래 두고 본다. 며칠을 봐도 계속 오르면 누수다.

실무에서: 어디부터 보나

메모리가 계속 오른다는 신고를 받았을 때, 순서가 있다.

먼저 새는지부터 가른다. 회수 직후 바닥을 여러 날에 걸쳐 본다. 이걸 안 하고 곧바로 힙 덤프부터 뜨면, 원래 큰 객체들을 보며 범인을 찾게 된다.

샌다면 무엇이 붙들고 있는지 본다. 힙 덤프를 시간을 두고 두 번 떠서 늘어난 것을 보고, 그 객체가 루트에서 어떤 참조를 타고 닿는지 경로를 본다. 답은 대개 그 경로의 어느 정적 필드나 컬렉션에 적혀 있다.

의심 목록은 짧다. 아래 넷 안에서 대부분 끝난다.

증상흔한 범인확인할 것
특정 컬렉션의 원소가 계속 는다넣기만 하는 목록·맵빼는 코드가 있나
같은 클래스의 인스턴스가 계속 는다해지 안 한 구독·콜백등록의 짝이 있나
맵 하나가 계속 커진다상한 없는 캐시개수·시간 상한이 있나
요청이 끝나도 안 준다재사용되는 스레드에 붙인 값요청 끝에 지우나

고칠 때는 참조를 끊는다. 회수기를 부르거나 힙을 키우는 것은 고치는 게 아니다. 오래 사는 쪽이 짧게 사는 쪽을 안 붙들게 바꾸는 것만이 고치는 것이다. 넣으면 빼고, 등록하면 해지하고, 캐시에는 상한을 두고, 붙였으면 지운다.

막는 편이 찾는 것보다 훨씬 싸다. 상한 있는 캐시 구현을 쓰고, 구독과 해지를 한 곳에서 짝지어 관리하고, 정적 컬렉션을 새로 만들 때 한 번 더 생각하는 것으로 대부분이 안 생긴다.

정리

  • 회수기는 안 쓰는 것이 아니라 안 닿는 것을 치운다. 자동 회수 언어의 누수는 전부 이 차이에서 나온다.
  • 뼈대는 하나다. 오래 사는 무언가가 짧게 살 것을 가리키고 있다.
  • 얼굴은 넷이다. 넣기만 하는 컬렉션 · 해지 안 한 구독 · 상한 없는 캐시 · 재사용되는 스레드에 붙인 값.
  • 새는지 아닌지는 회수 직후의 바닥으로 가른다. 오르내림이 아니라 바닥이 오르는지를 본다.
  • 샌다면 힙을 키워도 터지는 날만 미룬다. 고치는 건 참조를 끊는 것뿐이다.

이 시리즈는 회수가 왜 문제인지에서 출발했다. 손으로 하면 사람이 틀리고, 세면 고리가 남고, 닿아 보면 멈춤이 생기고, 다 갖춰도 우리가 안 놓으면 안 치워진다. 어느 방식도 “메모리를 신경 쓰지 않아도 되게” 만들어 주지는 않았다. 다만 신경 쓸 자리를 옮겨 줬을 뿐이다.

그래서 마지막에 남는 질문도 처음과 같다. 이 자리는 누가 언제 놓아주는가. 언어가 답해 주는 부분이 어디까지이고 내가 답해야 하는 부분이 어디부터인지를 아는 것, 그게 이 시리즈가 남기려 한 전부다.