본문 바로가기
유니티 공부/Unity

Unity - Unity 개발자가 왜 C++을 알아야할까?

by 코딩하는 돼징 2025. 4. 8.
반응형

Unity 개발자가 왜 C++을 알아야할까?

 

1. Unity 엔진 자체가 C++로 만들어져있다.

Unity는 엔진 레벨에서 C++로 동작하고 그 위에 C# 스크립팅 레이어가 올라가 있다. 그래서 GameObject, Rigidbody등과 같은 컴포넌트도 내부가 다 C++로 짜여있다. 

쉽게 말해서 C#은 조종사, C++은 정비사라고 보면된다. C#스크립트에서 "이만큼 움직여!"라고 명령을 내리면 실제로 C++에서 만들어진 엔진코어가 명령을 받고 움직이는 역할을 한다.

 

한줄로 정리를 해보면 

그래서 우리가 작성하는 C# 한줄의 코드도 실제로는 C++ 네이티브 함수를 호출하는 것이다.


2. 네이티브 호출은 성능에 영향을 준다.

네이티브 호출은 단순히 함수를 한 번 부르는 것이 아니라 C#에서 C++로 경계를 넘는 작업이다. 그러므로 호출할 때마다 아래와 같은 비용이 발생한다.

01 데이터 변환(Marshalling) : C#의 구조체/클래스를 C++에서 이해할 수 있는 형식으로 바꿔야 한다.

02 런타임 안정성 검사 : 크래시를 막기 위해 여러 검사를 수행한다.

등등 비용이 발생한다.

 

구체적인 예시를 보자

오브젝트 1000개의 위치를 매 프레임마다 변경하고 싶어 아래와 같이 코드를 적었다. 그러면 1000번 네이티브 호출이 발생하고, 그때마다 Transform 컴포넌트의 자식 오브젝트까지 모두 월드 좌표를 재계산하게 된다.

for (int i = 0; i < 1000; i++) {
    transform.position = new Vector3(i, 0, 0);
}

그러므로 최적화하기 위해 아래와 같이 코드를 작성하는 것이 좋다. 이렇게 적으면 네이티브 호출이 한번만 되기 때문이다.

Vector3 pos = transform.position;
for (int i = 0; i < 1000; i++) {
    pos.x += 1;
}
transform.position = pos;

3. C++ 메모리 모델을 알면 GC튐 현상을 이해하고 줄일 수 있다.

C++은 수동 메모리 관리 언어이다. 즉 new로 메모리를 할당하면 반드시 delete로 해제해줘야한다. 반면 C#은 자동 메모리 관리(GC, Garbage Collector)시스템을 사용한다. 편리하지만 성능측면에서 단점이 존재한다.

게임 도중 주기적으로 끊기거나 UI 오브젝트를 동적으로 생성/삭제할때 튀는 이유 중 하나는 GC가 메모리를 정리하느라 멈추기 때문이다.

struct MyStruct { public int x; } // 값 타입 → 스택 → GC 영향 없음
class MyClass { public int x; }  // 참조 타입 → 힙 → GC 대상

C++은 스택과 힙의 개념, 값타임과 참조 타입, 복사/이동 차이에 대한 이해가 철저하다. 개발자가 명확하게 컨트롤할 수 있다. 하지만 C#의 클래스는 기본적으로 GC의 대상이다.


4. C++ vs C# 실행 방식의 차이

01 컴파일 방식의 근본적인 차이

C++은 전통적인 정적 컴파일 언어다. 코드를 작성하고 컴파일을 하면, 곧바로 CPU가 이해할 수 있는 기계어(Machine Code)로 변환된다. 이때 플랫폼에 맞춰서(예: Windows용 x86, 모바일용 ARM 등) 완전히 네이티브한 바이너리 파일이 생성된다. 즉 프로그램이 실행되기 전에 이미 최종적인 실행파일이 만들어진다.

반면 C#은 .NET 기반 언어로, 컴파일하면 완전한 기계어가 아니라 IL(Intermediate Language, 중간 언어)이라는 형태로 변환된다. 이 IL은 여러 플랫폼에서 실행될 수 있도록 추상화된 코드다. 그리고 실제로 실행될 때, .NET 런타임(또는 Unity에선 Mono, IL2CPP 등)이 이 IL을 JIT(Just-In-Time) 컴파일을 통해 네이티브 코드로 변환해서 실행한다.

 

따라서 C#은 실행 시점에서 한 번 더 번역 과정을 거친다는 것이다.

 

02 그래서 Unity는 왜 둘 다 쓰는 걸까?

Unity는 개발자에게는 빠르게 코딩하고 배포할 수 있는 C#을 제공하면서 내부의 렌더링, 물리 연산, 애니메이션 시스템 등 고성능이 요구되는 부분은 C++로 처리한다. 

그리고 IL2CPP라는 빌드 파이프라인에서는 C#코드를 먼저 IL로 컴파일한 다음 그 IL을 다시 C++코드로 변환하고 그걸 다시 플랫폼에 맞춰 네이티브 바이너리로 만드는 방식이다. 즉 C#코드를 C++처럼 실행시키는 방법이다.

 

03 왜 굳이 이렇게 할까? 

C#보다 C++이 빠르기 때문이다. JIT 없이 정적으로 최적화된 네이티브 코드로 바뀌기 때문에 메모리 사용, 실행 속도, CPU 최적화 등에서 성능이 훨씬 좋아진다. 그러므로 IL2CPP빌드시 에러가 나는경우 C#코드에서는 이상이 없을 경우 변환된 C++ 코드에서 크래시 로그를 봐야하기 때문에 C++코드를 볼줄 알아야 한다.


5. 알아두면 좋은 C++ 개념들

01 포인터

C++은 값이 아니라 메모리 주소를 직접 다루는 언어이다.

int a = 10;
int* p = &a;   // a의 주소를 가리키는 포인터
*p = 20;       // 포인터를 통해 값을 바꿈

Unity와의 관계

NativeArray(메모리를 직접 관리 배열의 포인터 느낌), unsafe(포인터 직접 사용 가능), fixed(메모리 고정)구문은 포인터 개념과 매유 유사하다. 그리고 Job System에서 메모리를 직접 다룰때 포인터를 알고 있으면 더 좋게 구조 설계가 가능하다.

02 스택(Stack)과 힙(Heap)

스택은 함수 종료시 자동 해제되고 값 타입을 저장한다. 힙은 new로할당되고 delete로 해제되고 참조 타입으로 저장된다.

int x = 5;            // 스택
int* y = new int(10); // 힙
delete y;             // 수동 해제 필수!

Unity와의 관계

C#에서 클래스는 힙, 구조체는 스택이다. GC를 최대한 줄이려면 스탭과 힙에 저장되는 값들을 잘 알아야한다.

03 값타입과 참조 타입

C++에서는 함수에 값을 넘길떄는 복사 또는 참조를 선택할 수 있다.

void ChangeValue(int n) { n = 20; } // 값 복사
void ChangeValueRef(int& n) { n = 20; } // 참조 전달

Unity와의 관계

C#에서도 ref,in,out키워드를 통해 불필요한 복사를 줄이고 성능을 개선할 수 있다. 무거운 구조체의 경우 ref로 넘기는게 유리하다.

void ChangeValue(int n) { n = 20; }        // 값 복사
void ChangeValue(ref int n) { n = 20; }    // 참조 전달

04 RAII 패턴과 스마트포인터

객체가 생성되면 리소스를 획득하고, 객체가 소멸되면 자동으로 리소스를 해제하는 패턴이다. 즉 자원을 객체의 생명주기에 묶어버리는 방식이다.

class File
{
public:
    File(const std::string& path)
    {
        file = fopen(path.c_str(), "r");
    }

    ~File()
    {
        if (file) fclose(file); // 객체가 소멸될때 자동으로 정리
    }

private:
    FILE* file;
};

void ReadFile()
{
    File f("data.txt");
} // f가 범위를 벗어나면 자동으로 fclose 호출됨

스마트 포인터는 RAII 패턴을 활용해 만든 포인터 관리 도구이다.

Unity와의 관계

C#은 GC가 있어서 객체가 자동으로 메모리 해제를 하지만 파일,메모리등은 GC가 못해체하므로 Dispose패턴으로 직접 정리해야한다. 그래서 IDisposable인터페이스를 제공하고 using문으로 명시적으로 해제해주어야한다.

class File : IDisposable
{
    private StreamReader reader;

    public File(string path)
    {
        reader = new StreamReader(path);
    }

    public void Dispose()
    {
        reader?.Dispose(); // 리소스 정리
    }
}

void ReadFile()
{
    using (var file = new File("data.txt"))
    {
    } // Dispose() 호출됨
}

05 템플릿(Template) / 제네릭(Generic) 구조

C++에서 하나의 함수나 클래스를 여러 타입으로 재사용할 수 있게 해주는 문법으로 템플릿이 존재한다. 

template<typename T>
T Add(T a, T b) {
    return a + b;
}

컴파일러가 int,float,double등 필요한 타입으로 코드를 새로 생성한다.

Unity와의 관계

public T Add<T>(T a, T b)
{
    return (dynamic)a + b;
}

C#의 제네릭은 런타임에서 타입을 넣는 방식이다.

06 데이터 정렬(Alignment)와 캐시 효율(Cache Coherency)

CPU 캐시란?

CPU는 느린 메모리 대신 빠른 캐시에서 데이터를 미리 불러와 연산한다. 그러므로 메모리에 연속된 데이터가 있으면 CPU는 한 번에 여러개를 가져올 수 있다.

 

C++로 좋은예와 나쁜예를 한번 보자

// 좋은 예
struct Particle {
    float x, y, z;
    float vx, vy, vz;
};
Particle particles[1000]; // 연속된 메모리
// 나쁜 예
struct Particle {
    float* x;
    float* y;
    float* z;
}; // 메모리 접근시 포인터를 따라가야한다. 그러므로 CPU가 매번 RAM에 접근한다.

Unity와의 관계

Unity에서 캐시 효율은 왜 중요할까?

Unity는 실시간 엔진이므로 매 프레임마다 계산이 이루어진다. 그러므로 캐시미스가 쌓이면 결국 프레임 드랍으로 직결되기 때문이다.

 

그러면 어떻게 사용하나요? DOTS방식으로 사용한다.

class Particle {
    public float x, y, z;
    public float vx, vy, vz;
}
List<Particle> particles;

위와 같이 코드를 작성하면 데이터가 힙에 흩어진다. 그러므로 캐시미스가 발생할 확률이 높다.

 

AoS (Array of Structs)

각 파티클이 하나의 묶으로 저장된다. 직관적이지만 동일한 속성만 처리하고 싶을때는 비효율적이다.

struct Particle {
    float x, y, z;
    float vx, vy, vz;
};

Particle particles[1000];

SoA(Struct of Arrays)

데이터를 속성단위로 나눠서 같은 종류의 데이터끼리 배열로 저장하는 방식이다. 이렇게 저장하게 된다면 CPU캐시에 최적화되어서 매우 빠른 연산이 가능하다.

struct Particles {
    float x[1000];
    float y[1000];
    float z[1000];
    float vx[1000];
    float vy[1000];
    float vz[1000];
};

 

정리

SoA는 데이터를 속성별로 나눠 저장하는 방식이며 CPU 캐시 효율과 SIMD 병렬 처리 측면에서 AoS보다 훨씬 빠르다. Unity의 ECS, Burst, Job System은 이 구조를 적극적으로 활용한다.

반응형

댓글