본문 바로가기

블루프린트 성능 최적화 기법

Tick·캐싱·루프·콜리전·타이머·풀링을 점검하고 프로파일링 결과에 따라 블루프린트 병목을 줄입니다.

적 몇 마리와 상자 몇 개로 시험할 때는 부드럽게 돌던 레벨이, 적이 수십 마리로 늘고 비가 내리고 파편이 쏟아지기 시작하면 갑자기 끊기기 시작합니다. 이때 많은 사람이 "블루프린트라서 느리다"고 결론 내리고 C++로 옮길 궁리부터 하지만, 실제 원인은 매 프레임 월드 전체를 검색하는 노드 하나이거나, 아무도 보지 않는 UI를 매 프레임 갱신하는 바인딩일 때가 많습니다. 어디가 느린지 모르는 채로 고치면 엉뚱한 곳에 시간을 쓰게 됩니다.

이 글에서는 블루프린트의 성능을 점검하는 기본 기법을 정리합니다. 무엇이 비용을 만드는지 먼저 이해하고, Tick, 캐싱, 루프, 콜리전과 트레이스, 타이머, 풀링, 검색과 로딩을 차례로 살펴본 뒤, 마지막으로 측정 도구로 병목을 찾는 방법을 익힙니다. C++로 옮기지 않아도 블루프린트 안에서 줄일 수 있는 비용이 많으며, 앞 글들에서 만든 물리와 환경 상호작용처럼 움직이는 요소가 많을수록 이 점검이 쓸모 있습니다.


블루프린트의 비용은 어디서 생길까

블루프린트는 빠르고 직관적으로 개발할 수 있지만, 같은 로직을 C++로 쓴 것보다 상대적으로 느립니다. 블루프린트 그래프는 컴파일된 뒤 가상 머신 위에서 실행되므로, 노드 하나를 호출할 때마다 C++ 함수를 직접 부르는 것보다 부담이 더 들기 때문입니다.

다만 이 부담은 블루프린트의 실행 흐름과 노드 호출 자체에 대한 것입니다. 노드가 호출하는 물리 쿼리, 렌더링, 엔진 기능의 비용은 그 노드를 C++에서 불러도 그대로입니다. 예를 들어 트레이스 하나의 비용은 블루프린트에서 부르든 C++에서 부르든 크게 다르지 않습니다. 그래서 무엇을 옮길지는 실제 병목과 호출 횟수를 확인한 뒤에 고릅니다.

최적화의 목표는 세 가지입니다. 게임이 부드럽게 보이도록 프레임 레이트를 일정하게 유지하고, CPU, GPU, 메모리 사용량을 줄여 다양한 하드웨어에서 잘 돌게 하며, 입력 지연을 줄여 상호작용이 바로 반응하게 하는 것입니다.


Tick은 필요한 만큼만

Event Tick은 Tick이 켜져 있으면 Tick 간격(Tick Interval)에 따라 호출됩니다. 기본값으로는 매 프레임 실행되는 로직이 많으므로 흔한 병목 후보입니다. 그렇다고 Tick부터 줄이는 것이 순서는 아닙니다. 먼저 stat unit으로 게임 스레드(Game), 렌더링 스레드(Draw), GPU 가운데 어느 쪽이 느린지 나누고, 게임 스레드가 문제일 때 Tick과 블루프린트 호출 빈도를 좁혀 보는 순서가 안전합니다.

게임 스레드가 문제라면 세 가지를 검토합니다. 첫째, 항상 움직이거나 갱신될 필요가 없는 액터는 Class Defaults의 Actor Tick 항목에서 Start with Tick Enabled를 끕니다. 이 옵션은 시작할 때 Tick을 켤지에 대한 것이며, C++ 부모 클래스가 Tick 자체를 지원하는지와는 다른 설정입니다.

둘째, 필요한 순간에만 Set Actor Tick Enabled로 Tick을 켜고 끕니다. 예를 들어 캐릭터가 멈춰 있을 때는 Tick을 끄고 움직이기 시작할 때 다시 켭니다. 이때 꺼진 Tick은 자신을 다시 켤 조건을 검사할 수 없으므로, 입력, Overlap, 외부 관리자의 이벤트처럼 Tick 밖에서 다시 켜 줄 경로를 반드시 마련합니다.

셋째, 매 프레임 갱신할 필요가 없는 로직은 Set Actor Tick Interval로 Tick 주기를 늘립니다. 멀리 있는 AI의 시야 확인이나 특정 환경 효과 갱신이 그 예이며, 0.1초마다 한 번씩만 실행해도 충분한 경우가 많습니다.

더 깊이: Tick 그룹과 한 프레임 안의 실행 순서

액터 틱 문서에 따르면 액터와 컴포넌트의 Tick은 Tick 그룹(tick group)으로 나뉘어 정해진 순서로 실행되며, 한 그룹의 Tick이 모두 끝나야 다음 그룹이 시작됩니다. 순서는 프레임이 시작될 때의 TG_PrePhysics, 물리 시뮬레이션과 함께 진행되는 TG_DuringPhysics, 물리 시뮬레이션이 끝난 뒤의 TG_PostPhysics, 카메라를 갱신한 뒤의 TG_PostUpdateWork입니다. TG_PrePhysics에서 읽는 물리 데이터는 지난 프레임의 것이고, TG_DuringPhysics에서는 지난 프레임의 것인지 이번 프레임의 것인지 알 수 없습니다. 그래서 무기의 조준 트레이스처럼 이번 프레임에 그려질 최종 위치가 필요한 로직은 TG_PostPhysics에, 인벤토리 화면처럼 한 프레임 늦어도 괜찮은 로직은 TG_DuringPhysics에 두는 식으로 고릅니다.

Tick 그룹 사이에는 엔진의 다른 작업도 끼어 있습니다. 같은 문서의 표를 보면 TG_PostPhysics가 끝난 뒤에 잠재 동작(latent action)을 처리하고 월드의 타이머 매니저를 틱하므로, 뒤에서 다룰 타이머와 Delay의 완료도 이 시점에 처리됩니다. 또 한 액터가 다른 특정 액터의 Tick이 끝난 뒤에만 실행되어야 한다면, 그룹을 통째로 옮기는 대신 Add Tick Prerequisite Actor나 Add Tick Prerequisite Component로 의존 관계만 지정할 수 있습니다. 같은 그룹의 액터들은 병렬로 갱신될 수 있으므로, 필요한 한두 개의 의존만 거는 편이 그룹 전체가 끝나기를 기다리게 하는 것보다 효율적일 수 있습니다.


참조와 계산 결과를 저장해 두기

자주 참조하는 액터나 컴포넌트, 복잡한 계산 결과는 변수에 저장해 두고, 같은 Get 노드 호출이나 재계산을 반복하지 않습니다. 이를 캐싱(caching)이라고 합니다.

Cast도 마찬가지입니다. 캐스트 한 번의 비용보다 더 큰 문제는 Tick이나 큰 루프 안에서 같은 캐스트를 반복하는 것, 그리고 무거운 블루프린트 클래스로 직접 캐스트해 그 클래스와 에셋까지 함께 로드되게 만드는 것입니다. 한 번 확인한 참조는 변수에 저장하고, 가능하면 C++ 기본 클래스, 가벼운 부모 블루프린트, 인터페이스로 캐스트해 로드 의존성을 줄입니다. 아래는 플레이어 참조를 한 번만 얻어 저장하는 노드 연결을 글로 적은 의사 코드입니다.

(Event BeginPlay 또는 On Component Begin Overlap)
-> Cast To BP_PlayerCharacter (Object: BeginPlay에서는 Get Player Character(0), Overlap에서는 Other Actor)
-> Set PlayerCharacterRef (변수)

이렇게 저장한 참조는 쓸 때마다 유효한지 확인해야 합니다. 폰이 다시 스폰되거나 빙의 대상이 바뀌거나 EndPlay가 실행되면, 저장한 참조를 다시 연결하거나 비웁니다. 계산 결과도 같은 원리로, 루프 안에서 매번 같은 값을 계산하지 말고 루프 밖에서 한 번 계산해 변수에 넣은 뒤 루프 안에서는 그 변수를 씁니다.


루프와 배열 다루기

For Each Loop나 While Loop는 많은 요소를 처리할 때 성능을 떨어뜨리는 주범이 될 수 있습니다. 먼저 루프를 돌기 전에 처리할 필요가 없는 데이터를 걸러 냅니다. 원하는 결과를 찾으면 더 돌 필요가 없으므로, For Each Loop with Break의 Break 실행 입력으로 루프를 멈춥니다.

자료구조는 접근 방식에 맞게 고릅니다. 전체를 차례로 도는 일이 중심이라면 Array가 단순하고, 특정 키로 자주 찾는다면 Map이 알맞습니다. 어느 쪽이 무조건 빠른지가 아니라 데이터를 어떻게 읽는지가 기준입니다.

아주 큰 배열을 매 프레임 전부 도는 것은 피합니다. 대신 다음에 시작할 위치인 커서와 한 번에 처리할 최대 개수를 저장해 두고, 타이머 같은 호출마다 그 개수만큼만 처리합니다. 루프 전체를 타이머 안으로 옮기기만 해서는 나누어 처리되지 않습니다. 커서를 저장하고 이어서 처리하는 로직이 있어야 비로소 작업이 여러 번에 나뉩니다.


콜리전과 트레이스 줄이기

콜리전과 트레이스는 물리와 환경 상호작용의 중심이지만, 설정이 맞지 않으면 성능 문제를 만듭니다. 먼저 콜리전 응답을 필요한 만큼만 둡니다. BlockAll 대신 Custom 설정으로 필요한 Object Type에만 응답하게 하고, 무시해도 되는 것은 확실히 Ignore로 둡니다. 스태틱 메시에는 Use Complex Collision As Simple을 피하고 상자, 구, 캡슐처럼 단순한 콜리전 형상을 씁니다.

트레이스는 거리와 빈도를 줄입니다. Line Trace나 도형 트레이스의 End를 실제로 필요한 거리까지만 두고, 매 프레임 쏘는 대신 공격 버튼을 누를 때나 AI가 무언가를 알아챘을 때처럼 특정 이벤트에서만 실행합니다. 결과가 몇 개 필요한지도 먼저 정합니다. Multi ... By Channel은 첫 Block Hit까지의 결과를 돌려주므로, 첫 Block Hit 하나만 필요하다면 단일 By Channel 트레이스로 충분합니다. 두 방식의 실제 비용 차이는 같은 조건에서 측정해 확인합니다.


타이머와 Delay로 Tick 대신하기

주기적으로 실행하거나 나중에 실행할 로직은 Tick 대신 타이머로 만들 수 있습니다. Set Timer by Event나 Set Timer by Function Name은 일정 간격으로 반복하는 로직과 한 번만 늦게 실행하는 로직에 모두 씁니다. 타이머의 효과는 호출 빈도를 줄이는 데서 나오므로, 같은 빈도로 같은 일을 시킨다면 Tick을 타이머로 바꾼 것만으로 비용이 사라지지는 않습니다. Delay는 노드 몇 개를 일정 시간 뒤에 실행할 때 쓰는 간단한 일회성 지연입니다.


컴포넌트와 액터를 아껴 쓰기

파티클, 오디오, 물리처럼 자원을 쓰는 컴포넌트는 필요할 때만 켜고 다 쓰면 끕니다. 파티클이나 오디오는 Set Active로, 물리는 Set Simulate Physics로, 컴포넌트의 Tick은 Set Component Tick Enabled로 조절합니다.

총알, 이펙트, 파편처럼 게임 중에 자주 생기고 사라지는 액터는 Spawn Actor와 Destroy Actor를 반복하는 대신 오브젝트 풀링(object pooling)을 쓰면 생성과 정리의 비용을 줄일 수 있습니다. 액터를 미리 만들어 두고, 다 쓴 액터는 파괴하지 않고 풀로 돌려보냈다가 다시 씁니다. 돌려보낼 때는 타이머, 참조, 충돌, 속도, 효과를 정리하고, 다시 쓸 때 필요한 상태를 새로 설정해야 이전에 쓰던 상태가 남지 않습니다. 풀에 보관하는 메모리와 상태를 초기화하는 비용도 있으므로, 실제 생성 패턴에서 이득이 있는지 확인합니다.

클래스도 필요한 만큼 고릅니다. 사람 형태로 걷는 움직임이 아니라면 Character 대신 Pawn을 쓰고, CharacterMovementComponent 대신 FloatingPawnMovement나 ProjectileMovementComponent 같은 가벼운 이동 컴포넌트를 씁니다. 스태틱 메시 하나만 있으면 되는 물체라면 Static Mesh Actor가 단순한 구성이지만, 클래스 이름만으로 더 빠르다고 단정하지 말고 컴포넌트, Tick, 로직의 비용을 비교합니다.


검색, UI, 로딩 의존성 점검하기

Get All Actors Of Class를 반복해서 호출하지 않습니다. 액터가 많은 월드에서 Tick이나 짧은 주기로 전체를 검색하면 비용이 커집니다. BeginPlay에서 한 번 모으거나, Overlap이나 이벤트로 대상이 생길 때 등록하거나, 서브시스템이 목록을 관리하는 것처럼 참조를 미리 모아 두는 구조를 먼저 고려합니다.

UMG의 바인딩도 줄입니다. 위젯 속성에 직접 연결한 바인딩은 매 프레임 값을 확인할 수 있으므로, 자주 바뀌지 않는 값은 값이 바뀔 때만 갱신하는 이벤트 방식, MVVM의 FieldNotify, Invalidation Box나 Retainer Box 같은 방식으로 갱신 시점을 좁힙니다.

하드 참조(hard reference)는 로딩 끊김(hitch)과 메모리를 늘릴 수 있습니다. 블루프린트가 큰 에셋이나 무거운 클래스를 직접 참조하면, 그 블루프린트를 불러올 때 참조한 것까지 함께 불러오기 때문입니다. 필요할 때만 불러와도 되는 에셋은 소프트 오브젝트나 소프트 클래스 참조와 비동기 로드(Async Load)를 쓰고, 로딩 화면이나 사전 로드를 함께 설계합니다.

예전 자료에서 블루프린트 네이티브화(Blueprint Nativization)라는 기능을 볼 수 있습니다. 블루프린트를 C++ 코드로 자동 변환하던 기능으로, 언리얼 엔진 4.27까지 있었지만 UE5에서 제거되었습니다. 그래서 이 교재의 기준인 5.8에서는 쓸 수 없으며, 구조 개선, 필요한 부분의 C++ 이관, 측정 결과를 바탕으로 한 최적화로 대신합니다.

디버그 기능도 확인합니다. 디버그 드로잉과 Print String은 개발 빌드에서도 자주 호출되면 비용이 생깁니다. Development와 Shipping 빌드에 각각 실제로 포함되는지 확인하고, 필요한 진단만 빌드 설정과 실행 조건으로 제한합니다.


호출 수를 줄이는 것과 프레임이 빨라지는 것

최적화의 효과를 계산할 때는 줄어든 작업 수와 실제 프레임 시간을 구분해야 합니다. 활성 AI 20개가 초당 60번 각각 트레이스를 한 번씩 호출한다면, 트레이스는 20 × 60 = 1200회입니다. 이 판단이 0.1초 간격이면 충분해서 각 AI가 초당 10번 호출하도록 바꾸면 20 × 10 = 200회로 줄어듭니다. 호출 수는 6분의 1이 되었지만, 모든 트레이스의 비용이 같다거나 FPS가 여섯 배가 된다는 뜻은 아닙니다. 같은 장면, 같은 AI 수, 같은 조회 조건에서 필요한 반응 속도가 유지되는지와, 게임 스레드, 렌더링 스레드, GPU 시간이 실제로 어떻게 바뀌었는지를 다시 측정해야 합니다.

배열을 나누어 처리하는 작업도 계산해 보겠습니다. 요소가 103개인 배열을 한 번 호출에 최대 20개씩 처리한다면, 처리한 뒤 다음 시작 위치를 커서에 저장해야 합니다. 그러면 다섯 번의 호출로 100개를 처리하고, 여섯 번째 호출에서 남은 3개를 처리합니다. 타이머 콜백마다 배열 전체를 돈다면 여전히 호출마다 103개를 처리하는 셈이므로, 타이머로 옮겼다는 사실만으로 이렇게 나뉘지는 않습니다. 이 계산은 처리하는 동안 대상 배열이 바뀌지 않는다는 조건이며, 중간에 배열이 바뀌면 커서의 기준도 달라집니다.


측정 도구로 병목 찾기

최적화는 어디가 문제인지 아는 데서 시작하고, 언리얼 엔진은 이를 위한 측정 도구를 제공합니다.

stat unit과 stat fps는 프레임 레이트와 게임 스레드, 렌더링 스레드, GPU 시간을 보여 주는 가장 기본적인 명령입니다. 게임 스레드가 느리다면 stat game, stat script, stat tickgroups로 게임 스레드의 작업, 스크립트 실행, Tick 그룹의 부담을 좁혀 보고, dumpticks로 Tick을 가진 액터와 컴포넌트의 목록을 확인합니다.

자세한 분석에는 언리얼 인사이트(Unreal Insights)를 씁니다. 에디터 아래쪽 상태 표시줄의 Trace 위젯에서 캡처를 시작할 수 있고, Trace 채널을 골라 CPU, 메모리 같은 이벤트를 기록한 뒤 분석합니다. 측정하려는 항목의 채널이 실제 캡처에서 켜져 있는지 확인해야 원하는 정보가 기록됩니다. 인사이트를 열고 기록한 캡처의 타이밍 뷰를 읽는 방법은 언리얼 C++ 교재의 프로파일링 도구 사용법에서 자세히 다룹니다. 예전 자료에 나오는 stat startfile과 stat stopfile은 통계 파일을 저장해 세션 프런트엔드(Session Frontend)의 Profiler에서 분석하던 방식인데, 그 Profiler는 UE5에서 언리얼 인사이트로 대체되었으므로 5.8에서는 인사이트를 기본 도구로 씁니다.

이렇게 병목이 생기는 곳을 찾은 뒤에야 그 부분의 블루프린트 로직에 앞의 기법을 적용합니다. 그리고 최적화는 한 번으로 끝나는 일이 아니라서, 개발하는 동안 주기적으로 측정하고, 바꾼 전후를 같은 조건에서 비교하는 습관을 들이는 것이 중요합니다.


정리

  • 블루프린트의 추가 부담은 노드 호출과 실행 흐름에 있으며, 노드가 부르는 물리, 렌더링, 엔진 기능의 비용은 C++로 옮겨도 그대로입니다.
  • 먼저 stat unit으로 게임 스레드, 렌더링 스레드, GPU 가운데 병목을 나눈 뒤, 게임 스레드가 문제일 때 Tick과 호출 빈도를 줄입니다.
  • 꺼진 Tick은 스스로 다시 켤 수 없으므로 외부의 재활성화 경로를 마련하고, 주기를 늘리려면 Set Actor Tick Interval을 씁니다.
  • 참조와 계산 결과는 캐싱하되 유효성을 확인하고, 큰 배열은 커서와 배치 크기를 저장해 나누어 처리합니다.
  • Get All Actors Of Class의 반복 호출, 매 프레임의 UMG 바인딩, 불필요한 하드 참조를 줄입니다.
  • 블루프린트 네이티브화는 UE5에서 제거되었고, 자세한 분석은 언리얼 인사이트로 합니다.

연습 문제

1. 줄어든 트레이스와 GPU 병목

활성 오브젝트 30개가 갱신마다 트레이스를 세 번씩 실행합니다. 초당 60회 갱신하던 것을 0.2초 간격, 곧 초당 5회 갱신으로 바꿨을 때, 바꾸기 전과 후의 초당 트레이스 수를 계산해 보세요. GPU가 프레임에서 가장 오래 걸리는 장면에서도, 이 호출 감소만으로 FPS가 좋아진다고 장담할 수 있을까요?

해설 보기

바꾸기 전은 30 × 3 × 60 = 5400회, 바꾼 뒤는 30 × 3 × 5 = 450회로, 초당 트레이스 수가 12분의 1이 됩니다.

하지만 트레이스 하나하나의 비용과 프레임 전체의 병목은 아직 측정하지 않았습니다. GPU가 프레임을 제한하는 장면이라면 게임 스레드의 작업을 줄여도 FPS는 거의 바뀌지 않을 수 있습니다. 바꾼 조회 간격 때문에 게임플레이의 반응이 늦어지지 않았는지와, 같은 장면에서 각 구간의 시간을 함께 확인해야 실제로 얼마나 개선되었는지 판단할 수 있습니다. 이 문제는 주어진 초당 호출 수를 계산한 것이며, 실제 타이머와 프레임의 실행 시점은 실행 환경에 따라 측정해야 합니다.

2. 나누어 처리하는 배열

요소가 250개인 배열을 타이머 콜백 한 번에 최대 40개씩 처리하도록 커서를 두었습니다. 배열은 처리하는 동안 바뀌지 않습니다. 모두 처리하려면 콜백이 몇 번 실행되어야 하고, 마지막 콜백은 몇 개를 처리할까요? 커서를 저장하지 않고 콜백마다 처음부터 40개를 처리하면 어떻게 될까요?

해설 보기

40개씩 여섯 번 처리하면 240개이고, 남은 10개를 일곱 번째 콜백에서 처리하므로 콜백은 일곱 번 실행되어야 합니다. 커서는 0, 40, 80, ..., 240으로 바뀌며, 마지막 콜백은 커서 240에서 시작해 배열 끝까지 10개만 처리하고 끝납니다.

커서를 저장하지 않으면 매번 처음부터 시작하므로, 콜백이 몇 번 실행되든 앞의 40개만 반복해서 처리하고 나머지 210개는 영영 처리되지 않습니다. 나누어 처리하려면 다음 시작 위치를 저장하는 로직이 꼭 필요합니다.

3. 다시 켜지지 않는 Tick

어떤 개발자가 적 AI가 멈춰 있을 때 Tick에서 Set Actor Tick Enabled를 false로 바꾸고, 같은 Tick 안에 "플레이어가 가까워지면 Tick을 다시 켠다"는 검사를 넣었습니다. 플레이어가 다가가도 적이 계속 멈춰 있습니다. 왜 그럴까요? 어떻게 고쳐야 할까요?

해설 보기

Tick을 끄면 그 Tick 이벤트 자체가 더 이상 실행되지 않으므로, 그 안에 넣은 "다시 켜기" 검사도 실행되지 않습니다. 꺼진 Tick은 자신을 다시 켤 수 없습니다.

Tick 밖에서 다시 켜는 경로를 만들어야 합니다. 예를 들어 적 주위에 감지용 Sphere Collision을 두고 On Component Begin Overlap에서 플레이어가 들어오면 Set Actor Tick Enabled를 true로 바꾸거나, AI 관리자가 타이머로 거리를 확인해 다시 켜는 식입니다. 단순히 주기를 늦추는 것으로 충분하다면 Tick을 끄는 대신 Set Actor Tick Interval로 간격을 늘리는 방법도 있습니다.

Trace 캡처를 시작하고 분석하는 방법은 언리얼 엔진 공식 문서의 Unreal Insights에서 확인할 수 있습니다.

이것으로 10장과 언리얼 블루프린트 트랙을 마칩니다. 10장에서는 물리 시뮬레이션과 콜리전으로 물체가 부딪히고 반응하게 하고, 트레이스로 충돌 전에 주변을 탐색했으며, 낮과 밤, 날씨를 바꾸는 환경 시스템과 플레이어의 행동에 반응하는 상호작용을 만든 뒤, 그 모든 그래프의 비용을 측정하고 줄이는 방법까지 살펴봤습니다. 트랙 전체로 보면 노드와 이벤트에서 시작해 변수와 함수, 클래스와 인터페이스, 블루프린트 사이의 통신과 데이터 관리, UI와 애니메이션, AI, 게임플레이 프레임워크와 리플리케이션, 그리고 물리와 환경까지 블루프린트로 게임을 만드는 흐름을 하나씩 이어 왔습니다. 처음부터 다시 훑어보고 싶다면 언리얼 블루프린트 교재의 목차로 돌아가세요.