hello, robot

프로그램이 죽으면 죽은 줄부터 봐요. 자연스러운 반응이고, 대부분의 언어에서 맞아요. 예외가 던져진 자리가 대개 문제가 있는 자리니까요.

메모리를 직접 다루는 언어에서는 이 반응이 거의 항상 틀려요. 그리고 그 이유를 알면 디버깅 방식 자체가 바뀌어요.

허용되지 않은 자리를 건드리면 죽어요

운영체제는 프로세스마다 쓸 수 있는 메모리 구역을 정해 줘요. 그 밖을 건드리면 보호 장치가 발동해 프로그램을 강제 종료시켜요.

원인은 몇 가지로 정해져 있어요.

  • 아무것도 안 가리키는 포인터를 역참조하는 것
  • 배열 범위를 벗어나 접근하는 것
  • 이미 해제한 메모리를 다시 쓰는 것
  • 초기화 안 한 포인터가 쓰레기 주소를 가리키는 것
  • 재귀가 끝나지 않아 스택이 넘치는 것

목록만 보면 단순해 보여요. 문제는 이것들이 저지른 순간에 안 죽는다는 데 있어요.

저지른 자리와 터지는 자리가 달라요

배열 범위를 조금 넘어 쓴다고 해 볼게요. 그 자리가 다른 변수가 쓰는 메모리일 수 있어요. 그러면 운영체제 입장에서는 여전히 허용된 구역이라 아무 일도 안 일어나요.

대신 그 다른 변수의 값이 조용히 바뀌어요. 나중에 그 변수를 읽는 코드가 이상한 값을 보게 되고, 그 값으로 포인터 계산을 하면 그때 엉뚱한 주소가 나와 터져요.

그래서 크래시한 줄에는 대개 잘못이 없어요. 남이 망가뜨린 값을 읽었을 뿐이죠. 그 줄을 아무리 들여다봐도 이상한 데가 없는 게 당연해요.

해제 후 사용은 더 심해요. 해제된 메모리는 한동안 값이 그대로 남아 있어요. 그래서 읽으면 멀쩡한 값이 나와요. 그러다 다른 할당이 그 자리를 재사용하는 순간부터 값이 바뀌죠. 언제 재사용되는지는 실행마다 달라요.

이게 재현이 안 되는 이유예요. 같은 입력으로 열 번 돌려 아홉 번 정상이고 한 번 죽는데, 코드는 매번 똑같이 잘못하고 있어요. 드러나느냐 마느냐만 달라지는 거죠.

그래서 도구가 필요해요

사람이 코드를 읽어 찾을 수 있는 종류가 아니에요. 저지른 순간을 잡아 주는 도구가 필요해요.

컴파일 옵션 하나로 켤 수 있는 검사기가 있어요. 프로그램에 검사 코드를 심어서 잘못된 접근이 일어나는 그 자리에서 멈추고 알려 줘요. 어느 줄에서 어떤 종류의 위반을 했는지, 그 메모리가 어디서 할당되고 어디서 해제됐는지까지 나와요.

느려지긴 하는데 몇 배 정도라 개발 중에는 충분히 켜 둘 만해요. 며칠 걸릴 추적이 한 번의 실행으로 끝나는 걸 생각하면 비교가 안 되죠.

크래시 후에 호출 스택을 보는 도구도 있어요. 다만 앞에서 본 이유로 그 스택 맨 위가 범인이 아니에요. 어디서 터졌는지는 알려 주지만 왜 그 값이 이상해졌는지는 안 알려 주죠. 그래서 순서는 검사기가 먼저예요.

관리되는 언어에서 오면 낯선 부분

메모리를 알아서 관리해 주는 언어에서는 이 문제가 다른 얼굴로 나타나요.

아무것도 안 가리키는 참조를 쓰면 그 자리에서 예외가 던져져요. 배열 범위를 넘으면 역시 그 자리에서 예외가 나고요. 저지른 순간과 알려 주는 순간이 같아서 죽은 줄이 곧 범인이에요.

그리고 이미 해제한 메모리라는 개념 자체가 없어요. 참조가 살아 있으면 그 메모리도 살아 있으니까요.

그래서 넘어올 때 안전망이 없어진 게 아니라 진단 시점이 달라진 것으로 이해하는 게 정확해요. 잘못을 못 하게 막던 것이 사라진 게 아니라, 잘못을 즉시 알려 주던 것이 사라진 거죠.

습관으로 막는 편이 싸요

찾는 것보다 안 만드는 게 훨씬 싸요. 그래서 규칙 몇 개가 관습이 됐어요.

포인터를 직접 다루지 않고 수명을 타입으로 적는 도구를 쓰는 것, 배열 접근 대신 범위 기반 순회를 쓰는 것, 크기를 계산할 때 뺄셈을 피하는 것이요. 하나하나는 사소한데 합치면 위 목록의 원인 대부분이 사라져요.

그리고 경고를 켜 두는 것도 여기 들어가요. 초기화 안 한 변수나 부호가 섞인 비교는 컴파일 단계에서 잡히는데, 기본 설정에서는 조용히 넘어가거든요. 켜는 데 드는 비용이 옵션 하나라 안 켤 이유가 없어요.

수명을 타입으로 적는 도구는 C++ 소유권과 수명 — 스마트 포인터에, 컴파일러가 못 잡는 나머지 자리는 C++ STL — 컴파일러가 못 잡아 주는 자리에, 도구를 실제로 붙이는 순서는 C++ 도구와 디버깅 — 경고, sanitizer, gdb에 있어요.