이번에는 앞서 언급만 했던 복제(Replication)에 대해 코드로 구현해보려고 한다. 이를 한 문장으로 정리하면 다음과 같다.
"중요한 게임플레이는 서버가 권위를 갖고 처리하고, 그 결과를 클라이언트에 복제한다."
① 액터 복제 → ② 권위/역할 → ③ 어태치먼트 복제 → ④ 변수 복제 → ⑤ RepNotify → ⑥ 복제 조건 → ⑦ 커스텀 조건 → ⑧ 소유권 → ⑨ 컴포넌트 복제 순서로 액터의 복제가 어떻게 이루어지는 지 상세하게 다뤄보려고 한다.
액터의 복제는 세 개의 독립적인 플래그가 책임을 나눠 가진다.
| 플래그 | 역할 |
|---|---|
bNetLoadOnClient | 맵 로드 시 클라이언트에도 이 액터를 생성 |
bReplicates | 이 액터를 복제 대상으로 등록 (bNetLoadOnClient 도 강제됨) |
bReplicateMovement | root 컴포넌트의 이동을 복제 |
여기서 가장 중요한 부분으로 bReplicates 를 켰다고 움직임까지 복제되는 게 아니다. 위치·회전 동기화는 bReplicateMovement 가 따로 책임져줘야 한다.
액터마다 위 3가지의 플래그들을 조절할 수 있다.

생성자에서도 넣을 수 있는데, 유의할 점으로 생성자에서는 setter 대신 플래그에 직접 대입한다는 것이다. 생성자에서 SetReplicates() 를 부르면 LogActor 경고가 뜨고, SetReplicateMovement() 는 virtual 이라 생성자에서 호출하면 안 된다. 대신 비-virtual 버전인 SetReplicatingMovement() 를 쓴다.
생성자에서 액터 복제를 켜는 기본 형태는 다음과 같다.
AMP_Actor::AMP_Actor()
{
bNetLoadOnClient = true;
bReplicates = true; // 복제 대상으로 등록
SetReplicatingMovement(true); // root 이동 복제
}
물리(Physics)가 켜진 솔리드 액터를 복제하지 않으면, 서버엔 있는데 클라이언트엔 없는 상태가 된다. 이때 클라이언트에서 캐릭터를 그 액터가 있는 위치로 걸어 들어가면, 서버는 "클라가 못 가는 곳에 가 있다"고 판단해 캐릭터를 도로 끌어당긴다. 이를 Rubber Banding이라고 하며, 서버와 클라이언트와의 정보가 서로 달라서 생기는 문제이다.

bReplicateMovement 는 "root 컴포넌트의 이동" 만 복제하기 때문에 물리 메시(Mesh)가 root가 아니면, 메시가 물리로 제멋대로 움직여도 복제 대상이 아니라서 클라이언트와 위치가 어긋난다.
권위(Authority) 는 "이 머신이 이 액터를 책임진다"는 뜻이다. 서버가 스폰한 액터는 서버에서 HasAuthority() == true 다.
왜 권위를 체크할까? 서버 권위 모델에서는 중요한 게임플레이가 반드시 서버에서만 일어나야 하기 때문이다. if (HasAuthority()) 로 감싸 클라이언트가 임의로 게임 상태를 바꾸는 것을 막고, 서버가 계산한 값을 복제로 내려보낸다.
void AMP_Armor::NotifyActorBeginOverlap(AActor* OtherActor)
{
Super::NotifyActorBeginOverlap(OtherActor);
if (HasAuthority())
{
IMP_Player::Execute_GrantArmor(OtherActor);
}
}
같은 액터라도 머신마다 입장이 다르다. 이걸 표현하는 게 ENetRole enum이다.
| 값 | 의미 |
|---|---|
ROLE_Authority | 권위를 가진 버전 (보통 서버) |
ROLE_AutonomousProxy | 내가 직접 조종하는 버전 (내 캐릭터) |
ROLE_SimulatedProxy | 남이 조종하고 나는 복제만 받는 버전 |
ROLE_None | 역할 없음 |
조회 함수는 두 가지다.
GetLocalRole() : 이 머신에서 이 액터의 역할GetRemoteRole() : 상대 머신에서의 역할아래는 각각 LocalRole과 RemoteRole의 차이를 나타내는 로그이다.


멀티플레이에서 어태치(attach)가 클라이언트까지 복제되려면, 붙는 대상 자체가 복제 상태여야 한다. 그리고 어태치 방식에 따라 조건이 다르다.
AttachToActor) → 붙는 액터가 bReplicates 여야 복제됨AttachToComponent) → 붙는 컴포넌트가 SetIsReplicated(true) 여야 복제됨즉, 액터만 복제하고 있으면 컴포넌트 어태치는 클라에 복제되지 않는다.
변수 하나를 복제하려면 세 단계를 전부 거쳐야 한다.
GetLifetimeReplicatedProps를 오버라이드UPROPERTY(Replicated) 매크로 부착DOREPLIFETIME 으로 해당 변수가 복제 대상임을 알림// .h
#include "Net/UnrealNetwork.h" // 변수 복제를 위한 헤더 파일
UPROPERTY(Replicated) // 복제 변수로 표시
float Armor = 0.f;
// 복제 변수를 등록하는 함수 override
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// .cpp
void AMP_Character::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMP_Character, Armor); // 복제 목록에 추가
}
정리하면 GetLifetimeReplicatedProps override →UPROPERTY(Replicated) → DOREPLIFETIME 이고, 추가로 두 가지 전제가 더 있다.
Net/UnrealNetwork.h includebReplicates 상태일 것변수 복제는 서버에서 클라이언트로만 흐르기 때문에, 클라에서 변수를 바꿔도 서버로 올라가지 않으므로, 게임 상태 불일치가 생긴다. 따라서 반드시 서버에서 값을 set 하고, 복제가 알아서 클라에 반영하게 둬야 한다.
복제 변수가 클라이언트에 도착하는 순간 함수를 자동으로 부르고 싶을 때 RepNotify 를 쓴다. 지정자를 Replicated 에서 ReplicatedUsing 으로 바꾸면 된다.
// ── .h ──────────────────────────────
UPROPERTY(ReplicatedUsing = OnRep_Armor) // 복제 시 OnRep_Armor 호출
float Armor = 0.f;
UFUNCTION() // 리플렉션 등록을 위해 필수
void OnRep_Armor(float OldArmor); // 같은 타입 파라미터 = '이전 값'
// ── .cpp ────────────────────────────
void AMP_Character::OnRep_Armor(float OldArmor)
{
// 이 함수는 'C++ 에선 클라이언트에서만' 자동 호출된다.
// OldArmor(이전 값)와 비교해 변화량 기반 로직을 처리할 수 있다.
}
여기서 두 가지가 핵심이다.
Armor = New; OnRep_Armor(Old); 처럼 set 직후 직접 호출한다.조건 없이 복제하면 4인 게임에서는 변경이 일어날 때마다 네 곳 모두에 데이터를 보낸다. 대역폭(Bandwidth)이 4배로 낭비되는 셈이다. 예를 들어 내 캐릭터의 armor 값은 사실 나한테만 의미가 있다. 이럴 때 복제 조건으로 대상을 좁힌다.
DOREPLIFETIME 대신 DOREPLIFETIME_CONDITION 을 쓰면 된다.
// PickupCount를 '소유자에게만' 복제한다 → bandwidth 1/4
DOREPLIFETIME_CONDITION(AMP_Character, PickupCount, COND_OwnerOnly);
자주 쓰는 조건은 다음과 같다.
| 조건 | 의미 |
|---|---|
COND_InitialOnly | 최초 한 번만 복제 |
COND_OwnerOnly | 소유자에게만 복제 |
COND_SkipOwner | 소유자만 빼고 복제 |
COND_SimulatedOnly | SimulatedProxy 에게만 |
COND_AutonomousOnly | AutonomousProxy 에게만 |
COND_InitialOrOwner | 최초 또는 소유자 |
COND_Custom | 런타임에 on/off (7번 참고) |
COND_OwnerOnly를 쓰려면 소유 사슬이 연결돼 있어야 한다.APawn은 possess 시점에 자동 연결되지만, 일반AActor는SetOwner()로 수동 연결해야 한다. 연결이 없으면 owning connection이 없어 어떤 클라에도 복제되지 않는다.
COND_OwnerOnly 같은 고정 조건은 게임 중에 바꿀 수 없다. "특정 아이템을 든 동안에만 복제", "관전 모드에선 끔" 처럼 조건이 런타임에 바뀌는 상황을 직접 제어하고 싶을 때 COND_Custom 을 쓴다. 필요한 건 세 가지다.
// 등록은 COND_Custom으로 (.cpp의 GetLifetimeReplicatedProps)
DOREPLIFETIME_CONDITION(AMP_Character, PickupCount, COND_Custom);
// PreReplication에서 변수 연결
void AMP_Character::PreReplication(IRepChangedPropertyTracker& ChangedPropertyTracker)
{
Super::PreReplication(ChangedPropertyTracker);
// bReplicatePickupCount가 true일 때만 PickupCount를 복제한다.
DOREPLIFETIME_ACTIVE_OVERRIDE(AMP_Character, PickupCount, bReplicatePickupCount);
}
// Build.cs에 NetCore 모듈 추가
PublicDependencyModuleNames.AddRange(new[] { ..., "NetCore" });
여기서 토글에 쓰는 bool(bReplicatePickupCount)은 서버 값만 의미가 있다. 복제 여부를 결정하는 건 서버의 권한이기 때문이다. 클라에서 이 bool을 바꿔도 서버엔 반영되지 않는다.
다만, bool이 false → true 로 바뀌는 순간, 그동안 보내지 않았던 서버의 최신 authoritative 값이 즉시 한꺼번에 복제돼 클라가 보정된다.
모든 액터에는 소유자(owner) 가 있다(SetOwner / GetOwner). pawn이 컨트롤러에 possess되면 owner가 자동으로 그 컨트롤러가 된다.
클라이언트가 접속하면 서버에 UNetConnection 과 player controller가 생성된다. 어떤 액터의 owning net connection 은, 소유 사슬을 타고 올라가다 만나는 player controller의 net connection 이다.
이 net connection이 중요한 이유는 세 곳의 기준이 되기 때문이다.
즉 "이 데이터/함수가 어느 클라이언트 머신으로 가야 하나?"를 결정하는 공통 통로가 net connection인 것이다.
여기서 오해하면 안될 요소로
그래서 player controller는 owner-only relevant라서, 클라이언트에는 자기 PC만 존재한다.
복제는 액터뿐 아니라 UActorComponent 단위로도 할 수 있다. 컴포넌트의 owner는 자동으로 그것이 붙은 액터다.
변수와 달리 컴포넌트 복제는 지정자가 아니라 함수로 켠다. 그리고 호출 위치에 따라 함수가 다르다.
UMP_HealthComponent::UMP_HealthComponent()
{
SetIsReplicatedByDefault(true);
}
SetIsReplicated(true)SetIsReplicatedByDefault(true)그리고 컴포넌트가 복제 상태가 되면, 그 안의 변수도 액터와 똑같은 3단계로 복제할 수 있다. 단, GetLifetimeReplicatedProps 를 컴포넌트 클래스에서 override 해야 한다.