C 입출력·객체 수명·정의되지 않은 동작·부작용
C의 printf는 출력할 값을, scanf는 값을 저장할 목적지의 주소를 받는다. 서식과 자료형을 맞추고 문자열 버퍼의 경계, 지역·정적·동적 객체의 수명, 식의 부작용을 구분한다.
C의 printf는 출력할 값을, scanf는 값을 저장할 목적지의 주소를 받는다. 서식과 자료형을 맞추고 문자열 버퍼의 경계, 지역·정적·동적 객체의 수명, 식의 부작용을 구분한다.
그림으로 확인하기

표준 입출력과 스트림
C의 표준 입출력은 <stdio.h>의 스트림(stream) 을 통해 이루어진다. 프로그램이 시작될 때 일반적으로 다음 세 표준 스트림을 사용할 수 있다.
| 표준 스트림 | 기본 목적 | 대표 사용 |
|---|---|---|
stdin | 표준 입력 | 키보드 입력, 파이프 입력 |
stdout | 표준 출력 | 정상 결과, 일반 메시지 |
stderr | 표준 오류 | 오류·진단 메시지 |
입출력 함수는 처리 단위에 따라 구분할 수 있다.
| 처리 단위 | 입력 함수 예 | 출력 함수 예 | 핵심 특징 |
|---|---|---|---|
| 서식 변환 | scanf, fscanf | printf, fprintf | 서식 문자열에 따라 형을 변환한다. |
| 문자 한 개 | getchar, fgetc | putchar, fputc | 반환형과 EOF 처리가 중요하다. |
| 문자열·줄 | fgets | fputs, puts | 버퍼 크기와 개행 처리에 주의한다. |
puts는 문자열 뒤에 개행을 덧붙이지만 fputs는 자동으로 개행을 추가하지 않는다. gets는 버퍼 크기를 제한할 수 없어 현재 ISO C에서 제거된 함수이므로 사용하지 않는다.
입출력 반환값을 확인해야 하는 이유
입출력 함수는 성공 여부와 처리량을 반환한다.
printf계열은 성공하면 출력한 문자 수를, 오류가 나면 음수를 반환한다.scanf계열은 성공적으로 대입한 입력 항목 수를 반환한다. 첫 변환 전에 입력 실패가 발생하면EOF를 반환할 수 있다.fgets는 성공하면 목적지 배열을 가리키는 포인터를, 읽기 전에 입력 끝이나 오류를 만나면 널 포인터를 반환한다.getchar와fgetc는 읽은 문자를unsigned char값에서int로 변환해 반환하거나EOF를 반환한다.
입력 실패를 무시하면 아직 값이 정해지지 않은 객체를 후속 계산에 사용할 수 있다. 따라서 “함수를 호출했다”가 아니라 “요구한 항목 수만큼 실제로 읽었다”를 검사해야 한다.
printf 계열: 값을 문자 표현으로 출력
서식 문자열은 인자 형에 대한 계약이다
printf는 서식 문자열의 각 변환 지정자에 대응하는 값을 가변 인자 목록에서 가져온다. 가변 인자에는 기본 인자 승격(default argument promotions) 이 적용되어 float는 double로, char와 short 계열은 보통 int 또는 unsigned int로 승격된다.
| 출력 지정자 | 기대하는 대표 인자 | 의미 |
|---|---|---|
%d, %i | int | 부호 있는 10진 정수 |
%u | unsigned int | 부호 없는 10진 정수 |
%ld | long | 부호 있는 long 정수 |
%zu | size_t | sizeof 결과와 같은 크기 값 |
%c | int | 한 문자로 출력할 값 |
%s | 문자 배열의 첫 원소를 가리키는 포인터 | 널 문자 전까지 문자열 출력 |
%f | double | 고정 소수점 형식의 실수 |
%Lf | long double | long double 실수 |
%p | void * | 구현이 정한 포인터 표현 |
%% | 인자 없음 | % 문자 자체 출력 |
다음은 형이 맞는 대표 예이다.
int count = 7;
long distance = 1200L;
double rate = 3.5;
int values[3] = {1, 2, 3};
printf("count=%d distance=%ld rate=%.1f\n", count, distance, rate);
printf("capacity=%zu address=%p\n", sizeof values, (void *)values);
sizeof의 결과형은 size_t이므로 %zu가 대응한다. %p에는 void *가 요구되므로 객체 포인터를 (void *)로 변환해 전달하는 형태가 이식성이 좋다.
출력 폭과 정밀도
printf의 필드 폭은 출력할 최소 폭이다. 값이 폭보다 길면 잘리지 않고 더 넓게 출력된다.
printf("|%5d|\n", 42); /* | 42| */
printf("|%.2f|\n", 3.14159); /* |3.14| */
- 정수의 정밀도는 최소 자릿수와 관련된다.
- 실수의
.2는%f에서 소수점 아래 자릿수를 지정한다. - 문자열의 정밀도는 출력할 최대 문자 수를 제한하는 데 사용할 수 있다.
- 폭과 정밀도는 인자 형을 바꾸지 않는다.
%5d도 여전히int를 기대한다.
잘못된 서식은 단순한 오답이 아니다
다음 호출은 형 계약이 맞지 않는다.
long value = 100L;
double ratio = 2.5;
printf("%d\n", value); /* %d는 int를 기대 */
printf("%d\n", ratio); /* %d는 int를 기대 */
어떤 환경에서 우연히 숫자가 출력되어도 그 숫자를 언어가 보장한 결과로 볼 수 없다. 가변 인자 함수는 실제 인자의 형 정보를 서식 문자열과 자동으로 맞춰 주지 않기 때문이다.
외부 문자열을 그대로 서식 문자열로 사용해서도 안 된다.
printf("%s", user_text); /* 문자열을 데이터로 출력 */
printf(user_text); /* 문자열이 서식으로 해석되어 위험 */
두 번째 형태는 문자열 안의 %를 변환 지시로 해석한다. 대응 인자가 없거나 형이 맞지 않으면 정의되지 않은 동작이 될 수 있고, 보안 결함으로도 이어질 수 있다.
scanf 계열: 입력 결과를 목적지 객체에 저장
값이 아니라 포인터를 전달한다
scanf는 입력을 변환한 뒤 대응하는 객체에 저장해야 하므로 일반적으로 목적지의 주소를 받는다.
| 입력 지정자 | 기대하는 대표 인자 | 예 |
|---|---|---|
%d | int * | scanf("%d", &n) |
%u | unsigned int * | scanf("%u", &u) |
%ld | long * | scanf("%ld", &value) |
%zu | size_t * | scanf("%zu", &size) |
%f | float * | scanf("%f", &f) |
%lf | double * | scanf("%lf", &d) |
%c | char * | scanf(" %c", &ch) |
%s | 쓰기 가능한 문자 배열의 첫 원소를 가리키는 포인터 | scanf("%9s", name) |
%p | void ** | scanf("%p", &ptr)에서 ptr은 void * |
출력과 입력에서 실수 지정자를 특히 구분해야 한다.
float f;
double d;
scanf("%f", &f); /* float * */
scanf("%lf", &d); /* double * */
printf("%f %f\n", f, d); /* 출력에서는 f가 double로 승격 */
printf의 %f는 double을 받지만 scanf의 %f는 float *를 받는다. printf에서 %lf의 l은 부동소수점 변환에 별도 효과가 없지만, scanf에서는 %f와 %lf의 목적지 포인터형이 실제로 다르다. 입력에서 double *에 %f를 사용하거나 float *에 %lf를 사용하면 형 계약이 깨진다.
문자열 배열에는 보통 &를 붙이지 않는다
char name[10];
scanf("%9s", name); /* 올바른 형태 */
식에서 name은 대부분 첫 원소를 가리키는 char *로 변환되며 %s가 요구하는 목적지와 맞는다. &name은 배열 전체를 가리키는 char (*)[10] 형이므로 같은 주소값처럼 보일 수 있어도 요구 형이 다르다.
반면 스칼라 객체는 주소 연산자가 필요하다.
int age;
scanf("%d", &age);
따라서 “scanf에는 모든 변수 앞에 &를 붙인다”가 아니라 “지정자가 요구하는 목적지 포인터형을 전달한다”가 정확한 규칙이다.
문자열 입력 폭은 버퍼보다 1 작게
%s는 공백이 아닌 문자들을 읽은 뒤 종료 널 문자를 추가한다. 배열 용량이 10이면 최대 9문자만 읽도록 제한해야 한다.
char word[10];
if (scanf("%9s", word) == 1) {
printf("%s\n", word);
}
scanf의 필드 폭은 읽을 최대 입력 문자 수이다. %9s는 최대 9문자를 읽고, 배열의 마지막 한 칸은 \0에 사용한다. 폭이 없는 %s는 긴 입력으로 배열 범위를 넘길 수 있다.
공백 처리 규칙
대부분의 숫자·문자열 변환은 앞의 공백을 건너뛴다. 그러나 %c, %[, %n은 일반적인 공백 건너뛰기를 하지 않는다.
char ch;
scanf(" %c", &ch);
서식 문자열의 앞 공백은 입력에서 이어지는 공백 문자를 소비한 뒤 다음 비공백 문자를 %c가 읽게 한다. 반대로 scanf("%c", &ch)는 바로 다음 문자가 개행이면 그 개행을 읽는다.
서식 문자열 끝의 공백이나 개행은 “개행 문자 하나만 읽는다”는 뜻이 아니다. 이어지는 모든 공백을 소비하고 다음 비공백 문자를 기다릴 수 있으므로 대화형 입력에서 호출이 멈춘 것처럼 보일 수 있다.
반환값과 범위를 함께 확인한다
int x;
int y;
if (scanf("%d %d", &x, &y) != 2) {
/* 두 정수를 모두 읽지 못함 */
}
반환값 2는 두 객체에 대입했다는 뜻이다. 반환값이 1이면 첫 번째 값만 저장되었을 수 있고, 0이면 첫 변환부터 입력이 맞지 않았을 수 있다.
또한 변환된 값이 목적지 형으로 표현될 수 없는 경우도 안전하다고 가정하면 안 된다. 입력 범위까지 엄격히 검증해야 하는 프로그램에서는 한 줄을 fgets로 받은 뒤 strtol 같은 변환 함수로 문법·범위·남은 문자를 분리해 확인하는 방식이 유리하다.
줄 입력, 문자 입력, EOF
fgets는 배열 용량을 기준으로 읽는다
char line[32];
if (fgets(line, sizeof line, stdin) != NULL) {
/* line 사용 */
}
fgets의 핵심 규칙은 다음과 같다.
- 최대
n - 1개의 문자를 배열에 저장한다. - 그 전에 개행을 만나면 개행까지 저장하고 멈춘다.
- 읽은 내용 뒤에 종료 널 문자를 기록한다.
- 아무 문자도 읽기 전에 입력 끝이나 오류가 발생하면 널 포인터를 반환할 수 있다.
따라서 fgets로 읽은 문자열 끝에는 개행이 남아 있을 수 있다. 줄 단위 비교나 파싱 전에 개행 포함 여부를 의도적으로 처리해야 한다.
scanf와 fgets를 섞을 때 남은 개행
int age;
char line[32];
scanf("%d", &age);
fgets(line, sizeof line, stdin);
사용자가 20 뒤에 Enter를 누르면 %d는 숫자까지만 읽고 개행을 남길 수 있다. 이어지는 fgets는 그 개행만 읽어 빈 줄처럼 보일 수 있다. 해결 방법은 다음 중 하나를 명확히 선택하는 것이다.
- 입력 전체를
fgets로 받고 별도 변환한다. - 토큰 입력 뒤 남은 줄을 의도적으로 소비한다.
- 토큰 단위와 줄 단위 입력을 무계획하게 섞지 않는다.
fflush(stdin)으로 입력 버퍼를 비우는 방식은 ISO C가 일반 입력 스트림에 대해 보장하는 사용법이 아니다. 특정 구현의 확장 동작을 이식 가능한 규칙으로 외우지 않는다.
문자 입력 결과는 int에 저장한다
int ch;
while ((ch = getchar()) != EOF) {
putchar(ch);
}
getchar의 반환값을 char에 저장하면 모든 실제 문자 값과 EOF를 구분하지 못할 수 있다. 먼저 int로 받고 EOF와 비교한 뒤, 필요한 경우에만 문자로 다룬다.
feof는 미래를 예측하지 않는다
다음 형태는 잘못된 반복 패턴이다.
while (!feof(stdin)) {
ch = getchar();
/* ch 사용 */
}
입력 끝 표시자는 읽기 시도가 끝을 만난 뒤 설정된다. 따라서 “끝이 아니면 읽는다”가 아니라 “읽기를 시도하고 성공한 동안 처리한다”가 올바른 순서다.
while ((ch = getchar()) != EOF) {
putchar(ch);
}
if (ferror(stdin)) {
/* 입력 오류 */
}
EOF 반환만으로 실제 입력 끝과 읽기 오류를 항상 구분할 수 없으므로 필요하면 feof와 ferror로 상태를 확인한다.
변수의 범위와 객체의 수명
범위(scope)는 이름을 사용할 수 있는 코드 영역이고 수명(lifetime)은 객체가 존재하는 기간이다. 둘은 같은 기준이 아니다.
| 구분 | 기본적인 성질 | 초기화하지 않았을 때 |
|---|---|---|
| 일반 자동 지역 변수 | 보통 해당 블록 실행 동안 존재 | 0이라고 가정할 수 없음 |
| static 지역 변수 | 이름의 범위는 블록이지만 값은 호출 사이에 유지 | 정적 저장 기간이므로 0에 해당하는 값으로 초기화 |
| 전역 변수 | 함수 밖에 선언하며 프로그램 실행 동안 존재 | 정적 저장 기간이므로 0에 해당하는 값으로 초기화 |
| 동적 할당 객체 | 할당 성공부터 해제까지 존재 | 사용하는 할당 함수에 따라 다름 |
파일 범위의 static은 다른 번역 단위에서 그 이름을 참조하지 못하게 하는 내부 연결과 관련된다. extern은 다른 위치에 정의된 객체 등을 선언할 때 사용한다. static 지역 변수의 호출 간 값 유지와 파일 범위 static의 이름 연결 제한을 혼동하지 않는다.
동적 메모리의 기본
malloc은 요청한 바이트 수의 공간을 할당하며 그 내용을 자동으로 0으로 만들지 않는다. calloc은 요청한 공간의 비트를 0으로 초기화한다. 할당 실패 시 반환되는 널 포인터를 확인해야 한다. realloc은 기존 할당의 크기를 변경하는 함수이고, free는 할당받은 공간을 해제한다.
해제한 공간을 계속 가리키는 포인터를 댕글링 포인터라고 한다. 같은 공간을 두 번 해제하거나 해제 후 접근하는 것은 잘못된 동작이다. 더 이상 참조할 수 없는 할당 공간을 해제하지 못하면 메모리 누수가 생길 수 있다.
부작용과 결과를 계산할 수 없는 코드
대입과 증가·감소는 변수의 상태를 바꾸는 부작용이다. ++i는 증가한 값을 사용하고 i++는 증가 전 값을 식의 값으로 사용한다. 다만 한 식에서 같은 변수를 여러 번 변경할 때는 이 설명만으로 특정 결과를 정하면 안 된다.
연산자 우선순위는 식의 묶임을 정할 뿐 모든 피연산자의 평가 순서를 정하지 않는다. 예를 들어 C에서 i = i++ + 1이나 f(i++, i++)처럼 서로 순서가 정해지지 않은 변경이 충돌하는 식은 정해진 출력값을 가정해서는 안 된다.
또한 배열 경계 밖 접근, 잘못된 포인터 역참조, 서식과 인자형 불일치, signed 정수 오버플로 등은 정의되지 않은 동작을 일으킬 수 있다. 한 번 실행해 특정 값이 나왔다고 C 언어가 그 결과를 보장하는 것은 아니다.
반면 &&와 ||의 단락 평가는 왼쪽 결과에 따라 오른쪽 평가를 생략한다. 아래처럼 문장을 분리하면 변경 순서를 명확히 읽을 수 있다.
int i = 2;
int old = i++;
int next = ++i;
/* old는 2, next는 4, i는 4 */
할당 실패와 해제 후 상태
free(NULL)은 아무 작업도 하지 않는 유효한 호출이다. 그러나 이미 해제한 주소를 다시 free하는 것과는 다르다. free(p)가 호출자의 포인터 변수 p를 자동으로 NULL로 바꾸지는 않는다. 다른 별칭 포인터도 자동으로 바뀌지 않는다.
양의 새 크기로 realloc(p, n)을 호출했는데 실패하여 NULL을 반환했다면, 기존 할당은 유지된다. 결과를 임시 포인터로 받은 뒤 성공한 경우에만 p를 갱신해야 기존 주소를 잃지 않는다. 성공하면 이전 포인터값을 계속 사용하는 대신 반환된 포인터를 사용한다. 크기 0의 특수한 호출을 이 규칙의 예제로 사용하지 않는다.
malloc 성공은 공간 확보이지 내용 초기화가 아니다. 값을 쓰기 전 그 내용이 0일 것이라고 가정하지 않는다. 정적 지역 변수는 함수가 끝나도 저장 기간이 유지되지만, 일반 자동 지역 변수의 주소를 반환해 그 수명 종료 뒤 사용하면 안 된다.