🚀 CPU는 왜 이렇게 빠를까?
— 캐시(Cache) 완전 정복
핵심 요약: CPU는 초당 수십억 번 연산하지만, RAM은 그 속도를 따라가지 못한다.
이 병목을 해결하기 위해 CPU 가까이 배치한 초고속 임시 저장소가 캐시(Cache) 다.
1️⃣ 캐시가 탄생한 이유
CPU와 RAM 사이에는 심각한 속도 격차가 있다.
CPU 연산: ~0.3 나노초
RAM 접근: ~100 나노초 → CPU 입장에서 300배 넘게 기다려야 함
CPU가 연산할 데이터를 RAM에서 가져오는 동안 아무것도 못 하고 멈추는 상태를 스톨(Stall) 이라고 한다.
캐시는 이 스톨을 최소화하기 위해 자주 쓰는 데이터를 CPU 가까이 미리 복사해두는 전략이다.
🧠 캐시가 효과적인 이유 — 지역성(Locality)
캐시 전략이 효과적인 이유는 프로그램의 데이터 접근 패턴에 규칙성이 있기 때문이다.
| 지역성 종류 | 의미 | 예시 |
|---|---|---|
| 시간 지역성 (Temporal) | 한 번 쓴 데이터는 곧 또 쓴다 | 루프 내 변수, 카운터 |
| 공간 지역성 (Spatial) | 방금 쓴 데이터 주변도 곧 쓴다 | 배열 순차 접근, 구조체 필드 |
// 두 지역성이 모두 나타나는 전형적인 예
int[] arr = new int[1000];
for (int i = 0; i < arr.length; i++) { // i → 시간 지역성
arr[i] += 1; // arr[i] → 공간 지역성
}
2️⃣ L1 / L2 / L3 캐시 계층
캐시는 속도와 용량에 따라 3단계로 나뉜다.
숫자가 작을수록 CPU에 가깝고 빠르지만 용량은 작다.
계층별 비교
| 구분 | 위치 | 용량 | 속도 | 공유 여부 |
|---|---|---|---|---|
| L1 | 코어 내부 | 수십 ~ 수백 KB | 가장 빠름 (~1ns) | 코어 전용 |
| L2 | 코어 근처 | 수백 KB ~ 수 MB | 빠름 (~5ns) | 코어 전용 |
| L3 | CPU 칩 내부 | 수 MB ~ 수십 MB | 보통 (~20ns) | 모든 코어 공유 |
| RAM | 메인보드 | 수 GB ~ 수십 GB | 느림 (~100ns) | 시스템 전체 |
💡 L1이 명령어(i)와 데이터(d)로 분리된 이유?
명령어를 읽는 동시에 데이터를 읽을 수 있어 파이프라인 효율이 올라가기 때문이다.
3️⃣ 캐시 히트와 캐시 미스
CPU가 데이터를 요청할 때 두 가지 결과가 나온다.
캐시 히트율(Cache Hit Rate) 이 높을수록 프로그램이 빠르다.
실제 잘 작성된 프로그램의 L1 캐시 히트율은 95% 이상에 달한다.
4️⃣ Java 개발자 관점 — 캐시 친화적 코드
📌 배열 vs 리스트: 캐시 효율 차이
// ✅ 캐시 친화적 — 메모리에 연속 배치
int[] arr = new int[1000];
for (int i = 0; i < arr.length; i++) {
arr[i] += 1; // 공간 지역성 → 캐시 히트율 높음
}
// ⚠️ 캐시 비친화적 — 객체가 Heap 곳곳에 흩어짐
List<Integer> list = new ArrayList<>();
for (Integer num : list) {
// 각 Integer 객체 참조마다 Heap의 다른 위치 접근 → 캐시 미스 빈번
}
int[] 배열 메모리 구조 (연속):
[1][2][3][4][5][6]... → 캐시 라인(64byte)에 여러 원소가 한번에 올라옴
ArrayList<Integer> 메모리 구조 (분산):
[ref1] → Heap:0xA1 [1]
[ref2] → Heap:0xF3 [2] ← 각 Integer 객체가 Heap 여기저기 산재
[ref3] → Heap:0x2B [3]
⚡ False Sharing (거짓 공유)
멀티스레드 환경에서 의도치 않게 성능이 떨어지는 대표적인 함정이다.
캐시 라인 (64 bytes)
Thread A가 counter1을 수정하면 → 캐시 라인 전체가 무효화
→ Thread B도 같은 캐시 라인을 다시 읽어야 함 → 성능 저하
// ❌ False Sharing 발생 — 두 변수가 같은 캐시 라인에 위치할 수 있음
class Shared {
volatile long counter1 = 0;
volatile long counter2 = 0;
}
// ✅ Java 8+ @Contended 어노테이션으로 해결 (패딩 삽입)
class Padded {
@jdk.internal.vm.annotation.Contended
volatile long counter1 = 0;
@jdk.internal.vm.annotation.Contended
volatile long counter2 = 0;
}
💡 2D 배열 순회 방향도 성능에 영향을 준다
int[][] matrix = new int[1000][1000];
// ✅ 행 우선 순회 (Row-major) — 공간 지역성 활용, 빠름
for (int i = 0; i < 1000; i++)
for (int j = 0; j < 1000; j++)
matrix[i][j]++;
// ❌ 열 우선 순회 (Column-major) — 캐시 라인을 건너뜀, 느림
for (int j = 0; j < 1000; j++)
for (int i = 0; i < 1000; i++)
matrix[i][j]++;
⚡ 같은 Big-O라도 캐시 친화적 코드가 수십 배 빠를 수 있다.
알고리즘 복잡도가 같다면 데이터 접근 패턴이 실제 성능을 결정한다.
5️⃣ 핵심 정리
| 개념 | 한 줄 요약 |
|---|---|
| 캐시 | CPU-RAM 속도 격차를 줄이는 고속 임시 저장소 |
| 지역성 | 시간(재사용) + 공간(주변 접근) 패턴을 캐시가 활용 |
| L1/L2 | 코어 전용, 빠를수록 작음 |
| L3 | 모든 코어 공유, L1/L2 미스 시 최후 방어선 |
| 캐시 히트율 | 높을수록 좋음, 연속 메모리 접근이 핵심 |
| False Sharing | 다른 변수가 같은 캐시 라인에 있으면 멀티스레드 성능 저하 |
| 배열 vs 리스트 | int[]가 ArrayList<Integer>보다 캐시 효율이 훨씬 좋음 |