TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역] 언리얼엔진5의 GameplayGraph

jplee 2026. 5. 19. 00:18

저자: uabqi

UE5에는 기본으로 GameplayGraph 플러그인이 포함되어 있습니다. Gameplay 용도로 쓰기 위한 그래프 플러그인으로, 그래프(무향 그래프) 관련 데이터 구조, 검색 알고리즘(DFS/BFS), 이벤트 알림, 직렬화 등의 기능을 제공합니다. 그중에서도 꽤 특징적인 부분은 Island라는 구조를 제공해 그래프 안의 섬, 즉 서로 연결된 Vertex 집합을 직접적으로 표현한다는 점입니다. 최근에 사용해 보니 API가 꽤 쓰기 좋아서 작업량을 많이 줄일 수 있었습니다. 다만 왜인지 여러 버전 동안 계속 Experimental 상태에 머물러 있습니다. 5.3 때도 있었던 것 같은데, 5.6에서도 아직 정식으로 전환되지는 않았습니다. 코드 자체는 매우 단순하고 소박한 편이라, 프로젝트에서 바로 사용해도 충분하다고 생각합니다. 설령 버그가 있더라도 쉽게 수정할 수 있는 수준입니다. 현재 Zhihu에는 아직 이 내용을 다룬 글이 없는 것 같아서, 기회가 된 김에 한 편 가볍게 써 보며 이 플러그인을 구체적으로 설명해 보겠습니다.

메모: GameplayGraph는 “게임플레이 로직에서 쓰기 좋은 연결 관계 관리 도구”라고 보면 됩니다. 여기서 Graph는 점과 선으로 이루어진 구조이고, Vertex는 점, Edge는 선에 해당합니다. DFS/BFS는 그래프 안을 탐색하는 대표적인 방법입니다. Experimental은 아직 Epic이 정식 기능으로 보증하지 않는 실험적 플러그인이라는 뜻이므로, 실제 프로젝트에 넣을 때는 엔진 버전 변경 시 동작을 다시 확인하는 편이 좋습니다.

무엇에 사용할 수 있을까?

예를 들어 Gameplay 레벨에서 레벨의 기반 관리 프레임워크로 사용할 수 있습니다. 전체 레벨은 하나의 Graph이고, 각 POI는 하나의 Island이며, 각 POI 안의 AI는 Vertex라고 볼 수 있습니다. 어떤 AI를 공격했을 때 같은 POI 안의 다른 AI에게는 영향을 주어야 하지만, 다른 POI의 AI에게는 영향을 주면 안 되는 상황이 있다면 GameplayGraph를 쓰기에 아주 적합한 사례입니다.

메모: POI는 Point of Interest의 약자로, 게임 월드 안에서 의미 있는 장소나 구역을 가리킬 때 자주 씁니다. 이 글에서는 “서로 영향을 주고받는 하나의 구역” 정도로 이해하면 됩니다. Island는 그 구역처럼 서로 연결된 Vertex 묶음입니다.

그 외에도 Gameplay 대화 트리의 기반 구현 프레임워크로 사용할 수 있습니다. 하나의 대화에 여러 선택지가 있다면, 곧 여러 개의 인접 Vertex가 있는 구조입니다. 어떤 선택지를 고르면 다시 여러 개의 새로운 선택지가 이어지는데, 그 밑바탕 데이터는 본질적으로 그래프입니다. 이런 경우 GameplayGraph를 직접 사용하면 대화 프레임워크를 새로 개발하는 비용을 줄일 수 있습니다.

메모: 대화 트리는 보통 “선택지 → 다음 대사 → 다시 선택지”처럼 가지가 갈라지는 구조입니다. 초급자는 이를 단순한 트리로 생각하기 쉽지만, 실제 게임에서는 이전 선택지로 돌아가거나 여러 경로가 같은 노드로 합쳐질 수 있어 그래프 구조가 더 잘 맞는 경우가 많습니다.

퀘스트 시스템의 기반 프레임워크로도 사용할 수 있습니다. 퀘스트 시스템 전체는 하나의 Graph이고, 각 퀘스트 라인은 하나의 Island이며, 각각의 퀘스트는 Vertex가 됩니다.

GameplayGraph에는 다양한 알림 기능도 있으므로, Gameplay 상태 머신처럼 사용하는 것도 가능합니다.

메모: 상태 머신은 “현재 상태”와 “상태 전환 규칙”으로 동작을 관리하는 방식입니다. 예를 들어 AI가 대기, 수색, 전투 상태를 오가는 구조가 상태 머신입니다. GameplayGraph의 이벤트 알림을 이용하면 그래프 변화에 맞춰 이런 상태 전환을 처리할 수 있습니다.

또한 직렬화 기능도 제공하기 때문에, 게임 저장 시스템의 기반 프레임워크로도 사용할 수 있습니다.

메모: 직렬화는 메모리 안의 객체나 구조를 파일·네트워크·세이브 데이터로 저장할 수 있는 형태로 바꾸는 과정입니다. 저장된 데이터를 다시 읽어 원래 구조로 되돌리는 과정은 역직렬화라고 부릅니다.

물론 위에 든 예시는 제가 몇 가지 나열한 것일 뿐입니다. Graph는 범용적인 구조이기 때문에, 활용할 수 있는 곳은 분명히 훨씬 많습니다.

기본 구조

이 그래프 데이터 구조는 주로 무향 그래프 데이터 구조로, 연결 관계를 설명하는 Gameplay 상황에 적합합니다.

메모: 무향 그래프는 선에 방향이 없는 그래프입니다. A와 B가 연결되어 있다면 A에서 B로도 갈 수 있고, B에서 A로도 갈 수 있습니다. 반대로 대화 선택지처럼 한 방향 진행만 중요한 경우에는 방향 그래프가 더 직관적일 수 있습니다.

UGraph

그래프의 주요 구현 클래스입니다. Vertex와 Island를 관리하며, 내부적으로는 VertexHandle에서 Vertex로, IslandHandle에서 Island로 이어지는 Map을 보관합니다. U 클래스이므로 구체적인 Gameplay 비즈니스 로직에서는 UGraph를 상속해, 자신만의 Graph 특화 데이터를 저장해야 합니다.

메모: 언리얼에서 이름이 U로 시작하는 클래스는 보통 UObject 계열 클래스를 의미합니다. UObject는 언리얼의 리플렉션, 가비지 컬렉션, 에디터 연동 같은 기능의 기반입니다. 자세한 내용은 Epic 문서의 UObjectObjects in Unreal Engine을 참고하면 좋습니다. Map은 키로 값을 찾는 자료구조이며, 여기서는 Handle을 키처럼 사용해 실제 Vertex나 Island를 찾습니다.

UGraphVertex

그래프의 정점 클래스입니다. 노드라고 불러도 됩니다. 다른 노드와의 연결 관계를 저장하며, 내부적으로는 이웃 정점들의 Handle 데이터를 보관합니다. 이 역시 U 클래스이며, 그래프 위의 정점 정보를 저장합니다. 구체적인 Gameplay 비즈니스 로직에서는 이를 상속해 자신만의 정점 관련 정보를 저장해야 합니다.

메모: Vertex와 Node는 거의 같은 의미로 쓰이는 경우가 많습니다. 그래프 이론에서는 Vertex라는 말을 더 자주 쓰고, 게임 로직이나 에디터 UI에서는 Node라는 말을 더 자주 접할 수 있습니다. 이 글에서는 “그래프 안의 하나의 점”이라고 이해하면 충분합니다.

UGraphIsland

그래프의 연결 요소 클래스입니다. 같은 연결 요소 안의 노드들을 관리하며, 내부에는 현재 Island에 속한 모든 정점이 들어 있습니다. 다시 말해 내부의 모든 정점은 서로 연결 관계를 가지고 있습니다.

메모: 연결 요소는 서로 도달 가능한 노드들의 묶음입니다. 예를 들어 A-B-C는 서로 이어져 있고, D-E는 따로 이어져 있다면 그래프 안에는 두 개의 연결 요소가 있습니다. GameplayGraph는 이 연결 요소를 Island라는 이름으로 직접 다룹니다.

UGraphElement

Vertex와 Island의 기반 클래스이며, Graph에 대한 약한 참조를 저장합니다.

메모: 약한 참조는 객체를 “가리키기는 하지만 살려 두지는 않는” 참조입니다. 대상 객체가 사라졌을 때 안전하게 무효화될 수 있어 수명 주기 문제를 줄이는 데 도움이 됩니다. Epic 문서의 Weak Pointers in Unreal Engine을 함께 보면 이해하기 쉽습니다.

FGraphHandle, FVertexHandle, FIslandHandle

관련 요소들의 Handle입니다. Gameplay 레벨에서 Vertex나 Island가 꽤 복잡한 데이터 구조가 될 수 있기 때문에, 외부에서 위의 U 클래스를 직접 들고 있으면 수명 주기 문제가 생길 수 있습니다. 예를 들어 Graph는 파괴되었는데 Vertex가 외부 어딘가에 계속 잡혀 있는 식입니다. 또한 내부적으로도 클래스들이 서로 참조하고 있으므로, 포인터를 직접 들고 관리하는 방식은 그다지 좋지 않을 수 있습니다. 따라서 Handle을 보유하는 방식으로 프레임워크 내부의 관련 객체를 참조할 수 있습니다. GraphHandle은 VertexHandle과 IslandHandle로 나뉘며, 멤버 함수를 통해 Vertex 또는 Island 객체를 직접 얻을 수 있습니다.

메모: Handle은 실제 객체를 직접 들고 있는 대신, 그 객체를 찾아갈 수 있는 “안전한 표식”에 가깝습니다. 게임 엔진에서는 객체가 생성·삭제·이동되는 일이 많기 때문에, 생 포인터를 오래 들고 있는 것보다 Handle을 통해 접근하는 방식이 더 안전한 경우가 많습니다.

Island 조작의 구체적인 구현

Island라는 구조가 도입되었기 때문에, 그래프에 노드를 삽입하거나 삭제할 때 Island 데이터도 추가로 갱신해야 합니다. 여기에는 Island 자체의 생성과 삭제, 여러 Island의 병합, 분할 같은 작업이 포함됩니다. 내부 코드는 다소 번잡하게 작성되어 있지만, 성능은 비교적 좋은 편입니다. 특히 Graph에 있는 MergeOrCreateIslands와 RemoveOrSplitIsland 이 두 함수가 그렇습니다.

메모: 그래프에서 노드나 연결을 바꾸면 “어떤 노드들이 같은 묶음에 속하는지”도 달라질 수 있습니다. 그래서 Island를 유지하려면 단순히 Edge만 추가·삭제하는 것이 아니라, 연결 요소를 다시 합치거나 쪼개는 관리 코드가 필요합니다.

MergeOrCreateIslands의 주요 기능은 Island를 병합하거나 새로 생성하는 것입니다. 인자는 연결해야 하는 Vertex 쌍입니다. 각 정점 쌍을 순회할 때, 두 Vertex가 속한 Island가 서로 다르면 두 Island 안의 정점 수를 비교합니다. 그리고 Vertex 수가 적은 Island의 모든 Vertex를 Vertex 수가 많은 Island로 옮깁니다. 배치 작업을 지원하고, 순회 도중 삭제로 인한 문제를 피하기 위해, 순회 중에는 중간 컨테이너에 기록만 해 둡니다. 순회가 끝난 뒤 중간 컨테이너를 바탕으로 한꺼번에 처리하여 Island를 병합하거나 새로 만들고, 이후 비어 있는 Island를 삭제합니다.

메모: Vertex 수가 적은 Island를 큰 Island로 옮기는 방식은 흔히 “작은 쪽을 큰 쪽에 합치는” 최적화로 볼 수 있습니다. 이렇게 하면 많은 Vertex를 반복해서 옮기는 상황을 줄일 수 있습니다. 순회 중 바로 삭제하지 않고 임시 컨테이너에 기록해 두는 것도, 반복문 중 데이터 구조가 바뀌어 생기는 버그를 피하기 위한 흔한 구현 방식입니다.

연결성 검사와 검색

연결성 검사와 검색을 위한 별도의 API 함수도 제공합니다. 사실 본질적으로는 깊이 우선 탐색과 너비 우선 탐색입니다. 전체 구현은 평범한 편입니다. DFS는 TArray를 컨테이너로 사용하고, BFS는 TQueue를 컨테이너로 사용합니다. 개인적으로는 TQueue를 사용할 필요가 조금 없어 보입니다. UE의 Queue는 주로 멀티스레드 동기화에 쓰이는 편이기 때문입니다. 엔진 공식 쪽에서 TArray로 통일해 주면 좋겠다고 생각합니다. BFS의 경우에도 큐 헤드 인덱스 하나만 추가로 기록하면 충분히 요구 사항을 만족할 수 있습니다.

메모: DFS는 한 방향으로 깊게 들어가며 탐색하는 방식이고, BFS는 가까운 노드부터 층별로 탐색하는 방식입니다. TArray는 언리얼의 동적 배열 컨테이너이고, TQueue는 큐 컨테이너입니다. 공식 API 문서는 TArrayTQueue를 참고하면 됩니다.

이벤트 알림

Vertex 제거와 Vertex의 Island 설정 알림

Island 연결 수정, 제거, 내부 Vertex 추가와 제거 알림

Graph의 Vertex와 Island 생성 알림

Graph의 Edge 생성과 제거 알림

각 Vertex/Island의 추가, 삭제, 수정, 그리고 Island 내부 Vertex의 추가와 제거, 연결성 변경, Edge의 추가, 삭제, 수정에 대해 모두 대응되는 delegate를 제공합니다. 비즈니스 상위 레벨의 다른 모듈들은 그래프에 발생한 변화를 매우 쉽게 감지할 수 있습니다. 저는 이 점이야말로 Gameplay에서 사용할 수 있게 해 주는 가장 핵심적인 부분이라고 생각합니다.

메모: delegate는 어떤 일이 발생했을 때 등록된 함수를 호출해 주는 언리얼의 콜백·이벤트 시스템입니다. 예를 들어 “Vertex가 삭제됨”이라는 이벤트가 발생하면, 여기에 연결된 시스템이 자동으로 반응할 수 있습니다. 자세한 내용은 Epic 문서의 Delegates and Lambda Functions를 참고하면 좋습니다.

직렬화

GameplayGraph는 직렬화와 증분 직렬화 기능을 제공합니다. 내부에서는 이를 통합해 자체적인 FSerializableGraph, FSerializedIslandData, FSerializedEdgeData 등으로 변환합니다. Gameplay 안에서 비교적 복잡한 구조를 저장하는 데 매우 적합합니다. 코드도 비교적 단순합니다.

메모: 증분 직렬화는 전체 데이터를 매번 저장하는 대신, 바뀐 부분을 중심으로 저장하는 접근에 가깝습니다. 그래프처럼 연결 관계가 많은 구조에서는 저장·로드 비용을 줄이는 데 도움이 될 수 있습니다. GameplayGraph의 관련 타입은 Epic API 문서의 FSerializableGraphTDefaultGraphIncrementalSerialization에서 확인할 수 있습니다.

프레임워크의 장단점

전체 그래프 구현의 밑바탕은 본질적으로 인접 리스트 저장 방식입니다. 따라서 인접 리스트가 갖는 장점과 시간·공간 복잡도 특성을 이 프레임워크도 그대로 가지고 있습니다.

메모: 인접 리스트는 각 Vertex마다 “나와 연결된 이웃 목록”을 저장하는 방식입니다. 연결이 드문드문 있는 그래프에서는 메모리를 비교적 적게 쓰고, 이웃을 순회하기 쉽습니다. 반대로 모든 노드끼리 거의 다 연결된 그래프라면 다른 표현 방식과 비교해 장단점을 따져 볼 필요가 있습니다.

Graph, Vertex, Island가 기본적으로 모두 UObject이기 때문에, 일반적으로는 전역 싱글턴 형태의 Gameplay 시스템에 쓰는 것이 비교적 적합합니다. 예를 들어 퀘스트 시스템이나 대화 시스템 같은 경우입니다. 반면 규모가 매우 크고 업데이트가 매우 빈번한 상황에는 그다지 적합하지 않습니다. 예를 들어 대규모 레벨 파괴 같은 용도로 쓰기에는 맞지 않을 수 있습니다. 다만 코드가 매우 단순하고, U 클래스 객체들은 모두 Graph 내부에만 저장되며, 다른 곳에서는 Handle로 실제 클래스를 참조하는 방식이므로, 대규모 사용이 필요하다면 F 클래스 버전으로 개조하는 것도 어렵지 않습니다.

메모: UObject 기반 구조는 언리얼 시스템과 잘 맞지만, 객체 수가 매우 많고 매 프레임 자주 바뀌는 데이터에는 부담이 될 수 있습니다. 이럴 때는 더 가벼운 일반 C++ 구조체나 F 접두사의 값 타입 구조로 바꾸는 선택지를 검토할 수 있습니다. 전역 싱글턴은 프로젝트 전체에서 하나만 존재하는 관리자를 의미하지만, 남용하면 의존성이 복잡해질 수 있습니다.

FGraphUniqueIndex는 VertexHandle과 IslandHandle의 멤버 변수이며, Vertex와 Island를 고유하게 식별할 수 있습니다. 각 FGraphUniqueIndex에는 고유한 GUID가 있고, 그 외에도 bIsTemporary라는 bool 값이 하나 더 있습니다. 개인적으로는 bIsTemporary 변수가 이 구조 내부에 있어서는 안 된다고 생각합니다. 이 bool은 위에서 말한 Island 병합과 삭제 시 임시로 생성된 Island를 표시하는 데만 사용되기 때문입니다. 사실 임시 컨테이너 하나를 유지하며 기록하는 것만으로도 같은 목적을 달성할 수 있습니다. GUID 자체가 이미 16바이트를 차지하고, 여기에 bool이 추가되면서 바이트 정렬까지 발생해 전체 FGraphUniqueIndex는 24바이트를 차지하게 됩니다. Handle 내부에는 Graph에 대한 Weak 참조도 있으므로, 전체적으로는 32바이트를 차지합니다. 이 부분은 이 프레임워크 구현에서 그다지 좋지 않은 지점이라고 할 수 있겠습니다.

메모: GUID는 전역적으로 고유한 ID를 만들기 위한 값입니다. byte alignment는 CPU가 데이터를 효율적으로 읽기 위해 메모리 배치를 맞추는 규칙입니다. bool 하나는 논리적으로 작아 보이지만, 구조체 안에서는 정렬 규칙 때문에 실제 크기가 더 커질 수 있습니다. 이런 부분은 대규모 데이터 구조를 설계할 때 성능과 메모리 사용량에 영향을 줍니다.


원문
(72 封私信 / 30 条消息) UE5的GameplayGraph - 知乎