1. 캐시란 무엇인가?
캐시(Cache)는 더 큰 저장 공간에서 데이터의 일부를 가져와 일시적으로 저장해두는 더 작고 빠른 메모리다.
컴퓨터 과학에서 캐시의 근본적인 목적은 데이터 접근 속도를 향상시키는 것이다. 이는 '자주 사용하는 물건은 손이 닿기 쉬운 곳에 둔다'는 현실 세계의 원리와 같다. 도서관의 모든 책(느리고 큰 저장소, RAM) 중에서 지금 당장 읽고 있거나 자주 참고하는 책 몇 권을 책상(작고 빠른 저장소, 캐시) 위에 올려두는 것에 비유할 수 있다.
컴퓨터 시스템의 성능은 가장 느린 구성 요소에 의해 좌우되는 경우가 많다. 현대의 CPU는 엄청나게 빠른 속도로 연산을 수행하지만, 메인 메모리(RAM)의 데이터 접근 속도는 이를 따라가지 못한다. 이 속도 차이로 인해 발생하는 병목 현상을 메모리 장벽(Memory Wall)이라고 하며, 캐시는 이 문제를 해결하기 위한 핵심적인 하드웨어 기술이다.
2. 캐시의 핵심 원리: 지역성(Locality)
캐시가 효율적으로 동작할 수 있는 이유는 프로그램이 데이터에 접근하는 패턴이 무작위적이지 않고, 참조의 지역성(Locality of Reference) 원리를 따르기 때문이다.
- 시간적 지역성 (Temporal Locality): 최근에 접근했던 데이터는 가까운 미래에 다시 접근될 가능성이 높다. (예: 루프 안에서 사용되는 변수)
- 공간적 지역성 (Spatial Locality): 특정 데이터에 접근했을 때, 그 주변에 있는 데이터들도 곧이어 접근될 가능성이 높다. (예: 배열의 원소를 순차적으로 순회하는 경우)
캐시는 이 두 가지 지역성을 활용한다. 특정 데이터가 요청되면, 캐시는 해당 데이터뿐만 아니라 그 주변 데이터까지 하나의 묶음(Cache Line)으로 가져와 저장한다. 이로써 다음번 접근이 캐시에서 바로 처리될(Cache Hit) 확률을 높인다.
3. 현대 컴퓨터의 캐시 계층 구조 (Cache Hierarchy)
성능과 비용의 균형을 맞추기 위해, 캐시는 CPU 코어에 가까울수록 더 빠르고, 더 작고, 더 비싼 여러 계층으로 구성된다.
- L1 캐시 (Level 1 Cache):
- CPU 칩 내부의 각 코어(Core)에 개별적으로 존재하는 가장 작은 캐시.
- 속도가 가장 빠르며, CPU의 연산 속도와 거의 맞먹는다.
- 보통 명령어(Instruction)를 위한 I-Cache와 데이터(Data)를 위한 D-Cache로 분리된다.
- 크기: 수십 KB (예: 32KB, 64KB)
- L2 캐시 (Level 2 Cache):
- 역시 각 코어에 개별적으로 존재하지만, L1 캐시보다는 크고 느리다.
- L1 캐시에서 원하는 데이터를 찾지 못하면(L1 Cache Miss) L2 캐시를 확인한다.
- 크기: 수백 KB ~ 수 MB (예: 256KB, 1MB)
- L3 캐시 (Level 3 Cache / LLC: Last-Level Cache):
- CPU 칩 내의 모든 코어가 공유하는 캐시다.
- L1, L2보다 훨씬 크지만 속도는 더 느리다.
- 한 코어에서 캐싱한 데이터를 다른 코어가 접근할 때 RAM까지 가지 않고 L3에서 바로 가져올 수 있어 효율적이다.
- 크기: 수 MB ~ 수십 MB (예: 8MB, 16MB, 32MB)
- 메인 메모리 (RAM - DRAM):
- 캐시 계층의 다음 단계. 캐시보다 훨씬 크지만 속도는 현저히 느리다.
- 보조 기억 장치 (SSD/HDD):
- 가장 크고 가장 느린 영구 저장소.
+-----------------+ <-- 빠름, 작음, 비쌈
| CPU Registers |
+-------+---------+
|
+-------v---------+
| L1 Cache (Core0)| +-----------------+
+-----------------+ | L1 Cache (Core1)| ...
| L2 Cache (Core0)| +-----------------+
+-------+---------+ | L2 Cache (Core1)| ...
| +-------+---------+
| |
+-------v--------------------v---------+
| L3 Cache (Shared) |
+------------------+-------------------+
|
+------------------v-------------------+
| Main Memory (RAM) |
+------------------+-------------------+
|
+------------------v-------------------+
| Storage (SSD/HDD) |
+--------------------------------------+ <-- 느림, 큼, 저렴
4. 캐시의 동작 방식과 정책
4.1. 캐시 히트와 미스 (Hit & Miss)
- 캐시 히트 (Cache Hit): CPU가 요청한 데이터가 캐시에 존재하는 경우. 매우 빠른 속도로 데이터 접근이 완료된다.
- 캐시 미스 (Cache Miss): CPU가 요청한 데이터가 캐시에 존재하지 않는 경우.
- 하위 계층(e.g., L2, L3, RAM)으로 요청을 전달하여 데이터를 탐색.
- 찾은 데이터를 포함한 특정 크기의 블록(이를 캐시 라인(Cache Line)이라고 하며, 보통 64바이트)을 상위 캐시로 가져옴.
- CPU에 데이터를 전달.
4.2. 캐시 교체 정책 (Cache Replacement Policies)
캐시가 가득 찼을 때 새로운 데이터를 가져오려면 기존의 데이터 중 일부를 내보내야(evict) 한다. 이때 어떤 데이터를 내보낼지 결정하는 규칙이 교체 정책이다.
- LRU (Least Recently Used): 가장 오랫동안 사용되지 않은 데이터를 교체한다. 시간적 지역성을 가장 잘 활용하는 이론적으로 우수한 정책이지만, 모든 데이터의 접근 시간을 추적해야 하므로 하드웨어로 완벽하게 구현하기 복잡하고 비용이 많이 든다. (유사 LRU 방식이 주로 사용됨)
- FIFO (First-In, First-Out): 가장 먼저 들어온 데이터를 교체한다. 구현은 간단하지만, 자주 사용되는 데이터가 먼저 들어왔다는 이유로 교체될 수 있는 단점이 있다.
- LFU (Least Frequently Used): 가장 적게 참조된 데이터를 교체한다. 참조 횟수를 기록해야 하는 부담이 있다.
- Random: 무작위로 데이터를 하나 선택하여 교체한다. 구현이 매우 간단하고 특정 패턴에서 발생하는 성능 저하를 피할 수 있다.
5. 다중 코어 환경과 캐시 일관성 (Cache Coherence)
여러 개의 코어가 각자의 L1/L2 캐시를 가지고 L3 캐시와 메모리를 공유할 때, 데이터 불일치 문제가 발생할 수 있다. 예를 들어, Core 0이 특정 메모리 주소(A)의 값을 10으로 변경하고 자신의 캐시에 저장했는데, Core 1은 여전히 예전 값인 5를 자신의 캐시에 가지고 있을 수 있다.
이 문제를 해결하고 모든 코어가 일관된 메모리 뷰를 갖도록 보장하는 메커니즘을 캐시 일관성 프로토콜이라고 한다. 가장 널리 사용되는 프로토콜은 MESI 프로토콜이다.
- MESI: 각 캐시 라인의 상태를 4가지(Modified, Exclusive, Shared, Invalid)로 나누어 관리.
- Modified (수정): 이 캐시에만 데이터가 있고, RAM의 내용과 달라 나중에 RAM에 반영해야 함.
- Exclusive (독점): 이 캐시에만 데이터가 있고, RAM의 내용과 같음.
- Shared (공유): 여러 캐시에 데이터가 있고, 모두 RAM의 내용과 같음.
- Invalid (무효): 데이터가 유효하지 않음.
다른 코어가 특정 데이터에 읽기/쓰기를 시도할 때, 각 코어는 이 상태 정보를 버스(Bus)를 통해 공유하며 자신의 캐시 라인 상태를 적절히 변경하여 데이터의 정합성을 유지한다.
6. 요약
캐시는 간단히 말해 '자주 쓰는 물건을 손 닿는 곳에 두는 것'과 같은 원리입니다. CPU가 데이터를 처리해야 할 때, 매번 느린 메인 메모리(RAM)까지 가지 않고, 훨씬 빠른 CPU 내부의 작은 저장 공간인 '캐시'를 먼저 확인합니다.
만약 데이터가 캐시에 있으면 '캐시 히트(Hit)'라고 하고, 바로 가져와서 쓰기 때문에 엄청나게 빠릅니다. 없으면 '캐시 미스(Miss)'라고 하는데, 그러면 어쩔 수 없이 RAM까지 가서 데이터를 가져옵니다. 이때, 이왕 RAM까지 간 김에 요청한 데이터뿐만 아니라 그 주변 데이터까지 한 덩어리(캐시 라인)로 묶어서 캐시에 저장해둡니다. '어차피 이거 썼으니, 옆에 있는 것도 곧 쓰겠지?'라는 '지역성' 원리를 이용하는 겁니다.
이런 캐시는 CPU 바로 옆에 붙어있는 아주 작고 빠른 L1 캐시부터, 여러 코어가 함께 쓰는 좀 더 크고 느린 L3 캐시까지 계층 구조로 되어 있습니다. 컴퓨터 성능의 핵심은 결국 이 캐시를 얼마나 잘 활용해서 '캐시 히트'를 많이 만드느냐에 달려있다고 할 수 있습니다.
7. 실습 코드
#include <iostream>
#include <vector>
#include <chrono>
#include <numeric>
// 게임 월드 맵의 크기를 정의합니다.
const int WORLD_WIDTH = 8192;
const int WORLD_HEIGHT = 8192;
// 월드 맵 데이터를 저장할 2D 벡터를 선언합니다.
// C++에서 2D 배열은 `[row][column]` 순서로 인덱싱됩니다.
// 즉, `worldMap[y][x]`는 y번째 행, x번째 열을 의미합니다.
std::vector<std::vector<int>> worldMap(WORLD_HEIGHT, std::vector<int>(WORLD_WIDTH));
// 월드 맵 데이터를 초기화하는 함수
void initializeMap() {
for (int y = 0; y < WORLD_HEIGHT; ++y) {
for (int x = 0; x < WORLD_WIDTH; ++x) {
worldMap[y][x] = (y * WORLD_WIDTH + x) % 256; // 임의의 값으로 초기화
}
}
}
// 캐시 친화적인 방식으로 맵 데이터를 순회하고 합산하는 함수
long long traverseCacheFriendly() {
long long sum = 0;
// 행 우선 순회 (Row-major traversal)
// 메모리에 저장된 순서대로 데이터에 접근합니다.
for (int y = 0; y < WORLD_HEIGHT; ++y) {
for (int x = 0; x < WORLD_WIDTH; ++x) {
// Q1. 이 방식의 순회가 '캐시 친화적'이라고 불리는 이유는 무엇이며, CPU와 메모리 계층에서 어떤 일이 일어나나요?
// 제출:
sum += worldMap[y][x];
}
}
return sum;
}
// 캐시 비친화적인 방식으로 맵 데이터를 순회하고 합산하는 함수
long long traverseCacheUnfriendly() {
long long sum = 0;
// 열 우선 순회 (Column-major traversal)
// 메모리상에서 멀리 떨어진 위치로 계속 점프하며 접근합니다.
for (int x = 0; x < WORLD_WIDTH; ++x) {
for (int y = 0; y < WORLD_HEIGHT; ++y) {
// Q2. 이 방식의 순회는 왜 훨씬 느릴까요? '캐시 라인'과 '캐시 미스'의 개념을 사용하여 설명해보세요.
// 제출:
sum += worldMap[y][x];
}
}
return sum;
}
int main() {
std::cout << "Initializing world map (" << WORLD_WIDTH << "x" << WORLD_HEIGHT << ")..." << std::endl;
initializeMap();
std::cout << "Initialization complete." << std::endl << std::endl;
// --- 1. 캐시 친화적 순회 테스트 ---
std::cout << "Running cache-friendly traversal (row-major)..." << std::endl;
auto start = std::chrono::high_resolution_clock::now();
long long sum1 = traverseCacheFriendly();
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> friendly_duration = end - start;
std::cout << "Sum: " << sum1 << std::endl;
std::cout << "Time: " << friendly_duration.count() << " ms" << std::endl << std::endl;
// --- 2. 캐시 비친화적 순회 테스트 ---
std::cout << "Running cache-unfriendly traversal (column-major)..." << std::endl;
start = std::chrono::high_resolution_clock::now();
long long sum2 = traverseCacheUnfriendly();
end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> unfriendly_duration = end - start;
std::cout << "Sum: " << sum2 << std::endl;
std::cout << "Time: " << unfriendly_duration.count() << " ms" << std::endl << std::endl;
// --- 3. 결과 비교 ---
std::cout << ">>> Summary <<<" << std::endl;
std::cout << "Cache-friendly traversal was " << unfriendly_duration.count() / friendly_duration.count()
<< "x faster than cache-unfriendly traversal." << std::endl;
// Q3. 만약 `worldMap`을 `std::vector<std::vector<int>> worldMap(WORLD_WIDTH, std::vector<int>(WORLD_HEIGHT))`으로,
// 즉, `worldMap[x][y]` 형태로 선언했다면, 어떤 순회 방식(행 우선/열 우선)이 더 빨라졌을까요? 그 이유는 무엇인가요?
// 제출:
return 0;
}
'Computer Science' 카테고리의 다른 글
스택, 큐, 연결 리스트 (0) | 2025.09.04 |
---|