📚 이전 편 복습
[1] CPU 구조편에서 우리는 제어장치, 연산장치, 레지스터의 작동 원리와 명령어 사이클, 파이프라이닝을 배웠습니다. CPU가 명령어를 빠르게 처리하려면 데이터를 빠르게 가져올 수 있어야 합니다. 이번 편에서는 CPU에 데이터를 공급하는 메모리 시스템을 깊이 있게 다룹니다.
📑 목차
🏗️ 메모리 계층 구조란? - 속도와 용량의 트레이드오프
메모리 계층 구조(Memory Hierarchy)는 서로 다른 속도와 용량을 가진 메모리들을 계층적으로 배치한 구조입니다.
왜 메모리 계층이 필요한가?
이상적으로는 무한히 빠르고, 무한히 크고, 무한히 저렴한 메모리가 있으면 좋겠지만, 현실에서는 불가능합니다.
💡 메모리의 3가지 제약
- 속도: 빠른 메모리는 비싸고 용량이 작음
- 용량: 큰 메모리는 느리고 상대적으로 저렴
- 가격: 속도와 용량은 반비례 관계
메모리 계층 구조의 핵심 원리
🎯 지역성의 원리 (Principle of Locality)
프로그램은 최근에 사용한 데이터나 그 근처의 데이터를 다시 사용할 확률이 매우 높습니다.
시간적 지역성 (Temporal Locality)
- 최근에 접근한 데이터를 가까운 시간 내에 다시 접근
- 예: 반복문의 카운터 변수, 함수 내의 지역 변수
공간적 지역성 (Spatial Locality)
- 접근한 데이터의 인근 주소를 곧 접근
- 예: 배열의 연속된 요소 접근, 순차 코드 실행
메모리 계층 구조의 이점
- 성능: 자주 쓰는 데이터는 빠른 메모리에 저장
- 비용: 큰 용량이 필요한 데이터는 저렴한 메모리에 저장
- 효율성: 전체 시스템의 평균 속도 향상
📊 4단계 메모리 계층 구조
메모리는 CPU에서 가까운 순서대로 4단계 계층으로 구성됩니다.
메모리 피라미드
빠름 ↑
│
┌──────┴──────┐
│ 레지스터 │ ← 가장 빠름, 가장 작음
└─────────────┘
┌───────────────┐
│ 캐시 메모리 │ ← 매우 빠름, 작음
└───────────────┘
┌───────────────────┐
│ 주기억장치(RAM) │ ← 빠름, 중간
└───────────────────┘
┌───────────────────────┐
│ 보조기억장치(SSD/HDD) │ ← 느림, 매우 큼
└───────────────────────┘
│
느림 ↓
각 계층의 상세 비교
속도 차이 실감하기
사람의 시간으로 환산하면:
- 레지스터 (0.1ns) = 1초
- 캐시 L1 (1ns) = 10초
- RAM (100ns) = 16분
- SSD (0.1ms) = 11일
- HDD (10ms) = 3년
RAM 접근이 레지스터보다 1000배 느립니다!
메모리 계층 상세표
| 메모리 | 접근 시간 | 용량 | 가격 (GB당) | 휘발성 |
|---|---|---|---|---|
| 레지스터 | 0.1 ns | < 1 KB | - | 휘발성 |
| L1 캐시 | 1 ns | 32-128 KB | 매우 고가 | 휘발성 |
| L2 캐시 | 3-10 ns | 256KB-2MB | 고가 | 휘발성 |
| L3 캐시 | 10-20 ns | 4-32 MB | 고가 | 휘발성 |
| RAM (DRAM) | 50-100 ns | 4-64 GB | $3-10 | 휘발성 |
| SSD (NVMe) | 0.1 ms | 256GB-4TB | $0.1-0.3 | 비휘발성 |
| HDD | 5-10 ms | 500GB-20TB | $0.02-0.05 | 비휘발성 |
⚡ 캐시 메모리의 동작 원리
캐시 메모리(Cache Memory)는 CPU와 RAM 사이에 위치한 초고속 버퍼 메모리입니다.
캐시의 3단계 구조
┌─────────────────────────────────┐
│ CPU 코어 │
│ ┌──────────┐ ┌──────────┐ │
│ │ L1 캐시 │ │ L1 캐시 │ │ ← 각 코어마다
│ │Data│Inst│ │Data│Inst│ │ 독립적으로 존재
│ └──────────┘ └──────────┘ │
│ │ │ │
│ ┌──────┴────────────┴──────┐ │
│ │ L2 캐시 (통합) │ │ ← 각 코어마다
│ └───────────────────────────┘ │ 독립적
└──────────────┬──────────────────┘
│
┌──────┴──────────┐
│ L3 캐시 (공유) │ ← 모든 코어가 공유
└──────┬──────────┘
│
┌──────┴──────┐
│ 주기억장치 │
│ (RAM) │
└─────────────┘
캐시 동작 원리
1. 캐시 히트 (Cache Hit)
CPU가 요청한 데이터가 캐시에 있는 경우
CPU: "주소 0x1000의 데이터 주세요"
캐시: "네, 여기 있습니다!" (1ns 소요)
→ 매우 빠름!
2. 캐시 미스 (Cache Miss)
CPU가 요청한 데이터가 캐시에 없는 경우
CPU: "주소 0x2000의 데이터 주세요"
L1 캐시: "없습니다" (1ns)
L2 캐시: "없습니다" (3ns)
L3 캐시: "없습니다" (10ns)
RAM: "여기 있습니다" (100ns)
→ 캐시에 복사 후 CPU에 전달
총 114ns 소요
캐시 성능 지표
평균 메모리 접근 시간 (AMAT)
예시 계산:
- L1 캐시 히트율: 95%
- L1 히트 시간: 1ns
- RAM 접근 시간: 100ns
AMAT = 1ns + (0.05 × 100ns) = 6ns
캐시가 없었다면 평균 100ns → 16배 빠름!
캐시 교체 정책 (Replacement Policy)
캐시가 가득 찼을 때 어떤 데이터를 버릴지 결정하는 알고리즘
| 정책 | 설명 | 장단점 |
|---|---|---|
| LRU (Least Recently Used) |
가장 오래 사용하지 않은 것을 제거 | 효과적이지만 구현 복잡 |
| FIFO (First In First Out) |
가장 먼저 들어온 것을 제거 | 간단하지만 성능 낮음 |
| Random | 무작위로 선택해서 제거 | 간단하고 의외로 효율적 |
| LFU (Least Frequently Used) |
가장 적게 사용된 것을 제거 | 카운터 필요, 오버헤드 |
🗺️ 캐시 매핑 기법
캐시 매핑은 메모리 주소를 캐시의 어느 위치에 저장할지 결정하는 방법입니다.
1. 직접 매핑 (Direct Mapping)
메모리 주소를 캐시 블록 번호로 나눈 나머지로 매핑
직접 매핑 구조
메모리 주소: [태그 | 인덱스 | 오프셋]
예: 32비트 주소, 64바이트 블록, 1024개 캐시 라인
- 오프셋: 6비트 (2^6 = 64바이트)
- 인덱스: 10비트 (2^10 = 1024개)
- 태그: 16비트 (나머지)
메모리 블록 0, 1024, 2048, ... → 캐시 라인 0
메모리 블록 1, 1025, 2049, ... → 캐시 라인 1
✅ 장점
- 하드웨어 구현 간단
- 검색 속도 빠름 (1번만)
- 비용 저렴
❌ 단점
- 캐시 충돌 빈번
- 히트율 낮음
- 특정 패턴에 취약
2. 완전 연관 매핑 (Fully Associative Mapping)
메모리 블록을 캐시의 어느 위치에나 저장 가능
메모리 주소: [태그 | 오프셋]
모든 캐시 라인을 병렬로 검색
→ 태그가 일치하는 곳이 있으면 히트
예: 메모리 블록 100 → 캐시의 빈 곳 아무데나
✅ 장점
- 캐시 충돌 최소화
- 히트율 최대화
- 유연한 배치
❌ 단점
- 하드웨어 복잡/고가
- 검색 시간 오래 걸림
- 전력 소모 큼
3. 집합 연관 매핑 (Set-Associative Mapping) ⭐
가장 많이 사용되는 방식으로, 직접 매핑과 완전 연관의 절충안
N-Way Set-Associative (N=4 예시)
메모리 주소: [태그 | 세트 인덱스 | 오프셋]
캐시를 세트로 나누고, 각 세트는 N개의 라인 보유
→ 세트는 인덱스로 결정 (직접 매핑)
→ 세트 내에서는 아무 곳이나 (연관 매핑)
예: 4-way set-associative, 1024개 라인
- 256개 세트, 각 세트당 4개 라인
- 메모리 블록 0 → 세트 0의 4개 라인 중 하나
- 메모리 블록 256 → 세트 0의 4개 라인 중 하나 (경쟁)
💡 실제 CPU 예시
- Intel Core i7: L1 8-way, L2 4-way, L3 16-way
- AMD Ryzen: L1 8-way, L2 8-way, L3 16-way
높은 Way 수 = 더 좋은 히트율, 하지만 비용↑
매핑 방식 비교
| 방식 | 배치 위치 | 검색 횟수 | 히트율 | 복잡도 |
|---|---|---|---|---|
| 직접 매핑 | 1곳만 가능 | 1번 | 낮음 | 낮음 |
| 완전 연관 | 모든 곳 | 전체 | 높음 | 높음 |
| 집합 연관 | 세트 내 | N번 | 중상 | 중간 |
🌐 가상 메모리와 페이징
가상 메모리(Virtual Memory)는 물리 메모리보다 큰 프로그램을 실행할 수 있게 해주는 기술입니다.
가상 메모리의 개념
프로그램은 가상 주소를 사용하고, OS가 이를 물리 주소로 변환합니다.
- 가상 주소 공간: 각 프로그램이 보는 메모리 (예: 0 ~ 4GB)
- 물리 주소 공간: 실제 RAM (예: 0 ~ 16GB)
- 디스크 스왑: RAM 부족 시 디스크 사용
페이징 (Paging)
메모리를 페이지라는 고정 크기 블록으로 나누어 관리
페이징 구조
가상 주소: [페이지 번호 | 오프셋]
물리 주소: [프레임 번호 | 오프셋]
예: 4KB 페이지, 32비트 주소
- 오프셋: 12비트 (2^12 = 4KB)
- 페이지 번호: 20비트 (2^20 = 100만 페이지)
┌─────────────────────────────────┐
│ 가상 메모리 (프로그램 뷰) │
│ ┌─────────┐ │
│ │ Page 0 │ ─┐ │
│ ├─────────┤ │ │
│ │ Page 1 │ │ 페이지 테이블 │
│ ├─────────┤ ├─→ [변환] │
│ │ Page 2 │ │ │
│ └─────────┘ │ │
└───────────────┼─────────────────┘
↓
┌───────────────┼─────────────────┐
│ 물리 메모리 (RAM) │
│ ┌─────────┐ │ │
│ │ Frame 5 │←┘ (Page 0) │
│ ├─────────┤ │
│ │ Frame 2 │ (Page 1) │
│ ├─────────┤ │
│ │ Frame 7 │ (Page 2) │
│ └─────────┘ │
└─────────────────────────────────┘
페이지 테이블 (Page Table)
가상 페이지 번호를 물리 프레임 번호로 변환하는 자료구조
| 가상 페이지 | 물리 프레임 | 유효 비트 | 접근 권한 |
|---|---|---|---|
| 0 | 5 | 1 (RAM에 있음) | Read/Write |
| 1 | 2 | 1 | Read-only |
| 2 | - | 0 (디스크에 있음) | - |
| 3 | 7 | 1 | Execute |
페이지 폴트 (Page Fault)
접근하려는 페이지가 RAM에 없을 때 발생
페이지 폴트 처리 과정
- CPU가 가상 주소 접근 시도
- 페이지 테이블 확인 → 유효 비트 = 0
- 페이지 폴트 발생! (예외 처리)
- OS가 디스크에서 해당 페이지를 읽어옴 (수 밀리초)
- RAM에 빈 프레임이 없으면 → 기존 페이지 내보냄 (스왑)
- 새 페이지를 RAM에 적재
- 페이지 테이블 업데이트
- 명령어 재실행
⚠️ 페이지 폴트는 매우 느립니다! (10,000배 이상)
페이지 교체 알고리즘
| 알고리즘 | 설명 | 특징 |
|---|---|---|
| FIFO | 가장 먼저 들어온 페이지 교체 | 간단하지만 비효율적 |
| LRU | 가장 오래 사용하지 않은 페이지 교체 | 효과적, 구현 복잡 |
| Clock | 참조 비트 사용, 순환 검색 | LRU 근사, 효율적 |
| LFU | 가장 적게 사용된 페이지 교체 | 카운터 필요 |
TLB (Translation Lookaside Buffer)
TLB는 페이지 테이블의 캐시입니다. 최근 사용한 주소 변환 정보를 저장합니다.
- TLB 히트: 주소 변환 즉시 완료 (1클럭)
- TLB 미스: 페이지 테이블 접근 필요 (수십 클럭)
- 히트율: 보통 95~99%
🚀 메모리 접근 속도 최적화 기법
1. 캐시 친화적 코드 작성
✅ 좋은 예: 행 우선 접근
// 2차원 배열 순회 - 캐시 친화적
int data[1000][1000];
// 행 우선 (Row-major): 메모리 순서대로 접근
for (int i = 0; i < 1000; i++)
for (int j = 0; j < 1000; j++)
sum += data[i][j]; // 캐시 히트율 높음
// Delphi 버전
for i := 0 to 999 do
for j := 0 to 999 do
Sum := Sum + Data[i, j];
❌ 나쁜 예: 열 우선 접근
// 열 우선 (Column-major): 메모리 점프
for (int j = 0; j < 1000; j++)
for (int i = 0; i < 1000; i++)
sum += data[i][j]; // 캐시 미스 폭증!
// 성능 차이: 3~10배 느림
2. 데이터 구조 최적화
❌ Structure of Arrays (비효율)
type
TParticles = record
X: array[0..9999] of Single;
Y: array[0..9999] of Single;
Z: array[0..9999] of Single;
end;
// 업데이트 시 캐시 미스
✅ Array of Structures (효율)
type
TParticle = record
X, Y, Z: Single;
end;
TParticles = array[0..9999]
of TParticle;
// 업데이트 시 캐시 히트
3. 프리페칭 (Prefetching)
미리 필요한 데이터를 캐시로 가져오는 기법
하드웨어 프리페칭
- CPU가 접근 패턴을 자동 감지
- 순차 접근 시 다음 데이터를 미리 로드
- 대부분의 현대 CPU가 지원
소프트웨어 프리페칭
// C/C++ 예시
for (int i = 0; i < n; i++) {
__builtin_prefetch(&data[i+8]); // 미리 로드
process(data[i]);
}
4. 메모리 정렬 (Memory Alignment)
데이터를 캐시 라인 경계에 맞춰 배치
- 캐시 라인 크기: 보통 64바이트
- 정렬: 데이터 시작 주소를 64바이트 배수로
- 패딩: 빈 공간 추가로 정렬 맞춤
// Delphi 정렬 예시
type
{$ALIGN 64} // 64바이트 정렬
TCacheAlignedData = record
Value1: Integer;
Value2: Integer;
// 나머지 56바이트는 패딩
end;
5. 페이지 폴트 최소화
- 워킹 셋 관리: 자주 쓰는 데이터를 RAM에 유지
- 메모리 풀: 미리 할당해서 재사용
- 대용량 페이지: Huge Pages 사용 (2MB/1GB)
- 메모리 락: 중요한 데이터는 RAM 고정
💼 실무 적용: 설비 모니터링 메모리 관리
사례 1: 링 버퍼로 캐시 효율 극대화
문제: 1초마다 16채널 센서 데이터를 저장하면서 최근 1시간 데이터 조회
// 나쁜 예: 동적 할당 (페이지 폴트 유발)
var
LogList: TList;
begin
// 매번 새로운 메모리 할당
LogList.Add(NewSensorData); // 페이지 폴트 가능
end;
// 좋은 예: 링 버퍼 (캐시 친화적)
const
BUFFER_SIZE = 3600 * 16; // 1시간 × 16채널
var
RingBuffer: array[0..BUFFER_SIZE-1] of TSensorData;
WriteIndex: Integer = 0;
procedure AddData(const Data: TSensorData);
begin
RingBuffer[WriteIndex] := Data;
WriteIndex := (WriteIndex + 1) mod BUFFER_SIZE;
// 고정된 메모리 영역 재사용 → 캐시 효율 극대화
end;
사례 2: 메모리 풀 활용
시나리오: 알람 이벤트 객체를 빈번하게 생성/소멸
// 메모리 풀 구현
type
TAlarmPool = class
private
FPool: TStack;
public
function Acquire: TAlarmEvent;
procedure Release(Event: TAlarmEvent);
constructor Create(PoolSize: Integer);
end;
constructor TAlarmPool.Create(PoolSize: Integer);
var
i: Integer;
begin
FPool := TStack.Create;
// 미리 할당
for i := 0 to PoolSize - 1 do
FPool.Push(TAlarmEvent.Create);
end;
function TAlarmPool.Acquire: TAlarmEvent;
begin
if FPool.Count > 0 then
Result := FPool.Pop // 재사용
else
Result := TAlarmEvent.Create; // 부족하면 새로 생성
end;
procedure TAlarmPool.Release(Event: TAlarmEvent);
begin
Event.Clear;
FPool.Push(Event); // 풀에 반환
end;
// 사용 예
var
Alarm: TAlarmEvent;
begin
Alarm := AlarmPool.Acquire;
try
// 사용
finally
AlarmPool.Release(Alarm);
end;
end;
// 효과: 메모리 할당/해제 99% 감소!
사례 3: 대용량 로그 파일 처리
문제: 100MB 로그 파일을 분석해야 하는데 RAM은 제한적
// 나쁜 예: 전체 파일 로드
var
AllData: TStringList;
begin
AllData := TStringList.Create;
AllData.LoadFromFile('huge.log'); // 100MB RAM 사용!
// 처리...
end;
// 좋은 예: 스트리밍 방식
var
FileStream: TFileStream;
Buffer: array[0..8191] of Byte; // 8KB 버퍼
BytesRead: Integer;
begin
FileStream := TFileStream.Create('huge.log', fmOpenRead);
try
repeat
BytesRead := FileStream.Read(Buffer, SizeOf(Buffer));
ProcessChunk(Buffer, BytesRead);
// 8KB씩만 메모리 사용
until BytesRead = 0;
finally
FileStream.Free;
end;
end;
// 메모리 사용: 100MB → 8KB (99.99% 절감!)
사례 4: 캐시 라인 충돌 회피
문제: 멀티스레드에서 카운터 변수 경합
// 나쁜 예: False Sharing (거짓 공유)
type
TCounters = record
Counter1: Integer; // 0바이트
Counter2: Integer; // 4바이트
Counter3: Integer; // 8바이트
Counter4: Integer; // 12바이트
end;
// 모두 같은 캐시 라인(64바이트)에 존재
// 스레드1이 Counter1 수정 → 캐시 라인 무효화
// 스레드2의 Counter2도 영향받음!
// 좋은 예: 캐시 라인 분리
type
{$ALIGN 64}
TPaddedCounter = record
Value: Integer;
Padding: array[0..59] of Byte; // 60바이트 패딩
end;
TCounters = record
Counter1: TPaddedCounter; // 0~63 바이트
Counter2: TPaddedCounter; // 64~127 바이트
Counter3: TPaddedCounter; // 128~191 바이트
Counter4: TPaddedCounter; // 192~255 바이트
end;
// 각 카운터가 독립된 캐시 라인에 위치
// 스레드 간 간섭 없음
// 성능 향상: 4~8배!
실무 최적화 체크리스트
- ✅ 순차 접근 패턴 사용 (배열 순회 시 행 우선)
- ✅ 자주 쓰는 데이터는 지역 변수로
- ✅ 큰 구조체는 포인터로 전달
- ✅ 메모리 풀로 할당 오버헤드 제거
- ✅ 링 버퍼로 고정 메모리 재사용
- ✅ 멀티스레드는 캐시 라인 분리
- ✅ 대용량 파일은 스트리밍 처리
- ✅ 프로파일러로 캐시 미스율 측정
🚀 다음 편 예고: 컴퓨터 구조 [3] - 입출력 시스템
다음 포스팅에서는 입출력(I/O) 시스템을 깊이 있게 다룰 예정입니다.
다룰 내용:
- 입출력 방식 (Polling, Interrupt, DMA)
- 버스 시스템 구조와 종류
- 디스크 I/O 최적화 기법
- 통신 프로토콜 (UART, SPI, I2C, Modbus)
- 설비 모니터링에서의 실시간 I/O 처리 전략
📌 핵심 요약
- 메모리는 속도와 용량의 트레이드오프로 계층 구조 형성
- 캐시 메모리는 지역성 원리로 CPU 성능 극대화 (히트율 95% 목표)
- 집합 연관 매핑이 가장 효율적인 캐시 매핑 방식
- 가상 메모리는 페이징으로 물리 메모리보다 큰 프로그램 실행 가능
- 페이지 폴트는 매우 느리므로 최소화가 중요 (10,000배 차이)
- 실무에서는 캐시 친화적 코드, 메모리 풀, 링 버퍼 활용 필수
💬 여러분의 경험을 공유해주세요!
메모리 최적화로 성능을 크게 개선한 경험이 있으신가요?
또는 캐시, 가상 메모리에 대해 더 궁금한 점이 있다면 댓글로 남겨주세요!