캐시 메모리 — L1부터 L3까지, 지역성 원리가 성능에 미치는 영향
"같은 알고리즘인데 데이터 구조만 바꿨을 뿐인데, 왜 성능이 10배나 차이 나는 걸까?"
알고리즘의 시간 복잡도가 같아도 실제 실행 시간이 크게 차이 나는 경우가 있습니다. 공부하다 보니 그 원인의 대부분이 캐시 메모리 에 있더라고요. 빅오 표기법에는 드러나지 않는, 하드웨어 레벨의 성능 이야기입니다.
캐시 메모리란
CPU와 메인 메모리(RAM) 사이에 위치한 고속 메모리로, 자주 접근하는 데이터를 미리 가져다 놓아서 CPU가 RAM까지 갈 필요 없게 하는 장치입니다.
왜 필요한가
CPU 클럭 속도는 수 GHz(1사이클 ≈ 0.3ns)인데, RAM 접근은 약 80ns가 걸립니다. CPU가 데이터를 요청할 때마다 RAM까지 가면 수백 사이클을 허비합니다. 캐시가 이 갭을 메워줍니다.
캐시 계층 구조와 지연 시간
현대 CPU는 보통 3단계 캐시를 가지고 있습니다.
| 계층 | 크기 | 지연 시간 (사이클) | 지연 시간 (대략) | 특징 |
|---|---|---|---|---|
| L1 | 32~64 KB | 1~4 사이클 | < 1ns | 코어별 전용, 가장 빠름 |
| L2 | 256 KB~1 MB | 7~14 사이클 | 3~5ns | 코어별 전용 |
| L3 | 8~64 MB | 20~40 사이클 | 10~20ns | 코어 간 공유 |
| RAM | GB 단위 | 200+ 사이클 | ~80ns | 전체 공유 |
L3→RAM 점프가 성능의 분수령
L1에서 L2로, L2에서 L3로의 지연 시간 증가는 점진적입니다. 하지만 **L3에서 RAM으로의 점프는 약 4~5배 **(16ns → 80ns)입니다. 캐시 미스가 성능을 무너뜨리는 구간이 바로 여기입니다.
L1 (<1ns) → L2 (5ns) → L3 (16ns) → RAM (80ns)
×5 ×3 ×5
점진적 급격한 점프!
캐시가 동작하는 원리 — 지역성
캐시가 효과적인 이유는 프로그램의 메모리 접근 패턴에 ** 지역성(Locality)** 이라는 규칙성이 있기 때문입니다.
시간적 지역성 (Temporal Locality)
최근에 접근한 데이터는 곧 다시 접근할 가능성이 높습니다.
// 루프 변수 sum은 매 반복마다 접근 → 시간적 지역성
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += arr[i]; // sum은 L1 캐시에 계속 머무름
}
공간적 지역성 (Spatial Locality)
한 주소에 접근하면 근처 주소도 곧 접근할 가능성이 높습니다.
// 배열 순차 접근 → 공간적 지역성 극대화
int[] arr = new int[1000];
for (int i = 0; i < 1000; i++) {
arr[i] = i; // 연속 메모리 접근
}
캐시 라인 — 캐시의 최소 단위
캐시는 바이트 단위가 아니라 ** 캐시 라인(Cache Line)** 단위로 데이터를 가져옵니다. 현대 CPU의 캐시 라인 크기는 거의 예외 없이 64바이트 입니다.
이것이 의미하는 것
int 배열에서 arr[0]을 읽으면:
→ 캐시 라인 64바이트가 통째로 로드
→ int는 4바이트이므로 arr[0]~arr[15]까지 16개가 함께 캐시에 올라옴
→ arr[1]~arr[15]는 이미 캐시에 있으므로 히트!
배열 순차 접근이 빠른 진짜 이유 가 바로 이것입니다. 첫 번째 원소를 읽을 때 나머지 15개가 공짜로 따라옵니다.
배열 vs 연결 리스트 — 캐시 관점의 성능 차이
알고리즘 수업에서 "배열 순회와 연결 리스트 순회 모두 O(n)"이라고 배우지만, 실제 성능 차이는 수 배에서 수십 배까지 날 수 있습니다.
배열 (Array)
메모리 레이아웃:
[1][2][3][4][5][6][7][8][9][10][11][12][13][14][15][16]
|____________캐시 라인 1____________||__캐시 라인 2__|...
→ 첫 접근 1번 미스 + 이후 15번 히트 = 히트율 93.75%
연결 리스트 (Linked List)
메모리 레이아웃 (힙에 흩어짐):
[Node1] → ... [Node2] → ... [Node3] → ...
0x100 0x5F00 0x2A00
→ 노드가 서로 다른 캐시 라인에 위치 → 매 접근마다 캐시 미스 가능
→ 히트율이 극도로 낮음
실측 비교 (100만 개 원소 순회)
배열 순회: ~0.5ms (L1/L2 캐시 히트 극대화)
연결 리스트: ~5-15ms (캐시 미스 → RAM 접근 반복)
같은 O(n)이지만 캐시 효과로 인해 10~30배 차이가 날 수 있습니다. 이것이 "캐시 친화적(cache-friendly)" 코드가 중요한 이유입니다.
False Sharing — 멀티코어 환경의 함정
문제 상황
// 두 스레드가 각자 다른 카운터를 증가시킴
class SharedCounters {
volatile long counter1; // 스레드 A가 사용
volatile long counter2; // 스레드 B가 사용
}
논리적으로 counter1과 counter2는 독립적입니다. 하지만 두 변수가 같은 캐시 라인(64바이트) 안에 위치하면:
- 코어 A가
counter1을 수정 → 해당 캐시 라인을 dirty로 표시 - 코어 B가
counter2를 읽으려면 → 코어 A의 캐시 라인을 무효화하고 다시 로드 - 코어 A가 다시 수정하려면 → 코어 B의 캐시 라인을 무효화...
- MESI 프로토콜에 의한 캐시 라인 핑퐁이 반복
해결: 패딩으로 캐시 라인 분리
// Java에서 @Contended 어노테이션 또는 수동 패딩
class PaddedCounters {
volatile long counter1;
long p1, p2, p3, p4, p5, p6, p7; // 56바이트 패딩 → 다른 캐시 라인으로 밀어냄
volatile long counter2;
}
// Java 8+에서는 @sun.misc.Contended 사용 가능
// JVM 옵션: -XX:-RestrictContended
Java의 LongAdder가 AtomicLong보다 멀티스레드 환경에서 빠른 이유도 내부적으로 셀을 캐시 라인 단위로 분리하기 때문입니다.
SoA vs AoS — 데이터 레이아웃 전략
같은 데이터를 어떻게 배치하느냐에 따라 캐시 효율이 달라집니다.
Array of Structures (AoS)
// 구조체 배열: 한 객체의 모든 필드가 연속
class Particle {
float x, y, z; // 위치
float vx, vy, vz; // 속도
float mass; // 질량
}
Particle[] particles = new Particle[10000];
// x 좌표만 순회할 때:
// [x,y,z,vx,vy,vz,mass][x,y,z,vx,vy,vz,mass]...
// 캐시 라인에 불필요한 y,z,vx,vy,vz,mass도 함께 로드
Structure of Arrays (SoA)
// 배열의 구조체: 같은 필드끼리 연속
class Particles {
float[] x = new float[10000];
float[] y = new float[10000];
float[] z = new float[10000];
float[] vx = new float[10000];
float[] vy = new float[10000];
float[] vz = new float[10000];
float[] mass = new float[10000];
}
// x 좌표만 순회할 때:
// [x0, x1, x2, x3, x4, x5, x6, x7, x8, ...]
// 캐시 라인에 x 값만 빽빽하게 → 히트율 극대화
선택 기준
| 접근 패턴 | 권장 레이아웃 |
|---|---|
| 특정 필드만 반복 접근 | SoA — 캐시 라인 활용 극대화 |
| 모든 필드를 함께 접근 | AoS — 한 번의 캐시 로드로 전체 필드 |
| 게임 엔진, 과학 계산 | SoA가 일반적 |
| 일반 비즈니스 로직 | AoS(OOP 스타일)가 자연스러움 |
자바에서의 캐시 친화적 코딩
배열 순회 방향
// 좋음: 행 우선 순회 (Row-major, 메모리 연속)
int[][] matrix = new int[1000][1000];
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 1000; j++) {
matrix[i][j] = 0; // 연속 메모리 접근
}
}
// 나쁨: 열 우선 순회 (캐시 미스 폭증)
for (int j = 0; j < 1000; j++) {
for (int i = 0; i < 1000; i++) {
matrix[i][j] = 0; // 매번 다른 행으로 점프
}
}
행 우선 vs 열 우선 순회만 바꿔도 1000x1000 행렬에서 3~5배 성능 차이가 납니다.
컬렉션 선택
// 캐시 친화적: ArrayList (내부 배열, 연속 메모리)
List<Integer> list = new ArrayList<>();
// 캐시 비친화적: LinkedList (노드가 힙에 흩어짐)
List<Integer> list = new LinkedList<>();
// 순회 성능: ArrayList가 LinkedList보다 수 배~수십 배 빠름
LinkedList의 장점(중간 삽입 O(1))이 있더라도, 순회가 잦다면 ArrayList가 캐시 효과 덕분에 거의 항상 이깁니다.
정리
- **L1~L3 캐시 지연 시간 **: L1 < 1ns → L2 ~5ns → L3 ~16ns → RAM ~80ns, L3→RAM 점프가 가장 치명적
- ** 캐시 라인(64바이트)**: 캐시의 최소 전송 단위, 배열이 빠른 진짜 이유
- ** 지역성 **: 시간적(같은 데이터 재접근) + 공간적(인접 데이터 접근)이 캐시 효율의 핵심
- False sharing: 독립적인 변수가 같은 캐시 라인에 있으면 멀티코어 성능 급락 → 패딩으로 해결
- SoA vs AoS: 접근 패턴에 따라 데이터 레이아웃을 선택해야 함
- ** 자바 실전 **: ArrayList > LinkedList, 행 우선 순회, 캐시 라인을 의식한 데이터 설계
빅오 표기법으로는 같은 O(n)인데 실제 성능이 10배 차이 나는 이유, 공부하다 보니 결국 캐시로 귀결되더라고요. 알고리즘 최적화 다음 단계가 캐시 최적화라는 걸 이 주제에서 확실히 느꼈습니다.