Unreal Engine - Replication Graph

- 리플리케이션 그래프는 기본 드라이버가 매 프레임 모든 액터 × 모든 연결에 대해 하는 relevancy 검토를, 프레임을 넘어 유지되는 노드 리스트로 바꾼다. 어느 연결에 무엇을 보낼지가 같다면 송신량도 같고 보내는 액터 하나당 채널 밖 비용은 기본 드라이버보다 0.5 µs쯤 더 든다. 송신량은 frequency bucket처럼 보내는 횟수를 낮추는 도구를 썼을 때만 줄어든다.
- 액터 5,000개, 클라이언트 2개에서 프레임당
ServerReplicateActors는 맵 전체에 흩어진 조건에서 기본 2.54 ms, 리플리케이션 그래프 0.35 ms이고 모두 한곳에 몰린 조건에서 8.09 ms와 4.48 ms다. 밀집 조건의 차이는 frequency bucket이 dynamic 액터를 세 프레임에 한 번만 후보로 올려서 생겼다. 보내는 횟수를 맞추면 리플리케이션 그래프가 22% 느리다. 기본 드라이버도NetUpdateFrequency를 10 Hz로 낮추면 3.06 ms로 떨어진다. - 액터 수와 연결 수에 기본 드라이버는 선형(액터당 0.46 µs, 연결당 0.78 ms)이고 리플리케이션 그래프의 연결별 비용은 viewer 근처 액터 수를 따른다.
- 기본 드라이버에서 잠든 액터는 모든 연결에서 잠든 뒤에야 consider list에서 빠진다. 넓은 맵에 흩어진 잠든 소품은 매 프레임 검토되고 플레이어마다 자기 주변에만 액터가 있는 조건에서 밀집 조건에 가까운 6.47 ms가 나온다.
- 설정값 중 측정에서 효과가 컸던 것은 버킷 수와
NetUpdateFrequency뿐이다.DistancePriorityScale0은 거리 컬링을 끄고ActorChannelFrameTimeout0은 채널을 닫지 않는다. 대역폭이 포화되면StarvationPriorityScale이 공평성을 정한다. 0이면 가까운 액터만 갱신되고 접속 후 120초가 지나도 액터의 62%만 받는다.NetUpdateFrequency는 서버 프레임 레이트보다 낮을 때만 뜻이 있다. - 엔진에는 액터가 움직이는지 판별하는 코드가 없다.
UBasicReplicationGraph는 모든 액터를 dormancy 경로로 넣는다. 그래도 OpenWorld 그래프와 같은 자릿수(Uniform 0.40 ms, Cluster 3.44 ms)가 나온다. Team Node와 owner 변경이 필요 없으면 거기서 멈춰도 된다. - 5.8.3에서 재현한 것: 잠재운 직후의 reliable multicast가 지연이 있을 때 리플리케이션 그래프에서만 유실된다. static으로 넣은 액터는 옮겨도 옛 Cell에 남는다.
bNetTemporary발사체는 ensure가 나고 매 버킷 차례마다 채널을 다시 연다. 끊긴 연결의 dormancy 사본은 기본 설정에서 지워지지 않는다. 멀어진 클라이언트에 있는 잠든 액터를 파괴하면 그 클라이언트가 돌아올 때까지 남는다. stat game에서 consider와 prioritize 시간이ReplicateActor시간보다 크면 리플리케이션 그래프가 그 몫을 줄인다. Iris와는 양자택일이다. Epic은 deprecate 계획이 없다고 하지만 릴리스 노트에 5.6 이후 항목이 없다.

Night of the Dead는 밤마다 좀비가 몰려오는 오픈월드 생존 게임이고 Dedicated Server 멀티플레이를 지원한다. 2021년부터 2024년 6월까지 멀티플레이 동기화를 맡으면서 리플리케이션 그래프를 UE4에서 실험적으로 도입했다. UE5로 옮기면서 Lyra의 구성을 따라 위 그림처럼 개편했다. 리플리케이션 그래프는 켜면 서버가 가벼워진다고 알려져 있지만 무엇이 줄고 무엇이 그대로인지는 설명이 드물다. Iris를 공부하기 전에 리플리케이션 그래프를 최신 기준으로 더 완전히 이해하려고 5.8.3 소스와 측정으로 다시 확인했다.
테스트 프로젝트에서 기본 드라이버, UBasicReplicationGraph, 커스텀 그래프를 같은 조건으로 측정했다. 소스 경로는 UE 5.8.3 기준이다. 엔진 파일은 Engine/Source/Runtime/을, 플러그인 파일은 Engine/Plugins/Runtime/ReplicationGraph/Source/를 생략했다.
기본 드라이버가 하는 일과 리플리케이션 그래프가 바꾸는 것
UNetDriver::ServerReplicateActors는 매 네트워크 틱마다 세 단계를 거친다(Engine/Private/NetDriver.cpp).
- Consider:
FNetworkObjectList의 활성 액터를 순회해NetUpdateFrequency로 계산한 갱신 시각이 지난 액터를 모은다. dormant 액터는 별도 목록에 있어 이 순회에서 빠진다. - Prioritize: 연결마다 consider list의 액터 하나하나에
AActor::IsNetRelevantFor와GetNetPriority를 호출해 정렬한다. - Replicate: 정렬된 순서로 채널을 열어
UActorChannel::ReplicateActor를 호출한다.
두 번째 단계에 비용이 집중된다. IsNetRelevantFor와 GetNetPriority는 가상 함수라 엔진은 답이 언제 바뀌는지 알 수 없다. 그래서 매 프레임 모든 액터 × 모든 연결에 대해 다시 부른다. relevancy 검사를 통과한 액터만 세 번째 단계에 들어가므로 넓은 맵에 액터가 흩어진 게임일수록 두 번째 단계의 비용이 실제 송신 작업보다 커진다. 액터가 모두 한곳에 몰려 모든 연결에 관련 있으면 두 번째 단계는 어차피 다 통과하고 세 번째 단계가 지배한다.
리플리케이션 그래프는 UReplicationDriver를 구현한 UReplicationGraph다. 서버의 UNetDriver에 리플리케이션 드라이버가 설정돼 있으면 ServerReplicateActors는 stat·CSV 범위를 열고 메트릭을 리셋한 뒤 바로 ReplicationDriver->ServerReplicateActors로 넘긴다. consider list 구성과 우선순위 계산은 돌지 않는다. 플러그인 안에서 IsNetRelevantFor를 부르는 곳은 디버그 액터의 오버라이드뿐이다. 프로퍼티 비교와 직렬화는 그대로 UActorChannel::ReplicateActor가 한다.
리플리케이션 그래프는 프레임을 세 단계로 처리한다.
- Gather: 연결마다 전역 노드와 그 연결의 노드에
GatherActorListsForConnection을 호출해 후보 리스트를 모은다. 노드는 대부분 프레임을 넘어 유지되는 리스트를 돌려주므로 어떤 액터를 고려할지를 다시 계산하지 않는다. 기본 드라이버의 consider list 구성에 해당하는 단계다. - Prioritize: 모은 리스트의 액터마다 dormancy, 리플리케이션 주기, 거리 컬링을 적용하고 우선순위 점수를 매겨 정렬한다.
- Replicate: 정렬된 순서로
ReplicateSingleActor를 호출하고 연결이 포화되면 멈춘다.

공간 분할 노드인 Grid(UReplicationGraphNode_GridSpatialization2D)는 거리 검사를 대체하지 않는다. Grid는 맵을 정사각형 Cell로 나누고 viewer가 서 있는 Cell의 액터 리스트를 돌려줄 뿐이다. Cell에는 Net Cull Distance(액터의 NetCullDistanceSquared, 이하 Cull Distance) 범위가 Cell과 겹치는 모든 액터가 들어 있으므로 반경 밖 액터도 섞여 있다. 실제 거리 검사는 2단계에서 연결별 CullDistanceSquared로 따로 한다. 2018년 Epic 라이브스트림의 표현으로는 Grid가 broad phase, 거리 검사가 narrow phase다.
노드도 매 틱 조건을 검사하지 않는다. 노드의 역할은 조건을 한 번 평가해 리스트에 넣어 두고 바뀔 때만 옮기는 것이다. 매 틱 돌아가는 액터별 검사는 2단계에 모여 있다.
리플리케이션 그래프는 서버 CPU를 줄인다. 어떤 액터를 어느 연결에 보낼지가 같다면 보내는 바이트도 같다. 다만 리플리케이션 그래프 쪽에는 기본 드라이버에 없는 송신 측 도구가 둘 있다. dynamic 액터를 NumBuckets개 버킷에 나눠 두고 ReplicationFrameNum % NumBuckets번 버킷만 돌려주는 frequency bucket 노드와, 이동 상태를 한 번 직렬화해 모든 연결에 재사용하는 Fast Shared Path다.
Fast Shared Path는 액터 쪽에 FastSharedReplicationFunc로 등록할 unreliable multicast 함수를 써야 하고 그 프레임에는 일반 프로퍼티 리플리케이션이 생략되는 경로다. 그래서 측정에서는 켜지 않았다(Epic의 Fast Shared Path 가이드에 설정 절차가 있다). 테스트 액터에는 이동 상태가 없다. 효과도 Player Pawn 수 × 연결 수에서 나오므로 클라이언트 16개로는 보여 줄 폭이 작다.
측정: 액터 5,000개, 클라이언트 16개까지
실험 구성
측정 환경은 Push Model 포스트와 같은 테스트 프로젝트에 측정 모듈을 더한 것이다.
- 맵: 4 km × 4 km의 평평한 바닥에 PlayerStart 하나만 있는 측정 전용 레벨
- 프로세스: 에디터 바이너리를
-server로 띄운 Dedicated Server와-game -nullrhi -nosound로 띄운 헤드리스 클라이언트. 한 PC(Ryzen 9 5950X)에서 서버와 클라이언트를 서로 다른 CCD에 고정 - 지표: 서버 프레임은 약 46 ms(초당 21 프레임)이고 그중 약 41 ms가 틱 레이트 대기라 비교는 프레임당
ServerReplicateActors배타 시간으로 한다 - 대역폭: 클라이언트 rate 제한을 풀어 CPU만 잰다. 포화 조건 절에서만 30 KB/s로 제한한다
- 엔진:
++UE5+Release-5.8-CL-58210709
테스트 프로젝트의 서브시스템은 커맨드라인으로 조건을 받아 다음 순서로 돈다.
- 클라이언트 N개가 붙으면 Player Pawn을 배치하고 둘씩 같은 팀으로 묶는다. 플레이어마다 owner 전용 인벤토리 액터를 하나 스폰한다.
- 테스트 액터 5,000개를 소품(노란 상자) 60%, 루트(loot, 초록 구) 20%, AI(빨간 기둥) 15%, 탈것(파란 상자) 5%로 나눠 스폰한다. 탈것마다 dependent actor인 무기(하늘색 원뿔)를 하나 더 붙인다. 전역 액터 3개가 추가된다. 무기와 Pawn, 인벤토리까지 합치면 리플리케이션 액터는 5,265개다. 모든 테스트 액터는 리플리케이션 프로퍼티 16개를 push 기반으로 등록한다.
- 60초 warmup. 절반 지점에서 소품의 90%를
DORM_DormantAll로 보낸다. 루트는 처음부터 dormant다. - 60초 측정. CSV 프로파일러 캡처와 Insights 트레이스 리전을 연다. 깨어 있는 액터의 1%가 초마다 프로퍼티 하나씩을 바꾸고 루트의 0.1%가 초마다 flush 후 쓰기를 한다. AI는 초당 3 m로 직선 이동하며 영역 경계에서 튕긴다.

클라이언트에서 본 테스트 액터다. 색은 위의 종류 구분이고 마네킹이 Player Pawn이다.
공간 분포는 세 가지다. Cull Distance는 모든 테스트 클래스에서 150 m(NetCullDistanceSquared 기본값)다.
| 분포 | 액터 위치 | 플레이어 위치 | 상황 |
|---|---|---|---|
| Uniform | 2 km × 2 km에 균일 | 스쿼드별로 흩어짐 | 게임 중반. Cull Distance 안에 액터의 약 2% |
| Cluster | PlayerStart 중심 Cull Distance의 80% 안 | 모두 PlayerStart | 핫드롭. 모든 액터가 모든 연결에 관련 |
| NearPlayers | 플레이어마다 자기 주변 Cull Distance의 80% 안 | 맵 곳곳에 흩어짐 | 중간 지점. 플레이어마다 자기 몫만 보이고, 2인이면 각자 절반 |

드라이버는 셋이다.
- 기본 드라이버(
Generic) - 플러그인의
UBasicReplicationGraph. 모든 액터를 dormancy 경로로 넣는다 - 뒤의 커스텀 그래프 절에서 만드는
UOpenWorldReplicationGraph(이하 OpenWorld 그래프). 소품을 static, 루트를 dormancy, AI와 탈것을 dynamic 경로로 Grid에 넣고 dynamic 액터에 frequency bucket 3개를 쓴다
주 비교 조건은 5회, 나머지는 3회 반복해 평균을 쓴다. 실행마다 프레임 시간이 흔들리므로 프레임당 ms와 함께 Insights의 호출 수(Prioritize_InnerLoop가 검토한 액터 수, Replicate Actor Time의 호출 수)도 본다. 밀집 조건에는 보내는 횟수를 맞춘 대조군 둘을 더했다. OpenWorld 그래프의 버킷을 1개로 줄인 것과, 기본 드라이버의 NetUpdateFrequency를 10으로 낮춘 것이다.
Unreal Insights에는 리플리케이션 그래프 전용 트랙이 없지만 -statnamedevents로 띄우면 타이머가 보인다. 아래는 OpenWorld 그래프의 Cluster 런에서 서버 프레임 하나를 확대한 것이다.

① UReplicationGraph_ServerReplicateActors 4.56 ms 아래에 NET_PrepareReplication(Grid의 dynamic 액터 Cell 갱신이 여기)이 한 번 온다. 그 뒤에 연결마다 NET_ReplicateActors_GatherForConnection과 NET_ReplicateActors_ProcessGatheredLists(안에 PrioritizeForConnection과 ReplicateActorsForConnection), NET_ReplicateActors_LookForNonRelevantChannels가 이어진다. ② ③은 두 연결의 ProcessGatheredLists로 2.69 ms와 1.61 ms다. 프레임의 나머지 약 41 ms는 틱 레이트 대기다.
이 타이머 중 RG_QUICK_SCOPE_CYCLE_COUNTER로 선언된 Prioritize_InnerLoop*, NET_ReplicateActors_ReplicateSingleActor 등은 Test·Shipping에서 컴파일되지 않는다.
드라이버와 분포
아래는 프레임당 ServerReplicateActors ms의 5회 평균과 표준편차다.

| 분포 | 기본 드라이버 | Basic 그래프 | OpenWorld 그래프 |
|---|---|---|---|
| Cluster | 8.09 ± 0.09 | 3.44 ± 0.03 | 4.48 ± 0.04 |
| NearPlayers | 6.47 ± 0.47 | 2.25 ± 0.02 | 2.84 ± 0.02 |
| Uniform | 2.54 ± 0.20 | 0.40 ± 0.01 | 0.35 ± 0.02 |
기본 드라이버의 NearPlayers와 Uniform은 런마다 서버 프레임 시간이 41–47 ms로 흔들려 편차가 ±8%이고 나머지 조건은 ±5% 안이다. 같은 기본 드라이버 Uniform 조건을 다른 측정 세트에서 다시 돌리면 2.05–2.23 ms가 나온다. 표의 2.54는 가장 높은 세트의 값이다. 아래 단계별 분해는 위의 Insights 타이머로 했다. 트레이스가 있는 첫 번째 런 하나의 값이라 표의 평균과 조금 다르다.
Uniform에서 리플리케이션 그래프는 기본 드라이버의 7분의 1이다. 단계별로 나누면 다음과 같다.
| 단계 | 기본 드라이버 (ms) | OpenWorld 그래프 (ms) |
|---|---|---|
| Consider / Gather | consider list 구성 1.7 | PrepareForReplication 0.12, gather 0.01 |
| Prioritize | 두 연결 0.9 | 0.04 (검토 항목 71개) |
| Replicate | 0.2 (프레임당 67회) | 0.13 |
| 합계 | 2.8 | 0.3 |
기본 드라이버는 5,265개 중 연결당 30개 남짓만 보내는데 비용의 90% 이상이 보낼지 말지를 정하는 데 든다. OpenWorld 그래프는 Grid가 viewer Cell의 리스트만 돌려주므로 나머지 5,100개는 검토 대상이 아니다.
모든 액터가 모든 연결에 관련 있는 Cluster에서도 OpenWorld 그래프가 기본 드라이버의 55%, Basic이 43%다. 다만 이 차이는 판별 비용이 아닌 보내는 횟수에서 온다.
| 드라이버 | 연결당 ReplicateActor 호출 | 구성 |
|---|---|---|
| 기본 드라이버 | 1,561 | 깨어 있는 액터 전부를 매 프레임 |
| Basic 그래프 | 524 | 깨어 있는 1,580개 전부가 버킷 3개에 나뉘어 세 프레임에 한 번 |
| OpenWorld 그래프 | 735 | AI·탈것 1,000개가 버킷에서 333회, 깨어 있는 소품 315개가 static 리스트에서 매 프레임, dependent 무기가 탈것에 묻어 83회 |
보내는 횟수를 맞춘 대조군 둘의 결과다.

기본 드라이버의 NetUpdateFrequency를 10으로 낮추면 호출이 연결당 501회로 줄고 3.06 ms가 된다. 서버가 초당 21–23 프레임이라 10 Hz는 세 프레임에 한 번꼴이다.
반대로 OpenWorld의 버킷을 1개로 줄이면 호출 수가 1,555회로 100 Hz 기본 드라이버의 1,561회와 거의 같아진다. 이때 리플리케이션 그래프는 9.89 ms로 기본 드라이버보다 22% 느리다.
네 조건의 호출당 비용은 2.6–3.2 µs로 같은 자릿수다. 밀집 조건의 비용은 보내는 횟수가 정하고 그 횟수는 어느 드라이버든 설정으로 바꿀 수 있다.
아래 두 그림은 Cluster 조건에서 측정 구간 60초의 Callees 트리를 펼친 것이다.


① UActorChannel::ReplicateActor 안의 시간은 7.51 s와 7.22 s, 프레임당 5.80 ms와 5.55 ms로 같고 호출당 약 1.8 µs다. 후보를 고르는 단계도 비슷하다. 첫 번째 그림의 기본 드라이버는 ② 두 연결의 우선순위 1.04 s와 ③ consider list 894.94 ms(프레임당 0.69 ms)를 합쳐 프레임당 1.49 ms다. 두 번째 그림의 리플리케이션 그래프는 prepare와 gather 0.16 ms에 ③ 우선순위 계산 1.68 s를 합쳐 1.45 ms다.
NET_ReplicateActors_ReplicateSingleActor가 채널 밖에서 하는 일에서 차이가 난다. 두 번째 그림의 ② DependentActors 1.72 s와 CallPreReplication 1.1 s가 그 일부다. DependentActors는 inclusive라 그 안에서 재귀 호출되는 무기 액터의 채널 시간이 대부분이고 순회 자체(Excl 열)는 0.24 s다.
| 리플리케이션 그래프의 채널 밖 작업 | ms |
|---|---|
ReplicateSingleActor 자체. dependent 목록 수집, 연결 정보의 프레임 번호 갱신, 채널 조회 | 1.31 |
액터마다 PreReplication 호출(CallPreReplication) | 0.85 |
dependent actor 순회와 그 채널 닫힘 프레임 갱신(DependentActors 자체) | 0.19 |
연결별 리스트 순회와 채널 정리(ReplicateActorsForConnection 잔여, LookForNonRelevantChannels, 후처리) | 0.30 |
| 합계 | 2.65 |
기본 드라이버에서 ReplicateActor 밖에 남는 리플리케이션 루프 시간은 0.93 ms다. PreReplication은 consider list를 만들면서 부르므로 앞의 consider list 0.69 ms 안에 들어 있다. 리플리케이션한 액터 하나당 채널 밖 비용은 기본 0.30 µs, 리플리케이션 그래프 0.85 µs다. 영속 리스트가 줄이는 것은 검토할 액터 수뿐이고 보내는 액터마다 붙는 부가 비용은 기본 드라이버보다 크다. 보낼 액터가 많을수록 그 차이가 쌓인다.
NearPlayers는 둘의 중간이어야 하는데 기본 드라이버는 6.47 ms로 Cluster에 가깝다. 보내는 양은 연결당 770회로 Cluster의 절반이다. 그런데 consider list 구성이 1.38 ms로 Cluster의 두 배이고 우선순위 계산이 1.97 ms다.

테스트 프로젝트는 소품의 90%와 루트 전부를 DORM_DormantAll로 둔다. 그런데 FNetworkObjectList는 액터가 모든 연결에서 dormant가 됐을 때만 active 목록에서 뺀다(Engine/Private/NetworkObjectList.cpp, MarkDormant의 DormantConnections.Num() == NumConnections). Cluster에서는 두 클라이언트가 모든 액터를 받으므로 3,685개가 빠져 active가 1,580개다. 흩어진 분포에서는 각 소품을 한 클라이언트만 받으므로 영영 active에 남아 매 프레임 5,265개 전부가 consider list를 거친다. Uniform의 1.7 ms도 같은 원인이다.
CSV의 NumberOfFullyDormantActors는 Cluster에서 3,685, NearPlayers에서 0, Uniform에서 60 남짓(두 플레이어가 3 m 거리라 같은 소품 일부를 본다)이다. 일부 연결에서만 dormant인 액터는 그 연결의 우선순위 계산만 건너뛰고 consider list 구성 비용은 그대로 낸다. 목록에서는 모든 연결이 그 액터를 한 번씩 본 뒤에야 빠진다. 넓은 맵에서는 그런 일이 잘 일어나지 않는다. 리플리케이션 그래프는 dormant 액터를 연결별 bDormantOnConnection으로 걸러서 이 문제가 없다.
액터 수와 연결 수
아래는 Uniform에서 액터 수를 1,000에서 20,000으로 늘린 결과다.

기본 드라이버는 0.59에서 9.69 ms로 거의 선형으로 는다. 액터 하나당 프레임당 0.46 µs다. 20,000개 런의 분해는 consider list 5.2 ms, 우선순위 3.8 ms, 리플리케이션 0.5 ms다.
OpenWorld 그래프는 0.18에서 0.82 ms로 4.5배 늘고 그 증가분은 열린 채널 수(52 → 254)에 비례한다. 연결별 gather·prioritize·리플리케이션 비용은 맵의 액터 수와 상관없이 viewer 근처의 액터 수를 따른다. 전역 PrepareForReplication만 dynamic 액터 전체의 위치를 매 프레임 읽으므로 그 몫은 액터 수에 비례해 남는다.
연결 수는 2에서 16으로 늘렸다.

연결 수에서는 둘 다 선형이지만 기울기가 6배 다르다. 기본 드라이버는 연결당 0.78 ms, OpenWorld는 0.13 ms다. 연결 16개에서 기본 드라이버의 13.0 ms 중 9.2 ms가 16번의 우선순위 계산이다. 이 숫자는 액터 5,265개 × 연결 16개의 relevancy 검토에서 나온다. 리플리케이션 그래프의 2.14 ms는 리플리케이션 0.9 ms와 나머지 1.2 ms이고 연결마다 자기 Cell의 리스트만 본다.
dormant 비율과 Push Model
소품의 dormant 비율과 Push Model 설정을 바꾼 결과다.

소품을 모두 깨어 있게 두면 기본 드라이버는 이 세트의 기준값 2.13 ms에서 2.56 ms로, OpenWorld는 0.36에서 0.63 ms로 늘고 기본 드라이버의 채널 수가 73에서 167로 는다. 깨어 있는 소품은 값이 바뀔 때마다 보내지고 채널이 열린 채로 유지된다. 기본 드라이버의 consider list는 앞서 본 대로 어차피 5,265개 전부라 변화는 리플리케이션 단계에만 나타난다.
Push Model은 리플리케이션 그래프 위에서 그대로 동작하지만 이 부하에서는 잴 수 있는 차이가 없다. 차트의 Push Model 두 조건(끔 2.20/0.39 ms, 켜고 skip 2.39/0.42 ms)과 기준(Push Model 켬, skip 끔)의 차이는 런 편차 안이다.
net.PushModelSkipUndirtiedReplication은 UActorChannel::ReplicateActor가 부르는 FObjectReplicator 안에서 판정된다. 리플리케이션 그래프의 ReplicateSingleActor도 같은 함수를 부르므로 리플리케이션 그래프 쪽에는 Push Model을 의식하는 코드가 없다. 프레임당 리플리케이션이 41건이고 변경률이 1%라 프로퍼티 비교 시간이 0.025 ms에서 0.013 ms로 줄어도 ServerReplicateActors의 3–6%다.
다만 리플리케이션 그래프는 채널에 들어가기 전에 주기를 소비하므로(프레임당 검사 절) Push Model이 비교를 건너뛰어 0바이트로 끝나도 NumReplicatedCleanActors로 집계된다. 두 최적화는 서로 다른 층을 줄이므로 함께 켜도 손해가 없다.
수치가 나오는 구조: 세 층, Grid, 프레임당 검사
세 층의 상태
리플리케이션 그래프는 상태를 세 층으로 나눠 둔다(Public/ReplicationGraphTypes.h).
| 층 | 구조체 | 만들어지는 때 |
|---|---|---|
| 클래스 | FClassReplicationInfo | 리플리케이션 그래프 초기화의 InitGlobalActorClassSettings. 미등록 클래스는 첫 조회 때 lazy init |
| 액터 | FGlobalActorReplicationInfo | AddNetworkActor에서 클래스 정보를 Settings에 복사해 생성 |
| 연결×액터 | FConnectionReplicationActorInfo | 그 연결이 그 액터를 처음 만질 때 FindOrAdd로 생성. 액터 정보에서 주기와 Cull Distance를 복사 |

복사이므로 나중에 윗층을 바꿔도 아래층에는 전파되지 않는다. 클래스 정보를 조회하면 TClassMap::Get이 부모 클래스를 거슬러 올라가 값을 찾은 뒤 요청한 클래스 이름으로 복사해 캐시한다. 그래서 자식 클래스가 한 번이라도 조회된 뒤에 부모 클래스에 SetClassInfo를 해도 그 자식에는 반영되지 않는다. 런타임에 Cull Distance를 바꾸려면 클래스, 액터, 모든 연결의 정보를 따로 고쳐야 한다.
- 엔진의
SetAllCullDistanceSettingsForActor는 액터와 모든 연결 두 층만 쓰고 클래스는 둔다. - 디버그 명령
Net.RepGraph.SetClassCullDistance는 클래스와 액터, 그리고 명령을 보낸 연결 하나만 고친다. - 공간화된 액터는 거기에 더해 Grid의
NotifyActorCullDistChange로 Cell 등록을 다시 해야 넓어진 반경의 연결에서 후보가 된다.
뒤의 설정값 절에서 런타임 SetNetUpdateFrequency가 반영되지 않는 것도 같은 구조 때문이다. 거리 검사와 Cell 계산은 액터 정보에 캐시된 WorldLocation을 쓴다.
Grid와 Cell
UReplicationGraphNode_GridSpatialization2D가 공간 분할, 즉 거리 relevancy의 broad phase를 맡는다. 설정은 CellSize, SpatialBias, ConnectionMaxZ 셋이고 모두 생성자에서 0 또는 월드 최대로 초기화되므로 프로젝트가 채워야 한다. 액터는 세 경로로 들어온다.
| 경로 | Cell에 들어가는 때 | 위치 갱신 | 언제 쓰는가 |
|---|---|---|---|
AddActor_Static | 추가 시 한 번 | 없음 | 스폰 뒤 안 움직이는 액터 |
AddActor_Dynamic | 매 프레임 PrepareForReplication에서 | 매 프레임 GetActorLocation | 움직이는 액터 |
AddActor_Dormancy | bWantsToBeDormant면 static, 아니면 dynamic으로 넣고 dormancy 변경 이벤트를 구독 | dormant일 때 없음 | 잠들 때는 안 움직이고 깨면 움직이는 액터 |
액터 하나가 여러 Cell에 들어간다. GetCellInfoForActor는 위치를 중심으로 Cull Distance만큼의 정사각형을 만들어 그 사각형이 겹치는 모든 Cell에 넣는다. Cull Distance 150 m에 Cell 100 m면 사각형 한 변이 300 m라 한 액터가 4×4 Cell에 들어간다(Grid 가장자리에서만 줄어든다).
대신 gather는 viewer가 서 있는 Cell 하나만 본다. 이웃 Cell을 찾아다니지 않고 액터 쪽이 미리 퍼져 있는 구조다. 여기에 쓰이는 Cull Distance는 액터 정보의 Settings.GetCullDistance()에서 온다. 연결별 값은 쓰지 않는다.

static 액터는 추가될 때의 위치로 Cell이 정해지고 다시 계산되지 않는다. dynamic 액터만 매 프레임 PrepareForReplication에서 위치를 읽어 Cell을 옮긴다. SetBiasAndGridBounds로 경계를 정하지 않은 그래프에서는 SpatialBias보다 작은 좌표에 액터가 나타나면 Grid를 통째로 다시 만든다. 그래서 맵 밖으로 날아가는 발사체 클래스는 AddToClassRebuildDenyList에 넣어 가장자리 Cell로 클램프되게 한다. Lyra는 Lyra.RepGraph.DisableSpatialRebuilds(기본 1)로 AActor 자체를 목록에 넣어 재구축을 끈다.
Cell 노드는 자기 리스트가 곧 static 리스트다. dynamic 액터는 frequency bucket 노드로, dormant 액터는 dormancy 노드로 간다.
GridSpatialization2D└── GridCell (Cell마다) ├── static 리스트 (자기 ReplicationActorList) ├── DynamicNode: ActorListFrequencyBuckets 또는 DynamicSpatialFrequency └── DormancyNode └── ConnectionDormancyNode (연결마다, 마스터 리스트의 사본)static 액터가 잠들면 Cell이 자기 리스트에서 빼서 dormancy 노드로 옮기고 깨면 다시 돌려보낸다. AddActor_Dormancy로 들어온 액터는 깨어나면 dynamic 액터가 되어 위치가 갱신된다. AddActor_Static으로 들어온 액터는 깨어나도 원래 Cell에 그대로 있다. Dormancy 노드는 연결마다 마스터 리스트의 사본을 두고 gather 때마다 bDormantOnConnection인 액터를 뺀다. 그래서 사본은 그 연결이 아직 받지 못한 dormant 액터 목록이다. Cell에 dormant 액터가 많으면 새 연결마다 그만큼 복사한다. 연결이 끊겨도 사본은 저절로 사라지지 않는다.
프레임당 검사와 점수
gather가 끝나면 ReplicateActorListsForConnections_Default가 리스트를 순회해 FPrioritizedRepList를 만든다(Private/ReplicationGraph.cpp). 아래 그림은 액터 하나가 거치는 검사 순서와 각 단계를 정하는 설정이다.

- dormant 검사가 전역 정보 조회보다 앞에 있어서 잠든 액터의 프레임당 비용은 맵 조회 하나다.
- 거리 컬링은
DistancePriorityScale > 0인 클래스만 거친다.UReplicationGraph기본 구현(이하 베이스 그래프)은AInfo와APlayerController를 0으로 두지만 Basic과 Lyra식 초기화가 모든 클래스를 1로 덮어쓴다. 그래서 실제로는 Cull Distance 0이 이 액터들을 지킨다(0이면 검사를 건너뛴다).bForceCullDistanceToZero가 켜진 액터도 빠지지 않는다. - 채널 닫힘 프레임은 리플리케이션이 아니라 거리 검사를 통과한 시점에 갱신된다.
점수는 낮을수록 먼저 리플리케이션된다. 정렬이 오름차순이고 중요도 보정이 모두 뺄셈이기 때문이다.
| 항목 | 계산 | 기본값에서의 범위 |
|---|---|---|
| 기준 | AccumulatedNetPriorityBias | 0 |
| 거리 | Clamp(최단 거리² / MaxDistanceScaling, 0, 1) × DistancePriorityScale | 0–1. MaxDistanceScaling은 60000², 즉 600 m |
| starvation | 1 - Clamp((FrameNum - LastRepFrameNum) × StarvationPriorityScale / MaxFramesSinceLastRep, 0, 1) | 0–1. MaxFramesSinceLastRep는 20. 오래 못 받을수록 0에 가까움 |
| 잠들려는 액터 | bWantsToBeDormant이고 한 번 이상 리플리케이션됐으면 | −1.5 |
ForceNetUpdate | ForceNetUpdateFrame > LastRepFrameNum | −1 |
| 본인 viewer·ViewTarget | Net.RepGraph.ForceConnectionViewerPriority(기본 1)면 | −MAX_FLT, 아니면 −10 |
AccumulatedNetPriorityBias는 클수록 덜 중요하다. 이름만 보고 양수를 넣으면 반대로 동작한다.- 잠들려는 액터의 −1.5는 주석에 이유가 있다. dormancy는 마지막 리플리케이션이 ack돼야 완료되므로 새로 스폰된 액터들에 계속 밀리면 영영 잠들지 못한다.
- 정렬은
TArray::Sort, 즉 introsort라 같은 점수의 액터는 프레임마다 순서가 바뀔 수 있다. Epic이 포럼에서 우선순위는 고려 순서만 바꾸며 액터 간 순서는 보장되지 않는다고 답한 것과 맞는다.
정렬된 리스트는 앞에서부터 ReplicateSingleActor로 보낸다.
- 채널에 들어가기 전에
LastRepFrameNum과NextReplicationFrameNum을 쓰므로 0바이트를 보내도 주기는 소비된다. PreReplication은 연결이 몇 개든 액터당 프레임당 한 번만 부른다.bWantsToBeDormant면 리플리케이션할 때마다StartBecomingDormant를 다시 건다.- dependent actor는 부모 직후에 재귀적으로 리플리케이션한다.
채널은 프레임 수로 닫힌다. UpdateActorChannelCloseFrameNum은 FrameNum + ReplicationPeriodFrame + ActorChannelFrameTimeout + GlobalActorChannelFrameNumTimeout을 계산해 기존 값보다 크면 갱신한다. 기본값은 주기 1, 타임아웃 4, 전역 여유 2이므로 마지막으로 gather된 뒤 7프레임이 지나면 닫힌다.
LookForNonRelevantChannels가 매 프레임 연결의 채널 맵을 돌며 지난 채널을 Close(Relevancy)한다. startup 액터(IsNetStartupActor)의 채널은 이 경로로 닫지 않는다. 기본 드라이버의 RelevantTimeout이 초 단위인 것과 달리 전부 프레임 단위다.
설정값이 실제로 바꾸는 것
Cell 크기, 버킷 수, NetUpdateFrequency, 우선순위 스케일, 채널 타임아웃이 각각 무엇을 바꾸는지 소스에서 확인하고 OpenWorld 그래프로 하나씩 바꿔 가며 쟀다. 조건은 Uniform 분포, 액터 5,000개, 클라이언트 2개이고 기준값은 Cell 100 m, 버킷 3개, NetUpdateFrequency 100, DistancePriorityScale 1, StarvationPriorityScale 1, ActorChannelFrameTimeout 4다. 각 3회 평균이고 단위는 프레임당 ServerReplicateActors ms, 호출은 연결당 ReplicateActor 횟수, 채널은 두 연결에 열린 채널 수의 합(Replication/NumOpenChannels, 프레임 평균), 검토는 Prioritize_InnerLoop가 프레임에 본 항목 수다.
| 설정 | 값 | ms | 호출 | 채널 | 검토 |
|---|---|---|---|---|---|
| 기준 | 0.35 | 21 | 76 | 79 | |
CellSize | 25 m | 0.38 | 23 | 80 | 65 |
| 400 m | 0.40 | 23 | 80 | 181 | |
| 버킷 수 | 1 | 0.42 | 33 | 78 | 146 |
| 6 | 0.34 | 20 | 80 | 68 | |
NetUpdateFrequency | 10 | 0.32 | 14 | 74 | 80 |
| 2 | 0.28 | 8 | 74 | 80 | |
DynamicSpatialFrequency | 버킷 대신 | 0.40 | 16 | 76 | 48 |
DistancePriorityScale | 0 | 0.41 | 34 | 153 | 71 |
StarvationPriorityScale | 0 | 0.34 | 21 | 76 | 79 |
ActorChannelFrameTimeout | 0 | 0.35 | 21 | 212 | 79 |
시간을 크게 바꾸는 설정은 없다. 이 밀도에서 설정값은 보내는 횟수와 열린 채널 수를 바꾼다. 그 효과는 Cull Distance 안의 액터가 많을수록 커져 Cluster에서 두드러진다.
CellSize는 액터 하나가 등록되는 Cell 수(Cull Distance 150 m에 25 m Cell이면 13×13), dynamic 액터의 Cell 갱신 빈도, viewer Cell에 섞이는 Cull Distance 밖 액터 수를 정한다. 측정에서는 25 m와 400 m 모두 0.05 ms 안이고 400 m에서 검토 항목만 두 배다. Cull Distance와 같은 자릿수로 두고 NET_PrepareReplication과 검토 항목 수를 보며 조정한다.
버킷 수는 frequency bucket 노드가 Cell의 dynamic 액터를 나누는 개수이고 dynamic 액터 주기의 하한이다. ReplicationPeriodFrame과 겹치면 주기를 버킷 수의 배수로 올림한 값이 실제 간격이다(버킷 3에 주기 4면 6프레임). 1개면 호출 33회이고 Cluster에서는 4.48 ms와 9.89 ms를 가른다. Cull Distance 안에 dynamic 액터가 많을 때 가장 먼저 만진다. 단 30 Hz에 3개면 dynamic 액터가 10 Hz가 되니 이동 보간이 감당하는지 클라이언트에서 확인한다.
NetUpdateFrequency는 초기화 때 CDO 값으로 RoundToInt(NetServerMaxTickRate / NetUpdateFrequency)를 ReplicationPeriodFrame에 쓰고 그 뒤로는 프레임 수로만 센다. 런타임 SetNetUpdateFrequency는 반영되지 않고(Net.RepGraph.SetPeriodFrame은 클래스·액터와 명령을 보낸 연결의 값을 고친다) MinNetUpdateFrequency의 적응형 주기도 없다. 100·10·2에서 호출은 21·14·8회, 시간은 0.35·0.32·0.28 ms로 호출보다 완만하게 준다. 클래스별 송신 횟수를 정하는 값이고 서버 프레임 레이트보다 낮을 때만 뜻이 있다.
DynamicSpatialFrequency는 거리와 시야 방향 존마다 주기를 보간해 gather 안에서 직접 리플리케이션한다. prioritize 단계를 거치지 않아 DistancePriorityScale과 StarvationPriorityScale이 효과가 없다. 기본 존은 constexpr 30 Hz 가정이다. Net.RepGraph.DyanmicSpatialization.Reinit(오타가 실제 이름)도 같은 30 Hz 기본값을 다시 만들 뿐이어서 틱이 다르면 프로젝트가 존과 비트 예산을 직접 구성한다. 측정에서 호출은 16회로 줄지만 gather가 0.01에서 0.07 ms로 늘어 0.40 ms다. Cull Distance 안의 dynamic 액터가 수백 개일 때 송신량을 줄이는 용도이고 CPU는 늘어난다. 존 표의 함정은 커스텀 그래프 절에 있다.
DistancePriorityScale은 거리 점수의 가중치다. 거리 검사가 if (DistancePriorityScale > 0) 안에 있어 0이면 거리 컬링이 꺼진다. 측정에서 채널이 76에서 153으로, 호출이 34회로 늘었다. 거리 가중치만 끄려면 아주 작은 양수를 쓴다.
StarvationPriorityScale은 오래 못 받은 액터를 앞으로 당기는 가중치다. 위 표에서는 포화가 없어서 0으로 둬도 변화가 없었다. 포화돼서 뒤의 액터가 밀릴 때만 뜻이 있고 Net.RepGraph.StarvedList(마지막 리플리케이션 이후 프레임 수 순)로 확인한다. 포화 조건은 아래에서 따로 쟀다.
ActorChannelFrameTimeout은 마지막 gather 뒤 채널을 닫기까지의 프레임이다. 전역 여유 2를 더해 기본 7프레임이다. 0이면 relevancy로는 채널을 닫지 않는다(dormancy·tear-off·파괴로는 닫힌다). 0에서 채널이 212개가 되고 AI가 Cull Distance 경계를 드나드는 동안 계속 는다. Cull Distance 경계에서 채널이 자주 열리고 닫히면 올린다. 열린 채널은 양쪽 메모리를 쓰고 클라이언트에 액터가 남는다.
포화 조건
위 측정은 클라이언트 rate 제한을 풀고 CPU만 쟀다. 포화가 나면 무엇이 달라지는지 보려고 AI와 탈것이 이동을 리플리케이션하게 해 Cluster에서 연결당 약 90 KB/s가 나가게 만들고 클라이언트 rate를 30 KB/s로 제한했다(엔진 기본값은 100 KB/s). 각 3회다. “5초 안 갱신”은 클라이언트에 있는 AI 중 최근 5초 안에 갱신을 받은 비율이다. “전체 수신”은 접속 뒤 액터를 다 받기까지(클라이언트가 세는 5,250여 개 중 5,200개 기준) 걸린 시간이다.
| 조건 | ms | 호출 | 송신 KB/s | 5초 안 갱신 | 전체 수신 |
|---|---|---|---|---|---|
| OpenWorld, 제한 없음 | 6.76 | 735 | 91.5 | 전부 | 1초 |
| OpenWorld, 30 KB/s | 2.60 | 129 | 22.6 | 전부 | 12초 |
OpenWorld, 30 KB/s, StarvationPriorityScale 0 | 5.37 | 196 | 23.9 | 약 40% | 120초 뒤에도 62% |
OpenWorld, 30 KB/s, ActorDiscoveryBudget 10 KB/s | 2.53 | 129 | 22.6 | 전부 | 8초 |
| 기본 드라이버, 제한 없음 | 12.22 | 1,492 | 266 | 전부 | 1초 |
| 기본 드라이버, 30 KB/s | 4.44 | 125 | 21.9 | 전부 | 120초 뒤에도 약 80% |
이동을 리플리케이션하면 호출당 비용이 늘어 제한 없는 조건에서도 6.76 ms가 걸린다. 앞 표에서는 4.48 ms였다. 포화되면 서버는 오히려 덜 일한다. 연결이 포화되면 ReplicateActorsForConnection이 정렬된 리스트의 앞부분만 보내고 멈추므로 호출이 735회에서 129회로, 시간이 6.76 ms에서 2.60 ms로 줄었다. 대신 클라이언트가 액터를 다 받기까지 1초에서 12초로 늘었다.
StarvationPriorityScale을 0으로 두면 가까운 액터가 매 프레임 앞자리를 차지해 나머지는 영영 밀린다. 120초 뒤에도 클라이언트는 액터의 62%만 받았고 받은 AI 중 5초 안에 갱신된 것은 약 40%였다. 1이면 12초 안에 전부 받고 모든 AI가 5초 안에 갱신된다. 포화가 있는 서버에서 이 값은 성능보다 공평성을 정한다.
Net.RepGraph.ActorDiscoveryBudget <KB/s>는 아직 클라이언트가 스폰을 ack하지 않은 채널의 트래픽을 프레임당 그 양까지 연결의 송신량에서 빼 준다. 그만큼은 포화 판정에서 면제되고 넘치는 몫은 일반 트래픽으로 센다. 상한을 거는 값은 아니다. 콘솔 명령이고 기본값 0은 끔이다. 10 KB/s를 주자 접속 직후 전체 액터를 받기까지의 시간이 12초에서 8초로 줄었고 정상 상태는 같았다. 접속 직후나 핫드롭처럼 수천 채널이 한꺼번에 열릴 때 쓰는 값이다.
기본 드라이버는 같은 제한에서 받은 AI는 거의 전부 갱신하지만 접속 뒤 120초가 지나도 액터의 80%쯤만 받았다. 새 채널을 열어야 하는 액터가 뒤로 밀리는 것인데 원인은 소스에서 따로 확인하지 않았다.
커스텀 그래프: 라우팅 테이블, Team Node, 걸리는 것
커스텀 그래프의 출발점은 플러그인에 들어 있는 UBasicReplicationGraph다. 플러그인을 켜고 ReplicationDriverClassName에 /Script/ReplicationGraph.BasicReplicationGraph를 적으면 켜진다. Basic의 라우팅은 셋뿐이다.
bAlwaysRelevant면 전역ActorListbOnlyRelevantToOwner면 연결별 노드- 나머지는 전부 Grid의
AddActor_Dormancy
RouteAddNetworkActorToNodes에는 주석이 하나 달려 있다. 엔진에는 액터가 움직이는지 판별하는 코드가 없고 AActor에 그 정보가 없으므로 깨어 있으면 움직일 수 있다는 가정으로 넣는다는 내용이다. 안 움직이는 소품 수천 개를 깨운 채로 두면 Basic은 그만큼의 PrepareForReplication 비용을 낸다.
static과 dynamic을 가르려면 프로젝트가 클래스 라우팅 테이블을 만들어야 하고, 이것이 커스텀 그래프를 쓰는 첫 번째 이유다. Basic에는 owner 변경의 재라우팅, PlayerState 빈도 제한, 팀 같은 범주도 없다. PlayerState는 bAlwaysRelevant라 전역 리스트로 가긴 한다.
액터 범주와 라우팅
리플리케이션 그래프를 어떻게 구성할지는 게임의 액터가 어떤 범주로 나뉘는지에 달려 있다. 아래 표는 공식 문서의 예시와 Lyra 5.8.3의 ULyraReplicationGraph, 2018년 라이브스트림의 Fortnite 그래프에서 오픈월드 멀티플레이어의 범주를 추린 것이다. 테스트 액터 종류도 이 범주를 따른다.
| 범주 | 예와 특성 | 리플리케이션 그래프에서 가는 곳 |
|---|---|---|
| 월드 소품 | 건물 조각, 나무. 수만 개, 스폰 후 안 움직임 | Grid의 static 리스트. 피해를 받을 때만 깨어남 |
| 루트·픽업 | 바닥의 무기, 상자. 수천 개, 집히기 전까지 안 변함 | Grid의 dormancy 리스트. 집히면 거리 안의 연결에만 파괴 정보 전송(부록 A) |
| Player Pawn | 100명. 계속 움직이고 매 프레임 바뀜 | Grid의 dynamic 리스트. 이동은 Fast Shared Path |
| AI Pawn | 좀비, 야생동물. 수백 개, 움직임 | Grid의 dynamic 리스트. 거리별 빈도 조절 |
| 탈것과 부속 무기 | 탈것과 그 위의 무기 | 탈것은 dynamic, 무기는 dependent actor |
| 발사체 | 총알, 수류탄. 짧게 살고 많이 생김 | dynamic 리스트, Cell 재구축 금지 목록 |
| 전역 상태 | GameState, 안전지대. 모두에게 항상 필요 | AlwaysRelevant 노드 |
| 스쿼드 | 4인 스쿼드. 거리와 무관하게 서로 보여야 함 | Team Node |
| 본인 소유 | 인벤토리, PlayerController. owner에게만 | 연결별 AlwaysRelevant 노드 |
| PlayerState | 100명분. 모두에게 필요하지만 느려도 됨 | 빈도 제한 노드 |
측정에 쓴 UOpenWorldReplicationGraph는 Lyra 5.8.3의 구조를 바탕으로 이 표를 구현한 것이다. Lyra 자체는 ULyraReplicationGraphSettings의 bDisableReplicationGraph가 기본 true라 샘플을 켜도 리플리케이션 그래프는 꺼져 있다. 켜면 Cell 100 m, 버킷 3개, 파괴 정보 거리 300 m, Fast Shared Path 켬(10 KB/s, Cull Distance의 80%)이 기본값이다. 클래스마다 Spatialize_Static, Spatialize_Dynamic, Spatialize_Dormancy, RelevantAllConnections, RelevantOwnerConnection, NotRouted 중 하나를 정한다. 레거시 플래그에서 기본값을 유도한 뒤 프로젝트 설정으로 덮어쓴다.
bAlwaysRelevant면RelevantAllConnections- Lyra는
bOnlyRelevantToOwner와bNetUseOwnerRelevancy클래스를NotRouted로 두고 연결별 노드가 PlayerController와 Pawn, PlayerState만 직접 끌어온다. OpenWorld 그래프는 그 대신RelevantOwnerConnection라우팅을 두었다 - 나머지 리플리케이션 액터는
Spatialize_Dynamic
아래 코드는 위 라우팅과 노드 구성을 공식 문서의 일반 예제에 맞춰 5.8.3에서 컴파일되게 다시 쓴 것이다. OpenWorld 그래프에 있는 스트리밍 레벨 리스트, Team Node, owner가 아직 없는 액터의 재시도 같은 게임 특화 부분은 뺐다.
UENUM()enum class EClassRepNodeMapping : uint8{ NotRouted, RelevantAllConnections, RelevantOwnerConnection, Spatialize_Static, Spatialize_Dynamic, Spatialize_Dormancy,};
static bool IsSpatialized(EClassRepNodeMapping Mapping) { return Mapping >= EClassRepNodeMapping::Spatialize_Static; }
UCLASS()class UExampleReplicationGraph : public UReplicationGraph{ GENERATED_BODY()
public: virtual void InitGlobalActorClassSettings() override; virtual void InitGlobalGraphNodes() override; virtual void InitConnectionGraphNodes(UNetReplicationGraphConnection* RepGraphConnection) override; virtual void RouteAddNetworkActorToNodes(const FNewReplicatedActorInfo& ActorInfo, FGlobalActorReplicationInfo& GlobalInfo) override; virtual void RouteRemoveNetworkActorToNodes(const FNewReplicatedActorInfo& ActorInfo) override;
EClassRepNodeMapping GetClassNodeMapping(UClass* Class) const; void InitClassReplicationInfo(FClassReplicationInfo& Info, UClass* Class, bool bSpatialize) const;
UPROPERTY() TObjectPtr<UReplicationGraphNode_GridSpatialization2D> GridNode; UPROPERTY() TObjectPtr<UReplicationGraphNode_ActorList> AlwaysRelevantNode; TClassMap<EClassRepNodeMapping> ClassRepNodePolicies; TMap<TWeakObjectPtr<UNetConnection>, TObjectPtr<UReplicationGraphNode_AlwaysRelevant_ForConnection>> ConnectionNodes;};
// 모든 PlayerState를 프레임당 TargetActorsPerFrame개씩 돌려 보내는 노드 (공식 문서의 PlayerStateFrequencyLimiter)UCLASS()class UExampleNode_PlayerStateFrequencyLimiter : public UReplicationGraphNode{ GENERATED_BODY()
public: // 이 플래그가 없으면 InitNode가 PrepareForReplication 목록에 넣지 않아 노드가 아무것도 돌려주지 않는다 (Team Node 절 참고) UExampleNode_PlayerStateFrequencyLimiter() { bRequiresPrepareForReplicationCall = true; }
virtual void NotifyAddNetworkActor(const FNewReplicatedActorInfo& Actor) override {} virtual bool NotifyRemoveNetworkActor(const FNewReplicatedActorInfo& Actor, bool bWarnIfNotFound = true) override { return false; } virtual bool NotifyActorRenamed(const FRenamedReplicatedActorInfo& Actor, bool bWarnIfNotFound = true) override { return false; } virtual void PrepareForReplication() override; virtual void GatherActorListsForConnection(const FConnectionGatherActorListParameters& Params) override;
int32 TargetActorsPerFrame = 2; TArray<FActorRepListRefView> ReplicationActorLists;};EClassRepNodeMapping UExampleReplicationGraph::GetClassNodeMapping(UClass* Class) const{ if (!Class) return EClassRepNodeMapping::NotRouted;
// 명시적으로 정한 클래스는 그대로 if (const EClassRepNodeMapping* Explicit = ClassRepNodePolicies.FindWithoutClassRecursion(Class)) return *Explicit;
const AActor* CDO = Cast<AActor>(Class->GetDefaultObject()); if (!CDO || !CDO->GetIsReplicated()) return EClassRepNodeMapping::NotRouted;
// 부모와 플래그가 같으면 부모의 매핑을 따른다. NotRouted로 둔 PlayerState의 서브클래스도 여기로 온다 if (const AActor* SuperCDO = Cast<AActor>(Class->GetSuperClass()->GetDefaultObject())) { if (SuperCDO->GetIsReplicated() == CDO->GetIsReplicated() && SuperCDO->bAlwaysRelevant == CDO->bAlwaysRelevant && SuperCDO->bOnlyRelevantToOwner == CDO->bOnlyRelevantToOwner && SuperCDO->bNetUseOwnerRelevancy == CDO->bNetUseOwnerRelevancy) { return GetClassNodeMapping(Class->GetSuperClass()); } }
// 레거시 플래그에서 기본값을 유도한다. 프로젝트 설정으로 덮어쓰는 부분은 생략 if (CDO->bAlwaysRelevant && !CDO->bOnlyRelevantToOwner) return EClassRepNodeMapping::RelevantAllConnections; if (CDO->bOnlyRelevantToOwner) return EClassRepNodeMapping::RelevantOwnerConnection; return EClassRepNodeMapping::Spatialize_Dynamic;}
void UExampleReplicationGraph::InitClassReplicationInfo(FClassReplicationInfo& Info, UClass* Class, bool bSpatialize) const{ const AActor* CDO = Class->GetDefaultObject<AActor>(); Info.ReplicationPeriodFrame = GetReplicationPeriodFrameForFrequency(CDO->GetNetUpdateFrequency()); // Cull Distance는 Grid에 들어가는 클래스에만 준다. AlwaysRelevant·owner 전용 액터는 WorldLocation이 원점에 고정이라 // Cull Distance가 있으면 viewer가 원점에서 그만큼 멀어지는 순간 거리 컬링에 걸린다 if (bSpatialize) Info.SetCullDistanceSquared(CDO->GetNetCullDistanceSquared());}
void UExampleReplicationGraph::InitGlobalActorClassSettings(){ Super::InitGlobalActorClassSettings();
// 전용 노드가 있는 엔진 클래스는 라우팅하지 않는다 ClassRepNodePolicies.Set(APlayerState::StaticClass(), EClassRepNodeMapping::NotRouted); ClassRepNodePolicies.Set(ALevelScriptActor::StaticClass(), EClassRepNodeMapping::NotRouted); ClassRepNodePolicies.Set(AReplicationGraphDebugActor::StaticClass(), EClassRepNodeMapping::NotRouted);
// 초기화 뒤에 로드되는 Blueprint 서브클래스는 첫 조회 때 이 두 람다로 온다 ClassRepNodePolicies.InitNewElement = [this](UClass* Class, EClassRepNodeMapping& Mapping) { Mapping = GetClassNodeMapping(Class); return true; }; GlobalActorReplicationInfoMap.SetInitClassInfoFunc([this](UClass* Class, FClassReplicationInfo& Info) { InitClassReplicationInfo(Info, Class, IsSpatialized(ClassRepNodePolicies.GetChecked(Class))); return true; });
for (TObjectIterator<UClass> It; It; ++It) { UClass* Class = *It; const AActor* CDO = Cast<AActor>(Class->GetDefaultObject()); if (!CDO || !CDO->GetIsReplicated()) continue; if (Class->GetName().StartsWith(TEXT("SKEL_")) || Class->GetName().StartsWith(TEXT("REINST_"))) continue;
// TClassMap::Get은 부모 값을 자식 키로 복사해 돌려주므로 "없을 때만 Set"이라는 가드를 쓰면 자식이 부모 라우팅을 물려받는다. 무조건 쓴다 const EClassRepNodeMapping Mapping = GetClassNodeMapping(Class); ClassRepNodePolicies.Set(Class, Mapping);
FClassReplicationInfo Info; InitClassReplicationInfo(Info, Class, IsSpatialized(Mapping)); GlobalActorReplicationInfoMap.SetClassInfo(Class, Info); }
UReplicationGraphNode_ActorListFrequencyBuckets::DefaultSettings.NumBuckets = 3; UReplicationGraphNode_ActorListFrequencyBuckets::DefaultSettings.BucketThresholds.Reset();}
void UExampleReplicationGraph::InitGlobalGraphNodes(){ GridNode = CreateNewNode<UReplicationGraphNode_GridSpatialization2D>(); GridNode->CellSize = 10000.f; // 100 m GridNode->SpatialBias = FVector2D(-150000.f, -200000.f); // 맵의 최소 좌표 GridNode->AddToClassRebuildDenyList(AActor::StaticClass()); AddGlobalGraphNode(GridNode);
AlwaysRelevantNode = CreateNewNode<UReplicationGraphNode_ActorList>(); AddGlobalGraphNode(AlwaysRelevantNode);
AddGlobalGraphNode(CreateNewNode<UExampleNode_PlayerStateFrequencyLimiter>());}
void UExampleReplicationGraph::InitConnectionGraphNodes(UNetReplicationGraphConnection* RepGraphConnection){ Super::InitConnectionGraphNodes(RepGraphConnection); // viewer(PlayerController)와 ViewTarget을 매 프레임 다시 모으는 엔진 노드. owner 전용 액터도 여기에 넣는다 UReplicationGraphNode_AlwaysRelevant_ForConnection* Node = CreateNewNode<UReplicationGraphNode_AlwaysRelevant_ForConnection>(); AddConnectionGraphNode(Node, RepGraphConnection); ConnectionNodes.Add(RepGraphConnection->NetConnection, Node);}
void UExampleReplicationGraph::RouteAddNetworkActorToNodes(const FNewReplicatedActorInfo& ActorInfo, FGlobalActorReplicationInfo& GlobalInfo){ switch (ClassRepNodePolicies.GetChecked(ActorInfo.Class)) { case EClassRepNodeMapping::NotRouted: break; case EClassRepNodeMapping::RelevantAllConnections: AlwaysRelevantNode->NotifyAddNetworkActor(ActorInfo); break; case EClassRepNodeMapping::RelevantOwnerConnection: if (UNetConnection* Connection = ActorInfo.Actor->GetNetConnection()) { if (TObjectPtr<UReplicationGraphNode_AlwaysRelevant_ForConnection>* Node = ConnectionNodes.Find(Connection)) { (*Node)->NotifyAddNetworkActor(ActorInfo); } } break; // owner가 아직 없으면 다음 프레임에 다시 시도하는 목록이 따로 필요하다 case EClassRepNodeMapping::Spatialize_Static: GridNode->AddActor_Static(ActorInfo, GlobalInfo); break; case EClassRepNodeMapping::Spatialize_Dynamic: GridNode->AddActor_Dynamic(ActorInfo, GlobalInfo); break; case EClassRepNodeMapping::Spatialize_Dormancy: GridNode->AddActor_Dormancy(ActorInfo, GlobalInfo); break; }}
void UExampleReplicationGraph::RouteRemoveNetworkActorToNodes(const FNewReplicatedActorInfo& ActorInfo){ switch (ClassRepNodePolicies.GetChecked(ActorInfo.Class)) { case EClassRepNodeMapping::NotRouted: break; case EClassRepNodeMapping::RelevantAllConnections: AlwaysRelevantNode->NotifyRemoveNetworkActor(ActorInfo); break; case EClassRepNodeMapping::RelevantOwnerConnection: if (UNetConnection* Connection = ActorInfo.Actor->GetNetConnection()) { if (TObjectPtr<UReplicationGraphNode_AlwaysRelevant_ForConnection>* Node = ConnectionNodes.Find(Connection)) { (*Node)->NotifyRemoveNetworkActor(ActorInfo, false); } } break; case EClassRepNodeMapping::Spatialize_Static: GridNode->RemoveActor_Static(ActorInfo); break; case EClassRepNodeMapping::Spatialize_Dynamic: GridNode->RemoveActor_Dynamic(ActorInfo); break; case EClassRepNodeMapping::Spatialize_Dormancy: GridNode->RemoveActor_Dormancy(ActorInfo); break; }}// PlayerState를 프레임당 TargetActorsPerFrame개씩 나눠 돌려 보낸다void UExampleNode_PlayerStateFrequencyLimiter::PrepareForReplication(){ ReplicationActorLists.Reset(); ReplicationActorLists.AddDefaulted(); for (TActorIterator<APlayerState> It(GetWorld()); It; ++It) { if (!IsActorValidForReplicationGather(*It)) continue; if (ReplicationActorLists.Last().Num() >= TargetActorsPerFrame) ReplicationActorLists.AddDefaulted(); ReplicationActorLists.Last().Add(*It); }}
void UExampleNode_PlayerStateFrequencyLimiter::GatherActorListsForConnection(const FConnectionGatherActorListParameters& Params){ if (ReplicationActorLists.Num() == 0) return; Params.OutGatheredReplicationLists.AddReplicationActorList(ReplicationActorLists[Params.ReplicationFrameNum % ReplicationActorLists.Num()]);}라우팅 표는 RouteAddNetworkActorToNodes의 switch에 들어 있다. 노드 넷(Grid, AlwaysRelevant, 연결별 AlwaysRelevant, PlayerState 빈도 제한)은 글 첫머리 그림과 같은 구성이다. 그래프는 UReplicationDriver::CreateReplicationDriverDelegate나 ini의 ReplicationDriverClassName으로 지정한다. 노드는 전역·연결 노드면 CreateNewNode, 노드의 자식이면 CreateChildNode로만 만든다.
기본값을 dynamic으로 두는 것은 잘못 넣었을 때의 결과가 달라서다. 움직이는 액터를 static으로 넣으면 보이지 않지만 안 움직이는 액터를 dynamic으로 넣으면 비용만 든다. static과 dormancy는 클래스를 알 때만 명시한다.
탈것의 무기처럼 부모가 리플리케이션될 때만 함께 가면 되는 액터도 있다. 이런 액터는 노드에 넣지 않고 AddDependentActor로 부모에 묶는다. 기본 드라이버에서 bNetUseOwnerRelevancy로 owner를 따라가던 장비·무기 클래스가 여기 해당한다. Lyra 규칙으로는 NotRouted가 되어 그래프로 옮기면 조용히 보이지 않게 된다. 그래서 부모에 묶거나 dynamic으로 명시해야 한다.
부모와 같은 프레임에 고려된다는 것까지만 보장되고 같은 패킷에 들어간다는 보장은 없다. dependent actor 목록은 노드가 아니라 액터 정보의 배열이라 PrintAll의 노드 트리에는 나오지 않는다.
Team Node와 Cull Distance 0
팀원을 거리와 무관하게 보여 주는 노드는 리스트만 돌려줘서는 동작하지 않는다. prioritize 단계의 거리 컬링이 리스트 출처를 가리지 않기 때문이다. 포럼에서 본 “팀원을 리스트에 직접 넣었는데도 거리 컬링된다”는 보고가 이 경우로 보인다. 노드는 gather 때 그 연결의 액터 정보에 bForceCullDistanceToZero를 세우고 팀에서 빠진 액터는 플래그를 풀어야 한다.
// OpenWorldReplicationGraph.cpp, UOpenWorldReplicationGraphNode_Team::GatherActorListsForConnectionfor (FActorRepListType Actor : TeamActorList){ FConnectionReplicationActorInfo& Info = Params.ConnectionManager.ActorInfoMap.FindOrAdd(Actor); if (!Info.bForceCullDistanceToZero) { Info.bForceCullDistanceToZero = 1; Forced.Add(Actor); }}Params.OutGatheredReplicationLists.AddReplicationActorList(TeamActorList);Team Node는 같은 팀의 연결들이 공유하는 노드라 전역 노드 목록에는 없다. 그래도 CreateNewNode로 만들고 bRequiresPrepareForReplicationCall을 켜 두면 InitNode가 PrepareForReplicationNodes에 넣어 준다. CreateChildNode로 만든 노드만 직접 넣어야 한다. 둘 다 하면 PrepareForReplication이 프레임마다 두 번 돈다. 팀원의 Pawn은 Grid에도 있어 가까운 팀원은 두 리스트에서 돌아오지만 같은 프레임의 중복은 LastRepFrameNum == FrameNum으로 걸러진다.
만들 때 걸리는 것
- Blueprint 서브클래스의 설정.
InitGlobalActorClassSettings에서TObjectIterator<UClass>로 클래스를 돌 때SKEL_,REINST_로 시작하는 컴파일 중간 클래스는 건너뛰어야 한다.GlobalActorReplicationInfoMap.SetInitClassInfoFunc로 lazy init도 걸어야 한다. 리플리케이션 그래프는BeginPlay전에 초기화되므로 그 시점에 로드되지 않은 Blueprint 서브클래스는 순회에 나오지 않는다. lazy init이 없으면 부모 클래스 값이 복사돼 동작은 하지만 그 서브클래스에 설정을 줄 길이 없어진다. - 설정은 엔진 초기화 전에. 같은 이유로 클래스 설정은 CDO에서만 읽을 수 있다. 게임 코드에서 CVar를 바꾸는 것도 맵 로드 뒤에 실행되는
-ExecCmds도 늦다.UDeveloperSettingsBackedByCVars로 둔 값은-dpcvars="OpenWorld.RepGraph.DynamicActorFrequencyBuckets=1"형식으로 줘야 초기화 때 읽힌다. 설정값 절의 측정은 이 방식으로 조건을 바꿨다. - 드라이버 지정. ini의
ReplicationDriverClassName과UReplicationDriver::CreateReplicationDriverDelegate둘 다 공식 문서에 있다. 델리게이트가 ini보다 우선하고nullptr를 돌려주면 기본 드라이버로 간다. 어느 쪽이든NetDriverName이NAME_GameNetDriver인지 확인해야 한다.InitBase를 지나는 서버 드라이버가 모두 같은 델리게이트를 부르므로 DemoNetDriver(리플레이)와 BeaconNetDriver도 해당된다. DynamicSpatialFrequency존 표. 헤더의MinDotProduct주석은 이 값 이상이어야 그 존이라고 적지만CalcFrequencyForActor는DotP <= MinDotProduct인 첫 존을 고른다. 주석대로{0.8 앞쪽}, {-1.0 나머지}순으로 적으면 정면의 액터가 어느 존에도 들어가지 않아 리플리케이션에서 빠진다. 액터마다 프레임마다 “was not placed in any valid zone” 경고가 찍혀 측정 60초에 약 12,900줄, 프레임당 1.1 ms의 로그 비용이 Insights 측정값에 들어갔다. 설정값 절의 0.40 ms는 존을 고친 뒤 다시 잰 값이다. 존은 오름차순으로 적고 마지막을 1.0으로 끝낸다.- 스트리밍 레벨. 노드의 리스트는 레벨별로 나뉘어 있고 gather는 클라이언트가 그 레벨을 로드했을 때만(
ClientVisibleLevelNamesRef) 돌려준다. 로드하지 않은 레벨의 액터는LogReplicationGraphVerbose에 “Level Not Loaded”로 남는다. 스트리밍 레벨의bAlwaysRelevant액터는 전역 노드 대신 Lyra의AlwaysRelevantStreamingLevelActors처럼 레벨별 리스트에 둔다. 액터의 outer가 바뀌는 프로젝트는 5.5 업그레이드 노트대로Net.RepGraph.HandleDynamicActorRename(기본 0)을 켠다. - viewer는 카메라다. 리플리케이션 그래프의 viewer는 Pawn이 아니라
GetPlayerViewPoint가 돌려주는 위치다. 거리 검사도 이 위치 기준이라 Cull Distance 150 m보다 높이 띄운 관전 카메라는 바닥의 액터를 하나도 받지 못한다. 관전 카메라가 있는 게임은 viewer가 어디인지부터 확인한다.
도입 기준과 Iris
기준은 서버의 ServerReplicateActors에서 보낼지 말지를 정하는 비용과 실제로 보내는 비용의 비율이다. 기본 드라이버에서 stat game의 Consider Actors Time과 Prioritize Actors Time을 합친 값이 Replicate Actor Time보다 크면 리플리케이션 그래프가 그 몫을 줄인다. 둘이 비슷하거나 리플리케이션 쪽이 크면 리플리케이션 그래프는 액터당 0.5 µs쯤을 더 쓰는 쪽이다. 측정에서는 Insights의 consider list와 prioritize 타이머로 같은 비율을 봤다.
그 비율은 리플리케이션 액터 수와 연결 수의 곱에 따라 커진다. 맵이 넓고 액터가 흩어져 있을수록, 연결이 많을수록 차이가 커진다. 반대로 액터 대부분이 모든 플레이어에게 관련 있는 게임(작은 아레나, 소규모 협동)에서는 리플리케이션 그래프가 줄일 검토가 없다. 보내는 양을 줄이려면 기본 드라이버의 NetUpdateFrequency를 서버 프레임 레이트보다 낮게 잡으면 충분하다. Cluster에서 10 Hz로 낮춘 기본 드라이버는 3.06 ms로, 버킷 3개의 리플리케이션 그래프 4.48 ms보다 쌌다.
도입하기로 했다면 순서는 다음과 같다.
- 리플리케이션 그래프 없이
stat game이나 Insights로 검토 비용과 리플리케이션 비용의 비율을 잰다. 이 수치가 없으면 도입 뒤에 무엇이 나아졌는지 말할 수 없다. UBasicReplicationGraph로 켜고 같은 조건을 다시 잰다.- 커스텀 그래프는 클래스 라우팅 테이블부터 만든다. 앞의 범주 표대로 나누고 모르는 클래스는 dynamic으로 둔다.
- 버킷 수와
NetUpdateFrequency로 보내는 횟수를 정한다.Net.RepGraph.PrintAll로 Passed 목록이 기대한 액터로만 차 있는지 확인한다. Cell 크기와 우선순위 스케일은 측정 없이 만지지 않는다. - 부록 A의 버그를 피한다. dormant 직후의 reliable multicast, Seamless Travel 중의 multicast, 옮기는 static 액터,
bNetTemporary, 끊긴 연결의 dormancy 사본, 멀어진 뒤 파괴된 dormant 액터다.
플러그인은 IsBetaVersion: true로 선언돼 있고 Iris와 양립하지 않는다. 클래스 라우팅은 액터 클래스가 늘 때마다 유지보수 대상이 되고 잘못 라우팅된 액터는 조용히 보이지 않는다. 그래서 2번에서 멈춰도 된다. Basic 그래프는 측정에서 OpenWorld 그래프와 같은 자릿수였다. Team Node와 owner 변경이 필요 없는 게임이라면 그것으로 충분하다.
커뮤니티에는 리플리케이션 그래프가 “deprecated”됐다는 말이 돌지만 Epic의 공개 발언은 다르다. 2026년 1월 포럼 글 “5.7 Iris Strategy”에서 Epic의 Alex Koumandarakis는 레거시 리플리케이션 시스템과 리플리케이션 그래프를 deprecate할 계획이 없고 세 시스템이 나란히 지원되도록 계획돼 있다고 답했다. 다만 릴리스 노트의 리플리케이션 그래프 항목은 5.4의 수정과 5.5 업그레이드 노트의 HandleDynamicActorRename 권장이 마지막이고 5.6부터 5.8까지는 없다. 유지보수는 되고 개발은 멈춘 상태로 보인다.
Iris와는 양자택일이다. 5.8 문서 “Migrate to Iris”는 Iris가 리플리케이션 그래프를 지원하지 않으며 Iris의 Filter와 Prioritizer가 리플리케이션 그래프 기능의 대체물이라고 적고 있다. 소스에서는 Iris를 쓰도록 설정된 NetDriver에 SetReplicationDriver로 리플리케이션 드라이버를 붙이려 하면 checkf로 멈춘다.
Fortnite는 2024년 4월에 Iris로 전환했다고 Unreal Fest Orlando 2025에서 Petter Rangström이 말했다. 같은 강연에서 리플리케이션 그래프가 “Fortnite가 요구하는 액터·연결 수는 감당했지만 경쟁 게임 같은 고밀도 액션에서는 여전히 버거웠다”는 것이 Iris를 만든 동기로 나온다. 강연의 “Porting” 슬라이드(20분 30초)에 나온 대응 관계는 다음과 같다. 일부는 소스에서 확인해 덧붙였다.
| 리플리케이션 그래프 | Iris |
|---|---|
AlwaysRelevant 노드, NotRouted | ini의 클래스별 always/never relevant 설정 |
GridSpatialization2D | UNetObjectGridFilter와 거리 기반 Prioritizer |
AlwaysRelevant_ForConnection | never relevant로 두고 inclusion group에 추가 |
frequency bucket, DynamicSpatialFrequency, ReplicationPeriodFrame | Prioritizer. 누적 priority가 1을 넘을 때 리플리케이션하고 1 미만의 priority로 빈도를 낮춘다 |
| 연결별 Cull Distance override, dependent actor | ReplicationSystemUtil |
| dormancy 노드, Fast Shared Path, TearOff 노드 | 강연에서 언급 없음. 공식 “Migrate to Iris” 문서는 dormancy를 바뀌지 않는 기능으로 적는다 |
NetUpdateFrequency의 의미가 바뀐다는 점이 가장 큰 차이다. Iris에서는 폴링 주기일 뿐이고 언제 보낼지는 누적 priority가 정한다. 리플리케이션 그래프의 ReplicationPeriodFrame을 그대로 옮길 자리는 없다. 5.8.3 기준으로 리플리케이션 그래프는 플러그인 하나와 C++ 약 1,200줄(OpenWorld 그래프의 헤더, 구현, 설정 합계)로 켜진다. Fortnite가 2024년까지 이 경로를 썼다. Iris는 custom NetSerialize와 서브오브젝트 등록 같은 호환성 점검이 필요하고 리플레이가 아직 레거시 경로를 쓴다.
부록 A. 5.8.3에서 확인한 버그와 함정
커뮤니티에 보고된 문제 중 5.8.3 소스에서 확인한 것이다. 다섯은 테스트 프로젝트에서 재현했고 Seamless Travel 하나는 소스로만 확인했다. 소스 분석과 재현 절차는 접힌 블록에 있다.
dormant 직후의 reliable multicast가 유실된다
- 현상: 액터를
DORM_DormantAll로 보낸 직후 reliable multicast를 보내면 네트워크 지연이 있을 때 일부 클라이언트가 받지 못한다. Epic 포럼에 2026년 5월 올라온 보고(UE-378758)다. Epic이 재현을 확인했고 2026년 7월 기준 미수정이다. 테스트 프로젝트에서는PktLag=200에서 잠재운 지 4프레임 뒤에 보낸 reliable RPC 50개가 전부 사라졌고 기본 드라이버는 하나도 잃지 않았다. - 원인: 잠재운 액터의 채널이
Closing인 동안 리플리케이션 그래프는 close bunch의 ack가 올 때까지 연결 정보의 채널을 비우지 않는다. multicast 경로는 그 연결을 건너뛰고 새로 열지도 않는다. - 대처: reliable RPC를 먼저 보내고 잠재운다. 잠든 액터에 보내야 하면 그 연결의 채널이 정리된 뒤(
NotifyActorChannelCleanedUp)로 미룬다.
소스 분석과 재현 절차
기본 드라이버와 리플리케이션 그래프는 닫힌 채널을 다르게 처리한다.
SetNetDormancy를 받은 액터는 다음 리플리케이션에서StartBecomingDormant가 걸리고 ack 안 된 변경이 없으면(FObjectReplicator::ReadyForDormancy,net.DormancyHysteresis기본 0) 바로 다음 틱에Close(Dormancy)가 된다. 한동안 값이 바뀌지 않은 액터는 잠재우자마자 채널이 닫히는 셈이다.UActorChannel::Close는Closing을 세우고 엔진의ActorChannels맵에서 액터를 뺀다. 기본 드라이버는 그 순간부터 채널이 없다고 보고 다음 틱에 새 채널을 열 수 있다.- 리플리케이션 그래프는 같은 시점에 호출되는
NotifyActorChannelRemoved가 비어 있다(주석: “we’re still waiting for the close bunch to be acked”). close bunch의 ack가 와서CleanUp될 때의NotifyActorChannelCleanedUp에서야 연결 정보의Channel을 비운다. - 그 사이
ReplicateSingleActor는Closing채널을 만나면 0을 돌려주고 multicast RPC 경로는 그 연결을continue로 건너뛰며 새로 열지도 않는다. reliable이어도 재시도는 없다.

AI 50개를 같은 프레임에 DORM_DormantAll로 보내고 N프레임 뒤 액터마다 reliable multicast를 하나씩 보낸 뒤 두 클라이언트가 받은 수를 셌다. Cluster 1,000개 조건이라 50개 모두 두 클라이언트의 Cull Distance 안이다(움직이다 벗어나는 한두 개는 제외). 서버 송신에는 PktLag=200을 걸었다. 표는 두 클라이언트가 각각 받은 RPC 수다.
| RPC까지의 지연 | OpenWorld 그래프 | 기본 드라이버 |
|---|---|---|
| 4프레임 (190 ms) | 0 / 0 | 48 / 49 |
| 8프레임 (380 ms) | 32 / 32 | 측정 안 함 |
| 12프레임 | 49 / 49 | 49 / 49 |
| 16프레임 | 48 / 49 | 측정 안 함 |
| 24프레임 | 49 / 49 | 49 / 49 |
리플리케이션 그래프에서는 4프레임 뒤에 보낸 reliable RPC 50개가 전부 사라졌고 8프레임 뒤에는 18개가 사라졌다. 기본 드라이버는 같은 지연에서 잃지 않는다.
잠재우고 두어 프레임 뒤에 채널이 닫힌다. close bunch가 200 ms 뒤에 도착하고 그 ack가 돌아올 때까지 채널은 Closing이다. 잠재운 시점부터 재면 창은 8프레임(약 370 ms)에 가깝다. ack가 돌아와 채널이 정리된 연결부터 새 채널이 열리며 RPC가 다시 가므로 8프레임에서는 일부만 도착했고 12프레임부터는 전부 도착했다. 지연이 없으면 창이 로컬 ack 한 번의 길이뿐이라 어느 드라이버도 잃지 않는다.
Seamless Travel 중의 multicast
- 현상: 클라이언트가 Seamless Travel을 끝내기 전에 서버가 Persistent Level 액터(GameState 등)에 reliable multicast를 보내면 클라이언트는 도착 후 그 액터를 파괴해 버린다. Epic이 알려진 문제(UE-361443)로 확인했고 2026년 7월 기준 미수정이다. 테스트 프로젝트에서 재현하지는 않았고 소스로만 확인했다.
- 원인: owner RPC 경로는 채널을 열기 전에
NetDriver->IsLevelInitializedForActor를 확인하지만 multicast 경로는 확인하지 않는다. - 대처:
ProcessRemoteFunction을 오버라이드해 multicast에서도 같은 검사를 넣는다.
static으로 넣은 액터를 옮기면 보이지 않는다
- 현상: 스폰 직후 위치를 바꾸는 액터, 플레이어가 집어서 옮기는 소품을 static으로 두면 옛 Cell에 남아 끝까지 보이지 않는다. 포럼과 블로그에서 본 “static 액터가 뭉텅이로 사라진다”는 보고가 이 경우로 보인다.
- 원인:
AddActor_Static은 추가 시점의 위치로 Cell을 정하고 다시 계산하지 않는다. 깨워도 소용없다. Grid는AddActor_Dormancy로 들어온 액터의 dormancy 변화만 구독한다.Spatialize_Dormancy로 넣은 액터는SetNetDormancy(DORM_Awake)로 깨우면 dynamic 리스트로 옮겨져 다음PrepareForReplication에서 새 Cell에 들어간다.Spatialize_Static으로 넣은 액터는 깨워도 옛 Cell에 남는다. 액터 정보에 캐시된WorldLocation도 갱신되지 않아 거리 검사까지 옛 위치로 한다.FlushNetDormancy는 연결별 dormancy 사본만 고치고 Cell은 옮기지 않는다. - 대처: 옮길 수 있는 소품은
Spatialize_Dormancy로 두고 집을 때 깨웠다가 놓을 때 다시 재운다. 이미 static으로 등록한 액터를 옮겨야 하면 노드에서 빼고 다시 넣는다.

재현 절차
맵 반대편(2 km 밖)의 소품 5개를 플레이어 0의 Pawn에서 3–5 m 옆으로 옮기고 값을 하나 바꾼 뒤 그 클라이언트가 10 m 안에서 보는 소품 수를 30초 동안 셌다.
| 소품 상태와 조작 | 기본 드라이버 | Basic 그래프 (dormancy 경로) | OpenWorld 그래프 (static 라우팅) |
|---|---|---|---|
| 깨어 있는 소품을 옮김 | 5 | 5 | 0 |
잠든 소품을 옮기고 FlushNetDormancy | 측정 안 함 | 0 | 측정 안 함 |
잠든 소품을 DORM_Awake로 깨우고 옮김 | 측정 안 함 | 5 | 0 |
static으로 넣은 소품은 깨어 있든 깨우든 옛 Cell에 남아 끝까지 보이지 않는다. dormancy 경로도 잠든 채로 옮기면 결과가 같다. flush는 연결별 dormancy 사본에 액터를 다시 넣을 뿐 Cell을 옮기지 않는다. DORM_Awake로 깨워서 옮겼을 때만 다음 PrepareForReplication부터 5개가 보였다.
4.24 포럼 보고에 있던 “Spatialize_Static 액터를 파괴하면 ensure가 난다”는 문제는 5.8.3에서 측정 중에 소품 10개를 Destroy해도 재현되지 않았다. 그 뒤 수정된 것으로 보인다.
bNetTemporary는 지원되지 않는다
- 현상:
UReplicationGraph::AddNetworkActor는bNetTemporary액터에 대해 ensure를 내고(5.4 릴리스 노트의 “Ensure when an actor is replicated with bNetTemporary set”), 그 뒤에는 2019년 포럼의 관찰대로bNetTemporary를 무시하고 수백 번 리플리케이션한다. - 원인:
bNetTemporary액터의 첫 번치는bClose가 켜져 있어 채널을 열면서 닫는다. 기본 드라이버는SentTemporaries로 그 액터를 다시 고려하지 않는다(Engine/Private/DataChannel.cpp,NetDriver.cpp). 리플리케이션 그래프는 이 목록을 보지 않으므로 닫힌 채널이 정리되면 다음 버킷 차례에 채널을 새로 열어 다시 보낸다. - 대처: 발사체는
bNetTemporary대신 짧은 수명의 dynamic 액터로 두고 Cell 재구축 금지 목록에 넣는다.
재현 절차
테스트 프로젝트의 발사체는 bNetTemporary에 InitialLifeSpan 1.5초다. Uniform 5,000개 조건에서 초당 5개를 플레이어 주변에 스폰했다. 리플리케이션 그래프 서버 로그에는 ensure가 한 번 찍힌다.
Ensure condition failed: !Actor->bNetTemporary [File: ReplicationGraph.cpp] [Line: 790]ReplicationGraph does not support bNetTemporary. Actor: RepGraphLabProjectile_0 has bNetTemporary set.측정에서 프레임당 채널 열기가 0.02회에서 5.1회가 됐고 ServerReplicateActors는 0.35에서 0.59 ms로 늘었다. 계산상 살아 있는 발사체가 7개 안팎(초당 5개 × 1.5초)일 때의 값이다. 기본 드라이버는 같은 조건에서 2.51 ms로 발사체가 없을 때와 같다.
연결이 끊겨도 남는 dormancy 사본
- 현상: 연결이 끊겨도 Cell의 dormancy 노드에는 그 연결의 사본이 남아 있다. 테스트 프로젝트에서는 재접속 8번에 사본이 3개에서 17개로 늘었고 정리를 켜면 6개까지 줄되 톱니 모양으로 오르내렸다.
- 원인: 기본값
Net.RepGraph.DormancyNodeObsoleteBehavior0은 사본을 지우지 않는다. Epic 지원 포럼의 답변은 그 이유로 멀리 간 클라이언트의 add/remove 반복이 만드는 프레임 스파이크를 든다. 정리를 켜도 즉시는 아니다. 떼어내는 코드는CallFunctionOnValidConnectionNodes안에만 있어 그 Cell의 dormancy 노드에 액터가 추가·제거되거나 flush될 때만 돌고 gather 때는 돌지 않는다. - 대처:
DormancyNodeObsoleteBehavior를 1로 두면 연결 객체가 사라진 사본을 지운다.SetNumFramesUntilObsolete를 양수로 두면 살아 있지만 오래 gather되지 않은 연결의 사본까지 지운다. 접속·해제가 잦은 서버라면 두 설정을 같이 켜고 Cell마다 dormant 액터가 많다면 사본의 메모리까지 같이 본다.
재현 절차
Uniform 5,000개 조건으로 클라이언트 2개를 붙였다. 그 뒤 120초 동안 10초마다 두 번째 클라이언트를 죽이고 다시 띄우기를 8번 반복하면서 살아 있는 UReplicationGraphNode_ConnectionDormancyNode 수를 5초마다 셌다.
| 설정 | 시작 | 8회 재접속 뒤 |
|---|---|---|
기본값 (DormancyNodeObsoleteBehavior 0) | 3 | 17 |
DormancyNodeObsoleteBehavior 1, SetNumFramesUntilObsolete(100) | 2 | 6 (9까지 올랐다가 2로 떨어지는 톱니) |

기본값에서는 재접속마다 2개씩 늘고 하나도 줄지 않는다. 정리 옵션을 켜면 조용한 Cell의 사본이 다음 액터 변화가 있을 때까지 남아 노드 수가 9에서 5로, 7에서 2로 떨어졌다가 다시 쌓이는 톱니 모양이 됐다.
멀어진 뒤 파괴된 dormant 액터가 클라이언트에 남는다
- 현상: 클라이언트가 한 번 받은 액터(바닥 아이템)가 잠든 상태에서 그 클라이언트가 멀리 간 뒤 서버가 액터를 파괴하면 클라이언트에는 그 액터가 그대로 남는다. 테스트 프로젝트에서 플레이어를 500 m 밖으로 옮기고 원래 자리의 잠든 루트 100개를 파괴하자 리플리케이션 그래프에서는 클라이언트의 루트가 500개 그대로였고 기본 드라이버에서는 400개가 됐다. Basic 그래프도 남는다.
- 원인: 채널이 닫힐 때 relevancy 사유면 클라이언트가 액터를 지우지만 dormancy 사유면 남겨 둔다(
UActorChannel::CleanUp). 그래서 잠든 액터는 채널 없이 클라이언트에 남아 있다. 그 액터가 파괴되면 기본 드라이버는 거리와 무관하게 파괴 정보를 보낸다(NotifyActorDestroyed의DormantConnections검사). 리플리케이션 그래프는DestructInfoMaxDistanceSquared(기본 150 m, Lyra와 OpenWorld 그래프는 300 m, 높이를 뺀 2D 거리) 안의 viewer에게만 보낸다. 범위 밖이면OutOfRangeDestroyedActors에 두었다가 viewer가 충분히 움직인 뒤 다시 검사해 범위 안이면 보낸다. - 대처: 돌아오면 지워진다. 플레이어를 되돌리자 100개가 사라졌다. 멀리서도 지워져야 하는 액터는
DestructInfoMaxDistanceSquared를 늘리거나(OpenWorld 그래프의OpenWorld.RepGraph.DestructInfo.MaxDist를 1 km로 두자 바로 사라졌다) 액터마다SetActorDestructionInfoToIgnoreDistanceCulling을 건다. dynamic 액터가 잠든 채 Cell을 벗어나면 그 정리는 다른 경로(Net.RepGraph.DormantDynamicActorsDestruction, 기본 0)를 타므로 따로 켜야 한다.
재현 절차
NearPlayers 분포, 플레이어 2명을 떨어뜨린 조건에서 플레이어 0의 Pawn을 측정 5초에 500 m 옮기고 10초에 원래 자리 150 m 안의 잠든 루트 100개를 서버에서 파괴한 뒤, 그 클라이언트에 있는 루트 수를 2초마다 셌다. 각 2회.
| 조건 | 파괴 전 | 파괴 뒤 |
|---|---|---|
| 기본 드라이버 | 500 | 400 |
| Basic 그래프 (파괴 정보 거리 기본 150 m) | 500 | 500 |
| OpenWorld 그래프 (300 m) | 500 | 500 |
| OpenWorld, 15초에 플레이어를 되돌림 | 500 | 돌아온 뒤 400 |
OpenWorld, OpenWorld.RepGraph.DestructInfo.MaxDist 1 km | 500 | 400 |
부록 B. 디버깅: 끄고 비교하기, PrintAll
리플리케이션 그래프 문제가 생기면 첫 단계로 그래프를 끈다. ini의 ReplicationDriverClassName으로 켠 경우 Net.RepDriver.Enable 0이나 커맨드라인 -RepDriverDisable로 설정을 두고도 기본 드라이버로 다시 시작해 같은 현상이 나는지 본다. 둘 다 드라이버 생성 시점에만 읽힌다. Lyra처럼 CreateReplicationDriverDelegate로 만드는 프로젝트에서는 델리게이트가 먼저 반환하므로 그쪽의 끄기 설정(Lyra는 bDisableReplicationGraph)을 쓴다.
테스트 프로젝트에서는 이 비교로 두 드라이버의 차이를 하나 찾았다. 서버에서는 렌더링이 필요 없다는 이유로 테스트 액터 5,000개를 SetHidden(true)로 스폰했다. 기본 드라이버가 부르는 AActor::IsNetRelevantFor는 숨겨져 있고 충돌이 없는 액터를 관련 없음으로 판정하므로 테스트 액터가 하나도 리플리케이션되지 않았다. 이 함수를 부르지 않는 리플리케이션 그래프에서는 멀쩡했다. 아래는 Uniform 조건의 런 하나에서 두 연결에 열린 액터 채널 수의 합(프레임 평균)이다.
| 액터 상태 | 기본 드라이버 | OpenWorld 그래프 |
|---|---|---|
| 보임 | 73.4 | 62.1 |
SetHidden(true) | 28.0 (Pawn, PlayerState, 전역 액터뿐) | 78.7 |
리플리케이션 그래프 문제로 좁혀지면 서버에서 Net.RepGraph.PrintAll <프레임 수> <연결 인덱스>를 찍는다. 노드 트리와 각 리스트의 액터, 그리고 연결 하나의 우선순위 결과가 나온다. 아래는 테스트 프로젝트의 OpenWorld 그래프에서 Uniform 조건으로 찍은 출력을 줄인 것이다.
PrintAll 출력 예
Replication Frame 897ReplicationGraphNode_GridSpatialization2D_0 Tracking 1002 dynamic actors: ReplicationGraphNode_GridCell_0 Static World [17 Actors] RepGraphLabProp_219 RepGraphLabProp_357 RepGraphLabProp_449 ... Dynamic World Bucket 1 [13 Actors] RepGraphLabAI_6 RepGraphLabAI_238 RepGraphLabAI_365 ... World Bucket 2 [13 Actors] RepGraphLabAI_428 RepGraphLabAI_481 RepGraphLabVehicle_20 ... World Bucket 3 [13 Actors] RepGraphLabAI_21 RepGraphLabAI_183 RepGraphLabAI_332 ... Dormant World [158 Actors] RepGraphLabLoot_51 RepGraphLabLoot_63 RepGraphLabLoot_77 ... ReplicationGraphNode_GridCell_1 ... ReplicationGraphNode_GridCell_6 ... Dormant World [158 Actors] RepGraphLabLoot_4 RepGraphLabLoot_12 RepGraphLabLoot_51 ... Child: ReplicationGraphNode_ConnectionDormancyNode_0 World [158 Actors] RepGraphLabLoot_4 RepGraphLabLoot_12 RepGraphLabLoot_51 ...ReplicationGraphNode_ActorList_0 World [5 Actors] WorldSettings GameStateBase_0 RepGraphLabGlobal_0 RepGraphLabGlobal_1 RepGraphLabGlobal_2OpenWorldReplicationGraphNode_PlayerStateFrequencyLimiter_0 Bucket[0] [2 Actors] PlayerState_0 PlayerState_1Connection: NULL OpenWorldReplicationGraphNode_AlwaysRelevant_ForConnection_0 World [2 Actors] GameplayDebuggerCategoryReplicator_0 RepGraphLabInventory_0 Rebuilt [3 Actors] BP_ThirdPersonPlayerController_C_0 BP_ThirdPersonCharacter_C_0 PlayerState_0 Team0_0 (Team 0) Members [2 Actors] BP_ThirdPersonCharacter_C_0 BP_ThirdPersonCharacter_C_1[18 Skipped Actors]RepGraphLabProp_624 Dist Culled 18108.84RepGraphLabAI_108 Dist Culled 17347.25WorldSettings Not ready (2 frames left)PlayerState_1 Not ready (15 frames left) Culled Classes: [RepGraphLabProp, 7] [RepGraphLabAI, 5] [RepGraphLabVehicle, 3][23 Passed Actors]BP_ThirdPersonCharacter_C_0 -340282346638528859811704183484516925440.0000 (Dist: 399.78 | 0.00) (FramesLastRep: 1 | 0.95)BP_ThirdPersonCharacter_C_0 -340282346638528859811704183484516925440.0000 (Dist: 399.78 | 0.00) (FramesLastRep: 1 | 0.95)BP_ThirdPersonPlayerController_C_0 -340282346638528859811704183484516925440.0000 (Dist: 77347.03 | 1.00) (FramesLastRep: 1 | 0.95)BP_ThirdPersonCharacter_C_0 -340282346638528859811704183484516925440.0000 (Dist: 399.78 | 0.00) (FramesLastRep: 1 | 0.95)RepGraphLabAI_593 0.8515 (Dist: 2309.59 | 0.00) (FramesLastRep: 3 | 0.85)RepGraphLabAI_685 0.8697 (Dist: 8419.37 | 0.02) (FramesLastRep: 3 | 0.85)BP_ThirdPersonCharacter_C_1 0.9501 (Dist: 699.78 | 0.00) (FramesLastRep: 1 | 0.95)RepGraphLabProp_1923 0.9762 (Dist: 9709.84 | 0.03) (FramesLastRep: 1 | 0.95)RepGraphLabGlobal_0 1.9500 (Dist: 77347.03 | 1.00) (FramesLastRep: 1 | 0.95)PlayerState_0 1.9500 (Dist: 77347.03 | 1.00) (FramesLastRep: 1 | 0.95)읽는 법은 다음과 같다.
- 한 액터가 여러 Cell에 나와도 정상이다.
- Cell의 dormancy 사본이 마스터와 같은 수라면 그 사본에서 아직 빠진 액터가 없다는 뜻이다(새 연결, flush 직후, dormancy 완료 전).
[Skipped Actors]에는 거리나 주기 같은 사유가 붙는다. 거리·프레임 상세는 개발 빌드에서만 나온다.[Passed Actors]는 점수 오름차순이다. 본인 Pawn은 viewer 규칙으로-MAX_FLT이다. Grid, Team Node, 연결 노드가 각각 돌려주기 때문에 같은 Pawn이 여러 번 나온다.- Passed 목록이 기대한 액터로만 차 있는지 보면 라우팅을 검증할 수 있다.
그 밖의 명령은 다음과 같다.
Net.RepGraph.PrintAllActorInfo <문자열>: 이름이 문자열을 포함하는 액터의 클래스·전역·연결 정보를 찍어 세 층이 어떻게 복사됐는지 보여 준다.Net.RepGraph.StarvedList: 연결의 모든 액터를 마지막 리플리케이션 이후 프레임 수로 정렬해 HUD에 띄운다.Net.RepGraph.SetClassCullDistance,Net.RepGraph.SetPeriodFrame: 클래스·액터와 명령을 보낸 연결의 값을 바꾼다.
플러그인의 CVar와 명령은 50–60개로 Net.RepGraph. 접두사를 쓰고 대부분 ReplicationGraph.cpp와 ReplicationGraphDebugging.cpp에 있다. 로그 카테고리는 LogReplicationGraph다.
참고 자료
- Unreal Engine 5.8.3 소스
Engine/Plugins/Runtime/ReplicationGraph/Source/:ReplicationGraph.h,ReplicationGraphTypes.h,ReplicationGraph.cpp,ReplicationGraphDebugging.cpp,BasicReplicationGraph.cppEngine/Source/Runtime/Engine/Private/:NetDriver.cpp,DataChannel.cpp,DataReplication.cpp,NetworkObjectList.cpp,Actor.cppSamples/Games/Lyra/Source/LyraGame/System/:LyraReplicationGraph.h,LyraReplicationGraph.cpp,LyraReplicationGraphSettings.h
- Replication Graph in Unreal Engine
- Replication Graph Fast Shared Path Starting Guide
- Migrate to Iris in Unreal Engine
- Networking in 4.20: The Replication Graph, Unreal Engine Livestream, 2018
- Developing and Launching a New Replication System, Unreal Fest Orlando 2025
- 5.7 Iris Strategy, Epic Developer Community Forums
- Reliable RPCs get dropped on Replication Graph if Actor gets set dormant shortly before the reliable RPC gets called
- Clients destroy vital actors when seamless traveling and server broadcasts a multicast RPC using Replication Graph
- Guarantee replication order
- Replication Graph ignores bNetTemporary
- ReplicationGraph node teardown
글 공유하기
이 글이 도움이 되셨다면 다른 사람과 공유해 주세요!

Steady Spiral







