유니티 / / 2026. 6. 10. 22:55

게임 최적화(3) - 초보들이 놓치기 쉬운 최적화!

안녕하세요, 규니티입니다.

최적화 특집 시리즈 3편입니다. 오늘 주제는 놓치기 쉬운 최적화예요.

1편에서 Profiler 보는 법, 2편에서 Draw Call 줄이는 법을 다뤘는데, 이번 편은 조금 달라요. 코드가 돌아가긴 하는데 "이게 맞는 방식인가?" 싶은 것들이에요. 초보 때 몰라서 가장 많이 저지르는 실수 세 가지를 정리해봤습니다.


1. String GC Alloc

프레임이 별 이유 없이 뚝뚝 끊긴다면 첫 번째로 의심해야 할 게 문자열 연산이에요.

왜 string이 문제야?

C#에서 string은 불변(Immutable) 객체예요. 한 번 만들어진 문자열은 수정이 안 되고, 뭔가 바꾸려면 무조건 새 객체를 힙에 생성해야 해요.

 
 
string a = "Score: ";
string b = a + "100";  // "Score: 100"이라는 새 객체 생성
                       // 기존 "Score: "은 버려짐 → GC 대상

Update()처럼 매 프레임 이게 반복되면 힙에 쓰레기가 쌓이다가 GC가 터지는 거예요. GC가 터지는 순간 게임이 잠깐 멈추고 프레임이 뚝 떨어지는 거고요.


실수 패턴 ① UI 텍스트 매 프레임 갱신

// X
void Update()
{
    scoreText.text = "Score: " + score.ToString();
    hpText.text = "HP: " + hp + " / " + maxHp;  // 이건 객체가 3개 생성됨
}

"HP: " + hp + " / " + maxHp 이렇게 쓰면 중간 결합마다 새 객체가 생겨서 한 줄에서 객체가 3개 만들어져요.

// StringBuilder로 해결
private StringBuilder _sb = new StringBuilder();

void UpdateUI()
{
    _sb.Clear();
    _sb.Append("HP: ");
    _sb.Append(hp);
    _sb.Append(" / ");
    _sb.Append(maxHp);
    hpText.text = _sb.ToString();
}

실수 패턴 ② 빈 문자열 체크

// X "" 는 새 string 객체를 생성함
if (playerName == "") { }

// O
if (string.IsNullOrEmpty(playerName)) { }

실수 패턴 ③ 태그 비교

// CompareTag 대신 tag 프로퍼티 쓰면 매번 새 string 반환
if (other.gameObject.tag == "Enemy") { }

// CompareTag는 string 생성 없이 비교
if (other.gameObject.CompareTag("Enemy")) { }

이건 모르는 분이 꽤 많아요. tag 프로퍼티 자체가 호출될 때마다 새 string을 반환하거든요.


실수 패턴 ④ 숫자 반복 변환

점수처럼 자주 바뀌는 숫자를 매 프레임 문자열로 변환하는 경우, 미리 캐싱해두는 것도 방법이에요.

// 0~999 점수를 미리 캐싱
private static readonly string[] ScoreCache = new string[1000];

private void Awake()
{
    for (int i = 0; i < ScoreCache.Length; i++)
        ScoreCache[i] = i.ToString();
}

void UpdateScoreUI(int score)
{
    scoreText.text = score < ScoreCache.Length
        ? ScoreCache[score]
        : score.ToString();
}

실수 패턴 ⑤ Debug.Log 남발

// 빌드 후에도 살아있으면 매 프레임 string 연산 발생
void Update()
{
    Debug.Log("현재 HP: " + hp);
}

// 에디터에서만 동작, 빌드에서는 코드 자체가 사라짐
[System.Diagnostics.Conditional("UNITY_EDITOR")]
private void Log(string message)
{
    Debug.Log(message);
}

실수 패턴 ⑥ string.Format

// string.Format도 내부적으로 boxing + 힙 할당 발생
scoreText.text = string.Format("Score: {0}", score);

// ✅ StringBuilder 쓰거나, 값이 자주 안 바뀌면 갱신 시점 제한
// score가 바뀔 때만 업데이트
private int _lastScore = -1;

void Update()
{
    if (score == _lastScore) return;  // 값이 같으면 건너뜀
    _lastScore = score;

    _sb.Clear();
    _sb.Append("Score: ");
    _sb.Append(score);
    scoreText.text = _sb.ToString();
}

값이 바뀔 때만 업데이트하는 패턴은 string뿐 아니라 UI 갱신 전반에 써먹을 수 있어요.


Profiler에서 확인하는 법

CPU Usage → Hierarchy 뷰 → GC Alloc 컬럼 클릭 (내림차순 정렬)
→ BehaviourUpdate 하위에 string 관련 함수가 올라와 있으면 바로 이 케이스

Timeline 뷰에서는 분홍/마젠타색 마커가 GC 할당이 일어나는 지점이에요. Update() 구간에 분홍색이 촘촘하게 찍혀있으면 string 실수일 가능성이 높아요.


2. Update() 남용

Unity 개발을 처음 배우면 뭐든 Update()에 넣게 돼요. 근데 Update()는 매 프레임 호출되기 때문에, 굳이 매 프레임 실행할 필요 없는 로직이 들어있으면 CPU를 낭비하는 거예요.

자주 보이는 실수들

// 변하지 않는 값을 매 프레임 계산
void Update()
{
    float distance = Vector3.Distance(transform.position, target.position);
    if (distance < 5f) { ... }
}

// 매 프레임 오브젝트 탐색
void Update()
{
    GameObject player = GameObject.Find("Player");
}

해결 방법 ① 캐싱

자주 쓰는 값이나 참조는 Awake() 또는 Start()에서 한 번만 가져와서 변수에 저장해두세요.

private Transform _target;
private float _checkInterval = 0.2f;
private float _timer;

private void Awake()
{
    _target = GameObject.Find("Player").transform;
}

private void Update()
{
    _timer += Time.deltaTime;
    if (_timer >= _checkInterval)
    {
        _timer = 0f;
        float distance = Vector3.Distance(transform.position, _target.position);
        if (distance < 5f) { ... }
    }
}

해결 방법 ② 필요할 때만 실행

매 프레임 체크할 필요 없는 로직은 타이머나 이벤트 방식으로 바꿔요. 위 코드처럼 0.2초마다 한 번만 체크하면 Update() 호출 횟수는 같아도 실제 로직 실행 횟수는 확 줄어요.

해결 방법 ③ 아예 Update() 없애기

상태가 바뀔 때만 동작해야 하는 로직이라면 이벤트나 콜백으로 대체하는 게 제일 좋아요. 이전 포스팅에서 다뤘던 옵저버 패턴이나 이벤트버스가 여기서 진가를 발휘해요.

Profiler CPU Usage에서 BehaviourUpdate가 전체 프레임의 큰 비중을 차지하고 있으면 Update() 남용을 의심해보세요.


3. GetComponent 반복 호출

GetComponent<>()는 편리하지만 매 프레임 호출하면 꽤 비싼 연산이에요. 컴포넌트를 찾기 위해 내부적으로 탐색이 일어나거든요.

가장 흔한 실수

// 매 프레임 GetComponent 호출
void Update()
{
    GetComponent<Rigidbody>().AddForce(Vector3.up);
    GetComponent<Animator>().SetBool("IsWalking", true);
}

해결 방법 — Awake()에서 캐싱

private Rigidbody _rb;
private Animator _animator;

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

private void Awake()
{
    _rb = GetComponent<Rigidbody>();
    _animator = GetComponent<Animator>();
}

private void Update()
{
    _rb.AddForce(Vector3.up);
    _animator.SetBool(IsWalking, true);
}

Animator.StringToHash()도 같이 캐싱했는데, Animator 파라미터를 문자열로 매 프레임 비교하는 것도 GC Alloc을 일으키거든요. 해시값으로 바꿔두면 이것도 같이 잡혀요.

GameObject.Find(), FindObjectOfType<>() 도 마찬가지예요. Update() 안에서 절대 쓰면 안 되고 Awake()에서 한 번만 호출해서 캐싱해두세요.


마무리

세 가지 다 공통점이 있어요. "매 프레임 반복되는 곳에서 비싼 연산을 하고 있다" 는 거예요.

코드가 돌아가니까 문제없다고 생각하기 쉬운데, Profiler 켜보면 이것들이 조용히 프레임을 갉아먹고 있는 경우가 많아요. 특히 모바일에서는 더 심하게 나타나고요.

다음 편은 모바일 특화 최적화입니다. PC에서 멀쩡하던 게 모바일 빌드하면 터지는 이유, 텍스처 압축, 그림자 설정, Target Frame Rate까지 정리할게요.

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유