문승현 튜터님 강의 - ‘CPU에 대하여’
CPU에 대하여
지난번에 “MyGame.exe”가 실행되면 ‘가상 메모리’라는 4개 구역(Code, Static, Stack, Heap)의 ‘영토’가 생긴다고 했습니다.
그럼 CPU(Central Processing Unit)는 무엇일까요?
바로 그 영토에서 ‘Code 영역’에 적힌 설계도(기계어)를 한 줄 한 줄 읽고, ‘Static/Stack/Heap’ 영역에 있는 재료(데이터)를 가져와서,
실제 ‘제품’(연산 결과)을 만들어내는 ‘공장의 핵심 일꾼’ (혹은 ‘정밀한 로봇 팔’)입니다.
여러분이 C++로 작성한 if (Health < 0) 이나 Location += Velocity * DeltaTime; 같은 모든 로직은,
결국 이 ‘일꾼(CPU)’이 알아들을 수 있는 수백, 수천 개의 아주 간단한 ‘작업 지시서’로 번역된 것뿐입니다.
1. CPU의 핵심 구성 요소 (일꾼의 도구)
비유하자면, RAM(메인 메모리)이 ‘대형 부품 창고’라면,
CPU는 ‘핵심 조립 공장’ 그 자체입니다.
이 공장은 3가지 핵심 부품으로 돌아갑니다.
- 컨트롤 유닛 (Control Unit, CU)
-
비유: 공장의 ‘작업 반장’ 또는 ‘중앙 제어실’.
-
역할: 이 ‘반장’이 모든 걸 지시합니다. 다음 작업 지시(명령어)를 가져오고(Fetch),
그게 무슨 뜻인지 해석(Decode)합니다.
그리고 그 작업을 위해 어떤 기계(e.g., ALU)를 돌려야 할지 ‘지시’하고 ‘조율’하죠. -
if (Health < 0)같은 분기문이나Add(10, 20)같은 함수 호출(다른 작업으로 점프)을 처리하는 주체입니다.
-
- 산술/논리 연산 장치 (ALU - Arithmetic Logic Unit)
- 비유: 공장의 ‘실제 조립/가공 기계’ (프레스, 용접기, 계산기).
- 역할: 작업 반장(CU)의 지시를 받아 실제 ‘작업’을 수행(Execute)합니다.
- 덧셈/뺄셈(산술)이나
A && B,A > B같은 비교/판단(논리) 연산을 담당하죠. - 여러분이 쓴
Health -= Damage;코드는, CU의 지시로 ALU가 ‘뺄셈’ 작업을 착착 수행하는 겁니다.
- 비유: 공장의 ‘실제 조립/가공 기계’ (프레스, 용접기, 계산기).
- 레지스터 (Registers)
-
비유: 작업자의 ‘손’ 또는 ‘작업대 바로 위’ (지금 당장 쓰는 부품/공구).
-
역할: CPU가 ‘지금 당장’ 사용하는 데이터를 ‘임시’로 보관하는,
CPU 칩 내부에 존재하는 가장 빠른 저장 공간입니다. (RAM과는 속도 비교가 민망할 정도죠.) - 수명: 단일 작업 지시(명령어), 혹은 함수 실행 중 아주 짧은 순간 동안만 유지됩니다.
- 여러분이 작성한
int result = a + b;(이때 a=10, b=20) 코드는 CPU 내부에서 이렇게 돌아갑니다.
-
// 1. 우리가 작성한 C++ 코드
int a = 10;
int b = 20;
int result = a + b; // <- 이 한 줄이...
// 2. CPU(CU)가 해석하고 실행하는 유사 기계어
// (CU) "창고(메모리)에 있는 'a'(10) 값을 레지스터 EAX(작업대 1번)로 가져와!"
MOV EAX, [a의_메모리_주소]
// (CU) "창고(메모리)에 있는 'b'(20) 값을 레지스터 EBX(작업대 2번)로 가져와!"
MOV EBX, [b의_메모리_주소]
// (CU) "EAX랑 EBX, 둘 다 '가공 기계(ALU)'로 보내서 '더하기' 작업해!"
ADD EAX, EBX // (ALU가 덧셈 수행, 결과 30을 다시 EAX에 저장)
// (CU) "EAX(30)에 있는 결과물을 'result' 위치(메모리)에 다시 갖다 놔!"
MOV [result의_메모리_주소], EAX
지난번에 ‘스택(Stack)’ 영역이 왜 그렇게 빨랐는지 기억나시죠?
함수가 호출될 때마다 사용하는 ‘작업 공간’(스택 프레임)의
기준 주소(스택 포인터) 자체가 바로 이 ‘레지스터’에 저장되거든요.
그러니 ‘스택 포인터 이동’은 그냥 레지스터 값 덧셈/뺄셈일 뿐이라 빛처럼 빠른 겁니다.
2. CPU와 캐시(Cache)
우리가 방금 ‘레지스터’는 작업자의 ‘손’이고, ‘RAM’은 ‘대형 부품 창고’라고 했죠?
그런데 작업자(CPU)는 1초에 수천 번 작업을 하는데(0.2ns),
부품(데이터)을 가지러 저 멀리 ‘대형 부품 창고’(RAM)까지 다녀오려면 500배의 시간(100ns)이 걸립니다.
작업자가 ‘나사 1개’ 가지러 공장 밖 창고까지 10분 동안 뛰어갔다 오는 꼴이죠.
이러면 작업자가 아무리 빨라도 공장은 멈춰 섭니다.
그렇다면 어떤 해결 방법이 있을까요? 작업자 바로 옆에 ‘작은 공구함(L1 캐시)’과 ‘중간 부품 선반(L2 캐시)’을 두는 겁니다.
이 ‘공구함/선반’을 통틀어 캐시(Cache)라고 부릅니다.
| 이름 | 비유 | 속도 (접근 시간) | 크기 |
|---|---|---|---|
| 레지스터 | 작업자 손 (작업대) | 즉시 (0.2 ns) | 극도로 작음 (KB) |
| L1 캐시 | 작은 공구함 (개인) | 매우 빠름 (1 ns) | 매우 작음 (e.g., 128KB) |
| L2 캐시 | 중간 부품 선반 (개인) | 빠름 (4 ns) | 작음 (e.g., 1MB) |
| L3 캐시 | 공장 동(棟) 임시 창고 (공유) | 조금 느림 (15 ns) | 중간 (e.g., 32MB) |
| RAM (메인) | 대형 부품 창고 (외부) | 매우 느림 (100 ns) | 매우 큼 (GB) |
CPU(일꾼)는 ‘예언가’가 아니기 때문에, 100% 정확한 예측은 불가능합니다.
대신, 거의 항상 맞아떨어지는 두 가지 강력한 ‘경험 법칙’에 의존해 공구함/선반을 채워둡니다.
-
- 캐시 히트?
- 이러한 예측이 성공하여 바로 데이터를 가져오는 것
- 캐시 히트?
-
- 캐시 미스
- 예측이 실패하여 새로운 부품을 찾으러 가야 하는 상황
- 보통 캐시 미스는 DRAM까지 찾는 상황
- DRAM에도 없으면 페이지 폴트가 발생하여 OS가
해당 데이터를 디스크(HDD/SSD)에서 찾아옴
(이후 Page Table 업데이트 후, 명령 재실행)
- 캐시 미스
지역성은 CPU의 메모리 접근 패턴
시간 지역성 (Temporal Locality)
공장 일꾼이 ‘10mm 렌치’를 방금 사용했다면, 그 렌치를 저 멀리 창고에 갖다 두는 게 아니라,
일단 ‘손’이나 ‘작업대 위’에 둡니다. 왜? 방금 썼다는 건, 다음 작업에도 그걸 또 쓸 확률이 높다는 뜻이니까요.
데이터가 한 번 사용되면(RAM -> 캐시로 로드), CPU는 이 데이터가 ‘쓸모있다’고 판단하고,
캐시에서 이 데이터를 버리지 않고 최대한 오래 보관하려 합니다.
// 1000마리 몬스터의 골드를 모두 합산한다.
int TotalGold = 0;
TArray<FMonsterData> Monsters; // (1000개짜리 배열)
// 이 루프가 1000번 실행된다고 가정
for (int i = 0; i < 1000; i++)
{
// [!!!] 이 'TotalGold' 변수에 주목하세요.
// 1. TotalGold를 '읽고' (Read)
// 2. 몬스터의 골드를 '더하고' (Add)
// 3. TotalGold에 '다시 씁니다'. (Write)
TotalGold += Monsters[i].Gold;
}
시간 지역성이 없다면 (최악의 시나리오):
- i=0:
TotalGold(0)를 RAM에서 읽어옴 -> 10 더함 ->TotalGold(10)를 RAM에 씀. (RAM 왕복) - i=1:
TotalGold(10)를 RAM에서 읽어옴 -> 5 더함 ->TotalGold(15)를 RAM에 씀. (RAM 왕복) - …
- 이 루프는 RAM을 2000번 (읽기 1000번, 쓰기 1000번)이나 왕복해야 합니다. 재앙이죠.
시간 지역성이 있다면 (실제 동작):
- i=0:
- CPU: “
TotalGold필요해!” -> 캐시 미스! 💥 TotalGold(0)를 RAM에서 캐시(L1 공구함)로 가져옵니다.- CPU가 캐시에 있는 값(0)을 레지스터(손)로 가져와 10을 더합니다.
- 결과(10)를 다시 캐시에 씁니다.
- CPU: “
- i=1:
- CPU: “
TotalGold필요해!” - CPU가 L1 캐시(공구함)를 확인합니다. -> “어? 방금 썼던 거네!” -> 캐시 히트! ✨
- RAM까지 갈 필요 없이, 1나노초 만에 캐시에서 값(10)을 가져옵니다.
- 5를 더하고, 결과(15)를 다시 캐시에 씁니다.
- CPU: “
- i=2 ~ i=999:
- 모든 접근이 캐시 히트! ✨
- 모든 접근이 캐시 히트! ✨
그 결과 RAM 접근은 단 1번으로 줄고,
나머지 1999번의 접근은 빛처럼 빠른 L1/L2 캐시에서 처리됩니다.
이게 바로 ‘시간 지역성’의 위력입니다.
공간 지역성 (Spatial Locality)
일꾼이 창고(RAM)에 ‘A-5’ 선반의 ‘4mm 나사’를 가지러 갔습니다.
창고 관리자(메모리 컨트롤러)는 일꾼에게 ‘4mm 나사’ 딱 한 개만 주지 않습니다.
“어차피 A-5 선반까지 왔으니, 그 선반에 든 ‘부품 상자’를 통째로 가져가!”
이 ‘부품 상자’에는 ‘4mm 나사’뿐만 아니라, 그 옆에 있던 ‘5mm 나사’, ‘6mm 나사’, ‘작은 볼트’ 등이 모두 들어있습니다.
CPU가 int 하나(4바이트)를 요청해도,
RAM은 그 데이터가 포함된 64바이트짜리 ‘한 상자(Cache Line)’를 통째로 캐시에 쏘아줍니다.
이 ‘공간 지역성’은 TArray 같은 연속 메모리 구조가 왜 그토록 빠른지,
그리고 포인터 배열이 왜 그토록 느린지를 증명합니다.
int는 4바이트, 캐시 라인은 64바이트입니다.
즉, ‘부품 상자’ 하나에 int 16개가 들어갑니다.
- 주변에 존재하는 데이터를 같이 읽어옴
// int가 1000개 '연속으로' 쫙 깔려있는 배열
TArray<int> MyScores; // (1000개 가정)
void CalculateTotalScore()
{
int Total = 0;
for (int i = 0; i < 1000; i++)
{
// MyScores[i]에 접근할 때 일어나는 일
Total += MyScores[i];
}
}
루프가 실행될 때 캐시의 움직임:
- i = 0:
- CPU: “
MyScores[0]필요해!” -> 캐시 미스! 💥 - CPU가 RAM(창고)에
MyScores[0]의 주소를 요청합니다. - RAM: “오케이!
MyScores[0]이 포함된 64바이트 ‘한 상자’ 보낼게!” - 캐시에
MyScores[0]부터MyScores[15]까지 (총 16개의int)가 한꺼번에 로드됩니다.
- CPU: “
- i = 1:
- CPU: “
MyScores[1]필요해!” - 캐시(공구함) 확인 -> 캐시 히트! ✨ (방금
MyScores[0]가져올 때 상자에 같이 딸려왔죠.)
- CPU: “
- i = 2 ~ i = 15:
- CPU: “
MyScores[2]…[15]필요해!” -> 전부 캐시 히트! ✨
- CPU: “
- i = 16:
- CPU: “
MyScores[16]필요해!” -> 캐시 미스! 💥 (첫 번째 상자에는 없었죠.) - RAM: “오케이!
MyScores[16]이 포함된 두 번째 64바이트 상자 보낼게!” - 캐시에
MyScores[16]부터MyScores[31]까지가 로드됩니다.
- CPU: “
- i = 17 ~ i = 31:
- 전부 캐시 히트! ✨
- 전부 캐시 히트! ✨
MyScores에 1000번 접근했지만,
실제 ‘값비싼’ RAM 접근(캐시 미스)은 1000 / 16 = 약 63번 밖에 일어나지 않았습니다.
나머지 937번은 전부 캐시에서 100배 빠른 속도로 처리된 겁니다.
캐시 지옥 (현실): TArray<AActor*> 순회
TArray<int>가 ‘이상적인 천국’이었다면, TArray<AActor*>
(혹은 TArray<UObject*>)를 순회하는 것은 ‘현실적인 지옥’에 가깝습니다.
왜냐하면 AActor 객체들은 메모리 강의에서 배운 힙(Heap) 영역에,
그것도 new(언리얼에서는 SpawnActor)를 호출할 때마다 여기저기 흩어져 생성되기 때문입니다.
-
TArray 자체의 문제가 아니라 Pointer이며
내부에서 여러 Heap 공간에 할당되어 있음
(vector<Actor*> 와 비슷한 상황) -
Pointer에 대한 접근 자체는 빠르나
그 내부 요소에 접근하는 순간 캐시 미스 발생
// 1. 1000명의 적(Enemy)이 Heap 영역 곳곳에 흩어져 스폰됐다고 칩시다.
// (부품 위치: 0x1000A000, 0x3000C000, 0x5000F000, ...)
TArray<AEnemyCharacter*> EnemyActors; // '포인터'(부품 위치 주소)의 배열
void UpdateAllEnemies_Bad(float DeltaTime)
{
// 이 루프는 캐시 효율 면에서 '재앙'입니다.
for (AEnemyCharacter* Enemy : EnemyActors)
{
// 'Enemy' 포인터(주소) 자체를 읽는 것은 빠릅니다. (TArray는 연속적이므로)
// 하지만 그 주소가 가리키는 '실제 부품(데이터)'에 접근할 때...
// 1. Enemy 1 (0x1000A000)의 Health에 접근 -> 💥 캐시 미스!
// (RAM에서 0x1000A000 근처 64바이트(한 상자)를 가져옴)
// 2. Enemy 2 (0x3000C000)의 Health에 접근 -> 💥 캐시 미스!
// (아까 L1에 가져온 64바이트는 0x1000A000 근처 데이터라 아무 쓸모가 없음)
// 3. Enemy 3 (0x5000F000)의 Health에 접근 -> 💥 캐시 미스!
// ... (최악의 경우 캐시 미스 1000번 발생!)
if (Enemy->Health < 0) { }
}
}
TArray<int> 예시와 비교하면, EnemyActors 순회는
1000 / 16 = 63번이 아니라 1000번의 캐시 미스를 유발할 수 있습니다. 16배나 비효율적이죠.
하지만 AActor는 언리얼 엔진에서 사용하지 않을 수 없습니다.
(대부분의 경우 사용하는 방식)
- 물론 데이터 주도 프로그래밍 방식도 나름 생각할수는 있으나
그건 유지보수성을 어느정도 희생해야 함
‘다형성’ (e.g., AEnemyBase*가 AGrunt나 ATank를 가리킴)을 사용하기 위해서는 반드시 포인터(*)로 다뤄야만 합니다.
그렇다면 이 ‘공간 지역성’ 원칙은 언제 의미가 있을까요?
바로 개발자가 ‘선택’의 기로에 섰을 때입니다. AActor처럼 ‘월드에 존재하는 유일한 개체’가 아닌, ‘순수한 데이터 묶음’을 다룰 때가 그렇습니다.
이때 현실적으로 마주하는 갈림길이 바로 UObject를 쓸 것인가, UStruct를 쓸 것인가의 문제입니다.
게임에 ‘아이템’ 데이터가 필요하고, 이 아이템은 ‘스탯 버프’ 3개와 ‘이름’을 가진다고 가정해 봅시다.
[방식 1] “모든 걸 UObject로” (유연하지만, 캐시 지옥)
UStatBuff를 UObject로, UItemData도 UObject로 만듭니다.
UCLASS()
class UStatBuff : public UObject { /* ... */ };
UCLASS()
class UItemData : public UObject
{
public:
UPROPERTY() FString Name;
// [!!!] 버프 '포인터'를 3개 가집니다.
UPROPERTY() TObjectPtr<UStatBuff> Buff1; // 힙 어딘가에 생성 (e.g., 0x1000)
UPROPERTY() TObjectPtr<UStatBuff> Buff2; // 힙 어딘가에 생성 (e.g., 0x3000)
UPROPERTY() TObjectPtr<UStatBuff> Buff3; // 힙 어딘가에 생성 (e.g., 0x5000)
};
// 인벤토리는 당연히 UItemData의 포인터 배열입니다.
UPROPERTY()
TArray<TObjectPtr<UItemData>> Inventory; // (100개 아이템 소지)
- 순회:
Inventory[0]에 접근 -> 캐시 미스! ->Inventory[0]->Buff1에 접근 -> 또 캐시 미스! ->...->Buff2-> 또 캐시 미스! - 장점:
UStatBuff를 상속받는 ‘다형성’ 구현이 쉽고, GC가 알아서 관리해줍니다. - 단점:
TArray<AActor*>예시와 똑같습니다. 데이터가 메모리에 뿔뿔이 흩어져서 캐시 효율이 0에 수렴합니다.
UObject 상속시, 사실상 Heap에 생성
[방식 2] “데이터 덩어리는 UStruct로” (캐시 천국)
FStatBuff와 FItemData를 USTRUCT로 만듭니다.
USTRUCT(BlueprintType)
struct FStatBuff { /* ... */ };
USTRUCT(BlueprintType)
struct FItemData
{
GENERATED_BODY()
public:
UPROPERTY() FString Name;
// [!!!] 버프 '데이터 자체'를 3개 가집니다.
UPROPERTY() FStatBuff Buff1; // FItemData 메모리 안에 '포함됨'
UPROPERTY() FStatBuff Buff2; // FItemData 메모리 안에 '포함됨'
UPROPERTY() FStatBuff Buff3; // FItemData 메모리 안에 '포함됨'
};
// 인벤토리는 'FItemData' 구조체 자체를 TArray로 가집니다.
UPROPERTY()
TArray<FItemData> Inventory; // (100개 아이템 소지)
- 순회:
Inventory[0]에 접근 -> 캐시 미스! - 하지만!
FItemData구조체(Name, Buff1, Buff2, Buff3)가 ‘한 덩어리’이므로, 캐시 라인(64바이트 상자)에 통째로 같이 딸려옵니다. Inventory[0].Buff1접근 -> 캐시 히트! ✨Inventory[0].Buff2접근 -> 캐시 히트! ✨- 장점:
TArray<int>예시와 똑같은 완벽한 공간 지역성을 가집니다. 수천 개의 아이템을 순회해도 빛처럼 빠릅니다. - 단점:
FStatBuff는 상속(다형성)이 불가능합니다.
결론
AActor처럼 ‘월드에 존재하는 유일한 개체’나 ‘다형성’이 필요하면TArray<AActor*>(포인터)를 써야 합니다.- 하지만 ‘아이템 데이터’, ‘스탯 값’처럼 순수한 ‘데이터 묶음’을 다룰 때는,
UObject대신USTRUCT를 사용하고TArray<FItemData>(값)로 저장하는 것이 캐시 성능에 압도적으로 유리합니다.- 캐시 성능이 중요한지, 아니면 다형성이 중요한지는 개발자의 선택입니다. 상황과 필요에 따라 선택하면 됩니다. 항상 정답은 없습니다.
- 다형성이 중요한 경우라면, UObject 등을 고려한 클래스 방식
- 캐시 성능이 중요하다면 UStruct 같은 방식을 고려
3. CPU, 코어, 멀티 코어
자, 이제 공장(CPU)의 성능을 높이기 위해 ‘일꾼’ 자체를 늘려보겠습니다.
“CPU가 의미하는 것이 코어인가요?”
아주 좋은 질문입니다. 예전(싱글 코어 시대)에는
‘CPU 1개 = 일꾼 1명’이 맞았습니다. 하지만 지금은 다릅니다.
- CPU (물리적 칩): 비유하자면 ‘공장 건물’ 그 자체입니다.
- 코어 (Core): 공장 안에서 ‘실제로 일하는 작업 라인’ (또는 ‘일꾼’)입니다.
- 즉, 1개의 코어는 우리가 1번 항목에서 배운 ‘CU(작업 반장) + ALU(가공 기계) + 레지스터(작업대)’ 세트를 의미합니다.
(보통 L1, L2 캐시도 코어마다 전용으로 가집니다.)
- 즉, 1개의 코어는 우리가 1번 항목에서 배운 ‘CU(작업 반장) + ALU(가공 기계) + 레지스터(작업대)’ 세트를 의미합니다.
- 멀티 코어 (Multi-Core):
- 비유: 1개의 ‘공장 건물’(CPU) 안에, 8개의 ‘작업 라인’(코어)이 들어 있는 겁니다. (e.g., 8코어 CPU)
- 특징: 8개의 작업 라인이 진짜로 동시에 8개의 다른 작업을 처리할 수 있습니다. (이것을 ‘병렬성(Parallelism)’이라고 합니다.)
- 비유: 1개의 ‘공장 건물’(CPU) 안에, 8개의 ‘작업 라인’(코어)이 들어 있는 겁니다. (e.g., 8코어 CPU)
멀티 코어의 위험성 (데이터 공유 문제)
여러 일꾼(코어)이 동시에 일하는 것은 좋지만,
만약 이 일꾼들이 ‘하나뿐인 공용 부품’(공유 데이터)을 동시에 건드린다면 ‘대형 사고’가 터집니다.
이것이 바로 메모리 강의에서 배운 ‘Static 영역’의
G_TotalScore 같은 전역 변수가 위험한 이유입니다.
// G_TotalScore = 100 일 때
// (작업자 1 [코어 1]) | (작업자 2 [코어 2])
// ---------------------+----------------------
// 1. TotalScore 읽음 (100) |
// | 2. TotalScore 읽음 (100)
// 3. 100 + 10 = 110 계산 |
// | 4. 100 + 20 = 120 계산
// 5. TotalScore에 110 저장! |
// | 6. TotalScore에 120 저장!
// 결과: 120 (작업자 1의 +10 연산이 '증발'해버렸습니다.)
이것을 ‘경쟁 상태(Race Condition)’ 혹은 ‘동시성 이슈’라고 부릅니다.
언리얼에서는 이런 문제를 FCriticalSection(Mutex, 락)이나 TAtomic(원자적 연산)을 사용해
“이 부품(데이터)은 지금 나만 쓸게!”라고 ‘잠그는(Lock)’ 방식으로 해결하지만,
이 ‘잠금’ 자체가 또 다른 성능 저하를 유발할 수 있습니다.
- 이러한 상황을 피해야 성능을 최대한 이용할 수 있음
언리얼에서의 동시성:
- 언리얼 엔진은 ‘태스크 그래프(Task Graph)’라는 시스템을 이용해, 수많은 작업(물리, 렌더링, AI)을 여러 코어(일꾼)에게 자동으로 분배하고 병렬 처리합니다.
- 개발자는 가급적
G_TotalScore같은 ‘공용 부품’을 만들지 말고, 각자 ‘개인 부품’(스택 변수, 객체 멤버 변수)을 사용하도록 코드를 설계하는 것이 좋습니다.
4. 프로세스, 스레드, 그리고 CPU 스케줄링
자, 이제 ‘8개 작업 라인(코어)’을 가진 공장이 있습니다.
이 공장을 효율적으로 돌리기 위해 ‘공장장(OS)’이 등장하는데요.
공장장이 작업을 지시하기 전에, 우리는 ‘작업의 단위’를 먼저 알아야 합니다.
바로 프로세스와 스레드입니다.
프로세스 (Process): “독립된 작업장”
- 비유: “MyGame”이라는 ‘거대한, 격리된 작업장’입니다.
- 특징:
- 우리가
MyGame.exe를 더블 클릭하면, 공장장(OS)은 공장 한구석에 완벽히 격리된 공간(가상 메모리 영토)을 만들어줍니다. - 이 안에는 지난번에 배운 Code, Data, Heap, Stack 영역이 모두 들어있습니다.
- 보안(Isolation):
MyGame작업장(프로세스)과Chrome작업장(프로세스)은 철저히 분리되어 있습니다.Chrome작업장에서 불이 나도(크래시),MyGame작업장은 안전합니다. - 비용: 작업장을 하나 새로 짓는 건(프로세스 생성) 매우 비싸고 오래 걸리는 일입니다.
- 우리가
프로세스는 실행된 프로그램이자
OS가 만들어준 ‘가상 메모리’ 안에서 실행되는 API
스레드 (Thread): “작업장 안의 일꾼들”
- 비유:
MyGame작업장 안에서 일하는 ‘실제 일꾼들’입니다. - 특징:
- 하나의 작업장(프로세스) 안에는 최소 1명 이상의 일꾼(스레드)이 있습니다. (메인 스레드)
- 공유(Sharing): 이 일꾼들은 작업장 안의 자원(Code, Data, Heap)을 공유합니다. “야, 저기 힙 영역에 있는 몬스터 데이터 좀 줘봐!” 하면 바로 가져갈 수 있죠.
- 개인 공간(Stack): 단, 각 일꾼은 자기만의 ‘개인 작업대(Stack 영역)’와 ‘손(레지스터)’을 가집니다. (함수 호출 기록 등은 각자 관리해야 하니까요.)
- 비용: 일꾼을 한 명 더 고용하는 건(스레드 생성), 작업장을 새로 짓는 것보다 훨씬 싸고 빠릅니다.
- 하나의 작업장(프로세스) 안에는 최소 1명 이상의 일꾼(스레드)이 있습니다. (메인 스레드)
프로세스의 실행 유닛
같은 프로세스의 영역을 공유하기에 데이터 전환이 빠름
각자의 작업 상황이 존재 (Context)
다만, 스레드 하나가 잘못하면 프로세스 전체가 터지고
모든 스레드가 같이 죽는다…
- OS에서 컨텍스트 스위칭을 통해
작업 기록상황을 체크하여
이전에 중단한 지점에서 재시작할 수 있음
언리얼 엔진의 스레드 구조:
- 게임 스레드 (Game Thread): 대장 일꾼입니다.
Tick()함수, 게임 로직, 액터 관리를 도맡아 합니다.- 렌더 스레드 (Render Thread): 그림 그리는 전문 일꾼입니다. 게임 스레드가 “이거 그려줘”라고 명령하면, GPU에게 작업을 전달합니다.
- 워커 스레드 (Worker Threads): 물리 계산, 애니메이션, 로딩 등 잡일을 돕는 보조 일꾼들입니다.
CPU 스케줄링 (일꾼 관리하기)
이제 상황을 봅시다. 공장에는 8개의 작업 라인(코어)이 있는데,
처리해야 할 ‘일꾼(스레드)’이 500명(게임 스레드, 렌더 스레드, 윈도우 시스템 스레드 등)이나 대기 중입니다.
8개의 코어는 동시에 딱 8명의 스레드만 처리할 수 있습니다.
이때 ‘공장장(OS)’의 역할이 중요해집니다.
이 공장장이 하는 ‘작업 분배’ 행위를 CPU 스케줄링이라고 부릅니다.
스케줄러(공장장): 대기 중인 500명의 ‘일꾼(스레드)’ 명단을 봅니다.
- 역할 1: 우선순위 (Priority) 결정
- “음… 지금 사용자가 마우스를 움직이는 작업(1순위), 게임 렌더링 작업(1순위)은 매우 급하군!”
- “백그라운드에서 바이러스 검사하는 작업(5순위)은 천천히 해도 되겠어.”
- 스케줄러는 ‘급한 일꾼’을 ‘안 급한 일꾼’보다 먼저 코어(작업 라인)에 배치합니다.
- “음… 지금 사용자가 마우스를 움직이는 작업(1순위), 게임 렌더링 작업(1순위)은 매우 급하군!”
- 역할 2: 시분할 (Time Slice)과 문맥 교환 (Context Switch)
- 아무리 급한 일(게임 스레드)이라도, 그 일꾼이 코어 1번을 영원히 독점하게 놔두지 않습니다. (독재 방지)
- 공장장(OS): “자, 게임 스레드! 너 일단 0.01초만 일해! … 삑! 시간 됐다!”
- “네가 하던 거(레지스터 상태) 전부 ‘개인 작업대(Stack)’에 저장해두고 비켜! 저기 ‘음악 재생 스레드’ 들어와!”
- … 0.01초 뒤 …
- 공장장(OS): “음악 스레드 비켜! 아까 저장해뒀던 게임 스레드 다시 들어와서 이어서 해!”
- 아무리 급한 일(게임 스레드)이라도, 그 일꾼이 코어 1번을 영원히 독점하게 놔두지 않습니다. (독재 방지)
이처럼 아주 짧은 시간(Time Slice) 동안 여러 스레드를 번갈아 가며
코어에 넣었다 뺐다 하는 것을 ‘시분할(Time Slicing)’이라고 합니다.
그리고 일꾼을 교체하는 이 비싼 행위(저장하고 복구하는 과정)를
‘문맥 교환(Context Switch)’이라고 부릅니다.
우리가 8코어 CPU에서 프로그램 500개를 동시에 켜놓은 것처럼 보이는 이유는,
실제로는 이 ‘문맥 교환’이 1초에 수천 번 일어나며 우리 눈을 속이고 있기 때문입니다.
💡 공장장의 업무 수칙: 스케줄링 알고리즘
그럼 공장장(OS)은 수백 명의 일꾼 중 ‘누구’를 다음 타자로 뽑을까요?
이 ‘업무 수칙(알고리즘)’이 어떻게 짜여 있느냐에
따라 게임이 부드럽게 돌아갈 수도, 뚝뚝 끊길 수도 있습니다.
(1) 선착순 처리 (FIFO / FCFS) - “무능한 공장장”
- 방식: “먼저 온 사람 먼저!” (First-In, First-Out)
- 상황:
- ‘백신 정밀 검사’ 작업(10분 소요)이 먼저 도착해 코어를 잡았습니다.
- 직후에 ‘마우스 클릭’ 작업(0.001초 소요)이 도착했습니다.
- ‘백신 정밀 검사’ 작업(10분 소요)이 먼저 도착해 코어를 잡았습니다.
- 결과: 마우스 클릭 처리는 백신 검사가 끝날 때까지 10분 동안 대기해야 합니다. 사용자는 “컴퓨터 멈췄네” 하고 전원을 뽑겠죠.
- 특징: 게임 같은 실시간 반응형 시스템에서는 절대 쓰면 안 되는 방식입니다. (똥차 하나 때문에 뒤차들이 다 막히는 ‘콘보이 효과’가 발생합니다.)
비선점 방식
- 작업을 못 뺏음
(2) 라운드 로빈 (Round Robin) - “공평한 공장장”
- 방식: “모두에게 딱 0.01초(Time Slice)씩만 기회를 주겠다!”
- 상황: ‘백신’, ‘게임’, ‘음악’ 스레드가 둥글게 앉아 0.01초씩 돌아가며 코어를 씁니다.
- 결과: 백신 검사는 조금 늦어지겠지만, 게임과 음악은 끊김 없이 동시에 돌아가는 것처럼 보입니다.
- 특징: 현대 멀티태스킹 OS의 기본 뼈대입니다. 단, 너무 자주 교대하면 ‘문맥 교환 비용’이 커져 효율이 떨어집니다.
선점
- 작업을 뺏을 수 있음
RR은 오버헤드 비용이 클 수 있음
(3) 우선순위 스케줄링 (Priority Scheduling) - “자본주의 공장장”
- 방식: “급한(중요한) 놈이 먼저다!”
- 상황:
- 스케줄러는 각 작업에 ‘계급(Priority)’을 매깁니다.
- High: 게임 렌더링, 마우스 입력 처리 (실시간성 중요)
- Low: 윈도우 업데이트 다운로드, 파일 압축 (천천히 해도 됨)
- 스케줄러는 각 작업에 ‘계급(Priority)’을 매깁니다.
- 결과: 게임이 켜져 있을 땐 게임에 코어를 몰아주고, 게임이 최소화되면 백그라운드 작업에 코어를 나눠줍니다.
- 주의점 (기아 상태, Starvation): 높은 계급의 일꾼이 계속 들어오면, 낮은 계급의 일꾼은 영원히 코어를 못 받을 수도 있습니다. (OS가 오래 기다린 일꾼의 계급을 올려주는 ‘Aging’ 기법으로 해결합니다.)
오래 기다린 녀석들의 우선순위를 높여주는
MLFQ 등의 방식이 존재
💡 문맥 교환의 비용: “이사 비용” (프로세스 vs 스레드)
공장장(OS)이 일꾼을 교체하는 ‘문맥 교환’은 공짜가 아닙니다.
하던 일을 정리하고, 새 일을 준비하는 ‘행정 비용(Overhead)’이 듭니다.
그런데 이 비용은 ‘누구와 교대하느냐’에 따라 엄청난 차이가 납니다.
스레드 간의 교환 (같은 팀원끼리 교대)
- 상황:
MyGame작업장 안에서 ‘게임 로직 일꾼(Game Thread)’이 잠시 쉬고, 같은 팀인 ‘물리 연산 일꾼(Physics Thread)’이 들어옵니다. - 비용: 저렴합니다 (Lightweight).
- 이유:
- 같은 작업장(프로세스)을 쓰기 때문에, 공장 내부의 자재(Heap), 설계도(Code), 데이터(Data)를 그대로 두면 됩니다.
- 단지 작업자가 바뀌었으니, 개인 공구함(Stack, 레지스터)만 챙겨서 자리를 비켜주면 끝입니다.
- 캐시 효율: 작업장의 공용 자재를 같이 쓰기 때문에, 캐시(L2, L3)에 들어있는 데이터가 여전히 유효(Hot)할 확률이 높습니다.
- 같은 작업장(프로세스)을 쓰기 때문에, 공장 내부의 자재(Heap), 설계도(Code), 데이터(Data)를 그대로 두면 됩니다.
같은 프로세스 내의 스레드끼리의 문맥 교환은 저렴함
- 캐시 데이터 등을 그대로 사용할 가능성이 높음
페이지 테이블 등이 바뀌지 않음
프로세스 간의 교환 (다른 업체와 교대)
- 상황:
MyGame작업장 전체가 작업을 멈추고, 갑자기Chrome브라우저 작업장이 들어와야 합니다. - 비용: 매우 비쌉니다 (Heavyweight).
- 이유:
- 대공사: 작업장(가상 메모리 공간) 전체를 빼야 합니다. 벽에 붙은
MyGame설계도를 떼어내고,Chrome설계도로 도배를 다시 해야 합니다. - 캐시 오염 (Cache Pollution) - 치명타:
MyGame이 기껏 캐시(L1, L2)에 가져다 놓은 ‘몬스터 데이터’, ‘플레이어 정보’는Chrome입장에서는 완전한 쓰레기입니다.Chrome이 실행되면서 캐시를 자기 데이터로 싹 덮어씁니다.- 나중에 다시
MyGame순서가 돌아오면? 캐시가 텅 비어있습니다(Cold Cache). 다시 RAM에서 데이터를 가져오느라 엄청난 렉(Stall)이 발생합니다.
- 대공사: 작업장(가상 메모리 공간) 전체를 빼야 합니다. 벽에 붙은
- 캐시가 초기화되며
페이지 테이블을 바꿔야 함
(가상 주소와 물리 메모리 주소가 달라지므로)
요약:
- 스레드 교체: “같은 주방에서 요리사만 교대.” (칼이랑 도마만 챙기면 됨, 재료는 그대로 씀) -> 빠름
- 프로세스 교체: “한식당을 닫고 그 자리에 양식당 개업.” (인테리어, 재료 싹 다 바꿔야 함) -> 매우 느림 & 캐시 초기화
5. CPU의 숫자들: 64바이트와 64비트의 비밀
우리가 흔히 듣는 “캐시 라인은 64바이트다”,
“이건 64비트 컴퓨터다”라는 말들은 비슷해 보이지만, 공장(CPU)에서는 완전히 다른 의미로 쓰입니다.
1. 캐시 라인 64바이트: “택배 상자의 표준 규격”
앞서 우리는 “공간 지역성 때문에 나사 하나만 필요해도 ‘한 상자’를 통째로 가져온다”고 했죠?
여기서 ‘한 상자’의 크기가 바로 64바이트(Bytes)입니다.
❓ 왜 하필 64바이트인가요? (Trade-off)
이건 CPU 설계자들이 수많은 실험 끝에 찾아낸 ‘황금 밸런스’입니다.
만약 4바이트(나사 1개)라면?
- 장점: 딱 필요한 것만 가져오니 낭비가 없음
- 단점: 100개의 데이터가 필요하면 창고(RAM)를 100번 왔다 갔다 해야 함. (배송비 폭탄) -> 너무 느림
만약 1024바이트(초대형 컨테이너)라면?
- 장점: 한 번만 가면 됨.
- 단점: 나사 1개 필요한데 컨테이너를 통째로 가져옴. 캐시(공구함)가 금방 꽉 차버리고, 가져오는 시간(대역폭)도 오래 걸림. -> 너무 비효율적.
그래서 “너무 작지도, 너무 크지도 않은” 최적의 크기가 64바이트로 굳어진 것입니다.
64바이트 상자에는
int(4바이트)가 16개 들어갑니다. (TArray<int>가 빠른 이유) 64바이트 상자에는AActor*포인터(8바이트)가 8개 들어갑니다. 64바이트 상자에는FMatrix(64바이트)가 딱 1개 들어갑니다. (행렬 연산 최적화의 기준)
2. 32비트 vs 64비트 컴퓨터: “일꾼의 신체 스펙”
“내 컴퓨터는 64비트야”라고 할 때의 이 숫자는,
공장 일꾼(CPU 코어)의 ‘손 크기’와 ‘기록 능력’을 의미합니다.
레지스터의 크기 (손 크기)
레지스터는 일꾼이 데이터를 쥐고 있는 ‘손’이라고 했죠?
-
32비트 CPU: 일꾼의 손이 작습니다. 한 번에 32개의 0과 1(약 42억)까지만 쥘 수 있습니다.
만약 ‘50억 + 50억’을 계산하려면? 손이 작아서 한 번에 못 듭니다. 두 번에 나눠서 들어야 합니다. (느림) -
64비트 CPU: 일꾼의 손이 엄청 큽니다. 한 번에 64개의 0과 1(약 1844경)이라는 천문학적인 숫자도 한 손으로 가볍게 듭니다.
‘50억 + 50억’ 같은 큰 계산도 한 방에 처리합니다. (빠름)
주소 지정 능력 (배달 가능한 주소지 범위)
이게 게임 개발자에게 가장 치명적인 차이입니다. 일꾼이 창고(RAM)의 위치를 적을 수 있는 ‘주소록의 자릿수’입니다.
-
32비트 CPU ($2^{32}$):
주소록에 쓸 수 있는 주소가 최대 약 42억 개(4GB)입니다.
문제: 아무리 RAM을 16GB, 32GB를 꽂아놔도, 32비트 CPU(와 OS)는 4GB 이후의 주소는 읽지도, 쓰지도 못합니다.
옛날 게임들이 “Out of Memory” 띄우며 자주 튕겼던 이유가 바로 이겁니다. (게임이 4GB 이상을 못 씀) -
64비트 CPU ($2^{64}$):
주소록 한계가 16 엑사바이트(160억 GB)입니다.
사실상 무한대입니다. 현재 지구상의 어떤 게임도 이 메모리를 다 못 채웁니다.
언리얼 엔진 5는 64비트 OS에서만 돌아갑니다. 광활한 메모리 영토가 필요하니까요.
6. 언리얼 엔진과 드로우 콜
드로우 콜이 무엇인가요?
언리얼 엔진에서 여러분이 레벨에 StaticMeshActor 하나를 배치했다고 가정해 봅시다.
화면에 이 메시가 그려지기 위해서는 다음과 같은 복잡한 과정이 필요합니다.
- 게임 스레드 (CPU): “어? 카메라 앞에 의자가 있네? 이거 그려야겠다.” -> 렌더 스레드로 정보 전달.
- 렌더 스레드 (CPU): “의자를 그리려면 ‘나무 재질(Material)’이랑 ‘의자 모양(Mesh)’이 필요하군. GPU야 준비해!”
- RHI (Render Hardware Interface): 그래픽 카드 드라이버(DirectX, Vulkan 등)에게 명령을 번역해서 전달.
- GPU: 명령을 받고 화면에 그림.
여기서 CPU가 GPU에게 “자, 이 재료로 이거 하나 그려!”라고
명령서를 툭 던지는 행위, 이것이 바로 드로우 콜(Draw Call)입니다.
[참고] stat rhi 언리얼 에디터에서 ~ 키를 누르고 stat rhi를 입력하면 우측 상단에 정보가 뜹니다. 여기서 DrawPrimitive calls 숫자가 바로 현재 프레임의 드로우 콜 횟수입니다. (보통 PC 게임은 2,000~3,000회 이하, 모바일은 100~200회 이하를 권장합니다.)
왜 드로우 콜이 많으면 느려질까요?
- 상황: 여러분(CPU)이 이삿짐센터 직원이고, 트럭(GPU)에 짐을 실어야 합니다.
- 나쁜 예 (많은 드로우 콜):
- 책 1권을 들고 5층에서 내려와 트럭에 싣습니다. (드로우 콜 1)
- 다시 5층에 올라가서 책 1권을 들고 내려옵니다. (드로우 콜 1)
- 이걸 1,000번 반복합니다.
- 결과: 트럭(GPU)은 텅텅 비어서 놀고 있는데, 여러분(CPU)만 계단을 오르내리느라 탈진합니다. (CPU 병목 현상)
- 책 1권을 들고 5층에서 내려와 트럭에 싣습니다. (드로우 콜 1)
- 좋은 예 (배칭/인스턴싱):
- 책 1,000권을 큰 박스 하나에 담습니다.
- 한 번에 들고 내려와 트럭에 던집니다. (드로우 콜 1)
- 결과: CPU도 편하고, 트럭도 한 번에 꽉 찹니다.
- 책 1,000권을 큰 박스 하나에 담습니다.
언리얼 엔진에서의 해결책 (최적화 기법)
언리얼은 이 ‘드로우 콜’을 줄이기 위해 강력한 도구들을 제공합니다.
(1) 액터 병합 (Merge Actors)
식탁 다리 4개와 상판 1개를 각각 다른 StaticMeshActor로 배치했다면? 드로우 콜은 5번 발생합니다.
- 해결: 언리얼 에디터의 [도구] -> [액터 병합(Merge Actors)] 기능을 사용합니다.
- 원리: 다리 4개와 상판을 아예 하나의 새로운
StaticMesh로 구워버립니다. - 결과: 드로우 콜이 5회 -> 1회로 줄어듭니다.
- 주의: 너무 큰 덩어리를 합치면, 카메라에 꼬만 보여도 전체를 다 그려야 해서(오클루전 컬링 불가) 메모리와 GPU 손해를 볼 수 있습니다. “항상 붙어 다니는 작은 물체들”에 쓰세요.
(2) 인스턴싱 (Instancing)
숲에 나무가 1,000그루 있습니다. 모양(Mesh)과 재질(Material)은 똑같은데 위치만 다릅니다.
- 나쁜 방법:
StaticMeshActor1,000개 배치 -> 드로우 콜 1,000번 -> 렉 발생. - 좋은 방법:
Instanced Static Mesh (ISM)또는Hierarchical Instanced Static Mesh (HISM)컴포넌트 사용. - 원리: CPU가 GPU에게 이렇게 말합니다. “자, 여기 ‘나무’ 데이터 보이지? 그리고 여기 좌표 1,000개 명단 줄게. 한 방에 다 그려!“
- 언리얼 기능:
- 폴리지(Foliage) 툴: 여러분이 땅에 칠하는 나무와 풀은 자동으로 이
HISM기술을 써서 렌더링됩니다. 그래서 수만 그루를 심어도 렉이 안 걸리는 겁니다. - 블루프린트/C++: 똑같은 총알, 똑같은 바닥 타일은
AddInstance함수를 써서 배치하세요.
- 폴리지(Foliage) 툴: 여러분이 땅에 칠하는 나무와 풀은 자동으로 이
메시 데이터는 1개
좌표 데이터만 다르게 찍어내는 방식
(3) 머티리얼 관리
GPU가 그림을 그릴 때 가장 귀찮아하는 것이 ‘상태 변경(State Change)’입니다.
즉, “빨간 붓으로 칠하다가 파란 붓으로 바꾸는 시간”이 칠하는 시간보다 더 걸립니다.
- 상황: 큐브 100개를 그리는데, 큐브마다 머티리얼이 다 다르면?
- CPU: “1번 큐브(빨강) 그려! 잠깐, 붓 씻고… 2번 큐브(파랑) 그려! 잠깐 붓 씻고…“
- 결과: 드로우 콜 배칭이 끊겨서 성능이 나락으로 갑니다.
- CPU: “1번 큐브(빨강) 그려! 잠깐, 붓 씻고… 2번 큐브(파랑) 그려! 잠깐 붓 씻고…“
- 해결 (텍스처 아틀라스): 여러 개의 텍스처를 포토샵에서 큰 이미지 한 장에 합칩니다. 그리고 UV 좌표만 다르게 해서 씁니다.
- 이제 붓을 안 바꾸고(머티리얼 교체 없이) 한 번에 100개를 그릴 수 있습니다.
- 이제 붓을 안 바꾸고(머티리얼 교체 없이) 한 번에 100개를 그릴 수 있습니다.
(4) HLOD (Hierarchical Level of Detail) - “멀리 있는 건 뭉뚱그리기”
- 상황: 마을에 집이 100채 있습니다. 가까이 가면 집 하나하나가 다 보이지만, 아주 멀리서 보면 그냥 ‘마을’이라는 점으로 보입니다.
- 기능: 언리얼의 HLOD 시스템은 멀리 있는 여러 개의 액터(집 100채)를 자동으로 하나의 거대한 덩어리(Proxy Mesh)로 합쳐줍니다.
- 결과: 멀리 있을 때 드로우 콜 100번 -> 1번으로 감소.
댓글남기기