본문 바로가기

Unity(C#)

Unity 게임 최적화는 코드보다 데이터에서 시작하는 걸까?

Unity에서 최적화 관련 내용을 공부하다 보니 코드를 최적화 한다는게 정확하게 무엇을 의미하는걸까 라는 호기심이 생겼다. 연산 자체를 줄이거나 호출하는 함수의 숫자를 줄이는게 코드 최적화인가 아니면 다른 무언가 있을까?

그래서 여러 최적화 관련 글을 찾아보다 보니 공통적으로 나오는 이야기가 있다.

최적화는 느린 코드를 빠르게 만드는 것보다
시스템이 해야 하는 일 자체를 줄이는 것에서 시작한다.

그리고 이 이야기는 자연스럽게 Data-Oriented Design, 즉 데이터 중심 설계와 연결된다.

이번 글에서는 Jason Booth의 My approach to optimization과 Noel Llopis의 Data-Oriented Design 글을 참고해서 내가 이해한 방식으로 정리해보려고 한다.


먼저 코드보다 작업량을 보자

최적화를 한다고 하면 보통 코드부터 보게 된다.

예를 들어 이런 코드가 있다고 해보자.

for (int i = 0; i < monsters.Count; i++)
{
    UpdateMonster(monsters[i]);
}

그러면 먼저 이런 생각을 할 수 있다.

UpdateMonster를 더 빠르게 만들 수 없을까?

분기문을 줄일까?

함수 호출을 없앨까?

Burst로 돌릴까?

그런데 그 전에 하나 더 생각해볼 수 있다.

애초에 이 Monster들을 전부 Update 해야 하는가?

만약 화면에 보이지 않는 Monster까지 전부 매 프레임 처리하고 있다면,

함수 내부의 곱셈 하나를 줄이는 것보다

10000개 처리

↓

1000개 처리

로 만드는 것이 훨씬 큰 효과를 낼 수도 있다.

Jason Booth의 글도 비슷한 방향을 이야기한다.

실제 병목이 항상 내부 반복문의 작은 연산에서 발생하는 것이 아니라,

Memory Access Pattern
Data Size
State
Software Structure

같은 더 큰 구조에서 생기는 경우가 많다는 것이다.

그래서 최적화할 때는

이 코드를 더 빠르게 실행할 수 있는가?

보다 먼저

이 코드를 실행할 필요가 있는가?

를 생각해볼 필요가 있다.


코드를 적게 작성한다는 의미

Jason Booth의 글에는 이런 표현이 나온다.

Just write less code.

물론 코드 줄 수가 적다고 무조건 좋은 프로그램은 아니다.

100줄짜리 코드보다 50줄짜리 코드가 항상 빠른 것도 아니다.

하지만 같은 기능을 훨씬 적은 코드로 만들 수 있다면 구조 자체가 단순해졌을 가능성이 높다.

예를 들어 다음과 같은 시스템이 있다고 해보자.

Object
↓
Manager
↓
Interface
↓
Event
↓
State
↓
Callback
↓
실제 처리

반대로 같은 결과를 이렇게 만들 수 있다고 해보자.

Input Data
↓
Process
↓
Output Data

두 방식의 기능이 같다면 두 번째 구조가 이해하기 훨씬 쉽다.

그리고 코드가 단순해지면 자연스럽게

  • 관리해야 할 상태가 줄고
  • 객체가 줄고
  • 메모리 접근도 줄고
  • 디버깅도 쉬워질 가능성이 높다.

즉,

코드가 적다

그 자체가 중요한 게 아니라

필요한 구조만 남았다

는 것이 중요하다고 볼 수 있다.


상태는 왜 문제가 될까?

게임을 만들다 보면 상태가 정말 많이 생긴다.

예를 들어 Monster 하나만 생각해도

IsDead
IsAttacking
IsMoving
IsStunned
IsTargetVisible
IsInRange
CurrentState
PreviousState

같은 값이 생길 수 있다.

물론 이런 값들이 필요한 경우도 많다.

하지만 상태가 많아질수록 서로 맞춰야 하는 값도 늘어난다.

예를 들어

IsDead = true

가 되었는데

IsAttacking = true

가 그대로 남아 있다면?

또는

CurrentState = Dead

인데 Animation 쪽 State가 갱신되지 않았다면?

이런 식으로 시스템 여러 곳에서 상태를 가지고 있으면 서로 값이 어긋나는 순간 버그가 생기기 쉽다.

그래서 Jason Booth는 가능하다면 결과를 적은 양의 상태에서 다시 계산할 수 있는 시스템이 많은 프레임 상태를 저장하는 시스템보다 이해하기 쉽다고 이야기한다.

이걸 아주 단순하게 보면

이전 결과 저장
↓
변경 여부 확인
↓
Dirty Flag 확인
↓
상태 갱신

보다

현재 Data
↓
결과 계산

이 더 단순할 수도 있다는 이야기다.

여기서 중요한 것은

무조건 매 프레임 다시 계산해야 한다

는 의미는 아니다.

계산 비용이 굉장히 크다면 당연히 캐싱이 필요할 수 있다.

다만 연산 몇 번을 줄이기 위해 너무 많은 상태를 추가하고 있지는 않은지 확인해볼 필요가 있다는 것이다.


계산은 빠르고 메모리는 느리다?

여기서 자주 나오는 말이 있다.

Computation is fast.
Memory is slow.

조금 과장된 표현이지만 현대 CPU를 이해할 때 꽤 중요한 관점이다.

CPU는 연산 자체는 굉장히 빠르게 수행한다.

문제는 CPU가 필요한 데이터를 기다릴 때다.

CPU가 바로 사용할 수 있는 데이터는 Cache에 있다.

대략적으로 보면

CPU Register
↓
L1 Cache
↓
L2 Cache
↓
L3 Cache
↓
RAM

순으로 내려갈수록 접근 비용이 커진다.

그래서 코드의 연산 수가 적다고 해도 데이터가 메모리 여기저기에 흩어져 있다면 CPU는 계속 데이터를 기다릴 수 있다.

반대로 연산이 조금 더 많더라도 필요한 데이터가 연속적으로 잘 정리되어 있다면 더 빠를 수도 있다.

여기서 Data-Oriented Design이 등장한다.


Data-Oriented Design이란?

객체 지향 방식에서는 보통 먼저 객체를 생각한다.

예를 들어 게임에 Monster가 있다면

class Monster
{
    float hp;
    float speed;
    Vector3 position;

    void Move() {}
    void Attack() {}
    void Update() {}
}

처럼 생각할 수 있다.

Monster라는 객체 안에

Data
+
Behavior

를 같이 넣는 방식이다.

하지만 Data-Oriented Design은 조금 다르게 생각한다.

먼저 질문한다.

어떤 데이터를 가지고 있는가?

그 데이터는 어떻게 사용되는가?

어떤 순서로 읽는가?

어떤 결과 데이터가 만들어지는가?

Object

보다

Data

를 먼저 생각한다.

Noel Llopis는 프로그램 자체를 결국 입력 데이터를 받아 다른 출력 데이터로 변환하는 과정으로 설명한다.

게임도 결국 마찬가지다.

Input
+
Current Game Data
↓
Processing
↓
New Game Data
+
Rendering Command

의 반복이라고 볼 수 있다.


Object를 기준으로 데이터를 저장한다면

예를 들어 Monster가 10000마리 있다고 해보자.

객체 중심으로 생각하면 메모리상에서 대략 이런 형태가 될 수 있다.

Monster A
 ├ HP
 ├ Position
 ├ Speed
 ├ Animation
 └ AI

Monster B
 ├ HP
 ├ Position
 ├ Speed
 ├ Animation
 └ AI

Monster C
 ├ HP
 ├ Position
 ├ Speed
 ├ Animation
 └ AI

그리고 우리가 Position만 업데이트하고 싶다고 해보자.

CPU 입장에서는 Monster를 하나씩 가져오면서 Position과 Speed를 읽어야 한다.

하지만 그 주변에는 현재 작업에서 필요하지 않은 HP, Animation, AI 데이터도 있을 수 있다.

그리고 각 Monster 객체가 메모리 여기저기에 흩어져 있다면 접근 비용이 더 커질 수도 있다.


데이터 기준으로 저장한다면

이번에는 데이터를 기준으로 나눠보자.

Positions
[P][P][P][P][P][P][P]

Velocities
[V][V][V][V][V][V][V]

HP
[H][H][H][H][H][H][H]

이제 Position 업데이트에서는

Positions
+
Velocities

만 순서대로 읽으면 된다.

for (int i = 0; i < count; i++)
{
    positions[i] += velocities[i] * deltaTime;
}

CPU 입장에서는 필요한 데이터가 연속적으로 들어 있기 때문에 다음에 필요한 데이터를 예측해서 Cache로 가져오기 쉬워진다.

이것이 데이터 중심 설계에서 자주 이야기하는

Cache Friendly

한 구조다.


AoS와 SoA

이 차이는 흔히

AoS
Array of Structures

SoA
Structure of Arrays

로 설명한다.

AoS는 이런 형태다.

struct Monster
{
    public float hp;
    public float speed;
    public Vector3 position;
}

Monster[] monsters;

메모리를 단순화해서 보면

[HP Speed Position]
[HP Speed Position]
[HP Speed Position]
[HP Speed Position]

처럼 배치된다.

반대로 SoA는

float[] hp;
float[] speed;
Vector3[] position;

처럼 나눈다.

[HP][HP][HP][HP]

[Speed][Speed][Speed][Speed]

[Position][Position][Position][Position]

만약 Position만 처리한다면 SoA 쪽이 필요한 데이터만 연속적으로 읽을 수 있다.

물론 SoA가 항상 좋다는 뜻은 아니다.

항상 같이 사용하는 데이터라면 AoS가 더 편할 수도 있다.

중요한 것은

객체 구조가 예쁜가?

보다

실제로 어떤 데이터에
어떤 순서로 접근하는가?

를 기준으로 구조를 생각하는 것이다.


Unity Job System과 Burst가 이런 구조를 좋아하는 이유

Unity에서 Job System을 공부해보면 이런 코드가 자주 등장한다.

NativeArray<float3> Positions;
NativeArray<float3> Velocities;

그리고 Job에서는

Positions[index] += Velocities[index] * DeltaTime;

같이 단순한 연산을 수행한다.

이 구조가 Data-Oriented Design과 상당히 잘 맞는다.

연속된 Data
↓
단순한 Function
↓
많은 Data에 반복 적용

이라는 형태이기 때문이다.

이런 구조는

  • Cache 활용
  • SIMD
  • Multi Thread
  • Burst 최적화

와 잘 맞는다.

반대로 이런 구조라면

Monster
↓
Component
↓
Manager
↓
Virtual Function
↓
다른 Object 참조
↓
Event

여러 Thread에 나눠 처리하기가 훨씬 어려워진다.

각 Job이 어떤 데이터를 읽고 어떤 데이터를 수정하는지 명확하지 않기 때문이다.


멀티스레딩보다 먼저 구조를 고쳐야 한다

Jason Booth의 Kerbal Space Program 2 사례가 여기서 꽤 재미있다.

기존 행성 렌더링 시스템은 많은 객체와 모듈로 구성되어 있었고, Mesh 생성 비용을 여러 프레임에 나누는 방식으로 처리하고 있었다.

대략 이런 형태다.

Geometry 생성 요청
↓
여러 Frame에 분산
↓
LOD 조금 변경
↓
다음 Frame
↓
다시 변경

이 구조는 한 프레임에 해야 하는 작업을 줄여준다.

그런데 대신

  • 상태가 많아지고
  • 코드가 복잡해지고
  • 원하는 LOD에 도달하는 데 시간이 걸리고
  • Geometry가 계속 바뀌면서 Popping이 생기고
  • 20~80ms Stall까지 발생했다.

여기서 재미있는 점은 처음부터 Burst나 Compute Shader를 적용한 것이 아니라는 것이다.

먼저 복잡한 구조를 제거했다.

복잡한 Modularity 제거
↓
State 감소
↓
Data Size 감소
↓
흩어진 데이터를 Linear Array로 이동

그다음에야

Burst Job
↓
Compute Shader

를 적용했다.

느린 구조
+
Multi Thread

가 아니라

단순한 구조
+
좋은 Data Layout
+
Multi Thread

로 만든 것이다.

이 차이는 굉장히 중요하다고 생각한다.


잘못된 구조를 Burst로 돌린다고 좋은 구조가 되지는 않는다

예를 들어 어떤 코드가 있다고 해보자.

GameObject 접근
↓
Component 찾기
↓
Manager 접근
↓
Dictionary 검색
↓
다른 Object 접근
↓
결과 계산

이 코드가 느리다고 해서

Job System

으로 옮긴다고 바로 해결되는 것은 아니다.

오히려 Job으로 옮기기 어려울 가능성이 높다.

그리고 억지로 옮긴다고 해도 본질적인 문제인

Data가 흩어져 있음

은 그대로 남을 수 있다.

따라서 보통은 먼저

Data를 모으고

↓

필요한 Data만 남기고

↓

Access Pattern을 단순하게 만들고

↓

그다음 Parallelize

하는 쪽이 더 자연스럽다.


성능은 기능이다

이 부분도 인상적이었다.

Jason Booth는 성능이 중요한 시스템이라면 성능도 시스템의 하나의 기능으로 봐야 한다고 이야기한다.

예를 들어 요구사항이

몬스터 10000마리 표시

라고 해보자.

보통은 이 기능을 먼저 만든 다음

FPS가 안 나오네
↓
최적화하자

라고 생각할 수 있다.

하지만 실제 요구사항이

몬스터 10000마리
+
60FPS 유지

라면?

60FPS도 하나의 기능이라고 볼 수 있다.

그렇다면 처음 구조를 만들 때부터

10000개의 GameObject가 맞는가?

각 객체가 Update를 가져야 하는가?

데이터를 어떻게 저장할 것인가?

CPU에서 처리할 것인가?

GPU에서 처리할 것인가?

를 생각해야 한다.

즉 최적화는 마지막에 하는 청소 작업이 아니라 아키텍처 설계 단계부터 들어갈 수도 있다.


스마트한 코드가 항상 좋은 코드는 아니다

프로그래밍을 하다 보면 복잡한 구조를 만들었을 때 뭔가 잘 만든 것 같은 느낌이 들 때가 있다.

예를 들어

Generic
+
Reflection
+
Interface
+
Event
+
Lambda
+
Virtual Function

등을 이용해서 굉장히 유연한 시스템을 만들 수 있다.

하지만 실제 요구사항이 단순하다면 그 구조가 오히려 부담이 될 수도 있다.

특히 데이터가 수천, 수만 개가 된다면 작은 비용도 반복된다.

Jason Booth는 이걸 재미있게 표현한다.

게임에 10000개가 있다면
그건 Particle System처럼 생각해야 한다.

Particle System을 생각해보면 이해하기 쉽다.

Particle 하나마다

Particle Object
Particle Manager
Particle Interface
Particle Event

를 만들지는 않는다.

보통

Positions[]
Velocities[]
Colors[]
LifeTimes[]

같은 대량의 데이터를 한 번에 처리한다.

왜냐하면 Particle은 수가 많기 때문이다.

결국 규모가 커질수록 객체 하나하나보다 데이터 전체를 보는 것이 중요해진다.


그렇다면 OOP가 나쁜 것일까?

여기서 오해하면 안 되는 부분이 있다.

Data-Oriented Design 이야기를 보다 보면

OOP = 나쁨
DOD = 좋음

처럼 느껴질 수 있다.

하지만 그렇게 단순하게 볼 필요는 없다.

Noel Llopis도 모든 곳에서 OOP를 없애야 한다고 이야기하지는 않는다.

예를 들어

UI
Manager
Graphics Device
Editor Tool

처럼

  • 데이터 양이 적고
  • 성능이 크게 중요하지 않고
  • 객체 간 관계를 표현하는 것이 더 편한 영역

에서는 객체 중심 구조가 충분히 좋을 수 있다.

중요한 것은 모든 문제를 같은 방식으로 풀지 않는 것이다.

10000개의 Particle

Settings Window 하나

를 같은 구조로 설계할 필요는 없다.


Data-Oriented와 Data-Driven은 다르다

Unity에서 특히 헷갈리기 쉬운 부분이다.

Data-Oriented와 Data-Driven은 이름은 비슷하지만 다른 개념이다.

Data-Driven은

Code
↓
외부 Data를 읽음
↓
Data에 따라 Behavior 결정

하는 형태다.

예를 들어 내가 전에 만들었던 Tutorial System처럼

Tutorial Data

발생 조건
종료 조건
발생 시점
표현 방식

을 데이터로 두고 이를 읽어 튜토리얼을 구성한다면 Data-Driven에 가깝다.

반면 Data-Oriented는

Data가 Memory에 어떻게 배치되어 있는가?

어떤 Data를 같이 읽는가?

어떤 순서로 처리하는가?

를 중심으로 보는 설계다.

Data-Driven
=
동작을 Data로 정의

이고

Data-Oriented
=
Data 처리 효율을 중심으로 설계

라고 생각하면 이해하기 쉽다.

둘은 동시에 사용할 수도 있고 완전히 별개로 사용할 수도 있다.


최적화를 할 때 순서를 바꿔보자

이 두 글을 보고 가장 크게 느낀 부분은 최적화의 순서였다.

보통은

느린 코드 발견
↓
Loop 최적화
↓
Function Call 최적화
↓
Burst
↓
Job

같이 생각하기 쉽다.

그런데 조금 더 큰 범위에서 보면 이런 순서가 될 수 있다.

1. 실제 병목 확인

↓

2. 이 작업 자체가 필요한지 확인

↓

3. 처리하는 Data 양 줄이기

↓

4. State 줄이기

↓

5. Data Layout 확인

↓

6. Memory Access Pattern 개선

↓

7. 구조 단순화

↓

8. 이후 Burst / Job / Compute Shader

특히

더 빠르게 계산하는 방법

을 찾기 전에

더 적게 계산하는 방법

을 찾는 것이 중요해 보인다.


Unity에서 생각해볼 수 있는 예

예를 들어 10000마리 Monster의 거리를 계산한다고 해보자.

처음에는

foreach (Monster monster in monsters)
{
    float distance = Vector3.Distance(
        player.position,
        monster.transform.position
    );
}

같이 만들 수 있다.

여기서 바로

Vector3.Distance 대신 sqrMagnitude?

Burst?

Job System?

을 생각할 수도 있다.

하지만 그 전에 질문할 수 있다.

10000마리 전부 계산해야 하는가?

만약 플레이어 주변 100마리만 필요하다면

Spatial Partitioning
QuadTree
Grid

등을 이용해서 대상 자체를 줄일 수 있다.

그다음에도 병목이라면

Transform 접근 제거
↓
Position Data를 NativeArray에 저장
↓
Job System
↓
Burst

를 적용할 수 있다.

Algorithm
↓
Data
↓
Memory
↓
Parallel

순으로 생각할 수 있다.

물론 항상 이 순서가 정답인 것은 아니지만 무작정 Burst부터 적용하는 것보다 문제의 원인을 파악하기 쉽다.


정리

이번 두 글을 읽으면서 최적화에 대한 생각이 조금 바뀌었다.

예전에는 최적화라고 하면

코드를 더 빠르게 실행하는 방법

을 먼저 생각했다.

하지만 더 큰 관점에서는

필요한 작업만 하고

↓

Data를 작게 만들고

↓

State를 줄이고

↓

Memory에 잘 배치하고

↓

Access Pattern을 단순하게 만든 다음

↓

필요하면 병렬화

하는 과정이라고 볼 수 있다.

특히 중요한 질문은 이런 것 같다.

이 작업은 정말 필요한가?

이 State는 정말 저장해야 하는가?

이 Data를 더 작게 만들 수 없는가?

Data가 Memory 여기저기에 흩어져 있지는 않은가?

같이 처리하는 Data를 같이 모을 수 없는가?

이 시스템이 필요 이상으로 복잡하지 않은가?

그리고 마지막으로

최적화
=
코드를 어렵게 만드는 것

은 아닌 것 같다.

오히려 좋은 최적화는

더 적은 Code

더 적은 Data

더 적은 State

더 단순한 구조

로 같은 결과를 만드는 쪽에 더 가까울 수도 있다.

앞으로 Unity에서 최적화를 볼 때도 단순히 Profiler에서 시간이 오래 걸리는 함수 하나만 보기보다는,

왜 이 작업을 하고 있는지

왜 이 Data를 가지고 있는지

왜 이 구조로 되어 있는지

까지 같이 보는 습관을 들여보려고 한다.

 

 

참고자료

https://gamesfromwithin.com/data-oriented-design-now-and-in-the-future/?utm_source=chatgpt.com

 

Data-Oriented Design Now And In The Future

There has been a lot of recent discussion (and criticism) on Data Oriented Design recently. I want to address some of the issues that have been raised, but before that, I’ll start with this reprint from my most recent Game Developer Magazine. If you have

gamesfromwithin.com

https://gamesfromwithin.com/data-oriented-design/?utm_source=chatgpt.com

 

Data-Oriented Design (Or Why You Might Be Shooting Yourself in The Foot With OOP)

Picture this: Toward the end of the development cycle, your game crawls, but you don’t see any obvious hotspots in the profiler. The culprit? Random memory access patterns and constant cache misses. In an attempt to improve performance, you try to parall

gamesfromwithin.com

https://medium.com/@jasonbooth_86226/my-approach-to-optimization-bfcafc1f8768

 

My approach to optimization

I do a fair amount of work optimizing systems, and am known for writing very optimal shader systems. But I don’t consider myself a “low…

medium.com