realloc은 데이터를 보존하지만 주소까지 지켜주지는 않는다
C 버퍼 확장에서 데이터 보존 범위, 임시 포인터와 실패 처리, 내부 포인터 갱신, 크기 오버플로를 독립 예제로 확인한다.
기존 내용을 더 큰 공간으로 옮기는 모습을 표현한 AI 생성 개념 이미지. 실제 메모리 배치나 실행 화면은 아니다.
C에서 버퍼를 늘릴 때 realloc()을 호출하면 이전 데이터가 사라질까? 새 크기가 0보다 크고 재할당에 성공했다면, 기존 크기와 새 크기 중 작은 쪽까지의 내용은 보존된다. 확장했다면 기존 바이트 전체가 남는다. 다만 같은 주소를 유지하거나, 매번 실제 복사를 수행한다는 보장은 없다.
실무에서 더 자주 문제가 되는 부분은 데이터 복사 자체보다 실패 처리와 포인터 관리다. 반환값을 원래 변수에 바로 덮어쓰거나, 버퍼 안쪽을 가리키던 포인터를 그대로 쓰면 문제가 생길 수 있다.
데이터는 보존돼도 위치는 달라질 수 있다
WG14의 C23 공개 초안 N3096 §7.24.3.7은 새 객체에 이전 내용이 보존되는 범위를 규정한다. 새 영역이 이전보다 크다면 추가된 바이트의 값은 지정되지 않는다. 따라서 뒤쪽이 0이라고 가정해서 읽으면 안 된다.
Microsoft의 realloc 문서도 내용 보존, 주소 이동 가능성, 추가 영역을 0으로 초기화하지 않는 동작을 설명한다. Windows냐 Linux냐보다, 유효한 할당 포인터를 올바른 할당기에 돌려주고 성공 여부를 검사하는지가 먼저다.
실제 구현이 제자리에서 확장할 수 있으면 주소가 그대로일 수도 있다. 옮겨야 한다면 다른 위치의 메모리가 반환될 수 있다. 프로그램은 어느 방식이 선택되든 동작해야 한다.
원래 포인터에 바로 대입하지 않기
다음 코드는 짧지만 실패했을 때 원래 할당 주소를 잃는다.
0보다 큰 크기의 재할당에 실패하면 NULL이 반환되고 기존 객체는 해제되지 않는다. 그런데 위처럼 덮어쓰면 프로그램이 그 객체를 찾아 해제할 방법까지 잃을 수 있다. 임시 포인터에서 성공을 확인한 뒤 갱신한다.
실패했을 때 기존 데이터를 계속 쓸지, 작업을 취소하고 해제할지는 호출자의 정책이다. realloc()이 실패했다고 기존 데이터가 자동으로 없어지는 것은 아니다. Microsoft 반환값 설명
바이트 버퍼 뒤에 0을 붙이는 독립 예제
다음 코드는 demo.c로 저장해 실행할 수 있는 독립 예제다. append_zeros()는 malloc() 계열로 확보한 바이트 버퍼의 뒤에 0을 추가한다. len은 현재 버퍼의 유효 길이이며 최소 그만큼 할당되어 있어야 한다. 빈 버퍼는 NULL과 길이 0으로 시작할 수 있다. 호출자는 유효한 buf, len 주소를 넘겨야 한다.
추가 크기가 0이면 할당기를 호출하지 않는다. 덧셈이 SIZE_MAX를 넘어갈지도 먼저 검사한다. 성공한 경우에만 새 포인터와 길이를 반영한다.
이 예제는 문자열 전용 함수가 아니다. 문자열에 사용한다면 마지막 널 문자 위치까지 별도로 관리해야 한다. 구조체나 포인터 배열을 모든 비트 0으로 만드는 것과, 각 요소에 언어 차원의 초기값을 대입하는 것도 일반적으로 같은 개념으로 취급하면 안 된다.
원소 수로 크기를 계산하는 코드는 덧셈뿐 아니라 곱셈도 확인해야 한다. 예를 들어 count * sizeof *items를 계산하기 전에 count > SIZE_MAX / sizeof *items인지 검사하는 방식이다. 오버플로가 발생한 뒤 작은 크기를 할당하면, 할당 성공 여부 검사만으로는 막을 수 없다.
버퍼 안쪽 포인터도 다시 계산하기
버퍼의 시작 주소만 갱신한다고 끝나지 않는다. 파서가 buf + offset을 가리키는 커서를 보관하고 있었다면, 재할당 성공 뒤에는 그 커서도 다시 만들어야 한다.
성공한 realloc()은 이전 객체의 수명을 끝낸다. 반환된 주소 값이 우연히 같아 보이더라도 이전 포인터나 별칭을 계속 사용하는 방식에 기대지 않는다. 특히 성공 후 이전 포인터를 읽어 새 포인터와 비교하는 디버깅 코드는 피하는 편이 좋다. 새 반환 포인터와 검증된 오프셋을 기준으로 상태를 재구성한다. N3096 §7.24.3.7
작게 실행해서 확인한 범위
위 함수를 별도 C 파일에 넣고 다음 네 가지를 검사했다.
ABCD4바이트를 8바이트로 늘린 뒤 앞의 4바이트가 보존되는지
- 직접 초기화한 뒤쪽 4바이트가 모두 0인지
- 추가 크기 0일 때 길이가 그대로인지
- 오버플로 요청을 할당 전에 거부하고, 이후 2바이트로 축소했을 때
AB가 남는지
검증용 main()은 assert()를 사용하므로 이 테스트를 실행할 때는 NDEBUG를 정의하지 않는다. Linux의 GCC 14.2.0에서 C17 모드와 경고 옵션, AddressSanitizer·UndefinedBehaviorSanitizer로 빌드했다. 이 실행 환경에서는 LeakSanitizer가 추적 환경 제약으로 종료되어 누수 검사만 끈 뒤 다시 실행했다.
실제 출력은 다음과 같다.
이 검사는 작은 정상 경로와 크기 계산 방어를 확인한 것이다. 실제 메모리 부족을 강제로 만들지는 않았고, 누수 검사가 통과했다고도 말할 수 없다. 모든 운영체제에서 직접 실행한 결과도 아니다. 플랫폼을 넘는 내용 보존 규칙은 테스트 횟수가 아니라 앞의 언어·런타임 문서에 근거한다.
0바이트 요청은 명시적으로 처리하기
realloc(ptr, 0)은 표준 버전과 구현에 따른 차이를 고려해야 한다. C23 공개 초안은 ptr이 널이 아닌 상태에서 크기 0인 요청을 정의되지 않은 동작으로 규정하고, Microsoft 문서는 자체 구현의 해제 동작을 별도로 설명한다. 이 차이에 기대는 공통 코드를 만들기보다, 비우려는 의도라면 free()와 상태 초기화를 명시하는 편이 읽기 쉽다.
정리하면 재할당 코드를 볼 때는 임시 포인터, 크기 계산, 추가 영역 초기화, 내부 포인터 갱신을 함께 확인하면 된다. 데이터 보존 보장만 믿고 나머지 상태를 그대로 두지 않는 것이 안전한 버퍼 확장의 출발점이다.