컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
arch · 05

캐시 메모리

성능캐시메모리

캐시 메모리(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은 캐시만 먼저 갱신하고 나중에 메모리에 반영합니다.

히트율 (Hit Rate) — 전체 메모리 접근 중 캐시 히트 비율입니다. 히트율이 높을수록 성능이 좋습니다. 실제 값은 프로그램의 접근 패턴과 캐시 구성에 따라 크게 달라집니다.
블록 (Block / Cache Line) — 캐시는 데이터를 바이트 단위가 아닌 블록(보통 64바이트) 단위로 메모리에서 가져옵니다. 공간 지역성 활용의 핵심입니다.
직접 매핑 (Direct Mapped) — 메모리 블록이 캐시의 고정된 한 슬롯에만 들어갈 수 있습니다. 구조가 단순하지만 충돌 미스가 발생할 수 있습니다.
연관 매핑 (Fully Associative) — 메모리 블록이 캐시의 어느 슬롯에든 들어갈 수 있습니다. 충돌 미스가 없지만 검색 비용이 높습니다.
세트 연관 매핑 (Set Associative) — 직접·연관 매핑의 절충안입니다. 캐시를 세트로 나누고 각 세트 내에서 연관 매핑을 적용합니다.

캐시가 나온 배경

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비트를 쓰고 나머지 상위 비트가 태그입니다.

캐시 미스의 세 가지 종류

강제 미스(Compulsory) — 그 블록을 처음 접근해서 캐시에 없는 경우입니다. 캐시가 아무리 커도 피할 수 없습니다.
용량 미스(Capacity) — 작업에 필요한 데이터가 캐시보다 커서 쫓겨났다가 다시 필요해지는 경우입니다. 캐시를 키우면 줄어듭니다.
충돌 미스(Conflict) — 캐시에 빈 곳이 있어도 직접 매핑이나 세트 연관 매핑에서 같은 세트로 몰려 쫓겨나는 경우입니다. 연관도(way 수)를 높이면 줄어듭니다.

자주 하는 오해

캐시는 읽기만 빠르게 해 준다 — 쓰기도 캐시를 거칩니다. Write-Back 방식은 캐시 라인에 더티(dirty) 비트를 두고, 그 라인이 쫓겨날 때에만 메모리에 써서 메모리 쓰기 횟수를 줄입니다.
멀티코어에서는 캐시를 코어가 함께 쓰니 동기화 걱정이 없다 — L1은 코어마다 있어서 같은 주소를 서로 다른 값으로 갖지 않도록 캐시 일관성 프로토콜(MESI 등)이 필요합니다. 또 서로 다른 변수라도 같은 캐시 라인에 있으면 한 코어의 쓰기가 다른 코어의 캐시 라인을 무효화하는 거짓 공유(false sharing)로 느려질 수 있습니다.
히트율이 90%면 충분하다 — 미스 한 번의 비용이 히트보다 훨씬 커서 히트율이 몇 %p만 달라져도 평균 접근 시간이 크게 달라집니다. 메모리 계층 구조 토픽의 계산 예를 보세요.
캐시는 코드와 무관한 하드웨어의 일이다 — 캐시는 하드웨어가 자동으로 관리하지만, 같은 데이터를 어떤 순서로 접근하는지에 따라 히트율이 크게 달라집니다.

면접에서 이렇게 물어요

면접 질문

직접 매핑, 연관 매핑, 세트 연관 매핑을 비교해 보세요.

직접 매핑은 블록이 들어갈 자리가 하나라 검색이 빠르고 구조가 단순하지만 충돌 미스가 많습니다. 완전 연관은 어디든 들어가 충돌 미스가 없지만 모든 라인의 태그를 비교해야 해서 비용이 큽니다. 세트 연관은 이 둘의 절충으로, 실제 CPU 캐시는 대부분 세트 연관 구조입니다.

면접 질문

Write-Through와 Write-Back의 차이는 무엇인가요?

Write-Through는 쓸 때마다 캐시와 메모리를 함께 갱신해 단순하고 메모리가 항상 최신이지만 쓰기가 느려지기 쉽습니다(쓰기 버퍼로 완화하기도 합니다). Write-Back은 캐시만 갱신하고 라인이 교체될 때 메모리에 반영해 빠르지만, 더티 비트 관리와 멀티코어 일관성 처리가 필요합니다.

면접 질문

거짓 공유(false sharing)란 무엇이고 어떻게 피하나요?

서로 다른 스레드가 쓰는 서로 다른 변수가 같은 캐시 라인에 있어서, 한쪽이 쓸 때마다 다른 쪽 코어의 그 라인이 무효화되어 성능이 떨어지는 현상입니다. 자주 쓰는 변수를 라인 단위로 띄워 배치(패딩)해서 피합니다.

면접 질문

공간 지역성을 살리려면 코드를 어떻게 짜야 하나요?

연속된 주소를 순서대로 접근하게 합니다. 2차원 배열은 C처럼 행 우선으로 저장하는 언어에서는 행 단위로 순회하고, 자주 같이 쓰는 필드는 같은 구조체나 객체에 모아 한 캐시 라인에 들어가게 합니다.

실제 시스템에서는

lscpu — CPU의 L1d, L1i, L2, L3 캐시 크기를 보여줍니다(man 문서에 따르면 전체 CPU를 요약한 값입니다).
리눅스 perf stat — -e cache-references,cache-misses 이벤트로 캐시 참조와 미스 횟수를 세어 미스율을 계산할 수 있습니다. 보통 마지막 레벨 캐시(LLC) 기준이며, 이벤트의 정확한 의미는 CPU마다 다릅니다.
Java의 LongAdder — 값을 여러 셀에 나눠 더하고 필요할 때 합치는 구조로, 서로 다른 셀이 같은 캐시 라인에 놓여 거짓 공유가 생기지 않도록 셀에 패딩 어노테이션(@Contended)을 붙입니다.

함께 보면 좋은 토픽

메모리 계층 구조 — 캐시가 계층 안에서 차지하는 위치와 평균 접근 시간 계산을 봅니다.
가상 메모리 — TLB도 일종의 캐시라는 점에서 같은 원리를 어떻게 쓰는지 봅니다.
폰 노이만 구조 — 캐시가 풀려는 CPU와 메모리 사이의 병목을 봅니다.
파이프라이닝 — MEM 단계의 대기를 캐시가 어떻게 줄이는지 봅니다.
캐시 일관성(MESI) — 멀티코어에서 코어마다 가진 캐시의 복사본을 일치시키는 방법을 봅니다.

참고 자료

교재 — Hennessy, Patterson, "Computer Architecture: A Quantitative Approach" 6판 부록 B "Review of Memory Hierarchy"
원 논문 — Wilkes, "Slave memories and dynamic storage allocation", IEEE Transactions on Electronic Computers, 1965

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 캐시에서 하나의 메모리 블록이 매핑될 수 있는 캐시 라인 개수는?

'Direct'라는 이름처럼 갈 곳이 하나로 정해져 있습니다.
Direct Mapped 방식은 메모리 주소의 인덱스에 따라 캐시 라인이 딱 하나로 고정됩니다. 그래서 구현은 단순하지만 충돌(같은 라인을 두고 서로 밀어내는 상황)이 잦을 수 있습니다.

Q2

캐시 히트(hit)와 미스(miss)의 차이는?

찾으려는 데이터가 캐시 안에 있는지 여부입니다.
캐시 히트는 원하는 데이터가 캐시에 이미 있어 빠르게 접근하는 경우, 미스는 캐시에 없어 더 느린 메모리까지 가서 가져와야 하는 경우입니다.

Q3

LRU(Least Recently Used) 교체 정책이 캐시가 꽉 찼을 때 내보내는 것은?

'가장 최근에 안 쓰인'이라는 이름 그대로입니다.
LRU는 가장 오랫동안 사용되지 않은(Least Recently Used) 데이터를 캐시에서 내보내, 앞으로도 다시 쓰일 가능성이 낮은 데이터부터 제거합니다.