[Unity] CPU 내부 구조와 메모리 상호작용: 캐시 히트와 캐시 미스
![[Unity] CPU 내부 구조와 메모리 상호작용: 캐시 히트와 캐시 미스](/_next/image?url=https%3A%2F%2Fdata.develog.develrocket.com%2Fupload%2Fdevelog%2Fuser_1776858831954%2F1777192918989-zc1yzr%2Ftumb2.png&w=3840&q=75)
댓글 2
댓글을 작성하려면 로그인이 필요합니다.

관련하여 자주 나오는 토픽으로 퀵정렬과 힙정렬의 비교, 인트로 하이브리드 정렬을 다루는 경우도 많습니다! 좋은 자료 공유해주셔서 감사합니다.
몰랐던 내용이에요 댓글 감사합니다! 좋은 공부가 됐어요!!
![[Unity] CPU 내부 구조와 메모리 상호작용: 캐시 히트와 캐시 미스](/_next/image?url=https%3A%2F%2Fdata.develog.develrocket.com%2Fupload%2Fdevelog%2Fuser_1776858831954%2F1777192918989-zc1yzr%2Ftumb2.png&w=3840&q=75)
댓글을 작성하려면 로그인이 필요합니다.

관련하여 자주 나오는 토픽으로 퀵정렬과 힙정렬의 비교, 인트로 하이브리드 정렬을 다루는 경우도 많습니다! 좋은 자료 공유해주셔서 감사합니다.
몰랐던 내용이에요 댓글 감사합니다! 좋은 공부가 됐어요!!
"내 코드는 논리적으로 완벽한데, 왜 이렇게 느리지?" 게임 클라이언트 개발을 하다 보면 한 번쯤 이런 의문을 품게 된다. 그 답은 종종 CPU가 메모리와 상호작용하는 방식, 특히 캐시(Cache) 에 숨어 있다.
CPU가 연산을 수행할 때는 여러 하드웨어 구성 요소가 긴밀하게 협력한다. 이 과정을 이해하기 위해 먼저 각 구성 요소의 역할을 정리해 보자.
ALU는 CPU의 계산 엔진이다. 덧셈, 뺄셈, 곱셈 같은 산술 연산과 AND, OR, NOT 같은 논리 연산을 담당한다. 게임에서 캐릭터의 HP를 깎거나, 충돌 판정을 계산하거나, AI의 상태를 결정하는 모든 연산이 결국 ALU를 거친다.
레지스터는 CPU 내부에 존재하는 가장 빠른 저장 공간이다. ALU가 연산하기 위해 데이터를 잠시 올려두는 작업대라고 생각하면 된다. 접근 속도는 약 1 사이클(< 1ns) 로 사실상 즉시 접근이 가능하지만, 용량이 수십~수백 바이트에 불과해 극히 적은 양의 데이터만 보관할 수 있다.
MMU는 CPU와 메모리 사이에서 주소 번역을 담당하는 중간 관리자다. 프로그램이 사용하는 가상 주소(Virtual Address) 를 실제 물리 메모리의 물리 주소(Physical Address) 로 변환한다. 덕분에 각 프로세스는 자신이 메모리 전체를 독점하는 것처럼 착각하며 동작할 수 있다. 또한 메모리 보호(다른 프로세스 영역 침범 방지)도 MMU가 담당한다.
캐시는 CPU와 Main Memory 사이에 위치한 고속 임시 저장소다. CPU 칩 내부 또는 바로 인근에 위치하며, 계층 구조(L1 → L2 → L3)로 구성된다. 속도와 용량은 반비례한다.
| 계층 | 접근 속도 | 용량 | 위치 |
|---|---|---|---|
| L1 Cache | 32~64 KB | CPU 코어 내부 | |
| L2 Cache | 256 KB~1 MB | CPU 코어 내부/근방 | |
| L3 Cache | 8~64 MB | 여러 코어 공유 | |
| Main Memory | 수 GB~수십 GB | 메인보드 |
Bus는 CPU, 캐시, 메인 메모리, 기타 장치들 사이에서 데이터를 주고받는 통신 통로다. 데이터 버스, 주소 버스, 제어 버스로 구성된다. 버스 대역폭이 좁거나 경합이 발생하면 전체 시스템 성능의 병목이 될 수 있다.
Main Memory는 프로그램과 데이터가 실행 중에 상주하는 공간이다. 캐시보다 훨씬 크지만, 접근 속도는 훨씬 느리다.
게임 클라이언트 코드에서 player.hp -= damage; 라는 한 줄이 실행된다고 가정해 보자. 내부적으로는 다음과 같은 순서로 동작한다.
[프로그램 코드]
↓ (1) 명령어 Fetch
[CPU Core]
↓ (2) 가상 주소 → 물리 주소 변환 요청
[MMU + TLB]
↓ (3) 물리 주소로 캐시 탐색
[L1 Cache → L2 Cache → L3 Cache]
↓ (4) 캐시 미스 시 버스를 통해 Main Memory 접근
[Bus → Main Memory]
↓ (5) 데이터를 Register에 적재
[Register]
↓ (6) ALU가 연산 수행
[ALU]
↓ (7) 결과를 다시 Register → Cache → Memory 에 쓰기
[Register → Cache → Main Memory]
각 단계를 조금 더 자세히 살펴보자.
CPU는 프로그램 카운터(PC, Program Counter)가 가리키는 주소에서 명령어를 가져온다(Fetch). 명령어 자체도 명령어 캐시(I-Cache) 에서 먼저 찾고, 없으면 메모리에서 가져온다.
코드는 가상 주소를 사용한다. player.hp의 주소가 0x7FFF_1234_5678 이라면, MMU는 이를 실제 물리 주소로 변환한다. 이때 TLB(Translation Lookaside Buffer) 라는 캐시를 활용해 빠르게 변환한다. TLB에 해당 매핑 정보가 없으면(TLB 미스) 페이지 테이블을 직접 탐색해야 하므로 추가 지연이 발생한다.
물리 주소가 결정되면, CPU는 그 데이터가 캐시에 있는지 확인한다.
CPU가 원하는 데이터가 이미 캐시에 존재하는 경우다. L1에서 찾으면 약 4 사이클만에 데이터를 얻을 수 있다. 게임처럼 매 프레임 수십만 번의 연산이 일어나는 환경에서 캐시 히트는 성능의 핵심이다.
CPU → L1 Cache 탐색 → 데이터 발견 → Register로 적재 → ALU 연산
(약 4 사이클)
CPU가 원하는 데이터가 캐시에 없는 경우다. L1 미스 → L2 탐색 → L2 미스 → L3 탐색 → L3 미스 → Main Memory 접근 순서로 이어진다. Main Memory까지 가면 L1 히트 대비 50배 이상 느려질 수 있다.
CPU → L1 미스 → L2 미스 → L3 미스 → Bus → Main Memory
(약 200 사이클)
| 종류 | 설명 | 예시 |
|---|---|---|
| Cold Miss (강제 미스) | 데이터가 한 번도 캐시에 올라온 적 없음 | 게임 시작 시 첫 프레임 |
| Capacity Miss (용량 미스) | 캐시 공간이 부족해 교체 후 다시 필요해진 경우 | 너무 많은 오브젝트 순회 |
| Conflict Miss (충돌 미스) | 캐시의 구조적 한계로 같은 슬롯에 데이터가 충돌 | 특정 메모리 접근 패턴 |
캐시가 효과적인 이유는 지역성 원리(Principle of Locality) 때문이다.
i)캐시는 데이터를 단일 바이트 단위가 아닌 캐시 라인(Cache Line) 단위(보통 64바이트)로 가져온다. 한 번에 인접 메모리를 통째로 캐시에 올리는 것이다.
이제 실제 게임 클라이언트 개발에서 캐시 히트/미스가 어떻게 성능 차이를 만드는지 살펴보자.
게임에서 수백 명의 캐릭터를 매 프레임 업데이트한다고 가정해 보자.
❌ 캐시 비효율적인 방식 (AoS: Array of Structures)
// 각 캐릭터의 모든 데이터를 하나의 클래스에 몰아넣는 흔한 방식이다.
// MonoBehaviour를 사용하는 일반적인 Unity 코드 패턴이기도 하다.
public class Character : MonoBehaviour
{
public float hp;
public float maxHp;
public float animTimer;
public string characterName; // string은 참조형 → 힙의 다른 위치를 가리킨다
public int state;
public Animator animator; // 참조형 컴포넌트
// ... 수십 개의 필드 (클래스 인스턴스 크기: 수백 bytes)
}
// 씬에 1000명의 캐릭터가 있다면
Character[] characters = FindObjectsByType<Character>(FindObjectsSortMode.None);
void UpdateAllHp(float damage)
{
foreach (var c in characters)
{
// 문제: c는 힙의 임의 주소를 가리키는 참조다.
// 각 캐릭터 인스턴스가 메모리 곳곳에 흩어져 있어
// hp 하나 읽으려고 캐시 라인 64바이트 전체를 불러오지만
// 실제로 쓰는 건 hp 4바이트뿐 → 캐시 낭비 + 캐시 미스 연발!
c.hp -= damage;
}
}
✅ 캐시 친화적인 방식 (SoA: Structure of Arrays + NativeArray)
Unity6에서는 Unity.Collections의 NativeArray를 활용하면 연속된 메모리 블록을 보장받을 수 있다.
using Unity.Collections;
using UnityEngine;
public class CharacterPool : MonoBehaviour
{
const int MAX_CHARACTERS = 1000;
// 같은 종류의 데이터를 배열로 분리한다 (SoA 패턴)
// NativeArray는 관리 힙이 아닌 연속된 비관리 메모리에 할당된다.
NativeArray<float> hp;
NativeArray<float> maxHp;
NativeArray<float> posX;
NativeArray<float> posY;
NativeArray<float> posZ;
NativeArray<float> animTimer;
void OnEnable()
{
hp = new NativeArray<float>(MAX_CHARACTERS, Allocator.Persistent);
maxHp = new NativeArray<float>(MAX_CHARACTERS, Allocator.Persistent);
posX = new NativeArray<float>(MAX_CHARACTERS, Allocator.Persistent);
posY = new NativeArray<float>(MAX_CHARACTERS, Allocator.Persistent);
posZ = new NativeArray<float>(MAX_CHARACTERS, Allocator.Persistent);
animTimer = new NativeArray<float>(MAX_CHARACTERS, Allocator.Persistent);
}
void OnDisable()
{
hp.Dispose(); maxHp.Dispose();
posX.Dispose(); posY.Dispose(); posZ.Dispose();
animTimer.Dispose();
}
public void UpdateAllHp(float damage)
{
for (int i = 0; i < MAX_CHARACTERS; i++)
{
// hp 배열은 연속된 메모리에 float 4바이트씩 나란히 놓여 있다.
// 캐시 라인 64바이트 = float 16개를 한 번에 가져온다.
// 1000개를 처리하는 데 약 63번의 캐시 라인 로드로 충분하다 → 캐시 히트율 극대화!
hp[i] -= damage;
}
}
}
이 차이는 실제 대규모 게임에서 수 배의 성능 차이로 이어질 수 있다.
게임 씬에서 카메라 Frustum에 들어온 오브젝트만 렌더링하는 코드를 보자.
❌ GameObject 참조 리스트 방식 (캐시 미스 위험)
using System.Collections.Generic;
using UnityEngine;
public class NaiveCullingSystem : MonoBehaviour
{
// Unity에서 흔히 볼 수 있는 패턴: MonoBehaviour 참조 리스트
List<MeshRenderer> sceneRenderers = new();
void Start()
{
// FindObjectsByType은 씬 전체를 뒤져 참조를 수집한다.
// 각 MeshRenderer는 힙의 임의 주소에 존재한다.
sceneRenderers.AddRange(
FindObjectsByType<MeshRenderer>(FindObjectsSortMode.None)
);
}
void Update()
{
var cam = Camera.main;
var planes = GeometryUtility.CalculateFrustumPlanes(cam);
foreach (var renderer in sceneRenderers)
{
// 문제: renderer는 힙 어딘가를 가리키는 참조다.
// bounds를 읽으러 갈 때마다 MeshRenderer 객체 전체(수백 바이트)가
// 캐시에 올라오지만, 실제로 쓰는 건 bounds뿐이다.
// 수백 개의 렌더러가 흩어져 있으면 매번 캐시 미스 → 프레임 드랍!
renderer.enabled = GeometryUtility.TestPlanesAABB(planes, renderer.bounds);
}
}
}
✅ 데이터 지향 컬링 (캐시 친화적, Unity6 DOTS 스타일)
Unity6에서는 Unity.Mathematics와 NativeArray를 활용해 컬링 전용 데이터만 연속 메모리에 모을 수 있다.
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
public class DataOrientedCullingSystem : MonoBehaviour
{
const int MAX_OBJECTS = 500;
// 컬링 판정에 필요한 데이터만 연속 메모리에 모아둔다 (Data-Oriented Design)
// Bounds 대신 float3(center)와 float3(extents)로 분리해 NativeArray에 담는다.
NativeArray<float3> boundsCenter;
NativeArray<float3> boundsExtents;
NativeArray<int> rendererInstanceIds; // 나중에 활성화 전환용 ID
MeshRenderer[] renderers; // 실제 토글은 여기서
void OnEnable()
{
boundsCenter = new NativeArray<float3>(MAX_OBJECTS, Allocator.Persistent);
boundsExtents = new NativeArray<float3>(MAX_OBJECTS, Allocator.Persistent);
rendererInstanceIds = new NativeArray<int>(MAX_OBJECTS, Allocator.Persistent);
renderers = FindObjectsByType<MeshRenderer>(FindObjectsSortMode.None);
for (int i = 0; i < math.min(renderers.Length, MAX_OBJECTS); i++)
{
var b = renderers[i].bounds;
boundsCenter[i] = b.center;
boundsExtents[i] = b.extents;
}
}
void OnDisable()
{
boundsCenter.Dispose();
boundsExtents.Dispose();
rendererInstanceIds.Dispose();
}
void Update()
{
var planes = GeometryUtility.CalculateFrustumPlanes(Camera.main);
int count = math.min(renderers.Length, MAX_OBJECTS);
for (int i = 0; i < count; i++)
{
// boundsCenter, boundsExtents 배열은 연속된 메모리다.
// float3는 12바이트이므로 캐시 라인(64B)당 약 5개씩 처리된다.
// 순차적 접근 패턴 → CPU 프리페처가 미리 다음 데이터를 올려둔다!
var bounds = new Bounds(boundsCenter[i], boundsExtents[i] * 2f);
renderers[i].enabled = GeometryUtility.TestPlanesAABB(planes, bounds);
}
}
}
이펙트 팀에서 파티클 업데이트 로직을 다형성으로 구현했다고 가정해 보자.
❌ 인터페이스/MonoBehaviour 다형성 방식 (캐시 미스 위험)
using System.Collections.Generic;
using UnityEngine;
// Unity에서 자연스럽게 짜게 되는 OOP 패턴이다.
public interface IParticleUpdater
{
void UpdateParticle(float dt);
}
public class FireParticle : MonoBehaviour, IParticleUpdater { public void UpdateParticle(float dt) { /* ... */ } }
public class SmokeParticle : MonoBehaviour, IParticleUpdater { public void UpdateParticle(float dt) { /* ... */ } }
public class SparkParticle : MonoBehaviour, IParticleUpdater { public void UpdateParticle(float dt) { /* ... */ } }
public class ParticleManager : MonoBehaviour
{
List<IParticleUpdater> allParticles = new();
void Update()
{
foreach (var p in allParticles)
{
// 문제: allParticles는 힙의 임의 주소를 가리키는 참조 리스트다.
// 인터페이스 디스패치 시 vtable(메서드 테이블)을 찾으러 다시 점프한다.
// 수천 개 파티클이 서로 다른 타입으로 섞여 있으면
// 매 호출마다 캐시 미스 + 간접 호출 오버헤드 → 이펙트가 많아질수록 버벅임!
p.UpdateParticle(Time.deltaTime);
}
}
}
✅ 타입별 일괄 처리 + NativeArray (캐시 친화적)
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
// 파티클 타입별로 연속 메모리 풀을 분리해서 관리한다.
public class FireParticlePool
{
const int MAX = 2000;
public NativeArray<float3> positions;
public NativeArray<float> lifetimes;
public NativeArray<float> temperatures;
public int count;
public FireParticlePool()
{
positions = new NativeArray<float3>(MAX, Allocator.Persistent);
lifetimes = new NativeArray<float>(MAX, Allocator.Persistent);
temperatures = new NativeArray<float>(MAX, Allocator.Persistent);
}
public void UpdateAll(float dt)
{
// 동일 타입 데이터가 연속 메모리에 나란히 배치되어 있다.
// lifetimes: float 4바이트 × 2000개 = 8KB → L1 캐시에 통째로 들어간다!
// 순차 접근이므로 프리페처가 미리 다음 캐시 라인을 올려둔다.
for (int i = count - 1; i >= 0; i--)
{
lifetimes[i] -= dt;
if (lifetimes[i] <= 0f)
{
// 죽은 파티클은 맨 뒤 원소와 교체 후 count 감소 (순서 무관 삭제)
count--;
positions[i] = positions[count];
lifetimes[i] = lifetimes[count];
temperatures[i] = temperatures[count];
continue;
}
// 위로 떠오르는 불꽃 이동
var pos = positions[i];
pos.y += temperatures[i] * dt * 0.01f;
positions[i] = pos;
}
}
public void Dispose()
{
positions.Dispose();
lifetimes.Dispose();
temperatures.Dispose();
}
}
public class ParticleManager : MonoBehaviour
{
FireParticlePool firePool = new();
SmokeParticlePool smokePool = new(); // 동일 패턴으로 구현
SparkParticlePool sparkPool = new(); // 동일 패턴으로 구현
void Update()
{
float dt = Time.deltaTime;
firePool.UpdateAll(dt); // 같은 타입끼리 묶어서 처리 → 캐시 히트율 최고!
smokePool.UpdateAll(dt);
sparkPool.UpdateAll(dt);
}
void OnDestroy()
{
firePool.Dispose();
smokePool.Dispose();
sparkPool.Dispose();
}
}
CPU는 캐시 미스를 줄이기 위해 하드웨어 프리페처(Hardware Prefetcher) 를 내장하고 있다. 메모리 접근 패턴이 예측 가능하면(예: 순서대로 배열 읽기), 프리페처가 CPU보다 먼저 데이터를 캐시에 올려놓는다. 앞서 소개한 SoA 패턴이나 연속 배열 처리가 이 프리페처와 궁합이 좋다.
소프트웨어 프리페치를 Unity C#에서 직접 호출할 수는 없지만, 접근 패턴을 순차적으로 유지하는 것 자체가 하드웨어 프리페처를 최대한 활용하는 방법이다. Unity6의 Burst Compiler와 Job System을 활용하면 이 이점을 더욱 극대화할 수 있다.
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
// Burst로 컴파일되는 Job은 순차적 NativeArray 접근 시
// 하드웨어 프리페처와 SIMD 최적화를 최대한 활용한다.
[BurstCompile]
public struct UpdateEnemyPositionsJob : IJobParallelFor
{
public NativeArray<float3> positions; // 연속 메모리 보장
public NativeArray<float3> velocities; // 연속 메모리 보장
[ReadOnly] public float deltaTime;
public void Execute(int i)
{
// 인덱스 i, i+1, i+2 ... 순서대로 접근 → 완벽한 순차 패턴
// Burst + Job System이 이 패턴을 감지해 SIMD 벡터 연산으로 묶어준다.
// 하드웨어 프리페처도 다음 캐시 라인을 미리 올려두어 캐시 미스를 최소화한다.
positions[i] += velocities[i] * deltaTime;
}
}
public class EnemyMovementSystem : MonoBehaviour
{
const int ENEMY_COUNT = 2000;
NativeArray<float3> positions;
NativeArray<float3> velocities;
void OnEnable()
{
positions = new NativeArray<float3>(ENEMY_COUNT, Allocator.Persistent);
velocities = new NativeArray<float3>(ENEMY_COUNT, Allocator.Persistent);
}
void Update()
{
var job = new UpdateEnemyPositionsJob
{
positions = positions,
velocities = velocities,
deltaTime = Time.deltaTime
};
// IJobParallelFor로 멀티코어 활용 + 각 코어에서 캐시 친화적 순차 접근
job.Schedule(ENEMY_COUNT, 64).Complete();
}
void OnDisable()
{
positions.Dispose();
velocities.Dispose();
}
}
L1 캐시는 보통 물리 주소 기반으로 동작하지만, 접근 속도를 위해 가상 주소로 인덱싱하고 물리 주소로 태그를 붙이는 VIPT(Virtually Indexed, Physically Tagged) 방식을 많이 쓴다. 덕분에 MMU의 주소 변환과 캐시 탐색을 병렬로 진행할 수 있어 지연 시간을 줄인다.
[가상 주소]
├─→ [MMU/TLB] → 물리 주소 (태그 비교용)
└─→ [Cache Index 비트] → 캐시 슬롯 특정 (동시에!)
→ 둘을 조합해 캐시 히트 판정
게임 클라이언트 개발에서 캐시 문제를 진단할 때 Unity6에서 쓸 수 있는 도구들이다.
Unity Profiler (내장)
Unity Editor에서 Window → Analysis → Profiler를 열면 CPU 타임라인을 볼 수 있다. Deep Profile 모드를 켜면 메서드 단위로 시간을 측정할 수 있어, 특정 루프가 예상보다 느린지 확인하는 출발점이 된다.
Unity Profile Analyzer
Package Manager에서 Profile Analyzer를 설치하면 여러 프레임의 Profiler 데이터를 비교 분석할 수 있다. SoA 리팩토링 전후를 비교해 실제 성능 개선을 수치로 확인할 때 유용하다.
Burst Inspector
Jobs → Burst → Open Inspector를 열면 Burst로 컴파일된 Job의 어셈블리 코드를 볼 수 있다. SIMD 명령어(VMOVUPS, VADDPS 등)가 잘 생성되고 있는지 확인할 수 있어, 캐시 친화적인 코드가 실제로 최적화되고 있는지 검증할 수 있다.
플랫폼별 외부 프로파일러
| 플랫폼 | 도구 | 특징 |
|---|---|---|
| PC (Intel) | Intel VTune Profiler | L1/L2/L3 캐시 미스 횟수 직접 측정 |
| PC (AMD) | AMD μProf | 캐시 미스 및 메모리 대역폭 분석 |
| Android | Android GPU Inspector | ARM Mali/Adreno GPU + CPU 캐시 분석 |
| iOS | Xcode Instruments → CPU Counters | L1/L2 캐시 미스 측정 |
| PlayStation | PlayStation Razor CPU | 소니 공식 퍼스트파티 프로파일러 |
| 원칙 | 설명 |
|---|---|
| 연속 메모리를 사용하라 | NativeArray, SoA 패턴으로 공간적 지역성을 높여라 |
| 함께 쓰는 데이터는 함께 놔라 | 같이 처리되는 필드는 같은 배열에, 따로 처리되면 배열을 분리하라 |
| 참조 추적을 최소화하라 | MonoBehaviour 참조 리스트는 캐시 미스의 온상이다 |
| 인터페이스 다형성을 핫 루프에서 피하라 | 매 프레임 수천 번 호출되는 루프에서는 타입별 배치를 고려하라 |
| 접근 패턴을 예측 가능하게 | 순차적 접근이 캐시와 프리페처에 가장 우호적이다 |
| Burst + Job System을 활용하라 | 순차 NativeArray 접근은 SIMD 최적화와 캐시 효율을 동시에 얻는다 |
CPU의 ALU, Register, MMU, Cache, Bus, Main Memory는 서로 유기적으로 협력하며 프로그램을 실행한다. 이 흐름을 이해하고, 캐시 히트율을 높이는 코드를 작성하는 것이 고성능 Unity 게임 클라이언트 개발의 핵심 역량 중 하나다. Unity Profiler를 켜고, 캐시 미스를 추적하고, 데이터 레이아웃을 개선하는 습관을 들여보자.