Unity - '한 우산 아래'

Unity Profiler로 디버그 UI 프레임 드랍 원인 찾기

망고와플 2026. 4. 15. 08:01

이번에는 기능 구현이 아니라, 구현 중에 만난 성능 문제를 정리해보려고 한다.

최근 퍼즐 시스템을 만들면서 F3를 누르면 퍼즐끼리 어떤 식으로 연결되어 있는지 볼 수 있는 디버그 오버레이를 만들었다.

예를 들면 이런 식이다.

  • 어떤 발판이 눌렸는지
  • 그 발판이 어떤 조건 그룹에 연결되어 있는지
  • 조건 그룹이 어떤 문이나 플랫폼을 활성화하는지
  • 현재 조건이 만족되었는지

이 기능은 그냥 내가 보기 편하려고 만든 디버그 기능은 아니었다.

나중에 기획자가 맵을 설계할 때, 발판과 문, 조건 그룹이 어떻게 연결되어 있는지 눈으로 바로 확인할 수 있으면 좋겠다고 생각했다.

퍼즐이 몇 개 없을 때는 Inspector를 열어보면 되지만, 맵이 커지고 연결이 많아지면 이 버튼이 어디와 연결 되어있는지 찾는 것만으로도 시간이 걸릴 것 같았다.

그래서 F3를 누르면 퍼즐 오브젝트 사이의 연결 관계가 화면에 표시되도록 만들었다.

그런데 어느 순간부터 플레이 중에 Unity가 버벅이기 시작했다.

처음 의심한 것

처음에는 물리 쪽을 의심했다.

마침 그 시점에 손으로 밀고 당길 수 있는 상자도 만들고 있었고, Rigidbody, Collider, Trigger, WeightSensor 같은 것들이 많이 추가된 상태였다.

그래서 처음에는 이런 것들이 문제일 거라고 생각했다.

1. Physics.Simulate
2. Rigidbody 충돌 계산
3. Trigger 감지
4. GC.Collect
5. 상자 잡기 로직

하지만 테스트를 해보니 조금 이상했다.

F3 디버그 오버레이를 끄면 버벅임이 거의 사라졌다.

다시 F3를 켜면 프레임이 튀었다.

이때부터 원인은 상자나 물리 계산보다는, 디버그 오버레이 자체일 가능성이 높아졌다.

Profiler로 확인하기

Unity에서 Window > Analysis > Profiler를 열고 CPU Usage를 확인했다.

처음에는 Timeline으로 봤지만, Timeline은 전체 흐름을 보기에는 좋지만 정확히 어떤 함수가 문제인지 한눈에 보기 어려웠다.

그래서 Hierarchy 모드로 바꿔서 봤다.

확인해야 할 값은 대략 이렇다.

Total
Self
Calls
GC Alloc
Time ms

Profiler에서 눈에 띈 부분은 OnGUI였다.

PlayerLoop
 ㄴ UpdateScene
   ㄴ PreUpdate.IMGUISendQueuedEvents
     ㄴ GUI.Repaint
       ㄴ WeightedButton.OnGUI

특히 WeightedButton.OnGUI 쪽 시간이 크게 튀었다.

문제는 점선이었다

원인은 F3 디버그 오버레이에 그리던 점선이었다.

처음에는 퍼즐 연결이 더 잘 보이게 하려고 점선을 사용했다.

심지어 점선이 흐르듯 움직이면 이 둘이 연결되어 있다는 느낌이 더 잘 날 거라고 생각했다.

그런데 점선은 생각보다 비쌌다.

처음에는 나도 이렇게 생각했다.

선 몇 개 그리는 게 그렇게 무거울까?

그런데 로그를 찍어보니 숫자가 꽤 컸다.

[PuzzleDebug] Dashed screen lines: 4, segments: 65316
[PuzzleDebug] Dashed screen lines: 8, segments: 6528
[PuzzleDebug] Dashed screen lines: 10, segments: 28692

화면에는 점선 몇 개만 보였지만, 내부적으로는 한 프레임에 수천에서 수만 개의 선 조각을 그리고 있었다.

문제는 점선의 개수가 아니었다.

점선을 만들기 위해 쪼개진 segment 수가 문제였다.

왜 그냥 선을 그리지 않았을까?

여기서 헷갈릴 수 있는 부분이 있다.

처음에는 나도 그냥 선 하나 그리면 되는 거 아닌가?라고 생각했다.

Unity에서 선을 그리는 방법 자체가 없는 것은 아니다.

예를 들면 이런 것들이 있다.

Gizmos.DrawLine(...)
Debug.DrawLine(...)
Handles.DrawLine(...)
GL.LINES

하지만 내가 만들던 것은 Game View 위에 뜨는 런타임 OnGUI 디버그 오버레이였다.

OnGUI에서 자주 쓰는 함수들은 대부분 사각형 영역을 기준으로 동작한다.

GUI.Box(...)
GUI.Label(...)
GUI.DrawTexture(...)

반면 GUI.DrawLine(start, end)처럼 두 점을 받아 선분을 그리는 기본 함수는 없다.

그래서 지금 코드에서는 진짜 선 프리미티브를 그리는 대신, 얇고 긴 흰색 텍스처를 하나 그리고 그것을 선 방향으로 회전시켜 선처럼 보이게 만들었다.

private static void DrawScreenLine(Vector2 start, Vector2 end, Color color, float thickness)
{
    Matrix4x4 previousMatrix = GUI.matrix;
    Color previousColor = GUI.color;

    Vector2 delta = end - start;
    float angle = Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg;
    float length = delta.magnitude;

    GUI.color = color;
    GUIUtility.RotateAroundPivot(angle, start);
    GUI.DrawTexture(
        new Rect(start.x, start.y - thickness * 0.5f, length, thickness),
        Texture2D.whiteTexture);

    GUI.matrix = previousMatrix;
    GUI.color = previousColor;
}

쉽게 말하면 이렇다.

1. 얇고 긴 사각형을 그린다
2. 그 사각형을 선 방향으로 회전시킨다
3. 그러면 화면에서는 선처럼 보인다

실선은 이 작업을 한 번만 하면 된다.

하지만 점선은 다르다.

점선을 만들려면 선 전체를 작은 조각으로 나누고, 각 조각마다 위 작업을 반복해야 한다.

즉, 점선 하나가 이런 식으로 바뀐다.

선 하나
-> 작은 선 조각 여러 개
-> 각 조각마다 DrawTexture 호출해야함

그래서 화면에는 점선 몇 개만 보였지만, 실제로는 한 프레임에 수천에서 수만 개의 DrawTexture 호출로 쪼개지고 있었다.

기존 점선 코드의 구조

점선을 그리던 코드는 대략 이런 구조였다.

for (float distance = startDistance; distance < length; distance += step)
{
    float dashStart = Mathf.Max(distance, 0.0f);
    float dashEnd = Mathf.Min(distance + dashLength, length);

    if (dashEnd <= dashStart)
    {
        continue;
    }

    DrawScreenLine(
        start + direction * dashStart,
        start + direction * dashEnd,
        color,
        thickness);
}

짧은 선 몇 개 정도라면 이 방식도 괜찮다.

하지만 퍼즐 디버그 오버레이처럼 여러 오브젝트가 서로 연결되고, 그 연결선이 매 프레임 다시 그려지는 상황에서는 부담이 커졌다.

특히 OnGUI는 Repaint 타이밍에 UI를 다시 그린다.

여기서 선 조각이 수천, 수만 개로 늘어나면 디버그용 코드가 실제 게임 로직보다 더 무거워질 수 있다.

해결: 점선을 실선으로 바꾸기

해결은 단순했다.

점선을 포기하고 실선으로 바꿨다.

현재는 PuzzleDebugOverlay.DrawWorldLine과 DrawGuiToWorldLine을 사용한다.

public static void DrawWorldLine(
    Camera targetCamera,
    Vector3 worldStart,
    Vector3 worldEnd,
    Color color,
    float thickness)
{
    if (!TryGetGuiPoint(targetCamera, worldStart, out Vector2 start) ||
        !TryGetGuiPoint(targetCamera, worldEnd, out Vector2 end))
    {
        return;
    }

    DrawScreenLine(start, end, color, thickness);
}

점선보다 덜 화려하지만, 디버그 도구에서는 충분했다.

디버그 도구의 목적은 예쁘게 보이는 것이 아니라, 지금 어떤 오브젝트가 어디와 연결되어 있는지 빠르게 확인하는 것이다.

결과적으로 실선으로 바꾼 뒤 프레임 튐이 사라졌다.

디버그 코드를 공통 유틸리티로 분리하기

이번에 선을 정리하면서 디버그 표시 코드도 PuzzleDebugOverlay로 모았다.

처음에는 각 퍼즐 컴포넌트가 자기 라벨과 연결선을 직접 그리게 만들 수 있다고 생각했다.

하지만 그렇게 하면 월드 좌표를 GUI 좌표로 바꾸는 코드, 선을 그리는 코드, 라벨을 그리는 코드가 여러 스크립트에 반복된다.

그래서 역할을 나눴다.

퍼즐 컴포넌트:
무엇을 보여줄지 결정한다.

PuzzleDebugOverlay:
어떻게 화면에 그릴지 처리한다.

예를 들어 PuzzleConditionGroup은 어떤 조건과 연결되어 있는지만 알고 있으면 된다.

실제로 그 선을 어떻게 그릴지는 몰라도 된다.

//conditionPosition에서 sourcePosition까지 선을 그려줘.
PuzzleDebugOverlay.DrawWorldLine(
    targetCamera,
    conditionPosition,
    sourcePosition,
    color,
    debugLineThickness);

//--------------------------------------------------------
//그 뒤의 처리는 PuzzleDebugOverlay가 맡는다.
public static void DrawWorldLine(
    Camera targetCamera,
    Vector3 worldStart,
    Vector3 worldEnd,
    Color color,
    float thickness)
{
    if (!TryGetGuiPoint(targetCamera, worldStart, out Vector2 start) ||
        !TryGetGuiPoint(targetCamera, worldEnd, out Vector2 end))
    {
        return;
    }

    DrawScreenLine(start, end, color, thickness);
}

여기서 월드 좌표를 OnGUI에서 쓸 수 있는 GUI 좌표로 바꾸고, 카메라 뒤에 있는 대상은 그리지 않는다.

그리고 마지막에 선을 그린다.

이렇게 해두면 WeightedButton, PuzzleConditionGroup 같은 컴포넌트는 자기 역할에만 집중할 수 있다.

WeightedButton - 현재 무게와 눌림 상태를 보여준다.

PuzzleConditionGroup - 조건과 결과의 연결 관계를 보여준다.

PuzzleDebugOverlay - 라벨과 선을 실제 화면에 그린다.

나중에 점선을 실선으로 바꾸는 작업도 이 구조 덕분에 쉬웠다.

선 그리기 방식이 PuzzleDebugOverlay에 모여 있으니, 여러 퍼즐 컴포넌트를 하나씩 고칠 필요가 없었다.

얻은 것

이번 문제는 게임 로직의 문제가 아니었다.

디버그를 위해 만든 표시 기능이 너무 무거워진 문제였다.

처음에는 Rigidbody나 상자 잡기 로직을 의심했지만, Profiler를 보고 나서야 실제 원인이 OnGUI와 점선 렌더링 쪽이라는 걸 확인할 수 있었다.

이번에 배운 점은 세 가지다.

  1. 프레임이 튀면 감으로 고치기보다 Profiler로 먼저 확인한다.
  2. 디버그 UI도 매 프레임 도는 코드라 충분히 무거워질 수 있다.
  3. 점선처럼 단순해 보이는 표현도 내부적으로는 많은 그리기 호출로 쪼개질 수 있다.

결국 수정 자체는 점선을 실선으로 바꾸는 간단한 작업이었다.

하지만 이 과정을 통해 Unity Profiler를 어떻게 읽어야 하는지, 그리고 디버그 도구도 성능을 생각하며 만들어야 한다는 것을 배울 수 있었다.