Skip to main content

문자열

Views 55약 11분생성형 AI 활용
Summary

배열을 배웠다면, 이제 문자열을 만날 차례

앞 장에서 우리는 배열을 배웠습니다. 배열은 같은 자료형의 값을 메모리에 나란히 놓아 두는 방법이었습니다. 예를 들어 int scores[5]는 정수 5개를 줄 세워 놓은 공간이고, char letters[3]는 문자 3개를 줄 세워 놓은 공간입니다.

그렇다면 문자열은 무엇일까요?

많은 언어에서는 문자열이 특별한 자료형입니다. Python에는 str이 있고, JavaScript에는 string이 있습니다. 그래서 다음과 같이 문자열을 하나의 값처럼 다룹니다.

name = "Alice"
let name = "Alice";

하지만 C에는 string이라는 기본 자료형이 없습니다. C에서 문자열은 별도의 마법 같은 타입이 아니라 문자들이 들어 있는 배열입니다.

char name[] = "Alice";

여기서 name은 문자열 변수처럼 보이지만, 실제로는 char 배열입니다. 이 사실 하나 때문에 C 문자열은 처음에는 조금 낯설고, 때로는 위험합니다. 하지만 동시에 이 사실을 이해하면 C가 메모리를 어떻게 바라보는지 훨씬 선명하게 보입니다.

이번 장의 핵심은 한 문장입니다.

C 문자열은 널 문자 0으로 끝나는 문자 배열이다.

이 문장을 제대로 이해하면 scanf, fgets, strlen, strcpy, strcmp가 왜 그렇게 동작하는지 자연스럽게 연결됩니다.

문자열

문자 하나와 문자열 하나는 다르다

먼저 문자 하나를 보겠습니다.

char ch = 'A';

작은따옴표 'A'는 문자 하나를 뜻합니다. char 변수 하나에 저장할 수 있습니다.

반면 문자열은 문자 여러 개의 묶음입니다.

char word[] = "A";

겉으로 보기에는 둘 다 A 하나를 담는 것처럼 보이지만, 메모리에서는 다릅니다.

char ch = 'A';
char word[] = "A";

ch는 문자 하나만 저장합니다.

변수메모리에 저장되는 값
ch'A'

하지만 word는 배열입니다. 문자열 "A"를 저장하려면 문자 'A'뿐 아니라 문자열의 끝을 알려 주는 특별한 문자도 필요합니다.

인덱스word[0]word[1]
값'A''0'

여기서 0이 바로 널 문자입니다. 숫자 0과 같은 값이지만, 문자 배열 안에서는 "문자열이 여기서 끝난다"는 표식으로 사용됩니다.

그래서 문자열 "A"는 문자 하나처럼 보여도 실제 배열 크기는 2입니다.

#include <stdio.h>

int main(void)
{
    char ch = 'A';
    char word[] = "A";

    printf("sizeof ch = %zun", sizeof ch);
    printf("sizeof word = %zun", sizeof word);

    return 0;
}

대부분의 환경에서 결과는 다음과 비슷합니다.

sizeof ch = 1
sizeof word = 2

문자열은 겉으로 보이는 글자 수보다 항상 공간이 하나 더 필요합니다. 그 추가 공간은 문자열의 끝을 표시하는 0을 위한 자리입니다.

널 문자 0: 문자열의 조용한 마침표

C 문자열을 이해하는 열쇠는 널 문자입니다. 널 문자는 화면에 출력되지 않습니다. 보이지 않습니다. 하지만 문자열 함수들은 이 문자를 보고 멈춥니다.

다음 코드를 봅시다.

char greeting[] = "Hello";

겉으로는 다섯 글자입니다.

H e l l o

하지만 메모리에는 여섯 칸이 사용됩니다.

인덱스012345
값'H''e''l''l''o''0'

문자열을 직접 문자 배열로 초기화하면 다음과 같습니다.

char greeting[6] = {'H', 'e', 'l', 'l', 'o', '0'};

이 코드는 아래 코드와 거의 같은 의미입니다.

char greeting[] = "Hello";

문자열 리터럴 "Hello"를 사용하면 컴파일러가 마지막에 0을 자동으로 붙여 줍니다. 그래서 우리는 보통 직접 0을 쓰지 않아도 됩니다. 하지만 0이 실제로 존재한다는 사실은 반드시 기억해야 합니다.

다음 예제를 실행해 봅시다.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char word[] = "CAT";

    printf("sizeof word = %zun", sizeof word);
    printf("strlen(word) = %zun", strlen(word));

    return 0;
}

결과는 다음과 비슷합니다.

sizeof word = 4
strlen(word) = 3

sizeof word는 배열 전체의 크기를 봅니다. C, A, T, 0까지 포함해서 4입니다.

반면 strlen(word)는 문자열의 길이를 셉니다. 즉, 0이 나오기 전까지의 문자 개수를 셉니다. 그래서 결과가 3입니다.

이 차이는 매우 중요합니다.

char text[100] = "Hi";

이때 sizeof text는 100입니다. 배열 공간이 100칸이기 때문입니다. 하지만 strlen(text)는 2입니다. 문자열 내용은 "Hi"이고, 그 뒤 어딘가에 0이 있기 때문입니다.

배열의 용량과 문자열의 현재 길이는 다릅니다.

널 문자가 없으면 무슨 일이 생길까?

C는 친절한 안전요원이 아닙니다. 우리가 배열을 문자열처럼 쓰겠다고 하면, C는 그 배열 안에 정말 0이 있는지 매번 검사해 주지 않습니다.

다음 코드는 위험합니다.

#include <stdio.h>

int main(void)
{
    char bad[3] = {'C', 'A', 'T'};

    printf("%sn", bad);

    return 0;
}

bad에는 C, A, T 세 글자만 있습니다. 문자열의 끝을 알리는 0이 없습니다.

그런데 printf에서 %s를 사용하면 C는 이렇게 생각합니다.

"문자 배열의 시작 위치를 받았으니, 0이 나올 때까지 계속 출력해야지."

문제는 bad 배열 안에 0이 없다는 것입니다. 그러면 printf는 배열 바깥의 메모리까지 계속 읽을 수 있습니다. 운이 좋으면 근처에서 우연히 0을 만나 멈출 수도 있습니다. 운이 나쁘면 이상한 글자가 출력되거나 프로그램이 비정상 종료될 수 있습니다.

이런 상황을 정의되지 않은 동작이라고 부릅니다. 정의되지 않은 동작이란 C 표준이 결과를 보장하지 않는다는 뜻입니다. 쉽게 말해 "무슨 일이 일어나도 이상하지 않다"는 의미입니다.

안전하게 만들려면 널 문자를 넣을 공간을 확보해야 합니다.

char good[4] = {'C', 'A', 'T', '0'};

또는 문자열 리터럴을 사용합니다.

char good[] = "CAT";

이 경우 컴파일러가 자동으로 0까지 포함해 배열 크기를 4로 잡습니다.

문자열을 저장할 배열을 만들 때는 항상 이렇게 생각하세요.

저장하고 싶은 글자 수 + 널 문자 1칸

예를 들어 이름을 최대 19글자까지 저장하고 싶다면 배열은 20칸이 필요합니다.

char name[20];  // 최대 19글자 + '0'

이 습관 하나가 수많은 문자열 오류를 막아 줍니다.

문자 배열과 문자열 리터럴

다음 두 코드는 비슷해 보입니다.

char a[] = "Hello";
char b[6] = {'H', 'e', 'l', 'l', 'o', '0'};

둘 다 char 배열에 같은 문자열을 저장합니다.

a는 문자열 리터럴 "Hello"로 초기화한 배열입니다. 컴파일러는 H, e, l, l, o, 0을 배열에 넣습니다.

b는 우리가 직접 문자들을 하나씩 나열한 배열입니다. 마지막에 0을 직접 넣었습니다.

메모리 관점에서 두 배열은 다음과 같습니다.

인덱스012345
값Hello0

배열로 만들어진 문자열은 각 문자를 바꿀 수 있습니다.

#include <stdio.h>

int main(void)
{
    char animal[] = "cat";

    animal[0] = 'b';

    printf("%sn", animal);

    return 0;
}

결과는 다음과 같습니다.

bat

animal은 배열이고, 배열 안에는 c, a, t, 0이 들어 있습니다. animal[0]을 'b'로 바꾸면 문자열 내용이 "bat"이 됩니다.

하지만 문자열 리터럴 자체는 수정하려고 하면 안 됩니다. 다음 장에서 포인터를 배우면 이 차이가 더 정확히 보이겠지만, 지금은 이렇게 기억하면 충분합니다.

char 배열[] = "문자열";은 수정 가능한 배열을 만든다. 문자열 리터럴 자체는 수정 대상이 아니다.

초보 단계에서는 문자열을 바꿔야 한다면 char 배열을 만들어 사용하세요.

char title[20] = "C Language";

이렇게 만들면 배열 안의 문자를 바꿀 수 있고, 남은 공간에 더 짧은 문자열을 복사해 넣을 수도 있습니다. 물론 배열 크기를 넘지 않는다는 조건이 반드시 필요합니다.

문자열 출력: printf, puts, %s

문자열을 출력할 때 가장 많이 사용하는 방법은 printf의 %s입니다.

#include <stdio.h>

int main(void)
{
    char name[] = "Alice";

    printf("Hello, %s!n", name);

    return 0;
}

%s는 문자열을 출력하라는 뜻입니다. 정확히는 주어진 위치에서 시작해 0이 나올 때까지 문자를 출력합니다.

문자 하나를 출력할 때는 %c를 사용합니다.

char ch = 'A';
printf("%cn", ch);

문자열을 출력할 때는 %s를 사용합니다.

char word[] = "Apple";
printf("%sn", word);

문자와 문자열의 차이를 헷갈리면 안 됩니다.

char ch = 'A';       // 문자 하나
char str[] = "A";    // 문자 'A'와 '0'을 가진 배열

간단히 문자열만 한 줄 출력하고 싶다면 puts도 사용할 수 있습니다.

#include <stdio.h>

int main(void)
{
    char message[] = "Welcome to C";

    puts(message);

    return 0;
}

puts는 문자열을 출력한 뒤 자동으로 줄바꿈을 합니다. 그래서 다음 두 코드는 비슷한 결과를 냅니다.

printf("%sn", message);
puts(message);

차이는 printf가 형식 지정자를 이용해 다양한 값을 섞어 출력할 수 있다는 점입니다. 반면 puts는 문자열 하나를 간단히 출력할 때 편합니다.

중요한 점은 둘 다 문자열의 끝을 0으로 판단한다는 것입니다. 0이 없는 문자 배열을 %s나 puts에 넘기면 안전하지 않습니다.

문자열 입력 1: scanf는 공백 앞에서 멈춘다

문자열 입력을 받을 때도 scanf를 사용할 수 있습니다.

#include <stdio.h>

int main(void)
{
    char name[20];

    printf("이름을 입력하세요: ");
    scanf("%19s", name);

    printf("안녕하세요, %s님!n", name);

    return 0;
}

여기서 주목할 점이 두 가지 있습니다.

첫째, name 앞에 &를 붙이지 않았습니다.

scanf("%19s", name);

정수 입력에서는 이렇게 했습니다.

int age;
scanf("%d", &age);

그런데 문자열 배열에서는 보통 &name이 아니라 name을 사용합니다. 배열 이름이 배열의 시작 위치를 가리키는 것처럼 동작하기 때문입니다. 이 내용은 포인터 장에서 더 깊게 다룹니다. 지금은 "문자열 입력에는 배열 이름을 그대로 넘긴다" 정도로 기억하면 됩니다.

둘째, %19s처럼 최대 입력 길이를 제한했습니다.

char name[20];
scanf("%19s", name);

name 배열은 20칸입니다. 문자열 끝에는 0이 필요하므로 실제로 저장할 수 있는 글자는 최대 19개입니다. 그래서 %19s라고 적었습니다. scanf는 최대 19글자를 읽고 마지막에 0을 붙입니다.

만약 이렇게 쓰면 위험합니다.

char name[20];
scanf("%s", name);

사용자가 100글자를 입력하면 어떻게 될까요? name 배열은 20칸뿐인데, scanf는 그 이상을 써 버리려고 할 수 있습니다. 그러면 배열 바깥의 메모리를 망가뜨릴 수 있습니다. 이것을 버퍼 오버플로우라고 합니다. 버퍼는 데이터를 잠시 담아 두는 공간이고, 오버플로우는 그 공간을 넘쳐흐른다는 뜻입니다.

문자열 입력에서 버퍼 오버플로우는 C의 대표적인 위험 요소입니다. 그래서 scanf로 문자열을 받을 때는 반드시 폭을 제한하는 습관을 들여야 합니다.

char word[30];
scanf("%29s", word);

배열 크기가 30이면 %29s입니다. 마지막 한 칸은 0을 위한 자리입니다.

또 하나의 특징이 있습니다. scanf("%s", ...)는 공백에서 입력을 끊습니다. 사용자가 다음처럼 입력했다고 해 봅시다.

Alice Kim

scanf("%s", name)은 Alice까지만 읽습니다. 공백 뒤의 Kim은 입력 버퍼에 남습니다. 이름에 공백이 들어갈 수 있거나 문장 전체를 입력받고 싶다면 scanf보다 fgets가 더 적합합니다.

문자열 입력 2: fgets는 한 줄을 읽는다

공백을 포함한 문자열을 입력받고 싶다면 fgets를 사용합니다.

#include <stdio.h>

int main(void)
{
    char sentence[100];

    printf("문장을 입력하세요: ");
    fgets(sentence, sizeof sentence, stdin);

    printf("입력한 문장: %s", sentence);

    return 0;
}

fgets의 기본 형태는 다음과 같습니다.

fgets(배열이름, 배열크기, stdin);

stdin은 표준 입력을 뜻합니다. 보통 키보드 입력이라고 생각하면 됩니다.

fgets(sentence, sizeof sentence, stdin)은 sentence 배열에 한 줄을 읽어 넣습니다. 이때 최대 sizeof sentence - 1개의 문자만 저장합니다. 마지막 한 칸은 0을 위해 남겨 둡니다. 이 점에서 fgets는 안전한 입력 습관을 만들기에 좋습니다.

하지만 fgets에는 알아 두어야 할 특징이 있습니다. 사용자가 엔터를 누르면 그 줄바꿈 문자 n도 배열에 들어갈 수 있습니다.

예를 들어 사용자가 다음처럼 입력했다고 해 봅시다.

hello

실제 배열에는 다음처럼 저장될 수 있습니다.

인덱스0123456
값'h''e''l''l''o''n''0'

그래서 문자열 비교를 하거나 깔끔하게 출력하고 싶다면 마지막 줄바꿈을 제거하는 경우가 많습니다.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char word[20];

    printf("단어를 입력하세요: ");
    fgets(word, sizeof word, stdin);

    size_t len = strlen(word);

    if (len > 0 && word[len - 1] == 'n') {
        word[len - 1] = '0';
    }

    printf("입력한 단어는 [%s]입니다.n", word);

    return 0;
}

여기서 strlen(word)는 0 전까지의 길이를 구합니다. 마지막 문자가 n이면 그 자리를 0으로 바꿉니다. 그러면 문자열은 줄바꿈 없이 끝납니다.

scanf를 사용한 뒤 바로 fgets를 사용하면 예상치 못하게 빈 줄이 읽히는 경우가 있습니다. 이전 입력에서 남은 엔터 n을 fgets가 읽어 버리기 때문입니다. 이 문제는 입력 버퍼를 정리해야 해결됩니다.

int ch;
while ((ch = getchar()) != 'n' && ch != EOF) {
}

처음부터 문자열 입력을 많이 다루는 프로그램이라면 가능하면 fgets 중심으로 입력 방식을 통일하는 편이 더 안전하고 예측하기 쉽습니다.

strlen: 문자열 길이는 널 문자 전까지

문자열 관련 함수는 대부분 <string.h>에 들어 있습니다.

#include <string.h>

가장 먼저 볼 함수는 strlen입니다.

size_t strlen(const char *s);

함수 원형에는 아직 배우지 않은 포인터 문법이 보입니다. 지금은 겁먹지 않아도 됩니다. strlen에는 문자열을 넘기면 되고, 결과로 문자열의 길이를 돌려준다고 이해하면 됩니다.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char text[20] = "Hello";

    printf("배열 크기: %zun", sizeof text);
    printf("문자열 길이: %zun", strlen(text));

    return 0;
}

결과는 다음과 같습니다.

배열 크기: 20
문자열 길이: 5

strlen은 배열 전체 크기를 알려 주지 않습니다. 0이 나올 때까지 몇 글자가 있는지만 셉니다.

이 점 때문에 다음 코드는 특히 조심해야 합니다.

char text[20] = "Hello";

printf("%zun", sizeof text);    // 20
printf("%zun", strlen(text));   // 5

배열 크기와 문자열 길이는 서로 다른 질문에 대한 답입니다.

"이 상자는 몇 칸짜리인가?"에 대한 답은 sizeof입니다.

"이 상자 안에 현재 몇 글자의 문자열이 들어 있는가?"에 대한 답은 strlen입니다.

또한 strlen은 0을 만날 때까지 계속 읽습니다. 따라서 널 문자로 끝나지 않는 배열에 strlen을 사용하면 위험합니다.

char bad[3] = {'a', 'b', 'c'};
printf("%zun", strlen(bad));  // 위험

strlen 역시 0을 기준으로 동작한다는 사실을 잊지 마세요.

strcpy: 문자열을 복사한다

배열은 대입 연산자로 통째로 복사할 수 없습니다.

char a[20] = "Hello";
char b[20];

b = a;  // 오류

배열 이름은 대입의 대상이 될 수 없습니다. 문자열 내용을 복사하려면 strcpy를 사용합니다.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char src[] = "apple";
    char dst[10];

    strcpy(dst, src);

    printf("dst = %sn", dst);

    return 0;
}

strcpy(dst, src)는 src 문자열을 dst로 복사합니다. 이때 마지막 0까지 복사합니다.

그래서 목적지 배열에는 원본 문자열 길이보다 하나 더 큰 공간이 필요합니다.

char dst[6];
strcpy(dst, "apple");  // a p p l e 0, 총 6칸 필요

하지만 다음 코드는 위험합니다.

char dst[5];
strcpy(dst, "apple");  // 위험: 0을 넣을 공간이 없음

apple은 다섯 글자입니다. 하지만 문자열로 저장하려면 0까지 총 6칸이 필요합니다. dst[5]는 공간이 부족합니다.

더 위험한 예도 있습니다.

char dst[5];
strcpy(dst, "very long text");  // 매우 위험

strcpy는 목적지 배열의 크기를 모릅니다. 그저 원본 문자열을 0까지 계속 복사합니다. 목적지 공간이 충분하지 않으면 배열 바깥까지 써 버릴 수 있습니다.

따라서 strcpy를 사용할 때는 반드시 목적지 배열이 충분히 큰지 확인해야 합니다.

char dst[20];
char src[] = "apple";

if (strlen(src) < sizeof dst) {
    strcpy(dst, src);
}

여기서 조건이 <인 이유는 0을 넣을 공간까지 필요하기 때문입니다. strlen(src)가 5이면 실제 필요한 공간은 6입니다. sizeof dst가 6 이상이어야 안전합니다.

문자열을 안전하게 복사하는 한 가지 실용적인 방법은 snprintf를 사용하는 것입니다.

char dst[10];
char src[] = "apple";

snprintf(dst, sizeof dst, "%s", src);

snprintf는 지정한 크기를 넘지 않도록 문자열을 만들어 줍니다. 공간이 부족하면 잘라서 저장하고, 가능한 경우 마지막에 0도 넣습니다. 물론 잘린 문자열이 생길 수 있으므로, 잘림 여부가 중요한 프로그램에서는 결과 길이를 확인해야 합니다.

strcmp: 문자열을 비교한다

정수는 ==로 비교할 수 있습니다.

int a = 10;
int b = 10;

if (a == b) {
    printf("같습니다.n");
}

그렇다면 문자열도 이렇게 비교할 수 있을까요?

char name[20] = "Tom";

if (name == "Tom") {
    printf("같습니다.n");
}

이렇게 쓰면 원하는 대로 동작하지 않습니다. 문자열 내용이 같은지 비교하는 것이 아니라, 배열과 문자열 리터럴이 놓인 위치를 비교하는 꼴이 되기 때문입니다. 위치와 주소에 대한 이야기는 다음 장의 포인터에서 본격적으로 다룹니다.

문자열 내용이 같은지 비교하려면 strcmp를 사용합니다.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char password[20];

    printf("비밀번호: ");
    scanf("%19s", password);

    if (strcmp(password, "c-lang") == 0) {
        printf("로그인 성공n");
    } else {
        printf("로그인 실패n");
    }

    return 0;
}

strcmp(a, b)는 두 문자열을 앞에서부터 한 글자씩 비교합니다. 그리고 결과로 정수를 돌려줍니다.

결과의미
0두 문자열이 같다
음수앞 문자열이 사전순으로 더 앞에 있다
양수앞 문자열이 사전순으로 더 뒤에 있다

처음에는 strcmp가 0을 반환할 때 같다는 점이 어색할 수 있습니다. 하지만 이렇게 생각하면 됩니다.

"두 문자열의 차이가 0이면 같다."

그래서 문자열 비교는 보통 다음 형태로 씁니다.

if (strcmp(a, b) == 0) {
    printf("같은 문자열입니다.n");
}

fgets로 입력받은 문자열을 비교할 때는 줄바꿈 문자를 제거해야 하는 경우가 많습니다.

char answer[20];
fgets(answer, sizeof answer, stdin);

size_t len = strlen(answer);
if (len > 0 && answer[len - 1] == 'n') {
    answer[len - 1] = '0';
}

if (strcmp(answer, "yes") == 0) {
    printf("동의했습니다.n");
}

사용자가 yes를 입력하고 엔터를 눌렀다면 배열에는 y, e, s, n, 0이 들어갈 수 있습니다. 줄바꿈을 제거하지 않으면 문자열은 "yes"가 아니라 "yesn"입니다. 그래서 비교가 실패할 수 있습니다.

문자열 처리의 진짜 위험: 버퍼 크기

C 문자열이 위험해지는 순간은 대부분 하나입니다.

배열 크기보다 더 많은 문자를 넣으려고 할 때

다음 코드를 봅시다.

char name[8];
scanf("%s", name);

사용자가 Kim을 입력하면 문제가 없습니다. 하지만 VeryVeryLongName을 입력하면 배열 크기를 넘어설 수 있습니다.

메모리는 칸막이가 있는 책상과 비슷합니다. name[8]은 8칸짜리 작은 상자입니다. 그런데 그 안에 20글자를 억지로 넣으면 옆 상자까지 침범합니다. 옆 상자에는 다른 변수, 함수 호출 정보, 프로그램이 실행되는 데 필요한 중요한 값이 있을 수 있습니다.

이런 오류는 특히 무섭습니다. 프로그램이 바로 죽지 않을 수도 있기 때문입니다. 겉으로는 잘 실행되는 것처럼 보이다가, 전혀 다른 곳에서 이상한 결과가 나타날 수 있습니다.

예를 들어 다음 코드는 겉으로는 단순해 보이지만 안전하지 않습니다.

char title[10];
strcpy(title, "This title is too long");

title은 10칸인데 복사하려는 문자열은 훨씬 깁니다. strcpy는 목적지 배열의 크기를 모르므로 계속 복사합니다.

그래서 C 문자열을 다룰 때는 언제나 두 가지를 함께 생각해야 합니다.

문자열의 현재 길이와 배열의 전체 용량입니다.

strlen은 현재 길이를 알려 줍니다. sizeof는 배열이 현재 스코프에서 실제 배열로 보일 때 전체 용량을 알려 줍니다. 단, 배열을 함수에 전달하면 sizeof로 원래 배열 크기를 알 수 없다는 점을 앞 장에서 예고했습니다. 이 이야기는 포인터와 배열을 다룰 때 다시 중요해집니다.

안전한 문자열 입력 습관

C 문자열은 강력하지만, 안전장치가 자동으로 따라오지는 않습니다. 그래서 처음부터 안전한 습관을 들여야 합니다.

  • 문자열 배열을 만들 때는 항상 0을 위한 공간을 하나 더 생각합니다.

  • scanf로 문자열을 받을 때는 %19s처럼 최대 폭을 지정합니다.

  • 공백을 포함한 한 줄 입력은 가능하면 fgets를 사용합니다.

  • fgets를 사용한 뒤 필요하면 마지막 n을 0으로 바꿉니다.

  • 문자열을 복사할 때는 목적지 배열 크기가 충분한지 확인합니다.

  • 배열을 처음 만들 때 char text[100] = {0};처럼 0으로 초기화하면 예기치 않은 쓰레기 값을 줄일 수 있습니다.

  • gets는 사용하지 않습니다. 입력 길이를 제한할 방법이 없어 매우 위험한 함수입니다.

특히 초보 단계에서는 다음 패턴을 자주 사용하면 좋습니다.

char input[100];

printf("입력: ");
if (fgets(input, sizeof input, stdin) != NULL) {
    size_t len = strlen(input);

    if (len > 0 && input[len - 1] == 'n') {
        input[len - 1] = '0';
    }

    printf("입력한 문자열: %sn", input);
}

이 코드는 세 가지를 챙깁니다.

  • 배열 크기를 넘지 않게 입력받습니다.

  • 입력 실패 여부를 확인합니다.

  • 마지막 줄바꿈을 제거합니다.

조금 길어 보이지만, C에서는 이런 조심성이 프로그램을 안전하게 만듭니다.

실습: 간단한 단어 검색 프로그램

이제 문자열 배열과 strcmp를 사용해 간단한 단어 검색 프로그램을 만들어 봅시다. 여러 개의 단어를 저장해 두고, 사용자가 입력한 단어가 목록에 있는지 확인하는 프로그램입니다.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char words[5][20] = {
        "apple",
        "banana",
        "carrot",
        "desk",
        "engine"
    };

    char query[20];

    printf("찾을 단어: ");

    if (fgets(query, sizeof query, stdin) == NULL) {
        printf("입력을 읽지 못했습니다.n");
        return 1;
    }

    size_t len = strlen(query);

    if (len > 0 && query[len - 1] == 'n') {
        query[len - 1] = '0';
    }

    int found = 0;

    for (int i = 0; i < 5; i++) {
        if (strcmp(words[i], query) == 0) {
            found = 1;
            break;
        }
    }

    if (found) {
        printf("단어를 찾았습니다: %sn", query);
    } else {
        printf("목록에 없는 단어입니다.n");
    }

    return 0;
}

여기서 words[5][20]은 조금 낯설 수 있습니다. 이것은 char 배열이 5개 있는 구조입니다. 각 단어는 최대 19글자와 0을 저장할 수 있습니다.

char words[5][20];

이 선언은 다음처럼 이해할 수 있습니다.

표현의미
words[0]첫 번째 문자열 공간
words[1]두 번째 문자열 공간
words[2]세 번째 문자열 공간
words[3]네 번째 문자열 공간
words[4]다섯 번째 문자열 공간

각 words[i]는 크기 20인 char 배열입니다. 즉, 문자열 하나를 담을 수 있는 공간입니다.

검색 과정은 단순합니다. 반복문으로 단어 목록을 하나씩 확인합니다.

for (int i = 0; i < 5; i++) {
    if (strcmp(words[i], query) == 0) {
        found = 1;
        break;
    }
}

strcmp(words[i], query) == 0이면 두 문자열이 같다는 뜻입니다. 찾았으면 found를 1로 바꾸고 반복문을 빠져나옵니다.

이 작은 프로그램 안에는 이번 장의 핵심이 모두 들어 있습니다. 문자열 배열, fgets, 줄바꿈 제거, strlen, strcmp, 그리고 널 문자 기반의 문자열 처리가 함께 사용됩니다.

흔히 하는 실수들

문자열을 처음 배울 때 자주 만나는 실수들을 정리해 봅시다.

  • char name[5] = "Hello";처럼 0 공간을 생각하지 않는다.

  • scanf("%s", name);처럼 입력 길이를 제한하지 않는다.

  • fgets로 읽은 뒤 줄바꿈 n을 제거하지 않고 strcmp를 사용한다.

  • 문자열을 ==로 비교하려고 한다.

  • sizeof와 strlen을 같은 의미로 착각한다.

  • strcpy를 사용할 때 목적지 배열 크기를 확인하지 않는다.

하나씩 짚어 봅시다.

char name[5] = "Hello";

"Hello"는 다섯 글자지만 문자열로 저장하려면 0까지 필요합니다. 안전하게는 다음처럼 써야 합니다.

char name[6] = "Hello";

또는 배열 크기를 컴파일러에게 맡길 수 있습니다.

char name[] = "Hello";

이렇게 하면 컴파일러가 0까지 포함해 적절한 크기로 배열을 만듭니다.

문자열 비교 실수도 매우 흔합니다.

if (name == "Tom") {
    printf("Tom입니다.n");
}

이 코드는 문자열 내용 비교가 아닙니다. 문자열 내용 비교는 다음처럼 해야 합니다.

if (strcmp(name, "Tom") == 0) {
    printf("Tom입니다.n");
}

입력과 비교를 함께 쓸 때는 fgets의 줄바꿈도 조심해야 합니다.

char answer[10];
fgets(answer, sizeof answer, stdin);

if (strcmp(answer, "yes") == 0) {
    printf("YES!n");
}

이 코드는 사용자가 yes를 입력해도 실패할 수 있습니다. 실제 문자열이 yesn일 수 있기 때문입니다. 비교 전에 줄바꿈을 제거해야 합니다.

size_t len = strlen(answer);
if (len > 0 && answer[len - 1] == 'n') {
    answer[len - 1] = '0';
}

문자열 실수는 사소해 보이지만, C에서는 메모리 오류로 이어질 수 있습니다. 따라서 문자열을 다룰 때는 항상 배열 크기, 현재 길이, 널 문자를 함께 떠올려야 합니다.

문자열을 메모리로 바라보는 감각

C 문자열을 배우면 처음에는 불편합니다. 왜 문자열 타입이 따로 없을까요? 왜 끝에 0을 붙여야 할까요? 왜 복사도 직접 조심해야 하고, 비교도 strcmp를 써야 할까요?

하지만 바로 그 불편함 덕분에 우리는 컴퓨터의 실제 동작에 가까이 다가갑니다.

문자열 "Hello"는 추상적인 글자 덩어리가 아닙니다. 메모리 어딘가에 다음처럼 놓인 값들의 연속입니다.

'H' 'e' 'l' 'l' 'o' '0'

printf는 첫 칸부터 읽다가 0을 만나면 멈춥니다. strlen도 첫 칸부터 세다가 0을 만나면 멈춥니다. strcpy는 첫 칸부터 복사하다가 0까지 복사합니다. strcmp는 두 문자열을 앞에서부터 비교하다가 다른 문자를 만나거나 둘 다 0에 도달하면 결론을 냅니다.

즉, C 문자열 함수들은 모두 같은 약속 위에서 움직입니다.

문자열은 0에서 끝난다.

이 약속이 깨지면 프로그램은 길을 잃습니다. 어디까지 읽어야 하는지 모르기 때문입니다.

마무리

이번 장에서는 C 문자열의 정체를 살펴보았습니다. C 문자열은 특별한 기본 타입이 아니라 char 배열입니다. 그리고 그 배열은 반드시 널 문자 0으로 끝나야 합니다.

문자열을 이해할 때 가장 중요한 질문은 다음과 같습니다.

  • 이 배열에는 0을 넣을 공간이 있는가?

  • 현재 문자열의 길이와 배열의 전체 크기를 구분하고 있는가?

  • 입력받을 때 배열 크기를 넘지 않도록 제한했는가?

  • 복사와 비교를 배열 대입이나 ==가 아니라 문자열 함수로 처리하고 있는가?

C 문자열은 처음에는 까다롭지만, 배열과 메모리를 연결해서 이해하게 해 주는 훌륭한 훈련장입니다. 이제 우리는 문자들이 메모리에 나란히 놓이고, 널 문자가 그 끝을 표시한다는 사실을 알게 되었습니다.

다음 장에서는 이 감각이 더 깊어집니다. 배열 이름이 왜 주소처럼 동작하는지, scanf에서 왜 문자열 배열에는 &를 붙이지 않았는지, 함수가 원본 값을 바꾸려면 왜 주소가 필요한지 살펴볼 차례입니다.

그 주제의 이름은 포인터입니다.

책 목록

처음 만나는 C언어: 메모리와 포인터로 배우는 컴퓨터의 원리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기