컴퓨터프로그래밍I

경기과학고등학교 2026 직보 2주

김지수T

복습: C 기초

변수와 타입

변수는 값을 담는 이름 붙은 공간이다.

int age = 20;        // 정수
double pi = 3.14;    // 실수 (배정밀도)
float f = 1.5f;      // 실수 (단정밀도)
char grade = 'A';    // 문자 (1바이트)

선언하면서 바로 초기화하는 게 좋다. 초기화하지 않으면 쓰레기값이 들어 있다.

int x;          // 쓰레기값 - 무엇이 들어있는지 알 수 없다
int y = 0;      // 안전

printf와 scanf

printf는 출력, scanf는 입력이다.

int n;
printf("숫자를 입력하세요: ");
scanf("%d", &n);        // &n: n의 주소를 넘긴다
printf("입력한 값: %d\n", n);

scanf에서 &를 빠뜨리면 런타임 오류가 난다. &는 변수의 메모리 주소를 넘겨서 scanf가 그 위치에 값을 쓸 수 있게 한다.

printf 기본 포맷 지정자

지정자 의미 예시
%d 정수 (10진수) printf("%d", 42);42
%f 실수 printf("%f", 3.14);3.140000
%c 문자 printf("%c", 'A');A
%s 문자열 printf("%s", "Hi");Hi
%% % 문자 자체 printf("%%");%

산술 연산과 우선순위

산술 연산자

int a = 10, b = 3;
printf("%d\n", a + b);   // 13  (덧셈)
printf("%d\n", a - b);   // 7   (뺄셈)
printf("%d\n", a * b);   // 30  (곱셈)
printf("%d\n", a / b);   // 3   (정수 나눗셈, 소수부 버림)
printf("%d\n", a % b);   // 1   (나머지)

정수끼리 나누면 정수가 나온다. 10 / 3 = 3이지 3.333...이 아니다.

(여담) 나머지 연산의 쓸모

나머지 연산 %는 생각보다 자주 쓴다.

if (year % 4 == 0)   // 4의 배수인지 (윤년 판정의 첫 단계)
int last_digit = n % 10;   // 일의 자리 뽑기
int idx = i % SIZE;         // 배열을 원형으로 순회

홀짝 판별 n % 2, 자릿수 분해 n % 10, 범위 제한 n % MAX 같은 패턴은 수시로 나온다. 음수에 %를 쓰면 결과의 부호가 구현마다 다를 수 있으니 주의한다. C99부터는 피제수(왼쪽)의 부호를 따른다: -7 % 3 = -1.

연산자 우선순위

수학에서처럼 곱셈/나눗셈이 덧셈/뺄셈보다 먼저 계산된다.

int result = 2 + 3 * 4;    // 14 (3*4=12, 2+12=14)
int result2 = (2 + 3) * 4; // 20 (괄호 먼저)
우선순위 연산자 결합 방향
높음 () 왼→오
*, /, % 왼→오
+, - 왼→오
<, <=, >, >= 왼→오
==, != 왼→오
&& 왼→오
낮음 \|\| 왼→오

헷갈리면 괄호를 쓰는 게 가장 안전하다.

전위/후위 증감 연산자

int x = 5;
printf("%d\n", x++);  // 5 (현재 값 쓰고 나서 증가)
printf("%d\n", x);     // 6
printf("%d\n", ++x);  // 7 (먼저 증가하고 나서 사용)
  • x++ (후위): 현재 값을 먼저 돌려주고 나중에 1 증가
  • ++x (전위): 먼저 1 증가시키고 증가된 값을 돌려줌

단독으로 x++;++x;를 쓸 때는 차이가 없다. 다른 식 안에서 쓸 때만 다르다.

비교연산자와 논리연산자

비교연산자

비교 결과는 참(1) 또는 거짓(0)이다.

int a = 5, b = 10;
printf("%d\n", a > b);    // 0 (거짓)
printf("%d\n", a < b);    // 1 (참)
printf("%d\n", a == b);   // 0 (같은지)
printf("%d\n", a != b);   // 1 (다른지)
printf("%d\n", a >= 5);   // 1 (이상)
printf("%d\n", a <= 4);   // 0 (이하)

=는 대입, ==는 비교이다. 혼동하면 찾기 어려운 버그가 생긴다.

if (x = 5)   // 대입 - 항상 참 (버그)
if (x == 5)  // 비교 - 올바른 코드

논리연산자

연산자 의미 예시
&& AND (둘 다 참) (a > 0 && a < 10)
\|\| OR (하나라도 참) (a == 0 \|\| b == 0)
! NOT (반전) (!done)

단축 평가

&&||는 왼쪽만으로 결과가 정해지면 오른쪽을 아예 실행하지 않는다.

int a = 0, b = 5;
if (a != 0 && ++b > 5) {
    printf("YES\n");
}
printf("b=%d\n", b);  // b=5 (++b가 실행 안 됨)
  • &&: 왼쪽이 거짓이면 오른쪽을 건너뛴다 (어차피 거짓)
  • ||: 왼쪽이 참이면 오른쪽을 건너뛴다 (어차피 참)

부수 효과(side effect)가 있는 식을 &&/|| 오른쪽에 넣으면 실행 여부가 달라지므로 주의해야 한다.

(여담) 단축 평가 활용 패턴

단축 평가는 null 검사에서 자주 쓴다.

// p가 NULL이 아닐 때만 *p에 접근
if (p != NULL && *p > 0) { ... }

// 배열 범위를 먼저 검사하고 나서 접근
if (i >= 0 && i < n && arr[i] == target) { ... }

왼쪽 조건이 거짓이면 오른쪽을 건너뛰므로, p가 NULL일 때 *p를 읽는 사고를 막을 수 있다. Java, Python, JavaScript 등에서도 같은 방식으로 동작한다.

타입캐스팅

자동 형변환

C는 필요하면 알아서 타입을 바꾼다.

int a = 7, b = 2;
double result = a / b;     // 3.0 (정수 나눗셈 후 double로 변환)

정수끼리 나누면 정수 결과가 나온다. 7 / 2 = 3이 되고, 그 3을 double에 넣으면 3.0이다.

명시적 형변환 (캐스트)

(타입) 캐스트로 원하는 타입으로 바꿀 수 있다.

int a = 7, b = 2;
double result = (double)a / b;  // 3.5

(double)aa7.0으로 바꾸면, 7.0 / 2는 실수 나눗셈이 되어 3.5가 나온다.

printf("%.1f %d\n", (double)a / b, a / b);
// 출력: 3.5 3

%.1f는 소수점 아래 1자리까지 출력한다.

타입 승격 규칙

서로 다른 타입끼리 연산하면, 더 넓은 타입 쪽으로 맞춰진다.

char → int → long → float → double
char c = 'A';           // 65
int n = c + 1;          // 66 (char가 int로 올라감)
double d = n + 0.5;     // 66.5 (int가 double로 올라감)

작은 타입에서 큰 타입으로 가면 안전하다. 반대 방향은 값이 잘릴 수 있다.

int big = 300;
char small = (char)big;  // 44 (300 % 256 = 44, 데이터 손실)

(여담) 타입 변환이 만드는 버그

unsigned int len = 0;
if (len - 1 > 0) {
    printf("이 코드는 실행될까?\n");  // 실행된다
}

len - 1unsigned int에서 계산되므로 0 - 1 = 4294967295가 된다. 양수이므로 조건이 참이다. unsigned 변수를 쓸 때는 0 근처에서 빼기 연산을 하면 안 된다. 실제 코드에서 이런 버그가 꽤 많다.

printf 형식 지정자 심화

%d, %o, %x, %X

같은 정수를 다른 진법으로 출력할 수 있다.

int n = 171;
printf("%d\n", n);   // 171  (10진수)
printf("%o\n", n);   // 253  (8진수)
printf("%x\n", n);   // ab   (16진수 소문자)
printf("%X\n", n);   // AB   (16진수 대문자)
지정자 진법 접두사 출력
%d 10진수 없음
%o 8진수 %#o0253
%x 16진수 (소문자) %#x0xab
%X 16진수 (대문자) %#X0XAB

(여담) 왜 16진수를 쓰나?

2진수를 그대로 쓰면 너무 길다. 16진수 한 자리가 2진수 4비트에 대응하므로, 비트 패턴을 읽기 편하다.

2진수:  1100 1010 0011 1011
16진수:    C    A    3    B  → 0xCA3B

메모리 주소, 색상 코드(#FF8800), 네트워크 패킷, 파일 포맷 헤더 등에서 16진수를 쓴다. 8진수(%o)는 Unix 파일 권한(chmod 755)에서 주로 쓰인다. 그 밖에는 거의 쓰지 않는다.

진법 변환

10진수 171을 8진수와 16진수로 바꾸는 과정:

8진수: 8로 반복 나누기

171 ÷ 8 = 21 나머지 3
 21 ÷ 8 =  2 나머지 5
  2 ÷ 8 =  0 나머지 2
→ 아래에서 위로 읽으면: 253

16진수: 16으로 반복 나누기

171 ÷ 16 = 10 나머지 11 (= B)
 10 ÷ 16 =  0 나머지 10 (= A)
→ 아래에서 위로 읽으면: AB

printf 폭과 정밀도

printf("[%10d]\n", 42);     // [        42]  (폭 10, 오른쪽 정렬)
printf("[%-10d]\n", 42);    // [42        ]  (폭 10, 왼쪽 정렬)
printf("[%05d]\n", 42);     // [00042]       (0으로 채우기)
printf("[%.3f]\n", 3.14);   // [3.140]       (소수점 3자리)
printf("[%8.2f]\n", 3.14);  // [    3.14]    (전체 폭 8, 소수 2자리)

정수의 내부 표현

비트와 바이트

  • 비트(bit): 0 또는 1, 정보의 최소 단위
  • 바이트(byte): 8비트 = 1바이트
  • 워드(word): CPU가 한 번에 처리하는 단위 (요즘은 보통 64비트)
1바이트 = 8비트
  ┌─┬─┬─┬─┬─┬─┬─┬─┐
  │0│1│0│0│1│0│1│0│  = 74 (10진수)
  └─┴─┴─┴─┴─┴─┴─┴─┘
   7 6 5 4 3 2 1 0  ← 비트 번호

2의 보수

컴퓨터가 음수를 나타내는 방식이다.

양수 → 음수 바꾸기 (3단계)

  1. 양수의 2진수를 쓴다
  2. 모든 비트를 반전한다 (0↔︎1)
  3. 1을 더한다

예: 8비트에서 -42 구하기

 42 = 00101010        ← 1단계
      11010101        ← 2단계 (비트 반전)
    + 00000001        ← 3단계 (+1)
    ──────────
      11010110        ← -42의 2의 보수 = 0xD6

(여담) 1의 보수는 왜 안 쓰나?

1의 보수(비트 반전만)로 음수를 나타내면 0이 두 개 생긴다: +0 = 00000000, -0 = 11111111. 덧셈할 때도 올림(carry)을 다시 더해줘야 해서 회로가 복잡해진다.

2의 보수는 0이 하나뿐이고, 같은 덧셈 회로로 양수/음수를 다 처리할 수 있어서 요즘 CPU는 거의 다 이 방식을 쓴다.

2의 보수: 왜 이렇게 하나?

덧셈 회로 하나로 뺄셈까지 처리할 수 있다.

  42 + (-42) 를 2의 보수로 계산:
    00101010    (42)
  + 11010110    (-42)
  ──────────
  1 00000000    (8비트 범위를 넘으면 버림 → 0)

음수를 다시 양수로 바꿀 때도 같은 절차를 쓴다.

-42 = 11010110
      00101001  (반전)
    + 00000001  (+1)
    ──────────
      00101010  = 42

2의 보수: 해독하기

MSB(최상위 비트)가 1이면 음수이다.

예: 0xE7의 10진수 값

0xE7 = 11100111
MSB = 1 → 음수
  11100111  (원본)
  00011000  (반전)
+ 00000001  (+1)
──────────
  00011001  = 25 → 원래 값은 -25

타입별 크기와 범위

타입 크기 signed 범위 unsigned 범위
char 1바이트 (8비트) -128 ~ 127 0 ~ 255
short 2바이트 (16비트) -32768 ~ 32767 0 ~ 65535
int 4바이트 (32비트) -2^31 ~ 2^31-1 0 ~ 2^32-1
long long 8바이트 (64비트) -2^63 ~ 2^63-1 0 ~ 2^64-1

n비트 정수의 범위:

  • signed: \(-2^{n-1}\) ~ \(2^{n-1}-1\)
  • unsigned: \(0\) ~ \(2^n - 1\)

sizeof 연산자로 크기를 확인할 수 있다.

printf("int: %lu바이트\n", sizeof(int));    // 4
printf("char: %lu바이트\n", sizeof(char));  // 1

overflow와 underflow

표현 범위를 넘으면 값이 돌아간다.

signed char c = 127;
c = c + 1;
printf("%d\n", c);   // -128 (overflow)

unsigned char u = 0;
u = u - 1;
printf("%u\n", u);   // 255 (underflow)
signed char 127 + 1:
  01111111 + 1 = 10000000 = -128 (2의 보수)

unsigned char 0 - 1:
  00000000 - 1 = 11111111 = 255

(여담) overflow가 만든 사고들

  • 1996년 Ariane 5 로켓 폭발: 64비트 실수를 16비트 정수로 변환하다가 overflow. 발사 37초 만에 자폭. 5억 달러짜리 로켓이 날아갔다.
  • 2014년 PSY 강남스타일: YouTube 조회수가 int 최댓값(2,147,483,647)을 넘어서 Google이 조회수 카운터를 long long으로 바꿨다.
  • Pac-Man 256 버그: 레벨 번호가 unsigned char(8비트)로 저장되어 레벨 256에서 overflow가 나고 화면이 깨진다.

signed vs unsigned 비교 함정

unsigned int a = 1;
int b = -1;
if (a > b)
    printf("a가 크다\n");
else
    printf("b가 크다\n");

출력: b가 크다

unsignedsigned를 비교하면, signed 쪽이 unsigned로 바뀐다. -1의 비트 패턴 0xFFFFFFFF를 unsigned로 읽으면 4294967295다. 1 > 4294967295는 거짓이므로 “b가 크다”가 나온다.

비트 연산

비트단위 논리연산

연산자 이름 동작
& AND 둘 다 1이면 1
\| OR 하나라도 1이면 1
^ XOR 서로 다르면 1
~ NOT 비트 반전
unsigned char a = 0xCA;  // 11001010
unsigned char b = 0x3B;  // 00111011

a & b   →  00001010  = 0x0A
a | b   →  11111011  = 0xFB
a ^ b   →  11110001  = 0xF1
~a      →  00110101  = 0x35

비트 시프트

연산자 의미 효과
<< 왼쪽 시프트 ×2
>> 오른쪽 시프트 ÷2
int x = 13;              // 00001101
printf("%d\n", x << 2);  // 00110100 = 52  (×4)
printf("%d\n", x >> 1);  // 00000110 = 6   (÷2, 버림)

왼쪽으로 n비트 시프트 = \(\times 2^n\), 오른쪽으로 n비트 시프트 = \(\div 2^n\) (버림)

int a = 1;
printf("%d\n", a << 3);  // 8  (1 × 2³)
printf("%d\n", a << 10); // 1024  (1 × 2¹⁰)

비트 마스크 활용

특정 비트를 꺼내거나 설정할 때 비트 연산을 쓴다.

// 하위 4비트 꺼내기
unsigned char n = 0xCA;
unsigned char low4 = n & 0x0F;     // 0x0A

// k번째 비트가 1인지 보기
int k = 3;
int bit = (n >> k) & 1;            // 1

// k번째 비트를 1로 켜기
unsigned char set = n | (1 << k);  // 그대로 (이미 1)
동작 코드
k번째 비트 읽기 (n >> k) & 1
k번째 비트 켜기 n \| (1 << k)
k번째 비트 끄기 n & ~(1 << k)
k번째 비트 뒤집기 n ^ (1 << k)

(여담) 비트 연산의 실전

XOR swap: 임시 변수 없이 두 변수를 교환할 수 있다.

a ^= b;  // a = a ^ b
b ^= a;  // b = b ^ (a ^ b) = a
a ^= b;  // a = (a ^ b) ^ a = b

Unix 파일 권한: chmod에서 rwx를 비트로 나타낸다.

// r=4(100), w=2(010), x=1(001)
int perm = 0755;  // 소유자 rwx(7), 그룹 r-x(5), 기타 r-x(5)
if (perm & 0x04)  // 읽기 권한 있는지 검사

네트워크 프로그래밍에서도 IP 주소 마스킹, 체크섬 계산 등에 비트 연산을 많이 쓴다.

(여담) XOR의 성질

XOR은 재미있는 성질이 있다.

  • a ^ a = 0 (같은 값끼리 XOR하면 0)
  • a ^ 0 = a (0과 XOR하면 원래 값)
  • 교환법칙, 결합법칙이 성립한다
// 배열에서 하나만 홀수 번 나오는 원소 찾기
int arr[] = {3, 5, 3, 7, 5};
int result = 0;
for (int i = 0; i < 5; i++)
    result ^= arr[i];
// result = 7 (짝수 번 나온 것은 상쇄되고 홀수 번 나온 7만 남는다)

조건문

if / else if / else

위에서 아래로 조건을 보고, 처음 참인 블록만 실행한다.

int x = 75;
if (x >= 90)
    printf("A\n");
else if (x >= 80)
    printf("B\n");
else if (x >= 70)
    printf("C\n");    // ← 여기만 실행
else
    printf("F\n");

x = 75x >= 70에 걸리므로 "C"가 나온다. 그 아래는 검사하지 않는다.

switch / case

정수 값에 따라 분기할 때 쓴다. break가 없으면 아래로 떨어진다(fall-through).

int n = 2;
switch (n) {
    case 1: printf("A");
    case 2: printf("B");          // ← 여기서 시작
    case 3: printf("C"); break;   // fall-through 후 break
    case 4: printf("D");
    default: printf("E");
}
// 출력: BC

case 2에서 시작해서 break를 만날 때까지 계속 실행된다.

switch: 의도적 fall-through

여러 값을 같은 동작으로 묶을 때 쓸 수 있다.

int month = 4;
switch (month) {
    case 2:
        days = 28; break;
    case 4: case 6: case 9: case 11:   // 4, 6, 9, 11월
        days = 30; break;
    default:
        days = 31; break;
}

dangling else

else는 들여쓰기와 관계없이 가장 가까운 if와 짝이 된다.

int a = 1, b = 0;
if (a)
    if (b)
        printf("X\n");
else                    // if (b)의 else다 (if (a)의 else가 아님)
    printf("Y\n");
printf("Z\n");
// 출력: Y Z

의도대로 짝을 맞추려면 중괄호 {}를 쓴다.

if (a) {
    if (b) printf("X\n");
} else {
    printf("Y\n");     // 이제 if (a)의 else
}

반복문

for 문

//    초기화;    조건;    증감
for (int i = 0; i < 5; i++) {
    printf("%d ", i);
}
// 출력: 0 1 2 3 4

실행 순서: 초기화 → 조건 → 본문 → 증감 → 조건 → …

while 문과 do-while 문

// while: 조건을 먼저 본다
int x = 5;
while (x > 0) {
    printf("%d ", x);
    x--;
}
// 출력: 5 4 3 2 1
// do-while: 본문을 먼저 실행하고 조건을 본다
int y = 0;
do {
    printf("%d ", y);
    y += 3;
} while (y < 10);
// 출력: 0 3 6 9

do-while은 본문이 최소 1번은 실행된다는 점이 다르다.

중첩 반복문

for (int i = 1; i <= 4; i++) {
    for (int j = 1; j <= 4 - i; j++)
        printf(" ");
    for (int j = 1; j <= 2 * i - 1; j++)
        printf("*");
    printf("\n");
}

출력:

   *
  ***
 *****
*******

바깥 반복이 행, 안쪽 반복이 열을 담당한다.

continue와 break

  • continue: 남은 본문을 건너뛰고 다음 반복으로
  • break: 반복문을 바로 빠져나옴
for (int i = 0; i < 10; i++) {
    if (i % 2 == 0) continue;   // 짝수 건너뛰기
    if (i > 7) break;           // 7 넘으면 종료
    printf("%d ", i);
}
// 출력: 1 3 5 7

중첩 반복에서 break가장 안쪽 반복문만 빠져나온다.

반복문 예제: 자릿수 세기

int n = 30562, count = 0;
while (n > 0) {
    n /= 10;     // 마지막 자릿수 제거
    count++;
}
printf("%d\n", count);  // 5
반복 n (나누기 전) n /= 10 count
1 30562 3056 1
2 3056 305 2
3 305 30 3
4 30 3 4
5 3 0 5

goto

goto 문

goto는 지정한 레이블로 무조건 점프한다.

int sum = 0, i = 1;

loop:
    if (i > 10) goto done;
    if (i % 2 == 0) sum += i;
    i++;
    goto loop;

done:
    printf("%d\n", sum);  // 30 (2+4+6+8+10)

goto looploop: 레이블로 되돌아가고, goto donedone: 레이블로 건너뛴다. 반복문이 없던 시절에는 이렇게 반복을 만들었다.

goto → for 변환

위 코드는 for문으로 더 깔끔하게 쓸 수 있다.

int sum = 0;
for (int i = 1; i <= 10; i++) {
    if (i % 2 == 0) sum += i;
}
printf("%d\n", sum);  // 30

goto는 코드 흐름을 따라가기 어렵게 만든다. 대부분 for, while, break, continue로 대체할 수 있고, 그렇게 쓰는 게 좋다.

1968년 Dijkstra가 “Go To Statement Considered Harmful”이라는 글을 쓴 뒤로 goto를 피하는 게 업계 관행이 되었다.

goto를 쓰는 드문 경우

중첩 반복을 한 번에 빠져나올 때 goto가 유일하게 쓸 만하다.

for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        if (arr[i][j] == target) {
            printf("찾았다: (%d, %d)\n", i, j);
            goto found;   // 이중 반복 탈출
        }
    }
}
printf("못 찾았다\n");
found:
    // 계속 진행

break는 안쪽 반복만 빠져나오므로, 중첩을 한 번에 빠져나오려면 플래그 변수를 쓰거나 goto를 쓴다.

Linux 커널 소스에서는 에러 처리 후 자원 해제(cleanup)에 goto를 쓴다. goto cleanup; 같은 형태로, 함수 끝의 정리 코드로 점프한다.

문자열과 문자

문자 (char)

char는 1바이트 정수이다. ASCII 코드표에서 문자와 숫자가 대응된다.

char c = 'A';          // 65
printf("%c\n", c);     // A
printf("%d\n", c);     // 65
printf("%c\n", c + 3); // D (65 + 3 = 68)
범위 문자 ASCII
'0' ~ '9' 숫자 48 ~ 57
'A' ~ 'Z' 대문자 65 ~ 90
'a' ~ 'z' 소문자 97 ~ 122

대문자와 소문자의 차이는 항상 32이다. 'a' - 'A' = 32

문자열

C에서 문자열은 char 배열이다. 끝에 널 문자('\0')가 붙는다.

char s1[] = "Hello";
// 메모리: ['H']['e']['l']['l']['o']['\0']
// sizeof(s1) = 6 (문자 5개 + 널 문자 1개)

char s2[10] = "Hi";
// 메모리: ['H']['i']['\0'][0][0][0][0][0][0][0]
// sizeof(s2) = 10 (선언한 크기)

널 문자 '\0'이 문자열의 끝을 알려준다. C의 문자열 함수는 전부 이 약속을 전제로 동작한다.

문자열 처리 예시

대문자를 세는 코드:

char str[] = "Hello, World!";
int count = 0;
for (int i = 0; str[i] != '\0'; i++) {
    if (str[i] >= 'A' && str[i] <= 'Z')
        count++;
}
printf("%d\n", count);  // 2 (H, W)

소문자를 대문자로 바꾸기:

void to_upper(char str[]) {
    for (int i = 0; str[i] != '\0'; i++) {
        if (str[i] >= 'a' && str[i] <= 'z')
            str[i] = str[i] - 'a' + 'A';   // 또는 str[i] -= 32;
    }
}

'a'를 빼면 0~25 사이 값이 나오고, 'A'를 더하면 대문자가 된다.

(여담) 문자열과 보안

C 문자열은 길이 정보가 없고 널 문자에만 의존한다. 입력 크기를 검사하지 않으면 버퍼 오버플로우가 생길 수 있다.

char buf[8];
scanf("%s", buf);  // "Hello World" 입력하면 buf를 넘침 → 위험

버퍼 오버플로우는 보안 취약점 중에서도 오래 전부터 자주 악용되어 왔다. 1988년 Morris Worm이 gets() 함수의 버퍼 오버플로우를 이용해 인터넷의 약 10%를 감염시켰다.

안전한 대안:

fgets(buf, sizeof(buf), stdin);   // 최대 크기를 지정
scanf("%7s", buf);                 // 폭 지정으로 제한

(여담) ASCII의 설계

ASCII 코드표는 우연이 아니라 의도적으로 설계되었다.

  • 대문자와 소문자의 차이가 정확히 32(비트 5 하나)인 이유: 비트 하나만 바꾸면 대소문자를 전환할 수 있다.
  • 숫자 '0'~'9'의 하위 4비트가 0~9인 이유: c - '0'이 아니라 c & 0x0F로도 숫자 값을 얻을 수 있다.
  • 알파벳이 연속 배치된 이유: 사전순 정렬을 단순 비교(<)로 할 수 있다.

배열

배열 선언과 초기화

같은 타입의 값 여러 개를 연속된 메모리에 저장한다.

int arr[5] = {10, 20, 30, 40, 50};
int zeros[100] = {0};          // 전부 0으로 초기화
int partial[5] = {1, 2};       // 나머지는 0: {1, 2, 0, 0, 0}
int auto_size[] = {3, 6, 9};   // 크기 자동: 3칸

인덱스는 0부터 시작한다. arr[0] = 10, arr[4] = 50.

for (int i = 0; i < 5; i++)
    printf("%d ", arr[i]);
// 출력: 10 20 30 40 50

배열과 메모리

배열은 메모리에 연속으로 배치된다.

arr[0]  arr[1]  arr[2]  arr[3]  arr[4]
┌──────┬──────┬──────┬──────┬──────┐
│  10  │  20  │  30  │  40  │  50  │
└──────┴──────┴──────┴──────┴──────┘
0x1000  0x1004  0x1008  0x100C  0x1010

int가 4바이트이므로 주소가 4씩 증가한다.

배열 이름 arr는 첫 번째 원소의 주소(&arr[0])와 같다. 이 성질이 나중에 포인터를 배울 때 중요해진다.

배열 범위 밖 접근

C는 배열 인덱스 범위를 검사하지 않는다.

int arr[3] = {1, 2, 3};
arr[5] = 99;        // 컴파일은 되지만 위험
printf("%d\n", arr[-1]);  // 이것도 컴파일된다

범위 밖에 쓰면 다른 변수나 리턴 주소를 덮어쓸 수 있다. 프로그램이 이상하게 동작하거나, 보안 취약점이 생긴다.

Java나 Python은 범위를 벗어나면 바로 오류를 내지만, C는 아무런 경고 없이 실행한다. 직접 주의해야 한다.

배열 활용: 최댓값 찾기

int arr[] = {3, 7, 1, 9, 4};
int n = 5;
int max = arr[0];  // 첫 원소로 초기화
for (int i = 1; i < n; i++) {
    if (arr[i] > max)
        max = arr[i];
}
printf("최댓값: %d\n", max);  // 9

max를 0으로 초기화하면 배열에 음수만 있을 때 틀린다. 항상 arr[0]이나 INT_MIN으로 초기화해야 한다.

2차원 배열

int m[2][3] = {{1, 2, 3}, {4, 5, 6}};
printf("%d\n", m[1][2]);  // 6

메모리에는 행 우선(row-major)으로 일렬로 저장된다.

m[0][0] m[0][1] m[0][2] m[1][0] m[1][1] m[1][2]
┌──────┬──────┬──────┬──────┬──────┬──────┐
│  1   │  2   │  3   │  4   │  5   │  6   │
└──────┴──────┴──────┴──────┴──────┴──────┘

2중 반복문으로 전체를 순회한다.

int sum = 0;
for (int i = 0; i < 2; i++)
    for (int j = 0; j < 3; j++)
        sum += m[i][j];
// sum = 21

함수

함수의 기본 형태

// 반환타입 함수이름(매개변수) { 본문 }
int add(int a, int b) {
    return a + b;
}

int main() {
    int result = add(3, 4);
    printf("%d\n", result);  // 7
    return 0;
}
  • int add(int a, int b): int 두 개를 받아서 int를 돌려준다
  • return: 값을 돌려주고 함수를 빠져나온다
  • 반환할 게 없으면 void를 쓴다

값에 의한 전달

C는 함수에 인자를 넘길 때 값을 복사한다.

void try_change(int x) {
    x = 99;  // 복사본만 바뀐다
}

int main() {
    int a = 10;
    try_change(a);
    printf("%d\n", a);  // 여전히 10
    return 0;
}

a의 값이 x로 복사되므로, 함수 안에서 x를 바꿔도 원본 a는 그대로다.

원본을 바꾸려면 포인터를 넘겨야 한다 (다음 시간에 배운다).

함수 선언 (prototype)

함수를 호출하기 전에 컴파일러가 그 함수의 존재를 알아야 한다.

// 방법 1: 함수를 호출보다 위에 정의
int add(int a, int b) { return a + b; }
int main() { printf("%d\n", add(3,4)); }
// 방법 2: 선언(prototype)을 위에 쓰고, 정의는 아래에
int add(int a, int b);  // 선언만

int main() {
    printf("%d\n", add(3, 4));
    return 0;
}

int add(int a, int b) {  // 실제 정의
    return a + b;
}

큰 프로그램에서는 .h 헤더 파일에 선언을 모아두고, .c 파일에 정의를 둔다.

재귀 함수

함수가 자기 자신을 호출하는 것이다.

int factorial(int n) {
    if (n <= 1) return 1;      // 기저 조건
    return n * factorial(n - 1);  // 재귀 호출
}

printf("%d\n", factorial(5));  // 120
factorial(5) = 5 * factorial(4)
             = 5 * 4 * factorial(3)
             = 5 * 4 * 3 * factorial(2)
             = 5 * 4 * 3 * 2 * factorial(1)
             = 5 * 4 * 3 * 2 * 1
             = 120

기저 조건(base case)이 없으면 무한히 호출되다가 스택 오버플로우로 죽는다.

(여담) 재귀 vs 반복

같은 문제를 재귀와 반복 두 가지로 풀 수 있는 경우가 많다.

// 반복문 버전
int factorial_loop(int n) {
    int result = 1;
    for (int i = 2; i <= n; i++)
        result *= i;
    return result;
}

재귀는 코드가 깔끔하지만, 호출마다 스택 프레임이 쌓여서 메모리를 더 쓴다. factorial(100000) 같은 큰 입력에서는 스택이 넘칠 수 있다.

트리 구조나 분할 정복 같은 문제는 재귀가 자연스럽고, 단순 반복은 for/while이 낫다.

포인터 기초

메모리와 주소

변수를 만들면 메모리 어딘가에 공간이 잡힌다.

int x = 42;
printf("x의 값: %d\n", x);       // 42
printf("x의 주소: %p\n", &x);    // 0x7ffd... (실행마다 다름)

&는 변수의 메모리 주소를 구하는 연산자이다. scanf에서 &를 쓰는 이유가 바로 이것이다.

포인터 변수

포인터는 주소를 값으로 저장하는 변수이다.

int  x = 42;
int *p = &x;     // p에 x의 주소를 저장

printf("%p\n", p);    // x의 주소
printf("%d\n", *p);   // 42 (p가 가리키는 곳의 값)
  • int *p: “int를 가리키는 포인터” 선언
  • &x: x의 주소
  • *p: p가 가리키는 곳의 값 (역참조)

*는 선언할 때와 쓸 때 의미가 다르다. 선언에서는 “포인터 타입”, 식에서는 “역참조”이다.

포인터로 원본 바꾸기

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 10, y = 20;
    swap(&x, &y);          // 주소를 넘긴다
    printf("%d %d\n", x, y);  // 20 10
    return 0;
}

주소를 넘기면 함수 안에서 *a, *b로 원본에 직접 접근할 수 있다. 앞에서 try_change로 안 되던 것이 포인터로 가능한 이유가 이것이다.

포인터와 배열

배열 이름은 첫 번째 원소의 주소와 같다.

int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;     // arr == &arr[0]

printf("%d\n", *p);       // 10
printf("%d\n", *(p + 2)); // 30
printf("%d\n", p[3]);     // 40 (p[i]는 *(p+i)와 같다)

포인터에 정수를 더하면 sizeof(타입) 단위로 이동한다.

p + 2는 주소가 p + 2 * sizeof(int) = p + 8바이트만큼 이동한다. 배열의 세 번째 원소를 가리키게 된다.

NULL 포인터

아무것도 가리키지 않는 포인터는 NULL로 설정한다.

int *p = NULL;

if (p != NULL) {
    printf("%d\n", *p);  // NULL이면 여기 안 들어옴
}

NULL 포인터를 역참조하면 segmentation fault로 프로그램이 비정상 종료한다. 포인터를 쓰기 전에 반드시 NULL 여부를 검사해야 한다.

초기화하지 않은 포인터는 쓰레기 주소를 갖고 있어서 더 위험하다. 사용하지 않는 포인터는 항상 NULL로 둔다.

(여담) 포인터가 어려운 이유

포인터는 C를 배울 때 많이 막히는 부분이다. 혼동하기 쉬운 선언들:

int  x;      // int 변수
int *p;      // int를 가리키는 포인터
int **pp;    // int 포인터를 가리키는 포인터 (이중 포인터)

int  arr[5];    // int 5개짜리 배열
int *arr2[5];   // int 포인터 5개짜리 배열
int (*arr3)[5]; // int 5개짜리 배열을 가리키는 포인터

선언을 읽을 때는 변수 이름에서 시작해서 오른쪽 → 왼쪽 순서로 읽는다 (시계 방향 규칙, clockwise/spiral rule).

이중 포인터, 함수 포인터 등은 나중에 더 다룬다. 지금은 “포인터 = 주소를 담는 변수”만 확실히 이해하면 된다.

전처리기

#include와 #define

컴파일 전에 소스 코드를 텍스트 수준에서 바꾸는 단계이다.

#include <stdio.h>     // 표준 라이브러리 헤더
#include "myheader.h"  // 내가 만든 헤더

#define PI 3.14159
#define MAX_SIZE 100
  • #include <...>: 시스템 경로에서 찾는다
  • #include "...": 현재 디렉토리에서 먼저 찾고, 없으면 시스템 경로
  • #define: 이름을 값으로 치환한다. 타입 검사가 없다.

#define PI 3.14159이면, 코드에서 PI가 나올 때마다 컴파일러가 보기 전에 3.14159로 바뀐다.

매크로 함수

#define으로 함수처럼 쓸 수 있다. 하지만 함정이 많다.

#define SQUARE(x) ((x) * (x))
#define BAD_SQUARE(x) x * x

printf("%d\n", SQUARE(3));       // 9
printf("%d\n", SQUARE(1+2));     // 9  ((1+2) * (1+2))
printf("%d\n", BAD_SQUARE(1+2)); // 5  (1+2 * 1+2 = 1+2+2 = 5)

매크로는 단순 텍스트 치환이므로, 인자에 괄호를 빠뜨리면 연산 순서가 꼬인다.

#define MAX(a, b) ((a) > (b) ? (a) : (b))

모든 인자와 전체 식을 괄호로 감싸야 안전하다.

조건부 컴파일

#define DEBUG

#ifdef DEBUG
    printf("x = %d\n", x);  // DEBUG가 정의되어 있을 때만 컴파일
#endif

#ifndef HEADER_H
#define HEADER_H
// 헤더 내용 (중복 포함 방지)
#endif

#ifdef / #ifndef는 헤더 파일의 중복 포함을 막는 데 주로 쓰인다. 이 패턴을 include guard라고 한다.

(여담) 전처리기의 흑역사

C 전처리기는 쓸 수 있는 게 많지만, 남용하면 읽을 수 없는 코드가 된다.

// 실제로 존재했던 코드 스타일
#define BEGIN {
#define END }
#define IF if(
#define THEN ){
#define ELSE } else {

IF x > 0 THEN
    printf("양수");
ELSE
    printf("음수");
END

이건 C를 Pascal처럼 보이게 만들려는 시도인데, 디버깅하기가 매우 어렵다. 전처리기 매크로는 꼭 필요한 곳에만 쓰고, const 변수나 inline 함수로 대체하는 편이 낫다.

매년 열리는 IOCCC(International Obfuscated C Code Contest)에서는 전처리기를 한계까지 활용한 코드를 볼 수 있다.

구조체

struct 기초

관련 있는 변수 여러 개를 하나로 묶는다.

struct Point {
    int x;
    int y;
};

struct Point p1;
p1.x = 10;
p1.y = 20;
printf("(%d, %d)\n", p1.x, p1.y);  // (10, 20)

선언과 동시에 초기화할 수도 있다.

struct Point p2 = {30, 40};
struct Point p3 = {.y = 50, .x = 60};  // C99 지정 초기화

struct 활용 예시

struct Student {
    char name[50];
    int age;
    double gpa;
};

void print_student(struct Student s) {
    printf("%s (나이 %d, GPA %.1f)\n", s.name, s.age, s.gpa);
}

int main() {
    struct Student alice = {"Alice", 17, 3.8};
    print_student(alice);
    return 0;
}

함수에 struct를 넘기면 전체가 복사된다. 구조체가 크면 포인터로 넘기는 게 효율적이다.

void print_student_ptr(struct Student *s) {
    printf("%s (나이 %d)\n", s->name, s->age);
}
// s->name 은 (*s).name 과 같다

typedef

struct를 매번 쓰기 번거로우면 typedef로 별명을 붙인다.

typedef struct {
    double real;
    double imag;
} Complex;

Complex c1 = {3.0, 4.0};
Complex c2 = {1.0, -2.0};

Complex add(Complex a, Complex b) {
    Complex result = {a.real + b.real, a.imag + b.imag};
    return result;
}

typedef는 struct 외에도 긴 타입 이름을 줄이는 데 쓴다.

typedef unsigned long long ull;
ull big_number = 1234567890123ULL;

(여담) struct와 패딩

구조체의 크기는 멤버 크기의 합보다 클 수 있다.

struct A {
    char c;    // 1바이트
    int n;     // 4바이트
    char d;    // 1바이트
};
// sizeof(struct A) = 12 (6이 아니다)

CPU는 데이터가 정렬된(aligned) 주소에 있을 때 빠르게 읽는다. int는 4의 배수 주소에 놓여야 하므로, 컴파일러가 중간에 빈 공간(패딩)을 넣는다.

[c][pad][pad][pad][n n n n][d][pad][pad][pad]
 1    3              4       1    3          = 12바이트

멤버 순서를 바꾸면 크기가 달라질 수 있다.

struct B {
    int n;     // 4바이트
    char c;    // 1바이트
    char d;    // 1바이트
};
// sizeof(struct B) = 8

동적 메모리 할당

malloc과 free

지금까지 배운 변수는 크기가 컴파일 때 정해진다. 실행 중에 크기를 정하려면 malloc을 쓴다.

#include <stdlib.h>

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

int *arr = (int *)malloc(n * sizeof(int));  // n칸짜리 int 배열
if (arr == NULL) {
    printf("메모리 할당 실패\n");
    return 1;
}

for (int i = 0; i < n; i++)
    arr[i] = i * 10;

free(arr);  // 다 쓰면 반드시 해제
  • malloc(크기): 바이트 단위로 메모리를 요청한다. 성공하면 주소, 실패하면 NULL을 돌려준다.
  • free(포인터): malloc으로 받은 메모리를 반납한다.
  • free를 빠뜨리면 메모리 누수(memory leak)가 생긴다.

calloc과 realloc

// calloc: 0으로 초기화된 메모리
int *arr = (int *)calloc(100, sizeof(int));  // 100칸, 전부 0

// realloc: 기존 메모리 크기 변경
arr = (int *)realloc(arr, 200 * sizeof(int));  // 200칸으로 확장

realloc은 기존 데이터를 유지하면서 크기를 바꾼다. 내부적으로 새 메모리를 잡고 복사한 뒤 옛 메모리를 해제할 수도 있다.

realloc이 실패하면 NULL을 돌려주는데, 원본 포인터에 바로 대입하면 원본 주소를 잃는다.

// 위험: realloc 실패 시 arr을 잃는다
arr = realloc(arr, new_size);

// 안전: 임시 포인터로 받는다
int *tmp = realloc(arr, new_size);
if (tmp == NULL) { /* 에러 처리 */ }
else arr = tmp;

(여담) 메모리 누수와 Valgrind

free를 빠뜨리면 프로그램이 메모리를 계속 먹는다. 짧은 프로그램에서는 티가 안 나지만, 서버처럼 오래 도는 프로그램에서는 큰 문제가 된다.

void leak() {
    int *p = malloc(1000);
    // free(p) 없이 함수가 끝남
    // p가 사라지면서 할당된 1000바이트의 주소를 잃는다
}

Valgrind라는 도구로 메모리 누수를 잡을 수 있다.

gcc -g program.c -o program
valgrind --leak-check=full ./program

출력에 “definitely lost: 0 bytes”가 나오면 누수가 없는 것이다.

C++에서는 RAII 패턴이나 스마트 포인터로, Rust에서는 소유권(ownership) 시스템으로 이 문제를 언어 차원에서 해결한다. C에서는 프로그래머가 직접 관리해야 한다.

(여담) C의 탄생

C는 1972년 Bell Labs에서 Dennis Ritchie가 만들었다. Ken Thompson이 만든 B 언어를 개선한 것이라서 C라는 이름이 붙었다.

원래 목적은 Unix 운영체제를 다시 작성하기 위해서였다. 그 전에 Unix는 어셈블리로 쓰여 있어서, 다른 기종으로 옮기려면 처음부터 새로 짜야 했다. C로 다시 쓰고 나서는 컴파일러만 만들면 다른 기종에서도 돌릴 수 있게 되었다.

1978년 Ritchie와 Kernighan이 쓴 “The C Programming Language” (K&R)는 C 프로그래밍의 바이블로 통한다. 이 책에 처음 나온 “Hello, World” 예제가 이후 대부분의 프로그래밍 교재에서 첫 예제로 쓰이고 있다.

(여담) 왜 배열 인덱스가 0부터인가

배열 인덱스가 0부터 시작하는 건 C의 포인터 산술 때문이다.

arr[i]*(arr + i)와 같다. arr이 배열의 시작 주소일 때, 첫 번째 원소는 시작 주소에서 0칸 떨어져 있으므로 arr[0]이 된다.

Dijkstra도 1982년에 “Why numbering should start at zero”라는 글에서 0부터 세는 게 수학적으로 더 깔끔하다고 주장했다. 범위를 [0, n)으로 쓰면 원소 개수가 n - 0 = n으로 바로 나온다.

Pascal이나 Lua처럼 1부터 시작하는 언어도 있지만, C 계열 언어(C++, Java, Python, JavaScript)는 전부 0부터 시작한다.

(여담) Heartbleed

2014년에 발견된 OpenSSL의 버그로, 인터넷 서버의 약 17%가 영향을 받았다.

TLS heartbeat 메시지를 처리할 때, 클라이언트가 보낸 길이 값을 검증하지 않고 memcpy로 그만큼 복사해 돌려보냈다. 공격자가 길이를 크게 적으면 서버 메모리에 남아 있는 비밀 키, 비밀번호 등이 새어나왔다.

// 대략적인 취약 코드 구조
unsigned short payload_length = request->length;  // 클라이언트가 보낸 값
memcpy(response, request->data, payload_length);  // 검증 없이 복사

실제 데이터는 1바이트인데 길이를 65535로 보내면, 뒤에 있는 메모리 65534바이트를 읽어갈 수 있었다. C가 배열 경계를 검사하지 않는다는 바로 그 특성이 원인이었다.

정리

이번 주에 다룬 것

연산

  • 산술 연산과 우선순위
  • 비교연산자, 논리연산자
  • 비트 연산 (&, |, ^, ~, <<, >>)
  • 타입캐스팅

정수 표현

  • 2의 보수
  • overflow / underflow
  • 타입별 크기와 범위

제어 흐름

  • if / else if / else
  • switch / case (fall-through)
  • for / while / do-while
  • continue, break, goto

입출력과 문자열

  • printf 형식 지정자 (%d, %o, %x, %X)
  • scanf와 & 연산자
  • 문자(char)와 ASCII
  • 문자열과 널 종료

자료구조와 함수

  • 배열 (1차원, 2차원)
  • 함수, 값 전달, 재귀
  • 포인터 기초, NULL, 역참조

그 외

  • 전처리기 (#include, #define, 매크로)
  • 구조체 (struct, typedef, 패딩)
  • 동적 메모리 (malloc, free, calloc, realloc)