---
title: "램이 느려서 캐시가 생겼다"
description: "CPU 는 한 사이클에 일하는데 램은 수백 사이클 걸립니다. 그 사이를 메우려고 생긴 것이 캐시이고, 캐시가 기대는 것이 지역성입니다. 계층이 몇 단이고 각 단이 얼마인지부터 봅니다."
author: "유니얼"
pubDate: 2026-09-17
category: "programming/os"
tags: ["OS", "메모리"]
canonical: https://blog.unialgames.com/posts/os-cache-locality/
---
번역이 끝나면 물리 주소가 나오고, CPU 는 그 자리를 읽습니다.
그런데 **그 읽는 일 자체가 느립니다.**

3GHz 짜리 CPU 는 한 사이클이 0.3나노초쯤입니다. 램에서 값 하나를 가져오는 데는 100나노초쯤 걸립니다.
계산하면 **삼백 사이클을 기다린다**는 뜻입니다. 그동안 CPU 는 할 일이 없습니다.

이 차이를 메우려고 중간에 작고 빠른 저장소를 끼웠습니다. 그게 캐시입니다.

## 계층은 다섯 단이다

위로 갈수록 빠르고 작습니다. 아래로 갈수록 느리고 큽니다.

<figure class="diagram">
<svg viewBox="0 0 680 260" role="img" aria-label="레지스터에서 저장 장치까지 다섯 단계의 저장소를 위에서 아래로 늘어놓고 각 단계의 크기와 걸리는 사이클을 표시한 그림">
<g font-size="12">
<rect x="250" y="10" width="180" height="34" rx="6" fill="var(--accent-soft)" stroke="var(--accent)"></rect>
<rect x="215" y="52" width="250" height="34" rx="6" fill="var(--accent-soft)" stroke="var(--accent)"></rect>
<rect x="180" y="94" width="320" height="34" rx="6" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<rect x="145" y="136" width="390" height="34" rx="6" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<rect x="110" y="178" width="460" height="34" rx="6" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<rect x="75" y="220" width="530" height="34" rx="6" fill="var(--bg)" stroke="var(--warn)"></rect>
</g>
<g font-family="var(--font-mono)" font-size="11" fill="var(--text)">
<text x="264" y="32">레지스터</text>
<text x="229" y="74">L1</text>
<text x="194" y="116">L2</text>
<text x="159" y="158">L3</text>
<text x="124" y="200">메인 메모리</text>
<text x="89" y="242" fill="var(--warn)">저장 장치</text>
</g>
<g font-family="var(--font-mono)" font-size="11" fill="var(--text-muted)" text-anchor="end">
<text x="416" y="32">수백 바이트 · 0</text>
<text x="451" y="74">수십 KB · 4</text>
<text x="486" y="116">수백 KB · 12</text>
<text x="521" y="158">수십 MB · 40</text>
<text x="556" y="200">수 GB · 300</text>
<text x="591" y="242">수백 GB · 수십만</text>
</g>
</svg>
<figcaption>오른쪽 숫자는 크기와, 값 하나를 꺼내는 데 걸리는 대략적인 사이클 수.</figcaption>
</figure>

L1 과 램 사이가 **일흔 배 넘게** 벌어집니다. 같은 코드라도 값이 어디에 있었느냐에 따라 이만큼 갈립니다.

그런데 이상한 점이 있습니다. L1 은 수십 KB 밖에 안 됩니다. 게임이 쓰는 데이터는 GB 단위입니다.
**그 작은 칸에 무엇을 올려 둘지 어떻게 맞히는 걸까요.**

## 캐시는 두 가지 습관에 겁니다

맞히는 것이 아니라 **사람이 짠 코드에 습관이 있다**는 데 겁니다. 그 습관을 지역성이라고 합니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "시간 지역성", "text": "방금 쓴 값을 곧 또 씁니다. 반복문의 카운터, 자주 부르는 함수의 코드가 그렇습니다. 그래서 한 번 올라온 것은 당분간 안 내립니다.", "code": "for (int i = 0; i < n; i++)\n    total += damage;   // total 과 damage 를 매번 다시 읽는다", "tone": "good" },
    { "label": "공간 지역성", "text": "쓴 값의 옆자리를 곧 씁니다. 배열을 앞에서 뒤로 훑는 것이 그렇습니다. 그래서 값 하나를 가져올 때 그 주변까지 한 덩어리로 끌고 옵니다.", "code": "for (int i = 0; i < n; i++)\n    sum += hp[i];      // hp[0] 을 가져올 때 hp[1..] 도 같이 온다", "tone": "good" }
  ]
}
</script>
</x-compare>

두 번째가 중요합니다. **캐시는 값 하나만 가져오는 법이 없습니다.** 주변까지 통째로 끌고 옵니다.
그래서 배열을 순서대로 훑으면 한 번 다녀올 때마다 여러 개를 얻고, 흩어진 자리를 만지면 매번 하나만 얻습니다.

**이 습관이 없는 코드에서는 캐시가 없는 것과 같습니다.** 매번 램까지 다녀오게 됩니다.

## 하드웨어가 미리 끌어온다

한 걸음 더 나갑니다. CPU 는 **다음에 무엇을 만질지 짐작해서 미리 가져다 둡니다.**

짐작의 근거는 규칙성입니다.

- 주소가 일정한 간격으로 늘어나면 다음 것을 미리 끌어옵니다. 잘 맞습니다
- 매번 다른 데로 뛰면 짐작할 근거가 없습니다. 아무것도 못 합니다

그래서 **연속된 배열을 순서대로 도는 코드는 사실상 기다리지 않습니다.** 필요할 때 이미 와 있습니다.
포인터를 따라 뛰어다니는 코드는 매번 값을 받고 나서야 다음에 어디로 갈지 알기 때문에, 미리 가져다 둘 수가 없습니다.

<x-calc>
<script type="application/json">
{
  "label": "적중률이 접근 하나에 매기는 값",
  "inputs": [
    { "id": "h", "label": "L1 적중률", "options": [99, 95, 90, 70], "default": 95, "suffix": "%" },
    { "id": "m", "label": "빗나가면 어디까지", "options": [["L3 까지", 40], ["램까지", 300]], "default": 300 }
  ],
  "bars": [
    { "label": "접근 하나의 평균", "expr": "4 + (1 - h / 100) * m", "unit": "사이클", "accent": true },
    { "label": "전부 L1 에서 나올 때", "expr": "4", "unit": "사이클" }
  ],
  "verdict": { "expr": "bars[0] > 8", "yes": "L1 만 쓸 때보다 두 배 넘게 느림", "no": "거의 L1 속도" },
  "note": "L1 히트를 4사이클로 잡았습니다. 기계마다 다르니 자릿수로만 보세요."
}
</script>
</x-calc>

적중률 99% 와 90% 는 말로는 비슷합니다. 그런데 **평균은 일곱 배 넘게 벌어집니다.**

## 게임에서 보이는 자리

여기까지 오면 같은 말이 다르게 들립니다. **데이터가 어떻게 놓여 있느냐가 곧 속도입니다.**

- 필요한 값만 붙여 놓은 배열을 순서대로 도는 코드는 캐시가 다 받쳐 줍니다
- 객체마다 이것저것 다 들어 있는 것을 목록으로 들고 다니면, **쓰지도 않을 값까지 같이 끌려 옵니다.** 한 덩어리로 가져오니까요
- 포인터를 따라가는 자료구조는 미리 가져다 두는 것도 안 먹힙니다

주소를 번역하는 값과 이건 **다른 축**입니다. 번역은 "그 자리가 어디인가" 이고, 이건 "그 자리가 가까운가" 입니다.
둘 다 연속된 배열에서 유리한데 이유가 각각 따로 있습니다.

그리고 이 값은 프로파일러에서 **느린 함수로 보입니다.** 실제로 느린 것은 함수가 아니라 그 함수가 만지는 데이터의 배치입니다.
코드를 아무리 들여다봐도 안 나오는 이유가 이것입니다.

## 정리

- CPU 한 사이클과 램 접근은 **삼백 배쯤** 벌어져 있습니다. 그 사이를 메우려고 캐시가 생겼습니다
- 계층은 레지스터부터 저장 장치까지 다섯 단이고, **한 단 내려갈 때마다 자릿수가 바뀝니다**
- 캐시는 두 습관에 겁니다. **방금 쓴 것을 또 쓴다, 쓴 것의 옆을 쓴다**
- 그래서 값 하나를 가져올 때 주변을 통째로 끌고 옵니다. 순서대로 훑으면 한 번에 여러 개를 얻습니다
- 하드웨어는 규칙적인 접근을 짐작해 미리 가져다 둡니다. **뛰어다니면 짐작할 근거가 없어서 매번 기다립니다**
- 이 값은 느린 함수로 보입니다. 실제로 느린 것은 **데이터의 배치**입니다