내가 일하며 알게 된 프로그래밍
article thumbnail

 

프로파일러를 열기 전까지는 절대 몰랐을 것들이다.


게임이 어느 정도 완성되고 나면 한 번쯤 프로파일러를 켜는 시간이 온다. 처음엔 그냥 가볍게 확인해보려고 열었는데, GC Alloc 항목에 숫자가 계속 찍히고 있는 걸 보게 된다. 딱히 뭔가를 크게 만든 것도 아닌데, 프레임마다 메모리 할당이 발생하고 있다.

원인을 추적하다 보면 결국 코드 어딘가에서 반복적으로 객체를 만들고 버리고 있다는 걸 알게 된다. 로직이 틀린 건 아니다. 그냥 작동은 한다. 하지만 GC 입장에서는 매우 바쁜 코드다.

이 글은 실제 프로젝트 코드를 들여다보면서 발견한 패턴 세 가지를 정리한 것이다. 나쁜 코드라기보다는 "아, 이게 이런 문제를 만드는구나" 하고 깨닫게 되는 케이스들이다.


매 호출마다 새 List를 반환하는 함수

이건 처음 보면 그냥 무난한 유틸리티 함수처럼 보인다.

public List<AnimationInfo> GetCurrentAnimationList()
{
    var result = new List<AnimationInfo>();
    foreach (var layer in layers)
        foreach (var info in layer.animations)
            result.Add(info);
    return result;
}

문제가 없어 보인다. 각 레이어를 순회해서 애니메이션 정보를 수집하고 반환한다. 완전히 합리적인 코드다.

근데 이 함수가 매 프레임 호출된다면 얘기가 달라진다. 호출될 때마다 new List<AnimationInfo>()가 힙에 올라간다. 함수가 끝나고 나면 아무도 그 리스트를 참조하지 않으니 곧 GC 대상이 된다. 그게 초당 60번 반복된다.

캐릭터가 하나라면 버틸 수도 있다. 근데 던전에 몬스터가 30마리 있고 각자 매 프레임 이 함수를 호출하고 있다면? 프로파일러에서 이 함수 이름 옆에 숫자가 찍혀있을 것이다.

해결 자체는 간단하다. 결과를 캐싱해두고 상태가 바뀔 때만 다시 계산하면 된다.

private readonly List<AnimationInfo> _cache = new();
private bool _isDirty = true;

public List<AnimationInfo> GetCurrentAnimationList()
{
    if (!_isDirty) return _cache;

    _cache.Clear();
    foreach (var layer in layers)
        foreach (var info in layer.animations)
            _cache.Add(info);

    _isDirty = false;
    return _cache;
}

애니메이션 상태가 바뀌는 시점에 _isDirty = true로 표시해두면, 변경이 없을 때는 기존 리스트를 그대로 돌려준다. new List<T>() 할당이 완전히 사라진다.

이 패턴은 게임에서 꽤 자주 등장한다. 특히 "현재 상태를 수집해서 반환"하는 종류의 함수는 대부분 이런 방식으로 캐싱할 수 있다.


List에 Contains/Remove를 반복하는 코드

이것도 동작은 완벽하다. 아무런 버그가 없다.

private List<string> _activeAnimations = new();

public void RemoveAnimations(List<string> keysToRemove)
{
    foreach (var key in keysToRemove)
    {
        if (_activeAnimations.Contains(key))
            _activeAnimations.Remove(key);
    }
}

제거할 키 목록을 받아서 하나씩 확인하고 지운다. 직관적이고 읽기 쉬운 코드다.

문제는 List<T>.Contains()List<T>.Remove()가 둘 다 내부적으로 처음부터 끝까지 순회한다는 점이다. 즉 이 함수는 O(n × m)이다. 제거할 키가 n개, 리스트 크기가 m이면 최악의 경우 n*m번 비교가 일어난다.

애니메이션 레이어가 2~3개일 때는 전혀 티가 안 난다. 근데 레이어가 복잡한 캐릭터가 많아지고, 동시에 많은 애니메이션이 처리될 때 CPU 스파이크로 나타나기 시작한다.

private HashSet<string> _activeAnimations = new();

public void RemoveAnimations(IEnumerable<string> keysToRemove)
{
    foreach (var key in keysToRemove)
        _activeAnimations.Remove(key);
}

ListHashSet으로 바꾸는 것만으로 ContainsRemove 모두 O(1)이 된다. 컬렉션의 순서가 중요하지 않다면 대부분의 경우 HashSet이 더 적합하다.

다만 HashSet은 순서를 보장하지 않는다는 점은 주의해야 한다. 순서가 중요한 컬렉션이라면 Dictionary<string, T>를 쓰거나 구조를 다시 생각해봐야 한다.


Setter가 Guard 없이 매번 무거운 작업을 실행한다

이게 세 가지 중에 가장 발견하기 어려웠다. 코드 자체는 너무 자연스럽게 생겼거든.

private AnimationState _currentState;

public AnimationState CurrentState
{
    get => _currentState;
    set
    {
        _currentState = value;
        UpdateAnimations();
    }
}

상태가 바뀌면 애니메이션을 업데이트한다. 당연히 해야 할 일이고, 맞는 코드다.

근데 UpdateAnimations()가 꽤 무거운 함수라면 어떨까. 그리고 초기화 코드에서 이 setter를 여러 번 호출하고 있다면? 혹은 외부에서 같은 상태를 반복해서 세팅하는 경우가 있다면?

값이 바뀌지 않았는데도 UpdateAnimations()가 실행된다. 이게 누적되면 불필요한 계산이 꽤 많이 쌓인다.

public AnimationState CurrentState
{
    get => _currentState;
    set
    {
        if (_currentState == value) return;
        _currentState = value;
        UpdateAnimations();
    }
}

단 한 줄이다. if (_currentState == value) return; 이게 전부다.

이 한 줄이 없으면 setter를 호출할 때마다 무조건 UpdateAnimations()가 실행된다. 있으면 실제로 값이 변했을 때만 실행된다. 차이가 크다.

특히 애니메이션이나 렌더링처럼 상태 변경에 반응하는 로직에서 이 패턴이 자주 등장한다. setter를 신뢰해서 아무 데서나 CurrentState = something을 호출하다 보면, 어느 순간 같은 상태를 중복 세팅하는 경로가 생기기 마련이다.


마치며

세 패턴 모두 공통점이 있다. 로직 자체는 틀리지 않는다. 버그도 없다. 그냥 작동한다.

근데 규모가 커지면 달라진다. 오브젝트가 100개, 200개가 되고, 매 프레임 실행되는 코드 경로에 이런 패턴이 끼어 있으면 프로파일러에 흔적을 남기기 시작한다.

프레임 드랍이 오기 전에 미리 잡을 수 있는 가장 좋은 방법은 결국 프로파일러를 정기적으로 켜보는 습관이다. GC Alloc이 찍히는 함수가 있다면, 그 함수가 얼마나 자주 호출되는지 한번 세어보는 것이 시작이다.

'프로그래밍 > C#' 카테고리의 다른 글

결과가 같은데 왜 매번 만드나 (Flag 처리)  (0) 2026.05.21
Struct와 Class의 차이  (0) 2023.08.03
Partial Class를 사용하는 이유  (0) 2023.08.01
C#과 다중 상속  (0) 2023.08.01
Static 클래스  (0) 2023.07.31
profile

내가 일하며 알게 된 프로그래밍

@CtrlVGames