C 문자열: 50년간 이어진 실수
요약
C 언어의 전통적인 NUL 종료 문자열 방식은 길이 정보를 보존하지 않아 경계 오류와 복잡성을 야기합니다. 현대 프로그래밍에서는 데이터 포인터와 크기를 함께 저장하는 '길이 기반 문자열' 구조체가 더 안전하고 유연한 설계로 권장됩니다.
핵심 포인트
- NUL 종료는 메모리 제약 시대의 방식이며, 현대에는 길이 기반 처리가 유리함.
- 길이 기반 문자열은 빈 문자열 처리와 바이너리 데이터 탐색에 일관적임.
- 부분 문자열 반환 시 중간 할당/복사 없이 원본 메모리를 가리킬 수 있음.
- 문자열 함수 사용 시 NUL 포함 여부, 크기 인자 등을 항상 확인해야 함.
- C의
NUL 종료 문자열은 길이 정보를 보존하지 않아 반복 탐색과 경계 오류를 낳으며, 현대에는 포인터와 크기를 함께 저장하는 길이 기반 문자열이 기본 설계로 더 적합함 - 명시적 길이가 없으면
strlen
호출과 바이트 순회가 반복되고, snprintf
, sizeof
, strlen
마다 종료 바이트 포함 여부가 달라 코드 작성과 검토가 어려워짐
- 길이 기반 문자열은 빈 문자열을
size == 0
으로 일관되게 처리하며, NUL 바이트를 포함한 임의 바이너리 데이터에도 탐색·분할·슬라이싱 연산을 그대로 적용할 수 있음
- 원본 메모리의 일부를 가리키는
부분 문자열을 반환할 수 있어 자르기·검색·토큰화와 CSV·Markdown·JSON·C 파싱에서 중간 할당과 복사를 피할 수 있음 - 센티널은 불변식 유지와 일부 토큰 탐색에 여전히 유용하고 기존 C·OS API와의 변환도 필요하지만, 대부분의 코드에서는 길이 기반 문자열에
API 경계의 불변성을 결합하는 편이 단순하고 유연함
NUL 종료 대신 포인터와 길이 저장
- C 문자열은 문자 스트림을 가리키는 포인터와 마지막
NUL
바이트로 경계를 나타냄
-
1970년대의 메모리·성능 제약에서는 합리적이었을 수 있지만, 현대에는 이 방식을 유지할 이유가 거의 없음
-
최신 언어와 주요 프레임워크가 채택한 대안은
데이터 포인터와 크기를 함께 저장하는 구조체임
struct String
{
u8* data;
u64 size;
};
- 문자열 리터럴은
sizeof(s) - 1
로 길이를 계산해 String
으로 만들 수 있으며, 비교·출력 같은 연산은 저장된 크기를 직접 이용함
- 배열도 포인터로 변환되면 길이 같은 정보를 잃어 문자열과 비슷한 안전성·사용성 문제를 겪음
길이 정보를 버릴 때 생기는 비용
- 길이를 저장하지 않으면 사용하는 코드마다
strlen
을 반복 호출하거나 바이트를 하나씩 순회해야 하므로 불필요한 작업과 복잡성이 늘어남
- 런타임에서 길이를 검사하고 강제하기도 더 느리고 번거로우며, 디버거와 분석 도구는
char*
, char[N]
, char*[N]
을 일관되게 취급하기 어려움
- C의 메모리 문제와 오버플로 버그 상당수가 이 설계에서 비롯됐다는 견해가 있지만, 센티널만으로 충분하다는 반론도 있음
- 길이를 모르는 문자열은 배열의 무작위 접근보다 연결 리스트와 같은
순차 접근 패턴에 가까워짐 - NUL 종료 방식을 옹호하는 쪽에서는 순차 처리가 다중 패스보다 단일 패스 알고리듬을 유도해 효율적이라고 봄
- 초보자를 비롯한 많은 프로그래머는 문자열 길이를 저렴하게 얻을 수 있다고 가정해 코드를 작성함
- 일반적인 문자열 루틴도 길이가 제공되는 형태로 더 자연스럽게 작성되며, 반복적인
strlen
호출 때문에 실제 코드에서 같은 문자열을 여러 번 순회하게 됨
- 패스 수가 적으면 항상 빠르다는 전제는 현대 CPU의 동작 방식을 오해한 결과임
snprintf
, sizeof
, strlen
이 뜻하는 서로 다른 길이
snprintf
의 두 정수는 같은 “길이”처럼 보이지만 NUL 포함 규칙이 서로 반대임
int snprintf(char *str, size_t size, const char *format, ...);
- 입력
size
는 출력 버퍼의 NUL 종료 공간까지 포함해야 하지만, 반환값은 NUL 바이트를 제외하고 생성됐을 문자열 길이를 나타냄
- 문자열 관련 함수나 API를 사용할 때마다 다음 조건을 따로 확인해야 함
- 함수가 NUL 바이트를 직접 기록하는지
- 할당 크기에 NUL 바이트 공간을 추가해야 하는지
- 크기 인자와 반환값이 NUL 바이트를 포함하는지
sizeof("some string")
은 NUL 바이트를 포함하지만 strlen("some string")
은 제외함
- 리터럴 기반 코드를 런타임 포인터 기반 코드로 바꾸면서 동일한
- 1
을 유지하면 길이가 하나 짧아지는 버그가 생김
#define TEST_STRING "some string"
size_t size = sizeof(TEST_STRING) - 1;
const char *str = "some string";
size_t size = strlen(str) - 1;
중간 NUL 바이트를 반복해서 덮어쓰는 작업
- 여러
snprintf
반환값을 오프셋에 더해 문자열을 이어 붙이면, 각 호출이 기록한 NUL 종료 바이트를 다음 호출이 덮어씀
int offset = 0;
offset += snprintf(ptr + offset, size - offset, "%d", my_int);
offset += snprintf(ptr + offset, size - offset, "%s", my_str);
offset += snprintf(ptr + offset, size - offset, "%f", my_flt);
- 중간 종료 바이트를 기록하는 작업은 불필요하지만, 성능에 미치는 영향 자체는 크지 않을 수 있음
- 더 큰 문제는 이 동작이 직관적이지 않아
snprintf
가 NUL 종료를 하지 않는다는 오해를 낳는다는 점임
- 그 결과 마지막에
ptr[offset] = 0;
을 추가하는 코드가 생기지만, 이 작업도 불필요하며 같은 오해를 더욱 강화함
빈 문자열과 널 문자열의 이중 상태
- NUL 종료 모델에서는 빈 문자열과 널 포인터가 서로 다른
유효하지 않은 상태처럼 취급돼 문자열 처리 코드가 둘 다 검사해야 함 - C#의 문자열과 배열도 참조형이므로 널 상태에서
Length
를 읽거나 순회하면 예외가 발생해 별도 처리가 필요함
- 흔한 대응은 널 대신 항상 빈 문자열이나 빈 배열을 전달하고 반환하는 것임
String.IsNullOrEmpty
, “Don’t Return Null”, “Don’t Pass Null” 같은 규칙도 이 구분을 처리하기 위한 방식임
- 포인터와 길이를 가진 구조체에서는 빈 문자열 여부를
size == 0
하나로 판단할 수 있음
- 포인터는 널일 수도 있고 유효한 주소일 수도 있음
- 문자열 끝까지 이동했거나 큰 문자열 내부의 빈 부분 문자열을 가리킬 때는 길이가 0이어도 유효한 포인터를 가질 수 있음
- 길이를 확인한 뒤에만 역참조한다면, 이미 해제된 메모리를 가리키는 포인터도 길이가 0인 상태에서는 접근되지 않음
바이너리 데이터와 동일한 표현 사용
-
길이 기반 문자열은 센티널에 의존하지 않아
NUL 바이트가 포함된 임의 바이너리 데이터도 안전하게 저장할 수 있음 -
ASCII 문자열 함수가 UTF-8에서도 동작하듯, 스캔·분할·트리밍·슬라이싱 같은 길이 기반 연산을 바이트 배열에도 적용 가능함
-
NUL 종료 바이너리 형식은 종료 바이트를 저장할지 별도로 결정해야 함
-
저장하면 읽기·쓰기 코드가 일반적인 문자열 처리 방식과 가까워짐
-
저장하지 않으면 공간과 처리 오버헤드를 줄일 수 있으며, 이는 바이너리 형식을 사용하는 주요 이유 중 하나임
-
길이와 내용을 모두 저장하면서 NUL 바이트까지 붙이는 형식은 저장된 길이가 종료 바이트를 포함하는지 다시 확인해야 하므로 같은 모호성을 되살림
할당과 복사 없는 부분 문자열
- NUL 종료를 강제하면 트리밍·슬라이싱·분할·토큰화·검색 결과의 끝에도 종료 바이트를 배치해야 하므로 새 문자열과 중간 버퍼를
할당·복사하게 됨 - 길이 기반 문자열은 원본의 일부를 가리키는 포인터와 길이만 반환할 수 있음
String StrPrefix(String str, u64 size);
String StrPostfix(String str, u64 size);
String StrChop(String str, u64 size);
String StrSkip(String str, u64 size);
String Substr(String a, u64 min, u64 max);
String StrFindNeedle(String str, String needle);
String StrTrim(String str);
- CSV, Markdown, JSON, C용 렉서와 파서는 각 토큰을 복사하지 않고 입력 버퍼의
슬라이스를 파스 트리의 이름과 값으로 보관할 수 있음 - 파스 트리를 사용하는 후속 코드도 같은 슬라이스를 전달받아 추가 메모리 관리 없이 처리 가능함
센티널이 여전히 유용한 경우
- 센티널은 자료구조의 불변식을 유지하고 일부 상황에서 성능 이점을 줄 수 있음
- 현대 하드웨어와 컴파일러에서는 성능상 이점이 특수한 사례를 제외하면 대부분 약해졌지만, 프로그램이 관리해야 할
불변식 수를 줄이는 효과는 남아 있음 - 직접 작성한 토큰 검사에서는 NUL 센티널 덕분에 반복문이 현재 바이트를 안전하게 읽을 수 있음
s[i] == 'f' && s[i+1] == 'o' && s[i+2] == 'r'
같은 미리보기는 바이트 순서와 단락 평가를 이용해 조기에 실패할 수 있음
-
길이 기반 문자열은 공백으로 토큰이 끝난 경우와 입력 자체가 끝난 경우를 구분해야 하며, 미리보기에도 더 체계적인 경계 검사가 필요함
-
기존 C 라이브러리와 OS API 대부분이 NUL 종료 문자열을 요구하므로 변환 비용이 발생함
-
Windows에서는 UTF-8을 UTF-16으로 이미 변환해야 하므로 NUL 바이트를 추가하는 비용은 상대적으로 작음
-
OS와 벤더가 부과하는 제약은 실제보다 과대평가되고, 길이 기반 문자열의 이점은 과소평가될 수 있음
문자열 API에 불변성을 적용하는 방법
-
완성도 높은 문자열 계층의 전체 API 설계와 추상화는 범위 밖이지만, 핵심 구조 원칙으로
불변성을 권장함 -
문자열은 한 번 생성되면 내용을 변경하지 않고 상수처럼 다루며, 문자열을 받아 문자열을 반환하는 함수도 이 속성을 유지해야 함
-
불변성은 함수 시그니처와 API 경계에서 보장하면 충분함
-
함수 내부에서는 필요에 따라 제자리 수정이나 다른 절차적 처리를 사용할 수 있음
-
상위 정보 흐름에서는 함수형 프로그래밍의 불변성 이점을 얻고, 하위 구현에서는 절차적 프로그래밍의 유연성을 유지함
-
이 규칙은 언어나 런타임이 강제하지 않아도
코딩·API 관례로 운용할 수 있음 -
문자열 유형과 연산을 명확히 구분하면 규칙을 유지하는 부담이 크지 않으며, 코드베이스가 적용 범위와 완화 시점을 직접 정할 수 있음
-
언어도 하나의 API이므로 언어 위원회가 정한 제약에만 의존하지 않고, 필요한 세분성으로 규칙을 설계할 수 있음
길이 기반 문자열의 구현 사례
다른 문자열 표현이 가진 제약
유연한 배열 멤버
- Redis의 SDS처럼 구조체 앞부분에
현재 길이와 최대 용량을 저장하고, 마지막 필드를 유연한 배열 멤버로 선언해 문자 데이터를 배치함
strlen
의 O(n) 스캔과 수동 연결·경계 관리 같은 C 문자열의 문제를 피할 수 있음
-
문자열 리터럴과 직접 호환하기 어렵고, 길이 기반 문자열의 핵심 장점인 효율적인 부분 문자열을 지원하지 못함
스트레치 버퍼
- 스트레치 버퍼는 동적 배열과 비슷하며, 길이와 용량 헤더를 명시적 구조체가 아닌 포인터 앞의 별도 영역에 저장함
- 유연한 배열 멤버 방식의 단점에 더해 모든
char*
에 거의 보이지 않는 메타데이터 규칙을 부여하며, 의미 있는 문자열 연산마다 이 헤더를 API 모델의 일부로 사용해야 함
- 포인터 표현은 타입 안전성을 제공하고
.str
또는 ->str
접근을 피할 수 있지만, 이 이점만으로 숨은 규칙을 정당화하기는 어려움
-
별도
capacity 필드는 동적 배열에는 자연스럽지만, 문자열에서는 서로 다른 개념을 한 타입에 섞어 유형 간 구분을 흐림 -
유연한 배열 멤버에도 같은 문제가 있을 수 있으나, 초기 고정 크기를 용량으로 사용하고 동적 확장을 피할 수 있다는 차이가 있음
Pascal 스타일
- Pascal식 짧은 문자열은 보통 256바이트의
고정 크기 문자 배열을 사용하며, 길이용 1바이트를 예약하거나 NUL 종료를 이용함 - 과거의 실제 메모리·하드웨어 제약에서는 유효했지만, 현대에는 좋은 아레나 메모리 할당 전략을 사용할 수 있어 특수한 상황 외에는 적합하지 않음
길이 기반 문자열을 기본값으로 삼아야 하는 이유
- 길이 기반 문자열의 장점은 일부 커뮤니티와 숙련된 프로그래머 사이에서 알려져 있지만, 많은 개발자는 여전히 이런 구현을 고려한 적이 없음
- 관련 자료가 여러 곳에 흩어져 있어 길이 손실, 종료 바이트 모호성, 바이너리 처리, 부분 문자열, 센티널의 장단점과 대체 구현을 함께 비교하기 어려웠음
- 기본 문자열 타입을 포인터와 길이의 슬라이스 형태로 만들면
경계 정보 보존, 상태 단순화, 바이너리 호환, 할당 없는 부분 문자열을 하나의 설계로 해결할 수 있음
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기