게임 개발

유니티 GUI 최적화로 Drawcall 70% 줄인 이야기

3D를 의심했는데 범인은 UI였다. 프로파일링으로 원인을 찾고 반투명과 렌더링 순서를 손봐 Drawcall을 70% 줄인 유니티 GUI 최적화 실전 기록.

CodingJoa 8분 정도 걸립니다

유니티 GUI 최적화로 Drawcall 70% 줄인 이야기

오픈을 앞둔 프로젝트에서 인게임 Drawcall이 300을 넘겼다. 화면에 캐릭터가 떼로 몰려 있는 것도 아니고, 배경이 특별히 화려하지도 않았는데 숫자만 꾸역꾸역 올라갔다. 나는 당연히 3D 오브젝트나 이펙트를 범인으로 찍었다.

프로파일러를 붙이고 프레임 디버거로 한 줄씩 뜯어보니 엉뚱한 데서 답이 나왔다. GUI 혼자 100 이상을 먹고 있었다. 전체의 3분의 1을 UI가 가져간 것이다. 결국 렌더링 순서를 다시 짜고 반투명 요소를 걷어내는 것만으로 Drawcall을 70% 가까이 덜어냈다.

의심이 아니라 측정에서 출발해야 한다

그때 프로파일링을 건너뛰었다면 나는 텍스처 해상도부터 깎았을 거다. 가장 손에 익은 카드니까.

유니티 공식 채널의 최적화 강의에서도 똑같은 함정을 짚는다. 컨설팅을 나가 보면 측정 없이 최적화부터 시도하는 팀이 아주 많다고. 성능이 안 나온다며 아티스트에게 1024를 512로, 다시 256으로 줄여달라고 조르다가, 나중에 프로파일링해 보니 원인은 로직이나 물리였더라는 사례가 나온다. 아티스트만 피눈물을 흘린 셈이다.

내 경우 원인이 GUI라는 사실은 직관으로는 절대 안 나온다. UI는 ‘화면에 얹혀 있는 얇은 껍데기’처럼 보이기 때문이다. 하지만 실체를 뜯어보면 화면 전체를 덮는 사각형이 겹겹이 쌓인 구조다.

최적화 착수 전 체크리스트

  1. 에디터가 아니라 실제 타겟 기기에서 쟀는가
  2. CPU 바운드인지 GPU 바운드인지 갈랐는가
  3. Drawcall 총량 중 UI 몫을 따로 뽑아봤는가
  4. 잴 때 기기가 이미 발열로 스로틀링에 들어가 있진 않았는가

반투명은 생각보다 비싸다

가장 큰 효과를 본 조치는 반투명 걷어내기였다.

UI에서 알파는 거의 습관처럼 쓰인다. 딤 처리, 비활성 버튼, 그라데이션 배경, 페이드 연출까지. 문제는 이 녀석들이 배칭을 끊고 오버드로를 만든다는 점이다. 화면을 덮는 반투명 패널 한 장이 그 아래 UI 전부를 한 번 더 그리게 만든다.

내가 정리한 방식은 이랬다.

  • 딤 처리용 반투명 패널 → 알파를 미리 구워 넣은 불투명 텍스처로 교체
  • 비활성 버튼의 알파 처리 → 회색 스프라이트를 아예 따로 준비
  • 늘 켜져 있던 장식용 반투명 레이어 → 삭제

눈으로 보이는 손해는 거의 없었고 숫자만 내려갔다. 알파를 전면 금지하자는 얘기가 아니다. ‘이게 꼭 반투명이어야 하나’를 한 번씩 되묻자는 쪽에 가깝다. 그 질문 한 번이 내 프로젝트에서는 수십 개의 Drawcall이었다.

렌더링 순서를 바꾸면 배칭이 살아난다

두 번째 조치는 순서 조정이었다.

UGUI는 Hierarchy 순서대로 그린다. 같은 아틀라스, 같은 머티리얼을 쓰는 요소가 연달아 놓이면 한 덩어리로 묶여 나간다. 그런데 그 사이에 다른 아틀라스를 물고 있는 아이콘이나 텍스트가 하나 끼어들면 배칭이 딱 그 지점에서 잘린다.

내 프로젝트의 UI는 ‘기능 단위’로 깔끔하게 묶여 있었다. 개발자가 읽기엔 더없이 좋은 구조였지만 렌더러 입장에선 최악이었다. 이미지 → 텍스트 → 이미지 → 텍스트가 끝없이 번갈아 나오는 꼴이었으니까.

그래서 기준을 이렇게 갈아엎었다.

구분변경 전변경 후
정렬 기준기능·의미 단위아틀라스·머티리얼 단위
배경 이미지각 그룹마다 분산하위 레이어로 모음
텍스트이미지 사이사이 배치상위 레이어로 모음
아이콘개별 텍스처 다수공용 아틀라스로 통합

같은 강의에서는 SRP Batcher를 언급하며, 머티리얼이 달라도 셰이더만 같으면 SetPass Call이 줄어든다고 설명한다. 렌더링 최적화는 SRP Batcher에서 시작해 SRP Batcher로 끝난다는 표현까지 나올 정도다. 내 경험도 결이 같았다. 무엇을 그리느냐보다 어떤 순서로 묶여 나가느냐가 숫자를 결정했다.

로딩 때 UI를 다 올리면 게임이 멈춘다

Drawcall과 별개로 GUI가 만드는 골칫거리가 하나 더 있다. 진입 딜레이다.

UI 프리팹은 개수가 많고 각자 텍스처를 물고 있다. 씬에 들어가는 순간 팝업, 인벤토리, 상점, 설정창까지 전부 미리 만들어두면 프레임이 통째로 날아간다. 이걸 겪고 나서 UI 로딩을 세 갈래로 나눴다.

  1. 즉시 로드: 시작하자마자 무조건 보이는 HUD
  2. 지연 로드: 버튼을 처음 누른 시점에 만들고, 그다음부터는 재사용
  3. 미사용 해제: 한동안 열지 않은 무거운 창은 언로드

여기서 핵심은 2번의 ‘그다음부터 재사용’이다. 열 때마다 Instantiate하고 닫을 때 Destroy하면 GC 쓰레기가 계속 쌓인다. 앞서 언급한 강의에서도 C#의 가비지 컬렉션을 자동변속기에 비유한다. 자동이라고 손 놓는 게 아니라, 패들 시프트처럼 개발자가 끼어들 여지를 알고 있어야 한다는 얘기다. UI야말로 그 개입이 필요한 자리다. 껐다 켜는 방식(SetActive)이나 오브젝트 풀링으로 갈아탄 것만으로 GC 스파이크가 눈에 띄게 잦아들었다.

UI 코드에서 실제로 손본 것들

측정 다음은 코드다. 강의에서 다루는 C# 권장 사항 중 UI에서 유독 자주 걸리는 것들이 있다.

  • GetComponent는 호출할 때마다 컴포넌트 목록을 훑는다. Start에서 미리 받아 캐싱해두자. UI는 버튼 하나에도 여러 컴포넌트가 달라붙어 있어서 이 비용이 차곡차곡 쌓인다.
  • gameObject.tag == "..." 비교는 내부적으로 문자열을 새로 만들어 쓰레기를 남긴다. CompareTag를 쓰면 그 할당이 사라진다.
  • 문자열은 참조 타입이라 힙에 자리를 잡는다. HUD의 점수나 타이머를 매 프레임 문자열로 갈아 끼우면 그대로 GC 대상이 된다.
  • 코루틴의 yield return new WaitForSeconds(...)는 이름 그대로 매번 new가 붙는다. 연출용 코루틴이 반복된다면 인스턴스를 미리 만들어두고 돌려쓰는 편이 낫다.

이 중 체감이 가장 컸던 건 세 번째, HUD 텍스트 갱신이었다. 값이 그대로인데도 매 프레임 다시 쓰던 코드를 ‘바뀔 때만 갱신’으로 고쳤을 뿐인데 GC 할당 그래프가 눈에 띄게 평평해졌다.

모바일이라면 여유를 남겨야 한다

PC와 모바일을 가르는 결정적 차이는 쿨링이다. 데스크탑은 쿨러를 아홉 개씩 달고 수랭까지 얹지만, 스마트폰에는 그럴 공간이 없다.

그래서 모바일에서는 성능을 바닥까지 긁어 쓰는 게 미덕이 아니다. 강의에서는 이걸 “따뜻한 아이스 아메리카노"라는 말로 정리한다. 타겟이 30FPS라면 프레임당 예산 33ms를 다 쓰지 말고 25ms쯤에서 멈추라는 것이다. 발열로 스로틀링에 빠지면 기기가 알아서 클럭을 내려버리니까.

여기서 GUI가 중요해지는 이유는 하나다. UI는 늘 화면에 떠 있다. 전투 중에만 부하를 주는 이펙트와 달리 HUD는 게임이 켜져 있는 내내 매 프레임 그려진다. 상시 부하이니 절감분도 상시 이득이다. UI에서 아낀 Drawcall은 특정 구간이 아니라 플레이 전체에서 계속 굴러 들어오는 값이었다.

프로파일링할 때 발열 상태를 같이 봐야 하는 것도 같은 맥락이다. 똑같은 시나리오라도 기기가 차가울 때와 20분 돌린 뒤의 수치가 딴판이다. 그래서 나는 이후로 측정 → 기기 식힘 → 재측정을 한 세트로 돌린다.

GUI 최적화는 고급 기술의 영역이다

돌아보면 70%를 줄인 방법에 대단한 기술은 없었다. 프로파일링으로 범인을 정확히 특정했고, 반투명을 덜어냈고, 렌더링 순서를 다시 짰다. 그게 전부다.

다만 이 판단을 내리려면 배칭이 왜 끊기는지, 오버드로가 무엇인지, GC가 언제 튀는지를 알고 있어야 한다. 그래서 나는 GUI 최적화를 ‘기초 작업’이 아니라 ‘경험과 고급 기술이 필요한 영역’으로 본다. UI는 방치하면 조용히 자원을 갉아먹는다. 그리고 방치하기가 아주 쉽다.

마치며

  • Drawcall 300짜리 프로젝트에서 GUI가 100 이상을 차지했고, 이건 프로파일링 없이는 알 도리가 없었다.
  • 반투명 제거와 렌더링 순서 재정렬만으로 약 70%를 덜어냈다.
  • UI를 한꺼번에 올리면 진입 딜레이가 생긴다. 즉시·지연·해제 3단계로 쪼개는 편이 안전하다.
  • CompareTag, GetComponent 캐싱, 문자열 갱신 최소화처럼 UI 코드에서 반복되는 할당을 줄이면 GC 스파이크가 완만해진다.
  • 모바일은 발열이 성능을 좌우한다. 프레임 예산을 다 쓰지 말고 여유를 남기자.

자주 묻는 질문

Q. 유니티에서 UI Drawcall이 늘어나는 가장 흔한 원인은?

배칭을 끊는 배치 순서와 반투명 요소다. 서로 다른 아틀라스를 쓰는 이미지와 텍스트가 번갈아 놓이면 그 지점마다 Drawcall이 새로 생긴다.

Q. UI 최적화, 프로파일링 없이 감으로 하면 안 되나?

경험이 쌓이면 어느 정도는 짚어낼 수 있다. 다만 나는 3D 쪽을 의심했는데 실제 범인은 GUI였다. 감으로 덤볐다면 엉뚱한 곳에서 시간만 태웠을 거다.

Q. 반투명을 아예 쓰지 말아야 하나?

그건 아니다. 항상 켜져 있는 반투명 레이어부터 점검하면 된다. 순간적으로 스쳐 가는 연출보다, 상시로 화면을 덮고 있는 딤 패널이 훨씬 비싸다.

Q. 에디터에서 잰 결과를 믿어도 되나?

권하지 않는다. 에디터에는 에디터용 오버헤드가 있고, GetComponent처럼 에디터와 빌드에서 동작이 갈리는 API도 있다. 실제 타겟 기기에 프로파일러를 물려서 확인해야 한다.

Q. UI를 미리 다 만들어두는 것과 필요할 때 만드는 것 중 어느 쪽이 낫나?

HUD처럼 늘 쓰는 건 미리, 상점이나 설정창처럼 가끔 여는 건 처음 열 때 만들어 재사용하는 쪽이 좋다. 열고 닫을 때마다 생성과 파괴를 반복하면 GC가 쌓인다.

참고 자료

  • 알쓸유잡 라이브 — 모바일 게임 퍼포먼스를 위한 최적화 (프넬)
이 글 공유하기 X Facebook 네이버

이 글의 내용은 작성 시점에 직접 실행하고 확인한 결과와 글쓴이 개인의 판단을 담은 기록입니다. 도구의 버전·가격·동작은 시간이 지나면 달라질 수 있으니, 따라 하실 때는 공식 문서를 함께 확인해주세요. 자세한 내용은 이용약관에 정리해두었습니다.