페이징을 공부하다가 든 의문
페이징을 이해하고 나서 한 가지 찜찜한 부분이 있었습니다. 4KB 단위로 메모리를 싹둑싹둑 자르는 건 좋은데, 그럼 코드랑 데이터가 한 페이지에 섞이면 어떻게 되는 거지? 코드는 읽기만 가능해야 하고(Read-Only), 데이터는 수정도 가능해야 하는데(Read-Write), 페이징은 그냥 물리적 크기로만 자르잖아요.
회사 서비스 코드를 보다가 실제로 이런 생각이 들었던 적이 있습니다. "만약 버퍼 오버플로우 버그가 있어서 데이터 영역을 덮어쓰다가 실수로 코드 영역까지 건드리면?" 생각만 해도 끔찍했습니다. 그래서 OS가 메모리 영역을 어떻게 보호하는지 궁금해져서 세그먼테이션을 파고들었습니다.
The Struggle: 페이징의 물리적 분할 vs 논리적 의미
처음엔 페이징이 완벽한 솔루션인 줄 알았습니다. 고정 크기로 쪼개면 외부 단편화(External Fragmentation)도 없고, MMU 하드웨어로 빠르게 변환도 되고요. 근데 페이징은 프로그램이 실제로 어떻게 구성되어 있는지는 전혀 신경 쓰지 않습니다.
예를 들어서 제가 작성한 프로그램은 이렇게 구성됩니다.
// Code Segment (코드 영역)
int add(int a, int b) {
return a + b;
}
// Data Segment (전역 변수 영역)
int global_counter = 0;
int main() {
// Stack Segment (함수 호출 스택)
int local_var = 10;
global_counter = add(local_var, 5);
return 0;
}
여기엔 명확히 세 가지 논리적 영역이 있습니다.
- Code Segment:
add()함수,main()함수 같은 명령어들 - Data Segment:
global_counter같은 전역 변수들 - Stack Segment:
local_var같은 지역 변수들
근데 페이징은 이런 의미 구분 없이 그냥 "4KB씩 잘라!" 하고 끝입니다. 그래서 운이 나쁘면 코드 영역의 끝부분과 데이터 영역의 시작 부분이 한 페이지에 같이 들어갈 수도 있습니다.
이게 왜 문제냐면, 보안(Protection) 설정이 애매해진다는 겁니다. 코드 영역은 읽기만 가능하게 하고 싶은데, 같은 페이지에 데이터 영역이 섞여 있으면 "이 페이지는 Read-Only로 할까, Read-Write로 할까?" 선택이 곤란해집니다.
저는 이 부분에서 한참 헤맸습니다. "그럼 페이징은 보안이 약한 거 아니야?" 하고 의문이 들었거든요.
The 'Aha!' Moment: 논리적 단위로 자르면 되잖아!
그러다 세그먼테이션을 제대로 이해하는 순간이 왔습니다. "의미 단위로 자르자"는 발상이었습니다.
프로그램을 4KB 같은 물리적 크기가 아니라, 프로그램이 실제로 어떻게 구성되어 있는지에 따라 나누는 겁니다.
- Code Segment: 코드만 모아서 하나의 세그먼트로
- Data Segment: 전역 변수만 모아서 하나의 세그먼트로
- Stack Segment: 함수 호출 스택만 모아서 하나의 세그먼트로
- Heap Segment: 동적 할당 메모리만 모아서 하나의 세그먼트로
이렇게 나누면 보안 설정이 엄청나게 쉬워집니다.
Code Segment: Read + Execute (읽기, 실행 가능 / 쓰기 불가)
Data Segment: Read + Write (읽기, 쓰기 가능 / 실행 불가)
Stack Segment: Read + Write (읽기, 쓰기 가능 / 실행 불가)
Heap Segment: Read + Write (읽기, 쓰기 가능 / 실행 불가)
각 세그먼트마다 권한을 따로 줄 수 있으니까, "코드 영역에 쓰기를 시도하면 무조건 에러"를 깔끔하게 강제할 수 있습니다.
이 비유가 머릿속에 확 들어왔습니다.
"페이징은 책을 일정한 두께(4KB)로 자르는 거고, 세그먼테이션은 책을 챕터 단위로 자르는 거다."
책을 두께로 자르면 한 챕터가 여러 조각으로 나뉠 수 있지만, 챕터로 자르면 의미 단위가 딱 떨어집니다. 1장은 1장, 2장은 2장이죠.
세그먼테이션의 작동 원리 더 알아보기
세그먼테이션이 실제로 어떻게 동작하는지 이해하려면 Segment Table을 알아야 합니다.
Segment Table 구조
페이징이 Page Table을 쓴다면, 세그먼테이션은 Segment Table을 씁니다.
| Segment # | Base Address | Limit (Size) | Permissions |
|---|---|---|---|
| 0 (Code) | 0x1000 | 64KB | R-X |
| 1 (Data) | 0x11000 | 32KB | RW- |
| 2 (Stack) | 0x19000 | 16KB | RW- |
| 3 (Heap) | 0x1D000 | 128KB | RW- |
- Base Address: 이 세그먼트가 물리 메모리 어디에 있는지
- Limit: 이 세그먼트의 크기 (경계)
- Permissions: 읽기(R) / 쓰기(W) / 실행(X) 권한
주소 변환 과정
프로그램이 메모리 주소에 접근할 때, CPU는 이렇게 변환합니다.
Logical Address = <Segment #> + <Offset>
예를 들어, "Segment 1(Data)의 100번째 바이트"를 읽으려면:
Logical Address: <1, 100>
1. Segment Table에서 Segment 1 찾기
- Base Address: 0x11000
- Limit: 32KB (32,768 bytes)
2. Offset이 Limit을 넘는지 체크
- 100 < 32,768 → OK!
- 만약 Offset이 Limit을 넘으면? → Segmentation Fault!
3. Physical Address 계산
- Base + Offset = 0x11000 + 100 = 0x11064
이걸 코드로 시뮬레이션하면 이렇습니다.
#include <stdio.h>
#include <stdbool.h>
// 세그먼트 테이블 엔트리
typedef struct {
unsigned int base;
unsigned int limit;
char permissions[4]; // "RWX"
} SegmentEntry;
// 세그먼트 테이블 (간단히 4개만)
SegmentEntry segment_table[4] = {
{0x1000, 65536, "R-X"}, // Code
{0x11000, 32768, "RW-"}, // Data
{0x19000, 16384, "RW-"}, // Stack
{0x1D000, 131072, "RW-"} // Heap
};
// 논리 주소를 물리 주소로 변환
bool translate_address(int segment_num, int offset, unsigned int* physical_addr) {
if (segment_num < 0 || segment_num >= 4) {
printf("Error: Invalid segment number %d\n", segment_num);
return false;
}
SegmentEntry* seg = &segment_table[segment_num];
// Limit 체크 (세그먼트 경계를 벗어나면 Segmentation Fault)
if (offset < 0 || offset >= seg->limit) {
printf("Segmentation Fault! Offset %d exceeds limit %d in segment %d\n",
offset, seg->limit, segment_num);
return false;
}
// 물리 주소 계산
*physical_addr = seg->base + offset;
printf("Logical Address <Segment %d, Offset %d> → Physical Address 0x%X\n",
segment_num, offset, *physical_addr);
return true;
}
int main() {
unsigned int phys_addr;
// 정상 접근: Data Segment의 100번째 바이트
translate_address(1, 100, &phys_addr);
// Segmentation Fault: Data Segment의 범위를 벗어난 접근
translate_address(1, 50000, &phys_addr);
return 0;
}
실행 결과:
Logical Address <Segment 1, Offset 100> → Physical Address 0x11064
Segmentation Fault! Offset 50000 exceeds limit 32768 in segment 1
이 코드에서 핵심은 Limit 체크입니다. Offset이 Limit을 넘으면 무조건 에러가 나는 게 바로 세그먼테이션의 보안 메커니즘입니다.
Segmentation Fault가 발생하는 이유
여러분이 C 프로그램을 짜다가 한 번쯤 봤을 "Segmentation Fault (core dumped)" 에러가 바로 이거였습니다.
#include <stdio.h>
int main() {
int arr[10];
// 배열 범위를 벗어난 접근 (Stack Segment의 경계를 넘어감)
for (int i = 0; i < 1000000; i++) {
arr[i] = i; // 언젠가 Segmentation Fault 발생!
}
return 0;
}
이 코드는 10개짜리 배열에 100만 개의 값을 쓰려고 합니다. 처음엔 운 좋게 스택의 다른 부분을 덮어쓰다가, 어느 순간 Stack Segment의 Limit을 넘어가면 CPU가 "Segmentation Fault!"를 외치며 프로그램을 강제 종료시킵니다.
이게 세그먼테이션이 제공하는 보안 메커니즘입니다. 프로그램이 자기 세그먼트 밖으로 나가려 하면 무조건 막아버립니다.
세그먼테이션의 치명적 단점 - 외부 단편화의 귀환
"와, 그럼 세그먼테이션이 페이징보다 훨씬 좋은 거 아니야?"
저도 처음엔 그렇게 생각했습니다. 근데 치명적인 단점이 하나 있었습니다. 바로 외부 단편화(External Fragmentation) 문제가 다시 돌아온다는 겁니다.
세그먼테이션의 가장 큰 특징은 세그먼트 크기가 제각각이라는 점입니다.
- Code Segment: 100KB
- Data Segment: 200KB
- Stack Segment: 50KB
- Heap Segment: 300KB
페이징은 모든 페이지가 4KB로 고정이라서, 4KB 빈 공간만 있으면 어디든 집어넣을 수 있었습니다. 근데 세그먼테이션은 크기가 딱 맞는 빈 공간을 찾아야 합니다.
이 비유가 확 와닿았습니다.
"세그먼테이션은 창고에 짐을 정리하는데, 짐마다 크기가 다 달라서 빈 공간에 딱 맞는 짐을 찾기 힘든 상황이다."
예를 들어 메모리 상황이 이렇다고 치자.
|--- 프로세스 A (100KB) ---|--- 비어있음 (150KB) ---|--- 프로세스 B (200KB) ---|
여기서 프로세스 A가 종료되면:
|--- 비어있음 (100KB) ---|--- 비어있음 (150KB) ---|--- 프로세스 B (200KB) ---|
이제 빈 공간이 총 250KB가 있습니다. 근데 이게 두 개로 쪼개져 있어서, 200KB짜리 세그먼트는 못 들어갑니다!
150KB 구멍에도 안 맞고, 100KB 구멍에도 안 맞고. 빈 공간은 충분한데 못 쓰는 겁니다. 이게 바로 외부 단편화입니다.
예전에 회사에서 서버 메모리 사용률을 보다가 비슷한 상황을 본 적이 있습니다. 메모리가 30% 남아 있는데 "Out of Memory" 에러가 난 거예요. 알고 보니 메모리가 잔뜩 조각나 있어서, 큰 덩어리를 할당할 연속된 공간이 없었던 겁니다.
외부 단편화 해결 방법: Compaction
외부 단편화를 해결하는 방법은 Compaction(압축)입니다. 메모리에 있는 모든 세그먼트를 한쪽으로 밀어서 빈 공간을 한 곳에 모으는 겁니다.
Before Compaction:
|--- 프로세스 A ---|--- 비어있음 ---|--- 프로세스 B ---|--- 비어있음 ---|
After Compaction:
|--- 프로세스 A ---|--- 프로세스 B ---|------------- 비어있음 -------------|
근데 이게 엄청나게 비쌉니다. 모든 세그먼트를 옮기려면 메모리를 통째로 복사해야 하고, 그동안 프로그램을 멈춰야 하니까요.
마치 컴퓨터 하드디스크 조각 모음(Defragmentation)을 돌릴 때 몇 시간씩 걸리는 것과 비슷합니다. 저는 예전에 Windows XP 쓸 때 조각 모음 한 번 돌리면 하루 종일 컴퓨터 못 썼던 기억이 납니다.
세그먼테이션 vs 페이징 - 장단점 비교
이 두 방식을 직접 비교해보니 각각 장단점이 명확했습니다.
| 항목 | 페이징 | 세그먼테이션 |
|---|---|---|
| 분할 기준 | 고정 크기 (4KB) | 가변 크기 (논리적 단위) |
| 외부 단편화 | 없음 | 발생함 |
| 내부 단편화 | 발생함 (페이지 끝 부분) | 거의 없음 |
| 보안 설정 | 어려움 (물리적 분할) | 쉬움 (논리적 분할) |
| 공유 | 어려움 | 쉬움 (세그먼트 단위 공유) |
| 하드웨어 구현 | 간단함 (Page Table) | 복잡함 (Segment Table + Limit 체크) |
저는 이 표를 정리하면서 "완벽한 솔루션은 없다"는 걸 받아들였습니다. 페이징은 메모리 관리가 쉽지만 보안이 약하고, 세그먼테이션은 보안이 강하지만 외부 단편화 때문에 메모리 효율이 떨어집니다.
그래서 현대 OS는 둘 다 씁니다.
Paged Segmentation: 두 마리 토끼를 다 잡기
결국 현대 운영체제가 내린 결론은 "둘 다 쓰자"였습니다. 이게 바로 Paged Segmentation(페이지드 세그먼테이션) 방식입니다.
작동 원리
- 먼저 세그먼테이션: 프로그램을 논리적 단위(Code, Data, Stack, Heap)로 나눕니다.
- 그 다음 페이징: 각 세그먼트를 다시 4KB 페이지로 쪼갭니다.
이렇게 하면 세그먼테이션의 장점(보안, 공유)과 페이징의 장점(외부 단편화 없음)을 둘 다 가져갈 수 있습니다.
프로그램
├─ Code Segment (100KB)
│ ├─ Page 0 (4KB)
│ ├─ Page 1 (4KB)
│ ├─ ...
│ └─ Page 24 (4KB)
│
├─ Data Segment (200KB)
│ ├─ Page 0 (4KB)
│ ├─ ...
│ └─ Page 49 (4KB)
│
└─ Stack Segment (50KB)
├─ Page 0 (4KB)
├─ ...
└─ Page 12 (2KB) ← 마지막 페이지는 내부 단편화 가능
주소 변환 과정 (2단계)
주소 변환이 2단계로 늘어납니다.
Logical Address = <Segment #> + <Page #> + <Offset>
예를 들어, "Segment 1(Data)의 Page 5, Offset 100"을 읽으려면:
1. Segment Table에서 Segment 1의 Page Table 찾기
2. Page Table에서 Page 5의 물리 프레임 번호 찾기
3. Physical Address = Frame # × 4KB + Offset
이 방식은 복잡하지만, 보안(세그먼트 권한) + 메모리 효율(페이지 할당)을 동시에 챙길 수 있습니다.
x86의 세그먼테이션 레거시
재밌게도 x86 CPU는 아직도 세그먼테이션 하드웨어를 가지고 있습니다. CS (Code Segment), DS (Data Segment), SS (Stack Segment) 같은 Segment Register들이 바로 그겁니다.
mov ax, 0x1000 ; Data Segment 주소를 DS 레지스터에 설정
mov ds, ax
mov [0x100], bx ; DS:0x100 주소에 쓰기
근데 현대 운영체제(Linux, Windows)는 이 기능을 거의 안 씁니다. 대신 모든 세그먼트를 0부터 4GB까지로 설정해서 사실상 세그먼테이션을 무력화시킵니다. 이를 Flat Memory Model이라고 합니다.
왜 안 쓸까요?
- 외부 단편화 문제가 너무 심각함
- 페이징만으로도 충분히 보안 설정 가능함 (Page Table Entry에 권한 비트 추가)
- 64비트로 넘어오면서 세그먼테이션이 사실상 deprecated됨
x86-64 아키텍처는 아예 세그먼테이션을 거의 제거했습니다. FS, GS 레지스터만 특수 용도로 남아있고, 나머지는 다 flat 모드로 동작합니다.
저는 어셈블리 코드를 처음 봤을 때 DS, CS 같은 레지스터를 보고 "이게 뭐지?" 했는데, 알고 보니 이게 세그먼테이션의 흔적이었습니다. 하드웨어는 남아있지만 소프트웨어는 안 쓰는 기이한 상황인 거죠.
Application: 내 서비스에선 어떻게 적용되나?
세그먼테이션을 이해하고 나니, 프로그램의 메모리 레이아웃을 보는 눈이 달라졌습니다.
Segmentation Fault 디버깅
예전에 제 서비스에서 가끔 "Segmentation Fault" 에러가 나서 서버가 죽는 일이 있었습니다. 처음엔 "왜 갑자기 죽지?" 하고 당황했는데, 세그먼테이션을 이해하고 나니 원인을 추적하기 쉬워졌습니다.
// 버그 예시
char* ptr = NULL;
strcpy(ptr, "Hello"); // Segmentation Fault!
ptr이 NULL이면 0x00000000 주소에 쓰려고 합니다. 근데 이 주소는 어떤 세그먼트에도 속하지 않으니까 Segment Table에서 매칭이 안 되고, 그래서 Segmentation Fault가 발생합니다.
이제는 Segmentation Fault가 나면 "아, 세그먼트 경계를 벗어난 접근이구나"라고 바로 이해할 수 있습니다.
Docker 컨테이너 메모리 제한
Docker로 컨테이너를 띄울 때 --memory 옵션으로 메모리를 제한할 수 있습니다.
docker run --memory="512m" my-app
이게 내부적으로 어떻게 동작하는지 궁금했는데, 알고 보니 Linux Cgroup이 프로세스마다 메모리 세그먼트 크기를 제한하는 방식이었습니다.
세그먼테이션의 Limit 개념을 소프트웨어적으로 구현한 거죠. 컨테이너가 할당받은 메모리를 넘으려 하면 OOM(Out of Memory) Killer가 프로세스를 강제 종료시킵니다.
코드 리뷰에서의 활용
이제 코드 리뷰할 때 메모리 안전성을 더 잘 체크할 수 있게 됐습니다.
// 위험한 코드
char buffer[10];
scanf("%s", buffer); // 버퍼 오버플로우 가능!
// 안전한 코드
char buffer[10];
scanf("%9s", buffer); // 최대 9글자까지만 읽기 (NULL 종료 문자 고려)
예전엔 "왜 크기 제한을 해야 하지?"라고 막연하게 생각했는데, 이제는 "버퍼를 넘어서면 스택 세그먼트의 다른 부분을 덮어쓸 수 있고, 최악의 경우 코드 세그먼트까지 침범할 수 있다"고 구체적으로 이해하게 됐습니다.
도시 구획 비유 - 최종 정리
세그먼테이션을 한 마디로 정리하자면, "도시를 구획으로 나누는 것"이라고 받아들였습니다.
- 주거 구역 (Stack Segment): 사람들이 살아가는 공간. 읽기/쓰기 가능.
- 상업 구역 (Data Segment): 가게, 사무실. 읽기/쓰기 가능.
- 공업 구역 (Code Segment): 공장, 발전소. 읽기 전용 (함부로 건드리면 큰일).
- 공원 (Heap Segment): 필요에 따라 늘리거나 줄일 수 있는 유동적 공간.
각 구역마다 용도가 다르고, 규제(Permissions)도 다릅니다. 주거 구역에서 공장을 지을 수 없고, 공업 구역에서 집을 지을 수 없듯이, 세그먼테이션은 메모리 영역마다 할 수 있는 일을 명확히 제한합니다.
페이징이 "땅을 일정한 크기로 자르는 것"이라면, 세그먼테이션은 "땅을 용도에 맞게 구획하는 것"입니다. 두 방식 모두 필요하고, 그래서 현대 OS는 둘을 섞어서 씁니다.
저는 이 비유를 통해 세그먼테이션의 본질을 완전히 이해했다고 느꼈습니다. 결국 "의미 있는 분할"이 핵심이었던 거죠.
Summary
- 세그먼테이션은 메모리를 논리적 단위(Code, Data, Stack, Heap)로 나누는 방식입니다.
- 장점: 보안 설정이 쉽고(세그먼트마다 권한 부여), 공유도 쉽습니다(세그먼트 단위로 공유).
- 단점: 세그먼트 크기가 제각각이라 외부 단편화가 발생합니다.
- Segmentation Fault는 세그먼트의 Limit을 벗어난 접근 시 발생하는 에러입니다.
- 현대 OS: Paged Segmentation을 사용합니다. 먼저 세그먼트로 나누고, 각 세그먼트를 다시 페이지로 쪼갭니다.
- x86 레거시: 세그먼테이션 하드웨어는 남아있지만, 현대 OS는 Flat Memory Model을 써서 사실상 무력화시킵니다.
결국 이해한 건, 페이징과 세그먼테이션은 상호 보완적 관계라는 겁니다. 하나만으론 부족하고, 둘을 섞어야 완전해집니다. 마치 도시 계획에 구역 분할(Segmentation)도 필요하고 필지 분할(Paging)도 필요한 것처럼요.