TIL: Unreal Engine 상속과 인터페이스, NetUpdateFrequency, NetDormancy를 통한 네트워크 최적화 이해하기
오늘 배운 내용
오늘은 Unreal Engine에서 상속과 인터페이스의 차이, NetUpdateFrequency의 의미, ForceNetUpdate(), 클라이언트 보간 처리, 그리고 NetDormancy를 사용하는 이유에 대해 정리했다.
처음에는 상속과 인터페이스가 모두 기능을 재사용하거나 공통 기능을 만들기 위한 개념처럼 보여서 헷갈렸다.
하지만 정리해보면 상속은 부모 클래스의 기능과 속성을 물려받는 구조이고, 인터페이스는 서로 다른 클래스들이 같은 기능을 가지고 있다고 약속하는 구조다.
또 NetUpdateFrequency, ForceNetUpdate(), NetDormancy, FlushNetDormancy()는 모두 멀티플레이에서 서버와 클라이언트 간 복제 비용을 줄이거나, 필요한 순간에만 복제되도록 조절하는 네트워크 최적화 기능과 관련이 있다.
핵심은 다음과 같다.
상속
→ "~의 한 종류다" 관계
인터페이스
→ "~할 수 있다" 관계
NetUpdateFrequency
→ 액터를 최대 얼마나 자주 복제할지 정하는 값
ForceNetUpdate()
→ 다음 네트워크 업데이트 때 빠르게 복제되도록 요청
NetDormancy
→ 자주 안 바뀌는 액터를 휴면 상태로 두어 복제 비용을 줄이는 최적화
FlushNetDormancy()
→ 휴면 상태 액터를 깨워 변경사항을 복제할 수 있게 함
1. 상속의 의미
상속은 부모 클래스의 변수와 함수를 자식 클래스가 물려받는 구조다.
예를 들어 Unreal Engine에서 플레이어 캐릭터를 만들 때 보통 ACharacter를 상속받는다.
class AMyCharacter : public ACharacter
{
};
이 말은 AMyCharacter가 ACharacter의 기능을 물려받는다는 뜻이다.
ACharacter에는 이미 캐릭터를 만들기 위한 여러 기능이 들어 있다.
이동
점프
캡슐 충돌
CharacterMovement
애니메이션 처리
Controller에게 Possess 되는 구조
따라서 AMyCharacter는 이 기능들을 처음부터 다시 만들 필요 없이 사용할 수 있다.
상속은 다음과 같은 관계로 이해하면 쉽다.
AMyCharacter는 ACharacter의 한 종류다.
즉, 상속은 is-a 관계다.
PlayerCharacter is a Character.
MonsterCharacter is a Character.
NPCCharacter is a Character.
2. 상속을 쓰면 좋은 상황
상속은 공통 기능과 데이터를 여러 클래스가 함께 사용할 때 적합하다.
예를 들어 플레이어, 몬스터, NPC가 모두 캐릭터처럼 움직이고 HP도 가진다면, 공통 부모 클래스를 만들 수 있다.
BaseCharacter
├─ PlayerCharacter
├─ MonsterCharacter
└─ NPCCharacter
부모 클래스에 HP, 데미지 처리, 사망 처리 같은 공통 기능을 넣어둘 수 있다.
class ABaseCharacter : public ACharacter
{
public:
float HP;
void TakeDamage();
void Die();
};
그러면 자식 클래스들은 이 기능을 물려받는다.
class APlayerCharacter : public ABaseCharacter
{
};
class AMonsterCharacter : public ABaseCharacter
{
};
class ANPCCharacter : public ABaseCharacter
{
};
이런 경우에는 상속이 자연스럽다.
왜냐하면 플레이어, 몬스터, NPC 모두 캐릭터의 한 종류이기 때문이다.
3. 인터페이스의 의미
인터페이스는 기능을 직접 물려받는다기보다, 특정 기능을 가지고 있다고 약속하는 구조다.
예를 들어 게임에 다음과 같은 오브젝트들이 있다고 하자.
문
상자
NPC
아이템
스위치
이 오브젝트들은 서로 같은 종류가 아니다.
문은 NPC가 아니다.
상자는 캐릭터가 아니다.
아이템은 문이 아니다.
하지만 공통점이 있다.
플레이어가 상호작용할 수 있다.
이럴 때 Interact라는 인터페이스를 만들 수 있다.
BPI_Interactable
└─ Interact()
이 인터페이스를 구현한 오브젝트들은 모두 Interact() 기능을 가지고 있다고 약속하는 것이다.
각 오브젝트는 자기 방식대로 Interact()를 구현한다.
문
→ Interact 실행 시 문 열기
상자
→ Interact 실행 시 상자 열기
NPC
→ Interact 실행 시 대화 시작
아이템
→ Interact 실행 시 아이템 줍기
스위치
→ Interact 실행 시 스위치 작동
인터페이스는 다음과 같은 관계로 이해하면 쉽다.
이 객체는 상호작용할 수 있다.
즉, 인터페이스는 can-do 관계다.
Door can interact.
Chest can interact.
NPC can interact.
4. 상속과 인터페이스의 차이
상속과 인터페이스의 가장 큰 차이는 관계의 성격이다.
상속
→ "~의 한 종류다"
인터페이스
→ "~할 수 있다"
표로 정리하면 다음과 같다.
구분상속인터페이스
| 의미 | 부모 클래스의 기능과 속성을 물려받음 | 특정 기능을 가지고 있다고 약속함 |
| 관계 | "~이다" | "~할 수 있다" |
| 예시 | MyCharacter는 Character다 | Door는 Interact할 수 있다 |
| 변수 상속 | 가능 | 보통 불가능 |
| 함수 구현 상속 | 가능 | 보통 함수 이름만 약속 |
| 사용 목적 | 공통 기능 재사용 | 서로 다른 클래스를 같은 방식으로 호출 |
| 대표 예시 | Character 상속, Actor 상속 | Interactable, Damageable |
5. 인터페이스가 필요한 이유
만약 상호작용 가능한 오브젝트를 전부 상속으로 처리하려고 하면 문제가 생길 수 있다.
예를 들어 AInteractableActor라는 부모 클래스를 만들었다고 하자.
AInteractableActor
├─ Door
├─ Chest
└─ Item
문, 상자, 아이템은 AActor 기반이라면 이 구조가 가능할 수 있다.
하지만 NPC는 이미 ACharacter를 상속받고 있을 수 있다.
NPC → ACharacter
Unreal Engine의 UCLASS 구조에서는 보통 하나의 주요 부모 클래스만 상속받는 방식으로 설계한다.
그러면 NPC를 동시에 ACharacter이면서 AInteractableActor로 만들기 어렵다.
이럴 때 인터페이스가 유용하다.
Door → Actor + BPI_Interactable
Chest → Actor + BPI_Interactable
NPC → Character + BPI_Interactable
Item → Actor + BPI_Interactable
이렇게 하면 부모 클래스가 서로 달라도 같은 인터페이스를 붙일 수 있다.
플레이어는 대상이 문인지, 상자인지, NPC인지 몰라도 된다.
대상이 BPI_Interactable을 가지고 있는가?
↓
가지고 있으면 Interact 호출
즉, 인터페이스는 서로 다른 클래스를 같은 방식으로 다루고 싶을 때 사용한다.
6. NetUpdateFrequency의 의미
NetUpdateFrequency는 액터가 서버에서 클라이언트로 얼마나 자주 복제될 수 있는지를 정하는 값이다.
예를 들어 다음과 같이 설정하면,
SetNetUpdateFrequency(1.f);
이 액터는 최대 1초에 1번 정도 복제 시도 대상이 된다.
NetUpdateFrequency = 1
→ 1초에 최대 1번 복제 시도
하지만 중요한 점은 NetUpdateFrequency가 정확한 복제 주기를 보장하는 값은 아니라는 것이다.
NetUpdateFrequency는 최대치일 뿐
정확히 그 주기마다 복제된다고 보장하지 않는다.
예를 들어 서버 Tick Rate가 30이고 NetUpdateFrequency가 100이라면, 실제로 초당 100번 복제될 수 없다.
서버 Tick Rate = 30
NetUpdateFrequency = 100
결과:
초당 100번 복제 불가능
최대 초당 30번 수준
또 서버 성능, 액터 수, 네트워크 대역폭, 액터 우선순위, Relevancy 등에 따라 실제 복제 타이밍은 달라질 수 있다.
7. ForceNetUpdate()의 의미
ForceNetUpdate()는 액터의 변경사항을 다음 네트워크 업데이트 때 최대한 빨리 복제하도록 요청하는 함수다.
예를 들어 HP가 바뀌었을 때 바로 클라이언트에 알려주고 싶다면 다음처럼 사용할 수 있다.
void AMyCharacter::SetHP(float NewHP)
{
if (HasAuthority())
{
HP = NewHP;
ForceNetUpdate();
}
}
흐름은 다음과 같다.
서버에서 HP 변경
↓
ForceNetUpdate() 호출
↓
다음 네트워크 업데이트 때 빠르게 복제 시도
↓
클라이언트에서 OnRep_HP() 호출
하지만 ForceNetUpdate()도 완전한 즉시 전송 보장은 아니다.
정확히는 다음 의미에 가깝다.
지금 당장 무조건 보내라
가 아니라,
가능한 다음 네트워크 업데이트 때 우선적으로 보내라
에 가깝다.
따라서 NetUpdateFrequency는 최대 빈도를 조절하는 값이고, ForceNetUpdate()는 특정 순간에 빠른 복제를 요청하는 함수라고 볼 수 있다.
8. Reliable RPC와 Replication의 차이
상태값을 동기화할 때는 replicated 변수와 OnRep을 사용한다.
예를 들면 다음과 같다.
HP
점수
팀
현재 무기
문 열림 상태
사망 여부
준비 완료 여부
이런 값들은 현재 상태가 중요하다.
현재 HP가 얼마인가?
현재 문이 열려 있는가?
현재 무기가 무엇인가?
그래서 Replicated 변수와 OnRep이 적합하다.
반면 한 번만 발생하는 이벤트는 RPC가 더 적합하다.
접속 알림 출력
채팅 메시지 출력
공격 요청
스킬 사용
아이템 줍기 요청
이런 것은 계속 유지되는 상태가 아니라 특정 순간에 발생하는 동작이다.
예를 들어 접속 알림 메시지는 한 번 출력되면 된다.
"Player2 has joined the game."
이런 경우는 NetUpdateFrequency나 RepNotify보다 Reliable Client RPC가 더 자연스럽다.
UFUNCTION(Client, Reliable)
void Client_ShowJoinMessage(const FString& Message);
void AMyPlayerController::Client_ShowJoinMessage_Implementation(const FString& Message)
{
ShowMessageOnUI(Message);
}
정리하면 다음과 같다.
계속 유지되어야 하는 값
→ Replicated 변수 + OnRep
한 번만 발생하는 이벤트
→ RPC
9. DXBox 코드의 목적
DXBox 코드에서는 박스 액터의 NetUpdateFrequency를 1로 설정했다.
const static float BoxActorNetUpdateFrequency = 1.f;
SetNetUpdateFrequency(BoxActorNetUpdateFrequency);
즉 서버는 이 박스의 회전값을 대략 1초에 한 번 정도만 클라이언트에 복제하려고 한다.
NetUpdateFrequency = 1
→ 1초에 최대 1번 복제 시도
그리고 다음 코드로 복제 주기를 계산한다.
NetUpdatePeriod = 1 / GetNetUpdateFrequency();
현재 NetUpdateFrequency가 1이므로,
NetUpdatePeriod = 1 / 1
NetUpdatePeriod = 1초
즉 이 박스는 클라이언트 기준으로 서버 회전값이 약 1초마다 한 번씩 온다고 가정한다.
10. DXBox 클라이언트 보간 처리
서버가 회전값을 1초에 한 번만 보내면 클라이언트에서는 회전이 끊겨 보일 수 있다.
예를 들어 아무 처리를 하지 않으면 다음처럼 보일 수 있다.
0초: 0도
1초: 90도
2초: 180도
3초: 270도
이렇게 되면 회전이 부드럽게 도는 것이 아니라 순간이동하듯이 툭툭 바뀐다.
그래서 클라이언트에서는 마지막으로 받은 서버 회전값을 기준으로, 다음 서버 회전값을 예상해서 부드럽게 보간한다.
AccDeltaSecondSinceReplicated += DeltaSeconds;
const float LerpRatio = FMath::Clamp(AccDeltaSecondSinceReplicated / NetUpdatePeriod, 0.f, 1.f);
AccDeltaSecondSinceReplicated는 마지막으로 서버 회전값을 받은 뒤부터 얼마나 시간이 지났는지 누적하는 변수다.
서버 회전값 받음
→ AccDeltaSecondSinceReplicated = 0
0.25초 지남
→ AccDeltaSecondSinceReplicated = 0.25
0.5초 지남
→ AccDeltaSecondSinceReplicated = 0.5
1초 지남
→ AccDeltaSecondSinceReplicated = 1.0
LerpRatio는 보간 비율이다.
0초 지남 → LerpRatio = 0
0.5초 지남 → LerpRatio = 0.5
1초 지남 → LerpRatio = 1
이 값을 이용해서 서버에서 받은 회전값과 다음 예상 회전값 사이를 부드럽게 이동한다.
11. 다음 서버 회전값 예측
다음 코드는 다음 서버 회전값을 예측하는 부분이다.
const float NextServerRotationYaw = ServerRotationYaw + RotationSpeed * NetUpdatePeriod;
예를 들어 마지막으로 서버에서 받은 회전값이 30도이고, 회전 속도가 초당 90도라면,
ServerRotationYaw = 30도
RotationSpeed = 90
NetUpdatePeriod = 1초
다음 서버 회전값은 다음과 같이 예상할 수 있다.
NextServerRotationYaw = 30 + 90 * 1
NextServerRotationYaw = 120도
즉 클라이언트는 이렇게 예측하는 것이다.
서버가 마지막으로 30도라고 알려줬고,
이 박스는 초당 90도씩 회전하니까,
1초 뒤에는 아마 120도쯤 되어 있겠지?
이 값은 실제 서버에서 받은 값이 아니라 클라이언트가 계산한 예상값이다.
12. Lerp로 부드럽게 회전시키기
다음 코드는 실제로 회전값을 보간하는 부분이다.
const float EstimatedClientRotationYaw = FMath::Lerp(ServerRotationYaw, NextServerRotationYaw, LerpRatio);
Lerp는 두 값 사이를 비율에 따라 섞어주는 함수다.
Lerp(A, B, 0.0)
→ A
Lerp(A, B, 0.5)
→ A와 B의 중간
Lerp(A, B, 1.0)
→ B
예를 들어 다음과 같다면,
ServerRotationYaw = 30도
NextServerRotationYaw = 120도
결과는 다음처럼 된다.
LerpRatio = 0.0
→ 30도
LerpRatio = 0.5
→ 75도
LerpRatio = 1.0
→ 120도
그리고 계산된 회전값을 액터에 적용한다.
SetActorRotation(FRotator(0.f, EstimatedClientRotationYaw, 0.f));
즉, 서버는 1초에 한 번만 회전값을 보내지만 클라이언트는 그 사이를 직접 계산해서 부드럽게 회전시킨다.
13. OnRep_ServerRotationYaw()의 역할
OnRep_ServerRotationYaw()는 서버에서 ServerRotationYaw 값이 클라이언트로 복제되었을 때 호출된다.
void ADXBox::OnRep_ServerRotationYaw()
{
AccDeltaSecondSinceReplicated = 0.f;
}
새로운 서버 회전값을 받았으므로, 마지막 복제 이후 지난 시간을 다시 0으로 초기화한다.
흐름은 다음과 같다.
서버에서 ServerRotationYaw 복제
↓
클라이언트에서 OnRep_ServerRotationYaw() 호출
↓
AccDeltaSecondSinceReplicated = 0
↓
새로운 서버 회전값 기준으로 다시 보간 시작
이 구조 덕분에 클라이언트는 매번 새로운 서버 회전값을 기준으로 다시 자연스럽게 회전을 예측할 수 있다.
14. DXBox 코드의 전체 흐름
전체 흐름을 서버와 클라이언트로 나누면 다음과 같다.
[서버]
박스 회전 계산
↓
ServerRotationYaw 값 변경
↓
NetUpdateFrequency = 1 이므로 약 1초에 한 번 복제 시도
↓
클라이언트에게 ServerRotationYaw 전달
[클라이언트]
ServerRotationYaw 복제 받음
↓
OnRep_ServerRotationYaw() 호출
↓
AccDeltaSecondSinceReplicated = 0
↓
Tick마다 시간 누적
↓
현재 서버 회전값에서 다음 예상 회전값까지 Lerp
↓
SetActorRotation으로 부드럽게 회전
이 코드는 낮은 복제 빈도에서도 클라이언트 화면에서는 박스가 부드럽게 회전해 보이도록 만드는 코드다.
즉 목적은 다음과 같다.
복제는 적게 한다.
하지만 클라이언트 화면에서는 부드럽게 보이게 한다.
15. NetDormancy의 의미
NetDormancy는 액터를 네트워크 휴면 상태로 두는 기능이다.
휴면 상태인 액터는 더 이상 프로퍼티 레플리케이션이나 RPC가 동작하지 않는다.
즉, 서버가 계속해서 이 액터의 replicated property가 바뀌었는지 검사하지 않도록 하는 최적화 기능이다.
쉽게 말하면 다음과 같다.
Awake 상태
→ 서버가 계속 "이 액터 바뀐 거 있나?" 검사함
Dormant 상태
→ 서버가 "이 액터는 당분간 안 바뀌니까 검사하지 말자" 하고 쉬게 둠
중요한 점은 Dormant 상태가 액터를 삭제하는 것은 아니라는 것이다.
액터는 서버와 클라이언트에 그대로 존재한다.
하지만 휴면 상태 동안은 복제 대상으로 거의 고려되지 않는다.
16. NetDormancy를 쓰는 이유
NetDormancy를 쓰는 이유는 자주 안 바뀌는 replicated 액터의 복제 비용을 줄이기 위해서다.
예를 들어 맵에 다음과 같은 액터들이 많다고 해보자.
보물상자 500개
문 100개
스위치 50개
레버 30개
퀘스트 오브젝트 100개
이 액터들이 모두 replicated 상태이고 계속 Awake 상태라면, 서버는 매번 이런 검사를 해야 한다.
이 상자 상태 바뀌었나?
이 문 상태 바뀌었나?
이 스위치 상태 바뀌었나?
이 레버 상태 바뀌었나?
이 퀘스트 오브젝트 상태 바뀌었나?
하지만 대부분은 평소에 상태가 바뀌지 않는다.
상자는 열기 전까지 상태 변화 없음
문은 누가 열기 전까지 상태 변화 없음
스위치는 누르기 전까지 상태 변화 없음
이럴 때 Dormancy를 쓰면 평소에는 액터를 휴면 상태로 두고, 상태가 바뀔 때만 깨워서 복제할 수 있다.
평소
→ Dormant 상태
→ 서버의 복제 검사 비용 감소
상태 변경 시
→ 깨움
→ 변경사항 복제
→ 다시 Dormant
17. NetDormancy 상태 종류
네트워크 휴면 상태에는 여러 종류가 있다.
DORM_Never
→ 절대 휴면되지 않음
DORM_Awake
→ 현재 휴면 상태가 아님. 복제 대상
DORM_DormantPartial
→ 일부 커넥션에 대해서만 휴면 상태
→ Deprecated
DORM_Initial
→ 휴면 상태로 시작하고 필요할 때 깨울 수 있음
DORM_DormantAll
→ 모든 커넥션에게 휴면 상태
실전에서는 보통 다음 상태들을 많이 생각하면 된다.
DORM_Never
→ 플레이어 캐릭터처럼 항상 업데이트가 필요한 액터
DORM_Awake
→ 현재 복제 중인 상태
DORM_Initial
→ 맵에 배치되어 있고 처음에는 자주 안 바뀌는 액터
DORM_DormantAll
→ 모든 클라이언트에 대해 휴면 상태
18. NetDormancy를 쓰면 좋은 액터
NetDormancy는 replicated가 필요하지만 자주 바뀌지는 않는 액터에 적합하다.
예시는 다음과 같다.
문
상자
레버
스위치
엘리베이터
퀘스트 오브젝트
점령 지점
함정 장치
파괴 가능한 오브젝트
월드에 배치된 아이템
이런 액터들은 클라이언트에게 상태가 전달되어야 한다.
예를 들어 문은 열렸는지 닫혔는지 알아야 하고, 상자는 이미 열렸는지 알아야 한다.
하지만 매 Tick마다 바뀌는 것은 아니다.
그래서 Dormancy와 잘 맞는다.
19. NetDormancy를 쓰면 안 좋은 액터
반대로 자주 바뀌는 액터에는 Dormancy가 적합하지 않다.
예를 들면 다음과 같다.
플레이어 캐릭터
움직이는 몬스터
실시간 이동하는 투사체
차량
계속 회전하는 액터
움직이는 플랫폼
매 Tick 상태가 바뀌는 액터
이런 액터는 위치, 회전, 애니메이션, 상태가 계속 바뀐다.
따라서 Dormant로 재워두면 계속 깨워야 해서 오히려 비효율적일 수 있다.
예를 들어 앞에서 본 DXBox처럼 계속 회전하는 액터는 Dormancy보다 다음 방식이 더 자연스럽다.
NetUpdateFrequency 조절
클라이언트 보간 Lerp
클라이언트 예측
ReplicateMovement 조절
즉, 계속 움직이는 액터는 휴면시키기보다 복제 빈도를 조절하고 클라이언트에서 부드럽게 보이도록 처리하는 것이 더 적합하다.
20. 문 액터에서 NetDormancy 사용 예시
문 액터는 Dormancy를 사용하기 좋은 예시다.
문은 평소에는 거의 상태가 바뀌지 않는다.
닫혀 있음
아무도 건드리지 않음
계속 그대로 있음
하지만 누군가 문을 열면 상태가 바뀐다.
bIsOpen = true
이 값은 모든 클라이언트에게 전달되어야 한다.
그래서 문 액터는 다음처럼 설정할 수 있다.
ADoor::ADoor()
{
bReplicates = true;
// 맵에 배치된 문이라면 처음에는 휴면 상태로 시작
NetDormancy = ENetDormancy::DORM_Initial;
}
문이 열릴 때는 다음처럼 처리할 수 있다.
void ADoor::OpenDoor()
{
if (HasAuthority())
{
FlushNetDormancy();
bIsOpen = true;
ForceNetUpdate();
}
}
흐름은 다음과 같다.
문은 평소에 Dormant 상태
↓
플레이어가 문과 상호작용
↓
서버에서 OpenDoor 실행
↓
FlushNetDormancy로 휴면 상태 깨움
↓
bIsOpen = true
↓
ForceNetUpdate로 빠른 복제 요청
↓
클라이언트에서 OnRep_IsOpen 실행
↓
문 열림 애니메이션 재생
21. 상자 액터에서 NetDormancy 사용 예시
보물상자도 Dormancy를 사용하기 좋다.
상자는 열리기 전까지 상태 변화가 거의 없다.
bIsOpened = false
플레이어가 상자를 열면 서버에서 상태를 바꾼다.
void AChest::OpenChest()
{
if (HasAuthority())
{
FlushNetDormancy();
bIsOpened = true;
RewardItemId = 3;
ForceNetUpdate();
SetNetDormancy(ENetDormancy::DORM_DormantAll);
}
}
흐름은 다음과 같다.
상자 닫힘 상태로 맵에 존재
↓
평소에는 Dormant 상태
↓
서버가 계속 검사하지 않음
↓
플레이어가 상자 열기
↓
서버에서 bIsOpened 변경
↓
변경사항 복제
↓
클라이언트에서 상자 열림 애니메이션 재생
↓
다시 Dormant 상태 유지
상자는 한 번 열리면 다시 자주 바뀌지 않기 때문에 Dormancy와 잘 맞는다.
22. FlushNetDormancy와 SetNetDormancy 차이
FlushNetDormancy()와 SetNetDormancy()는 모두 Dormant 액터를 다시 복제되게 만들 수 있지만, 용도가 다르다.
FlushNetDormancy()는 이번 변경사항을 한 번 보내고 싶을 때 사용한다.
FlushNetDormancy();
bIsOpen = true;
ForceNetUpdate();
예시는 다음과 같다.
문 열림
상자 열림
스위치 눌림
레버 작동
반면 SetNetDormancy(DORM_Awake)는 액터가 당분간 계속 바뀔 예정일 때 사용한다.
SetNetDormancy(ENetDormancy::DORM_Awake);
예시는 다음과 같다.
엘리베이터가 5초 동안 움직임
문이 열리는 애니메이션 동안 여러 상태가 바뀜
움직이는 플랫폼이 작동 시작함
그리고 상태 변화가 끝나면 다시 재울 수 있다.
SetNetDormancy(ENetDormancy::DORM_DormantAll);
정리하면 다음과 같다.
한 번만 변경사항 보내고 싶다
→ FlushNetDormancy()
당분간 계속 복제하고 싶다
→ SetNetDormancy(DORM_Awake)
다시 안 바뀐다
→ SetNetDormancy(DORM_DormantAll)
23. 지금 배우는 내용이 최적화와 관련 있는 이유
오늘 배운 내용들은 대부분 멀티플레이 네트워크 최적화와 관련이 있다.
다만 전부 최적화만은 아니고, 크게 두 가지로 나눌 수 있다.
1. 네트워크 동기화 구조 이해
2. 네트워크 최적화
먼저 다음 내용들은 멀티플레이 동기화 기본기에 해당한다.
RPC
Replicated 변수
OnRep 함수
RepNotify
Owner
Possess
GameMode / GameState / PlayerController 흐름
이것들은 서버와 클라이언트가 어떻게 데이터를 주고받는지 이해하기 위한 기본 구조다.
반면 다음 내용들은 최적화와 관련이 크다.
NetUpdateFrequency
NetUpdatePeriod
Client-side Lerp
NetDormancy
FlushNetDormancy
ForceNetUpdate
NetCullDistance
Relevancy
DOREPLIFETIME_CONDITION
이 기능들은 공통적으로 다음 질문과 연결된다.
서버가 얼마나 자주 보낼 것인가?
누구에게 보낼 것인가?
언제 안 보낼 것인가?
꼭 필요한 데이터만 보낼 수 있는가?
복제를 줄여도 부드럽게 보이게 할 수 있는가?
즉, 지금 배우는 내용은 단순히 복제가 되는지 안 되는지만 보는 것이 아니라, 서버 부담과 네트워크 사용량을 줄이면서도 클라이언트 화면에서는 자연스럽게 보이게 만드는 방법이다.
24. 동기화와 최적화의 차이
동기화는 서버와 클라이언트의 상태를 맞추는 것이다.
서버에서 HP 변경
↓
클라이언트로 HP 복제
↓
클라이언트에서 OnRep_HP 호출
↓
UI 갱신
이건 멀티플레이를 정상적으로 작동시키기 위한 기본 구조다.
반면 최적화는 필요한 정보만 효율적으로 보내는 것이다.
자주 안 바뀌는 액터는 재워둔다.
멀리 있는 액터는 안 보낸다.
필요한 클라이언트에게만 보낸다.
복제 빈도를 낮춘다.
클라이언트에서 보간해서 부드럽게 보이게 한다.
정리하면 다음과 같다.
서버와 클라이언트를 맞추는 법
→ 동기화
그걸 덜 보내고도 잘 맞아 보이게 하는 법
→ 최적화
오늘 헷갈렸던 점
오늘 헷갈렸던 부분은 상속과 인터페이스의 차이였다.
처음에는 둘 다 공통 기능을 만들 때 쓰는 것처럼 보여서 비슷하게 느껴졌다.
하지만 상속은 부모 클래스의 기능과 속성을 물려받는 구조이고, 인터페이스는 서로 다른 클래스들이 같은 기능을 가지고 있다고 약속하는 구조였다.
상속
→ PlayerCharacter는 Character의 한 종류다.
인터페이스
→ Door, Chest, NPC는 모두 Interact할 수 있다.
또 NetUpdateFrequency가 1이면 정확히 1초마다 복제되는 것처럼 보였지만, 실제로는 최대 빈도일 뿐 보장값이 아니었다.
서버 Tick Rate, 서버 성능, 네트워크 대역폭, 액터 우선순위 등에 따라 실제 복제 타이밍은 달라질 수 있다.
그리고 DXBox 코드에서 서버가 1초에 한 번만 회전값을 보내는데도 클라이언트에서 부드럽게 회전해 보이는 이유는, 클라이언트가 RotationSpeed와 NetUpdatePeriod를 이용해서 다음 회전값을 예상하고 Lerp로 보간하기 때문이었다.
마지막으로 NetDormancy는 복제를 더 빠르게 하는 기능이 아니라, 자주 바뀌지 않는 액터의 복제 검사를 줄이는 최적화 기능이라는 점이 중요했다.
오늘의 핵심 정리
- 상속은 부모 클래스의 기능과 속성을 자식 클래스가 물려받는 구조다.
- 상속은 "~의 한 종류다" 관계로 이해하면 쉽다.
- AMyCharacter : public ACharacter는 내 캐릭터가 ACharacter의 기능을 물려받는다는 뜻이다.
- 인터페이스는 특정 기능을 가지고 있다고 약속하는 구조다.
- 인터페이스는 "~할 수 있다" 관계로 이해하면 쉽다.
- 문, 상자, NPC, 아이템이 모두 상호작용 가능하다면 BPI_Interactable 인터페이스를 사용할 수 있다.
- 상속은 공통 기능과 데이터를 물려받고 싶을 때 사용한다.
- 인터페이스는 서로 다른 클래스를 같은 방식으로 호출하고 싶을 때 사용한다.
- NetUpdateFrequency는 액터가 최대 얼마나 자주 복제될 수 있는지 정하는 값이다.
- NetUpdateFrequency는 정확한 복제 주기를 보장하지 않는다.
- 서버 Tick Rate, 서버 성능, 네트워크 대역폭에 따라 실제 복제 빈도는 달라질 수 있다.
- ForceNetUpdate()는 다음 네트워크 업데이트 때 빠르게 복제되도록 요청하는 함수다.
- ForceNetUpdate()도 완전한 즉시 전송 보장은 아니다.
- 상태값은 Replicated 변수와 OnRep으로 관리하는 것이 적합하다.
- 한 번만 발생하는 이벤트는 RPC가 적합하다.
- DXBox 코드는 서버가 회전값을 낮은 빈도로 보내고, 클라이언트에서 Lerp로 부드럽게 보이게 하는 구조다.
- AccDeltaSecondSinceReplicated는 마지막 복제 이후 지난 시간을 누적하는 변수다.
- LerpRatio는 현재 보간이 얼마나 진행되었는지 나타내는 비율이다.
- NextServerRotationYaw는 클라이언트가 예상한 다음 서버 회전값이다.
- OnRep_ServerRotationYaw()에서는 새 서버 회전값을 받았으므로 시간을 0으로 초기화한다.
- NetDormancy는 자주 안 바뀌는 액터를 휴면 상태로 두어 복제 검사 비용을 줄이는 최적화 기능이다.
- DORM_Initial은 처음부터 휴면 상태로 시작하고 필요할 때 깨울 수 있는 상태다.
- DORM_DormantAll은 모든 클라이언트 연결에 대해 휴면 상태인 상태다.
- 문, 상자, 레버처럼 가끔만 상태가 바뀌는 액터는 Dormancy와 잘 맞는다.
- 플레이어 캐릭터, 움직이는 몬스터, 투사체처럼 자주 바뀌는 액터는 Dormancy에 적합하지 않다.
- FlushNetDormancy()는 휴면 상태 액터의 변경사항을 복제할 수 있게 깨우는 함수다.
- SetNetDormancy(DORM_Awake)는 액터가 당분간 계속 바뀔 예정일 때 깨워두는 용도로 사용할 수 있다.
- SetNetDormancy(DORM_DormantAll)은 상태 변화가 끝난 뒤 다시 휴면 상태로 만들 때 사용할 수 있다.
- 멀티플레이에서 동기화는 서버와 클라이언트의 상태를 맞추는 것이다.
- 멀티플레이에서 최적화는 필요한 정보만 효율적으로 보내는 것이다.
정리하면 오늘 배운 내용은 단순히 네트워크 복제를 사용하는 방법을 넘어서, 어떤 액터를 얼마나 자주 복제할지, 언제 복제를 쉬게 할지, 복제가 적어도 클라이언트 화면에서는 어떻게 부드럽게 보이게 할지를 이해하는 과정이었다.
결국 멀티플레이 네트워크에서는 모든 것을 자주 보내는 것이 정답이 아니라, 필요한 데이터만 적절한 타이밍에 보내고 나머지는 클라이언트 보간이나 Dormancy 같은 방식으로 최적화하는 것이 중요하다.
'unreal 8기' 카테고리의 다른 글
| 2026-6-22 TIL (0) | 2026.06.22 |
|---|---|
| 2026-6-19 TIL (0) | 2026.06.19 |
| 2026-6-17 TIL (0) | 2026.06.17 |
| 2026-6-16 TIL (0) | 2026.06.16 |
| 2026-6-15 TIL (0) | 2026.06.15 |