Step 60. 메모리 구조 — 실행 중인 프로그램의 지도
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 56~59를 마쳤다. 포인터와 배열을 주소로 확인해 봤다.
- 준비물: 리눅스 터미널, gcc, 지도를 그릴 종이와 펜.
- 주의: 오늘 실습은 내 컴퓨터 안의 실험뿐이라 안전합니다. 주소는 실행할 때마다 달라집니다 — 오늘은 그것 자체가 주제 중 하나입니다.
지금까지 우리는 변수 하나하나의 번지를 봤습니다. 오늘은 지도 전체를 펼칩니다. 프로그램이 실행되면 운영체제는 그 프로그램에게 메모리라는 큰 땅을 내어 주는데, 그 땅은 무작정이 아니라 용도별로 구획이 나뉘어 있습니다. 기계어가 사는 구역, 오래 사는 변수들의 구역, 함수가 잠깐 쓰는 구역, 직접 빌려 쓰는 구역.
이 지도가 왜 중요할까요. 보안 공격의 유명한 기법들 이름을 들어 보세요 — 스택 오버플로우, 힙 오버플로우. 전부 이 지도의 지명입니다. 어느 구역에서 일어나는 사고인지에 따라 기법도 방어도 달라집니다. 오늘은 변수들의 주소를 찍어 그 구획을 직접 눈으로 확인합니다. 지도를 보는 것과 지도를 그려 보는 것은 다릅니다. 오늘은 그립니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 실행 중인 프로그램의 메모리가 네 영역(코드/데이터/힙/스택)으로 나뉨을 설명한다
- 전역, 정적, 지역 변수와 malloc으로 빌린 칸이 각각 어느 영역에 사는지 안다
- 변수들의 주소를 찍어 영역별 위치를 눈으로 확인한다
- 스택과 힙이 자라는 방향을 말할 수 있다
- ASLR로 번지가 바뀌어도 유지되는 것(구역의 순서)을 구분한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C 언어, 리눅스 터미널, gcc -Wall (실측: Ubuntu 13.3.0, 64비트) |
| 오늘의 문법 | 전역·정적(static)·지역 변수, malloc/free, 함수의 재귀 호출, %p로 주소 비교 |
| 필요한 개념 | 메모리 4구역(코드/데이터/힙/스택), 변수의 수명 = 구역, 스택과 힙의 성장 방향, ASLR |
2-1. 네 개의 구역
실행 중인 프로그램(프로세스)의 메모리는 크게 넷으로 나뉩니다.
- 코드(code) 영역: 번역된 기계어 명령이 사는 곳. 우리가 만든 실행 파일의 내용이 여기 실립니다. 읽기 전용이라 함부로 못 바꿉니다.
- 데이터(data) 영역: 프로그램이 살아 있는 동안 내내 살아 있는 변수들 — 전역 변수와 정적(static) 변수가 사는 곳.
- 힙(heap) 영역: 프로그램이 실행 중에 "이만큼 빌려 주세요" 하고 직접 빌리는 땅. 낮은 주소에서 높은 주소로 자랍니다.
- 스택(stack) 영역: 함수가 불릴 때마다 지역 변수들이 차곡차곡 쌓이는 곳. 함수가 끝나면 싹 정리됩니다. 높은 주소에서 낮은 주소로 자랍니다.
힙과 스택이 마주 보며 자란다고 기억하면 좋습니다.
2-2. 변수의 신분증 — 어디 사느냐는 어떻게 정해지나
변수가 어느 구역에 사는지는 "어떻게 만들었는가"가 정합니다. 함수 밖에 만든 전역 변수와 static 표시를 붙인 정적 변수는 데이터 영역(프로그램과 운명을 함께함). 함수 안에 만든 지역 변수는 스택(함수와 운명을 함께함). malloc으로 빌린 것은 힙(내가 반납할 때까지).
수명이 다르고, 그 수명이 구역으로 나뉜 것입니다. 구역을 아는 것은 곧 수명을 아는 것입니다.
2-3. malloc — 힙에서 땅 빌리기
오늘 처음 쓰는 함수입니다. malloc(sizeof(int))는 "int 한 칸만큼 힙에서 빌려 줘"이고, 빌린 칸의 주소를 돌려줍니다. 다 쓰면 free(주소)로 반납합니다. 빌리고 반납하는 이 직접 관리가 힙의 특징입니다 — 반납을 잊으면 땅이 새어 나갑니다(메모리 누수).
2-4. ASLR — 매번 바뀌는 번지
주소를 찍을 때마다 번지가 바뀌는 것, Step 58~59에서 봤습니다. 이것은 ASLR(Address Space Layout Randomization, 주소 공간 무작위 배치)이라는 보호 장치 때문입니다. 실행할 때마다 구역들의 시작 위치를 무작위로 섞어, 공격자가 "그 번지"를 예측하기 어렵게 만듭니다. 지금은 "구역들의 상대적 위치는 유지되되, 시작점이 매번 바뀐다"로 알아 두면 충분합니다.
3. 따라 하기
오늘의 모든 주소는 실행할 때마다 달라집니다. 숫자가 아니라 무리(어느 앞자리끼리 모이는가)와 순서(누가 높은가)를 보세요.
3-1. 네 신분의 변수, 주소 찍기
입력 (memmap.c)
#include <stdio.h>
#include <stdlib.h>
int global_var = 100; /* 전역 변수 */
static int static_var = 200; /* 정적 변수 */
int main(void) {
int local_var = 300; /* 지역 변수 */
int *heap_var = malloc(sizeof(int)); /* 힙에서 빌린 칸 */
*heap_var = 400;
printf("코드(main의 주소): %p\n", main);
printf("데이터(전역): %p\n", &global_var);
printf("데이터(정적): %p\n", &static_var);
printf("힙(malloc): %p\n", heap_var);
printf("스택(지역): %p\n", &local_var);
free(heap_var);
return 0;
}
컴파일과 실행 (두 번 실행해 봅니다)
gcc -Wall memmap.c -o memmap
./memmap
(1회차)
코드(main의 주소): 0x5dfb098fe1a9
데이터(전역): 0x5dfb09901010
데이터(정적): 0x5dfb09901014
힙(malloc): 0x5dfb1e41a2a0
스택(지역): 0x7ffefc90e31c
(2회차)
코드(main의 주소): 0x5a59993a71a9
데이터(전역): 0x5a59993aa010
데이터(정적): 0x5a59993aa014
힙(malloc): 0x5a59bfca92a0
스택(지역): 0x7ffc4cb2848c
(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)
읽는 법: 주소의 앞자리를 비교하세요. 코드와 데이터는 0x5d...(2회차엔 0x5a...)로 시작하는 이웃이고, 힙도 그 근처에 있습니다. 그런데 스택만 0x7ff...로 훨씬 큰 번지에 따로 있습니다. 구역이 실제로 나뉘어 있다는 것이 주소에서 보입니다. 전역(…010)과 정적(…014)은 4바이트 차이로 나란히 붙어 있습니다 — 같은 데이터 구역의 이웃인 증거입니다.
두 실행을 비교하면, ASLR로 번지는 전부 바뀌었지만 무리와 순서는 그대로입니다. 코드 < 데이터 < 힙 < 스택. 이것이 지도에서 변하지 않는 뼈대입니다.
왜: 이 다섯 줄의 출력이 오늘의 지도입니다. "전역/정적은 낮은 쪽에 몰리고, 지역은 높은 쪽 스택에"라는 그림을 눈으로 본 것이 수확입니다.
3-2. 스택은 함수가 부를 때마다 쌓인다
입력 (stackgrow.c)
#include <stdio.h>
void deeper(int depth) {
int here = depth;
printf("깊이 %d의 지역 변수 주소: %p\n", depth, &here);
if (depth < 3) {
deeper(depth + 1);
}
}
int main(void) {
deeper(1);
return 0;
}
컴파일과 실행
gcc -Wall stackgrow.c -o stackgrow
./stackgrow
깊이 1의 지역 변수 주소: 0x7ffec80f3064
깊이 2의 지역 변수 주소: 0x7ffec80f3034
깊이 3의 지역 변수 주소: 0x7ffec80f3004
(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)
읽는 법: 함수가 자기 자신을 부를 때(재귀 호출)마다 새 지역 변수가 생기는데, 주소가 낮아지는 쪽으로 쌓입니다. 끝자리가 64 → 34 → 04로, 16진수로 0x30(48)씩 줄어듭니다. 스택이 "위에서 아래로" 자란다는 것의 실황입니다. 함수가 끝나면 이 칸들은 자동으로 정리됩니다.
왜: 스택의 성장 방향은 스택 오버플로우라는 사고를 이해하는 데 필요한 지도의 한 축입니다.
3-3. 수명 실험 — 함수가 끝난 뒤의 지역 변수
입력 (life.c)
#include <stdio.h>
int *make(void) {
int temp = 42;
printf("함수 안에서 temp 주소: %p\n", &temp);
return &temp;
}
int main(void) {
int *p = make();
printf("돌려받은 주소: %p\n", p);
return 0;
}
컴파일과 실행
gcc -Wall life.c -o life
./life
(컴파일 경고)
life.c: In function 'make':
life.c:6:12: warning: function returns address of local variable [-Wreturn-local-addr]
6 | return &temp;
| ^~~~~
(실행 결과)
함수 안에서 temp 주소: 0x7ffd85081434
돌려받은 주소: (nil)
(2026-09-09 실측.)
읽는 법: 두 가지를 보세요. 첫째, 컴파일러가 경고합니다 — "지역 변수의 주소를 돌려주려는데, 그 변수는 함수가 끝나면 사라지는데?" 스택의 변수는 함수와 운명을 함께하기 때문입니다. 둘째, 실행 결과가 (nil)입니다. 요즘 컴파일러(gcc 13 실측)는 이 실수를 감지하면 아예 0번지(NULL)를 돌려주도록 바꿔 버립니다. 컴파일러가 두 번이나 말려 준 셈입니다.
돌려받은 주소는 "이미 철수한 자리"를 가리키게 됩니다. 사라진 자리를 가리키는 포인터는 유명한 버그의 한 종류이고, "돌아가는 것처럼 보이는 것"과 "올바른 것"이 다른 대표 사례입니다.
왜: 구역은 곧 수명입니다. 스택=함수와 함께 죽음, 데이터=프로그램과 함께 삶, 힙=내가 반납할 때까지. 이 삼분법이 변수의 신분증입니다.
3-4. 데이터 구역은 프로그램이 끝날 때까지 산다
전역 변수의 수명을 확인하는 실험입니다.
입력 (global_life.c)
#include <stdio.h>
int counter = 0;
void visit(void) {
counter++;
printf("방문 %d번째, counter 주소 %p\n", counter, &counter);
}
int main(void) {
visit();
visit();
visit();
return 0;
}
컴파일과 실행
gcc -Wall global_life.c -o global_life
./global_life
방문 1번째, counter 주소 0x618ad75a9014
방문 2번째, counter 주소 0x618ad75a9014
방문 3번째, counter 주소 0x618ad75a9014
(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)
읽는 법: 함수가 세 번 불려도 counter의 주소는 같고, 값은 계속 살아남아 늘어납니다. 데이터 구역의 변수는 함수와 운명을 함께하지 않습니다 — 프로그램 전체와 함께 삽니다. 지역 변수였다면 매번 새 칸에 0부터 다시 시작했을 것입니다. 이 대조가 "구역 = 수명"의 실황입니다.
예측: counter를 visit 안의 지역 변수로 옮기면 출력이 어떻게 될까요? 그리고 그 지역 변수 앞에
static을 붙이면 또 어떻게 달라질까요? 이 둘을 실험하면 데이터 구역의 이해가 완성됩니다.
3-5. 힙의 성장 방향 — 두 번 빌려 보기
입력 (malloc2.c)
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *a = malloc(sizeof(int));
int *b = malloc(sizeof(int));
printf("첫 번째 힙 칸: %p\n", a);
printf("두 번째 힙 칸: %p\n", b);
free(a);
free(b);
return 0;
}
컴파일과 실행
gcc -Wall malloc2.c -o malloc2
./malloc2
첫 번째 힙 칸: 0x5e4cb59202a0
두 번째 힙 칸: 0x5e4cb59202c0
(2026-09-09 실측. 주소는 실행할 때마다 다릅니다.)
읽는 법: 두 번째 칸(…2c0)이 첫 번째(…2a0)보다 높은 번지입니다. 힙이 "낮은 쪽에서 위로" 자란다는 실황입니다. 스택(3-2, 아래로 자람)과 반대 방향 — 마주 보고 자라는 그림이 실측으로 확인됐습니다.
3-6. 지도 그리기 — 손으로 정리
종이에 세로로 긴 직사각형을 하나 그리고, 위에서 아래로 이렇게 채웁니다.
높은 주소 ┌──────────┐
│ 스택 │ (아래로 자람)
│ ↓ │
│ ... │
│ ↑ │
│ 힙 │ (위로 자람)
│ 데이터 │
낮은 주소 │ 코드 │
└──────────┘
읽는 법: 3-1의 출력에서 스택(0x7ff…)이 제일 컸던 것, 코드/데이터(0x5d…)가 낮았던 것과 이 그림이 일치합니다. 힙(3-5, 위로 자람)과 스택(3-2, 아래로 자람)이 마주 보는 모양도 새기세요. 이 그림 하나가 오늘 전체의 요약이고, 앞으로 배울 수많은 주제의 배경화면입니다.
4. 미션과 연습문제
미션 — 나만의 메모리 지도 리포트
- memmap.c를 확장해 여섯 가지의 주소를 찍습니다: main 함수, 전역 변수, 정적 변수, 지역 변수 둘(둘의 거리 확인), malloc으로 빌린 칸.
- 출력을 보고, 번지를 큰 순서대로 나열합니다.
- 그 순서가 3-6의 지도 그림과 맞는지 확인하고, 노트에 지도를 그려 실제 주소를 옆에 적습니다.
- 같은 프로그램을 세 번 실행해, ASLR로 번지가 바뀌어도 "순서"가 유지되는지 확인합니다.
- 결과를 리포트로 정리합니다: 각 변수의 신분(어느 구역인가), 실제 주소, 실행 간 변화 관찰.
연습문제
문제 1. 코드/데이터/힙/스택 네 구역에 각각 무엇이 사는지, 그리고 어느 구역이 어느 방향으로 자라는지 말해 보세요.
문제 2. "구역은 곧 수명이다"라는 문장을, 지역 변수·전역 변수·malloc으로 빌린 칸 셋의 예로 설명해 보세요.
문제 3. 같은 프로그램을 두 번 실행했더니 모든 주소가 달라졌지만, 어떤 것은 유지되었습니다. 무엇이 바뀌고 무엇이 유지되며, 번지가 매번 바뀌는 이 장치의 이름과 목적은 무엇인가요?
문제 4. 함수가 자기 지역 변수의 주소를 돌려주면 안 되는 이유를 "스택의 수명"으로 설명하고, 실측에서 컴파일러가 이 실수에 어떻게 반응했는지(두 가지) 말해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
memmap.c에 지역 변수 하나를 더한 형태가 뼈대입니다:
#include <stdio.h>
#include <stdlib.h>
int global_var = 100;
static int static_var = 200;
int main(void) {
int local_a = 300;
int local_b = 400;
int *heap_var = malloc(sizeof(int));
*heap_var = 500;
printf("코드(main): %p\n", main);
printf("데이터(전역): %p\n", &global_var);
printf("데이터(정적): %p\n", &static_var);
printf("힙(malloc): %p\n", heap_var);
printf("스택(지역a): %p\n", &local_a);
printf("스택(지역b): %p\n", &local_b);
free(heap_var);
return 0;
}
검증하는 법: ① 큰 순서로 나열하면 스택 > 힙 > 데이터 > 코드가 되어야 합니다(3-1 실측: 스택은 0x7ff…, 나머지는 0x5d… 무리). ② 지역 변수 둘은 스택 안에서 서로 가까운 번지여야 합니다. ③ 세 번 실행해 번지가 매번 바뀌어도 순서표가 유지되면, "구조는 고정, 시작점은 무작위"를 확인한 것입니다. ④ 리포트의 각 변수 옆에 구역 이름(코드/데이터/힙/스택)이 적혀 있으면 완성입니다.
연습문제 해답
문제 1 해답. 코드 구역에는 번역된 기계어가, 데이터 구역에는 전역·정적 변수가, 힙에는 malloc으로 빌린 칸이, 스택에는 함수의 지역 변수가 삽니다. 스택은 높은 주소에서 아래로, 힙은 낮은 주소에서 위로 자랍니다 — 둘은 마주 보고 자랍니다.
문제 2 해답. 지역 변수는 스택에 살아 함수가 끝나면 함께 사라지고, 전역·정적 변수는 데이터에 살아 프로그램이 끝날 때까지 남고(3-4에서 주소도 값도 유지됐습니다), malloc으로 빌린 칸은 힙에 살아 내가 free로 반납할 때까지 유지됩니다. 사는 곳이 곧 사는 기간입니다.
문제 3 해답. 바뀌는 것은 각 구역의 시작 번지 전부이고, 유지되는 것은 구역의 무리와 순서(코드 < 데이터 < 힙 < 스택)입니다. 이 장치는 ASLR(주소 공간 무작위 배치)로, 공격자가 목표 주소를 미리 예측하지 못하게 하려는 보호 기능입니다.
문제 4 해답. 지역 변수는 스택에 살고 함수가 끝나면 그 칸은 정리됩니다. 주소를 돌려받아 봤자 "이미 철수한 자리"를 가리킬 뿐입니다. 실측에서 컴파일러는 ① 컴파일 때 warning: function returns address of local variable로 경고하고, ② 실행 때는 아예 (nil)(NULL)을 돌려주도록 처리했습니다 — 두 번 말려 주는 셈입니다.
완료 기준 체크리스트
- [ ] 메모리 네 구역의 이름과 역할을 말할 수 있다
- [ ] 전역/정적/지역/힙 변수가 각각 어느 구역인지 설명할 수 있다
- [ ] 주소 출력으로 구역의 위치(높은 쪽/낮은 쪽)를 확인할 수 있다
- [ ] 스택은 아래로, 힙은 위로 자람을 실측으로 확인했다
- [ ] ASLR로 번지가 바뀌어도 구역의 순서는 유지됨을 안다
- [ ] 지역 변수의 주소를 돌려주면 안 되는 이유를 설명할 수 있다
- [ ] 나만의 메모리 지도 리포트를 완성했다
6. 흔한 실수와 해결
벽 1. 주소가 매번 바뀌어 "내가 뭘 잘못했나" 한다
증상: 실행할 때마다 번지가 달라져서 혼란.
원인: ASLR이라는 정상적인 보호 기능입니다. 고장이 아닙니다. 3-1 실측에서도 1회차(0x5d…)와 2회차(0x5a…)의 번지가 전부 달랐습니다.
해결: 절대 번지가 아니라 "무리와 순서"를 보세요. 코드/데이터/힙은 낮은 쪽, 스택은 높은 쪽 — 이 구조는 매번 유지됩니다. 지도에서 중요한 것은 번지 숫자가 아니라 상대 위치입니다.
벽 2. &를 빼고 포인터를 찍는다 (또는 반대)
증상: 지역 변수의 주소를 찍으려는데 이상한 값이 나옵니다.
원인: &local_var가 아니라 그냥 local_var를 %p로 찍었거나, 반대의 경우입니다. 포인터 변수(heap_var)는 이미 주소가 값이라 그대로 찍고, 일반 변수는 &를 붙입니다.
해결: "내가 찍고 싶은 것은 이 변수가 사는 번지인가, 이 변수에 적힌 번지인가"를 물으세요. 전자는 &변수, 후자는 포인터 변수 그 자체입니다.
벽 3. 함수의 주소를 찍다가 괄호를 붙인다
증상: main의 주소를 찍으려다 printf("%p", main())이라고 써서 엉뚱한 일이 납니다.
원인: 괄호를 붙이면 "함수를 실행하라"입니다. 이름만 쓰면 그 함수가 사는 번지(코드 영역)가 됩니다.
해결: 함수도 이름은 주소라는 것을 기억하세요. 배열 이름이 첫 칸의 주소였던 것과 같은 원리입니다. 컴파일러가 형 변환을 요구하면 (void*)main처럼 감싸면 됩니다.
벽 4. "지역 변수 주소 돌려주기"의 함정을 가볍게 본다
증상: 3-3의 경고를 보고도 "돌아가긴 하던데?" 하고 넘깁니다.
원인: 사라진 자리의 주소도 "당장은" 값이 남아 있어 보일 수 있습니다. 다른 함수가 그 자리를 덮기 전까지요. (실측에서는 컴파일러가 아예 (nil)로 바꿔 버렸지만, 그것은 이 컴파일러의 호의일 뿐 규칙이 아닙니다.)
해결: "돌아가는 것"과 "올바른 것"은 다릅니다. 언제 덮일지 모르는 자리를 가리키는 코드는 시한폭탄입니다. 경고가 뜬 코드는 고치는 것이 원칙입니다.
벽 5. malloc만 하고 free를 잊는다
증상: 빌리기만 하고 반납하지 않습니다.
원인: 힙은 자동 정리가 없습니다. 반납은 빌린 사람의 책임입니다.
해결: malloc과 free를 짝꿍처럼 함께 쓰세요. 반납을 잊으면 프로그램이 도는 동안 땅이 조금씩 새어 나갑니다(메모리 누수, memory leak). 오늘 코드들의 free(heap_var);가 그 습관의 시작입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 코드 영역 | 번역된 기계어가 사는 곳 — 읽기 전용 |
| 데이터 영역 | 전역·정적 변수가 사는 곳 — 프로그램과 운명을 함께 |
| 힙 영역 | malloc으로 직접 빌리는 땅 — 위로 자람, free로 반납 |
| 스택 영역 | 함수의 지역 변수가 쌓이는 곳 — 아래로 자람, 함수와 함께 정리 |
| ASLR | 구역의 시작점을 매번 섞는 보호 장치 — 순서는 유지, 번지는 무작위 |
오늘의 문법
| 문법 | 하는 일 |
|---|---|
int global_var = 100; (함수 밖) |
데이터 영역에 사는 전역 변수 |
static int s = 200; |
정적 변수 — 데이터 영역 |
int local = 300; (함수 안) |
스택에 사는 지역 변수 |
malloc(sizeof(int)) |
힙에서 한 칸 빌리기 — 주소를 돌려줌 |
free(포인터) |
빌린 힙 칸 반납 |
%p |
주소 출력 — 무리와 순서로 구역을 읽는다 |
문법보다 중요한 감각
오늘 여러분은 실행 중인 프로그램의 지도를 손에 넣었습니다. 변수를 볼 때 "이건 스택에 사는 녀석이군" 하고 생각하는 습관, 이것이 메모리를 보는 눈의 완성형입니다. "스택 기반 버퍼 오버플로우", "힙 스프레이" 같은 기법 이름이 이제 지도 위의 지명으로 읽히기 시작할 것입니다.
지도는 외우는 것이 아니라 펼쳐 보는 것입니다. 변수의 수명을 정할 때(함수가 끝나도 남아야 하면 스택은 답이 아닙니다), 이상한 버그를 만났을 때("이 포인터가 가리키는 곳은 어느 구역인가, 그 구역의 수명은?"). 종이에 그린 그 지도를 책상 옆에 붙여 두세요. 이 책의 끝까지 계속 쓰게 될 그림입니다. 그리고 실험에서 번지가 여러분 것과 달라도 전혀 문제없습니다 — 중요한 것은 숫자가 아니라 무리와 순서와 방향입니다.
전부 체크되면 Step 60 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.