C 스타일 문자열
널 문자로 끝나는 char 배열의 저장 구조와 입출력·문자열 함수 사용법을 익히고 버퍼 한계를 이해합니다.
C++에서 글자를 다룰 때는 보통 다음 글의 std::string을 쓰지만, 운영체제 API나 C 라이브러리, 오래된 코드에서는 여전히 char 배열과 const char*를 만납니다. 이런 곳에서 쓰는 C 스타일 문자열은 char 배열의 끝에 널 문자(null character)를 두어 문자열이 어디서 끝나는지 표시합니다.
이 글은 C 스타일 문자열의 저장 방식, 선언과 초기화, 입력을 고정 크기 배열에 받을 때의 주의점, <cstring>의 문자열 함수를 봅니다. 가장 중요한 것은 배열이 담을 수 있는 칸 수인 용량과, 첫 널 문자 앞까지의 글자 수인 길이를 구별하는 것입니다.
용량과 길이
C 스타일 문자열은 char가 이어진 배열에서 첫 번째 널 문자 '\0'이 끝을 표시합니다. 널 문자는 값이 0인 문자이며, 길이에는 들어가지 않지만 저장 공간은 한 칸 차지합니다.
C++ · NULL-TERMINATED BYTE STRING
C 문자열의 용량·길이·끝 널을 따로 확인한다
C 스타일 문자열은 임의의 char 배열이 아니라, 접근 가능한 범위 안에 첫 \0이 있는 바이트열입니다. 배열 용량과 문자열 길이는 서로 다른 값입니다.
| 인덱스 | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 저장 값 | 'O' |
's' |
'l' |
'o' |
'\0' |
| 역할 | 문자열 내용 · 길이 4 | 첫 널 문자 · 끝 표시 | |||
용량 5 · 길이 4
인덱스 0 1 2 3 4
값 O s l o \0
마지막 한 칸이 문자열 탐색을 멈추게 합니다.
배열 extent와 문자열 length를 구분한다
char city[5] = "Oslo";
sizeof city // 5
std::strlen(city) // 4
sizeof는 배열 표현식이 배열 타입을 유지하는 문맥에서 전체 저장 칸을 셉니다. strlen은 첫 널 문자 직전까지만 셉니다.
맞는 bound 안에서 끝 표시와 나머지 0을 만든다
char name[] = "Ada";
// {'A', 'd', 'a', '\0'}
char padded[8] = "Hi";
// H · i · \0 · 0 · 0 · 0 · 0 · 0
문자열 리터럴의 끝 널 문자까지 배열에 들어가야 합니다. 필요한 원소 수보다 작은 bound로 초기화하면 프로그램은 ill-formed입니다.
유효한 배열이어도 C 문자열은 아닐 수 있다
char raw[4]{'S', 'e', 'o', 'u'};
// raw is not a null-terminated
// byte string
이 값을 strlen처럼 널 종료를 전제로 하는 함수에 넘기면 함수가 배열 밖을 읽으며 undefined behavior가 발생할 수 있습니다.
변환 뒤에는 bound가 포인터 값에 남지 않는다
void inspect(const char text[]);
// parameter type adjusts to:
void inspect(const char* text);
배열은 상수 포인터가 아닙니다. 많은 표현식에서 첫 원소 포인터로 변환될 뿐이며, 함수 매개변수의 대괄호 표기는 배열 bound를 전달하지 않습니다.
검사 순서는 접근 가능한 capacity → 첫 \0의 위치 → 그 전까지의 length입니다. 셋 중 하나라도 증명할 수 없으면 널 종료 문자열 함수의 전제도 증명되지 않습니다.
#include <cstring>
#include <iostream>
int main() {
char city[5] = "Oslo";
std::cout << sizeof city << ' ' << std::strlen(city) << '\n';
}5 4그림처럼 city의 다섯 칸에는 O, s, l, o와 널 문자가 들어갑니다. sizeof city는 배열 전체의 바이트 수이고 char는 1바이트이므로 용량 5가 되며, std::strlen(city)는 첫 널 문자 앞까지 세어 길이 4를 돌려줍니다. 문자 네 개를 담으려면 다섯 칸이 필요하다는 점을 기억해 둡니다.
1차원 배열에서 본 것처럼 배열은 대부분의 식에서 첫 원소를 가리키는 포인터로 바뀌고, 함수 매개변수의 const char text[20]도 실제로는 const char*입니다. 포인터에는 배열의 용량이 들어 있지 않으므로, 함수가 버퍼의 용량을 알아야 한다면 길이를 따로 받아야 합니다.
선언과 초기화
문자열 리터럴로 char 배열을 초기화하면 리터럴의 글자와 끝의 널 문자가 차례로 들어갑니다. 크기를 생략하면 널 문자까지 포함한 크기가 정해집니다.
char greeting[6] = "Hello"; // H e l l o \0
char name[] = "World"; // 크기 6
char padded[8] = "Hi"; // H i \0 \0 \0 \0 \0 \0
// char too_short[4] = "LongString"; // 컴파일 오류배열이 리터럴과 널 문자를 다 담지 못하면 컴파일 오류입니다. 크기가 남는 padded는 나머지 칸이 모두 0으로 채워지므로, 이 0들도 널 문자입니다.
글자를 하나씩 나열해 초기화할 때는 널 문자를 직접 넣어야 C 스타일 문자열이 됩니다.
char valid[5]{'S', 'e', 'o', 'u', '\0'}; // C 스타일 문자열
char raw[4]{'S', 'e', 'o', 'u'}; // 배열이지만 C 스타일 문자열은 아님
char buffer[100]; // 지역 배열이면 값이 정해지지 않음raw는 올바른 char 배열이지만 안에 널 문자가 없습니다. 이것이나 값을 넣지 않은 buffer를 std::strlen이나 std::cout <<처럼 널 문자를 찾는 연산에 넘기면, 배열을 벗어나 널 문자를 찾게 되어 정의되지 않은 동작이 됩니다. 또 배열에는 대입 연산이 없어서 buffer = "text";처럼 문자열을 대입할 수 없으며, 내용을 바꾸려면 뒤에서 볼 복사 함수를 써야 합니다.
고정 크기 배열에 입력받기
입력을 받을 때는 공백 앞까지의 단어 하나를 원하는지, 줄바꿈 앞까지의 한 줄을 원하는지 먼저 정합니다. 길이를 알 수 없는 입력은 std::string 클래스에서 볼 std::string으로 받는 것이 가장 간단하며, 고정 배열에 받아야 할 때는 넘칠 때의 처리까지 함께 생각해야 합니다.
#include <iostream>
#include <iterator>
int main() {
char word[20]{};
std::cin >> word; // C++20부터 최대 19글자
char line[50]{};
std::cin.getline(line, std::size(line)); // 최대 49글자
}std::cin >> word는 앞의 공백을 건너뛰고 다음 공백 앞까지 읽습니다. C++20부터는 배열을 받는 버전이 배열 크기를 알고 있어 최대 19글자와 널 문자만 저장하지만, C++17까지는 포인터를 받는 버전이어서 크기 제한이 없었습니다. std::cin.getline(line, 50)은 줄바꿈 앞까지 최대 49글자를 저장하고 널 문자를 붙이며, 줄바꿈은 입력에서 꺼내 버립니다. 다만 이 프로그램처럼 >> 바로 뒤에 getline을 부르면, >>가 단어 뒤의 줄바꿈을 입력에 남겨 두므로 getline은 다음 줄을 기다리지 않고 그 줄의 나머지(단어만 입력했다면 빈 문자열)를 읽고 끝납니다. 이 문제를 피하는 방법은 std::string 클래스의 한 줄 입력에서 다룹니다.
한 줄이 49글자보다 길면 getline은 49글자만 저장하고 실패 상태(failbit)를 설정합니다. 실패 상태에서는 다음 읽기가 모두 실패하므로, 원인이 길이 초과인지 확인한 뒤 상태를 지우고 그 줄의 나머지를 버립니다.
#include <iostream>
#include <iterator>
#include <limits>
int main() {
char name[6]{};
std::cin.getline(name, std::size(name));
if (std::cin.fail() && !std::cin.bad() && !std::cin.eof()) {
std::cout << "too long\n";
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
std::cout << name << '\n';
}name은 여섯 칸이지만 마지막 한 칸은 널 문자의 자리이므로, getline은 다섯 글자까지만 저장할 수 있습니다. if의 조건은 실패 상태이면서 입력 장치의 오류(badbit)나 입력의 끝(eofbit)은 아닌 경우, 즉 길이를 넘긴 경우만 골라냅니다. 입력이 Alexander이면 다섯 글자 Alexa만 저장하고 실패 상태가 되므로, 출력은 다음과 같습니다.
too long
Alexaclear()로 상태를 먼저 지워야 하는 것은 실패 상태에서는 ignore()도 아무것도 꺼내지 않기 때문입니다. 인자 없는 std::cin.ignore()는 한 글자만 버리므로, 줄의 나머지를 모두 버리려면 위처럼 최대 개수와 줄바꿈을 넘깁니다. 입력이 Ann처럼 짧거나 정확히 다섯 글자인 Alexa이면 실패하지 않고 그대로 출력합니다.
<cstring>의 문자열 함수
<cstring> 헤더에는 C 스타일 문자열을 다루는 함수가 있습니다. 이 함수들은 배열의 용량을 알지 못하므로, 넘기는 문자열이 널 문자로 끝나는지와 목적지에 결과가 들어갈 공간이 있는지는 호출하는 쪽에서 확인해야 합니다.
| 함수 | 하는 일 | 호출 전에 확인할 것 |
|---|---|---|
std::strlen(s) | 첫 널 문자 앞까지의 길이 | s가 널 문자로 끝남 |
std::strcpy(dest, src) | src를 널 문자까지 복사 | dest에 strlen(src) + 1칸 이상, 두 배열이 겹치지 않음 |
std::strcat(dest, src) | dest 끝에 src를 이어 붙임 | 두 문자열이 널 문자로 끝남, 이어 붙인 결과와 널 문자가 dest에 들어감, 겹치지 않음 |
std::strcmp(a, b) | 두 문자열 비교 | 두 문자열이 널 문자로 끝남 |
std::strcmp는 두 문자열이 같으면 0, 첫 번째가 앞서면 음수, 뒤서면 양수를 돌려줍니다. 비교는 글자를 unsigned char 값으로 바꾸어 앞에서부터 하므로 사전의 순서와는 다를 수 있고, 음수나 양수의 구체적인 값에는 의미가 없습니다. if (std::strcmp(a, b) == 0)처럼 0인지와 부호만 봅니다.
다음 프로그램은 이어 붙이기 전에 필요한 칸 수를 계산해 용량과 비교합니다.
#include <cstring>
#include <iostream>
#include <iterator>
int main() {
char destination[6]{"Hi"};
const char source[]{"!"};
const auto required = std::strlen(destination) + std::strlen(source) + 1;
if (required <= std::size(destination)) {
std::strcat(destination, source);
} else {
std::cout << "skip\n";
}
std::cout << destination << ' ' << std::strlen(destination)
<< ' ' << sizeof destination << '\n';
}Hi! 3 6필요한 칸은 기존 길이 2, 붙일 길이 1, 널 문자 1을 더한 4이므로 용량 6에 들어갑니다. strcat은 기존 널 문자의 자리에 !를 쓰고 그다음 칸에 새 널 문자를 둡니다. 길이는 3이 되었지만 용량은 여전히 6입니다.
이름에 n이 붙은 std::strncpy와 std::strncat도 넘침을 막아 주는 함수가 아닙니다. strncpy(dest, src, n)은 src의 앞 n글자 안에 널 문자가 없으면 널 문자 없이 n글자만 복사하므로, 결과가 C 스타일 문자열이 아닐 수 있습니다. strncat의 n은 붙일 글자의 최대 개수이고 그 뒤에 널 문자를 하나 더 쓰므로, 목적지의 남은 용량은 따로 계산해야 합니다.
C++ · TEXT BOUNDARY DECISIONS
입력 단위와 버퍼 계약에 맞는 문자열 API를 고른다
새 C++ 텍스트는 std::string으로 소유하고, 고정 char 배열과 C 함수는 경계에서만 사용합니다. 어떤 API를 고르든 저장 용량과 널 종료 전제를 따로 증명해야 합니다.
| 목적 | 권장 형태 | 멈춤·안전 계약 |
|---|---|---|
| 공백 전 token | |
선행 공백을 건너뛰고 다음 공백에서 멈춥니다. C++20 이후 고정 배열 overload는 bound에서 끝 널 한 칸을 뺀 제한을 사용하지만, 이전 포인터 overload에는 같은 보호가 없습니다. |
| 공백 포함 한 줄 | |
구분자 개행까지 읽습니다. 앞선 formatted input의 나머지를 버릴 의도라면 먼저 최대 길이까지 ignore합니다. |
| 고정 배열 한 줄 | |
최대 배열 크기보다 한 글자 적게 저장하고 끝 널을 둡니다. 구분자 전에 한도에 닿으면 원인을 확인하고 failbit를 지운 뒤 남은 입력을 처리합니다. |
| 복사·이어 붙이기 | std::string의 대입과 append |
strcpy·strcat은 목적지의 남은 용량을 검사하지 않습니다. 호출 전 결과 전체와 끝 널 공간을 증명해야 합니다. |
| C API에 읽기 전달 | |
포인터를 수정하지 않습니다. 소유 문자열의 수명과 무효화 가능 연산을 가로질러 보관하지 않고 호출 경계에서 다시 얻습니다. |
token · 다음 공백까지
std::string token;
std::cin >> token;
고정 배열보다 동적 길이 문자열을 기본으로 둡니다. char[N]의 bound-aware overload는 C++20 이후 계약입니다.
line · 다음 개행까지
std::string line;
std::getline(std::cin, line);
formatted input의 남은 줄을 버릴 때만 최대 길이 ignore를 먼저 사용합니다.
fixed buffer · bound 전달
char line[50]{};
std::cin.getline(line,
std::size(line));
한도 도달 원인을 확인하고 failbit를 지운 뒤 남은 입력을 복구합니다.
복사·이어 붙이기
std::string 대입과 append()를 기본으로 사용합니다. strcpy·strcat은 최종 내용과 끝 널 공간을 호출 전에 증명해야 합니다.
C API · 짧은 포인터 수명
legacy(text.c_str());
읽기 전용으로 쓰고, 소유 문자열의 수명이나 무효화 가능 연산을 가로질러 보관하지 않습니다.
strcpy와 strcat
source가 널 종료됐고 destination에 최종 내용과 \0이 모두 들어가며 두 객체가 겹치지 않을 때만 호출합니다.
strncpy와 strncat
strncpy는 첫 n개 안에 널이 없으면 널 종료하지 않고, 짧으면 padding합니다. strncat은 최대 n문자 뒤에 끝 널 한 칸을 더 씁니다.
strcmp
두 입력은 모두 유효한 널 종료 문자열이어야 합니다. unsigned character 값에 따른 lexicographical 비교이며, locale 사전 정렬과 다릅니다. 음수·0·양수만 사용합니다.
한 문자 ignore()를 “버퍼 비우기”로 부르지 않는다
std::cin.ignore(
std::numeric_limits<
std::streamsize
>::max(),
'\n'
);
스트림이 정상일 때 현재 줄의 나머지를 정말 버리려는 경우에 사용합니다. failbit가 있다면 실패 원인을 확인하고 먼저 지워야 하며, 같은 입력을 이어 읽어야 한다면 남은 문자를 보존합니다.
선택 순서: 원하는 입력 단위 → 소유 타입 → capacity와 terminator → 실패 상태와 남은 입력 → C 경계의 포인터 수명.
위 그림처럼 새 C++ 코드에서는 글자를 std::string으로 소유하고, 복사는 대입, 이어 붙이기는 +=나 append, 비교는 ==로 합니다. 고정 크기 char 배열과 <cstring> 함수는 외부 API가 그 형식을 요구하는 곳에서만 쓰고, 그때마다 용량과 널 문자를 확인합니다.
더 깊이: 목적지의 크기를 받는 복사 함수들
C++ 표준의 <cstring>에는 목적지의 크기를 받아 넘침을 막는 복사 함수가 없지만, 플랫폼에는 그런 함수가 있습니다. POSIX.1-2024에 들어간 strlcpy(dst, src, dstsize)는 많아야 dstsize - 1바이트를 복사하고, dstsize가 0이 아니면 언제나 끝에 널 문자를 붙입니다. 돌려주는 값은 복사하려던 원본의 길이여서, 그 값이 dstsize 이상이면 결과가 잘렸다는 것을 호출한 쪽이 알 수 있습니다. 이어 붙이는 strlcat도 같은 방식으로 동작합니다.
Microsoft의 C 런타임에는 strcpy_s(dest, dest_size, src)가 있습니다. Microsoft 문서에 따르면 이 함수는 목적지가 너무 작으면 결과를 잘라 담지 않고 dest[0]을 0으로 만든 뒤 잘못된 매개변수 처리기를 부르며, 실행이 계속되면 ERANGE를 돌려줍니다. C++에서는 배열을 받는 템플릿 버전이 배열의 크기를 스스로 알아내므로 strcpy_s(buffer, src)처럼 크기를 생략할 수도 있습니다. 두 함수는 넘칠 때의 동작이 서로 다르고 어느 쪽도 C++ 표준의 일부가 아니므로, 여러 플랫폼에서 쓸 코드라면 본문처럼 필요한 칸 수를 직접 계산하거나 std::string을 씁니다.
C API에 std::string 넘기기
읽기 전용 const char*를 받는 C API에는 std::string의 c_str()을 넘깁니다. c_str()은 널 문자로 끝나는 내부 문자열을 가리키는 포인터를 돌려줍니다.
#include <string>
void legacy_read_only(const char* text);
void send_status() {
std::string text = "status";
legacy_read_only(text.c_str());
}이 포인터로 문자열을 바꾸면 안 됩니다. 또 포인터는 text가 살아 있고 text를 바꾸는 연산이 없는 동안에만 쓸 수 있습니다. 글자를 덧붙이거나 다른 문자열을 대입하면 내부 저장 공간이 옮겨 가 이전 포인터가 쓸모없어질 수 있으므로, 포인터를 저장해 두지 말고 필요할 때마다 c_str()을 다시 부릅니다.
정리
- C 스타일 문자열은
char배열에서 첫 널 문자'\0'이 끝을 표시하며, 용량은 배열의 칸 수이고 길이는 첫 널 문자 앞까지의 글자 수입니다. - 문자열 리터럴로 초기화하면 널 문자가 자동으로 들어가지만, 글자를 나열할 때는 직접 넣어야 합니다. 널 문자가 없는 배열을 문자열 함수에 넘기면 정의되지 않은 동작입니다.
std::cin.getline(buf, n)은 최대n - 1글자를 저장하며, 줄이 더 길면 실패 상태가 되므로clear()뒤ignore()로 나머지를 버립니다.strcpy와strcat은 목적지의 용량을 검사하지 않으므로, 결과와 널 문자가 들어갈 칸 수를 미리 계산합니다.strncpy는 결과에 널 문자를 붙이지 않을 수 있으므로 넘침을 막는 함수로 여기지 않습니다.- 새 코드에서는
std::string을 쓰고, C API에는c_str()을 넘기되 그 포인터를 오래 보관하지 않습니다.
연습 문제
1. 들어가지 않는 문자열
safe_strcat.cpp에서 source의 리터럴만 "abcd"로 바꾸면 필요한 칸 수와 출력은 무엇인가요?
해설 보기
필요한 칸은 2 + 4 + 1 = 7이어서 용량 6을 넘습니다. 그래서 이어 붙이지 않고 skip을 출력한 뒤, 바뀌지 않은 destination에 대해 Hi 2 6을 출력합니다.
2. 한 줄 입력의 한도
getline_overflow.cpp에 Alexa를 입력하면 무엇이 출력되나요? 배열을 char name[5]로 줄이고 같은 입력을 넣으면 어떻게 되나요?
해설 보기
name[6]은 최대 다섯 글자를 저장할 수 있고, 다섯 글자를 읽은 다음 글자가 줄바꿈이므로 실패하지 않습니다. 출력은 Alexa 한 줄입니다. name[5]이면 네 글자 Alex를 저장한 뒤 다음 글자가 줄바꿈이 아니어서 실패 상태가 되므로, too long과 Alex를 출력합니다.
3. 널 문자가 없는 복사
char dest[3]; std::strncpy(dest, "Hello", 3); 다음에 std::cout << dest;를 실행해도 되나요?
해설 보기
안 됩니다. "Hello"의 앞 세 글자 안에 널 문자가 없으므로 strncpy는 H, e, l 세 글자만 복사하고 널 문자를 붙이지 않습니다. dest는 C 스타일 문자열이 아니게 되어, 출력하면 배열 밖에서 널 문자를 찾는 정의되지 않은 동작이 됩니다. dest를 4칸 이상으로 만들고 마지막 칸에 널 문자를 직접 넣거나, std::string을 써야 합니다.
다음 글 std::string 클래스에서는 저장 공간과 길이를 스스로 관리해, 이 글에서 직접 계산한 용량과 널 문자를 신경 쓰지 않아도 되는 std::string을 다룹니다.