CS · Memory · Backend

손으로 돌려주기 - 두 번 놓거나 한 번도 안 놓거나

반납을 사람이 적으면 실패하는 방식이 셋이다. 안 놓거나, 두 번 놓거나, 놓고 나서 또 쓰거나. 이 단순해 보이는 일을 왜 사람이 자꾸 틀리는지, 그리고 왜 그 버그가 하필 엉뚱한 데서 터지는지를 본다.

·메모리의 수명 2편
목차
  1. 반납을 코드에 적는다
  2. 첫째 실패: 안 놓는다
  3. 둘째 실패: 두 번 놓는다
  4. 셋째 실패: 놓고 나서 또 쓴다
  5. 셋 다 조용히 틀린다
  6. 왜 사람이 못 하나
  7. 규칙을 언어에 맡기는 쪽
  8. 실무에서: 반납 안 되는 자원은 여전히 손에 있다
  9. 정리

앞 글에서 반납은 누군가 해야 한다고 했다. 가장 곧은 답은 사람이 적는 것이고, 실제로 오랫동안 그렇게 해 왔다.

반납을 코드에 적는다

C에서는 힙에서 자리를 얻는 일과 돌려주는 일이 둘 다 함수 호출이다.

c
Order *o = malloc(sizeof(Order));   // 방을 하나 빌린다
use(o);
free(o);                            // 다 썼으니 반납한다

free를 부르는 순간 그 자리는 “비었다”로 표시되고, 다음에 누가 malloc을 부르면 그 자리가 다시 나갈 수 있다.

이 방식은 값이 확실하다. 언제 반납할지를 사람이 정하니 프로그램은 아무 판단도 안 한다. 회수기가 돌지 않으니 예상 못 한 멈춤도 없고, 반납한 순간 그 자리는 곧바로 재사용된다. 회수 방식 중에 가장 빠르고 가장 예측 가능한 쪽이다.

대신 대가가 하나다. 사람이 틀린다. 그리고 틀리는 방식이 정확히 세 가지다.

첫째 실패: 안 놓는다

가장 흔한 건 그냥 free를 안 쓰는 것이다.

c
Order *o = malloc(sizeof(Order));
if (!validate(o)) return -1;   // 여기로 빠지면 반납이 없다
use(o);
free(o);

정상 흐름에는 반납이 적혀 있다. 그런데 중간에 빠져나가는 길이 하나 생기자 그 길에는 반납이 없다. 회의실로 치면 회의를 하다 급한 일이 생겨 그냥 나가 버린 것이다. 방은 여전히 내 이름으로 잡혀 있다.

이게 메모리 누수다. 한 번 새면 그 자리는 프로그램이 끝날 때까지 아무도 못 쓴다. 요청 하나마다 조금씩 새면 하루 뒤에 힙이 꽉 찬다.

무서운 점은 테스트를 통과한다는 것이다. 결과는 맞고, 잠깐 돌려서는 아무 증상도 안 난다. 오래 돌려야만 드러난다.

둘째 실패: 두 번 놓는다

반대로 같은 자리를 두 번 반납하는 경우다.

c
free(o);
...
free(o);   // 이미 반납한 자리를 또 반납한다

한 함수 안에 free가 두 번 적히는 일은 드물다. 대개는 같은 자리를 두 곳이 들고 있을 때 생긴다. A도 이 주문을 갖고 있고 B도 갖고 있는데, 둘 다 “내가 다 썼으니 반납한다”고 판단하는 것이다.

회의실이라면 두 번 반납해도 별일이 없다. 이미 빈 방을 다시 비웠을 뿐이다. 여기서 비유가 깨진다. 메모리 관리자는 빈 자리들을 자기 나름의 장부로 이어 놓고 관리하는데, 이미 빈 자리를 또 비우면 그 장부 자체가 망가진다. 그러고 나면 이후의 모든 malloc이 이상한 자리를 내주기 시작한다.

셋째 실패: 놓고 나서 또 쓴다

반납은 제대로 했는데, 그 자리를 가리키던 이름이 아직 남아 있는 경우다.

c
free(o);
printf("%d\n", o->id);   // 이미 남의 자리일 수 있다

free는 자리를 비웠다고 표시할 뿐 그 자리를 가리키던 변수까지 지워 주지는 않는다. o는 여전히 같은 곳을 가리키고 있다. 그래서 읽으면 값이 읽히기는 한다.

문제는 그사이 그 자리가 다른 누군가에게 나갔을 수 있다는 것이다. 회의실을 반납했는데 열쇠를 안 돌려준 채로 다시 문을 여는 것과 같다. 방이 아직 비어 있으면 아무 일도 안 나고, 이미 다른 팀이 들어와 있으면 남의 회의 한복판에 들어가는 셈이다.

읽기만 하면 엉뚱한 값을 읽고, 쓰면 남의 데이터를 덮어쓴다. 셋 중 가장 위험한 실패다.

제대로 돌려줬다반납 표시가 하나
이 코드가 쓰는 중
반납
다음 코드가 쓴다
안 놓는다반납 표시가 없다
이 코드가 쓰는 중
끝까지 아무도 못 쓴다
두 번 놓는다반납 표시가 둘
이 코드가 쓰는 중
반납
이미 빈 자리
또 반납 - 장부가 망가진다
놓고 나서 쓴다표시 뒤로 손이 하나 더
이 코드가 쓰는 중
반납
다른 데이터가 들어와 있다
옛 이름으로 읽고 쓴다
시간 →

제대로 된 줄에서 달라지는 건 줄마다 하나다 - 표시가 없거나, 둘이거나, 표시 뒤에 손이 하나 더 뻗는다.

셋 다 조용히 틀린다

세 실패에는 공통점이 하나 있고, 그게 이 방식의 진짜 어려움이다. 틀린 자리에서 안 터진다.

실패언제 드러나나
안 놓는다몇 시간 뒤, 힙이 찼을 때
두 번 놓는다나중에 엉뚱한 malloc이 이상해질 때
놓고 나서 쓴다그 자리를 다른 쪽이 쓰기 시작한 뒤

셋 다 잘못한 코드는 멀쩡히 통과하고, 아무 잘못 없는 코드가 죽는다. 스택 트레이스를 봐도 범인이 안 나온다. 거기 찍힌 건 피해자다.

게다가 재현이 잘 안 된다. 반납 후 사용이 문제가 되려면 그사이 누군가 그 자리를 가져가야 하는데, 그건 타이밍에 달렸다. 내 컴퓨터에선 되는데라는 말이 나오는 버그의 한 갈래가 이런 모양이다. 개발 장비에서는 부하가 없어 자리가 곧바로 재사용되지 않기 때문이다.

왜 사람이 못 하나

코드를 더 조심해서 쓰면 되지 않나 싶지만, 그렇게 안 된다. 이유는 세 가지다.

마지막 사용자를 한 곳에서 알 수 없다. 반납해도 되는 시점은 “아무도 안 쓸 때”인데, 그걸 판단하려면 이 자리를 누가 들고 있는지 전부 알아야 한다. 함수 하나를 짜는 사람이 볼 수 있는 범위를 넘는다.

빠져나가는 길이 여럿이다. return이 세 군데, 예외가 두 군데면 반납을 다섯 번 적어야 한다. 하나만 빠뜨려도 샌다. 그리고 나중에 누가 return을 하나 더 추가하면 그때 또 샌다.

소유가 옮겨 다닌다. 만든 쪽이 반납하는 게 아니라 넘겨받은 쪽이 반납해야 할 때가 있다. 그럼 “이 자리는 누구 것인가”가 코드에는 안 적히고 사람 머릿속에만 남는다. 규칙이 문서나 주석에만 있으면 반드시 어긋난다.

규칙을 언어에 맡기는 쪽

그래서 오래된 언어들도 손으로 다 하게 두지는 않는다. 반납 시점을 사람이 매번 적는 대신, 컴파일러가 확인할 수 있는 규칙으로 바꾸는 것이 방향이다.

  • 범위를 벗어나면 자동으로 반납한다. 힙에서 얻은 자리를 스택에 사는 작은 객체가 대신 들고 있게 하고, 그 객체가 걷힐 때 반납하게 만든다. 그러면 빠져나가는 길이 몇 개든 반납이 한 번은 일어난다. C++의 소멸자와 스마트 포인터가 이 꼴이다
  • 소유자를 하나로 못 박는다. 이 자리의 주인은 언제나 정확히 하나이고, 넘기면 앞의 주인은 잃는다. 그러면 두 번 반납할 수가 없다. 러스트의 소유권이 이 꼴이다
  • 여러 곳이 들어야 하면 수를 센다. 몇 곳이 들고 있는지 세다가 0이 되면 반납한다

앞의 둘은 사람이 적던 규칙을 언어가 검사하게 만든 것이다. 반납이 사라진 게 아니라 적는 자리가 옮겨 갔을 뿐이다.

마지막 것은 성격이 다르다. 프로그램이 실행 중에 세어 판단한다. 여기서부터는 사람이 아니라 프로그램이 회수를 맡는 영역이고, 다음 글의 주제다.

실무에서: 반납 안 되는 자원은 여전히 손에 있다

메모리를 자동으로 회수하는 언어를 쓰고 있어도 이 글의 세 실패는 남는다. 회수기가 맡는 건 메모리뿐이고, 나머지 자원은 여전히 사람이 닫아야 하기 때문이다.

  • 닫는 코드를 갈림길마다 적지 않는다. 파일·연결·잠금은 try-with-resources나 defer처럼 블록을 벗어나면 반드시 닫히는 문법으로 감싼다. 첫째 실패를 문법이 대신 막아 준다
  • 두 번 닫히는 길을 막는다. 넘겨받은 쪽도 닫고 넘긴 쪽도 닫으면 둘째 실패와 같은 모양이 된다. 닫는 책임은 한 곳으로 못 박는다
  • 닫은 뒤에 쓰지 않는다. 이미 반납한 연결을 들고 있다가 재사용하는 코드가 셋째 실패다. 반납한 참조는 그 자리에서 버린다
  • 누가 주인인지를 이름과 타입으로 드러낸다. 넘겨받은 것을 닫아야 하는지 아닌지를 주석으로만 적어 두면 반드시 어긋난다. 함수 이름이나 반환 타입이 그걸 말하게 만드는 편이 낫다

메모리 회수를 언어가 대신 해 주는 것은 이 골칫거리 하나를 통째로 없애 준 일이다. 얼마나 큰 일인지는 위의 세 실패를 손으로 막아 본 사람만 안다. 다만 다음 글부터 볼 것처럼, 그 대신 치르는 값이 따로 있다.

정리

  • 반납을 사람이 적으면 가장 빠르고 가장 예측 가능하다. 프로그램은 아무 판단도 안 한다.
  • 대신 실패가 셋이다. 안 놓기(누수)·두 번 놓기(장부 손상)·놓고 나서 쓰기(남의 자리 침범).
  • 셋 다 틀린 자리에서 안 터진다. 잘못한 코드는 통과하고 애먼 코드가 죽는다.
  • 사람이 못 하는 이유는 셋이다. 마지막 사용자를 알 수 없고, 빠져나가는 길이 여럿이고, 소유가 코드에 안 적힌다.
  • 그래서 방향은 하나다. 반납 시점을 사람의 기억이 아니라 언어가 검사할 수 있는 규칙으로 옮긴다.

다음 글은 그 규칙을 프로그램이 실행 중에 지키는 첫 번째 방법으로 간다. 방에 든 사람 수를 세다가 0이 되면 비우는 방식이다.