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

레트로의 유니티 성능 최적화 - C# 스크립트 최적화

by 라이티아 2026. 9. 12.

원문 작성일: 2026-08-23

문자열 조합을 최적화하기

문자열을 표시하는 string은 불변 클래스이다. 한 번 생성되면 문자열 값은 수정할 수 없으며, 문자열을 변경하는 모든 연산은 새로운 문자열 인스턴스를 생성한다.

이 특성으로 인해서 문자열을 반복적으로 덧붙이는 코드는 의도하지 않은 메모리 할당과 GC를 유발한다.

string text = "A: " + testNumA + " B: " + testNumB;

위의 코드에서 문자열을 여러 번 이어 붙이는게 아닌 하나의 string.Concat() 호출로 변환한다.

string text = string.Concat("A: ", testNumA, " B: ", testNumB);

하나의 표현식 내부에서 수행되는 문자열 결합은 중간 임시 문자열을 여러 개 생성하지 않고 대부분 효율적으로 처리된다.

string text = ""
for (int i = 0; i < 100; i++)
    text += i + ", ";

이와 같이 루프 내부에서 문자열을 누적할 경우 매 반복마다 새로운 문자열이 생성되고 이전 문자열은 GC대상이 된다. 이는 성능에 악영향을 줄 수 있다.

루브 내부에서 문자열을 조합해야 한다면 StringBuilder를 사용하는 것이 권장된다.

var sb = new StringBuilder();

for (int i = 0; i < 100; i++)
    sb.Append(i).Append(", ");

string text = sb.ToString();

StringBuilder는 내부적으로 크기를 조절할 수 있는 가변적 문자 배열을 사용한다. 문자열을 조합할 때마다 새로운 문자열 인스턴스를 생성하지 않고, 기존 배열에 문자를 추가하거나 수정하는 방식으로 동작한다. 결과적으로 반복적인 문자열 조합 작업에서 메모리 할당을 줄이고 전반적인 성능을 향상시킬 수 있다.

CompareTag()로 태그 비교하기

tag 프로퍼티는 C#코드에서 값을 읽을 때 네이티브 오브젝트에 저장된 태그 문자열을 C#의 string 타입으로 변환한다. 이 과정에서 문자열 생성과 비교에 대한 비용이 발생한다.

if (gameobject.name == "Player") 
  • CPP => C# 경계 호출
  • 네이티브 오브젝트에 저장된 태그 문자열을 C# string으로 생성
  • 문자열 비교 연산

이를 CompareTag() 메서드를 사용해 줄일 수 있다.

if (gameobject.CompareTag("Player"))

CompareTag()는 엔진 내부에서 관리되는 태그 ID = 정수를 기반으로 비교하는 최적화된 API이다.

  1. 전달된 문자열을 태그 테이블에서 정수 ID로 변환
  2. 게임 오브젝트가 보유한 태그 ID와 정수 비교
  3. 비교 결과 반환

불필요한 물자열 생성과 문자열 비교보다 빠른 정수 비교로 성능에서 우위를 가진다.

식별자로 문자열 대신 해시 사용하기

유니티 에셋이나 오브젝트 일부 파라미터, 프로퍼티는 에니터에서 문자열로 표시되지만 내부적으로는 해시값을 통해 식별된다.

  • 애니메이터 컨트롤러 파라미터
  • 셰이터 프로퍼티

해시는 문자열과 달리 동적 메모리 할당이 발생하지 않고 문자열 비교에 비해 처리 속도가 빠르다. 다만, 모든 파라미터의 해시값을 사람이 관리하는 것은 현실적이지 못하다.

animator.SetBool("Run", true);

유니티는 문자열 형태의 파라미터 이름으로부터 해시 ID를 생성하는 API를 제공한다. 문자열에서 해시 ID를 한 번 생성해 캐싱한 뒤 이를 재사용하면 파라미터에 접근할 때마다 문자열을 생성하지 않아도 되기에 불필요한 메모리 할당을 줄이고 성능을 개선할 수 있다.

private static readonly int RunHash = Animator.StringToHash("Run");

animator.SetBool(RunHash, true);

정적 데이터의 enum을 문자열로 다루지 않기

개발 과정에서 enum을 문자열처럼 다루는 상황이 생긴다.

  • enum 멤버를 ToString() 같은 메서드를 통해 문자열로 변환해 사용하는 방식
  • enum으로 표현해야 할 값을 문자열로 관리하는 방식

이는 다음과 같은 문제를 야기한다.

  • 문자열 오탈자가 발생할 수 있음
  • 문자열과 enum 사이 변환 비용이 발생함
  • 문자열 비교 자체가 무거움

런타임에 AddComponent 사용하지 않기

직렬화된 데이터로 유니티 오브젝트를 생성하는 방식과 비교하면 AddComponent() 메서드를 런타임에 호출해 컴포넌트를 추가하는 방식은 매우 느리다.

  • 스크립트 캐시에서 해당 스크립트를 검색한다
  • 캐시되어 있지 않다면 추가적 메모리 할당이 발생한다
  • 새로 생성될 MonoBehaviour을 위한 메모리 할당이 발생한다
  • 특정 컴포넌트는 중복이 허용되지 않기에 중복 여부를 검사한다

할당이 없는 물리 캐스트 API 사용하기

다음과 같은 Cast 계열 API는 결과를 컬렉션 형태로 반환한다.

  • Physics.Raycast()
  • Physics.OverlapSphere()
  • Physics.OverlapBox()

이러한 메서드는 실행될 때마다 새 컬렉션을 위한 할당이 발생한다.

Update() 메서드 내에서 실행하면 매 프레임 새로운 할당으로 인한 CPU 오버헤드와 GC 유발 문제가 발생한다.

컬렉션을 반환하고 할당이 없는 NonAlloc API가 존재한다면 해당 API를 사용하는 것이 성능에 도움이 된다.

유니티는 아래와 같은 NonAlloc API를 제공한다.

  • Physics.RaycastNonAlloc()
  • Physics.OverlapSphereNoneAlloc()
  • Physics.OverlapBoxNonAlloc()

트랜스폼 연산을 한 번에 수행하기

트랜스폼의 위치와 회전을 설정할 때 값을 position, rotation을 프로퍼티로 나누어 설정하는 것 보다 SetPositionAndRotation() 메서드를 사용해 한 번에 설정하는 방식이 성능에 더 좋다.

transform.position = a;
transform.rotation = Quaternion.Euler(b);
transform.SetPositionAndRotation(a, Quaternion.Euler(b));

conditional 디버그 로그

로그는 성능을 소모한다. 또한 로그 함수에 문자열을 조합하는 과정에서 추가 성능이 발생한다.

  • Log() 자체의 호출 비용
  • 문자열을 조합하는 비용

개발자용 빌드가 아니라면 일부 로그는 필요하지 않을 수 있다. 이를 위해서 Conditional 속성을 활용한 로그 래퍼를 만들어 개발자용 빌드에서만 출력되도록 구성할 수 있다.

컴파일러는 Conditional 속성에 전달된 전처리기 식별자가 정의되어 있을 때 해당 메서드를 빌드에 포함해 컴파일 한다.

식별자가 DEVELOPMENT_BUILD 일시 개발자용 빌드에만 포함된다. 다만 이 방식도 문자열 조합 비용은 생긴다.

Fake null 유의하기

네이티브 오브젝트에서 파괴되었지만 C# 오브젝트는 파괴되지 않은 상태를 의미한다.

UnityEngine.Object를 상속하는 유니티 C# 오브젝트는 CPP 오브젝트와 바인딩되어 있다. 이로 인해서 유니티 오브젝트르 파괴할 때 CPP 오브젝트는 파괴되었으나 C# 오브젝트는 파괴가 늦을 수 있다.

이때를 위해 C# 유니티 오브젝트에 대한 == 연산을 오버라이드해 CPP 오브젝트의 파괴 여부를 함께 검사한다. 유니티 오브젝트에 대해서 == null 검사를 수행하면 다음과 같이 작동한다.

object == null
  • C# 객체가 null이면 true를 반환
  • C# 객체가 null이 아니면 네이티브 오브젝트의 null 검사 후 결과 반환

이 방식으로 C# 오브젝트가 파괴되지 않았더라도 유니티 네이티브 오브젝트가 파괴된 상태를 감지할 수 있다. 다만 일반적 null 검사보다 비용이 더 발생한다.

다만 해당 방식은 큰 성능 개선을 가져오지 못하기에 코드 가독성을 희생할 정도는 아니다.

오브젝트 풀 사용하기

게임 오브젝트의 인스턴스화 파괴는 많은 CPU, GC 비용을 요구한다. 이 문제를 완화하려 오브젝트 풀을 사용한다.

오브젝트 풀은 직접적인 메모리 최적화 효과를 제공하지는 않는다. 오브젝트 풀을 사용했는데 메모리가 더 사용될 수도 있다. 즉, 언제나 맞는 정답은 아니다.

오브젝트 풀 방법은 2가지 방식으로 나뉜다.

오브젝트를 미리 많이 만들기

초기화 시점에 풀에 지정된 수 만큼 미리 오브젝트를 생성후 비활성화 한다.

  • 게임 플레이 도중 인스턴스화로 인한 히칭이 발생하지 않는다
  • 초기화 시점에서 큰 CPU 비용과 메모리 비용이 발생한다
  • 항상 동일한 수의 오브젝트를 유지한다

사전에 생성될 오브젝트 수를 예측할 수 있는 경우에 적합하다. 만약 수를 넘은 경우 처리는 아래와 같다

  • 예외 발생시키기
  • 새로 생성하기 = 풀 확장
  • 라운드 로빈 = 풀에 가장 오래된 오브젝트를 다시 제공한다
  • 처음부터 많이 생성하기

오브젝트 지연 생성하기

UnityEngine.Pool 네임스페이스에 포함된 ObjectPool과 IObjectPool 타입을 통해 구현할 수 있다.

특징
- 풀에 오브젝트를 미리 생성하지 않는다
- 오브젝트를 생성하는 시점이 아니라 해제하는 시점에 오브젝트가 풀로 반환된다
- 풀의 크기보다 많은 오브젝트가 요구되어도 대응된다
- 많은 수의 오브젝트를 항시 유지하지 않아도 된다

풀의 최대 용량을 정해두지만, 미리 생성해두지 않는다.

풀에서 오브젝트를 요청할 때
- 풀에 남아있는 오브젝트가 있으면 해당 오브젝트를 활성화해 반환
- 풀에 남아있는 오브젝트가 없으면 새로 생성해 반화
- 이때 생성된 오브젝트트 풀에 저장되지 않음

오브젝트를 해제할 때
- 풀에 남은 공간이 있으면 해제된 오브젝트를 풀에 저장
- 풀에 남은 공간이 없으면 헤제된 오브젝트를 파괴

해당 방식은 순간적으로 많은 오브젝트를 사용하는 씬을 지원하기 위해 전체 게임 성능을 희생하는 구조이다. 다만 대량의 오브젝트가 나오는 경우를 대응할 수 있다.

런타임에 리플렉션 자제하기

리플렉션은 아래와 같은 단점이 존재한다.

  • 정적 분석 불가
  • 코드 복잡성 증가
  • 성능 저하

리플렉션을 과도하게 사용할 경우 코드에 의도적으로 설정된 제약과 방어 매커니즘을 우회하고 관리가 어려운 코드가 되게 된다.

계산 결과 캐싱하기

수학 연산의 결과가 이미 알려진 값이라면 연산을 반복 수행하지 않고 캐싱된 상수를 사용하여 CPU 자원을 아낄 수 있다.

Update()
{
    float a = 1 / 30f; //
    b = a * 5;
}
private static float OneFrame = 0.33f

Update()
{
    b = a * OneFrame;
}

다만 삼각함수와 같은 값을 캐싱할시 계산 결과의 정확도가 낮아질 수 있다.

잡 시스템 활용하기

유니티의 MonoBehavior 이벤트 메서드는 메인 스레드에서 실행되어야 한다. 하디만 일부 처리는 C# 잡 시스템을 사용해 워커 스레드로 분리해 실행할 수 있다. 추가적으로 잡 시스템 코드를 Burst 컴파일러로 최적화 하면 성능을 더 개선할 수 있다.

애니메이션, 파티클 시뮬레이션 같은 연산은 잡 시스템으로 구현되어 워커 스레드에서 수행된다.

잡 시스템을 구현하는 방식은 다양하고, 해당 책에서는 Update() 타이밍에 잡을 예약하고 LateUpdate() 타이밍에 계산된 결과를 적용하는 방법을 알려준다.

잡을 구현할 때 메인 스레드에서 조회할 결괏값은 NativeArray와 같은 NativeContainer 타입으로 선언해야 한다.

  • 잡은 값 타입이며 워커 스레드로 디스패치 될 때 복제본이 전달된다
  • 네이티브 컨테이너를 사용하면 디스패치된 잡과 이를 사용하는 코드가 동일한 데이터를 공유할 수 있다
  • 네이티브 컨테이너는 스레드 세이프하게 접근할 수 있도록 설계되어 있다

구조체 사용하기

매우 단순한 구조의 데이터나 대규모로 생성되고 파괴되는 데이터는 성능을 고려해 클래스 대신 구조체로 구현할 수 있다.

구조체 인스턴스는 스택에 할당되고 스택 할당은 힙 할당보다 빠르고 함수 호출이 종료될 때 함께 해제되기에 GC를 발생시키지 않는다.

데이터 지향 설계와 SoA 사용하기

데이터 지향 설계의 핵심 목표는 아래 2가지이다.

  1. CPU 캐시에 잘 맞는 연속적인 데이터 구조로 재구성하기
  2. 대량의 데이터를 병렬적으로 처리하기

해당 목표를 가장 효과적으로 달성하는 방식이 SoA = Structure of Arrays 구조이다.

SoA 구조는 직관성이 떨어지에 기존 프로젝트에 도입하기는 어려우나 신규 유니티 DOTS의 Entities 패키지 도입을 고려한다면 반드시 이해를 해야한다.

OOP 기반의 AoS = 배열의 구조 방식의 한계

전통적인 C# 클래스 기반 설계는 객체 단위로 데이터가 묶인 구조를 만든다. 이는 이해는 쉽지만 CPU 관점에서는 비효율적이다.

  • 서로 다른 필드가 메모리상에 섞여 배치된다
  • 캐시 미스가 빈번하기 발생한다
  • 대량 데이터 처리 시 병렬화가 어렵다

만약 Enemy 클래스의 배열 Enemies[]를 전부 순회하면서 HP를 낮추는 처리가 있다면 Enemies[]는 객체 자체가 아니라 해당 객체를 가리키는 참조이기에 CPU는 참조를 따라서 힙의 메모리 임의 위치로 이동해 실제 데이터를 가져와야 한다. 이때 불필요한 HP 이외의 Enemy 객체의 데이터를 접근하게 되며 이는 불필요한 성능 낭비를 유발한다.

SoA 방식

구조체의 각 속성을 개별 배열로 분리하는 방식이다.

Enemy의 hp를 다루고 싶다면 hp만 별도의 연속된 배열로 구성해 처리할 수 있다.

단순한 처리 방식의 변화지만 CPU 입장에서는 큰 자원 절약을 할 수 있다.

  • 필요한 데이터만 연속적으로 접근하기에 캐시 적중률이 크게 상승한다
  • 대량의 단순 반복 연산에서는 OOP 기반 구조에 비해 수 배에서 수십 배까지 성능 차이가 발생할 수 있다

메모리 레이아웃 관점에서 hp 값이 메모리상에서 연속적으로 배치되기 때문에 캐시 히트율이 높아지고 CPU 처리 속도가 매우 빨라진다.