Step 58. 포인터 1 — 주소를 손에 쥐는 날
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 56~57을 마쳤다. C로 변수, 제어문, 함수를 쓸 수 있다.
- 준비물: 리눅스 터미널, gcc, 종이와 펜(그림을 그릴 일이 있습니다).
- 주의: 오늘 실습은 내 컴퓨터 안의 실험뿐이라 안전합니다. 다만 프로그램이 "Segmentation fault"를 뱉으며 죽는 장면이 나옵니다 — 고장이 아니라 커리큘럼입니다.
C 공부에서 "여기를 넘으면 C가 보인다"는 관문이 있습니다. 포인터(pointer)입니다. 어렵다는 소문이 자자하지만, 포인터는 어려운 개념이 아니라 낯선 개념입니다. 처음엔 헷갈립니다. 정상입니다. 이 파트를 배우는 모든 사람이 같은 어색함을 지나갑니다.
미리 결론을 말하면 이렇습니다. 모든 변수는 메모리 어딘가의 주소에 삽니다. 포인터는 "그 주소를 적어 둔 종이" 같은 변수입니다. 종이에 적힌 주소로 찾아가면, 거기 사는 값을 읽을 수도 바꿀 수도 있습니다. 이것이 전부입니다. 그런데 이 전부가 C의 심장이고, 나중에 배울 메모리 공격 기법은 결국 "주소를 조작하는 기술"입니다. 오늘은 그 기술의 알파벳을 배우는 날입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 모든 변수가 메모리의 주소에 산다는 사실을
%p출력으로 확인한다 &(주소 얻기)와*(주소 따라가기)를 구분해 쓴다- 포인터 변수로 다른 변수의 값을 읽고 바꾼다
- scanf가 왜
&를 요구했는지 포인터로 설명한다 - 포인터를 상자와 화살표 그림으로 그려 설명한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C 언어, 리눅스 터미널, gcc -Wall (실측: Ubuntu 13.3.0, 64비트) |
| 오늘의 문법 | &변수(주소), int *p(포인터 선언), *p(역참조), %p(주소 출력), sizeof |
| 필요한 개념 | 메모리 주소, 포인터 변수, 역참조, 주소는 실행할 때마다 바뀜 |
2-1. 메모리는 번지수의 나라
컴퓨터의 메모리(RAM)는 길게 늘어선 사물함입니다. 칸마다 번지(주소, address)가 있고, 변수를 만들면 그중 한 칸을 배정받습니다. int x = 10;은 "10이라는 값을 어떤 번지에 넣고, 그 칸에 x라는 이름표를 붙여라"입니다. 우리는 지금까지 이름표(x)로만 다녔지만, 칸에는 번지가 있습니다.
2-2. & — 주소를 알려 줘
변수 앞에 &를 붙이면 그 변수의 주소가 됩니다. &x는 "x가 사는 번지"입니다. Step 56의 scanf에 &를 붙인 이유가 이제 보입니다 — scanf가 값을 넣으려면 상자의 번지를 알아야 하니까요.
2-3. * — 두 가지 얼굴
별표가 포인터에서 두 얼굴로 쓰입니다.
- 선언할 때:
int *p;는 "p는 int가 사는 주소를 담는 변수다"라는 표시입니다. - 사용할 때:
*p는 "p에 적힌 주소로 찾아가서, 거기 있는 값"입니다.
같은 별표인데 자리에 따라 뜻이 다릅니다. 초보의 혼란 대부분이 여기서 오니, 오늘은 이 구분에 특히 힘을 줍니다.
2-4. 역참조 — 찾아가기
*p처럼 "주소를 따라가서 값을 보는" 행위를 역참조(dereference)라고 합니다. 참조(주소)를 거슬러 값에 닿는다는 뜻입니다. 용어는 어렵고 하는 일은 간단합니다 — 종이에 적힌 번지로 찾아가는 것입니다.
3. 따라 하기
오늘 실습에서 나오는 주소는 전부 실행할 때마다 달라집니다. 여러분의 화면에 나온 긴 숫자가 책과 다른 것이 정상입니다. 숫자 자체가 아니라 "같다/다르다/몇 씩 뛴다"의 관계를 보세요.
3-1. 주소 보기 — 변수의 번지 출력
입력 (addr.c)
#include <stdio.h>
int main(void) {
int x = 10;
printf("x의 값: %d\n", x);
printf("x의 주소: %p\n", &x);
return 0;
}
컴파일과 실행 (세 번 연속 실행해 봅니다)
gcc -Wall addr.c -o addr
./addr
(1회차) x의 값: 10
x의 주소: 0x7ffe31d17e14
(2회차) x의 값: 10
x의 주소: 0x7ffdf2bcfd94
(3회차) x의 값: 10
x의 주소: 0x7ffde9b6e814
(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)
읽는 법: %p는 주소를 출력하는 서식 지정자입니다. 0x로 시작하는 긴 16진수가 x의 번지입니다(Step 50의 16진수 표기법입니다). 세 번 실행하니 세 번 다 다른 번지가 나왔습니다. 주소가 매번 바뀌는 것은 운영체제의 보안 기능(ASLR, 주소 무작위 배치) 때문인데, 자세한 이야기는 Step 60에서 만납니다.
왜: "변수는 번지에 산다"를 말로 듣는 것과, 내 눈으로 번지를 보는 것은 다릅니다. 이 출력이 오늘 모든 실험의 기준입니다.
3-2. 포인터 선언과 저장
입력 (ptr1.c)
#include <stdio.h>
int main(void) {
int x = 10;
int *p = &x;
printf("x의 주소: %p\n", &x);
printf("p에 저장된 값: %p\n", p);
return 0;
}
컴파일과 실행
gcc -Wall ptr1.c -o ptr1
./ptr1
x의 주소: 0x7ffdf6e7500c
p에 저장된 값: 0x7ffdf6e7500c
(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)
읽는 법: int *p = &x;는 "정수가 사는 주소를 담는 상자 p를 만들어, x의 번지를 넣어라"입니다. 출력 둘이 같습니다 — p에는 x의 번지가 들어 있습니다. 종이에 번지를 적어 둔 것입니다. 선언의 별표(int *p)는 "이건 주소 상자야"라는 표시일 뿐, 찾아가라는 뜻이 아닙니다.
3-3. 역참조 — 주소 따라가기
입력 (ptr2.c)
#include <stdio.h>
int main(void) {
int x = 10;
int *p = &x;
printf("p가 가리키는 곳의 값: %d\n", *p);
return 0;
}
컴파일과 실행
gcc -Wall ptr2.c -o ptr2
./ptr2
p가 가리키는 곳의 값: 10
(2026-09-09 실측.)
읽는 법: 사용 자리에서의 *p는 "p에 적힌 번지로 찾아가서 거기 있는 값"입니다. x를 직접 부르지 않고, 번지로 돌아서 10에 닿았습니다. 이 한 줄이 역참조입니다.
예측:
printf("%d\n", *&x);는 어떻게 될까요? &와 *가 연달아 붙었습니다 — 주소를 구했다가 그 주소로 다시 찾아가는 셈입니다. 예측하고 실행해 보세요. (실측:10이 나왔습니다. 번지로 갔다가 다시 돌아온 것이니 제자리입니다.)
3-4. 포인터로 값 바꾸기
입력 (ptr3.c)
#include <stdio.h>
int main(void) {
int x = 10;
int *p = &x;
printf("바꾸기 전 x: %d\n", x);
*p = 99;
printf("바꾼 뒤 x: %d\n", x);
return 0;
}
컴파일과 실행
gcc -Wall ptr3.c -o ptr3
./ptr3
바꾸기 전 x: 10
바꾼 뒤 x: 99
(2026-09-09 실측.)
읽는 법: *p = 99;는 "p에 적힌 번지로 찾아가서, 거기에 99를 넣어라"입니다. x를 직접 건드린 적이 없는데 x가 바뀌었습니다. 번지를 아는 자는 그 칸의 주인처럼 행동할 수 있습니다. 이 문장, 메모리 공격을 배울 때 다시 만나게 됩니다.
왜: 포인터의 힘은 "남의 칸을 원격으로 읽고 쓸 수 있다"는 것입니다. 읽기(3-3)와 쓰기(3-4)가 오늘의 두 무기입니다.
3-5. scanf의 수수께끼 풀기
이제 Step 56의 &가 풀립니다.
입력 (scanfwhy.c)
#include <stdio.h>
int main(void) {
int age;
printf("나이: ");
scanf("%d", &age);
printf("당신이 준 주소: %p\n", &age);
printf("그 주소에 담긴 값: %d\n", age);
return 0;
}
컴파일과 실행
gcc -Wall scanfwhy.c -o scanfwhy
./scanfwhy
나이: 25
당신이 준 주소: 0x7ffc882102c4
그 주소에 담긴 값: 25
(2026-09-09 실측. 25는 키보드 입력이며, 주소는 실행할 때마다 다릅니다.)
읽는 법: scanf에게 age가 아니라 &age를 준 이유 — scanf는 "입력값을 어느 칸에 넣을지" 번지를 알아야 하기 때문입니다. 값(age)을 주면 칸을 못 찾습니다. 주소(&age)를 주니 찾아가서 넣을 수 있었습니다. 3-4에서 우리가 *p로 한 일을, scanf가 내부에서 한 것입니다.
3-6. 포인터의 크기와 타입의 의미
입력 (ptrsize.c)
#include <stdio.h>
int main(void) {
int a = 1;
char b = 'A';
int *pa = &a;
char *pb = &b;
printf("int 포인터 크기: %zu\n", sizeof(pa));
printf("char 포인터 크기: %zu\n", sizeof(pb));
return 0;
}
컴파일과 실행
gcc -Wall ptrsize.c -o ptrsize
./ptrsize
int 포인터 크기: 8
char 포인터 크기: 8
(2026-09-09 실측. 64비트 환경 기준입니다.)
읽는 법: sizeof는 "상자의 크기를 재라"는 연산자이고, %zu는 그 크기를 출력하는 빈칸입니다. 포인터는 무엇을 가리키든 크기가 같습니다(64비트 환경에서 8바이트). 번지를 적은 종이니까, 종이의 크기는 모두 같다는 것입니다. 그럼 왜 int *, char *처럼 종류를 적을까요? "찾아갔을 때 몇 바이트를 읽을지"를 알려 주는 안내판이기 때문입니다.
3-7. 그림으로 그리기 — 손이 기억하는 포인터
코드를 떠나 종이에 그려 봅시다.
[ x ] 번지: 0x100 값: 10
[ p ] 번지: 0x200 값: 0x100 ──→ [ x ]로 화살표
읽는 법: 상자 둘을 그리고, p의 값 칸에 x의 번지를 적고, 화살표를 긋습니다. *p는 "화살표를 따라간 곳"입니다. 이 그림 하나가 오늘 배운 모든 코드의 설계도입니다. 헷갈릴 때마다 코드를 보지 말고 이 그림을 다시 그리세요. 포인터는 그림으로 배우는 개념입니다.
3-8. 포인터와 함수 — 원본을 바꾸는 부품
포인터의 첫 번째 진짜 쓸모입니다. C는 함수에 값을 건네면 복사본이 전달돼서, 함수 안에서 바꿔도 원본은 안 바뀝니다. 그런데 주소를 건네면 원본을 바꿀 수 있습니다.
입력 (swap.c)
#include <stdio.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main(void) {
int x = 10, y = 20;
printf("전: x=%d, y=%d\n", x, y);
swap(&x, &y);
printf("후: x=%d, y=%d\n", x, y);
return 0;
}
컴파일과 실행
gcc -Wall swap.c -o swap
./swap
전: x=10, y=20
후: x=20, y=10
(2026-09-09 실측.)
읽는 법: swap에게 값이 아니라 두 칸의 번지(&x, &y)를 줬습니다. 함수는 그 번지로 찾아가(*a, *b) 원본 둘의 내용을 맞바꿨습니다. "값을 주면 복사본을, 주소를 주면 원본을 바꾼다" — 이 문장이 포인터 쓰임의 절반입니다. scanf가 &를 요구한 이유와 같은 원리입니다. "원본을 바꾸는 함수"는 전부 주소를 받습니다.
4. 미션과 연습문제
미션 — 책 없이 쓰는 포인터 프로그램
- 교재를 덮고, 처음부터 끝까지 혼자 다음 프로그램을 작성합니다.
- int 변수 두 개(x=10, y=20)를 만듭니다.
- 포인터로 x와 y의 값을 바꿉니다(임시 변수 사용).
- 바꾸기 전과 뒤를 출력합니다.
전: x=10, y=20
후: x=20, y=10
- 완성하면 책을 펴서 비교하고, 틀린 곳은 고칩니다.
- 3-7의 그림 방식으로 자신의 프로그램을 종이에 그립니다 — 상자, 번지, 화살표.
- 일부러
int *p = NULL;만 하고*p에 값을 넣는 코드를 실행해 봅니다. Segmentation fault(영역 위반 오류)를 만나는 것도 오늘의 수업입니다. - 3-5의 scanfwhy.c에서
&를 빼고-Wall로 컴파일해, 어떤 경고가 뜨는지 관찰합니다.
연습문제
문제 1. int *p = &x;에서 별표의 뜻과, 그 아래 줄 *p = 99;에서 별표의 뜻이 어떻게 다른지 말해 보세요.
문제 2. 어떤 포인터 p에 대해 p, *p, &p를 각각 출력하면 전부 다른 값이 나옵니다. 셋이 각각 무엇인지, 3-7의 상자 그림 용어로 설명해 보세요.
문제 3. 실측에서 int 포인터와 char 포인터의 크기는 둘 다 8바이트였습니다. 크기가 같은데 왜 int *와 char *처럼 종류를 구분해 적을까요?
문제 4. "값을 주면 복사본을, 주소를 주면 원본을 바꾼다"는 문장으로, scanf가 &age를 요구한 이유와 swap이 &x, &y를 받은 이유를 하나의 원리로 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
미션의 뼈대는 3-8의 swap과 같습니다. 함수 없이 main 안에서만 하면 이렇게 됩니다:
#include <stdio.h>
int main(void) {
int x = 10, y = 20;
int *px = &x, *py = &y;
int temp;
printf("전: x=%d, y=%d\n", x, y);
temp = *px;
*px = *py;
*py = temp;
printf("후: x=%d, y=%d\n", x, y);
return 0;
}
실행 결과 (2026-09-09 실측)
전: x=10, y=20
후: x=20, y=10
검증하는 법: ① 책을 안 보고 썼다는 것이 핵심이고, 틀렸다면 고친 자국이 공부의 증거입니다. ② 그림에서는 상자가 네 개(x, y, px, py)이고 화살표가 두 개(px→x, py→y)여야 합니다. ③ 4번 실험의 Segmentation fault와 5번 실험의 경고 메시지를 눈으로 확인했는가 — 실측 메시지는 벽 2와 벽 3에 있습니다.
연습문제 해답
문제 1 해답. 선언 자리(int *p)의 별표는 "이 변수는 주소 상자입니다"라는 표시입니다. 사용 자리(*p = 99)의 별표는 "p에 적힌 주소로 찾아가서"라는 동작입니다. 같은 기호, 다른 뜻 — 자리를 보면 구분됩니다.
문제 2 해답. p는 종이에 적힌 번지(x의 주소), *p는 그 번지로 찾아가서 본 값(x의 내용), &p는 그 종이 자체가 놓인 칸의 번지(p 자신의 주소)입니다. 그림에서 x 상자, p 상자, 화살표 셋 중 어디를 가리키는 말인지 찾으면 헷갈리지 않습니다.
문제 3 해답. 포인터의 크기는 "번지를 적은 종이"의 크기라 무엇을 가리키든 같습니다(64비트에서 8바이트). 그래도 종류를 적는 이유는, 그 종류가 "찾아갔을 때 몇 바이트를, 어떤 형태로 읽을지"의 안내판이기 때문입니다. 안내판과 실제 내용이 어긋나면 엉뚱하게 읽습니다.
문제 4 해답. 둘 다 "원본을 바꿔야 하는 함수"이기 때문입니다. scanf는 원본 변수에 입력값을 넣어야 하고, swap은 원본 둘의 내용을 맞바꿔야 합니다. 값을 건네면 함수는 복사본만 받아 원본에 닿을 수 없고, 주소를 건네야 번지로 찾아가 원본을 바꿀 수 있습니다.
완료 기준 체크리스트
- [ ] 변수의 주소를 %p로 출력할 수 있다
- [ ] 주소가 실행할 때마다 바뀌는 것이 정상임을 안다
- [ ] 선언의 *와 사용의 *를 구분해 설명할 수 있다
- [ ] 포인터로 다른 변수의 값을 읽고 바꿀 수 있다
- [ ] scanf에 &를 주는 이유를 "칸의 번지"로 설명할 수 있다
- [ ] Segmentation fault를 직접 만나고 원인을 설명할 수 있다
- [ ] 포인터 프로그램을 책 없이 작성하고 그림으로 설명할 수 있다
6. 흔한 실수와 해결
**벽 1. 선언의 와 사용의 를 혼동한다
증상: int *p = &x; 해 놓고, 다음 줄에 *p = &y;처럼 별표를 또 붙입니다.
원인: 같은 별표라 뜻이 같다고 생각했습니다. 선언의 *는 "주소 상자 표시", 사용의 *는 "찾아가기"입니다.
해결: 문장을 소리 내어 읽는 습관 — "int 별 p"는 "정수 주소 상자 p", "별 p는"은 "p가 가리키는 곳은". 자리를 보면 구분이 됩니다.
벽 2. Segmentation fault (세그폴트)
증상: int *p = NULL; *p = 5; 같은 코드를 실행하면 이렇게 죽습니다 (2026-09-09 실측):
Segmentation fault (core dumped)
원인: 가리킬 곳이 정해지지 않았거나(NULL, 쓰레기 번지), 접근이 허락되지 않은 영역에 손을 댔습니다. 운영체제가 "그 칸은 네 것이 아니다"라며 프로그램을 내쫓는 것입니다.
해결: "이 포인터에 유효한 번지를 넣은 적이 있는가?"를 물으세요. 세그폴트는 초보 시절의 통과의례입니다 — 미션 4번에서 일부러 만나 본 것이 예방 접종입니다.
벽 3. 초기화하지 않은 포인터 — 안 죽는 게 더 무섭다
증상: int *p;만 선언하고 *p = 5;를 썼는데, 컴파일 경고와 함께 (2026-09-09 실측):
segv.c:5:8: warning: 'p' is used uninitialized [-Wuninitialized]
원인: 번지를 넣은 적 없는 포인터는 쓰레기 번지를 들고 있습니다.
해결: 주의할 점 — 실측에서는 이 프로그램이 죽지 않고 우연히 실행돼 버렸습니다. 쓰레기 번지가 하필 쓸 수 있는 칸이었기 때문입니다. "돌아가는 것"과 "올바른 것"은 다릅니다. 포인터는 선언과 동시에 유효한 번지(또는 NULL)를 넣는 습관을 들이세요.
*벽 4. p와 p와 &p를 섞어 출력한다
증상: 셋을 찍어 봤더니 전부 다른 것이 나와 패닉.
원인: 정상입니다. p는 "적힌 번지", *p는 "그 번지의 값", &p는 "p 자신의 번지"로 셋은 다른 존재입니다.
해결: 3-7의 그림을 그리고 각각이 그림의 어느 부분인지 표시해 보세요. 그림에서 자리를 찾으면 코드에서도 헷갈리지 않습니다.
벽 5. 포인터의 타입을 무시한다
증상: int *에 char 변수의 주소를 넣었다가 이상한 값을 읽습니다.
원인: 타입은 "찾아갔을 때 몇 바이트를, 어떻게 읽을지"의 안내판인데, 안내판과 실제 내용이 어긋났습니다.
해결: 컴파일러의 경고를 읽으세요. 포인터 타입 불일치는 -Wall이 경고해 줍니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 주소(address) | 변수가 사는 메모리 칸의 번지 — %p로 출력 |
| 포인터(pointer) | 주소를 값으로 담는 변수 — 번지를 적은 종이 |
| & | "이 변수의 주소를 줘" |
| *(사용 자리) | "적힌 주소로 찾아가서" — 역참조 |
| 역참조(dereference) | 주소를 따라가 값에 닿는 행위 |
| Segmentation fault | 남의 칸에 손대 운영체제가 쫓아내는 오류 |
오늘의 문법
| 문법 | 하는 일 |
|---|---|
&x |
x의 주소 |
int *p = &x; |
int를 가리키는 포인터 선언 + 번지 저장 |
*p |
p가 가리키는 칸의 값 (읽기) |
*p = 99; |
p가 가리키는 칸에 쓰기 |
%p |
주소 출력 빈칸 |
sizeof(포인터) |
포인터의 크기 — 64비트에서 8바이트 |
문법보다 중요한 감각
"번지를 아는 자는 그 칸의 주인처럼 행동할 수 있다." 정상 프로그램에서 포인터는 이 힘의 올바른 사용이고, 메모리 공격은 이 힘의 오용입니다. 공격자가 "가리키는 번지"를 자기 마음대로 바꿔 치면, 프로그램은 공격자가 적은 곳을 찾아가 원하는 일을 합니다 — 그 기술의 뿌리에는 언제나 오늘의 두 기호 &와 *가 있습니다.
그리고 포인터는 하루에 이해되지 않습니다. C를 오래 쓴 사람들도 처음엔 다들 며칠을 헤맸습니다. 오늘 머리에 안 들어왔다면 정상이고, 내일 3-1부터 3-4까지만 다시 실행해 보세요. 반복 실행 다섯 번쯤이면 &와 *가 손에 붙기 시작합니다. 개념이 아니라 감각으로 이해하는 것이 포인터 정복의 순서입니다.
전부 체크되면 Step 58 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.