C 언어 기초: 첫 프로그램, 자료형, printf, 경고 읽기
C 강좌 1. Apple clang 21 로 직접 실행: 5분 만에 컴파일, -Wall -Wextra 경고 읽기, int 크기와 오버플로, printf 서식 실수, 정수 나눗셈.
시리즈: C 강좌 1/6 (초급). C 프로그램을 한 번도 컴파일해 본 적 없는 분, 또는 학교나 국비 과정에서 잠깐 배우고 회사에서 처음 진짜 C 코드를 마주한 분을 위한 글입니다. 다 읽으면 터미널에서 빌드하고 실행하며, clang 경고를 무시하지 않고 읽고, 숫자에서 생기는 세 가지 고전 버그(printf 서식 실수, 정수 오버플로, 정수 나눗셈)를 피할 수 있습니다.
정보처리기사 실기를 준비해 봤다면 "이 C 코드의 출력은?" 문제에서 7 / 2 나 %d 에 속아 본 경험이 있을 겁니다. 실무에서는 같은 함정이 더 조용하게 나타납니다. 프로그램은 잘 돌고, 숫자만 틀립니다. 이 글은 그런 경우를 실제로 컴파일해서 보여 줍니다. 모든 결과는 Apple Silicon Mac(M1 Pro), macOS 26, Apple clang 21.0.0 에서 직접 실행한 것이고, 출력 속 경로만 ~/demo/ 로 바꿨습니다.
5분 만에 첫 프로그램
Mac 에서는 Xcode Command Line Tools 에 C 컴파일러가 들어 있습니다. 먼저 확인합니다.
$ clang --version
Apple clang version 21.0.0 (clang-2100.4.1.1)
Target: arm64-apple-darwin25.6.0
Thread model: posix
(리눅스라면 패키지 관리자로 gcc 나 clang 을 설치합니다. 플래그는 같습니다. 여기서는 실행하지 않았습니다.)
hello.c 를 만듭니다.
#include <stdio.h>
int main(void) {
printf("Hello, C!\n");
return 0;
}
#include <stdio.h> 는 printf 가 어떻게 생겼는지(선언) 적힌 헤더 파일을 가져오라는 뜻입니다. main 은 프로그램의 시작점, \n 은 줄바꿈, return 0 은 셸에 "성공"을 돌려줍니다.
$ clang -std=c17 -Wall -Wextra -o hello hello.c
$ ./hello
Hello, C!
-std=c17 은 언어 판(C17)을 고르는 옵션, -Wall -Wextra 는 초보자 버그 대부분을 잡는 경고를 켜는 옵션, -o hello 는 출력 파일 이름입니다. -std 를 빼면 Apple clang 21 은 gnu17(C17 + GNU 확장)로 동작합니다. __STDC_VERSION__ 을 찍어 201710L 인 것을 확인했습니다.
"컴파일"은 사실 네 단계
clang 한 번에 네 가지 도구가 차례로 돕니다. -save-temps 로 중간 파일을 남겨 보면 이렇습니다.
$ clang -std=c17 -save-temps -o hello hello.c
$ ls -l # 크기와 이름
80 hello.c
31553 hello.i # 전처리 후: 572 줄
949 hello.s # 어셈블리
736 hello.o # 오브젝트 파일
33432 hello # 실행 파일
80 바이트짜리 소스가 전처리기가 stdio.h 를 펼친 뒤 572 줄이 됩니다. 이어서 컴파일러가 어셈블리로, 어셈블러가 기계어(hello.o)로, 링커가 C 라이브러리와 묶어 실행 파일로 만듭니다. 링크 오류(undefined symbol)와 컴파일 오류가 다르게 생긴 이유가 이 단계 차이입니다.
누구나 처음 만나는 오류 두 개
세미콜론을 빠뜨렸을 때:
nosemi.c:4:26: error: expected ';' after expression
4 | printf("Hello, C!\n")
| ^
| ;
1 error generated.
4:26 은 4번째 줄 26번째 칸입니다. 오류가 하나라도 있으면 실행 파일이 만들어지지 않습니다.
#include <stdio.h> 를 빠뜨렸을 때:
noinclude.c:2:5: error: call to undeclared library function 'printf' with type 'int (const char *, ...)'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration]
noinclude.c:2:5: note: include the header <stdio.h> or explicitly provide a declaration for 'printf'
오래된 교재에는 "경고만 나고 실행된다"고 적혀 있기도 합니다. 암묵적 함수 선언은 C99 에서 언어에서 빠졌고, Apple clang 21 에서는 오류입니다. 블로그에서 복사한 옛날 예제가 컴파일되지 않으면 이것부터 의심하세요.
경고는 내 편: 파일 하나에 버그 다섯 개
경고는 빌드를 멈추지 않습니다. 그래서 더 위험합니다. 아래 파일은 컴파일이 됩니다.
#include <stdio.h>
int main(void) {
int count = 3;
int unused = 42;
double price = 19.99;
long big = 5000000000L;
int total;
printf("count=%d price=%d\n", count, price); /* wrong: %d for double */
printf("big=%d\n", big); /* wrong: %d for long */
printf("name=%s\n", count); /* wrong: %s for int */
if (count > 2)
total = count * 10;
printf("total=%d\n", total); /* maybe uninitialized */
return 0;
}
-Wall -Wextra 를 켜면 다섯 개를 모두 짚어 줍니다(줄임).
warn.c:10:42: warning: format specifies type 'int' but the argument has type 'double' [-Wformat]
warn.c:11:24: warning: format specifies type 'int' but the argument has type 'long' [-Wformat]
warn.c:12:25: warning: format specifies type 'char *' but the argument has type 'int' [-Wformat]
warn.c:5:9: warning: unused variable 'unused' [-Wunused-variable]
warn.c:13:9: warning: variable 'total' is used uninitialized whenever 'if' condition is false [-Wsometimes-uninitialized]
5 warnings generated.
대괄호 안의 -Wformat 같은 이름이 그 경고를 켜고 끄는 플래그이고, 검색할 때 쓰는 키워드입니다. 경고를 무시하고 터미널에서 실행하면:
count=3 price=-1546188227
big=705032704
Segmentation fault: 11 (종료 코드 139)
price=-1546188227: arm64 macOS 에서는 printf 의 가변 인자가 8 바이트 칸에 나란히 놓이고,%d는 19.99 가 든 칸의 아래 4 바이트를 int 로 읽었습니다. 19.99 의 double 비트 패턴은0x4033FD70A3D70A3D, 아래 절반0xA3D70A3D를 부호 있는 int 로 읽으면 정확히 -1546188227 입니다(Pythonstruct로 확인). 세 번 돌려 세 번 다 같은 값이었습니다. 무작위가 아니라서 오히려 "멀쩡히 돈다"고 착각하게 됩니다.big=705032704: 50 억의 아래 32 비트. 50 억 − 2³² 와 정확히 같습니다.%s에 int: 숫자 3 을 주소로 보고 읽으러 갔다가 죽었습니다. 세그멘테이션 오류(segmentation fault)는 접근하면 안 되는 메모리를 건드렸다는 뜻이고, 종료 코드 139 는 128 + 시그널 11 입니다.
함정 하나 더. 같은 프로그램을 ./warn | cat 처럼 파이프로 연결하자 크래시 전 두 줄조차 아무것도 출력되지 않았습니다. 출력이 파이프나 파일로 갈 때 stdout 은 블록 단위로 버퍼링되고, 크래시가 나면 버퍼째 사라집니다. "아무것도 안 찍혔으니 첫 printf 전에 죽었다"고 단정하면 안 됩니다.
엄격하게 하고 싶다면 -Werror 로 경고를 오류로 바꿉니다. 이 파일은 5 errors generated. 가 되고 실행 파일이 생기지 않았습니다.
printf 서식 정리표
| 값의 타입 | 서식 | 예 |
|---|---|---|
int |
%d |
42 |
unsigned int |
%u |
UINT_MAX |
long / long long |
%ld / %lld |
5000000000L |
double (float 도 승격되어 같음) |
%f, %.2f |
19.99 |
size_t (sizeof 결과) |
%zu |
sizeof(int) |
문자열 char * |
%s |
"Minsu" |
| 포인터 | %p (void * 로 캐스트) |
&x |
% 문자 자체 |
%% |
"100%%" |
int 는 몇 바이트? 짐작 말고 재기
char 1 byte(s)
short 2
int 4 range -2147483648 .. 2147483647
long 8
long long 8
float 4
double 8
void* 8
int32_t 4 int64_t 8
char is signed
sizeof 는 바이트 수를 돌려줍니다. C 표준은 최솟값만 정해 두기 때문에, long 은 이 Mac 과 64 비트 리눅스에서는 8 바이트지만 64 비트 Windows 에서는 4 바이트입니다(Windows 는 실행하지 않음). 파일 형식이나 통신 프로토콜처럼 폭이 중요할 때는 <stdint.h>(C99)의 int32_t, int64_t 를 씁니다.
오버플로: 부호 없는 쪽엔 규칙이 있고, 부호 있는 쪽엔 없다
#include <stdio.h>
#include <limits.h>
int main(void) {
unsigned int u = UINT_MAX;
u = u + 1; /* defined: wraps to 0 */
printf("UINT_MAX + 1 = %u\n", u);
int i = INT_MAX;
i = i + 1; /* undefined behaviour */
printf("INT_MAX + 1 = %d\n", i);
int a = 100000, b = 100000;
long long wrong = a * b; /* multiplied as int first */
long long right = (long long)a * b;
printf("wrong = %lld\nright = %lld\n", wrong, right);
return 0;
}
UINT_MAX + 1 = 0
INT_MAX + 1 = -2147483648
wrong = 1410065408
right = 10000000000
-Wall -Wextra 로도 경고가 나오지 않습니다. 부호 없는 정수는 정의대로 0 으로 돌아갑니다. 부호 있는 정수의 오버플로는 정의되지 않은 동작(undefined behavior, UB)이라 표준이 결과를 보장하지 않고, 최적화기는 "이런 일은 없다"고 가정하고 코드를 바꿔도 됩니다(5강에서 -O0 과 -O2 결과가 갈리는 예를 봅니다). wrong 줄이 실무에서 가장 흔한 형태입니다. 받는 변수가 long long 이어도 a * b 는 대입 전에 int 로 계산됩니다.
UndefinedBehaviorSanitizer 를 붙이면 실행 중에 줄 번호까지 잡아 줍니다.
$ clang -std=c17 -Wall -Wextra -fsanitize=undefined -g -o overflow_ub overflow.c
$ ./overflow_ub
overflow.c:10:11: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
overflow.c:14:25: runtime error: signed integer overflow: 100000 * 100000 cannot be represented in type 'int'
정수 나눗셈, 그리고 늘 틀리는 평균
7 / 2 = 3
7 / 2.0 = 3.500000
-7 / 2 = -3
-7 % 2 = -1
avg_bad = 3.00
avg_ok = 3.50
0.1 + 0.2 = 0.30000000000000004
C99 부터 정수 나눗셈은 0 쪽으로 버립니다. -7 / 2 는 -3, 나머지 -7 % 2 는 왼쪽 피연산자의 부호를 따라 -1 입니다. 정보처리기사 실기에 자주 나오는 부분이기도 합니다. 평균 버그는 이렇게 고칩니다.
int sum = 7, n = 2;
double avg_bad = sum / n; /* 3.00: int 끼리 먼저 나눔 */
double avg_ok = (double)sum / n; /* 3.50 */
흔한 실수 → 실제로 일어난 일 → 해결
| 실수 | 실제로 본 것 | 해결 |
|---|---|---|
| "돌아가니까" 경고 무시 | 이상한 숫자 뒤 종료 코드 139 | -Wall -Wextra 경고를 모두 고치고 CI 는 -Werror |
long·double·sizeof 에 %d |
705032704, -1546188227 |
정리표대로. sizeof 는 %zu |
int 끼리 long long x = a * b; |
100 억 대신 1410065408 |
먼저 캐스트: (long long)a * b |
double avg = sum / n; |
3.50 대신 3.00 |
(double)sum / n |
long 은 어디서나 8 바이트라고 가정 |
Mac/리눅스에선 되고 Windows 에서 깨짐 | 폭이 중요하면 int64_t |
작은 실측: -O0 과 -O2
-O0(기본값)은 코드를 거의 그대로 옮겨 디버깅이 쉽고, -O2 는 최적화를 허용합니다. 루프 부분만 쟀고, 난수 시드는 srand(42) 로 고정, 각 7 회 실행의 중앙값입니다.
srand(42); /* fixed seed */
for (int i = 0; i < N; i++) a[i] = rand() % 100;
/* 여기부터 측정 */
for (int i = 0; i < N; i++) sum += a[i]; /* N = 50,000,000 */
| 루프 | -O0 | -O2 | 배율 |
|---|---|---|---|
| int 5,000 만 개 합 | 55.1 ms | 9.4 ms | 5.9 배 |
sum += (i*i) % 7 3 억 번 |
284.0 ms | 159.7 ms | 1.8 배 |
배열 합은 clang 이 벡터 명령(int 4 개를 한 번에)으로 바꿔 크게 빨라졌습니다. % 7 루프는 나눗셈이 병목이라 최적화로도 일부만 줄었습니다. -O2 배열 합은 8.4~20.2 ms 로 흔들렸으니, 한 번 잰 값보다 중앙값을 봐야 합니다. 개발할 때는 -O0 -g 에 경고와 새니타이저, 배포는 -O2, 그리고 -O2 로도 테스트하는 것이 기본입니다.
솔직한 한계와 버전 메모
- 컴파일·실행은 arm64 macOS 26 의 Apple clang 21.0.0 에서만 했습니다. GCC, MSVC, 리눅스/Windows 의 타입 크기는 설명만 했고 실행하지 않았습니다.
- 암묵적 함수 선언 삭제는 C99. Apple clang 21 은 오류로 처리하고, GCC 도 14 부터 기본이 오류입니다(GCC 는 실행하지 않음).
%zu,long long,<stdint.h>,/의 0 쪽 버림은 C99 부터입니다. 본문은-std=c17입니다. C23(clang 21 의-std=c23,__STDC_VERSION__202311L)에서nullptr, 키워드bool,[[fallthrough]]가 추가됐습니다.- double 을
%d로 찍었을 때의 값은 호출 규약에 따라 다릅니다. x86-64 리눅스에서는 double 이 부동소수점 레지스터로 전달되어 전혀 다른 값이 나옵니다(실행하지 않음). 버그인 것은 같습니다. - 시간은 한 대에서 잰 값입니다. 밀리초보다 배율을 보세요.
연습 문제
1. 출력을 예상한 뒤 실행해 보세요.
printf("%d %d %d %d %.1f\n", 17 / 5, 17 % 5, -17 / 5, -17 % 5, 17 / 5.0);
2. 아래 줄은 경고와 함께 컴파일됩니다. 어떤 경고이고 어떻게 고칠까요?
printf("int is %d bytes\n", sizeof(int));
3. 아래 프로그램은 1215752192 를 출력합니다. 왜일까요? 올바른 마이크로초 값을 얻으려면?
int seconds = 100000;
int us = seconds * 1000 * 1000;
printf("%d\n", us);
정답
1. 실제 출력은 3 2 -3 -2 3.4. 0 쪽으로 버리므로 -17 / 5 는 -3, 나머지는 왼쪽 부호를 따라 -17 % 5 는 -2 입니다(Python 의 -17 // 5 는 -4 라서 여기서 다릅니다).
2. warning: format specifies type 'int' but the argument has type 'unsigned long' [-Wformat]. sizeof 의 타입은 size_t 이므로 %zu 를 씁니다. printf("int is %zu bytes\n", sizeof(int)); 로 고치면 경고 없이 int is 4 bytes 가 나옵니다.
3. seconds * 1000 에서 이미 1 억이고, 다시 * 1000 하며 int 를 넘칩니다. -fsanitize=undefined 로 빌드하면 ex3.c:4:29: runtime error: signed integer overflow: 100000000 * 1000 cannot be represented in type 'int' 가 나옵니다. long long us = (long long)seconds * 1000 * 1000; 으로 바꾸고 %lld 로 찍으면 100000000000 입니다.
다음 강의
2강은 if, 반복문, 함수, 배열입니다. 같은 off-by-one 버그가 빌드 방법에 따라 세 가지 다른 결과를 내다가, AddressSanitizer 가 정확한 줄을 짚어 내는 과정을 봅니다.
- 다음: C 강좌 2/6: 제어문·함수·배열
- 이어지는 강의: 포인터와 malloc, 문자열·구조체·버퍼 오버플로, 정의되지 않은 동작과 최적화, 캐시·make·lldb
- 다른 언어와 함께 보는 순서: 프로그래밍 학습 로드맵
자주 묻는 질문
printf 에서 double 을 %d 로 찍으면 왜 이상한 숫자가 나오나요?
printf 는 인자의 실제 타입이 아니라 서식 문자열을 믿기 때문입니다. 19.99 를 %d 로 넘기자 -1546188227 이 나왔는데, 이는 double 비트 패턴 0x4033FD70A3D70A3D 의 아래 32 비트입니다. clang 은 -Wformat 으로 "format specifies type 'int' but the argument has type 'double'" 경고를 냅니다. double 은 %f, long 은 %ld, sizeof 결과는 %zu 를 씁니다.
C 에서 7 / 2 가 왜 3 인가요?
양쪽이 모두 int 라서 정수 나눗셈이 되고, 0 쪽으로 버림하기 때문입니다(-7 / 2 는 -3). 한쪽을 먼저 실수로 만들어야 합니다. 7 / 2.0 이나 (double)sum / n 은 3.5 입니다. double avg = sum / n 처럼 나눈 뒤에 바꾸면 이미 늦습니다.
int 가 오버플로하면 어떻게 되나요?
부호 없는 정수는 규칙대로 한 바퀴 돕니다(UINT_MAX + 1 은 0). 부호 있는 정수의 오버플로는 정의되지 않은 동작이라, 이 Mac 에서는 INT_MAX + 1 이 -2147483648 이었지만 무슨 일이 일어나도 이상하지 않습니다. -fsanitize=undefined 로 빌드하면 "runtime error: signed integer overflow" 를 줄 번호와 함께 알려 줍니다.