본문 바로가기

C/기초와 문법

[C] 문자와 문자열의 입/출력

반응형

🔊 해당 포스팅은 인프런의 널널한 개발자님의 독하게 시작하는 C 프로그래밍 강의를 듣고 개인적인 복습 목적 하에 작성된 글입니다. 해당 포스팅에 사용된 모든 자료는 필자가 직접 재구성하였음을 알립니다.

 

C언어를 배워보자!


최근에 CS(Computer Science) 공부를 이론으로 접하면서 어딘가 답답한 느낌이 들었다. 뭔가 이론적으로는 배우긴 했는데, 피부에 와닿지 않는 느낌이었다. 한참을 고민하다가 문득 생각이 들었다. CS를 코드 레벨로 이해하면 어떨까? 했다. 그리고 필자가 메인으로 사용하는 Python은 C로 구현된 CPython 구현체이다. 결국 Python의 내부 동작을 깊이 이해하기 위해서라도 C언어에 대한 학습은 필요했다. 그래서 널널한 개발자님의 강의를 결제하고 2가지 목적(CS를 코드 레벨로 이해하기, Python을 보다 깊이 이해하기)을 달성하고자 C언어 학습을 시작했다. 이제 천천히 한 걸음씩 시작해보자.

1. C언어는 3가지 중요한 시점을 갖는다

C언어는 인터프리터 언어인 Python과 다르게 컴파일 언어이다. 이 컴파일 언어라는 것이 무엇인지 이해하기 위해, 우선 C언어에서는 소스코드를 실행하는 데 있어서 크게 3가지 시점을 갖는다. 아래 그림을 보자.

 

C언어는 3가지의 시점을 갖는다

 

우선 크게 보면 빌드 타임과 런타임으로 나뉜다. 그리고 빌드 타임 내에서는 컴파일 타임과 링크 타임으로 나뉜다. 가장 먼저 컴파일 타임이 먼저 진행되는데, 여기서 C언어로 작성된 소스코드가 목적파일(확장자가 obj이며 여기서 obj는 object를 의미)로 번역된다. 이 목적파일로 번역된다라는 것은 C 소스코드가 컴퓨터가 알아들을 수 있는 기계어로 번역된다는 의미이다. 참고로 이 컴파일 타임에 작성된 소스코드에 대한 문법 검사가 이루어진다는 점도 알아두자. 그리고 이 컴파일 타임의 주체는 컴파일러이다.

 

다음으로는 링크 타임이다. 링크 타임에서는 컴파일 타임에서 생성된 목적파일과 외부 라이브러리 파일(확장자가 lib)을 실행파일로 조립하는 역할을 수행한다. 이 링크타임의 주체는 링커이다. 

 

이렇게 컴파일 타임과 링크 타임이 수행되면 생성된 실행파일이 실행되는 런타임이 수행된다. 이 실행파일이라고 하면 윈도우에서는 .exe 확장자의 파일을 생각하면 된다. MacOS에서는 별도의 확장자가 붙지는 않고 파일 이름 자체가 실행파일 이름이 된다. 

2. C언어에서의 Hello World ! 

그러면 이제 C언어에서 Hello World를 찍어보자. 필자는 MacOS 환경이라서 C언어의 IDE 중에 JetBrains의 CLion을 이번 기회에 사용해보려고 한다. 어찌되었건 C언어로 Hello World를 찍어보자. 소스 코드 내용은 아래와 같다.

 

#include <stdio.h>

int main(void) {
    printf("Hello World\n");
    return 0;
}

 

처음 보는 구문이 하나 둘이 아니다.. 소스 코드를 하나씩 한번 파헤쳐보도록 하자. 우선 그림으로 나타낸 설명은 아래와 같다.

 

C언어로 Hello World 찍어보기

 

우선 #으로 시작하는 것은 컴파일 전처리기라고 부른다. 그리고 #include 이후에 오는 <stdio.h> 는 헤더파일이라고 부르는데, 이 헤더 파일을 사용해서 사용자만이 정의한 새로운 함수(추후에 이를 분할 컴파일을 활용해서 한다. 분할 컴파일에서는 나중에 배우도록 하자)나 외부 라이브러리를 가져다 사용할 때 다른 헤더 파일들도 선언한다. 만약 내가 사용하고자 하는 함수가 <stdlib.h> 헤더 안에 있는데, 해당 헤더 파일을 선언하지 않고 그 함수를 사용하려면 에러가 발생한다.

 

이제 main 함수를 살펴보자. 참고로 C언어로 작성된 모든 프로그램은 main 함수가 시작점이고 main 함수의 끝이 프로그램의 끝이다. 이는 Python만 사용하는 유저들에게 매우 익숙하지 않을 것이다. C로 어떤 프로그램을 작성하든 C언어는 무조건 main 함수를 시작과 끝점으로 지정한다. 

 

main 앞에 있는 int는 main 함수가 반환하는 값의 type을 의미한다. 위 예시에서는 0이므로 당연히 int이다. 그리고 void 자리는 함수의 매개변수를 의미한다. 다만, 여기서는 매개변수가 하나도 없기 때문에 void를 작성한 것이다. 

 

그리고 마지막으로 중괄호로 감싸고 있는데, 해당 부분을 함수의 body 부분이라고 칭한다. 함수의 body 부분에 우리가 작성할 여러가지 로직을 정의한다. 

 

참고로 위에서 사용한 printf() 함수는 Python의 print() 함수와 달리 개행이 되지 않기 때문에 반드시 \n 라는 개행 문자를 넣어주어야 개행이 된다.

3. 문자 입/출력

이번엔 C언어로 문자를 입/출력하는 매우 간단한 콘솔 프로그램을 만들어보자. 소스코드 부터 살펴보자.

 

#include <stdio.h>

int main(void) {
    char ch = 0;
    printf("입력하세요: ");
    ch = getchar();
    putchar(ch);
    putchar('Z');
    return 0;
}

 

가장 첫 번째 줄에 ch 라는 char 타입의 변수를 선언하고 정의했다. C언어에서는 변수를 선언할 때 해당 변수의 타입을 지정해주어야 한다. 여기서는 char 라고 했고, char 는 문자를 뜻한다.(문자'열' 아님에 주의!) 그리고 0이라는 값으로 초기화를 했다.

 

엇 그런데 이상하다. 왜 문자 라는 char로 선언하고 정수(int)형인 0을 정의했는데 이것이 가능한걸까? C언어는 기본적으로 컴퓨터의 기계어에 가장 가까운 언어이다. 그래서 문자는 결국 컴퓨터에겐 정수형의 숫자인 셈이다. ASCII 코드 표를 보면 특정 문자가 어떤 숫자에 매핑되는지 알 수 있다. 그러므로 위에서 ch 라는 변수에 정수 0을 할당했지만, 이를 문자로 보는 셈이다. 실제로 ASCII 코드 표 상 십진수 97는 알파벳 소문자 'a'를 나타내는데, 아래 소스코드를 실행하면 a가 나오는 것을 볼 수 있다. 

 

#include <stdio.h>

int main(void) {
    char ch = 97;
    printf("%c", ch);
    return 0;
}

 

자, 이제 getchar() 함수에 대해 알아보자. getchar() 함수는 문자를 입력으로 받는 함수이다. 여기서의 '입력'이라는 것은 키보드 장치의 입력을 의미한다. 여기서 우리는 Buffered I/O에 대해서 알 필요가 있다.

 

Buffered I/O란, 키보드와 같은 장치들이 입/출력 통신을 할 때 데이터를 Buffer 라는 곳에 write, read 하면서 주고받는 방법을 의미한다. 이 Buffered I/O는 큐 형태의 자료구조로 만들어져 있어서 입력한 문자가 Buffer에 들어가고, 출력시 Buffer에서 뺀다.

 

위에서 살펴본 입/출력하는 소스코드가 동작하는 방식을 Buffered I/O 그림을 그려서 도식화해보면 아래와 같다.

 

 

getchar() 함수가 실행되면 가장 먼저 Buffered I/O에 키보드로 입력한 문자하나가 들어간다. 여기서 주목할 점은 Buffered I/O에 문자 a 뒤에 \n 이라는 것이 또 들어가는 것이다. \n은 개행을 의미한다. 왜 개행이 들어갔을까? 우리는 문자를 하나 입력하고 '엔터를 쳤다.' 즉, 이 엔터를 친 것도 입력으로 들어가는 것이다. 참고로 개행인 \n은 ASCII 코드 표 상에서 십진수 10이다. 실제로 개행 \n을 정수로 printf 해보면 숫자 10이 출력되는 것을 볼 수 있다.

 

그리고 getchar() 함수 Buffered I/O로부터 read를 수행하는데, 이 때 a라는 문자를 꺼낸다. 그리고 이 a라는 문자는 변수 ch에 할당된다. 그리고 난 뒤, putchar() 함수를 사용해서 a 문자를 콘솔에 출력한다. putchar() 함수는 문자 하나를 출력하는 함수이다.

 

참고로 putchar() 함수가 동작할 때, ch 변수에 저장되어 있는 데이터를 출력하기 전에 Bufferd I/O에 다시 담아놓았다가 꺼내어 출력한다.

 

4. 문자열 입/출력

다음은 문자열을 입/출력하는 방법에 대해서 알아보자. 문자열은 문자로 이루어진 배열의 줄임말이다. 따라서 문자와 문자열은 엄연히 다른 말이다. 특히, C언어에서는 이 두 차이를 명확히 구분해야 한다. 단적인 예로 문자를 표시하기 위해서는 홑따옴표를 이용해야 한다. 만약에 문자에 쌍따옴표로 감싸주면 문제가 발생한다. 반대로, 쌍따옴표는 문자열에만 적용해야하고, 문자에는 홑따옴표만을 이용해야 한다.

 

문자열 입/출력이 문자 입/출력과의 차이점이라고 한다면 메모리에 대한 차이점이다. 예시 소스코드를 하나만 살펴보자.

 

#include <stdio.h>

int main(void) {
    char szName[32] = { 0 };
    printf("입력하세요: ");
    fgets(szName, sizeof(szName), stdin);
    printf("결괴: ");
    puts(szName);
    return 0;
}

 

우선 문자열을 정의하기 위해서는 똑같이 char 타입을 정의해주되 생성할 사이즈를 대괄호 []안에 넣어주어야 한다. 물론 대괄호 안에 숫자를 넣어주지 않으면 정의하는 문자열의 길이에 맞게 알아서 세팅해주긴 하지만, 여기서는 0으로 초기화를 하기 때문에 사이즈를 32로 넣어주었다. 

 

문자열을 입력받기 위해서는 fgets() 함수를 사용해야 한다. gets() 함수도 있긴 하지만 보안 취약점이 있어 fgets() 함수를 사용해야 한다. fgets() 함수에는 총 3가지 인자를 받는데, 첫번째는 초기화한 문자열, 문자열의 사이즈인 32, 표준입력을 이야기하는 stdin 을 입력해준다. fgest() 함수 역시 Buffered I/O에 입력받은 문자열을 write 한다. 아래 그림을 보자.

 

문자열일 경우

 

입력을 Hello 라고 했다고 가정해보자. 그럴 경우, Buffered I/O에는 Hello 문자열이 들어가고 맨 뒤에 \0 이라는 것이 들어간다. 이 \0은 숫자 0으로 ASCII 코드 표 상 NULL이 된다. 문자열일 경우에는 반드시 문자열 끝에 \0 즉, NULL이 들어간다. 이는 문자열을 read 할 때 중요한 역할을 한다.

 

이제 문자열을 출력할 때, Buffered I/O에서 한 글자씩 출력을 한다. 이 때, 문자열의 '끝'이 있다라는 것을 컴퓨터가 인지를 해야 하는데, 이 '끝'이라는 기준을 \0 으로 인지하게 되는 것이다. 즉, \0이 등장하는 순간 문자열의 끝이라고 생각하고 출력을 멈추는 것이다.

 

다음으로 살펴볼 함수는 getchar() 함수와 비슷하게 입력을 받는 함수인 scanf() 함수에 대해 알아보도록 하자. 우선 아래의 소스코드를 실행시켜보자.

 

#include <stdio.h>

int main(void) {
    char szName[32] = { 0 };
    int nAge = 0;

    printf("나이를 입력하세요: ");
    scanf("%d", &nAge);
    printf("이름을 입력하세요: ");
    fgets(szName, sizeof(szName), stdin);
    return 0;
}

 

소스코드를 실행하면 나이를 입력한 뒤에, 이름을 입력하려고 하니 그대로 프로그램이 종료되버린다. 대체 왜이럴까? 일단 이를 이해하기에 앞서 못보던 연산자 &가 나왔다. &는 메모리 주소를 알려주는 연산자이다. 그리고 scanf() 함수는 사용자의 입력을 받는 함수다. 즉, scanf() 함수는 Buffered I/O에 데이터를 쓰는(write) 함수이다. C언어에서는 데이터를 읽어(read)들일 때 메모리 주소가 없이 변수 이름만 있어도 식별이 가능하다. 하지만 데이터를 쓸(write) 때는 반드시 메모리의 어떤 위치(주소)에다가 데이터를 쓸지 사람이 지정해주어야 하기 때문에 일종의 데이터의 목적지 주소를 반드시 명시해주어야 한다. 그래서 scanf() 함수의 인자에 nAge 변수 앞에 & 연산자를 붙이는 것이다.

 

이제 scanf() 함수에 대해 알아보았다. 그런데 위 소스코드를 실행하면 이름을 입력받기도 전에 대체 왜 바로 종료가 되는 것일까? 이유는 바로 scanf() 함수로 입력을 받았을 때, Buffered I/O에서는 개행인 \n 문자까지 존재하는 상태이기 때문이다. 아래 그림을 보자.

 

 

처음에 scanf() 함수에다가 입력할 나이를 11로 입력하고 엔터를 쳤다. 그러면 Buffered I/O에는 11이라는 숫자와 개행까지 들어있는 상태다. 그리고 난 뒤 숫자 11을 Buffered I/O에서 빼준 뒤, fgets() 함수가 문자열을 입력 받으려고 한다. 어라? 그런데 fgets() 함수가 입력을 받기 전인데 Buffered I/O에 이미 \n 이라는 문자가 들어있다는 사실을 확인했다. 그 순간 fgets() 함수는 사용자의 입력이 \n 이라고 착각하고 \n 문자를 Buffered I/O에서 빼버린 후 그대로 프로그램 실행이 종료되게 된다.

 

그러면 이 문제를 어떻게 고칠까? fgets() 함수가 입력을 받기 전에  \n 이라는 문자를 Buffered I/O에서 제거해버려야 한다. 이를 위해 형식문자 %*c 라는 것을 사용한다. 여기서 c 는 문자를 의미한다. 따라서 아래처럼 소스코드를 수정해주면 정상적으로 이름까지 입력하고 프로그램이 종료되는 것을 확인할 수 있다.

 

#include <stdio.h>

int main(void) {
    char szName[32] = { 0 };
    int nAge = 0;

    printf("나이를 입력하세요: ");
    scanf("%d%*c", &nAge);  // 수정된 부분
    printf("이름을 입력하세요: ");
    fgets(szName, sizeof(szName), stdin);
    return 0;
}

 

그리고 또 한가지 scanf() 함수에서 주의할 점은 형식문자에 개행(\n)을 입력해서는 안된다. 만약 개행을 입력하면 개행을 입력 받는 것을 대기하기 때문에 일종의 개행 무한 루프에 빠지게 된다. 아래 소스코드를 실행하면 개행만 무한대로 치다가 아무 문자나 입력하면 프로그램이 바로 종료되는 것을 볼 수 있다.

 

#include <stdio.h>

int main(void) {
    char szName[32] = { 0 };
    int nAge = 0;

    printf("나이를 입력하세요: ");
    scanf("%d\n", &nAge); // 개행을 넣으면 무한 개행에 빠진다
    return 0;
}

 

그리고 scanf() 함수는 두 개 이상의 입력을 받을 때 형식문자 사이에 공백이 있어서는 안된다.

 

#include <stdio.h>

int main(void) {
    int x = 0, y = 0;

    scanf("%d%d", &x, &y);
    printf("x: %d, y: %d\n", x, y);
    return 0;
}

 

반응형