캐시 메모리
캐시 메모리(Cache Memory)는 CPU와 주기억장치(RAM) 사이에 위치한 초고속 임시 저장 공간입니다. CPU가 자주 쓰는 데이터를 미리 담아두어 메모리 접근 속도 차이를 극복하고 전체 성능을 끌어올립니다.
핵심 개념
캐시 히트 (Cache Hit)
CPU가 요청한 데이터가 캐시에 있는 경우입니다. 메인 메모리 접근 없이 캐시에서 바로 데이터를 가져오므로 매우 빠릅니다.
핵심 개념
캐시 미스 (Cache Miss)
CPU가 요청한 데이터가 캐시에 없는 경우입니다. 메인 메모리에서 데이터를 가져온 뒤 캐시에 적재(Load)합니다.
핵심 개념
지역성 원리
시간 지역성(방금 쓴 데이터를 곧 다시 씀)과 공간 지역성(인접한 주소의 데이터를 곧 씀)을 활용해 캐시 효율을 높입니다.
핵심 개념
캐시 계층 (L1 / L2 / L3)
L1은 가장 빠르고 작으며 CPU 코어에 직접 붙어있습니다. L2·L3로 갈수록 느리지만 용량이 커집니다.
핵심 개념
교체 정책 (LRU)
캐시 라인을 넣을 자리(세트)가 가득 찼을 때 가장 오랫동안 사용되지 않은 라인을 제거합니다. LRU(Least Recently Used)가 대표적이며, 실제 CPU는 비용을 줄이려고 LRU를 근사한 방식(pseudo-LRU)을 쓰기도 합니다.
핵심 개념
쓰기 정책
Write-Through는 캐시와 메모리를 동시에 갱신합니다. Write-Back은 캐시만 먼저 갱신하고 나중에 메모리에 반영합니다.
캐시가 나온 배경
1965년 모리스 윌크스(Maurice Wilkes)가 '슬레이브 메모리(slave memory)'라는 이름으로 큰 주기억장치 앞에 작고 빠른 메모리를 두는 개념을 제안했고, 상용 컴퓨터에 캐시를 넣은 초기 사례로는 1960년대 후반의 IBM System/360 모델 85가 알려져 있습니다. 오늘날 CPU와 메모리의 속도 차이가 커서, 캐시가 없으면 메모리 접근 대기가 성능을 크게 좌우합니다. 캐시는 SRAM으로 만들고 주기억장치는 보통 DRAM으로 만듭니다.
캐시는 주소를 이렇게 나눠 봅니다
캐시는 메모리 주소를 태그(tag) · 인덱스(index) · 오프셋(offset) 세 부분으로 나눠 해석합니다. 오프셋은 캐시 라인 안의 위치, 인덱스는 어느 세트에 있는지, 태그는 그 자리에 있는 라인이 찾는 주소의 것이 맞는지 확인하는 값입니다. 예를 들어 32KB, 8-way, 라인 64바이트 캐시(예: Intel Skylake, AMD Zen 4의 L1 데이터 캐시 구성)는 세트가 32768 ÷ (64 × 8) = 64개이므로 오프셋 6비트, 인덱스 6비트를 쓰고 나머지 상위 비트가 태그입니다.
캐시 미스의 세 가지 종류
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
직접 매핑, 연관 매핑, 세트 연관 매핑을 비교해 보세요.
직접 매핑은 블록이 들어갈 자리가 하나라 검색이 빠르고 구조가 단순하지만 충돌 미스가 많습니다. 완전 연관은 어디든 들어가 충돌 미스가 없지만 모든 라인의 태그를 비교해야 해서 비용이 큽니다. 세트 연관은 이 둘의 절충으로, 실제 CPU 캐시는 대부분 세트 연관 구조입니다.
면접 질문
Write-Through와 Write-Back의 차이는 무엇인가요?
Write-Through는 쓸 때마다 캐시와 메모리를 함께 갱신해 단순하고 메모리가 항상 최신이지만 쓰기가 느려지기 쉽습니다(쓰기 버퍼로 완화하기도 합니다). Write-Back은 캐시만 갱신하고 라인이 교체될 때 메모리에 반영해 빠르지만, 더티 비트 관리와 멀티코어 일관성 처리가 필요합니다.
면접 질문
거짓 공유(false sharing)란 무엇이고 어떻게 피하나요?
서로 다른 스레드가 쓰는 서로 다른 변수가 같은 캐시 라인에 있어서, 한쪽이 쓸 때마다 다른 쪽 코어의 그 라인이 무효화되어 성능이 떨어지는 현상입니다. 자주 쓰는 변수를 라인 단위로 띄워 배치(패딩)해서 피합니다.
면접 질문
공간 지역성을 살리려면 코드를 어떻게 짜야 하나요?
연속된 주소를 순서대로 접근하게 합니다. 2차원 배열은 C처럼 행 우선으로 저장하는 언어에서는 행 단위로 순회하고, 자주 같이 쓰는 필드는 같은 구조체나 객체에 모아 한 캐시 라인에 들어가게 합니다.
실제 시스템에서는
-e cache-references,cache-misses 이벤트로 캐시 참조와 미스 횟수를 세어 미스율을 계산할 수 있습니다. 보통 마지막 레벨 캐시(LLC) 기준이며, 이벤트의 정확한 의미는 CPU마다 다릅니다.
함께 보면 좋은 토픽
참고 자료
C — 직접 매핑 캐시 시뮬레이션
// 직접 매핑(Direct Mapped) 캐시 구현 // 캐시 크기: 4슬롯 / 메모리 블록: 16개 #define CACHE_SIZE 4 #define MEM_SIZE 16 typedef struct { int valid; // 유효 비트 (데이터가 있으면 1) int dirty; // Dirty 비트 (Write-Back: 캐시가 메모리와 불일치하면 1) int tag; // 태그 (어느 메모리 블록인지) int data; // 실제 데이터 } CacheSlot; CacheSlot cache[CACHE_SIZE]; int memory[MEM_SIZE]; // 직접 매핑: 슬롯 인덱스 = 블록 주소 % CACHE_SIZE int cache_read(int addr) { int index = addr % CACHE_SIZE; // 슬롯 결정 int tag = addr / CACHE_SIZE; // 태그 계산 if (cache[index].valid && cache[index].tag == tag) { /* ✅ Cache Hit — 캐시에서 바로 반환 */ return cache[index].data; } /* ❌ Cache Miss — 교체 전 dirty 슬롯이면 메모리에 write-back */ if (cache[index].valid && cache[index].dirty) { int evict_addr = cache[index].tag * CACHE_SIZE + index; memory[evict_addr] = cache[index].data; // Write-Back } cache[index].valid = 1; cache[index].dirty = 0; // 읽기로 적재 → clean cache[index].tag = tag; cache[index].data = memory[addr]; return cache[index].data; } // Write-Through: 캐시와 메모리 동시 갱신 → dirty bit 불필요 void cache_write_through(int addr, int val) { int index = addr % CACHE_SIZE; int tag = addr / CACHE_SIZE; cache[index].valid = 1; cache[index].dirty = 0; // 항상 메모리와 동기화 → clean cache[index].tag = tag; cache[index].data = val; // 캐시 갱신 memory[addr] = val; // 메모리 동시 갱신 } // Write-Back: 캐시만 갱신하고 dirty bit 설정 // 교체 시점에 dirty=1 슬롯만 메모리에 반영 void cache_write_back(int addr, int val) { int index = addr % CACHE_SIZE; int tag = addr / CACHE_SIZE; cache[index].valid = 1; cache[index].dirty = 1; // 메모리와 불일치 → dirty 표시 cache[index].tag = tag; cache[index].data = val; // 캐시만 갱신 // memory[addr]는 교체 시 dirty=1 확인 후 반영 (cache_read 참조) }
직접 매핑에서 슬롯 인덱스는 주소 % 캐시 크기로 결정됩니다.
유효 비트(valid)와 태그(tag)를 비교해 히트 여부를 판단합니다.
Write-Back 미스 시 기존 슬롯의 dirty=1이면 교체 전에 메모리에 write-back하고,
새 데이터는 dirty=0(clean)으로 적재합니다.
Write-Through는 항상 메모리와 동기화하므로 dirty bit가 불필요합니다.
Java — LRU 캐시 교체 정책
import java.util.LinkedHashMap; import java.util.Map; /** * LRU(Least Recently Used) 캐시 구현 * LinkedHashMap의 accessOrder=true를 활용해 * 접근 순서 기반으로 자동 정렬 */ public class LRUCache<K, V> { private final int capacity; private final LinkedHashMap<K, V> map; public LRUCache(int capacity) { this.capacity = capacity; // accessOrder=true: get() 호출 시 해당 항목을 맨 뒤로 이동 this.map = new LinkedHashMap<>(capacity, 0.75f, true) { protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return size() > capacity; // 용량 초과 시 가장 오래된 항목 자동 제거 } }; } /** 캐시 조회 — Hit: 값 반환 / Miss: null */ public V get(K key) { return map.getOrDefault(key, null); } /** 캐시 적재 — 용량 초과 시 LRU 항목 자동 제거 */ public void put(K key, V value) { map.put(key, value); } /** 현재 캐시 상태 출력 (MRU → LRU 순) */ public void printCache() { System.out.println("Cache: " + map); } } // 사용 예시 class Main { public static void main(String[] args) { LRUCache<String, Integer> cache = new LRUCache<>(4); cache.put("A", 10); cache.printCache(); // [A] cache.put("B", 20); cache.printCache(); // [A, B] cache.get("A"); cache.printCache(); // [B, A] ← A가 MRU로 cache.put("C", 30); cache.printCache(); // [B, A, C] cache.put("D", 40); cache.printCache(); // [B, A, C, D] ← 풀 cache.put("E", 50); cache.printCache(); // [A, C, D, E] ← B 제거(LRU) } }
Java의 LinkedHashMap은 accessOrder=true 설정 시
접근할 때마다 해당 항목을 내부적으로 맨 뒤로 이동시킵니다.
removeEldestEntry를 오버라이드하면 용량 초과 시
맨 앞(가장 오래된) 항목을 자동으로 제거해 LRU를 완성합니다.
Q1
Direct Mapped 캐시에서 하나의 메모리 블록이 매핑될 수 있는 캐시 라인 개수는?
Q2
캐시 히트(hit)와 미스(miss)의 차이는?
Q3
LRU(Least Recently Used) 교체 정책이 캐시가 꽉 찼을 때 내보내는 것은?