Post

언리얼 RPC와 프로퍼티 리플리케이션

언리얼 RPC와 프로퍼티 리플리케이션에 대해 정리했습니다.

언리얼 RPC와 프로퍼티 리플리케이션

개요


언리얼 멀티플레이는 서버가 게임 상태의 권한을 갖고, 클라는 그 상태를 복제받는 구조이다. 이렇게 서버의 상태를 클라에 복제하는 것을 리플리케이션(Replication)이라고 하고, 복제 단위는 액터와 그 컴포넌트이다.

네트워크로 무언가를 보내는 방법은 두 가지이다.

  • 프로퍼티 리플리케이션 : 복제하도록 지정한 UPROPERTY의 값이 서버에서 바뀌면 서버가 새 값을 클라로 보낸다.
  • RPC(Remote Procedure Call) : 한 머신에서 호출한 함수가 다른 머신에서 실행된다. UFUNCTION에 Server, Client, NetMulticast를 붙여 만든다.
1
2
3
4
5
6
7
8
9
10
11
UCLASS()
class AMyCharacter : public ACharacter
{
    GENERATED_BODY()

    UPROPERTY(Replicated)
    int32 Health;          // 값: 서버의 Health가 바뀌면 클라로 복제된다

    UFUNCTION(Server, Reliable)
    void ServerFire();     // 호출: 클라가 부르면 서버에서 실행된다
};

프로퍼티 리플리케이션은 서버에서 클라로만 간다. RPC의 방향은 지정자로 정한다. Server RPC는 클라에서 서버로, Client RPC는 서버에서 그 액터를 소유한 클라로, NetMulticast RPC는 서버에서 그 액터가 관련된 모든 클라로 간다. 그래서 클라가 서버로 무언가를 보낼 방법은 Server RPC뿐이다. 클라가 자기 쪽에서 프로퍼티 값을 바꿔도 서버에는 전달되지 않는다.

두 방법의 차이는 나중에 접속하거나, 멀리 있다가 다시 관련 범위에 들어온 클라에서 드러난다. 프로퍼티는 현재 상태라서 이런 클라도 현재 값을 받는다. RPC는 그 순간의 호출이라서, 지나간 호출은 받지 못한다. 그래서 상태는 값으로, 사건은 호출로 보내는 것이 기본이다. 체력, 장착 무기, 문 열림 상태처럼 계속 유지되는 것은 프로퍼티로, 발사 요청, 효과음, 일회성 연출처럼 그 순간에 일어나는 것은 RPC로 보낸다. 이 차이로 어떤걸 언제 쓸까 판단할 수 있다.

이 글은 UE 5.8을 기준으로 한다. 5.8에는 새 리플리케이션 시스템인 Iris도 들어 있지만 기본으로 꺼져 있으므로, 기본 리플리케이션 시스템을 다룬다.


기본 전제


프로퍼티 리플리케이션과 RPC는 모두 복제되는 액터를 통해 동작한다. 그리고 그 액터에 대해 이 머신이 어떤 Role인지, 그 액터를 어느 클라가 소유하는지에 따라 동작이 달라진다.


1. 복제켜기

액터를 복제하려면 bReplicates를 켜야 한다.

1
2
3
4
AMyActor::AMyActor()
{
    bReplicates = true;   // 이 액터를 클라에 복제한다
}

실행 중에 바꿀 때는 SetReplicates(true)를 쓴다. BlueprintAuthorityOnly로 선언되어 있어 서버에서 호출해야 된다. 이 함수는 켜면 RemoteRole을 ROLE_SimulatedProxy로, 끄면 ROLE_None으로 설정한다.

컴포넌트는 생성자에서 SetIsReplicatedByDefault(true), 실행 중에는 SetIsReplicated(true)로 켠다. 컴포넌트는 따로 복제되지 않고 소유 액터의 복제에 실려 간다. 그래서 액터가 복제되지 않으면 그 컴포넌트도 복제되지 않는다.

복제되지 않는 액터는 클라와 연결된 액터 채널이 없으므로 프로퍼티도 RPC도 오갈 수 없다.


2. 네트워크 역할

네트워크 역할(ENetRole)은 이 머신이 그 액터에 대해 어떤 권한을 갖는지를 나타낸다.

  • ROLE_Authority : 액터에 대한 권한을 가진다.
  • ROLE_AutonomousProxy : 권한은 없지만 이 머신의 플레이어가 직접 조종한다.
  • ROLE_SimulatedProxy : 권한도 없고 조종도 없이, 서버에서 받은 대로 보여 준다.
  • ROLE_None : 역할 없고, 복제되지 않는다.

액터는 역할을 두 개 가진다. Role은 이 머신에서의 역할, RemoteRole은 상대 머신에서의 역할이다. 둘 다 private 멤버라서 코드에서는GetLocalRole(), GetRemoteRole()로 읽는다.

서버가 액터를 만들면 서버 입장에서 자신은 권한을 가지므로 Role은 ROLE_Authority, 상대인 클라는 받은 대로 보여주므로RemoteRole은 ROLE_SimulatedProxy이다. 이 액터가 클라로 복제되면 기준이 클라로 바뀐다. 서버가 상대의 역할로 적어둔ROLE_SimulatedProxy가 클라 자신의 Role이 되고, 서버의 ROLE_Authority는 RemoteRole이 된다. 같은 관계를 각자 자기 기준으로 적었기 때문에 두 값이 서로 바뀌어 보이게 된다. 실제로 클라가 액터를 받을 때 엔진이 Role과 RemoteRole을 서로 바꾼다.

ROLE_AutonomousProxy는 플레이어가 조종하는 액터에 붙는다. 원격 플레이어의 컨트롤러가 폰에 빙의하면 APawn::PossessedBy가SetAutonomousProxy(true)를 호출한다. 다만 서버는 이 폰을 소유하지 않은 클라에게 복제할 때, 그 순간만ROLE_SimulatedProxy로 낮춰서 보낸다. 그래서 같은 캐릭터라도 머신마다 역할이 다르다.

예를 들어 클라 A가 조종하는 캐릭터는 아래 표처럼 표현된다.

머신RoleRemoteRole
서버AuthorityAutonomousProxy
클라 A (조종하는 플레이어)AutonomousProxyAuthority
클라 B (다른 플레이어)SimulatedProxyAuthority

서버에서만 실행할 코드는 보통 HasAuthority()로 나눈다. 이 함수는 GetLocalRole() == ROLE_Authority를 반환할 뿐이다. 따라서 HasAuthority()는 “서버인가”가 아니라 “이 액터에 대한 권한이 있는가”이다. 클라가 복제되지 않는 액터를 직접 스폰하면, 그 액터의 Role은 그 클라에서 ROLE_Authority이다. 그래서 그 클라에서도 HasAuthority()가 true이다.


3. 소유권

액터의 소유 연결(owning connection)은 그 액터를 어느 클라가 소유하는지를 나타낸다. 액터의 Owner를 따라 올라가다 플레이어 컨트롤러에 닿으면, 그 컨트롤러의 네트워크 연결이 소유 연결이다.

  • AActor::GetNetConnection()은 Owner ? Owner->GetNetConnection() : nullptr이다. 소유자에게 계속 위임한다.
  • APlayerController::GetNetConnection()은 자기 NetConnection을 돌려준다.
  • APawn::GetNetConnection()은 컨트롤러가 있으면 컨트롤러에게 물어본다. 빙의할 때 PossessedBy가 SetOwner(Controller)를 호출하므로, 폰은 빙의한 플레이어의 것이 된다.

무기처럼 캐릭터가 들고 있는 액터는 스폰할 때 Owner를 캐릭터로 지정하거나 SetOwner()로 정해 주어야 그 플레이어의 것이 된다.

소유권이 중요한 이유는 뒤에서 나오긴 하지만, RPC의 경우 Server RPC와 Client RPC는 소유 연결이 있어야 보낼 수 있다. 프로퍼티에서는 COND_OwnerOnly처럼 소유자에게만 보내는 복제 조건이 소유권으로 판단된다.


RPC(Remote Procedure Call)


RPC는 UFUNCTION에 Server, Client, NetMulticast중 하나를 붙여 선언한 함수로, 호출하면 지정한 머신에서 실행된다. 어느 머신에서 실행되는지는 지정자와 호출한 머신, 그 액터의 소유자에 따라 정해진다.


1. 선언과 구현

1
2
3
4
5
6
7
8
9
10
11
12
// MyCharacter.h
UFUNCTION(Server, Reliable)
void ServerFire(FVector Direction);

// MyCharacter.cpp
void AMyCharacter::ServerFire_Implementation(FVector Direction)
{
    // 서버에서 실행될 내용
}

// 호출하는 쪽
ServerFire(GetActorForwardVector());

선언은 ServerFire, 구현은 ServerFire_Implementation에 쓴다. ServerFire의 본문은 엔진(UHT)이 자동으로 만들고, 그 본문이 호출을 네트워크에 보내거나 _Implementation을 직접 실행한다. 그래서 호출할 때는 _Implementation 없이 ServerFire를 부른다.

Reliable과 Unreliable 중 하나는 반드시 붙여야 한다. 빼면 빌드 에러가 난다.

반환값을 가질 수 없다. 결과를 돌려받으려면 반대 방향 RPC를 하나 더 만들거나 프로퍼티로 받는다.

이름 앞의 Server, Client, Multicast는 관례이다. 필수는 아니지만 호출하는 코드만 보고도 방향을 알 수 있어서 대부분 이렇게 쓴다.


2. 세 방향

  • Server : 클라에서 서버로 간다. 발사, 아이템 줍기, 문 열기처럼 클라의 요청을 서버에 전달할 때 쓴다.
  • Client : 서버에서 그 액터를 소유한 클라 한 명에게 간다. 피격 표시나 개인 알람처럼 한 플레이어에게만 필요한 것에 쓴다.
  • NetMulticast : 서버에서 실행되고, 그 액터가 관련된 모든 클라에서도 실행된다. 폭발 이펙트처럼 모두가 봐야 하는 연출에 쓴다.


3. 어디서 부르면 어디서 실행되나

같은 RPC라도 어디서 불렀는지와 액터를 누가 소유하는지에 따라 실행되는 곳이 달라진다.

서버에서 호출할 때 실행되는 곳:

액터의 소유ServerClientNetMulticast
클라가 소유서버소유 클라서버 + 관련된 모든 클라
소유 클라 없음서버서버서버 + 관련된 모든 클라


클라에서 호출할 때 실행되는 곳:

액터의 소유ServerClientNetMulticast
호출한 클라가 소유서버호출한 클라호출한 클라
다른 클라가 소유버려짐호출한 클라호출한 클라
소유 클라 없음버려짐호출한 클라호출한 클라

세 가지를 기억해야 한다.

  1. Server RPC는 자기가 소유한 액터에서 불러야 서버에 간다. 그래서 문을 열고 싶으면 문 액터의 Server RPC를 부르는게 아니라, 자기 캐릭터 또는 플레이어 컨트롤러의 Server RPC로 “이 문을 열어달라”고 요청해야 한다.
  2. NetMulticast는 서버에서 불러야 의미가 있다. 클라에서 부르면 다른 머신으로 전달되지 않고, 부른 클라에서만 실행된다.
  3. Client RPC는 원격 클라가 소유하지 않은 액터에서 부르면 서버에서 실행된다. AI가 조종하는 폰처럼 소유자가 없는 액터나, 리슨 서버를 연 호스트 자신의 캐릭터가 여기에 해당된다. 호스트는 서버와 같은 머신이라, 호스트의 캐릭터에서 부른 Client RPC는 서버에서 실행되는 것이 곧 호스트 화면에서 실행되는 것이다.


4. WithValidation

Server RPC는 클라가 보낸 값을 서버가 그대로 받아 실행한다. 조작된 클라가 말이 안 되는 값을 보낼 수 있으므로, 서버에서 실행 전에 검사할 수 있다.

1
2
3
4
5
6
7
UFUNCTION(Server, Reliable, WithValidation)
void ServerFire(FVector Direction);

bool AMyCharacter::ServerFire_Validate(FVector Direction)
{
    return Direction.IsNormalized();   // false를 반환하면 이 클라의 연결을 끊는다
}

WithValidation을 붙이면 _Validate 함수를 반드시 구현해야 한다. 붙이지 않으면 검증 없이 바로 실행된다. 5.8 기준으로 WithValidation은 선택 사항이다.

서버는 _Implementation보다 _Validate를 먼저 호출하고, false가 나오면 _Implementation을 호출하지 않고 그 클라의 연결을 끊는다.

그래서 _Validate에는 정상적인 클라라면 절대 보낼 수 없는 값(정규화되지 않은 방향, 범위를 벗어난 인덱스 등)만 검사한다. 탄약이 없는데 발사를 요청한 것처럼 정상 플레이에서도 생길 수 있는 경우는 _Implementation 안에서 무시하면 된다.


Reliable과 Unreliable


Reliable과 Unreliable은 RPC가 상대 머신에 도착하는 것과 실행 순서를 엔진이 보장할지 정하는 지정자이다. 모든 RPC는 둘 중 하나를 반드시 붙여야 한다.

1. 보장하는 것

Reliable은 반드시 도착한다. 패킷이 빠지면 상대가 받았다고 확인해 줄 때까지 엔진이 다시 보낸다. 그리고 보낸 순서대로 실행된다. 다만 이 순서는 같은 액터(그 액터의 컴포넌트 포함) 안에서만 보장된다. 서로 다른 액터에서 보낸 Reliable RPC끼리는 보낸 순서대로 실행된다는 보장이 없다.

Unreliable은 도착을 보장하지 않는다. 패킷이 빠지면 그 RPC는 그대로 사라지고, 다시 보내지 않는다. Server, Client Unreliable RPC는 네트워크가 포화 상태면 아예 보내지 않고 건너뛴다. Unreliable NetMulticast는 바로 보내지 않고 큐에 모아 두었다가, 그 액터의 다음 프로퍼티 갱신 때 함께 보낸다. 그래서 최대 액터의 갱신 주기만큼 늦게 도착할 수 있다.


2. Reliable은 하나가 빠지면 뒤의 RPC가 기다린다

Reliable은 순서를 보장하기 때문에, 중간 것이 빠지면 그게 다시 도착할 때까지 뒤의 것도 실행되지 못한다.

예를 들어 한 액터에서 Reliable RPC 5, 6, 7번을 보냈는데 5번이 담긴 패킷만 빠졌다고 해 보자. 받는 쪽에 6, 7번이 먼저 도착해도 바로 실행하지 않고 쌓아 둔다. 5번이 다시 전송되어 도착한 뒤에야 5, 6, 7번을 순서대로 실행한다. 그래서 패킷 하나가 빠지면, 그 액터의 뒤이은 Reliable RPC가 모두 재전송 시간만큼(대략 왕복 시간 한 번) 늦어진다. Unreliable은 기다리지 않는다. 빠진 것은 버리고, 도착한 것은 바로 실행한다.


3. 너무 많이 보내면 연결이 끊긴다

엔진은 상대가 받았다고 확인하기 전에 Reliable 데이터를 버리지 않고 보관한다. 이 보관 공간에는 한도가 있어서, 5.8 기준으로 한 액터 채널당 512개이다. 확인을 기다리는 Reliable 데이터가 이 한도를 넘으면 엔진은 복구할 방법이 없다고 보고, 에러와 함께 연결을 끊어 버린다.

이 문제는 보통 Reliable RPC를 매 프레임 보낼 때 생긴다. 지연이 크거나 패킷이 빠지는 환경에서는 확인이 돌아오기 전에 새 RPC가 계속 쌓이기 때문이다.


4. 어디에 써야 될까?

1
2
3
4
5
6
7
8
UFUNCTION(Server, Reliable)
void ServerUseItem(int32 SlotIndex);          // 빠지면 아이템이 사용되지 않는다

UFUNCTION(Server, Unreliable)
void ServerUpdateAim(FRotator AimRotation);   // 매 프레임 보낸다. 하나 빠져도 다음 값이 덮는다

UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayFootstep();                 // 놓쳐도 게임 진행에 영향이 없는 연출

Reliable은 빠지면 게임 진행이 틀어지고, 가끔 보내는 것에 쓴다. Unreliable은 자주 보내서 하나쯤 빠져도 다음 값이 덮어 주는 것, 또는 놓쳐도 되는 연출 등에 쓴다.

빠지면 안 되면서 자주 보내야 하는 값이라면 RPC가 아니라 프로퍼티 리플리케이션이 맞는 경우가 많다.


프로퍼티 리플리케이션


프로퍼티 리플리케이션은 서버에 있는 액터의 프로퍼티 값이 바뀌면, 엔진이 그 값을 클라에 있는 같은 액터의 프로퍼티에 자동으로 맞춰 주는 기능이다. 값은 서버에서 클라로만 간다.

RPC는 한 머신에서 호출한 함수를 다른 머신에 있는 같은 액터에서 실행하는 기능이다. 프로퍼티 리플리케이션은 함수를 실행하지 않는다. 서버는 프로퍼티의 현재 값을 보내고, 클라는 같은 액터의 프로퍼티를 그 값으로 덮어쓴다.


1. 선언

세 단계가 필요하다.

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
// MyCharacter.h
UCLASS()
class AMyCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    AMyCharacter();
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

protected:
    UPROPERTY(Replicated)
    int32 Ammo;

    UPROPERTY(ReplicatedUsing = OnRep_Health)
    float Health;

    UFUNCTION()
    void OnRep_Health(float OldHealth);
};

// MyCharacter.cpp
#include "Net/UnrealNetwork.h"

AMyCharacter::AMyCharacter()
{
    bReplicates = true;
}

void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(AMyCharacter, Ammo);
    DOREPLIFETIME(AMyCharacter, Health);
}
  1. UPROPERTY(Replicated) 또는 UPROPERTY(ReplicatedUsing = 함수명)을 붙인다. ReplicatedUsing은 값을 받았을 때 호출할 함수(OnRep)를 함께 지정한다.
  2. GetLifetimeReplicatedProps에 DOREPLIFETIME으로 등록한다. Super 호출을 빠뜨리면 부모 클래스의 프로퍼티가 리플리케이션되지 않는다.
  3. 액터가 리플리케이션되어야 한다. 앞에서 봤던 bReplicates가 true여야 한다.


2. 언제 어떤 것이 전달되는가

서버는 액터를 갱신할 차례가 오면, 각 클라에 마지막으로 보낸 값과 현재 값을 비교해서 바뀐 프로퍼티만 보낸다. 이 방식 때문에 다음과 같은 특징이 생긴다.

  1. 중간 값은 전달되지 않는다. 두 번의 갱신 사이에 체력이 100 -> 50 -> 80으로 바뀌면, 클라는 80만 받는다. 50이었던 순간은 클라에 존재하지 않는다.
  2. 패킷이 빠지면 그 시점의 최신 값을 다시 보낸다. 빠진 데이터를 그대로 재전송하는 Reliable RPC와 다르다. 프로퍼티는 “바뀌었다”는 표시만 다시 세우고, 다음 갱신 때 그 순간의 현재 값을 보낸다.
  3. 클라에서 값을 바꿔도 서버로 가지 않고, 바로 덮이지도 않는다. 서버는 자기가 마지막으로 보낸 값과 비교할 뿐, 클라 값이 바뀐 건 모른다. 그래서 서버 값이 다시 바뀌기 전까지는 클라의 잘못된 값이 그대로 남는다.
  4. 늦게 접속하거나, 관련 범위에 다시 들어온 클라도 현재 값을 받는다. 채널이 열릴 때 기본값과 다른 프로퍼티를 모두 보내기 때문이다.


3. OnRep

OnRep은 클라가 서버로부터 프로퍼티 값을 받아 적용한 뒤 호출되는 함수이다. 값이 바뀐 것에 반응해야 할 때 쓸 수 있다.

매개변수를 하나 받을 수 있고, 그 값은 바뀌기 전의 값이다. 위 예제처럼 쓰면 이전 값과 비교해서 얼마나 깎였는지를 알 수 있다. 매개변수 타입은 프로퍼티 타입과 같아야 한다.

C++에서는 서버에서 OnRep이 자동으로 호출되지 않는다. 서버는 값을 받는 쪽이 아니기 때문이다. 리슨서버의 호스트처럼 서버도 같은 반응을 해야 한다면, 값을 바꾼 뒤 직접 호출하면 된다.

블루프린트에서는 다르다. 블루프린트에서 만든 RepNotify 변수는 Set 노드로 값을 바꾸면 그 자리에서 OnRep 함수가 호출된다. 서버에서 바꿔도 호출된다.

OnRep의 호출 조건은 DOREPLIFETIME_CONDITION_NOTIFY의 마지막 인자로 정할 수 있다.

  • REPNOTIFY_OnChanged (기본값) : 받은 값이 클라의 현재 값과 다를 때만 호출한다.
  • REPNOTIFY_Always : 값을 받을 때마다 같은 값이어도 호출한다.

REPNOTIFY_Always는 클라가 예측으로 값을 미리 바꿔 두었다가 서버 값으로 확정받는 경우처럼, 같은 값이더라도 “서버가 보냈다”는 사실 자체에 반응해야 할 때 쓴다.


4. 복제 조건 (COND_*)

복제 조건은 프로퍼티를 어느 클라에 보낼지 연결마다 정하는 조건이다. DOREPLIFETIME_CONDITION으로 지정한다. 조건이 되게 많던데, 자주 쓸만한 것만 정리해 봤다.

조건보내는 대상예
COND_None (기본값)바뀔 때마다 모든 연결체력
COND_InitialOnly채널이 처음 열릴 때만생성 후 바뀌지 않는 팀 번호
COND_OwnerOnly그 액터를 소유한 클라만탄약, 인벤토리
COND_SkipOwner소유한 클라를 뺀 나머지소유자는 이미 예측해서 알고 있는 값
COND_SimulatedOnly그 액터가 SimulatedProxy인 클라만다른 사람 화면에만 필요한 연출용 값
COND_AutonomousOnly그 액터가 AutonomousProxy인 클라만 

COND_OwnerOnly는 앞에서 말한 소유 연결 기준이다. 탄약처럼 나만 알면 되는 값을 모두에게 보내지 않으면 대역폭이 줄어든다.


5. NetUpdateFrequency와 ForceNetUpdate

“갱신할 차례”를 정하는 것이 NetUpdateFrequency이다. 서버가 이 액터의 프로퍼티를 1초에 최대 몇 번 검사해서 보낼지를 뜻하며, AActor의 기본값은 100이다. 5.5부터 멤버에 직접 접근하는 방법 대신 SetNetUpdateFrequency(), GetNetUpdateFrequency()를 쓴다. 값을 낮추면 대역폭과 서버 CPU를 아낄 수 있지만, 값이 바뀌고 클라에 도착하기까지 걸리는 시간이 길어진다. 다만 서버는 한 프레임에 한 번 리플리케이션하므로, 실제 횟수는 서버의 프레임 레이트를 넘지 못한다.

ForceNetUpdate()는 이 액터를 다음 네트워크 틱의 갱신 대상에 바로 넣는 함수이다. 그 자리에서 즉시 패킷을 보내는 것이 아니라, 다음 갱신 시각을 앞당기는 것이다. 갱신 빈도를 낮게 잡아 둔 액터에서, 드물게 생기는 중요한 변화만 빨리 보내고 싶을 때 쓴다. 서버에서만 효과가 있다.


RPC와 프로퍼티 리플리케이션을 함께 쓸 때


보통 게임 개발하다보면 두 가지를 같이 쓰는 경우가 대부분이다. 클라의 요청은 RPC로 받고, 그 결과는 프로퍼티로 퍼뜨리는 식으로 두 방법을 함께 쓴다.


1. 요청은 Server RPC, 결과는 프로퍼티, 반응은 OnRep

플레이어가 문을 여는 기능을 예시로 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Door.h
UCLASS()
class ADoor : public AActor
{
    GENERATED_BODY()

public:
    ADoor();
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

    void Open();         // 서버에서만 호출

protected:
    UPROPERTY(ReplicatedUsing = OnRep_IsOpen)
    bool bIsOpen = false;

    UFUNCTION()
    void OnRep_IsOpen(); // 문이 열리는 애니메이션, 소리
};

// MyCharacter.h
UFUNCTION(Server, Reliable)
void ServerOpenDoor(ADoor* Door);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// MyCharacter.cpp
void AMyCharacter::ServerOpenDoor_Implementation(ADoor* Door)
{
    if (Door && FVector::Dist(GetActorLocation(), Door->GetActorLocation()) < 300.f)
    {
        Door->Open();
    }
}

// Door.cpp
void ADoor::Open()
{
    bIsOpen = true;
    OnRep_IsOpen();               // 서버(리슨 서버의 호스트)에서도 같은 연출을 하려면 직접 호출
}
  1. 클라가 Server RPC로 요청한다. 클라가 서버로 무언가를 보낼 방법은 Server RPC 뿐이다. 그리고 문은 클라가 소유한 액터가 아니기에 RPC를 문이 아닌 그 클라가 소유한 캐릭터나 플레이어 컨트롤러, 또는 거기에 붙은 컴포넌트(상호작용 컴포넌트 등)에 선언한다.
  2. 서버가 검증하고 상태를 바꾼다. 거리처럼 정상 플레이에서도 실패할 수 있는 조건은 _Implementation 안에서 검사하고, 통과하지 못하면 무시한다.
  3. 바뀐 상태는 프로퍼티로 퍼진다. bIsOpen이 프로퍼티이기 때문에 나중에 접속한 클라나, 멀리 있다가 다가온 클라도 문이 열린 상태로 본다.
  4. 클라는 OnRep에서 반응한다. 애니메이션이나 소리 같은 연출은 OnRep에 둔다. C++에서는 서버에서 OnRep이 자동으로 호출되지 않으므로, 서버도 같은 연출을 해야 한다면 직접 호출한다.

같은 기능을 NetMulticast로 “문 열기” 연출만 보내도 화면에서는 똑같이 동작한다. 하지만 이 방식이면 늦게 들어온 클라에게는 문이 닫혀 보인다.


2. RPC와 프로퍼티 사이의 순서는 보장되지 않는다

둘을 함께 쓸 때는 어느 쪽이 먼저 도착한다고 가정하면 안 된다. 같은 액터라도 마찬가지다.

1
2
3
4
5
void AMyCharacter::Die()          // 서버에서 실행
{
    Health = 0.f;
    MulticastPlayDeath();         // Reliable
}

코드에서는 Health를 먼저 바꿨지만, 클라에서는 MulticastPlayDeath가 먼저 실행된다. 왜냐하면 RPC는 호출하는 순간 보내고(큐에 모으는 Unreliable NetMulticast는 예외), 프로퍼티는 그 액터의 다음 갱신 때 보내기 때문이다. 그래서 MulticastPlayDeath가 실행되는 순간 클라의 Health는 아직 옛 값이다.

반대로 Reliable RPC가 담긴 패킷이 유실되면 상황이 뒤집힐 수 있다. 빠진 RPC는 재전송을 기다리고, 프로퍼티 갱신은 기다리지 않고 먼저 적용된다.

순서는 상황마다 달라지므로 기대지 않도록 설계하자. RPC 안에서 필요한 값은 프로퍼티에서 읽지 말고 RPC 매개변수로 함께 보낸다. 상태에 따라 달라지는 연출은 OnRep에서 처리하자. 쓰러진 모습처럼 늦게 들어온 클라도 봐야 하는 연출이라면 처음부터 OnRep이 맞다.


정리


 RPC프로퍼티 리플리케이션
보내는 것함수 호출프로퍼티의 현재 값
방향Server: 클라 → 서버
Client: 서버 → 소유한 클라
NetMulticast: 서버 → 관련된 모든 클라
서버 → 클라
보내는 시점호출하는 순간 (Unreliable NetMulticast는 다음 갱신 때)액터의 다음 갱신 때
패킷이 빠지면Reliable: 같은 데이터를 다시 보내고, 같은 액터의 뒤 RPC가 기다린다
Unreliable: 사라진다
그 시점의 최신 값을 다시 보낸다
늦게 들어온 클라지나간 호출은 받지 못한다현재 값을 받는다
  • 상태는 프로퍼티로, 사건은 RPC로 보낸다. 계속 유지되어 늦게 들어온 클라도 알아야 하는 것은 프로퍼티로, 그 순간에만 의미 있는 것은 RPC로 보낸다.
  • 클라가 서버로 보낼 방법은 Server RPC뿐이다. Server RPC는 그 클라가 소유한 액터에서 불러야 서버에 도착한다.
  • Reliable은 빠지면 안 되는 드문 요청에만 쓴다. 매 프레임 보내면 쌓인 Reliable 데이터가 한도를 넘어 연결이 끊길 수 있다. 자주 바뀌는 값은 프로퍼티나 Unreliable로 보낸다.
  • 요청은 Server RPC, 결과는 프로퍼티, 반응은 OnRep으로 나눈다. 이때 RPC와 프로퍼티 사이의 도착 순서에 기대지 않는다.
This post is licensed under CC BY 4.0 by the author.