TECH 으로 돌아가기
TECH HACKER NEWS 오늘 6분 읽기 32 READS

Zig ArrayList에 도입된 포인터 안정성 잠금, 메모리 버그를 조기에 잡는 법

동적 배열은 거의 모든 언어에서 가장 흔하게 쓰이는 자료구조지만, 그만큼 조용히 메모리 버그를 만들어내는 원인이기도 하다. 배열이 용량을 초과해 커질 때 내부 저장소가 새 위치로 재할당되면, 그 이전에 확보해 둔 요소 포인터나 슬라이스가 일제히 무효화되기 때문이다. C나 C++를 다뤄 본 사람이라면 std::vector에 원소를 추가한 뒤 예전 반복자가 망가지는 경험을 떠올릴 것이다. Zig 표준 라이브러리는 최근 이 오래된 함정을 컴파일 이후 실행 시점에 잡아내기 위한 장치를 std.ArrayList에 추가했다.

이 기법은 갑자기 나온 것이 아니다. Zig는 2024년에 해시 맵 계열 컨테이너에 '포인터 안정성 잠금(Pointer Stability Lock)'을 먼저 도입했고, Leo Emar-Kar가 2025년에 연 풀 리퀘스트를 통해 같은 방식이 이번에 ArrayList까지 확장됐다. 사용법 자체는 단순하다. 리스트가 뒷받침하는 요소나 슬라이스의 포인터를 처음 저장하는 시점에 lockPointers()를 호출하고, 그 포인터를 더 이상 쓰지 않게 되면 unlockPointers()를 호출하면 된다. 잠금이 걸려 있는 동안 리스트가 재할당을 유발하는 연산을 시도하면 실행이 즉시 중단된다.

흔한 버그의 형태

원문이 든 예시는 실무에서 쉽게 재현되는 구조다. 두 개의 ArrayList가 있는데, 하나는 입력 전체 내용을 메모리에 담아두고 다른 하나는 그중 관심 있는 조각들, 이를테면 각 줄을 가리키는 정보를 저장한다. 문제는 두 번째 리스트의 원소들이 첫 번째 리스트가 담긴 메모리 위치에 의존한다는 점이다. 첫 번째 리스트가 현재 용량을 넘어 확장되어야 할 때 그 위치가 바뀌면, 두 번째 리스트가 가리키던 포인터들은 모두 엉뚱한 곳을 향하게 된다. 하나의 버퍼를 파싱하면서 그 결과를 다른 버퍼가 참조하는 흔한 패턴이기에, 겉보기에는 자연스러운 코드가 조용히 깨진다.

이 버그가 골치 아픈 이유는 증상이 일관되지 않기 때문이다. 원문 저자에 따르면 zig test로 실행하면 문제가 있다는 사실 자체는 드러나지만, 버그가 겉으로 나타나는 방식은 어떤 할당자(allocator)를 쓰느냐에 따라 달라진다. 메모리 문제 디버깅에 익숙하지 않다면 원인이 로직 오류인지 메모리 오류인지조차 판단하지 못한 채 오랜 시간을 헤맬 수 있다.

잠금이 바꾸는 것

여기서 문제의 포인터를 저장한 직후에 lockPointers()를 넣어 두면 상황이 달라진다. 리스트가 잠금 상태에서 재할당을 일으키려는 순간 패닉이 발생하고, 포인터 안정성에 대한 가정이 어디에서 깨졌는지 스택 트레이스가 정확히 지목해 준다. 원인을 넓은 코드 영역에서 추리하는 대신, 위반이 일어난 지점을 곧바로 확인할 수 있는 것이다. 실패의 원인 후보에 '메모리 안전성 문제'를 명시적으로 올려 준다는 점만으로도 디버깅 부담이 크게 줄어든다.

다만 ArrayList가 해시 맵과 결정적으로 다른 특성 하나를 짚어둘 필요가 있다. ArrayList는 순서가 있는 컨테이너라서, 전체 저장소를 옮기거나 크기를 바꾸거나 해제하지 않더라도 내부 연산이 원소들의 위치를 이리저리 옮길 수 있다. 예컨대 addManyAsSlice(gpa, n)이 돌려준 슬라이스는, 이후 orderedRemove()pop()을 호출하고 나면 더 이상 리스트의 마지막 n개 원소를 가리키지 않을 수 있다. 이 때문에 orderedRemove()pop()은 아무런 새 할당을 하지 않는데도, lockPointers()가 걸린 상태에서는 동일한 assertion을 발동시킨다. 재할당만이 아니라 원소 재배치까지 위험으로 간주한다는 뜻이다.

실무적 함의와 한계

이 기능의 가치는 정적 검사가 아니라 런타임 검사라는 데 있다. 즉 컴파일 단계에서 위험을 원천 차단해 주는 것이 아니라, 실제로 문제가 되는 경로를 실행할 때 시끄럽게 실패하도록 만들어 준다. 따라서 테스트가 해당 경로를 밟아야 효과를 보며, lockPointers()unlockPointers()의 범위를 개발자가 직접 관리해야 한다는 부담도 남는다. 잠금 해제를 잊으면 정당한 연산까지 막힐 수 있어, 포인터의 실제 수명에 맞춰 잠금 구간을 좁게 잡는 규율이 필요하다.

그럼에도 하나의 버퍼를 백업 저장소로 두고 그 안의 조각을 다른 자료구조가 참조하는 구조는 파서, 로그 처리, 스트리밍 입력 처리 등 실코드에서 자주 등장한다. 재할당으로 인한 포인터 무효화가 원래는 운에 맡겨진 크래시로 나타났다면, 이제는 명확한 위반 지점을 가진 진단으로 바뀐다. 값비싼 도구를 새로 들이지 않고도 이런 종류의 메모리 오류를 조기에 드러낼 수 있다는 점에서, Zig가 안전성을 언어 차원의 규칙보다 실행 가능한 계약으로 다루려는 방향을 다시 확인시켜 주는 변화다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://ziglang.org/devlog/2026/#2026-08-27
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...