[C언어 50강] 22강. 문자열 기초: char 배열, 널 종료 문자(\0)
C언어에서 문자열은 독립적인 전용 타입이 아닙니다. 이 한 문장을 제대로 이해하면 앞으로 fgets, strcpy, strcmp, 포인터, 버퍼 오버런 같은 주제가 훨씬 덜 어렵게 느껴집니다. 오늘은 “문자열은 결국 char의 연속이며, 끝을 알려주는 \0이 반드시 필요하다”는 핵심을 개념 중심으로 정리합니다.
핵심 개념
- C 문자열은 **
char배열 + 널 종료 문자(\0)**로 표현된다. - 컴파일러는 문자열 리터럴(
"hello") 뒤에 자동으로\0을 붙인다. - 문자열 처리 함수는 길이를 따로 들고 있지 않기 때문에,
\0을 만날 때까지 읽는다. - 그래서 문자열 문제의 상당수는 사실상 경계(배열 크기)와 종료(
\0) 관리 문제다.
개념 먼저 이해하기
처음 C를 배울 때 많은 사람이 “문자열은 글자 여러 개니까 그냥 문자열 타입이 있겠지”라고 생각합니다. 하지만 C에서는 문자열 전용 타입이 없고, 문자(char)를 한 칸씩 담은 배열을 문자열로 취급합니다. 예를 들어 char name[6] = "Toby";는 메모리 관점에서 {'T','o','b','y','\0', ?}에 가까운 모습입니다(남는 칸은 초기화 방식에 따라 다름). 핵심은 눈에 보이는 글자 4개가 끝이 아니라, 그 뒤의 \0까지 포함되어야 비로소 “완성된 문자열”이라는 점입니다.
왜 이런 설계를 했을까요? C는 하드웨어와 가까운 언어라서, 복잡한 런타임 구조 대신 단순한 메모리 모델을 선호합니다. 문자열 길이를 별도 필드로 저장하는 고수준 객체 대신, 바이트 배열과 종료 마커(\0)만으로 문자열을 다룹니다. 장점은 단순함과 이식성, 그리고 저수준 제어입니다. 단점은 프로그래머가 직접 경계를 지켜야 한다는 부담입니다.
여기서 가장 중요한 오해 하나를 짚어보겠습니다. char s[5] = "abcde";는 “5칸이니까 글자 5개 가능”처럼 보이지만, C 문자열로는 틀린 설계입니다. 문자열 "abcde"는 실제로 a b c d e \0까지 6바이트가 필요합니다. \0이 들어갈 자리가 없으면 문자열 함수가 어디서 멈춰야 하는지 알 수 없어서, printf("%s", s); 같은 호출이 배열 경계를 넘어가 쓰레기 값을 읽거나 운 나쁘면 크래시로 이어질 수 있습니다.
또 하나 중요한 구분은 문자열 리터럴과 문자 배열입니다. char *p = "hello";에서 p는 보통 읽기 전용 영역의 문자열 리터럴을 가리킵니다(플랫폼/컴파일러 구현에 따라 세부는 다르지만, 수정은 정의되지 않은 동작). 반면 char arr[] = "hello";는 수정 가능한 배열 복사본이 생성됩니다. 둘 다 출력은 잘 되지만, 수정 가능성·수명·저장 위치 맥락이 다릅니다. 실무에서 버그가 나는 지점도 바로 이 차이를 놓쳤을 때입니다.
결국 문자열을 안정적으로 다루는 감각은 다음 질문을 습관화하면 생깁니다. (1) 이 버퍼의 총 크기는 몇 바이트인가? (2) 현재 실제 문자열 길이는 얼마인가? (3) \0이 항상 보장되는가? (4) 입력이 버퍼 크기를 넘으면 어떤 일이 벌어지는가? 이 네 가지를 계속 점검하면 문자열 관련 버그의 70~80%는 예방할 수 있습니다.
기본 사용
예제 1) 문자열의 실제 메모리 모습 확인
#include <stdio.h>
int main(void) {
char word[] = "cat"; // {'c','a','t','\0'}
printf("문자열 출력: %s\n", word);
printf("각 바이트(정수) 확인:\n");
for (int i = 0; i < 4; i++) {
printf("word[%d] = %d\n", i, (unsigned char)word[i]);
}
return 0;
}
설명:
"cat"은 글자 3개지만 저장 시\0이 추가되어 총 4바이트를 사용합니다.%s는 내부적으로\0을 만날 때까지 문자를 읽습니다.- 바이트 값을 직접 찍어보면 마지막 칸이 0(널 문자)임을 확인할 수 있습니다.
예제 2) 버퍼 크기와 안전한 입력 길이 설계
#include <stdio.h>
int main(void) {
char name[8]; // 최대 7글자 + '\0'
printf("이름 입력(최대 7글자): ");
if (fgets(name, sizeof(name), stdin) == NULL) {
return 1;
}
// 개행 제거
for (int i = 0; name[i] != '\0'; i++) {
if (name[i] == '\n') {
name[i] = '\0';
break;
}
}
printf("입력된 이름: %s\n", name);
return 0;
}
설명:
sizeof(name)를 함께 전달하면 함수가 버퍼 경계를 인지하고 동작합니다.fgets는 읽은 문자열 끝에\0을 넣어 주므로 상대적으로 안전합니다.- 다만 입력이 짧을 경우 개행(
\n)이 남을 수 있어 후처리가 필요합니다.
예제 3) 문자열 리터럴 포인터 vs 수정 가능한 배열
#include <stdio.h>
int main(void) {
char *p = "hello"; // 문자열 리터럴을 가리킴(수정 금지)
char a[] = "hello"; // 배열 복사본(수정 가능)
// p[0] = 'H'; // 정의되지 않은 동작(매우 위험) - 보통 금지
a[0] = 'H';
printf("p: %s\n", p);
printf("a: %s\n", a);
return 0;
}
설명:
- 초보자에게 가장 헷갈리는 부분입니다. 문법은 비슷하지만 의미가 다릅니다.
- 팀 코드에서는 리터럴을 가리키는 포인터를 수정하려는 시도를 컴파일 경고 단계에서 차단하도록 설정하는 편이 좋습니다.
자주 하는 실수
실수 1) 배열 크기를 “글자 수”만큼만 잡는 경우
- 원인:
\0이 한 칸 필요하다는 사실을 빼먹음. - 해결: 문자열 n글자를 담을 버퍼는 최소
n + 1바이트로 선언. 입력 함수에는 항상 버퍼 크기를 함께 전달.
실수 2) scanf("%s", buf)를 무제한 입력으로 사용하는 경우
- 원인:
%s가 공백 전까지 계속 읽는다는 점, 그리고 길이 제한이 없다는 점을 간과. - 해결: 가능하면
fgets사용. 꼭scanf를 쓸 때는 폭 제한(%7s)을 명시하고, 그 숫자는 버퍼 크기-1과 일치시킴.
실수 3) 문자열 리터럴을 수정하려는 경우
- 원인:
char *p = "abc";를 “수정 가능한 문자열 변수”로 오해. - 해결: 수정이 필요하면 반드시 배열(
char p[] = "abc";)로 선언.
실수 4) \0 보장을 확인하지 않고 문자열 함수를 호출하는 경우
- 원인: 외부 입력/수동 복사 이후 종료 문자가 살아있다고 가정.
- 해결: 경계 기반 복사 후 마지막 바이트를 명시적으로
\0처리하는 습관을 들임.
실무 패턴
- 크기 중심 사고: 문자열 변수 옆에 항상 “총 버퍼 크기” 정보를 같이 관리합니다. 함수 인자도
(char *buf, size_t buf_size)형태를 선호합니다. - 입력은 좁게, 검증은 빠르게: 큰 버퍼를 맹신하지 말고, 입력 직후 길이/허용 문자 집합/형식을 검증합니다.
- 표준화된 줄 처리 규칙:
fgets사용 후 개행 제거 규칙을 팀 공통 유틸로 고정하면 버그가 급감합니다. - 경고를 에러로 승격:
-Wall -Wextra -Werror로 문자열 관련 경고를 빌드 단계에서 즉시 막습니다. - 테스트 케이스를 경계값 위주로 작성: 빈 문자열, 최대 길이 정확히 맞는 입력, 최대 길이 초과 입력, 공백 포함 입력을 반드시 테스트합니다.
오늘의 결론
한 줄 요약: C 문자열은 “글자들의 모음”이 아니라 “\0으로 끝나는 char 배열”이며, 안정성의 핵심은 언제나 경계와 종료를 동시에 관리하는 데 있습니다.
연습문제
char id[10]에 사용자 ID를 받아 저장한다고 할 때, 안전한 입력 코드(fgets기반)와 개행 제거 코드를 작성해 보세요.char *a = "devlab";와char b[] = "devlab";의 차이를 “수정 가능성, 저장 위치 관점, 함수 전달 시 동작” 기준으로 설명해 보세요.- 길이가 최대 15자인 문자열 두 개를 입력받아 사전순으로 앞선 문자열을 출력하는 프로그램을 작성해 보세요. (힌트: 다음 강의에서 배울 함수 없이도 루프 비교로 구현 가능)
이전 강의 정답
지난 21강(배열과 함수: 배열 전달, 길이 전달 패턴)의 핵심은 “배열을 함수에 넘길 때는 실제로 포인터로 전달되므로 길이를 별도 인자로 함께 전달해야 안전하다”였습니다. 아래는 대표 정답 예시입니다.
#include <stdio.h>
double average(const int arr[], int n) {
if (n <= 0) return 0.0;
long long sum = 0;
for (int i = 0; i < n; i++) {
sum += arr[i];
}
return (double)sum / n;
}
int main(void) {
int scores[] = {80, 95, 70, 100, 85};
int n = (int)(sizeof(scores) / sizeof(scores[0]));
printf("평균: %.2f\n", average(scores, n));
return 0;
}
포인트 정리:
sizeof(arr)는 함수 내부에서 배열 길이를 알려주지 못함(포인터 크기만 나옴).- 따라서 호출부에서 길이를 계산해 함께 전달해야 함.
- 읽기 전용 의도를 드러내기 위해
const int arr[]를 붙이는 습관이 좋음.
실습 환경/재현 정보
- 컴파일러: Apple clang version 17.x 이상(또는 GCC 13+)
- 컴파일 옵션:
-std=c11 -Wall -Wextra -O2 - 실행 환경: macOS(arm64) 터미널, Ubuntu 22.04에서도 동일 재현 가능
- 재현 체크:
- 예제 1: 마지막 바이트가 0으로 출력되는지 확인
- 예제 2: 7글자 초과 입력 시 잘리는지 및
\0보장 확인 - 예제 3: 배열만 수정 가능함을 확인(리터럴 수정 코드는 주석 유지)