Step 59. 포인터 2: 배열 — 붙어 있는 칸들의 법칙

Step 59. 포인터 2: 배열 — 붙어 있는 칸들의 법칙

Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 56~58을 마쳤다. 포인터(&와 *)를 기본 실험으로 만져 봤다.

  • 준비물: 리눅스 터미널, gcc, 주소를 적어 볼 노트.
  • 주의: 오늘 실습은 내 컴퓨터 안의 실험뿐이라 안전합니다. 주소는 실행할 때마다 달라집니다 — 책과 숫자가 다른 것이 정상입니다.

Step 58에서 우리는 변수 하나의 번지를 봤습니다. 오늘은 다섯 칸을 한꺼번에 봅니다. 배열(array) — 같은 종류의 변수 여러 개를 줄 세운 것 — 입니다. 파이썬의 리스트와 비슷해 보이지만, 속은 훨씬 단순하고 그래서 더 많은 것을 보여 줍니다.

오늘의 주제를 한 문장으로 말하면 "배열과 포인터는 사실 같은 존재"입니다. 이 문장은 C의 가장 유명한 진실이자, 처음엔 가장 믿기 어려운 진실입니다. 그래서 오늘은 설득이 아니라 실험으로 갑니다. 주소를 찍고, 더하고, 따라가 보면 저절로 믿어집니다. 그리고 이 진실이 문자열의 끝에 숨어 있는 작은 경비원 \0과 만나면, 버퍼 오버플로우라는 거대한 주제의 대문이 보이기 시작합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • 배열이 "메모리에 붙어 있는 칸들"임을 주소 출력으로 확인한다
  • 배열 이름이 사실 첫 칸의 주소라는 것을 실험으로 증명한다
  • arr[i]*(arr + i)가 같은 뜻임을 두 방식으로 접근해 확인한다
  • 포인터 연산에서 "한 걸음"이 자료형 크기로 정해짐을 안다
  • 문자열 끝의 보이지 않는 경비원 \0을 설명한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C 언어, 리눅스 터미널, gcc -Wall (실측: Ubuntu 13.3.0, 64비트)
오늘의 문법 int arr[5], arr[i], *(arr + i), 포인터 덧셈·뺄셈, '\0'
필요한 개념 배열 = 붙어 있는 칸들, 배열 이름 = 첫 칸의 주소, 포인터 연산의 걸음 크기, 널 문자

2-1. 배열 — 붙어 있는 칸들

int arr[5];는 "int 칸 다섯 개를 붙여서 만들어라"입니다. 파이썬의 리스트가 각자 떠 있는 상자들의 묶음이라면, C의 배열은 다섯 칸짜리 통 사물함입니다. 붙어 있다는 것이 핵심입니다. 첫 칸의 번지를 알면, 나머지 칸의 번지는 덧셈으로 계산됩니다.

2-2. 배열 이름 = 첫 칸의 주소

여기서 오늘의 진실. 배열 이름 arr은 사실 "첫 칸의 주소"와 같은 값입니다. arr == &arr[0]이 성립합니다. 그래서 Step 56의 scanf에서 문자 배열에 &를 안 붙였던 것입니다 — 이름 자체가 이미 주소였으니까요.

2-3. 포인터 연산 — 걸음의 크기

주소에 1을 더하면 몇 번지를 갈까요? 1번지가 아닙니다. "그 자료형 한 칸"만큼 갑니다. int(4바이트) 포인터에 1을 더하면 번지는 4가 늘어납니다. 걸음의 크기가 신발(자료형)에 따라 정해지는 것입니다. 그래서 arr + 1은 "두 번째 칸의 주소"이고, *(arr + 1)은 "두 번째 칸의 값"입니다. 그리고 arr[1]은 그것의 예쁜 줄임말입니다.

2-4. \0 — 문자열의 끝 경비원

C에서 문자열은 "문자들의 배열 + 끝을 알리는 표시"입니다. "Hi"는 사실 세 칸입니다 — H, i, 그리고 보이지 않는 \0(널 문자, null character, 값 0). C는 이 경비원을 만날 때까지 문자열을 읽습니다. 경비원이 없거나 밀려나면? 읽기는 끝을 모르고 옆 칸까지 넘어갑니다. 이 사고가 버퍼 오버플로우 이야기의 핵심 장면입니다.


3. 따라 하기

오늘도 주소는 실행할 때마다 달라집니다. 숫자 자체가 아니라 "몇 씩 뛰는가, 둘이 같은가"의 관계를 보세요.

3-1. 배열의 주소들 — 붙어 있는지 확인

입력 (arr1.c)

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};
    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d, 주소: %p\n", i, arr[i], &arr[i]);
    }
    return 0;
}

컴파일과 실행

gcc -Wall arr1.c -o arr1
./arr1
arr[0] = 10, 주소: 0x7fffd1d9bd50
arr[1] = 20, 주소: 0x7fffd1d9bd54
arr[2] = 30, 주소: 0x7fffd1d9bd58
arr[3] = 40, 주소: 0x7fffd1d9bd5c
arr[4] = 50, 주소: 0x7fffd1d9bd60

(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)

읽는 법: 주소의 끝자리를 보세요. 50, 54, 58, 5c, 60 — 16진수로 4씩 뜁니다. int 한 칸이 4바이트라서, 다섯 칸이 정확히 붙어 있는 증거입니다. 여러분의 주소는 다르지만 "4씩 뛰는" 패턴은 같을 것입니다.

예측: int를 char로 바꾸면 주소 간격이 어떻게 될까요? char는 1바이트입니다. 예측하고 확인해 보세요. (실측: char arr[5]는 주소가 …c3, c4, c5, c6, c7로 1씩 뛰었습니다.)

: "배열은 붙어 있다"를 말로 듣는 것과, 눈으로 4씩 뛰는 번지를 보는 것은 다릅니다. 이 화면이 오늘의 모든 공식의 뿌리입니다.

3-2. 배열 이름의 정체

입력 (arr2.c)

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};
    printf("arr      = %p\n", arr);
    printf("&arr[0]  = %p\n", &arr[0]);
    return 0;
}

컴파일과 실행

gcc -Wall arr2.c -o arr2
./arr2
arr      = 0x7ffde7cc5bc0
&arr[0]  = 0x7ffde7cc5bc0

(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)

읽는 법: 두 줄이 같은 번지를 찍었습니다. 배열 이름 arr은 첫 칸의 주소 그 자체였습니다. 배열이 "주소를 담은 상자"라기보다, 이름 자체가 곧 주소인 셈입니다. Step 56에서 문자 배열의 scanf에 &가 필요 없던 이유가 여기서 풀립니다.

3-3. 포인터 연산 — 걸어서 다음 칸으로

입력 (arr3.c)

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};
    int *p = arr;
    for (int i = 0; i < 5; i++) {
        printf("*(p + %d) = %d, 주소 %p\n", i, *(p + i), p + i);
    }
    return 0;
}

컴파일과 실행

gcc -Wall arr3.c -o arr3
./arr3
*(p + 0) = 10, 주소 0x7ffffd11b1e0
*(p + 1) = 20, 주소 0x7ffffd11b1e4
*(p + 2) = 30, 주소 0x7ffffd11b1e8
*(p + 3) = 40, 주소 0x7ffffd11b1ec
*(p + 4) = 50, 주소 0x7ffffd11b1f0

(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)

읽는 법: p에 0, 1, 2를 더했는데 주소는 0, 4, 8씩 늘어납니다. "더하는 숫자 × 자료형 크기"만큼 가는 것이 포인터 연산(pointer arithmetic)입니다. *(p + i)는 "i칸 뒤 번지로 찾아가서 값을 줘". 3-1의 arr[i]와 출력이 정확히 같습니다.

: arr[i]*(arr + i)의 예의 바른 표기였습니다. 이제 여러분은 배열을 "인덱스"와 "주소 걸음" 두 언어로 말할 수 있습니다.

3-4. 같은 값, 두 문법 — 대조 실험

입력 (arr4.c)

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};
    int i = 3;
    printf("arr[i]     = %d\n", arr[i]);
    printf("*(arr + i) = %d\n", *(arr + i));
    return 0;
}

컴파일과 실행

gcc -Wall arr4.c -o arr4
./arr4
arr[i]     = 40
*(arr + i) = 40

(2026-09-09 실측.)

읽는 법: 같은 40입니다. arr[i]라고 쓰면 컴파일러는 속으로 *(arr + i)로 바꿔 번역합니다. 대괄호는 사람을 위한 포장지였습니다.

예측: 그렇다면 i[arr]이라고 거꾸로 써도 될까요? *(i + arr)과 같은 식이 되니까… 컴파일해 보세요. (실측: 컴파일도 실행도 됐고 i[arr] = 40이 나왔습니다.) 되는 것과 쓰는 것은 다르지만, 되는 이유를 설명할 수 있으면 오늘을 이해한 것입니다.

3-5. 문자열과 \0 — 경비원 찾기

입력 (str0.c)

#include <stdio.h>

int main(void) {
    char s[] = "Hi";
    for (int i = 0; i < 3; i++) {
        printf("s[%d] = %c (값 %d)\n", i, s[i], s[i]);
    }
    return 0;
}

컴파일과 실행

gcc -Wall str0.c -o str0
./str0
s[0] = H (값 72)
s[1] = i (값 105)
s[2] =   (값 0)

(2026-09-09 실측.)

읽는 법: "Hi"는 두 글자인데 세 칸입니다. 마지막 칸의 값이 0 — 이것이 널 문자 \0, 문자열의 끝 경비원입니다. %c로는 보이지 않지만(값 0은 화면에 그려지지 않음), %d로 찍으니 모습을 드러냈습니다. H가 72인 것은 Step 50의 ASCII 번호표 그대로입니다.

: C의 문자열 함수들(printf의 %s 포함)은 전부 "이 경비원을 만날 때까지 읽는다"로 작동합니다. 경비원의 존재가 C 문자열의 모든 규칙의 원천입니다.

3-6. 경비원이 없으면 — 정직한 실험

입력 (str1.c)

#include <stdio.h>

int main(void) {
    char s[4] = {'H', 'e', 'l', 'p'};
    printf("%s\n", s);
    return 0;
}

컴파일과 실행 (세 번 실행)

gcc -Wall str1.c -o str1
./str1
(1회차) Help
(2회차) Help
(3회차) Help

(2026-09-09 실측.)

읽는 법 — 정직하게 말하면: 네 칸을 꽉 채워 경비원 \0이 설 자리가 없습니다. 그러니 %s는 "Help"를 읽고도 멈출 표시가 없어, 옆 칸을 계속 읽다가 우연히 값 0인 칸을 만나야 멈춥니다. 실측에서는 세 번 모두 깨끗하게 "Help"만 나왔습니다 — 바로 옆 칸이 우연히 0이었기 때문입니다. 이것은 운입니다. 언제 끝날지 모르는 이 동작을 두고 "정해지지 않은 동작(미정의 동작)"이라고 부릅니다. 다른 날, 다른 코드에서는 "Help" 뒤에 쓰레기 글자가 따라붙을 수 있습니다.

예측: char s[5] = {'H','e','l','p'}로 칸을 하나 늘리면? 다섯 번째 칸에는 무엇이 들어갈까요? (실측: 초기화하지 않은 다섯 번째 칸은 0이 되어, 깨끗하게 Help가 출력됐습니다 — 경비원이 저절로 선 셈입니다.)

: 이 "끝을 모르고 넘어가는 읽기"가 정보 유출 사고의 한 장면이 됩니다. 오늘은 무해한 실험이지만, 원리는 크고 작은 사고들의 원리와 같습니다.

3-7. 문자 배열의 걸음 — char 포인터 연산

int에서 배운 걸음을 char에도 적용해 봅니다.

입력 (charwalk.c)

#include <stdio.h>

int main(void) {
    char s[] = "SECURITY";
    char *p = s;
    while (*p != '\0') {
        printf("%c", *p);
        p++;
    }
    printf("\n(총 %ld글자)\n", p - s);
    return 0;
}

컴파일과 실행

gcc -Wall charwalk.c -o charwalk
./charwalk
SECURITY
(총 8글자)

(2026-09-09 실측.)

읽는 법: p를 한 걸음씩 옮기며(p++) *p를 찍고, 경비원 '\0'을 만나면 멈춥니다. char 포인터라 한 걸음이 1바이트입니다. 마지막의 p - s는 "끝 주소 빼기 시작 주소"로, 걸음 수 곧 글자 수가 됩니다. 포인터끼리의 뺄셈은 "몇 칸 떨어져 있나"를 줍니다.

: C의 문자열 함수들(길이 재기, 복사, 비교)은 내부에서 전부 이런 걸음으로 되어 있습니다. strlen이 하는 일을 방금 손으로 한 것입니다.


4. 미션과 연습문제

미션 — 두 언어로 말하는 배열 프로그램

  1. int 점수 배열(다섯 과목, 값은 자유)을 만듭니다.
  2. 첫 번째 출력: 인덱스 방식(arr[i])으로 전 과목과 평균을 출력합니다.
  3. 두 번째 출력: 포인터 연산(*(arr + i))으로 같은 것을 다시 출력합니다. 둘의 결과가 같아야 합니다.
  4. 각 과목의 주소도 함께 찍어, 붙어 있는 모습(4씩 뛰는 것)을 눈으로 확인합니다.
  5. char로 바꾼 미니 실험(알파벳 다섯 글자)을 추가하고, 주소 간격이 1로 바뀌는 것을 확인합니다.
  6. 완성 후 노트에 그림을 그립니다: 다섯 칸 사물함, 각 칸의 번지, 첫 칸을 가리키는 arr 화살표.

연습문제

문제 1. 다섯 칸짜리 배열 int arr[5]에서 arr[5]를 읽으면 어떻게 되며, C는 왜 그것을 막아 주지 않을까요?

문제 2. arr&arr[0]의 관계를 말하고, 이것이 Step 56의 scanf("%19s", name)에서 &가 필요 없던 이유를 설명해 보세요.

문제 3. int 포인터에 1을 더했더니 주소가 4 늘었습니다. 왜 1이 아니라 4일까요? 그리고 char 포인터에서는 얼마가 늘까요?

문제 4. C에서 문자열 배열의 크기는 왜 "글자 수 + 1"이어야 할까요? 그리고 이 "+1"을 빠뜨린 코드가 어떤 사고로 이어지는지, 3-6의 실험을 근거로 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

#include <stdio.h>

int main(void) {
    int scores[5] = {80, 92, 75, 88, 95};
    int sum = 0;

    printf("[인덱스 방식]\n");
    for (int i = 0; i < 5; i++) {
        printf("과목%d: %d점 (주소 %p)\n", i + 1, scores[i], &scores[i]);
        sum += scores[i];
    }
    printf("평균: %d\n", sum / 5);

    sum = 0;
    printf("[포인터 방식]\n");
    for (int i = 0; i < 5; i++) {
        printf("과목%d: %d점\n", i + 1, *(scores + i));
        sum += *(scores + i);
    }
    printf("평균: %d\n", sum / 5);
    return 0;
}

실행 (2026-09-09 실측. 주소는 실행할 때마다 다릅니다)

[인덱스 방식]
과목1: 80점 (주소 0x7ffe83646940)
과목2: 92점 (주소 0x7ffe83646944)
과목3: 75점 (주소 0x7ffe83646948)
과목4: 88점 (주소 0x7ffe8364694c)
과목5: 95점 (주소 0x7ffe83646950)
평균: 86
[포인터 방식]
과목1: 80점
과목2: 92점
과목3: 75점
과목4: 88점
과목5: 95점
평균: 86

검증하는 법: ① 두 방식의 점수와 평균이 한 줄씩 정확히 일치해야 합니다. ② 주소가 16진수로 4씩(40, 44, 48, 4c, 50) 뛰는지 확인합니다. ③ char 미니 실험에서는 1씩 뛰어야 합니다. ④ 그림에는 다섯 칸과 번지, 첫 칸을 가리키는 화살표가 있어야 합니다.

연습문제 해답

문제 1 해답. 다섯 칸은 arr[0]~arr[4]이고, arr[5]는 여섯 번째 칸 — 즉 남의 땅입니다. C는 경계를 검사하지 않기 때문에 아무 경고 없이 그 자리의 쓰레기 값을 읽어 줍니다. "프로그래머를 믿는다"는 C의 설계 때문이고, 그래서 경계 지키기는 언제나 작성자의 몫입니다.

문제 2 해답. arr == &arr[0], 배열 이름은 첫 칸의 주소와 같은 값입니다. 문자 배열 name은 이름 자체가 이미 주소이므로, scanf에 그대로 건네면 그것이 곧 "담을 칸의 번지"가 됩니다. 그래서 &를 붙이지 않았던 것입니다.

문제 3 해답. 포인터 연산의 단위는 바이트가 아니라 "자료형 한 칸"이기 때문입니다. int는 한 칸이 4바이트라 주소가 4씩 늘고, char는 한 칸이 1바이트라 1씩 늡니다. 걸음의 크기는 신발(자료형)이 정합니다.

문제 4 해답. C 문자열은 끝을 알리는 널 문자 \0 한 칸이 반드시 필요하기 때문입니다. "+1"을 빠뜨리면 경비원이 설 자리가 없어서, 3-6처럼 읽기가 끝을 모르고 옆 칸으로 넘어갑니다. 읽기가 넘치면 쓰레기 값이나 남의 데이터가 새어 나오고, 쓰기가 넘치면 옆 변수를 덮습니다 — 버퍼 오버플로우의 출발점입니다.

완료 기준 체크리스트

  • [ ] 배열 칸들이 붙어 있는 것을 주소 출력으로 확인했다
  • [ ] 배열 이름이 첫 칸의 주소임을 실험으로 보일 수 있다
  • [ ] arr[i]와 *(arr + i)가 같음을 두 방식 출력으로 확인했다
  • [ ] 포인터에 1을 더하면 자료형 크기만큼 감을 안다
  • [ ] 문자열 끝의 \0의 역할과 없을 때의 사고를 설명할 수 있다
  • [ ] 두 언어로 말하는 배열 프로그램을 완성했다

6. 흔한 실수와 해결

벽 1. 배열의 첨자는 0부터

증상: arr[5]까지 있다고 생각하고 다섯 칸 배열의 arr[5]를 읽습니다.
원인: 다섯 칸은 arr[0]~arr[4]입니다. arr[5]는 여섯 번째 칸, 즉 남의 땅입니다.
해결: "칸 수 N이면 첨자는 0~N-1"을 외우세요. 남의 땅을 읽어도 C는 혼내지 않고 쓰레기 값을 줍니다 — 이 침묵이 무서운 것입니다. for (i = 0; i < 5; i++)의 조건이 <인 이유를 새기세요.

벽 2. p + 1이 1바이트라고 착각한다

증상: 주소에 1을 더했는데 4가 늘어나 어리둥절.
원인: 포인터 연산의 단위는 바이트가 아니라 자료형 한 칸입니다.
해결: "걸음 크기는 신발(자료형)이 정한다"를 기억하세요. int 포인터는 4바이트, char 포인터는 1바이트씩 걷습니다. 3-1과 3-3의 주소 출력이 그 증거입니다.

벽 3. 문자열 칸 수를 글자 수로 잡는다

증상: char s[5]에 "Hello"를 넣으려다 이상 동작.
원인: "Hello"는 다섯 글자지만, 경비원 \0까지 여섯 칸이 필요합니다.
해결: 문자열 배열은 언제나 "글자 수 + 1"로 잡습니다. Step 56의 char name[20]%19s를 쓴 것이 바로 이 계산이었습니다.

벽 4. 경비원 없는 문자열이 "멀쩡히" 출력된다

증상: 3-6의 str1.c가 깨끗하게 "Help"만 출력합니다 (2026-09-09 실측에서도 세 번 모두 깨끗했습니다).
원인: 옆 칸이 우연히 0이어서 읽기가 바로 멈췄습니다. 규칙이 지켜진 것이 아니라 운입니다.
해결: "지금 잘 돌아간다"를 근거로 삼지 마세요. 끝 표시 없는 문자열은 언제 끝날지 모르는 미정의 동작이고, 컴파일러·실행 환경이 바뀌면 결과가 바뀝니다. 문자열 배열은 항상 \0 자리를 남기세요.

벽 5. 배열을 함수에 건네면 길이를 잃는다

증상: 함수 안에서 배열의 길이를 알 방법이 없어 당황합니다.
원인: 배열을 함수에 건네면 첫 칸의 주소만 전달됩니다. "다섯 칸짜리"라는 정보는 함께 가지 않습니다.
해결: 길이를 따로 인자로 건네는 것이 C의 관례입니다 — void print_scores(int arr[], int n). 이 제약이 왜 C 문자열에 \0 경비원이 필요했는지를 거꾸로 설명해 줍니다. 길이를 못 전하니 끝 표시로 대신한 것입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
배열 같은 자료형의 칸들이 메모리에 붙어 있는 것
배열 이름 첫 칸의 주소 — arr == &arr[0]
포인터 연산 주소 + i는 "i × 자료형 크기"만큼 이동
널 문자(\0) 문자열의 끝 경비원 — 값 0인 한 칸
미정의 동작 끝 표시 없는 읽기처럼, 결과를 장담할 수 없는 동작

오늘의 문법

문법 하는 일
int arr[5] = {10, 20, 30, 40, 50}; 다섯 칸 배열 생성과 초기화
arr[i] i번째 칸 — *(arr + i)의 예의 바른 표기
*(arr + i) i칸 뒤 번지로 찾아가 값 읽기
int *p = arr; 배열 첫 칸을 가리키는 포인터
p - s 두 주소 사이의 거리(칸 수)
'\0' 널 문자 상수 — 문자열의 끝 표시

문법보다 중요한 감각

오늘 두 가지를 봤습니다. 칸 밖을 읽는 사고(3-6)와, 칸이 붙어 있다는 사실(3-1). 이 둘을 합치면 무서운 생각이 가능합니다 — "칸 밖에 쓰기를 하면?" 배열 바로 옆 칸에 다른 변수가 살고 있다면, 그 변수가 덮입니다. 이것이 버퍼 오버플로우의 출발점이고, C가 칸의 경계를 검사하지 않는 이상 경계는 언제나 사람의 몫입니다.

그리고 주소를 읽는 눈이 생겼기를 바랍니다. 오늘부터 주소 출력을 볼 때 끝자리의 간격(몇 바이트씩 붙어 있는가 — 간격을 보면 자료형이 보입니다)과 반복 실행에도 유지되는 패턴을 보세요. 주소는 그저 긴 숫자가 아니라, 메모리라는 지도 위의 좌표입니다. 칸들이 붙어 있고 화살표가 그 사이를 걸어 다니는 그림이 머릿속에 그려지기 시작했다면, 여러분은 이미 메모리를 보는 눈의 소유자입니다.


전부 체크되면 Step 59 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.