본문 바로가기

Unity(C#)

Unity 비어 있는 Update는 왜 지워야 할까?

작업을 하다 보면 “비어 있는 Update는 제거해야 한다”는 이야기를 자주 듣게 된다.

처음에는 조금 의문이 들 수 있다.

void Update()
{
}

아무 코드도 없는데, 이게 정말 성능에 영향을 줄까?

단순히 비어 있는 함수 하나가 존재하는 것뿐인데 왜 굳이 지워야 한다고 하는 걸까?

이번에는 Unity에서 Update가 어떻게 호출되는지, 그리고 왜 비어 있는 Update도 제거하는 것이 좋은지 알아보자.

Unity는 매 프레임 리플렉션으로 Update를 찾지 않는다

먼저 오해하기 쉬운 부분이 있다.

Unity가 매 프레임마다 모든 MonoBehaviour를 리플렉션으로 검사하면서 Update 함수가 있는지 찾는 것은 아니다.

Unity는 스크립트가 처음 로드되거나 사용될 때, 해당 MonoBehaviour가 어떤 Message Function을 가지고 있는지 확인한다.

예를 들면 다음과 같은 함수들이다.

void Awake() { }
void Start() { }
void Update() { }
void FixedUpdate() { }
void LateUpdate() { }

Unity는 이런 Message Function이 구현되어 있는지 확인한 뒤, 각 메시지에 대응하는 내부 관리 목록에 해당 컴포넌트를 등록한다.

즉, Update가 있는 컴포넌트는 Unity 내부의 Update 호출 목록에 들어간다.

게임이 실행되는 동안 Unity는 매 프레임 이 목록을 순회하면서 등록된 Update들을 호출한다.

따라서 Update가 비어 있더라도, 그 함수가 존재하는 순간 해당 컴포넌트는 Unity의 Update 호출 대상이 된다.

비어 있어도 호출 비용은 존재한다

비어 있는 Update 안에는 실행할 코드가 없다.

하지만 그렇다고 비용이 완전히 0이 되는 것은 아니다.

Unity는 매 프레임 Update 목록을 순회해야 한다.
그리고 각 컴포넌트가 활성 상태인지, 호출 가능한 상태인지 확인해야 한다.

또한 Update 호출은 단순히 C# 함수 하나를 호출하는 것과 다르다.

Unity 엔진의 핵심은 Native C++ 영역에서 동작한다.
반면 우리가 작성한 MonoBehaviour의 Update는 Managed C# 영역에 있다.

즉, Unity가 Update를 호출한다는 것은 Native 영역에서 Managed 영역으로 넘어가서 C# 메서드를 실행하는 과정이 포함된다.

이 과정에는 단순 함수 호출 이상의 오버헤드가 있다.

물론 Update 하나만 보면 매우 작은 비용이다.

하지만 이 비용이 수천 개, 수만 개, 수십만 개의 컴포넌트에서 매 프레임 발생한다면 이야기가 달라진다.

비어 있는 Update라도 존재하면 다음과 같은 비용이 생긴다.

컴포넌트 생성 시 Update 목록에 등록되는 비용
컴포넌트 비활성화 또는 파괴 시 목록에서 제거되는 비용
매 프레임 Update 목록을 순회하는 비용
Native에서 Managed로 넘어가 Update를 호출하는 비용

그래서 사용하지 않는 Update는 제거하는 것이 좋다.

Update가 많으면 생기는 문제

Update는 게임 개발에서 매우 자주 사용하는 함수다.

캐릭터 이동, 입력 처리, AI 갱신, 타이머, 상태 체크 등 많은 로직이 Update 안에서 실행된다.

그래서 Update 자체가 나쁜 것은 아니다.

문제는 너무 많은 오브젝트가 각각 자기만의 Update를 가지고 있을 때다.

예를 들어 몬스터가 200,000마리 있다고 가정해보자.

각 몬스터는 매 프레임 자신의 체력과 스태미너를 회복한다고 해보자.

public class Monster : MonoBehaviour
{
    public float hp;
    public float stamina;

    void Update()
    {
        hp += 1.0f * Time.deltaTime;
        stamina += 1.0f * Time.deltaTime;
    }
}

 

 

코드 자체는 매우 단순하다.

그냥 체력과 스태미너를 조금씩 증가시키는 작업뿐이다.

하지만 이 코드가 몬스터 200,000마리 각각에 들어 있다면, Unity는 매 프레임 200,000개의 Update를 호출해야 한다.

즉, 실제 작업은 단순하지만 호출 구조 자체가 무거워질 수 있다.

테스트를 해보면 단순한 회복 로직만 실행했는데도 약 13.95ms 정도가 걸릴 수 있다.

생각보다 큰 비용이다.

여기서 중요한 점은, 이 시간이 전부 체력과 스태미너 계산 때문에 발생한 것이 아니라는 점이다.

각각의 MonoBehaviour.Update가 개별적으로 호출되는 비용도 포함되어 있다.

하나의 Manager에서 처리하면 어떻게 될까?

그렇다면 몬스터마다 Update를 두지 않고, 하나의 Manager에서 모든 몬스터를 순회하면 어떻게 될까?

예를 들면 이런 방식이다.

public class Monster
{
    public float hp;
    public float stamina;
}

public class MonsterManager : MonoBehaviour
{
    private readonly List<Monster> monsters = new List<Monster>();

    void Update()
    {
        float deltaTime = Time.deltaTime;

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

            monster.hp += 1.0f * deltaTime;
            monster.stamina += 1.0f * deltaTime;
        }
    }
}

이 방식에서는 Update가 하나만 존재한다.

Unity는 매 프레임 MonsterManager.Update 하나만 호출한다.
그 안에서 C# 코드로 몬스터 리스트를 순회하면서 필요한 처리를 한다.

즉, 기존 방식은 이런 구조였다.

Unity가 Monster.Update를 200,000번 호출

Manager 방식은 이런 구조다.

Unity가 MonsterManager.Update를 1번 호출
Manager 내부에서 for문으로 200,000번 처리

둘 다 200,000마리의 몬스터를 처리한다는 점은 같다.

하지만 큰 차이가 있다.

첫 번째 방식은 Unity의 Message Function 호출이 200,000번 발생한다.
두 번째 방식은 Unity의 Message Function 호출이 1번만 발생한다.

실제로 테스트해보면 기존 방식에서 13.95ms가 걸리던 작업이, 하나의 Manager에서 처리했을 때 약 0.79ms 정도로 줄어들 수 있다.

단순히 Update를 한 곳에서 관리하는 방식으로 바꿨을 뿐인데 약 20배 이상 빨라진 것이다.

왜 이렇게 차이가 날까?

이 차이는 체력 회복 계산 자체가 빨라졌기 때문만은 아니다.

핵심은 호출 방식이다.

각 몬스터가 MonoBehaviour로 존재하고 각각 Update를 가지고 있으면, Unity는 매 프레임 모든 Update 대상 컴포넌트를 순회하고 개별적으로 호출해야 한다.

하지만 Manager 방식에서는 Unity의 Update 호출은 한 번만 발생한다.

이후의 반복 처리는 일반적인 C# for문으로 진행된다.

일반적인 C# 루프는 Unity Message Function 호출보다 훨씬 단순하고 예측 가능하다.

그래서 동일한 수의 몬스터를 처리하더라도, Update를 분산시키는 것보다 한 곳에서 모아서 처리하는 방식이 더 빠를 수 있다.

비어 있는 Update도 같은 이유로 제거해야 한다

다시 처음 질문으로 돌아가보자.

왜 비어 있는 Update를 지워야 할까?

void Update()
{
}

이 함수는 아무 일도 하지 않는다.

하지만 Unity 입장에서는 이 컴포넌트가 Update를 구현하고 있는 컴포넌트다.

그래서 내부 Update 목록에 등록된다.

그리고 매 프레임 호출 대상이 된다.

실제 함수 안에서 아무 작업도 하지 않더라도, 호출되기 전까지의 과정은 사라지지 않는다.

즉, 비어 있는 Update는 다음과 같은 의미를 가진다.

실제로 하는 일은 없음
하지만 매 프레임 호출 대상에는 포함됨

하나만 있으면 거의 의미 없는 비용이다.

하지만 프로젝트가 커지고, 이런 비어 있는 Update가 수백 개, 수천 개 쌓이면 불필요한 비용이 된다.

특히 프리팹에 빈 Update가 남아 있고, 그 프리팹이 씬에 많이 배치되는 경우 문제가 될 수 있다.

모든 Update를 없애야 한다는 뜻은 아니다

그렇다고 Update를 무조건 쓰면 안 된다는 뜻은 아니다.

Update는 Unity에서 꼭 필요한 기능이다.

입력 처리, 카메라 이동, 캐릭터 제어, 애니메이션 보정, 타이머 처리 등 매 프레임 갱신이 필요한 작업은 당연히 Update에서 처리할 수 있다.

중요한 것은 “정말 매 프레임 이 컴포넌트가 직접 Update를 가져야 하는가?”를 생각하는 것이다.

다음과 같은 경우는 Update를 제거하거나 구조를 바꾸는 것이 좋다.

함수 안이 비어 있는 Update
나중에 쓸 것 같아서 남겨둔 Update
특정 조건에서만 가끔 필요한 Update
수많은 오브젝트가 각각 가지고 있는 단순 반복 Update

특정 조건에서만 필요하다면, 항상 Update를 돌리는 대신 필요할 때만 Manager에 등록하는 방식도 사용할 수 있다.

예를 들어 몬스터가 실제로 회복 중일 때만 Manager의 업데이트 목록에 넣고, 회복이 필요 없어지면 목록에서 제거할 수 있다.

public interface IUpdatable
{
    void Tick(float deltaTime);
}
public class UpdateManager : MonoBehaviour
{
    private readonly List<IUpdatable> updatables = new List<IUpdatable>();

    void Update()
    {
        float deltaTime = Time.deltaTime;

        for (int i = 0; i < updatables.Count; i++)
        {
            updatables[i].Tick(deltaTime);
        }
    }

    public void Register(IUpdatable target)
    {
        if (!updatables.Contains(target))
        {
            updatables.Add(target);
        }
    }

    public void Unregister(IUpdatable target)
    {
        updatables.Remove(target);
    }
}

이런 방식으로 관리하면 Unity의 Update 호출 개수를 줄이고, 실제로 갱신이 필요한 대상만 처리할 수 있다.

정리

Unity는 매 프레임 리플렉션으로 Update를 찾는 것은 아니다.

하지만 Update 함수가 존재하는 MonoBehaviour는 Unity 내부의 Update 호출 목록에 등록된다.

그리고 매 프레임 Unity는 그 목록을 순회하면서 Update를 호출한다.

비어 있는 Update라도 호출 대상에 포함되기 때문에 비용이 완전히 0은 아니다.

특히 Update는 Native C++ 영역에서 Managed C# 영역으로 넘어가는 호출이기 때문에, 단순한 C# 함수 호출보다 더 큰 오버헤드가 발생할 수 있다.

그래서 사용하지 않는 Update는 제거하는 것이 좋다.

하나의 비어 있는 Update는 큰 문제가 아닐 수 있다.

하지만 많은 오브젝트에 비어 있는 Update가 있거나, 수많은 컴포넌트가 각각 단순한 Update를 가지고 있다면 누적 비용이 커질 수 있다.

실제로 200,000개의 몬스터가 각각 Update를 돌며 단순한 회복 로직을 처리하는 것보다, 하나의 Manager에서 한 번의 Update로 모든 몬스터를 처리하는 방식이 훨씬 빠를 수 있다.

결론적으로 중요한 것은 이것이다.

Update가 필요한 곳에서는 사용한다.
하지만 필요 없는 Update는 제거한다.
많은 오브젝트가 가진 단순 Update는 Manager 방식으로 모아서 처리할 수 있는지 검토한다.

비어 있는 Update를 지우는 것은 단순한 코드 정리 규칙이 아니다.

Unity의 Message Function 호출 구조를 이해하면, 왜 이 규칙이 성능 최적화와 연결되는지 알 수 있다.

 

참고자료

https://unity.com/kr/blog/engine-platform/10000-update-calls

 

10000번의 Update() 호출

void Update() { transform.Translate(0, 0, Time.deltaTime); }하지만 숙련된 개발자는 위의 코드에서 몇몇 의문이 들 수 있습니다. 이 함수는 도대체 언제 호출되지? 만약, 여러개의 스크립트가 있고 그 스크립

unity.com

https://medium.com/@jasonbooth_86226/intro-to-jobs-burst-dod-66c6b81c017f

 

Intro to Jobs/Burst/DoD

I often talk about performance gains I get on projects and people simply don’t believe them, especially when things get 100 times faster…

medium.com