# C 문자열: 50년간 이어진 실수

> Clean Markdown view of GeekNews topic #31474. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=31474](https://news.hada.io/topic?id=31474)
- GeekNews Markdown: [https://news.hada.io/topic/31474.md](https://news.hada.io/topic/31474.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-07-16T09:09:58+09:00
- Updated: 2026-07-16T09:09:58+09:00
- Original source: [longtran2904.substack.com](https://longtran2904.substack.com/p/c-strings-a-50-year-mistake)
- Points: 1
- Comments: 1

## Topic Body

- C의 **NUL 종료 문자열**은 길이 정보를 보존하지 않아 반복 탐색과 경계 오류를 낳으며, 현대에는 포인터와 크기를 함께 저장하는 길이 기반 문자열이 기본 설계로 더 적합함
- 명시적 길이가 없으면 `strlen` 호출과 바이트 순회가 반복되고, `snprintf`, `sizeof`, `strlen`마다 **종료 바이트 포함 여부**가 달라 코드 작성과 검토가 어려워짐
- 길이 기반 문자열은 빈 문자열을 `size == 0`으로 일관되게 처리하며, NUL 바이트를 포함한 **임의 바이너리 데이터**에도 탐색·분할·슬라이싱 연산을 그대로 적용할 수 있음
- 원본 메모리의 일부를 가리키는 **부분 문자열**을 반환할 수 있어 자르기·검색·토큰화와 CSV·Markdown·JSON·C 파싱에서 중간 할당과 복사를 피할 수 있음
- 센티널은 불변식 유지와 일부 토큰 탐색에 여전히 유용하고 기존 C·OS API와의 변환도 필요하지만, 대부분의 코드에서는 길이 기반 문자열에 **API 경계의 불변성**을 결합하는 편이 단순하고 유연함

---

### NUL 종료 대신 포인터와 길이 저장
- C 문자열은 문자 스트림을 가리키는 포인터와 마지막 `NUL` 바이트로 경계를 나타냄
  - 1970년대의 메모리·성능 제약에서는 합리적이었을 수 있지만, 현대에는 이 방식을 유지할 이유가 거의 없음
- 최신 언어와 주요 프레임워크가 채택한 대안은 **데이터 포인터와 크기**를 함께 저장하는 구조체임

```c
struct String
{
    u8* data;
    u64 size;
};
```

- 문자열 리터럴은 `sizeof(s) - 1`로 길이를 계산해 `String`으로 만들 수 있으며, 비교·출력 같은 연산은 저장된 크기를 직접 이용함
- 배열도 포인터로 변환되면 길이 같은 정보를 잃어 문자열과 비슷한 안전성·사용성 문제를 겪음

### 길이 정보를 버릴 때 생기는 비용
- 길이를 저장하지 않으면 사용하는 코드마다 `strlen`을 반복 호출하거나 바이트를 하나씩 순회해야 하므로 **불필요한 작업**과 복잡성이 늘어남
- 런타임에서 길이를 검사하고 강제하기도 더 느리고 번거로우며, 디버거와 분석 도구는 `char*`, `char[N]`, `char*[N]`을 일관되게 취급하기 어려움
- C의 메모리 문제와 오버플로 버그 상당수가 이 설계에서 비롯됐다는 견해가 있지만, 센티널만으로 충분하다는 반론도 있음
- 길이를 모르는 문자열은 배열의 무작위 접근보다 연결 리스트와 같은 **순차 접근 패턴**에 가까워짐
- NUL 종료 방식을 옹호하는 쪽에서는 순차 처리가 다중 패스보다 단일 패스 알고리듬을 유도해 효율적이라고 봄
  - 초보자를 비롯한 많은 프로그래머는 문자열 길이를 저렴하게 얻을 수 있다고 가정해 코드를 작성함
  - 일반적인 문자열 루틴도 길이가 제공되는 형태로 더 자연스럽게 작성되며, 반복적인 `strlen` 호출 때문에 실제 코드에서 같은 문자열을 여러 번 순회하게 됨
  - 패스 수가 적으면 항상 빠르다는 전제는 [현대 CPU의 동작 방식](https://nrk.neocities.org/articles/cpu-vs-common-sense)을 오해한 결과임

### `snprintf`, `sizeof`, `strlen`이 뜻하는 서로 다른 길이
- `snprintf`의 두 정수는 같은 “길이”처럼 보이지만 **NUL 포함 규칙**이 서로 반대임

```c
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`을 유지하면 **길이가 하나 짧아지는 버그**가 생김

```c
#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 종료 바이트를 다음 호출이 덮어씀**

```c
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 종료를 강제하면 트리밍·슬라이싱·분할·토큰화·검색 결과의 끝에도 종료 바이트를 배치해야 하므로 새 문자열과 중간 버퍼를 **할당·복사**하게 됨
- 길이 기반 문자열은 원본의 일부를 가리키는 포인터와 길이만 반환할 수 있음

```c
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용 렉서와 파서는 각 토큰을 복사하지 않고 입력 버퍼의 **슬라이스**를 파스 트리의 이름과 값으로 보관할 수 있음
- 파스 트리를 사용하는 후속 코드도 같은 슬라이스를 전달받아 추가 메모리 관리 없이 처리 가능함

### 센티널이 여전히 유용한 경우
- 센티널은 [자료구조의 불변식](https://fgiesen.wordpress.com/2010/09/27/data-structures-and-invariants/)을 유지하고 일부 상황에서 [성능 이점](https://easyperf.net/blog/2016/11/21/Sentinels)을 줄 수 있음
- 현대 하드웨어와 컴파일러에서는 성능상 이점이 특수한 사례를 제외하면 대부분 약해졌지만, 프로그램이 관리해야 할 **불변식 수를 줄이는 효과**는 남아 있음
- [직접 작성한 토큰 검사](https://guide.handmadehero.org/code/day510/#2858)에서는 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 관례**로 운용할 수 있음
- 문자열 유형과 연산을 [명확히 구분](https://www.gingerbill.org/article/2024/04/05/string-type-distinctions/)하면 규칙을 유지하는 부담이 크지 않으며, 코드베이스가 적용 범위와 완화 시점을 직접 정할 수 있음
- 언어도 하나의 API이므로 언어 위원회가 정한 제약에만 의존하지 않고, 필요한 세분성으로 규칙을 설계할 수 있음

### 길이 기반 문자열의 구현 사례
- [RAD Debugger](https://www.youtube.com/watch?v=s7pTd4np5Rw)는 프로젝트 전반에서 아레나가 뒷받침하는 길이 기반 문자열을 기본으로 사용하며, [Metadesk](https://github.com/ryanfleury/metadesk/tree/master)는 독립 파싱 라이브러리에 적용한 사례임
- Allen Webster의 [코드베이스 기반 계층 구축 시리즈](https://www.youtube.com/playlist?list=PLT6InxK-XQvNKTyLXk6H6KKy12UYS_KDL)에는 [문자열 전용 에피소드](https://www.youtube.com/watch?v=2wio9UOFcow&list=PLT6InxK-XQvNKTyLXk6H6KKy12UYS_KDL&index=7)가 있음
- VoxelRifts의 [C 학습 과정 영상](https://youtu.be/3IAlJSIjvH0?t=335)도 길이 기반 문자열을 다룸
- Chris Wellons는 C에서 [이 방식을 지속적으로 사용](https://nullprogram.com/blog/2023/10/08/#strings)했으며 [C++에서도 실험](https://nullprogram.com/blog/2024/04/14/)함
- NRK의 글에는 길이 정보를 버리지 말라는 [부록](https://nrk.neocities.org/articles/cpu-vs-common-sense#addendum-don-t-throw-the-length-away)이 포함돼 있음
- 실험용 [Base 저장소](https://github.com/longtran2904/Base/tree/main)에서도 관련 구현을 확인할 수 있음

### 다른 문자열 표현이 가진 제약
- ## 유연한 배열 멤버
  - Redis의 SDS처럼 구조체 앞부분에 **현재 길이와 최대 용량**을 저장하고, 마지막 필드를 유연한 배열 멤버로 선언해 문자 데이터를 배치함
  - `strlen`의 O(n) 스캔과 수동 연결·경계 관리 같은 C 문자열의 문제를 피할 수 있음
  - 문자열 리터럴과 직접 호환하기 어렵고, 길이 기반 문자열의 핵심 장점인 효율적인 부분 문자열을 지원하지 못함
- ## 스트레치 버퍼
  - [스트레치 버퍼](https://ruby0x1.github.io/machinery_blog_archive/post/minimalist-container-library-in-c-part-1/)는 동적 배열과 비슷하며, 길이와 용량 헤더를 명시적 구조체가 아닌 포인터 앞의 별도 영역에 저장함
  - 유연한 배열 멤버 방식의 단점에 더해 모든 `char*`에 거의 보이지 않는 메타데이터 규칙을 부여하며, 의미 있는 문자열 연산마다 이 헤더를 API 모델의 일부로 사용해야 함
  - 포인터 표현은 타입 안전성을 제공하고 `.str` 또는 `->str` 접근을 피할 수 있지만, 이 이점만으로 숨은 규칙을 정당화하기는 어려움
  - 별도 **capacity 필드**는 동적 배열에는 자연스럽지만, 문자열에서는 서로 다른 개념을 한 타입에 섞어 유형 간 구분을 흐림
  - 유연한 배열 멤버에도 같은 문제가 있을 수 있으나, 초기 고정 크기를 용량으로 사용하고 동적 확장을 피할 수 있다는 차이가 있음
- ## Pascal 스타일
  - Pascal식 짧은 문자열은 보통 256바이트의 **고정 크기 문자 배열**을 사용하며, 길이용 1바이트를 예약하거나 NUL 종료를 이용함
  - 과거의 실제 메모리·하드웨어 제약에서는 유효했지만, 현대에는 좋은 [아레나 메모리 할당 전략](https://www.dgtlgrove.com/p/untangling-lifetimes-the-arena-allocator)을 사용할 수 있어 특수한 상황 외에는 적합하지 않음

### 길이 기반 문자열을 기본값으로 삼아야 하는 이유
- 길이 기반 문자열의 장점은 일부 커뮤니티와 숙련된 프로그래머 사이에서 알려져 있지만, 많은 개발자는 여전히 이런 구현을 고려한 적이 없음
- 관련 자료가 여러 곳에 흩어져 있어 길이 손실, 종료 바이트 모호성, 바이너리 처리, 부분 문자열, 센티널의 장단점과 대체 구현을 함께 비교하기 어려웠음
- 기본 문자열 타입을 포인터와 길이의 슬라이스 형태로 만들면 **경계 정보 보존**, 상태 단순화, 바이너리 호환, 할당 없는 부분 문자열을 하나의 설계로 해결할 수 있음

## Comments



### Comment 61884

- Author: neo
- Created: 2026-07-16T09:09:59+09:00
- Points: 1

###### [Lobste.rs 의견들](https://lobste.rs/s/upgpyq/c_strings_50_year_mistake) 
- **패딩 바이트**를 피하려면 구조체 멤버 순서를 다음과 같이 바꾸는 편이 낫다고 봄
  
  ```c  
  struct String  
  {  
      u8* data;  
      u64 size;  
  };  
  ```
  
  대신:
  
  ```c  
  struct String  
  {  
      u64 size;  
      u8* data;  
  };  
  ```
  - **64비트 포인터** 환경에서는 두 필드의 크기가 같아 어느 순서든 패딩이 없고, 32비트 환경에서는 포인터로 표현할 수 있는 범위보다 큰 `u64` 자체가 낭비임  
    CHERI처럼 128비트 포인터를 쓰면 첫 번째 배치는 `data`와 `size` 사이에, 두 번째 배치는 구조체 끝에 정렬용 패딩이 생기므로 결국 공간이 조금 낭비됨
  - 올바른 정의는 **가변 길이 배열 멤버**를 쓰는 형태임
    
    ```c  
    typedef struct {  
      u64 len;  
      u8 buf[];  
    } String;  
    ```

- C 표준에는 **`u8`과 `u64` 타입**이 정의돼 있지 않으며, 이는 Rust식 표기임  
  32비트 환경에서 `u64`로 길이를 나타내는 것도 어울리지 않고, C에서 배열 크기를 표현하는 관용적인 타입은 `size_t`이므로 다음과 같이 쓰는 편이 자연스러움
  
  ```c  
  struct String  
  {  
      char* data;  
      size_t size;  
  };  
  ```
  
  글을 쓴 사람이나 이를 생성한 LLM이 C를 제대로 모르는 듯함
  - 많은 C 프로젝트가 `u8`과 `u64`를 `typedef`로 정의해 쓰며, 이는 매우 흔한 관행임  
    표준의 `uintN_t` 명칭을 번거롭게 여기는 경우도 많고, 규모가 큰 프로젝트 중에는 해당 표준보다 오래된 것도 있으며 **Linux 커널**도 이런 별칭을 사용함  
    오히려 이 비판이 C 프로젝트에 익숙하지 않다는 점을 드러냄

- C나 C++를 몇 달 이상 작성했다면 **포인터와 길이를 함께 전달**하는 방식은 이미 잘 알려진 사실 아닌가 싶음  
  현대적인 C 코드베이스는 거의 모두 결국 데이터 포인터와 길이를 함께 넘기거나 자체 문자열 라이브러리를 갖추게 됨
  - 길이 저장 여부는 C 문자열이 가진 문제 중 하나일 뿐이며, **수명 관리**에서도 골칫거리가 많음  
    현대 C++의 `std::string_view`는 “이 문자열을 볼 수는 있지만 변경하거나 보관해서는 안 된다”는 의미를 명시해 버그를 크게 줄여줌  
    C 문자열을 받는 API에서는 이런 제약이 문서상의 약속에 불과함

- 진짜 실수는 길이 저장이나 널 종료 여부가 아니라, 임의의 맥락에서 의미를 갖기엔 너무 광범위한 **`String`이라는 개념** 자체라고 봄  
  문자열은 단순한 문자 배열을 넘어 온갖 용도로 쓰이며, 여기서 “문자”라는 말조차 진공 속 구형 소를 가정하는 물리학 농담처럼 지나치게 단순화돼 있음  
  “길이”가 자소 클러스터 수인지, 코드 포인트 수인지, 전체 바이트 수인지부터 불분명함  
  인코딩은 무엇인지, 사용자에게 노출되어 현지화가 필요한지, 정제됐는지, 맵 키처럼 수정할 이유가 없는 **불가분 값**인지도 구분해야 함  
  이런 관점에서 보면 통상적인 구현의 문자열 타입은 더 이상 원하지 않게 됨
  - Rust에 문자열 타입이 너무 많다고 불평하기도 하지만, 그중 일부는 이런 정보를 **타입 시스템**에 담기 위한 것임  
    어떤 라이브러리도 자소 클러스터 수를 단순히 “길이”라고 부르지는 않는 것으로 앎  
    기본 인코딩을 UTF-8로 정하고 다른 인코딩이나 바이트 배열에는 별도 타입을 쓰는 관례로 일부 문제를 해결할 수 있으며, 대부분은 타입 시스템으로 표현할 수 있음

- 내가 알기로 C에는 대부분의 현대 언어가 제공하는 **언어 차원의 문자열 개념**이 없음  
  문자열 조작은 언어 자체가 아니라 표준 라이브러리에서 제공함  
  K&R 2판은 C가 문자·숫자·주소처럼 컴퓨터가 직접 다루는 대상에 가까운 비교적 저수준 언어이며, 문자열·집합·목록·배열 같은 복합 객체 전체를 직접 조작하는 연산을 제공하지 않는다고 설명함  
  이런 기능의 부재가 큰 결함처럼 보일 수 있지만, 언어를 작게 유지한 덕분에 짧게 기술하고 빠르게 배울 수 있으며 프로그래머가 언어 전체를 이해하고 일상적으로 사용할 수 있다는 장점이 있음  
  C가 왜 이렇게 설계됐고 당연하게 여겨지는 현대 기능들이 왜 없는지 이해하려면 **K&R 2판의 서문과 도입부**를 읽어보길 강력히 권함
  - 다만 문자열 리터럴의 타입과 메모리 배치가 라이브러리 형태를 결정한다는 제한적인 의미에서는 문자열도 **C 언어의 일부**임  
    이는 Rust 같은 저수준 언어의 언어와 라이브러리 간 구분과도 크게 다르지 않음

- 직렬화 방식으로 https://cr.yp.to/proto/netstrings.txt 같은 제안이 있고, https://web.archive.org/web/20230305073119/http://people.csail.mit.edu/rivest/sexp.html 처럼 **복잡한 구조체**까지 다루는 방식도 있음  
  하지만 안전하게 파싱하기에는 너무 단순한 모양인지, XML·JSON·YAML·Protocol Buffers 등의 열성 지지자들이 계속 바쁘게 일할 수 있게 됨
