Search

scanf 동작 방식

Created
2020/06/19
tag
C
씹어먹는 C 언어
scanf
%c
%d
%s

1. 들어가기 전에

C 언어를 접하는 많은 사람들이 그러하겠지만, scanf를 이용하면서 한 번쯤은 애를 먹었을 것이다. 특히, 분명 올바른 값을 입력 했는데도 입력을 올바르게 받지 못하는 오류가 가장 결정적이었을 것 같다.
나도 그렇다. 이 부분에 대해서 따로 공부를 하기 전까지는 여전히 그 이유도 모르고 오류 해결도 못하고 갸우뚱만 하고 있었다. 이번 글에서는 scanf가 어떻게 동작하는지 살펴보고, 이를 통해 이제까지 애먹었던 이유를 알고 같은 오류를 반복하지 않기 위해서는 어떻게 해야하는지 알아보도록 해보자.

2. 자주 겪던 오류?

#include <stdio.h> int main() { int intInput; char charInput; printf("정수 입력 -> "); scanf("%d", &intInput); printf("%d\n", intInput); printf("문자 입력 -> "); scanf("%c", &charInput); printf("%c\n", charInput); return 0; }
C
위에서 제시된 간단한 코드를 실행 해보기 전에, 먼저 머리로 Tracking 해보자.
5라는 값을 입력으로 받았다고 했을 때, 5라는 값이 출력될 것이다. 그리고 c라는 문자 입력을 받고 c라는 값이 출력될 것이다. 하지만 위 코드를 직접 실행해보면 아래 그림과 같이 5는 정상적으로 입력을 받았지만 문자열은 입력을 받지도 않았는데 종료되는 것을 확인할 수 있다. 왜 그런 것일까?
결론부터 얘기하면, %c 형식 지정자를 이용하여 입력을 받았기 때문이다. 그럼 도대체 왜 %c 때문인지 scanf의 동작 방식을 확인하여 알아보자.

3. scanf 동작 방식

사용자로부터 입력을 받아 a, b, c, d, e 라는 변수에 값을 할당하기 위해 Console 창에 1 2 3 4 5와 같이 입력 했다고 가정해보자. scanf로 입력 받을 때 컴퓨터는 이를 어떻게 처리를 하게 될까?
가장 먼저 생각할 수 있는 방식은 a라는 공간에 1을 할당하러 갔다 오고, b라는 공간에 2를 할당하러 갔다 오고... e까지 반복하는 과정을 겪게 된다. 이는 상당히 비효율적인 방식이다.
이것이 왜 비효율적인 방식인지 와닿지 않는다면, 값을 입력 받는 것을 접시에 반찬을 받는 것이라고 생각하고, 변수에 값을 할당하는 것을 반찬을 담은 접시를 손님들에게 서빙하는 것이라고 생각해보자. 한 종류의 반잔을 담고 서빙하고, 다른 한 종류의 반찬을 담고 서빙하고... 굉장히 이상하면서도 비효율적이지 않은가?
scanf는 이렇게 한 번씩 왔다갔다 하는 방식으로 동작하지 않는다.
이런 비효율적인 과정을 개선하려면 어떻게 해야할까? 식당 아주머니를 보면 알겠지만 쟁반에 여러 종류의 반찬을 담아 서빙을 하게 된다. scanf를 통해서 입력을 받는 것도 마찬가지라고 볼 수 있다. a라는 변수에 값을 넣기 위해 1을 받았으면 이를 바로 할당하지 않고 쟁반에 두고, b라는 변수에 값을 넣기 위해 2를 받았으면 이를 바로 할당하지 않고 쟁반에 두고... e까지 할당할 값들을 모두 다 받았을 때 e에 할당할 값까지 쟁반에 담아 서빙을 하면 되지 않겠는가? 여기서 쟁반 역할을 하는 것이 버퍼(Buffer)이다.
다시 한 번 Console에 입력 받는 것을 생각해보자. 실제 컴퓨터에서는 우리가 키보드로 입력을 하면 키보드의 입력을 처리해주는 stdin이라는 표준 입력 스트림이 자신의 버퍼에 입력 값들을 담게 된다. scanf의 경우 stdin버퍼에 계속 입력을 받고 있다가, 개행 문자(\n)를 입력하여 버퍼개행 문자가 담기면 버퍼 안에 있는 값들을 스트림으로 다른 장치에 넘기게 된다.
여기서 스트림이란 장치와 장치를 이어주는 파이프이면서 Abstract Device라고 볼 수 있다.
Abstract Device란 다음과 같이 이해하면 된다. 사용자가 키보드로 값을 입력하여 모니터로 출력한다고 했을 때 이 일련의 과정을 사용자가 직접한 것처럼 느끼게 된다. 하지만 실제로는 사용자는 값을 입력하기만 했고, 입력 값을 처리하여 모니터에 뿌려주는 것은 스트림들이 해준 것이다. 즉, 하드웨어의 자원을 직접 접근한 것처럼 느끼지만 실제로는 스트림이 처리를 해줬기 때문에 이를 Abstract Device라고 볼 수 있는 것이다.
따라서 버퍼 안의 값들이 다른 장치에 넘어가게 되면서 입력한 값들이 한 번에 옮겨져 변수에 할당되는 과정을 겪게 된다. 그렇다면 제일 처음에 자주 겪던 오류에서 소개한 것과 같은 예시를 통해 stdin 스트림버퍼를 살펴보자.
이 다음에 나타나는 scanf%c를 통해서 입력 값을 받아오게 된다. %c의 특성 상, 무조건 하나의 문자만을 갖고 오게 되는데, 버퍼가 비어 있다면 입력을 받고 그렇지 않다면 버퍼 안에 있는 값 하나를 추출하여 변수에 할당하게 된다.
따라서 stdin버퍼에는 개행 문자가 남아 있기 때문에 charInput은 별도의 입력을 받지 않고 종료되고 아무런 값도 나타나지 않고 줄만 한 줄 더 띈 상태로 프로그램이 종료하게 되는 것이다. 이것이 원하는 방식대로 작동하지 않았던 이유이다.

4. scanf가 버퍼의 값을 처리하는 방식

위에서는 scanf가 어떤 방식으로 입력 값을 옮기는지 확인할 수 있었다. 하지만 scanf의 형식 지정자에 따라서 처리하는 방식이 조금 다른 것을 짐작할 수 있는데 (당연히 %d는 정수 값 처리, %s는 문자열 처리 등등 차이가 있겠지만) 이에 대해서 아주 조금만 더 자세히 살펴보자.

1) %d

%d 형식 지정자를 통해서 입력 값을 처리할 때는 정확히 숫자 데이터만 의미 있는 데이터가 된다. %d개행 문자를 만났을 때 입력을 종료하면서, 버퍼에서 %d에게 정확히 의미 있는 값을 찾게 된다. 따라서 버퍼 안에서 가장 먼저 만난 숫자 데이터를 Retrieve하고 그 값을 버퍼에서 삭제하게 된다.
단, 특정 입력 값 없이 개행 문자를 포함한 공백 문자를 받게 되면 입력 값이 들어올 때까지 계속해서 입력을 받는 상태가 유지 된다.
만일 공백 문자를 제외하고 데이터를 Retrieve 했는데, 숫자가 없는 경우에는 어떻게 될까? 버퍼 안에서 %d가 의미 있는 값을 찾지 못하는 경우 입력을 종료하게 된다. 즉 %d를 통해 변수에 할당 되어야 하는 값이 없는 상태로 진행되기 때문에 치명적일 수 있다. 따라서 %d로 입력을 받고 그 다음에 %c로 입력을 받는다고 했을 때, a%d의 입력 값으로 줬다면 입력이 종료되면서 %c에서 찾는 값이 a라는 문자가 된다.

2) %s

%s 형식 지정자는 문자열만이 가장 의미 있는 데이터가 된다. 공백 문자를 제외한 모든 입력들은 문자열이 될 수 있기 때문에, 첫 문자가 나오기 전까지 공백 문자를 무시한다. 공백 문자를 무시하다가 의미 있는 데이터를 만나게 되면, 그 다음 공백 문자를 만나기 전까지 데이터를 Retrieve하게 된다. Retrieve한 데이터들은 버퍼에서 삭제된다.
따라서 %d로 입력을 받아서 버퍼에 남아 있는 5 \n 중에 5Retrieve하고 \n이 남아 있다면, %s는 의미 있는 데이터를 만나기 전까지 모든 공백 문자를 무시하므로 입력 값을 받게 되는 것이다. 입력 값을 받은 뒤 버퍼개행 문자가 들어가게 되면 %s에는 해당 개행 문자 전까지를 데이터로 사용하게 된다.

3) %c

위에서 잠깐 언급 했지만, %c 형식 지정자는 %d, %s 형식 지정자처럼 의미 있는 데이터가 들어오기 전에 공백 문자를 무시하듯이 공백 문자를 무시하지 않는다. 따라서 버퍼에 어떤 데이터라도 남아 있다면 해당 데이터를 Retrieve 해버리고 버퍼에서 그 값을 지우게 된다.
반면 버퍼가 비어 있다면 입력 값을 받게 된다. 따라서 %c를 이용하여 꼭 문자를 받고 싶다면 버퍼가 비어 있어야 가능하기 때문에, 빈 버퍼를 보장할 수 있어야 의도치 않은 오류를 막을 수 있다.
따라서 %c 형식 지정자가 %d, %s와 달리 버퍼에 남아 있는 띄어 쓰기나 개행 문자와 같은 공백 문자를 무시할 수 없는 이유는 의외로 간단하게 생각할 수 있다. 그런 공백 문자 마저도 입력으로 처리해야할 수도 있기 때문이다.

4) 입력 버퍼 비우기

위에서 작성한 것과 같이 %d, %s 형식 지정자는 scanf를 통해 입력 값을 사용하는데 있어서 별 문제가 되지 않는 것을 알 수 있었다. 이전에 만나는 공백 문자들은 모두 무시하게 되고, 개행 문자가 들어왔을 때 버퍼의 값을 전달하여 각 변수에 의미 있는 값을 할당할 수 있도록 하기 때문에 공백 문자가 별 문제가 되지 않는다.
하지만 %c 형식 지정자는 공백 문자를 무시하지 않고 이 마저도 입력 값으로 생각하고 활용하게 되기 때문에 의도하지 않은 값이 들어가면서 곤란하게 만들 수 있다. 만일 %c 형식 지정자를 이용하여 문자를 꼭 받아야 한다면 버퍼에 있는 값을 비워야 하는데 어떻게 하면 비울 수 있을까?

fflush 함수를 이용하여 stdin 스트림의 버퍼를 모두 비우기

이 방법은 내가 Windows를 사용하고, Visual Studio를 사용할 때도 많이 사용 했던 방법이다. 1학년 때 코딩하면서 원하는 방식으로 처리가 안 되었던 기억이 나는데, 컴퓨터 프로그래밍 조교님께서 fflush(stdin)을 통해서 입력 버퍼를 지워 보라는 얘기를 해주셨고 잘 해결이 되어 매우 놀랐던 기억이 난다. fflush의 인자로 스트림을 주면, 해당 스트림버퍼를 비워버리는 역할을 수행한다. 버퍼를 비워주는 역할이니 굉장히 편리하지 않은가?
하지만 이 구문은 MicrosoftVisual Studio 위주로 동작하는 비표준이며, gcc와 같은 다른 곳에서는 작동하지 않을 가능성이 높다.
Visual Studio 2015 부터는 정상 작동 하지 않는다고 한다.
심지어 fflush를 이용하여 stdin과 같이 입력 스트림버퍼를 지우는 것은 해당 구문의 원래 목적과는 다르므로 사용을 지양해야 한다. fflush의 원래 목적은 버퍼를 비우되, 출력 버퍼를 비우면서 버퍼 안의 데이터를 다른 목적지로 전송하는데 있다. 즉, 입력 스트림버퍼에서 데이터를 비우기 위한 것이 아니라는 것이다.
그렇다면 fflush를 통해서 stdin과 같은 입력 스트림의 버퍼에서 데이터를 비우기 위해서는 어떻게 해야할까?

getchar 함수를 이용하여 버퍼 안 1개의 값을 Retreive 하기

fflush 함수를 통해 스트림버퍼를 비우는 것은 다른 목적지로 버퍼의 데이터를 출력 시키기 위해 사용하는 것이므로, 출력 스트림버퍼를 비우는 용도로 사용하는 것이라 했다. 그렇다면 stdin과 같은 입력 스트림버퍼는 어떻게 비우는 것이 좋을까?
입력 스트림버퍼를 비운다는 의미는 해당 버퍼로부터 데이터를 읽어오면 되는 것이다.
입력 스트림버퍼로부터 데이터를 읽어오는 역할을 하는 것은 getchar라는 함수이다. 해당 함수는 버퍼에 있는 데이터들 중 하나만 Retrieve하게 된다. 즉, 버퍼가 비어있는 상태가 될 때까지 getchar함수를 호출 해주면 되겠다. 만일 버퍼에 한 개의 데이터만 비우면 된다면 1회 호출만하면 되고, 여러 데이터들이 있다면 해당 횟수만큼 호출을 하거나 반복문을 통해서 호출한다.

5. 결론

%d, %s ,`%c` 형식 지정자를 통해 버퍼로부터 데이터를 읽어와서 변수에 할당하는 과정을 살펴봤다.
%d, %s와 같은 형식 지정자는 공백 문자에 대해서 알아서 처리해주는 반면, %c공백 문자버퍼로부터 읽어오기도 하기 때문에 의도치 않은 결과를 내놓는 것을 볼 수 있었다. 물론 의도치 않은 결과를 내지 않도록 버퍼를 수동으로 비우면서 작업을 하는 것도 좋지만, 제일 좋은 방법은 문자 입력보다는 문자열 입력을 받으면서 %c를 최대한 안 쓰는 방향으로 코딩하는 것이 예기치 못한 오류를 방지할 수 있는 좋은 방법이 되겠다.
또한 반드시 문자 입력을 받아야 한다면, fflush보다는 getchar를 통해 입력 스트림버퍼를 비우는 것이 더 바람직하다고 볼 수 있겠다.

6. Reference