CS · Memory · Backend

참조 세기 - 아무도 안 보면 버린다

가리키는 곳이 몇 개인지를 세다가 0이 되면 그 자리를 돌려준다. 가장 단순하고 가장 즉시적인 자동 회수 방식이다. 세는 값이 어디서 나가는지, 그리고 이 방식이 절대 못 푸는 한 가지가 무엇인지 본다.

·메모리의 수명 3편
목차
  1. 방에 든 사람 수를 센다
  2. 늘리고 줄이고, 0이면 비운다
  3. 값은 즉시성이다
  4. 세는 일도 공짜가 아니다
  5. 하나가 0이 되면 줄줄이 무너진다
  6. 서로 문을 잡아주면 0이 안 된다
  7. 한쪽은 세지 않기로 한다
  8. 실무에서: 이미 쓰고 있는 곳들
  9. 정리

사람이 반납 시점을 판단하기 어렵다면, 프로그램이 판단하게 하면 된다. 가장 먼저 떠오르는 방법은 세는 것이다.

방에 든 사람 수를 센다

회의실 문 옆에 숫자판을 하나 달아 둔다고 해 보자. 사람이 들어가면 1을 올리고 나오면 1을 내린다. 관리인은 방 안을 들여다볼 필요가 없다. 숫자판이 0이면 비운다. 그게 전부다.

메모리도 똑같이 한다. 힙의 자리마다 그 자리를 가리키는 곳이 몇 개인지를 함께 적어 둔다. 이 수를 참조 카운트(reference count) 라고 한다.

이 방식의 매력은 판단이 지역적이라는 데 있다. 프로그램 전체를 훑어볼 필요가 없다. 참조 하나가 사라지는 그 자리에서, 숫자 하나만 보면 답이 나온다.

늘리고 줄이고, 0이면 비운다

규칙은 셋이다.

java
Order a = new Order();   // 새로 만든다        → 카운트 1
Order b = a;             // 가리키는 곳이 늘었다 → 카운트 2
b = null;                // 하나가 손을 놓았다   → 카운트 1
a = null;                // 마지막도 놓았다      → 카운트 0 → 회수
  • 새 참조가 생기면 1 올린다. 대입, 컬렉션에 넣기, 함수에 넘기기가 전부 여기 해당한다
  • 참조가 없어지면 1 내린다. 다른 값을 대입하거나, 그 변수를 담고 있던 함수가 끝나면 그렇다
  • 0이 되는 순간 자리를 돌려준다

앞 글에서 본 손으로 하는 반납과 비교하면 차이가 분명하다. 사람이 적을 때는 free를 어디에 적을지를 판단해야 했다. 여기서는 그런 판단이 없다. 참조가 하나 사라질 때마다 기계적으로 1을 내리고, 0이면 비운다. 판단이 사라지고 규칙만 남았다.

값은 즉시성이다

이 방식의 가장 큰 값은 바로 회수된다는 점이다. 마지막 참조가 사라지는 그 순간에 자리가 돌아간다. 나중에 청소부가 와서 훑을 때까지 기다리지 않는다.

여기서 따라오는 것이 셋이다.

  • 메모리를 적게 쓴다. 죽은 객체가 힙에 머무는 시간이 거의 없다
  • 시점을 예측할 수 있다. 어느 줄에서 회수가 일어나는지가 코드에 드러난다
  • 회수 작업이 한 번에 몰리지 않는다. 조금씩 계속 나눠서 일어난다

마지막 것이 중요하다. 뒤에서 볼 다른 방식들은 가끔 크게 멈추는 성격이 있는데, 세는 방식은 그런 큰 멈춤이 원리적으로 잘 안 생긴다. 응답 시간이 고르게 나온다는 뜻이다.

세는 일도 공짜가 아니다

대신 값을 치른다. 참조를 대입할 때마다 프로그램이 일을 더 한다.

java
b = a;   // 겉보기엔 대입 하나지만
         // 실제로는: a가 가리키던 것 +1, b가 원래 가리키던 것 -1, 0이면 회수

포인터 하나 옮기는 줄이 사실은 숫자를 두 번 고치고 조건을 한 번 보는 일이 된다. 대입은 프로그램에서 가장 자주 일어나는 동작이라, 이 작은 비용이 전체에 얇게 깔린다.

여러 스레드를 쓰면 값이 더 든다. 카운트는 여러 스레드가 함께 고치는 공유 값이기 때문이다. 공유 상태를 여럿이 고칠 때 생기는 문제를 그대로 맞는다. 두 스레드가 동시에 1을 내리면 하나가 묻혀서 카운트가 안 맞고, 그러면 아직 쓰는 자리를 회수하거나 영영 회수 못 하게 된다.

그래서 카운트 갱신은 원자적으로 해야 하는데, 그건 보통 대입보다 눈에 띄게 비싸다. 참조를 많이 주고받는 프로그램일수록 이 값이 커진다.

하나가 0이 되면 줄줄이 무너진다

즉시 회수가 늘 잔잔한 것도 아니다. 큰 덩어리 하나를 놓는 순간을 보자.

주문 10만 건이 든 목록을 놓았다고 하자. 목록의 카운트가 0이 되어 회수하는데, 목록이 사라지면 그 안의 주문 10만 개의 카운트가 각각 1씩 내려간다. 그중 다른 데서 안 쓰이던 것들이 다시 0이 되어 회수되고, 그 주문들이 들고 있던 값들이 또 0이 되고…

참조 하나를 놓았을 뿐인데 회수가 연쇄로 번진다. 그 연쇄가 끝날 때까지 프로그램은 그 줄에서 멈춰 있다.

그래서 “세는 방식에는 멈춤이 없다”는 말은 정확하지 않다. 큰 멈춤이 예고 없이 오지는 않는다가 맞는 말이다. 멈춤은 큰 것을 놓는 자리에서 생기고, 그 자리는 코드에 드러나 있다.

서로 문을 잡아주면 0이 안 된다

그리고 이 방식이 원리적으로 못 푸는 것이 하나 있다.

회의실 둘을 빌린 두 사람이 서로 상대방 방문을 잡아 주고 있다고 해 보자. A방 숫자판에는 1(B가 잡고 있다), B방 숫자판에도 1(A가 잡고 있다)이 찍혀 있다. 정작 바깥의 아무도 두 방을 쓰지 않는데 숫자판은 영영 0이 안 된다. 관리인은 규칙대로 그 방들을 안 치운다.

한쪽만 가리킬 때

부모0
자식1

부모가 0이라 회수되고,
그러면 자식도 0이 된다

서로 가리킬 때

부모1
자식1

둘 다 1에서 안 내려간다.
바깥에서는 이미 놓았는데도

두 판에서 달라지는 건 화살표 하나다. 그 하나가 되돌아와 고리를 닫는 순간 숫자가 0에 못 닿는다.

코드에서는 이렇게 생긴다.

java
Parent p = new Parent();
Child c = new Child();
p.child = c;      // p가 c를 가리킨다
c.parent = p;     // c가 p를 가리킨다

p = null;         // 바깥에서 놓았다
c = null;         // 바깥에서 놓았다
// 그런데 둘 다 카운트 1 - 서로가 서로를 가리키고 있어서다

이게 순환 참조다. 그리고 이건 드문 모양이 아니다. 부모와 자식이 서로를 가리키는 구조, 목록의 원소가 목록 자신을 가리키는 구조, 두 객체가 서로를 참조하는 구조는 실제 코드에 흔하다. 관계를 점과 선으로 놓고 보면 순환이 흔하다는 것이 그대로 여기서 문제가 된다.

세는 방식은 자기 문 앞만 본다. 그래서 자기 밖에서 만들어진 고리를 볼 수가 없다. 카운트를 아무리 정확히 세도 이 한계는 안 없어진다.

한쪽은 세지 않기로 한다

고리를 끊는 흔한 방법은 한쪽 참조를 세지 않기로 정하는 것이다. 이런 참조를 약한 참조(weak reference) 라고 부른다.

부모와 자식 관계라면 부모가 자식을 가리키는 쪽은 세고, 자식이 부모를 가리키는 쪽은 안 센다. 그러면 바깥에서 부모를 놓는 순간 부모의 카운트가 0이 되고, 부모가 회수되면서 자식의 카운트도 내려간다. 고리가 풀린다.

대신 약한 참조에는 대가가 붙는다. 가리키던 것이 이미 사라졌을 수 있다. 그래서 약한 참조를 쓸 때는 쓰기 전에 아직 살아 있는지 확인해야 하고, 없으면 없는 대로 동작해야 한다.

그리고 무엇보다 어느 쪽을 약하게 할지는 사람이 정한다. 결국 사람의 판단이 다시 들어온 것이다. 손으로 반납하던 시절만큼은 아니지만, “소유는 어느 쪽인가”를 사람이 답해야 한다는 성질은 남았다.

실무에서: 이미 쓰고 있는 곳들

세는 방식은 옛날 방식이 아니다. 지금도 널리 쓰인다.

  • 파이썬 - 기본 회수 방식이 참조 세기다. 그래서 마지막 참조를 놓는 순간 객체가 정리되고, 파일을 닫는 코드도 그 시점에 돈다. 다만 순환은 세기로 못 잡으니 순환만 따로 찾는 회수기를 하나 더 두고 있다
  • 스위프트 - 참조 세기를 컴파일러가 코드에 심어 준다. 순환은 언어가 안 풀어 주므로 개발자가 weak·unowned로 어느 쪽을 안 셀지 직접 표시한다
  • C++의 공유 포인터 - 여럿이 나눠 갖는 자리에 쓰고, 고리가 생기는 자리는 약한 포인터로 끊는다

여기서 실무 감각 하나가 나온다. 세는 언어에서 메모리가 안 줄면 먼저 의심할 것은 순환이다. 그리고 순환은 대개 “양쪽이 서로를 알아야 편해서” 만든 관계에서 생긴다.

  • 부모와 자식이 서로를 가리키는가. 한쪽 방향만으로 충분한지 먼저 본다
  • 콜백이 자기를 등록한 객체를 다시 붙들고 있는가. 이 모양은 세는 방식이 아닌 언어에서도 다른 이유로 문제가 되는데, 그 이야기는 이 시리즈의 마지막에서 한다
  • 끊어야 한다면 어느 쪽이 주인인지부터 정한다. 주인이 아닌 쪽을 약하게 만든다

그런데 순환을 사람이 찾아 표시해야 한다는 건 결국 자동 회수가 절반만 자동이라는 뜻이다. 그래서 다른 길을 택한 언어들이 있다. 세는 대신 닿아 보는 것이다.

정리

  • 자리마다 가리키는 곳의 개수를 세다가 0이 되면 회수한다. 판단이 지역적이라 단순하다.
  • 값은 즉시성이다. 마지막 참조가 사라지는 그 자리에서 회수되고, 회수가 잘게 흩어진다.
  • 대가는 대입마다 붙는 비용이고, 여러 스레드가 쓰면 원자적 갱신이라 더 비싸다.
  • 큰 덩어리를 놓으면 회수가 연쇄로 번져 그 자리에서 멈춘다.
  • 원리적 한계가 하나 있다. 서로 가리키는 고리는 카운트가 0이 안 된다.
  • 고리는 한쪽을 안 세기로(약한 참조) 끊는데, 어느 쪽을 안 셀지는 사람이 정한다.

다음 글은 이 한계를 정면으로 넘는 방식으로 간다. 세는 대신 로비에서 출발해 닿을 수 있는지를 따라가 보면 고리가 저절로 풀린다.