언리얼 멀티플레이 전투 동기화: Animation, Attack Prediction, ActorComponent Replication
멀티플레이 캐릭터의 전투를 동기화하려면 애니메이션, 입력, 피격 판정, 체력, UI처럼 수명이 다른 정보를 함께 다뤄야 한다. 이 요소들을 모두 같은 RPC로 전달하거나 모든 변수를 복제하면 구현은 시작하기 쉽지만, 지연과 패킷 손실이 생기는 순간 결과가 어긋난다.
안정적인 구조를 만들려면 정보를 세 계층으로 나눠야 한다.
표현 계층
이동 애니메이션, Aim Offset, 공격 몽타주, 파티클
판정 계층
공격 가능 여부, 공격 시점, 충돌 검사, 데미지
상태 계층
현재 HP, 최대 HP, 사망 여부, 버프 상태
- 표현은 각 클라이언트가 복제된 상태를 바탕으로 재생한다.
- 게임 결과는 서버가 판정한다.
- 현재 상태는 Property Replication으로 모든 클라이언트가 수렴한다.
- 소유 클라이언트는 필요한 부분을 먼저 예측하고 서버 결과로 보정한다.
애니메이션을 복제하는 대신 원인이 되는 상태를 복제한다
걷기, 달리기, 점프 같은 로코모션 애니메이션은 매 프레임 애니메이션 이름을 네트워크로 보낼 필요가 없다. ACharacter와 UCharacterMovementComponent가 위치, 회전, 속도, Movement Mode를 동기화하면 각 컴퓨터의 AnimInstance가 자신의 로컬 CharacterMovement 상태를 읽어 같은 포즈를 계산할 수 있다.
소유 클라이언트 입력
→ CharacterMovement 로컬 예측
→ 이동 데이터를 서버로 전송
→ 서버 재현 및 검증
→ 서버 이동 상태를 Simulated Proxy에 복제
→ 각 컴퓨터의 AnimInstance가 로코모션 파라미터 계산
여기서 동기화되는 것은 GroundSpeed나 bIsFalling이라는 애니메이션 변수 자체가 아니다. 이동 시스템이 동기화되고, AnimInstance가 그 결과에서 애니메이션 변수를 다시 계산한다.
네트워크 역할별 CharacterMovement
| 네트워크 역할 | 존재 위치 | 이동 처리 |
| Autonomous Proxy | 캐릭터를 직접 조작하는 소유 클라이언트 | 입력을 즉시 예측하고 이동 기록을 서버에 보낸다. |
| Authority | 서버 | 클라이언트 이동을 재현하고 검증하며 권위 있는 결과를 만든다. |
| Simulated Proxy | 해당 캐릭터를 바라보는 다른 클라이언트 | 서버에서 받은 이동 상태를 보간해 표현한다. |
일반 Actor의 bReplicateMovement와 CharacterMovement의 네트워크 처리 경로를 혼동하면 안 된다. ACharacter는 CharacterMovement와 함께 이동 예측, 서버 검증, 오차 보정, Simulated Proxy 보간을 위한 전용 경로를 제공한다.
Replicate Movement를 끄면 원격 Character의 위치와 속도가 더 이상 정상적으로 전달되지 않으므로, AnimInstance가 계산하는 로코모션도 자연스럽게 동기화되지 않는다.
AnimInstance에서 로코모션 상태 계산하기
AnimInstance는 초기화 단계에서 소유 Character와 MovementComponent를 캐시하고, 매 업데이트에 필요한 표시용 값을 계산한다.
void UDXAnimInstanceBase::NativeInitializeAnimation()
{
Super::NativeInitializeAnimation();
OwnerCharacter = Cast<ADXPlayerCharacter>(TryGetPawnOwner());
if (IsValid(OwnerCharacter))
{
OwnerMovement = OwnerCharacter->GetCharacterMovement();
}
}
void UDXAnimInstanceBase::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
if (!IsValid(OwnerCharacter) || !IsValid(OwnerMovement))
{
return;
}
Velocity = OwnerMovement->Velocity;
GroundSpeed = Velocity.Size2D();
bShouldMove = GroundSpeed > 3.0f;
bIsFalling = OwnerMovement->IsFalling();
}
| 애니메이션 변수 | 파생 원본 | 별도 복제 필요 여부 |
GroundSpeed |
CharacterMovement->Velocity.Size2D() |
일반적으로 불필요 |
bIsFalling |
CharacterMovement->IsFalling() |
일반적으로 불필요 |
| 이동 방향 | Velocity와 Actor Rotation의 차이 | 일반적으로 불필요 |
| 공격 시작 | 일회성 게임플레이 이벤트 | RPC 또는 복제된 공격 상태 필요 |
| 사망 상태 | 서버가 확정한 영속 상태 | Property Replication 권장 |
GetCurrentAcceleration()은 소유 클라이언트의 입력 의도를 표현하는 데 유용하지만, Simulated Proxy에서는 동일한 정밀도로 존재한다고 가정하기 어렵다. 원격 캐릭터의 단순 Idle/Move 전환은 속도 기준으로 만들고, 가속·감속 표현이 꼭 필요할 때만 역할별 데이터를 구분하는 편이 안전하다.
Aim Offset 동기화
카메라 Pitch는 캐릭터의 Actor Rotation에 그대로 포함되지 않는 경우가 많다. 따라서 원격 캐릭터가 위아래를 바라보는 방향을 Aim Offset에 사용하려면 별도의 시선 정보가 필요하다.
직접 float AimPitch를 Tick마다 Server RPC로 보내기 전에 APawn이 이미 제공하는 기능을 확인하는 것이 좋다.
UE 5.5의 APawn은 서버에서 Controller의 Pitch를 RemoteViewPitch에 압축해 저장하고, 소유자를 제외한 클라이언트에 복제한다. GetBaseAimRotation()은 로컬 Pawn에서는 Controller의 시선 회전을, 원격 Pawn에서는 복원된 RemoteViewPitch를 이용한다.
따라서 기본적인 Aim Pitch는 다음처럼 계산할 수 있다.
void UDXAnimInstanceBase::UpdateAimOffset()
{
const FRotator AimRotation =
OwnerCharacter->GetBaseAimRotation();
const FRotator ActorRotation =
OwnerCharacter->GetActorRotation();
const FRotator DeltaRotation =
(AimRotation - ActorRotation).GetNormalized();
AimPitch = DeltaRotation.Pitch;
AimYaw = DeltaRotation.Yaw;
}
| 접근 방식 | 장점 | 주의점 |
GetBaseAimRotation() 활용 |
기존 Pawn의 RemoteViewPitch 경로를 재사용한다. | 게임의 시선 규칙이 기본 Pawn 동작과 맞는지 확인해야 한다. |
| 커스텀 Aim 값 복제 | 상체 회전, 무기 방향 등 고유 규칙을 표현할 수 있다. | 전송 빈도, 양자화, 소유자 제외 조건과 보간을 직접 설계해야 한다. |
커스텀 Aim 값을 보내야 한다면
기본 Pitch만으로 부족한 게임에서는 커스텀 데이터를 보낼 수 있다. 하지만 매 Tick마다 float가 조금이라도 달라졌다는 이유로 RPC를 보내는 방식은 피하는 것이 좋다.
- 각도를
uint8또는uint16범위로 양자화한다. - 일정 각도 이상 변했을 때만 전송한다.
- 초당 전송 횟수를 제한한다.
- Server RPC는 Unreliable로 보내고 다음 값으로 유실을 보완한다.
- 서버에서 허용 범위로 Clamp한다.
- 소유 클라이언트는 로컬 값을 사용하고 원격 프록시만 복제값을 사용한다.
- 원격 AnimInstance에서는 복제값 사이를 보간한다.
void ADXPlayerCharacter::UpdateLocalAim(float DeltaSeconds)
{
if (!IsLocallyControlled())
{
return;
}
AimSendAccumulator += DeltaSeconds;
const float NewPitch = FMath::Clamp(
FRotator::NormalizeAxis(GetControlRotation().Pitch),
-90.0f,
90.0f
);
const bool bChangedEnough =
FMath::Abs(NewPitch - LastSentAimPitch) >= 1.0f;
if (AimSendAccumulator >= 0.05f && bChangedEnough)
{
ServerUpdateAimPitch(NewPitch);
LastSentAimPitch = NewPitch;
AimSendAccumulator = 0.0f;
}
}
UFUNCTION(Server, Unreliable)
void ServerUpdateAimPitch(float NewPitch);
void ADXPlayerCharacter::ServerUpdateAimPitch_Implementation(
float NewPitch
)
{
ReplicatedAimPitch = FMath::Clamp(
NewPitch,
-90.0f,
90.0f
);
}
소유 클라이언트는 이미 자신의 입력값을 알고 있으므로 커스텀 프로퍼티도 COND_SkipOwner로 등록할 수 있다.
DOREPLIFETIME_CONDITION(
ThisClass,
ReplicatedAimPitch,
COND_SkipOwner
);
이 예시는 기본 개념을 보여 주기 위한 것이다. 실제 트래픽을 줄이려면 RPC 인자부터 복제 프로퍼티까지 압축된 정수 표현을 유지하는 편이 낫다.
공격 몽타주는 자동으로 모든 컴퓨터에서 재생되지 않는다
이동 애니메이션은 복제된 이동 상태에서 파생할 수 있지만, 근접 공격처럼 특정 순간에 시작하는 Montage는 별도의 시작 신호가 필요하다.
Root Motion Montage의 이동 데이터는 CharacterMovement 복제 경로에서 일부 처리되지만, Montage_Play() 호출 자체가 모든 컴퓨터에서 자동 실행되는 것은 아니다. 각 인스턴스가 같은 공격을 재생하도록 게임 코드에서 RPC나 복제 상태로 시작을 전달해야 한다.
| 애니메이션 유형 | 동기화 원천 | 대표 구현 |
| Idle, Walk, Run, Jump | 복제된 Velocity와 Movement Mode | Anim Blueprint State Machine |
| 짧은 근접 공격 | 공격 시작 사건 | 로컬 예측 + Server RPC + Multicast |
| 긴 채널링 공격 | 시작 시각, 단계, 취소 여부 | 복제된 공격 상태와 RepNotify |
| 사망 포즈 | 서버가 확정한 사망 상태 | ReplicatedUsing 상태에서 재생 |
서버 응답만 기다리면 입력 지연이 그대로 보인다
가장 단순한 공격 흐름은 클라이언트가 Server RPC를 보내고 서버가 승인한 뒤 Multicast로 몽타주를 재생하는 방식이다.
입력
→ Server RPC
→ 서버 승인
→ Multicast
→ 소유 클라이언트 몽타주 재생
왕복 지연이 100ms라면 버튼을 누른 뒤 약 100ms 후에 내 캐릭터가 반응할 수 있다. 지연이 커질수록 조작감이 무거워진다.
이를 줄이기 위해 소유 클라이언트는 공격 몽타주를 즉시 재생하고, 동시에 서버에 공격을 요청한다.
소유 클라이언트
입력 즉시 몽타주 재생 ───────────────┐
Server RPC 전송 │
↓
서버
공격 가능 여부 검증 → 권위 공격 시작 → 원격 프록시에 재생 신호
이것이 클라이언트 예측이다. 화면 반응은 즉시 제공하지만 공격 성공과 데미지는 서버가 확정한다.
예측을 포함한 공격 시작 구조
UFUNCTION(Server, Reliable)
void ServerRequestMeleeAttack();
UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayMeleeAttack();
소유 클라이언트는 로컬 조건을 확인한 뒤 먼저 몽타주를 재생한다.
void ADXPlayerCharacter::HandleMeleeAttackInput()
{
if (!IsLocallyControlled() || !CanPredictMeleeAttack())
{
return;
}
PlayMeleeAttackMontage();
ServerRequestMeleeAttack();
}
서버는 클라이언트의 bCanAttack을 믿지 않고 자신의 상태로 다시 검증한다.
void ADXPlayerCharacter::ServerRequestMeleeAttack_Implementation()
{
if (!CanStartMeleeAttackOnServer())
{
ClientRejectMeleeAttack();
return;
}
bCanAttack = false;
PlayMeleeAttackMontage();
MulticastPlayMeleeAttack();
GetWorldTimerManager().SetTimer(
AttackCooldownHandle,
this,
&ThisClass::FinishMeleeAttackOnServer,
MeleeAttackDuration,
false
);
}
Multicast는 소유 클라이언트와 서버에서 이미 재생한 몽타주를 다시 시작하지 않도록 구분한다.
void ADXPlayerCharacter::MulticastPlayMeleeAttack_Implementation()
{
if (HasAuthority() || IsLocallyControlled())
{
return;
}
PlayMeleeAttackMontage();
}
| 인스턴스 | 몽타주 시작 시점 | 목적 |
| 소유 클라이언트 | 입력 즉시 | 조작 지연을 숨긴다. |
| 서버 | Server RPC 승인 후 | 권위 있는 공격 타이밍과 Notify를 처리한다. |
| 다른 클라이언트 | Multicast 수신 후 | 원격 캐릭터의 공격을 표현한다. |
짧은 코스메틱 공격 몽타주는 Unreliable Multicast로도 충분할 수 있다. 한 번 유실되더라도 서버의 데미지와 다음 상태는 유지되기 때문이다. 반면 공격 상태가 여러 초 동안 지속되거나 늦게 relevant해진 플레이어도 현재 단계를 알아야 한다면 AttackState, 서버 시작 시각, Montage Section을 프로퍼티로 복제해야 한다.
서버가 거절한 예측은 보정해야 한다
로컬 조건과 서버 조건은 지연 때문에 달라질 수 있다.
- 클라이언트 화면에서는 쿨다운이 끝났지만 서버에서는 아직 끝나지 않았다.
- 서버에서 이미 기절 또는 사망 상태가 되었다.
- 이동 모드가 공격 불가능한 상태로 바뀌었다.
- 입력이 너무 빠르게 반복되었다.
서버가 공격을 거절했다면 소유 클라이언트의 예측 몽타주를 중단하거나 상태를 되돌려야 한다.
UFUNCTION(Client, Unreliable)
void ClientRejectMeleeAttack();
void ADXPlayerCharacter::ClientRejectMeleeAttack_Implementation()
{
StopMeleeAttackMontage(0.1f);
RestorePredictedAttackState();
}
거절 응답을 Reliable로 만들지는 호출 빈도와 복구 방법에 따라 결정한다. 다음 복제 상태가 곧 잘못된 예측을 덮어쓸 수 있다면 Unreliable도 선택할 수 있다.
공격 시간은 클라이언트가 결정하지 않는다
GameState->GetServerWorldTimeSeconds()는 클라이언트에서도 서버 시간에 가까운 값을 얻는 데 유용하다. 하지만 클라이언트가 보낸 공격 시각을 그대로 신뢰해 쿨다운을 판정해서는 안 된다. 클라이언트는 자신의 메모리와 RPC 인자를 수정할 수 있기 때문이다.
| 시간 정보 사용처 | 권장 기준 |
| 클라이언트 몽타주 재생 위치 보정 | 동기화된 서버 시간과 서버가 확정한 시작 시각 |
| 공격 쿨다운 승인 | 서버가 기록한 마지막 승인 시각 |
| 피격 판정 | 서버 월드 상태 또는 서버가 관리하는 과거 스냅샷 |
| 디버그 표시 | 서버·클라이언트 시각을 함께 기록 |
지연 시간을 쿨다운에서 단순히 빼 주면 높은 지연을 가진 클라이언트가 더 빠르게 공격할 수 있는 규칙이 만들어질 수 있다. 입력 반응성은 로컬 예측으로 해결하고, 공격 빈도 제한은 서버의 승인 기록으로 유지하는 편이 명확하다.
Anim Notify와 데미지 판정을 분리한다
공격 Montage의 특정 프레임에 Anim Notify를 배치하면 타격 시점을 애니메이션과 맞출 수 있다. 하지만 같은 Montage는 서버와 여러 클라이언트에서 재생되므로 Notify도 각 인스턴스에서 호출될 수 있다.
데미지 검사는 서버에서만 실행해야 한다.
void UDXAnimInstanceBase::AnimNotify_CheckMeleeHit()
{
if (IsValid(OwnerCharacter) &&
OwnerCharacter->HasAuthority())
{
OwnerCharacter->CheckMeleeAttackHitOnServer();
}
}
Montage Notify의 Tick Type을 Branching Point로 설정하면 정확한 프레임 타이밍이 필요한 Notify를 더 정밀하게 처리할 수 있다. 다만 Branching Point가 네트워크 동기화를 대신하거나 중복 호출 가능성을 제거하는 것은 아니다. 데미지 함수의 Authority 검사와 공격별 중복 피격 방지는 여전히 필요하다.
전용 서버에서 Skeletal Mesh 애니메이션 평가를 최적화하면 화면에 보이지 않는 Mesh의 Pose나 Notify가 기대한 빈도로 처리되지 않을 수도 있다. 중요한 판정을 Notify에 연결한다면 서버의 Visibility Based Anim Tick Option과 Montage 실행 여부를 반드시 검증해야 한다. 더 명시적인 구조가 필요하면 서버가 공격을 승인할 때 권위 있는 타격 타이머를 시작하고, Anim Notify는 로컬 사운드와 Trail 같은 표현 타이밍에만 사용한다.
가장 단순하고 안전한 근접 공격 판정
기본 구현에서는 서버가 공격 Notify 시점에 Sweep을 실행한다.
void ADXPlayerCharacter::CheckMeleeAttackHitOnServer()
{
check(HasAuthority());
TArray<FHitResult> HitResults;
TSet<AActor*> UniqueTargets;
const FVector Forward = GetActorForwardVector();
const FVector Start =
GetActorLocation() +
Forward * GetCapsuleComponent()->GetScaledCapsuleRadius();
const FVector End = Start + Forward * MeleeAttackRange;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
const bool bHit = GetWorld()->SweepMultiByChannel(
HitResults,
Start,
End,
FQuat::Identity,
ECC_Pawn,
FCollisionShape::MakeSphere(MeleeAttackRadius),
QueryParams
);
if (!bHit)
{
return;
}
for (const FHitResult& Hit : HitResults)
{
AActor* Target = Hit.GetActor();
if (!IsValid(Target) || UniqueTargets.Contains(Target))
{
continue;
}
UniqueTargets.Add(Target);
UGameplayStatics::ApplyDamage(
Target,
MeleeAttackDamage,
GetController(),
this,
UDamageType::StaticClass()
);
}
}
TSet으로 같은 공격에서 동일한 대상이 여러 HitResult에 잡혀도 한 번만 데미지를 적용한다. 공격 콜리전 채널은 ECC_Camera 같은 임시 채널보다 프로젝트 전용 Trace Channel을 만들어 사용하는 편이 좋다.
클라이언트가 보고한 피격 대상은 힌트일 뿐이다
로컬 클라이언트에서 Sweep을 실행하면 화면에서 맞았다고 느낀 순간을 빠르게 포착할 수 있다. 그러나 클라이언트가 ServerApplyDamage(Target)처럼 대상만 보내고 서버가 즉시 데미지를 적용하면 치팅에 취약하다.
서버는 최소한 다음을 다시 검사해야 한다.
- 서버가 승인한 공격이 진행 중인가?
- Notify에 대응하는 유효한 공격 구간인가?
- 대상이 살아 있고 공격 가능한 타입인가?
- 공격자와 대상 사이의 거리가 유효한가?
- 공격자의 전방 각도 안에 있는가?
- 벽이나 장애물로 시야가 막히지 않았는가?
- 같은 Attack ID로 이미 맞힌 대상이 아닌가?
- 클라이언트가 보낸 시각이 허용 오차 안에 있는가?
| 판정 방식 | 장점 | 비용과 위험 |
| 현재 서버 위치에서 판정 | 단순하고 서버 권위가 명확하다. | 지연이 큰 플레이어에게 판정이 늦게 느껴질 수 있다. |
| 클라이언트 대상 보고 + 서버 재검증 | 클라이언트 체감과 서버 판정을 절충할 수 있다. | 검증 항목이 빠지면 치팅에 취약하다. |
| 서버 Rewind | 클라이언트가 공격한 과거 시점의 위치로 판정할 수 있다. | 과거 스냅샷, 시간 동기화, 보간과 보안 설계가 필요하다. |
서버 Rewind는 단순히 클라이언트가 보낸 시간을 빼는 기능이 아니다. 서버가 과거 충돌 상태를 일정 시간 보관하고, 허용 범위의 시각으로 되감아 검사한 뒤 현재 월드에 결과를 적용하는 별도의 시스템이다.
공격 가능 상태는 서버가 소유한다
bCanAttack은 서버가 최종 결정해야 하는 게임플레이 상태다. 클라이언트는 로컬 예측용 복사본을 가질 수 있지만 서버의 권위 상태를 변경할 수는 없다.
UPROPERTY(ReplicatedUsing = OnRep_CanAttack)
bool bCanAttack = true;
OnRep_CanAttack()에서는 UI와 표시 상태를 갱신하고, 서버에서 값을 변경할 때는 동일한 공통 함수를 직접 호출한다.
void ADXPlayerCharacter::SetCanAttackOnServer(bool bNewCanAttack)
{
check(HasAuthority());
bCanAttack = bNewCanAttack;
ApplyCanAttackState();
}
void ADXPlayerCharacter::OnRep_CanAttack()
{
ApplyCanAttackState();
}
공격 중 이동 금지를 구현하려고 MOVE_None을 무조건 복제된 bool에 연결하면 CharacterMovement의 예측과 서버 보정이 크게 튈 수 있다. 이동 불가가 게임 규칙이라면 서버와 소유 클라이언트의 예측 경로 모두에 같은 규칙을 넣고, 거절 시 보정하는 구조를 고려해야 한다.
ActorComponent로 상태 시스템 분리하기
체력, 스태미나, 버프처럼 여러 Actor에서 재사용되는 상태는 UActorComponent로 분리할 수 있다. ActorComponent는 Owner Actor의 일부인 Subobject로 복제된다.
컴포넌트 프로퍼티가 복제되려면 두 단계가 모두 활성화되어야 한다.
ADXPlayerCharacter::ADXPlayerCharacter()
{
bReplicates = true;
StatusComponent =
CreateDefaultSubobject<UDXStatusComponent>(
TEXT("StatusComponent")
);
}
UDXStatusComponent::UDXStatusComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}
| 복제 조건 | 설정 위치 | 누락 시 결과 |
| Owner Actor 복제 | bReplicates = true |
컴포넌트를 전달할 Actor 채널 자체가 없다. |
| ActorComponent 복제 | SetIsReplicatedByDefault(true) |
컴포넌트의 프로퍼티와 RPC가 복제되지 않는다. |
| 프로퍼티 등록 | GetLifetimeReplicatedProps() |
UPROPERTY만 선언해도 값은 전송되지 않는다. |
생성자에서는 SetIsReplicatedByDefault()를 사용한다. 런타임에 동적으로 생성한 컴포넌트의 복제를 켤 때는 서버에서 생성하고 SetIsReplicated(true)를 사용하는 방식이 필요하다.
체력 컴포넌트의 서버 권위 구조
UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class UDXStatusComponent : public UActorComponent
{
GENERATED_BODY()
public:
UDXStatusComponent();
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps
) const override;
float ApplyDamage(float Damage);
float GetCurrentHealth() const { return CurrentHealth; }
float GetMaxHealth() const { return MaxHealth; }
void SetCurrentHealth(float NewCurrentHealth);
void SetMaxHealth(float NewMaxHealth);
protected:
UFUNCTION()
void OnRep_CurrentHealth();
UFUNCTION()
void OnRep_MaxHealth();
void NotifyCurrentHealthChanged();
void NotifyMaxHealthChanged();
UPROPERTY(ReplicatedUsing = OnRep_CurrentHealth)
float CurrentHealth = 100.0f;
UPROPERTY(ReplicatedUsing = OnRep_MaxHealth)
float MaxHealth = 100.0f;
};
void UDXStatusComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps
) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, CurrentHealth);
DOREPLIFETIME(ThisClass, MaxHealth);
}
상태 변경 함수는 Owner Actor의 Authority를 확인한다.
float UDXStatusComponent::ApplyDamage(float Damage)
{
AActor* OwnerActor = GetOwner();
if (!IsValid(OwnerActor) || !OwnerActor->HasAuthority())
{
return 0.0f;
}
const float ActualDamage = FMath::Clamp(
Damage,
0.0f,
CurrentHealth
);
CurrentHealth = FMath::Clamp(
CurrentHealth - ActualDamage,
0.0f,
MaxHealth
);
NotifyCurrentHealthChanged();
return ActualDamage;
}
클라이언트는 복제값이 도착했을 때 RepNotify에서 로컬 Delegate를 발생시킨다.
void UDXStatusComponent::OnRep_CurrentHealth()
{
NotifyCurrentHealthChanged();
}
void UDXStatusComponent::OnRep_MaxHealth()
{
NotifyMaxHealthChanged();
}
C++ RepNotify가 서버에서도 자동으로 호출된다고 가정하지 않고, 서버의 Setter와 클라이언트의 OnRep가 같은 알림 함수를 호출하도록 만든다.
Delegate는 네트워크가 아니라 로컬 알림 통로다
OnCurrentHealthChanged.Broadcast()는 다른 컴퓨터로 전송되는 네트워크 Multicast가 아니다. 같은 프로세스 안의 리스너에게 알리는 C++ Delegate다.
서버
CurrentHealth 변경
→ Property Replication
클라이언트
CurrentHealth 수신
→ OnRep_CurrentHealth()
→ 로컬 Delegate Broadcast
→ HP Widget 갱신
| 기능 | 범위 | 용도 |
| NetMulticast RPC | 서버와 관련 클라이언트 | 네트워크를 통한 일회성 함수 실행 |
| C++ Multicast Delegate | 현재 프로세스 내부 | 상태 변경을 UI와 다른 로컬 시스템에 알림 |
| RepNotify | 복제값을 받은 로컬 인스턴스 | 네트워크 상태를 로컬 표현으로 연결 |
Widget이 생성되기 전에 OnRep가 먼저 호출될 수 있으므로, Widget 초기화 시 현재값을 한 번 읽고 그다음 Delegate를 구독하는 패턴이 안전하다.
void UDXHealthWidget::InitializeFromStatus(
UDXStatusComponent* Status
)
{
UpdateCurrentHealth(Status->GetCurrentHealth());
UpdateMaxHealth(Status->GetMaxHealth());
Status->OnCurrentHealthChanged.AddUObject(
this,
&ThisClass::UpdateCurrentHealth
);
}
ActorComponent의 네트워크 생명주기
| 함수 | 호출 시점 | 적합한 작업 |
InitializeComponent() |
컴포넌트 등록 후, 게임 시작 전 | 일반적인 내부 상태와 참조 초기화 |
ReadyForReplication() |
초기화 후, 복제 컴포넌트의 Owner가 네트워크 복제 준비를 마친 시점 | 컴포넌트가 보유한 추가 Replicated Subobject 등록 |
BeginPlay() |
Owner Actor가 플레이를 시작할 때 | 다른 게임플레이 객체와 상호작용하는 로직 |
기본 UPROPERTY 복제만 사용하는 상태 컴포넌트라면 GetLifetimeReplicatedProps()와 복제 활성화로 충분하다. ReadyForReplication()은 컴포넌트가 다시 별도의 Replicated UObject를 보유하고 등록해야 할 때 특히 유용하다.
ActorComponent 자체에는 Actor의 LocalRole과 RemoteRole이 없다. 네트워크 로그를 남길 때는 Owner의 역할을 확인해야 한다.
void UDXStatusComponent::LogNetworkState() const
{
const AActor* OwnerActor = GetOwner();
if (!IsValid(OwnerActor))
{
return;
}
UE_LOG(
LogTemp,
Log,
TEXT("[%s][%s/%s] HP=%.1f"),
*UEnum::GetValueAsString(GetNetMode()),
*UEnum::GetValueAsString(OwnerActor->GetLocalRole()),
*UEnum::GetValueAsString(OwnerActor->GetRemoteRole()),
CurrentHealth
);
}
매크로에서 무조건 GetOwner()->GetLocalRole()을 호출하면 초기화나 파괴 시점의 null Owner 때문에 문제가 생길 수 있으므로 유효성 검사를 포함하는 함수가 디버깅에 더 안전하다.
조건부 컴포넌트 프로퍼티 복제
컴포넌트의 모든 상태를 모든 클라이언트에 보낼 필요는 없다.
void UDXStatusComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps
) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, CurrentHealth);
DOREPLIFETIME_CONDITION(
ThisClass,
MaxHealth,
COND_OwnerOnly
);
}
COND_OwnerOnly의 Owner는 컴포넌트가 아니라 컴포넌트를 소유한 Actor의 Owning Connection을 기준으로 결정된다.
| 상태 | 가능한 복제 조건 | 판단 기준 |
| 현재 HP | 모든 relevant 클라이언트 | 다른 플레이어의 월드 HP 바에 필요한가? |
| 최대 HP | COND_OwnerOnly 가능 |
원격 클라이언트가 HP 비율을 계산하지 않아도 되는가? |
| 스태미나 | COND_OwnerOnly 가능 |
본인 UI에만 필요한 정보인가? |
| 사망 여부 | 모든 relevant 클라이언트 | 충돌과 애니메이션을 모두가 알아야 하는가? |
원격 HP 바가 CurrentHealth / MaxHealth 비율을 표시한다면 MaxHealth도 원격 클라이언트에 필요하다. 이 경우 MaxHealth를 숨기면서 비율만 별도 양자화해 복제하거나, 두 값을 모두 보내는 선택을 비교해야 한다. 조건부 복제는 데이터 사용처를 먼저 분석한 뒤 적용해야 한다.
월드 UI는 복제하지 않는다
WidgetComponent와 UserWidget 자체를 네트워크로 복제하는 것이 아니라, 각 클라이언트가 로컬 Widget을 생성하고 복제된 상태 컴포넌트를 표시한다.
Replicated Character
└─ Replicated StatusComponent
└─ OnRep → Local Delegate
└─ Local HP Widget
월드 공간 HP 바가 항상 로컬 카메라를 바라보게 하는 회전도 각 렌더링 클라이언트에서 계산해야 한다. 데디케이티드 서버에는 카메라와 화면이 없으므로 실행할 필요가 없다.
void ADXPlayerCharacter::UpdateHealthWidgetFacing()
{
if (GetNetMode() == NM_DedicatedServer ||
!IsValid(HealthWidgetComponent))
{
return;
}
APlayerCameraManager* CameraManager =
UGameplayStatics::GetPlayerCameraManager(this, 0);
if (!IsValid(CameraManager))
{
return;
}
const FVector WidgetLocation =
HealthWidgetComponent->GetComponentLocation();
const FRotator LookRotation =
UKismetMathLibrary::FindLookAtRotation(
WidgetLocation,
CameraManager->GetCameraLocation()
);
HealthWidgetComponent->SetWorldRotation(LookRotation);
}
Authority 여부로 화면 로직을 제외하면 리슨 서버 호스트의 로컬 화면까지 빠질 수 있다. 렌더링 제외 목적이라면 NM_DedicatedServer를 구분하는 편이 정확하다.
버프 상자도 서버가 소비를 확정한다
Overlap 이벤트는 서버와 클라이언트의 각 액터 인스턴스에서 발생할 수 있다. 버프와 아이템 소비는 서버에서 한 번만 확정해야 한다.
void ADXBuffBox::OnOverlapBegin(
UPrimitiveComponent* OverlappedComponent,
AActor* OtherActor,
UPrimitiveComponent* OtherComponent,
int32 OtherBodyIndex,
bool bFromSweep,
const FHitResult& SweepResult
)
{
if (!HasAuthority() || bConsumed)
{
return;
}
ADXPlayerCharacter* Character =
Cast<ADXPlayerCharacter>(OtherActor);
if (!IsValid(Character))
{
return;
}
bConsumed = true;
SetActorEnableCollision(false);
Character->TakeBuffOnServer(50.0f);
MulticastPlayPickupEffect();
SetLifeSpan(1.0f);
}
void ADXPlayerCharacter::TakeBuffOnServer(float BuffValue)
{
check(HasAuthority());
StatusComponent->SetMaxHealth(
StatusComponent->GetMaxHealth() + BuffValue
);
StatusComponent->SetCurrentHealth(
StatusComponent->GetCurrentHealth() + BuffValue
);
}
| 버프 상자 요소 | 처리 위치 | 동기화 방식 |
| Overlap 승인 | 서버 | Authority 검사 |
| HP와 MaxHP 증가 | 서버 | StatusComponent Property Replication |
| 획득 파티클과 사운드 | 관련 클라이언트 | NetMulticast RPC |
| 상자 제거 | 서버 | 서버의 Actor Destroy 복제 |
서버가 즉시 Destroy()하면 Unreliable 효과 RPC가 액터 제거와 경쟁할 수 있다. 효과가 꼭 보여야 한다면 제거를 짧게 지연하거나, 독립적인 Effect Actor를 스폰하거나, 소비 상태의 RepNotify에서 로컬 효과를 재생하는 방식을 검토할 수 있다.
전체 동기화 흐름
1. 소유 클라이언트가 공격 버튼을 누른다.
2. 소유 클라이언트는 몽타주를 즉시 예측 재생한다.
3. Server RPC로 공격 의도를 전송한다.
4. 서버는 쿨다운, 상태, 호출 권한을 검증한다.
5. 서버가 권위 있는 공격 상태와 몽타주를 시작한다.
6. 다른 클라이언트는 Multicast 또는 복제 공격 상태로 몽타주를 재생한다.
7. 서버 Anim Notify 시점에 Sweep을 수행한다.
8. 서버가 StatusComponent의 CurrentHealth를 변경한다.
9. 컴포넌트 프로퍼티가 클라이언트로 복제된다.
10. OnRep가 로컬 Delegate를 발생시켜 HP Widget을 갱신한다.
| 데이터 | 권위 | 전달 방식 | 복구 방법 |
| 이동 위치와 속도 | 서버 검증 | CharacterMovement 복제 | 서버 보정과 Simulated Proxy 보간 |
| Aim Pitch | 소유 입력, 서버 전달 | RemoteViewPitch 또는 양자화 프로퍼티 | 다음 업데이트와 보간 |
| 공격 몽타주 | 서버 승인 | 로컬 예측과 RPC | 거절 응답 또는 공격 상태 복제 |
| 피격과 데미지 | 서버 | 서버 로직 | 권위 상태가 최종 결과 |
| 현재 HP | 서버 | ActorComponent Property Replication | 늦은 접속과 relevancy 재진입 시 최신값 수신 |
| HP Widget | 각 로컬 클라이언트 | OnRep와 로컬 Delegate | Widget 초기화 시 현재 상태 재조회 |
NumberBaseball 구조와 연결하기
현재 NumberBaseball 프로젝트에는 Character 전투 클래스가 없지만, ANBGameStateBase의 턴 상태가 같은 설계 원리를 사용한다.
UPROPERTY(ReplicatedUsing = OnRep_TurnState)
FNBTurnState TurnState;
void ANBGameStateBase::OnRep_TurnState()
{
OnTurnStateChanged.Broadcast();
}
서버가 TurnState를 변경하고, 클라이언트는 OnRep_TurnState()에서 로컬 Delegate를 발생시켜 턴 UI를 갱신한다. StatusComponent의 CurrentHealth → OnRep → Delegate → HP Widget 흐름과 동일하다.
게임에 체력이나 버프 시스템을 추가한다면 GameState에 모든 플레이어의 세부 상태를 몰아넣기보다, 각 Character 또는 PlayerState에 재사용 가능한 상태 컴포넌트를 두는 구조를 검토할 수 있다.
디버깅 체크리스트
| 증상 | 확인할 항목 |
| 원격 캐릭터는 움직이지만 애니메이션이 멈춰 있다. | AnimInstance의 Owner 캐시, MovementComponent Velocity, State Machine 전환식을 확인한다. |
| 원격 Aim Offset이 갱신되지 않는다. | GetBaseAimRotation(), RemoteViewPitch, 커스텀 값의 복제 조건과 보간을 확인한다. |
| 내 공격 몽타주가 지연된다. | 서버 응답 후에만 재생하는지, 소유 클라이언트 예측이 있는지 확인한다. |
| 공격 몽타주가 두 번 시작된다. | 소유 클라이언트가 예측 재생한 뒤 Multicast를 다시 실행하는지 확인한다. |
| 한 번 공격했는데 데미지가 여러 번 적용된다. | Notify의 Authority 검사, 공격별 피격 대상 Set, Notify 배치와 Montage 재생 횟수를 확인한다. |
| HP가 서버에서만 변한다. | Owner Actor와 StatusComponent의 복제 활성화, DOREPLIFETIME 등록을 확인한다. |
| HP는 복제되지만 Widget이 갱신되지 않는다. | RepNotify, Delegate 바인딩 시점, Widget 생성 전 상태 수신 여부를 확인한다. |
| 리슨 서버 호스트의 월드 UI가 카메라를 보지 않는다. | 표현 로직을 Authority로 제외하지 않았는지, Dedicated Server만 제외했는지 확인한다. |
| 버프가 여러 번 적용된다. | Overlap Authority 검사, 소비 상태, 충돌 비활성화 순서를 확인한다. |
네트워크 테스트에서는 정상 환경만 확인하지 말고 패킷 지연과 손실을 적용해야 한다. 로컬 예측이 중복 재생되지 않는지, 서버 거절이 보정되는지, Unreliable 효과를 잃어도 HP와 사망 상태가 정상적으로 수렴하는지를 각각 확인해야 한다.
'UE > 멀티플레이' 카테고리의 다른 글
| UE C++ 언리얼 멀티플레이 서버 및 생명주기 (0) | 2026.08.11 |
|---|---|
| UE C++ 숫자야구 (0) | 2026.08.10 |
| UE C++ RPC오너십, 프로퍼티 리플리케이션 (1) | 2026.08.06 |
| UE C++ 액터 리플리케이션 (0) | 2026.08.05 |
| UE C++ 멀티플레이 게임 프레임워크 (1) | 2026.08.04 |