작성일 :

게임 루프란 무엇인가

게임은 영화와 다릅니다. 영화는 미리 만들어진 프레임을 순서대로 재생하면 됩니다. 하지만 게임은 플레이어의 입력에 따라 매 순간 새로운 화면을 만들어야 합니다. 캐릭터가 왼쪽으로 이동했다면, 다음 화면에는 캐릭터가 왼쪽으로 옮겨진 모습이 그려져야 합니다.

이 과정을 반복하는 것이 게임 루프(Game Loop)입니다. 게임 루프는 게임이 실행되는 동안 끊임없이 돌아갑니다. 루프가 한 번 도는 것이 프레임(Frame) 한 장입니다. 60fps라면 이 루프가 1초에 60번 돌아갑니다.

게임 루프가 한 바퀴 도는 동안 수행하는 작업은 크게 네 단계로 나뉩니다.

1
2
3
4
5
6
7
8
9
10
11
┌──────────────────────────────────────────────────────────┐
│                     게임 루프 한 바퀴                      │
│                                                          │
│   입력 처리 → 로직 업데이트 → 렌더링 → 화면 표시         │
│                                                          │
│   (1) 플레이어가 버튼을 눌렀는지 확인                    │
│   (2) 게임 세계의 상태를 갱신 (이동, 충돌, AI 등)        │
│   (3) 갱신된 상태를 바탕으로 화면을 그림                  │
│   (4) 그려진 화면을 디스플레이에 출력                     │
│                                                          │
└──────────────────────────────────────────────────────────┘

입력을 먼저 확인하는 이유는, 플레이어의 조작이 로직에 반영되어야 하기 때문입니다. 로직 업데이트가 먼저 일어나면 플레이어의 입력은 한 프레임 늦게 반영됩니다. 렌더링은 로직이 끝난 후에 수행됩니다. 로직이 끝나야 “이번 프레임에서 무엇을 그릴지”가 확정되기 때문입니다.


게임 루프의 역사: 고정 속도에서 가변 타임스텝으로

초기 게임의 고정 속도 루프

1970~80년대의 초기 게임은 특정 하드웨어에서만 동작했습니다. 아케이드 기판이나 특정 가정용 컴퓨터는 CPU 속도가 고정되어 있었기 때문에, 게임 루프도 항상 같은 속도로 돌았습니다.

1
2
3
4
5
6
7
8
고정 속도 루프 (초기 게임)

while (게임 실행 중) {
    입력 처리
    로직 업데이트        ← 항상 같은 양만큼 이동
    렌더링
    화면 표시
}

이 방식에서 캐릭터를 이동시킬 때는 “한 프레임에 3픽셀 이동”처럼 고정된 값을 사용했습니다. CPU 속도가 일정하니까 루프가 도는 시간도 일정하고, 결과적으로 캐릭터의 이동 속도도 일정했습니다.

하지만 이 게임을 더 빠른 컴퓨터에서 실행하면 루프가 더 빨리 돕니다. 원래 1초에 30번 돌던 루프가 1초에 300번 돌면, 캐릭터는 10배 빠르게 움직입니다. 반대로 느린 컴퓨터에서는 캐릭터가 느려집니다.

실제로 1990년대에는 이 문제가 자주 발생했습니다. 오래된 DOS 게임을 새 컴퓨터에서 실행하면 게임이 너무 빨라서 플레이가 불가능한 현상이 흔했습니다.

가변 타임스텝과 delta time의 도입

이 문제를 해결하기 위해 가변 타임스텝(Variable Timestep) 방식이 도입되었습니다. “한 프레임에 고정된 양만큼 이동”하는 대신, 이전 프레임이 얼마나 걸렸는지를 측정하고, 그 시간 간격(delta time)을 이동량에 곱합니다.

1
2
3
4
5
6
7
8
9
가변 타임스텝 루프

while (게임 실행 중) {
    deltaTime = 현재 시각 - 이전 프레임 시각
    입력 처리
    로직 업데이트(deltaTime)   ← deltaTime에 비례하여 이동
    렌더링
    화면 표시
}

캐릭터의 속도가 “초당 300픽셀”이라면:

  • 프레임이 1/60초(약 16.6ms)에 끝났으면: 300 x 1/60 = 5픽셀 이동
  • 프레임이 1/30초(약 33.3ms)에 끝났으면: 300 x 1/30 = 10픽셀 이동

한 프레임에 이동하는 거리는 다르지만, 1초 동안의 총 이동 거리는 동일합니다. 60fps에서는 60프레임 × 5픽셀 = 300픽셀, 30fps에서는 30프레임 × 10픽셀 = 300픽셀입니다. CPU가 빠르든 느리든 캐릭터는 같은 속도로 움직입니다.

Unity에서 Time.deltaTime이 바로 이 delta time입니다. Update, FixedUpdate 그리고 LateUpdate에서 다룬 것처럼, 속도에 Time.deltaTime을 곱하면 프레임률과 무관하게 일정한 속도로 움직입니다.

물리 시뮬레이션과 고정 타임스텝의 공존

가변 타임스텝은 대부분의 게임 로직에 적합하지만, 물리 시뮬레이션에서는 문제를 일으킵니다. 물리 엔진은 “이전 위치에서 힘을 적용하면 다음 위치는 어디인가”를 수치 적분으로 계산하는데, 이 계산에는 근사 오차가 발생합니다. 시간 간격이 일정하면 오차도 일정하게 유지되어 예측 가능한 범위에 머무르지만, delta time이 프레임마다 달라지면 오차도 프레임마다 달라집니다. 그 결과 물체가 벽을 뚫고 지나가거나 충돌 판정이 한쪽에서만 발생하는 등 물리 시뮬레이션이 불안정해집니다.


그래서 현대 게임 엔진은 두 가지 타임스텝을 함께 사용합니다. 게임 로직은 가변 타임스텝으로, 물리 시뮬레이션은 고정 타임스텝으로 처리하는 방식입니다. Unity에서는 Update()가 가변 타임스텝, FixedUpdate()가 고정 타임스텝에 해당합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
현대 게임 루프의 구조

while (게임 실행 중) {
    deltaTime = 현재 시각 - 이전 프레임 시각
    입력 처리

    누적시간 += deltaTime
    while (누적시간 >= 고정간격) {         ← 고정 타임스텝 (물리)
        물리 업데이트(고정간격)
        누적시간 -= 고정간격
    }

    로직 업데이트(deltaTime)               ← 가변 타임스텝 (게임 로직)
    렌더링
    화면 표시
}

물리 업데이트가 while 루프 안에 있는 이유는, 프레임 시간이 고정 간격보다 길 때 물리 시뮬레이션을 여러 번 실행하여 누적된 시간을 소진하기 위해서입니다. 예를 들어 프레임이 50ms 걸렸고 고정 간격이 20ms라면, 물리 업데이트는 2번 실행되어 40ms를 소진하고, 10ms가 다음 프레임으로 이월됩니다. Unity의 기본 고정 간격은 0.02초(50Hz)입니다.


Unity의 실행 순서 (Execution Order)

앞에서 게임 루프의 일반적인 구조를 살펴보았습니다. Unity는 이 구조를 더 세분화하여 여러 단계로 나눕니다. 각 단계에서 특정 콜백 함수가 호출되고, 개발자는 이 콜백 안에 게임 로직을 작성합니다.

Unity 프레임 한 장의 실행 순서입니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
┌────────────────────────────────────────────────────────────────┐
│                       Unity 프레임 한 장                       │
│                                                                │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ (1) 초기화 단계                                         │   │
│  │     Awake() → OnEnable() → Start()                      │   │
│  │     게임 시작 시 또는 오브젝트 생성 시 한 번 실행        │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              │                                 │
│                              ▼                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ (2) 물리 단계                                           │   │
│  │     FixedUpdate() → 물리 시뮬레이션 → 물리 콜백         │   │
│  │     고정 타임스텝으로 실행                               │   │
│  │     OnTriggerEnter, OnCollisionEnter 등 물리 콜백 포함   │   │
│  │     한 프레임에 0회, 1회, 또는 여러 회 호출될 수 있음    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              │                                 │
│                              ▼                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ (3) 입력 처리 단계                                      │   │
│  │     OnMouseDown 등 입력 이벤트                           │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              │                                 │
│                              ▼                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ (4) 게임 로직 단계                                      │   │
│  │     Update()                                             │   │
│  │     매 프레임 호출 - 게임 로직의 핵심                    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              │                                 │
│                              ▼                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ (5) 애니메이션 / 후처리 단계                            │   │
│  │     애니메이션 평가 → LateUpdate()                       │   │
│  │     카메라 추적, 본 조정 등 후처리 작업                  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              │                                 │
│                              ▼                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ (6) 렌더링 단계                                         │   │
│  │     OnWillRenderObject → 렌더링 → OnRenderImage          │   │
│  │     카메라가 씬을 그림                                   │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              │                                 │
│                              ▼                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ (7) 화면 표시 (Present)                                 │   │
│  │     그려진 결과를 디스플레이에 출력                      │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                │
└────────────────────────────────────────────────────────────────┘


각 단계는 다음과 같습니다.

(1) 초기화 단계: Awake / OnEnable / Start

씬이 로드되거나 새 오브젝트가 생성될 때 실행됩니다. Awake()는 오브젝트가 로드되는 시점에 한 번 호출되며, 자기 자신의 컴포넌트 참조를 설정하는 데 사용됩니다. OnEnable()은 오브젝트가 활성화될 때마다 호출됩니다. Start()는 첫 번째 Update() 호출 직전에 한 번 실행되며, 다른 오브젝트의 Awake()가 모두 완료된 후이므로 다른 오브젝트를 참조하는 로직을 작성하기에 적합합니다. 예를 들어 플레이어 오브젝트가 UI 매니저를 찾아서 등록하는 코드는 Start()에서 작성합니다. Awake, Start 그리고 OnEnable에서 이 세 함수의 차이를 다룬 바 있습니다.

이 단계는 게임이 시작될 때나 오브젝트가 새로 생성될 때만 실행됩니다. 매 프레임 반복되는 것은 아닙니다.

(2) 물리 단계: FixedUpdate

고정 타임스텝(기본 0.02초)마다 호출됩니다. Rigidbody를 통한 힘 적용, 충돌 검사, 관절 시뮬레이션 등 물리 연산은 모두 이 단계에서 처리됩니다. Rigidbody는 물리 법칙의 영향을 받아야 하는 오브젝트에 부착하는 컴포넌트입니다.

FixedUpdate()의 호출 횟수는 프레임마다 다릅니다. 프레임이 빠르게 끝나면(예: 10ms) FixedUpdate()가 한 번도 호출되지 않을 수 있고, 프레임이 오래 걸리면(예: 50ms) 한 프레임 안에서 여러 번 호출될 수 있습니다. 앞에서 설명한 “누적 시간이 고정 간격 이상일 때 while 루프로 반복 호출”하는 구조와 동일합니다.

물리 시뮬레이션이 끝나면, 그 결과로 발생한 충돌/트리거 이벤트가 콜백으로 전달됩니다. OnTriggerEnter(), OnCollisionEnter() 같은 콜백이 이 시점에 호출됩니다. 이 콜백들은 물리 단계의 일부이므로, FixedUpdate()와 마찬가지로 한 프레임에 여러 번 호출될 수 있습니다. 개발자는 이 콜백 안에서 충돌에 따른 게임 로직(대미지 적용, 아이템 획득 등)을 처리합니다.

(3) 입력 처리 단계

물리 단계 이후, Update() 이전에 입력 이벤트가 처리됩니다. OnMouseDown() 같은 입력 콜백이 이 시점에 호출됩니다.

(4) 게임 로직 단계: Update

매 프레임 한 번 호출됩니다. 게임 로직의 중심입니다. 플레이어 입력 확인, AI 판단, 타이머 갱신, 상태 전환 등 대부분의 게임 로직을 이 함수 안에서 처리합니다. Update, FixedUpdate 그리고 LateUpdate에서 설명한 것처럼, 프레임률에 따라 호출 간격이 달라지므로 Time.deltaTime을 곱해야 시간에 비례하는 동작을 구현할 수 있습니다.

(5) 애니메이션 / 후처리 단계: LateUpdate

Update() 이후에 애니메이터가 애니메이션을 평가하고, 그 다음 LateUpdate()가 호출됩니다. LateUpdate()는 모든 Update()가 완료된 후에 실행되므로, 카메라가 캐릭터를 따라가는 로직처럼 “다른 오브젝트의 최종 위치를 기준으로 동작해야 하는 로직”에 적합합니다.

(6) 렌더링 단계

카메라가 씬을 그리는 단계입니다. 엔진은 먼저 카메라의 시야 안에 있는 오브젝트만 골라냅니다. 카메라에 보이지 않는 오브젝트까지 그리면 GPU 자원이 낭비되기 때문입니다.

선별된 오브젝트의 3D 모델 데이터를 GPU에 전달합니다. 각 오브젝트는 꼭짓점(vertex)과 삼각형으로 구성된 형태 정보(mesh)와 표면에 입히는 이미지(texture)를 가지고 있습니다. GPU는 이 데이터를 받아서 화면상의 픽셀 색상을 계산하고 최종 이미지를 만듭니다. 이 과정은 엔진이 자동으로 처리하며, 개발자가 직접 호출하지 않습니다.

(7) 화면 표시 (Present)

렌더링이 완료된 결과를 디스플레이에 출력합니다. GPU가 그린 이미지는 메모리의 특정 영역(프레임 버퍼)에 저장됩니다. 이 단계에서 프레임 버퍼의 내용이 디스플레이로 전달됩니다.

디스플레이는 자체적인 갱신 주기가 있습니다. 60Hz 디스플레이라면 16.67ms마다 한 번씩 화면을 다시 그립니다. GPU가 프레임을 완성하는 시점과 디스플레이가 화면을 갱신하는 시점이 어긋나면, 화면 위쪽은 새 프레임이고 아래쪽은 이전 프레임인 상태가 보일 수 있습니다. 이것이 화면 찢어짐(tearing)입니다.

VSync(Vertical Sync)는 GPU의 프레임 전달 시점을 디스플레이의 갱신 타이밍에 맞추는 기능입니다. Unity 설정에서 VSync를 활성화하면 디스플레이가 갱신될 때까지 프레임 전달을 대기하므로 찢어짐이 사라집니다. 대신 프레임률이 디스플레이 주사율에 맞춰 제한됩니다. VSync를 끄면 프레임이 완성되는 즉시 전달되므로 프레임률 제한은 없지만, 찢어짐이 발생할 수 있습니다.

이 전체 과정이 매 프레임 반복됩니다. 60fps 게임이라면, 이 순서가 1초에 60번 실행되는 것입니다.


프레임 예산 (Frame Budget)

앞에서 Unity가 프레임 한 장을 처리하는 7단계 순서를 살펴보았습니다. 이 모든 단계를 합친 시간이 프레임 한 장의 처리 시간이며, 목표 프레임률에 의해 허용되는 시간이 정해집니다. 이 허용 시간을 프레임 예산(Frame Budget)이라고 부릅니다.

목표 프레임률과 시간 예산

1
2
3
4
5
6
목표 fps     프레임 예산
───────────────────────
  30fps       33.3ms
  60fps       16.6ms
  90fps       11.1ms
 120fps        8.3ms


60fps를 유지하려면, 게임 루프의 모든 작업 – 입력 처리, 물리, 게임 로직, 애니메이션, 렌더링, 화면 표시 – 을 16.6ms 안에 끝내야 합니다.

모바일 환경의 프레임 예산

데스크톱 게임은 보통 60fps 이상을 목표로 합니다. 모바일 게임도 60fps를 목표로 하는 경우가 늘고 있지만, 여전히 30fps로 설계하는 타이틀도 많습니다. 모바일 기기의 CPU와 GPU가 데스크톱에 비해 성능이 낮기 때문이기도 하지만, 배터리와 발열이라는 모바일 고유의 제약도 큽니다.

모바일 기기는 배터리로 동작합니다. 60fps를 유지하려면 CPU와 GPU가 매 프레임 16.6ms 안에 모든 작업을 끝내야 하므로, 클럭 속도를 높게 유지하면서 쉴 틈 없이 연산을 수행합니다. 이 과정에서 전력 소비가 늘어나고, 칩에서 발생하는 열도 함께 증가합니다.


데스크톱 PC는 팬과 히트싱크로 열을 외부로 빠르게 배출합니다. 하지만 모바일 기기에는 팬이 없고, 얇은 금속 케이스가 방열의 전부입니다. 열을 배출하는 속도보다 열이 쌓이는 속도가 빠르면, 칩 온도가 계속 올라갑니다. 칩 온도가 임계점(보통 80~90°C 부근)을 넘으면 기기는 하드웨어 손상을 막기 위해 스스로 성능을 낮춥니다. 이것이 서멀 쓰로틀링(Thermal Throttling)입니다.


서멀 쓰로틀링이 발동하면 CPU와 GPU의 클럭 속도가 강제로 낮아집니다. 예를 들어, 평소 2.8GHz로 동작하던 CPU가 1.5GHz 이하로 떨어질 수 있습니다. 온도를 떨어뜨리기 위해 처리 속도를 희생하는 것입니다. 결과적으로 16.6ms 안에 끝나던 작업이 33ms 이상 걸리게 되고, 60fps를 목표로 설계했던 게임이 갑자기 30fps 이하로 떨어집니다. 게임 시작 직후에는 부드럽게 동작하다가 5~10분 후부터 프레임이 급격히 떨어지는 현상이 서멀 쓰로틀링의 전형적인 증상입니다.


목표 프레임률을 낮추면 CPU와 GPU에 여유가 생기고, 발열이 줄어들어 장시간 안정적인 성능을 유지할 수 있습니다. 이런 이유로 모바일 게임에서는 프레임률을 높이는 것보다 일정한 프레임률을 유지하는 쪽을 우선하는 경우가 많습니다.

프레임 예산 초과와 프레임 드롭

프레임 예산을 초과하면 프레임 드롭이 발생합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
60fps 목표 (프레임 예산: 16.6ms)

정상:
│ 프레임 1 │ 프레임 2 │ 프레임 3 │ 프레임 4 │
│  14ms    │  15ms    │  13ms    │  16ms    │
├──────────┼──────────┼──────────┼──────────┤
0ms       16.6ms    33.3ms    50ms       66.6ms


프레임 드롭 발생:
│ 프레임 1 │     프레임 2      │ 프레임 3 │
│  14ms    │      28ms         │  15ms    │
├──────────┼───────────────────┼──────────┤
0ms       16.6ms    33.3ms    50ms       66.6ms
                      ↑
               이 시점에 새 화면이
               표시되어야 하지만,
               프레임 2가 아직 처리 중.
               → 프레임 1이 한 번 더 표시됨.
               → 사용자에게는 끊김(stutter)으로 느껴짐.


프레임 2의 처리가 28ms 걸렸으므로, 16.6ms 지점에서 화면을 갱신하지 못합니다. 디스플레이는 이전 프레임(프레임 1)을 한 번 더 보여줍니다. 이것이 프레임 드롭(Frame Drop)입니다. 프레임 드롭이 산발적으로 발생하면 사용자는 화면이 “끊긴다”고 느낍니다.

모든 프레임이 균일하게 약간 느린 것보다, 대부분 빠르고 가끔 느린 것이 사용자에게 더 나쁘게 느껴집니다. 평균 프레임률이 60fps여도 가끔 100ms짜리 프레임이 끼어 있으면, 플레이어는 끊김을 체감합니다. 최적화의 목표는 평균 프레임률을 높이는 것이 아니라, 최악의 프레임 시간을 예산 이내로 유지하는 것입니다.


프레임 예산의 내부 구성

16.6ms라는 프레임 예산은 CPU와 GPU가 나누어 사용합니다. CPU가 게임 로직과 렌더링 준비를 담당하고, GPU가 실제 화면을 그립니다.

CPU가 하는 일

CPU는 프레임 한 장에서 다음 작업들을 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CPU의 프레임 내 작업

┌────────────────────────────────────────────────────────┐
│                                                        │
│  스크립트 실행                                         │
│  (FixedUpdate, Update, LateUpdate 등)                  │
│  ├── 입력 처리                                        │
│  ├── AI 판단                                          │
│  ├── 물리 시뮬레이션                                  │
│  ├── 애니메이션 평가                                  │
│  └── 게임 로직 전반                                   │
│                                                        │
│  렌더링 준비 (CPU 측)                                  │
│  ├── 가시성 판단 (카메라에 보이는 오브젝트 선별)      │
│  ├── 정렬 (투명/불투명, 거리순)                       │
│  └── 드로우 콜 생성 (GPU에 보낼 그리기 명령)          │
│                                                        │
└────────────────────────────────────────────────────────┘


스크립트 실행은 개발자가 작성한 코드입니다. Update() 안에서 수백 개 오브젝트의 AI를 계산하거나, 복잡한 경로 탐색을 수행하면 이 부분의 시간이 늘어납니다.

렌더링 준비는 엔진이 수행합니다. 씬에 오브젝트가 많을수록, 사용하는 머티리얼이 다양할수록 CPU가 생성해야 하는 드로우 콜(Draw Call) 수가 늘어나고, 이 단계의 시간이 증가합니다.

드로우 콜은 CPU에서 GPU로 보내는 그리기 명령입니다. “이 메쉬를, 이 설정으로 그려라”라는 지시입니다. 여기서 “설정”을 머티리얼(Material)이라고 부릅니다. 머티리얼은 오브젝트의 표면이 어떻게 보일지를 정의하는 것입니다. 텍스처, 색상, 반사 정도 등의 정보가 여기에 담깁니다.

GPU가 하는 일

GPU는 CPU가 보낸 드로우 콜을 받아서 실제 픽셀을 계산합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GPU의 프레임 내 작업

┌────────────────────────────────────────────────────────┐
│                                                        │
│  정점 처리 (Vertex Processing)                         │
│  ├── 메쉬의 정점을 화면 좌표로 변환                   │
│  └── 정점 셰이더 실행                                 │
│                                                        │
│  래스터화 (Rasterization)                              │
│  └── 삼각형을 픽셀로 변환                             │
│                                                        │
│  프래그먼트 처리 (Fragment Processing)                 │
│  ├── 각 픽셀의 색상 계산                              │
│  ├── 텍스처 샘플링                                    │
│  ├── 조명 계산                                        │
│  └── 프래그먼트 셰이더 실행                           │
│                                                        │
│  후처리 (Post-Processing)                              │
│  └── 블룸, 안티앨리어싱 등                            │
│                                                        │
└────────────────────────────────────────────────────────┘


정점 처리는 3D 모델의 꼭짓점(정점)을 화면상의 2D 좌표로 변환하는 작업입니다. 정점 수가 많은 모델일수록 이 단계의 부담이 커집니다. 정점 10만 개 이상의 고폴리곤 모델은 처리 비용이 큽니다.

래스터화는 정점 처리 결과로 나온 삼각형을 픽셀로 바꾸는 작업입니다. 삼각형이 화면에서 차지하는 영역 안의 모든 픽셀을 찾아냅니다. 이렇게 찾아낸 각 픽셀을 프래그먼트(Fragment)라고 부릅니다.

프래그먼트 처리는 각 프래그먼트(픽셀)의 최종 색상을 계산하는 단계입니다. 텍스처에서 해당 위치의 색상을 읽어오고, 조명을 적용하여 최종 색상을 결정합니다.

이 계산을 수행하는 프로그램이 셰이더(Shader)입니다. 해상도가 높을수록 계산할 픽셀이 많아지고, 셰이더가 복잡할수록 픽셀당 계산량이 늘어납니다. 모바일 기기에서 해상도를 낮추면 성능이 개선되는 이유는 프래그먼트 처리 부담이 줄어들기 때문입니다.

후처리는 프래그먼트 처리가 끝난 최종 이미지에 추가적인 시각 효과를 적용하는 단계입니다. 밝은 부분이 주변으로 번져 보이는 블룸(Bloom)이나, 도형 가장자리의 계단 현상을 완화하는 안티앨리어싱(Anti-Aliasing)이 대표적입니다.


후처리 효과 하나하나가 화면 전체의 픽셀을 다시 읽고 계산하는 과정이므로, 효과가 늘어날수록 GPU 부담이 비례하여 커집니다. 모바일에서는 블룸이나 안티앨리어싱 등 시각적 효과가 큰 것만 선별하여 사용하는 경우가 많습니다.


CPU와 GPU의 병렬 실행 (파이프라이닝)

앞에서 CPU와 GPU의 역할을 나누어 살펴보았습니다. CPU가 로직을 처리하고 GPU가 렌더링을 처리한다는 사실을 알았지만, 이 둘이 시간상 어떻게 협력하는지는 아직 다루지 않았습니다.

순차 실행과 병렬 실행의 비교

가장 단순한 방식은 CPU와 GPU가 한 프레임을 순차적으로 처리하는 것입니다. CPU가 프레임 1의 로직을 끝내면, 그 결과를 GPU에 넘기고, GPU가 렌더링을 끝낼 때까지 기다립니다. 프레임 1이 완전히 끝나야 프레임 2를 시작합니다.

1
2
3
4
5
6
7
8
9
10
11
12
순차 실행 (비효율적)

시간 →
       ┌─────────┐            ┌─────────┐
CPU    │ 프레임 1 │    대기    │ 프레임 2 │    대기
       └─────────┘            └─────────┘
                  ┌─────────┐             ┌─────────┐
GPU      대기     │ 프레임 1 │    대기     │ 프레임 2 │
                  └─────────┘             └─────────┘

       ├─── 프레임 1 완료 ───┤├─── 프레임 2 완료 ───┤
              총 20ms                  총 20ms


CPU가 10ms 일하고, GPU가 10ms 일한다면, 프레임 하나에 20ms가 걸립니다. CPU가 일하는 동안 GPU는 대기하고, GPU가 일하는 동안 CPU는 대기합니다. 둘 중 하나는 항상 유휴 상태입니다.


실제 Unity(그리고 대부분의 게임 엔진)는 파이프라이닝(Pipelining)을 사용합니다.

1
2
3
4
5
6
7
8
9
병렬 실행 (파이프라이닝)

시간(ms) →  0        10        20        30        40
            ┌─────────┐┌─────────┐┌─────────┐┌─────────┐
CPU         │ 프레임 1 ││ 프레임 2 ││ 프레임 3 ││ 프레임 4 │
            └─────────┘└─────────┘└─────────┘└─────────┘
                       ┌─────────┐┌─────────┐┌─────────┐
GPU           대기     │ 프레임 1 ││ 프레임 2 ││ 프레임 3 │
                       └─────────┘└─────────┘└─────────┘


0~10ms 구간에서 GPU가 대기하는 이유는, CPU가 프레임 1의 로직을 아직 처리 중이라 GPU에 렌더링 명령이 전달되지 않았기 때문입니다. CPU가 프레임 1을 끝내야 GPU가 그 결과를 받아 렌더링을 시작할 수 있습니다.

10ms~20ms 구간부터 파이프라이닝이 동작합니다. 여기서 파이프라이닝은 CPU와 GPU가 서로 다른 프레임을 동시에 처리하는 방식을 말합니다. GPU 내부에서 3D 데이터를 2D 이미지로 변환하는 단계별 처리 과정인 “렌더링 파이프라인”과는 다른 개념입니다. CPU는 프레임 2의 로직을, GPU는 프레임 1의 렌더링을 동시에 처리합니다. 순차 실행에서는 CPU 10ms + GPU 10ms = 20ms가 걸렸지만, 파이프라이닝에서는 서로 다른 프레임을 동시에 처리하므로 10ms마다 하나의 프레임이 완성됩니다. 프레임 시간은 CPU와 GPU 중 오래 걸리는 쪽이 결정합니다.

파이프라이닝의 대가: 입력 지연

파이프라이닝 덕분에 처리량은 두 배가 되었지만, 대가가 있습니다. 위 다이어그램에서 프레임 1을 따라가 보면, CPU가 0~10ms에 로직을 처리하고, GPU가 10~20ms에 렌더링을 마칩니다. 프레임 1의 결과가 화면에 표시되는 것은 20ms 시점입니다. 플레이어가 0ms에 버튼을 눌렀다면, 그 입력이 반영된 화면을 보기까지 20ms(2프레임분)가 걸리는 것입니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
프레임 1이 화면에 표시되기까지의 과정

시간(ms) →  0        10        20
            ┌─────────┐
CPU         │ 프레임 1 │
            │  (로직)  │
            └─────────┘
                       ┌─────────┐
GPU                    │ 프레임 1 │
                       │(렌더링) │
                       └─────────┘
                                  ┌─────────┐
화면                              │ 표시    │
                                  └─────────┘

            ├── 입력 → 화면 표시까지의 지연 ──┤


이 지연을 입력 지연(Input Latency) 또는 입력 랙(Input Lag)라고 합니다. 파이프라이닝이 없는 순차 실행에서도 입력 지연은 존재하지만, 파이프라이닝에서는 CPU와 GPU가 서로 다른 프레임을 동시에 처리하기 때문에 최소 1프레임의 추가 지연이 발생합니다.

대부분의 게임에서 1~2프레임의 입력 지연은 플레이어가 인식하기 어렵습니다. 하지만 격투 게임이나 리듬 게임처럼 정밀한 입력 타이밍이 중요한 장르에서는 이 지연이 체감될 수 있습니다.

병목 지점: CPU-bound와 GPU-bound

파이프라이닝 구조에서 프레임 시간은 CPU와 GPU 중 느린 쪽이 결정합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
균형 상태 (이상적):
       ┌──────────┐┌──────────┐
CPU    │  10ms    ││  10ms    │
       └──────────┘└──────────┘
       ┌──────────┐┌──────────┐
GPU    │  10ms    ││  10ms    │
       └──────────┘└──────────┘
       프레임 시간: 10ms


CPU-bound (CPU가 병목):
       ┌───────────────┐┌───────────────┐
CPU    │     15ms      ││     15ms      │
       └───────────────┘└───────────────┘
       ┌──────────┐     ┌──────────┐
GPU    │  10ms    │대기 │  10ms    │대기
       └──────────┘     └──────────┘
       프레임 시간: 15ms  (GPU가 5ms 대기)


GPU-bound (GPU가 병목):
       ┌──────────┐     ┌──────────┐
CPU    │  10ms    │대기 │  10ms    │대기
       └──────────┘     └──────────┘
       ┌───────────────┐┌───────────────┐
GPU    │     15ms      ││     15ms      │
       └───────────────┘└───────────────┘
       프레임 시간: 15ms  (CPU가 5ms 대기)


CPU가 15ms 걸리고 GPU가 10ms 걸리면, GPU는 프레임 1의 렌더링을 10ms에 끝내지만, CPU가 프레임 2의 로직을 끝낼 때까지 5ms 동안 아무 일도 하지 않고 기다립니다. 이때 프레임 시간은 15ms입니다. 이 상태를 CPU-bound라고 합니다. CPU가 병목입니다. GPU의 성능을 아무리 올려도 프레임 시간은 줄어들지 않습니다.

반대로 GPU가 15ms 걸리고 CPU가 10ms 걸리면, CPU는 로직을 10ms에 끝내지만 GPU가 렌더링을 끝낼 때까지 기다려야 합니다. 이때는 GPU-bound입니다. GPU가 병목입니다. CPU 최적화를 해도 프레임 시간은 변하지 않습니다.

최적화의 첫 단계는 현재 게임이 CPU-bound인지 GPU-bound인지 파악하는 것입니다. 병목이 아닌 쪽을 아무리 최적화해도 프레임 시간은 개선되지 않습니다.


프레임 예산의 현실적 분배

CPU-bound인지 GPU-bound인지를 파악했다면, 다음은 프레임 예산 안에서 각 단계가 얼마를 차지하는지 확인해야 합니다. 앞에서 살펴본 것처럼 CPU와 GPU는 파이프라이닝으로 병렬 실행되므로, 예산도 각각 따로 관리합니다. 비율은 게임에 따라 다르지만, 일반적인 3D 게임에서의 대략적인 분배는 다음과 같습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
60fps 기준 프레임 예산 (16.6ms) 의 대략적 분배

CPU 예산 (16.6ms)
┌─────────────────────────────────────────────────────────┐
│  스크립트 (Update 등)   ███░░░░░░░░░░░░░░  약 2~4ms    │
│  물리 (FixedUpdate)     ██░░░░░░░░░░░░░░░  약 1~2ms    │
│  애니메이션             █░░░░░░░░░░░░░░░░  약 1~2ms    │
│  렌더링 준비            ████░░░░░░░░░░░░░  약 3~5ms    │
│  기타 (GC, OS 등)       █░░░░░░░░░░░░░░░░  약 1~2ms    │
│                                   합계: 약 8~15ms      │
└─────────────────────────────────────────────────────────┘

GPU 예산 (16.6ms)
┌─────────────────────────────────────────────────────────┐
│  버텍스 처리            ██░░░░░░░░░░░░░░░  약 2~3ms    │
│  프래그먼트 처리        █████░░░░░░░░░░░░  약 4~6ms    │
│  후처리                 ██░░░░░░░░░░░░░░░  약 1~3ms    │
│                                   합계: 약 7~12ms      │
└─────────────────────────────────────────────────────────┘

프레임 시간 = max(CPU, GPU) = 둘 중 오래 걸리는 쪽


CPU와 GPU가 각자의 예산 안에서 작업을 끝내야 16.6ms(60fps)를 유지할 수 있습니다. 어느 한쪽이라도 16.6ms를 넘기면 프레임 드롭이 발생합니다. 일반적으로 렌더링 관련 작업(CPU 측 렌더링 준비 + GPU 실행)이 양쪽 예산에서 가장 큰 비중을 차지합니다. 오브젝트가 많고, 텍스처가 크고, 셰이더가 복잡할수록 이 비중은 더 커집니다.

모바일 게임에서 이 비중은 더욱 두드러집니다. 모바일 GPU는 데스크톱 GPU에 비해 처리 능력이 제한적이므로, 씬 복잡도를 낮추더라도 렌더링이 GPU 예산의 상당 부분을 차지하기 쉽습니다. 최적화가 부족한 경우 30fps(33.3ms 예산)에서도 GPU 측 렌더링만으로 20ms를 넘길 수 있습니다.


렌더링이 예산에서 차지하는 비중이 크기 때문에, GPU-bound인 게임에서는 렌더링 최적화가 성능 개선에 가장 직접적인 효과를 줍니다. 렌더링을 최적화하려면 렌더링이 처리하는 데이터를 이해해야 합니다. 메쉬의 정점 수, 텍스처의 크기와 포맷, 셰이더의 복잡도, 드로우 콜의 수가 렌더링 시간에 직접적으로 영향을 미칩니다.


프레임의 전체 흐름 요약

게임 루프의 역사부터 Unity의 실행 순서, 프레임 예산, CPU/GPU 병렬 실행까지 살펴본 내용을 정리합니다.


CPU가 한 프레임에서 수행하는 작업의 순서는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
CPU의 프레임 N 처리 순서

  초기화 (필요시)
  FixedUpdate (0~N회)        ← 고정 타임스텝 (물리)
  입력 이벤트
  Update                     ← 가변 타임스텝 (게임 로직)
  애니메이션
  LateUpdate
  렌더링 준비                ← 가시성 판단, 드로우 콜 생성
  드로우 콜을 GPU에 전달


GPU는 CPU가 전달한 드로우 콜을 받아 렌더링을 수행합니다.

1
2
3
4
5
6
7
GPU의 프레임 N 처리 순서

  정점 처리
  래스터화
  프래그먼트 처리
  후처리
  화면 표시


앞에서 살펴본 것처럼, CPU와 GPU는 파이프라이닝으로 병렬 실행됩니다. CPU가 프레임 N+1을 처리하는 동안 GPU는 프레임 N을 렌더링합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
파이프라이닝 전체 흐름

시간(ms) →  0        10        20        30
            ┌─────────┐┌─────────┐┌─────────┐
CPU         │ 프레임 N ││프레임N+1││프레임N+2│
            │  (로직)  ││  (로직) ││  (로직) │
            └─────────┘└─────────┘└─────────┘
                       ┌─────────┐┌─────────┐
GPU                    │ 프레임 N ││프레임N+1│
                       │(렌더링) ││(렌더링) │
                       └─────────┘└─────────┘

프레임 시간 = max(CPU, GPU) ≤ 16.6ms → 60fps 유지


이 전체 과정이 프레임 예산 안에 끝나면 목표 프레임률이 유지됩니다. 예산을 초과하면 프레임 드롭이 발생합니다. CPU와 GPU는 파이프라이닝으로 병렬 실행되며, 둘 중 느린 쪽이 전체 프레임 시간을 결정합니다.


마무리

게임 루프는 입력 처리, 로직 업데이트, 렌더링, 화면 표시를 매 프레임 반복합니다. 이 루프가 한 바퀴 도는 것이 프레임 한 장이며, 목표 프레임률에 따라 허용되는 시간(프레임 예산)이 결정됩니다.


  • 게임 루프는 초기의 고정 속도 루프에서 가변 타임스텝(delta time) 방식으로 발전했다
  • Unity는 Initialization → Physics → Input → Update → Animation/LateUpdate → Rendering → Present 순서로 프레임을 처리한다
  • CPU와 GPU는 파이프라이닝으로 서로 다른 프레임을 동시에 처리하며, 각각 16.6ms(60fps 기준) 안에 작업을 끝내야 한다
  • 프레임 시간은 CPU와 GPU 중 느린 쪽이 결정하며, 어느 쪽이 병목인지에 따라 최적화 전략이 달라진다
  • 최적화의 목표는 평균 프레임률을 높이는 것이 아니라, 최악의 프레임 시간을 예산 이내로 유지하는 것이다
  • 렌더링은 CPU와 GPU 양쪽 예산에서 큰 비중을 차지하는 경우가 많다

결국 게임 최적화는 16.6ms(또는 33.3ms)라는 시간 제약 안에서 CPU와 GPU의 작업을 어떻게 배분하고, 병목을 어디에서 줄이느냐의 문제입니다. 프레임 구조를 이해하는 것은 그 출발점입니다.


프레임 예산의 대부분을 차지하는 렌더링을 최적화하려면, 먼저 현재 게임이 CPU-bound인지 GPU-bound인지 파악해야 합니다. 병목 지점에 따라 최적화 전략이 완전히 달라지기 때문입니다.

Part 2에서는 CPU-bound와 GPU-bound를 진단하는 방법과, 각 상황에서의 최적화 방향을 살펴봅니다. Unity Profiler로 프레임 시간을 분석하고, 병목 지점을 찾아내는 실제 과정을 다룹니다.



관련 글

시리즈

Tags: Unity, 게임루프, 모바일, 최적화

Categories: ,