언리얼 엔진에서 멀티플레이를 구현할 때 가장 자주 헷갈리는 개념 중 하나가 Multicast와 Replication이다.
둘 다 서버와 클라이언트 사이에서 데이터를 맞춰주는 역할을 한다는 점은 비슷하지만, 실제로는 목적과 동작 방식이 전혀 다르다.
간단하게 비교하면 아래처럼 정리할 수 있다.
- Multicast는 순간적으로 발생하는 이벤트를 여러 클라이언트에게 빠르게 전달하는 데 적합하다.
- Replication은 계속 유지되어야 하는 상태를 서버 기준으로 자동 동기화하는 데 적합하다.
즉, 둘 다 네트워크 동기화 수단이기는 하지만, 하나는 이벤트 전달, 다른 하나는 상태 유지에 가깝다.
이번 글에서는 먼저 Multicast와 Replication의 차이를 정리하고, 그 뒤에 전직(Class Change) 이펙트를 Replication 방식으로 동기화한 코드 예시를 함께 살펴보려고 한다.
1. Multicast는 순간 이벤트 전파에 적합
Multicast는 서버가 UFUNCTION(NetMulticast)로 선언된 함수를 호출했을 때, 서버 자신과 해당 액터와 관련 있는 클라이언트들에서 같은 함수를 실행시키는 방식이다.
즉, 어떤 일이 발생한 순간 그 사실을 여러 쪽에 빠르게 퍼뜨리는 데 적합하다.
예를 들어 플레이어가 스킬을 사용해서 폭발 이펙트를 재생해야 한다고 해보자.
이때 서버에서 폭발 효과 재생용 Multicast 함수를 호출하면, 그 순간 관련 클라이언트들이 같은 이펙트를 동시에 재생하게 된다.
즉, Multicast는 아래 같은 상황에 잘 어울린다.
- 폭발 이펙트
- 사운드 재생
- 피격 연출
- 문이 열릴 때의 먼지 파티클
- 짧게 한 번 재생되고 끝나는 연출
이 방식의 장점은 분명하다.
함수를 호출하는 것만으로 즉시 원하는 로직을 여러 클라이언트에서 실행할 수 있다는 점이다.
다만 단점도 있다.
- 서버 권한이 필요하다
- 호출 빈도가 너무 높으면 네트워크 부담이 커질 수 있다
- 이미 지나간 이벤트는 새로 접속한 클라이언트가 자동으로 알 수 없다
즉, Multicast는 그 순간 보여줘야 하는 것에는 강하지만, 나중에 들어온 클라이언트도 알아야 하는 지속 상태에는 적합하지 않다.
2. Replication은 지속 상태 동기화에 적합
반면 Replication은 서버가 가진 값을 클라이언트에게 동기화하는 방식이다.
주로 UPROPERTY(Replicated) 또는 ReplicatedUsing을 통해 사용하며, 액터 자체의 위치나 회전 같은 기본 속성도 같은 개념 위에서 동작한다.
이 방식은 아래처럼 항상 최신 상태가 유지되어야 하는 값에 적합하다.
- 캐릭터 체력
- 장비 상태
- 아이템 존재 여부
- 문이 열려 있는지 여부
- 현재 직업 상태
- 현재 버프/디버프 상태
즉, Replication은 “지금 이 값이 무엇인가”를 맞추는 데 강하다.
이 방식의 장점은 아래와 같다.
- 서버가 기준이 되는 최신 상태를 자동 전송
- 클라이언트는 별도 RPC 없이 상태를 받을 수 있음
- 늦게 들어온 클라이언트도 현재 상태를 기준으로 맞춰질 수 있음
하지만 단점도 있다.
- 순간적으로 한 번만 실행되는 연출에는 적합하지 않을 수 있음
- 값이 자주 바뀌면 네트워크 부하가 커질 수 있음
- 상황에 따라 NetUpdateFrequency, MinNetUpdateFrequency 같은 값 조정이 필요함
그리고 중요한 점이 하나 더 있다.
Replication은 최신 상태를 맞추는 방식이지, 짧은 시간 동안 발생한 모든 중간 변화가 이벤트처럼 하나하나 전달되는 구조는 아니다.
그래서 일회성 이벤트보다는 지속 상태 관리에 더 잘 맞는다.
즉, Replication은 이벤트를 날리는 방식이라기보다, 상태를 유지하고 전파하는 방식이라고 보는 편이 이해하기 쉽다.
3. 실제 게임에서는 둘을 섞어서 사용하는 경우가 많음
실제 게임 개발에서는 Multicast와 Replication 중 하나만 고집하기보다, 둘을 상황에 맞게 섞어 쓰는 경우가 많다.
예를 들어 플레이어가 레버를 당겨 문을 여는 상황을 생각해 보면,
- 문이 열렸는지 여부 자체는 Replication
- 문이 열릴 때 발생하는 먼지 파티클이나 효과음은 Multicast
이런 식으로 나누는 것이 일반적이다.
즉,
- 지속되는 정보는 Replication
- 순간적으로 재생되는 피드백은 Multicast
로 분리하는 방식이 가장 깔끔하다.
특히 새로 접속하는 클라이언트도 이미 열린 문 상태를 알아야 한다면, 단순 Multicast만으로는 부족하다.
이 경우에는 RepNotify나 일반 Replication을 통해 문 상태가 계속 유지되도록 설계해야 한다.
결국 중요한 것은 이것이 한 번 발생하고 끝나는 이벤트인지, 아니면 계속 유지되어야 하는 상태인지를 먼저 구분하는 것이다.
4. RepNotify로 전직 이펙트를 동기화한 예시
아래 예시는 Multicast가 아니라, Replication + RepNotify(ReplicatedUsing)를 활용해서 전직(Class Change) 이펙트를 동기화한 코드다.
전직 이펙트 자체는 순간적으로 재생되는 연출이지만, 이번 구조에서는 bEffectActive라는 플래그를 서버에서 토글 하고, 그 값이 복제될 때 OnRep_EffectActive()를 통해 각 클라이언트에서 이펙트를 재생하도록 만들었다.
즉, “이펙트 재생”을 직접 Multicast로 날린 것이 아니라, 복제되는 상태 변경을 트리거로 삼아 이펙트를 출력하는 방식이라고 볼 수 있다.
전직 이펙트는 PlayerBase에 여러 개의 Niagara System이 미리 할당되어 있고, 해당 이펙트들을 순차적으로 또는 일괄 출력하도록 구성했다.
5. ClassComponentBase에서 이펙트 상태를 복제하도록 구성
먼저 ClassComponentBase에서 bEffectActive라는 bool 값을 ReplicatedUsing으로 선언해, 값이 복제될 때 OnRep_EffectActive()가 호출되도록 했다.
ClassComponentBase.h
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "MyGame/Character/PlayerBase.h"
#include "MyGame/Struct/AbilityData.h"
#include "ClassComponentBase.generated.h"
USTRUCT()
struct FInitClassAbility
{
GENERATED_BODY()
public:
UPROPERTY()
bool bIsPassive = false;
UPROPERTY()
TSubclassOf<UGameplayAbility> Ability;
};
UCLASS(Blueprintable, ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class MyGame_API UClassComponentBase : public UActorComponent
{
GENERATED_BODY()
public:
UClassComponentBase();
EClassType GetCharacterClass() const;
void InitClassAbility(UAbilitySystemComponent* ASC, EClassType Class, int CurrentLevel);
/**
* 전직 로직 트리거: 이펙트 토글 호출
* BP에서 직접 호출할 수 있도록 BlueprintCallable 지정
*/
UFUNCTION(BlueprintCallable, Category = "Class")
void ClassChange();
/**
* 전직 이펙트를 켜거나 끌 때 사용합니다.
* 클라이언트는 서버 RPC를 호출하고, 서버는 실제 토글 후 이펙트를 재생
*/
UFUNCTION(BlueprintCallable)
void SetEffectActive();
private:
TArray<FInitClassAbility> GetInitClassAbilityInfo(EClassType Class, int CurrentLevel) const;
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category=Class, meta=(AllowPrivateAccess="true"))
EClassType ClassType = EClassType::None;
protected:
/** 이펙트 On/Off 플래그 (변경 시 OnRep_EffectActive 호출) */
UPROPERTY(ReplicatedUsing = OnRep_EffectActive)
bool bEffectActive = false;
/** bEffectActive가 복제되어 변경될 때 클라이언트에서 호출 */
UFUNCTION()
void OnRep_EffectActive();
/** 실제 Niagara 이펙트를 Spawn */
void PlayClassChangeEffect();
/** 클라이언트가 호출하면 서버에서 실행되는 RPC */
UFUNCTION(Server, Reliable)
void Server_SetEffectActive();
/** 네트워크로 복제할 프로퍼티 등록 */
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
|
cs |
여기서 중요한 부분은 bEffectActive 자체가 이펙트 상태를 의미한다기보다, 값이 바뀌는 순간을 이용해 클라이언트에서 효과를 재생하는 트리거 역할을 하고 있다는 점이다.
6. 서버에서 플래그를 바꾸고, OnRep에서 실제 이펙트 재생
이제 SetEffectActive()에서는 서버 권한 여부를 확인한 뒤, 클라이언트라면 서버 RPC를 호출하고, 서버라면 직접 bEffectActive를 토글 하도록 했다.
그 후 값이 복제되면 각 클라이언트에서 OnRep_EffectActive()가 호출되고, 그 안에서 실제 Niagara 이펙트를 재생한다.
ClassComponentBase.cpp
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
|
#include "ClassComponentBase.h"
#include "DataRegistrySubsystem.h"
#include "Net/UnrealNetwork.h"
#include "NiagaraFunctionLibrary.h"
#include "MyGame/Character/PlayerBase.h"
#include "MyGame/Struct/AbilityData.h"
UClassComponentBase::UClassComponentBase()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true); // 컴포넌트 리플리케이션 허용
}
EClassType UClassComponentBase::GetCharacterClass() const
{
return ClassType;
}
void UClassComponentBase::InitClassAbility(UAbilitySystemComponent* ASC, EClassType Class, int CurrentLevel)
{
if (!ASC || CurrentLevel <= 0 || Class == EClassType::None)
return;
auto Infos = GetInitClassAbilityInfo(Class, CurrentLevel);
if (!Infos.IsEmpty())
{
for (auto Info : Infos)
{
FGameplayAbilitySpec Spec(Info.Ability);
ASC->GiveAbility(Spec);
if (Info.bIsPassive)
{
ASC->TryActivateAbilityByClass(Info.Ability);
}
UE_LOG(LogTemp, Log, TEXT("[%s] %s 어빌리티가 부여되었습니다."),
*GetOwner()->GetName(), *Spec.Ability->GetName());
}
}
}
/** 전직 시 이펙트 토글 로직을 호출 */
void UClassComponentBase::ClassChange()
{
SetEffectActive(); // 이펙트 켜기/끄기 로직 실행
}
/**
* 이펙트 Active 플래그를 토글
* 클라이언트는 서버 RPC 호출, 서버는 실제 토글 후 즉시 이펙트 재생
*/
void UClassComponentBase::SetEffectActive()
{
// 소유자가 서버 권한이 아니라면 RPC 호출
if (!GetOwner() || GetOwnerRole() < ROLE_Authority)
{
Server_SetEffectActive();
return;
}
// 서버(호스트)인 경우 플래그 토글 후 이펙트 재생
bEffectActive = !bEffectActive;
PlayClassChangeEffect();
}
TArray<FInitClassAbility> UClassComponentBase::GetInitClassAbilityInfo(EClassType Class, int CurrentLevel) const
{
UDataTable* DT = nullptr;
TArray<FInitClassAbility> Result;
switch (Class)
{
case EClassType::Archer:
DT = LoadObject<UDataTable>(nullptr,
TEXT("/Game/09_DataAssets/Class/DT_ArcherAbility.DT_ArcherAbility"));
break;
default:
DT = nullptr;
break;
}
if (DT)
{
auto* ASI = Cast<IAbilitySystemInterface>(GetOwner());
for (FName RowName : DT->GetRowNames())
{
const FAbilityData* RowData = DT->FindRow<FAbilityData>(RowName, TEXT("Class init"));
bool bHasRequiredLevel = RowData && CurrentLevel >= RowData->RequiredLevel;
bool bHasRequiredTag = RowData->EnhanceRequiredTags.IsEmpty() ||
(ASI->GetAbilitySystemComponent()->HasAllMatchingGameplayTags(RowData->EnhanceRequiredTags));
if (bHasRequiredLevel && bHasRequiredTag)
{
FInitClassAbility Info;
Info.Ability = RowData->Ability;
Info.bIsPassive = (RowData->Type == EAbilityType::Passive);
Result.Add(Info);
}
}
}
return Result;
}
/** bEffectActive가 업데이트되면 클라이언트에서 호출 */
void UClassComponentBase::OnRep_EffectActive()
{
PlayClassChangeEffect(); // RepNotify로 이펙트 재생
}
/** 실제 Niagara 이펙트를 스폰/붙이는 로직 */
void UClassComponentBase::PlayClassChangeEffect()
{
// 소유자를 APlayerBase로 캐스트
if (auto* Player = Cast<APlayerBase>(GetOwner()))
{
// 모든 설정된 NiagaraSystem에 대해 Spawn
for (UNiagaraSystem* FX : Player->ClassChangeEffects)
{
if (FX)
{
UNiagaraFunctionLibrary::SpawnSystemAttached(
FX,
Player->GetRootComponent(),
NAME_None,
FVector::ZeroVector,
FRotator::ZeroRotator,
EAttachLocation::KeepRelativeOffset,
true // autoDestroy
);
}
}
}
}
/** 클라이언트 → 서버 RPC 구현: bEffectActive 토글 */
void UClassComponentBase::Server_SetEffectActive_Implementation()
{
bEffectActive = !bEffectActive; // 서버에서 플래그 반전
OnRep_EffectActive(); // 서버 뷰포트에서도 즉시 재생
}
/** 네트워크로 복제할 프로퍼티를 등록 */
void UClassComponentBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UClassComponentBase, bEffectActive); // bEffectActive 복제
}
|
cs |
이 흐름을 정리하면 아래와 같다.
- 클라이언트가 전직 요청
- 서버 RPC 호출
- 서버가 bEffectActive를 토글
- 서버는 자신의 화면에서도 즉시 이펙트 재생
- 값이 복제되면서 각 클라이언트에서 OnRep_EffectActive() 호출
- 각 클라이언트가 동일한 전직 이펙트 재생
즉, 이번 구조는 상태 복제를 이용해 이펙트 재생 시점을 맞춘 방식이라고 볼 수 있다.
다만 이 방식은 상태 변화에 반응해 이펙트를 재생하는 구조이기 때문에, 매우 짧은 시간 안에 같은 값이 여러 번 토글 되는 상황에서는 중간 변화가 모두 개별 이벤트처럼 보장되지는 않을 수 있다.
그래서 전직 연출처럼 드물게 발생하는 효과에는 적합하지만, 매우 빈번한 일회성 VFX 이벤트라면 Multicast가 더 직관적인 선택이 될 수 있다.
7. Multicast 대신 RepNotify를 사용한 이유
전직 이펙트처럼 짧게 한 번 실행되는 연출만 놓고 보면, 겉보기에는 Multicast가 더 직관적으로 보일 수 있다.
하지만 이번 예시에서는 ClassComponentBase 안에서 이미 클래스 변경과 관련된 흐름을 관리하고 있었고, 그 구조 안에서 bEffectActive 플래그를 RepNotify로 묶어 처리하는 편이 더 자연스러웠다.
즉, 이번 방식의 장점은 아래와 같다.
- 기존 컴포넌트 구조 안에서 처리 가능
- 서버 권한 기준으로 명확하게 동작
- RepNotify를 이용해 클라이언트별 이펙트 실행 타이밍 통일
- 추가 상태 확장이 쉬움
다만 이 방식도 항상 정답은 아니다.
정말 단순한 일회성 연출이고 별도의 상태 유지가 필요 없다면, Multicast가 더 간단하고 직관적일 수도 있다.
결국 중요한 것은 이펙트를 이벤트로 볼 것인지, 아니면 상태 변화에 반응하는 연출로 볼 것인지를 먼저 정하는 것이다.
8. 이번 예시로 정리하는 Multicast와 Replication 차이
이번 글 내용을 기준으로 다시 정리하면 아래처럼 볼 수 있다.
Multicast가 잘 맞는 경우
- 폭발, 피격, 사운드 같은 일회성 연출
- 지금 접속 중인 모두에게 즉시 보여주면 되는 경우
- 상태로 오래 유지할 필요가 없는 경우
Replication / RepNotify가 잘 맞는 경우
- 체력, 장비, 문 상태처럼 계속 유지되어야 하는 값
- 늦게 접속한 클라이언트도 현재 상태를 알아야 하는 경우
- 상태 변경을 기준으로 후속 로직을 실행하고 싶은 경우
그리고 실제 게임에서는 이 둘을 섞어 쓰는 경우가 가장 많다.
- 상태는 Replication
- 순간 연출은 Multicast
이 기준만 분명하게 잡아도 네트워크 구조가 훨씬 깔끔해진다.
이번 글에서는 Multicast와 Replication의 차이를 정리하고, 그 예시로 전직(Class Change) 이펙트를 RepNotify 방식으로 동기화한 코드를 살펴보았다.
정리하면 핵심은 아래와 같다.
- Multicast는 순간 이벤트 전파에 적합
- Replication은 지속 상태 동기화에 적합
- 새로 접속한 클라이언트도 알아야 하는 값은 Replication 계열이 필요
- 일회성 연출은 Multicast가 더 직관적인 경우가 많음
- 이번 예시는 ReplicatedUsing + OnRep를 활용해 전직 이펙트를 동기화한 구조
즉, Multicast는 일회성 이벤트 전파에, Replication과 RepNotify는 지속 상태 동기화에 더 적합하며, 실제 멀티플레이 게임에서는 두 방식을 목적에 맞게 함께 사용하는 경우가 많다.
'Unreal Engine > Functional Implementation' 카테고리의 다른 글
| 언리얼 엔진 신규 스킬 해금 UI 구현 - 위젯 출력과 스킬창 알림 배지 만들기 (5) | 2025.08.25 |
|---|---|
| 언리얼 엔진 GAS Aura 시스템 구현 - Gameplay Tag로 직업과 직업 차수에 맞는 Aura 출력하기 (0) | 2025.08.21 |
| 언리얼 엔진 Rich Text Block Gradient 글꼴 적용 - Font Material과 Widget 애니메이션 정리 (0) | 2025.08.01 |
| NPC - Player 상호작용 (Paused) (2) | 2025.07.28 |
| 언리얼 엔진 NPC 대화 시스템 설계 - DataTable, DataAsset, Subsystem으로 구조 잡기 (1) | 2025.07.16 |