포스트

늦참(Late Join) 동기화 트러블슈팅

복제 상태로 두지 않은 것은 초기 스냅샷에 실리지 않는다

기존 기능 자동 검사 371항목이 전부 통과한 뒤에도 두 건이 남아 있었다. 판이 시작된 뒤 들어온 클라이언트에게만 가구가 옛 자리로 보이고 손에 든 아이템이 스케일 0으로 깨졌는데, 원인은 둘 다 '알리기'와 '상태로 두기'를 구분하지 않은 것이었다.

늦참(Late Join) 동기화 트러블슈팅

운반 게임의 결함을 한 주 동안 일곱 건 고쳤는데, 기존 기능 자동 검사가 끝까지 못 잡은 것이 둘 있었다. 잡는 각도·계단·공전처럼 혼자 돌려도 재현되는 것들은 검사로 걸렸지만, 이 둘은 매번 통과했다. 공통점은 하나였다 — 두 번째 클라이언트가 판이 시작된 뒤에 들어왔을 때만 나온다. 하나는 옮겨 둔 가구가 원래 자리에 서 있는 것이고, 다른 하나는 남이 들고 있는 배트·낚싯대가 스케일 0으로 깨지는 것이다. 겉보기에는 서로 무관해 보이는 두 결함인데, 원인을 끝까지 따라가니 같은 한 줄이었다.

요구사항

  • 도중에 들어온 사람도 먼저 있던 사람과 같은 장면을 봐야 한다
  • 누가 들어왔다고 해서 이미 접속해 있던 사람의 화면이 흔들려서는 안 된다

트러블슈팅 ① — 옮겨 둔 가구가 원래 자리에 서 있다

증상 — 늦게 들어온 쪽에만 옛 배치가 보인다

호스트가 가구를 한참 옮겨 놓은 뒤 두 번째 클라이언트가 세션 브라우저로 들어온다. 들어온 쪽 화면에는 가구가 레벨에 배치된 원래 자리에 서 있다. 더 고약한 건 그다음이다 — 누군가 그 가구를 다시 잡는 순간 제자리로 텔레포트한다.

수정 전 — 왼쪽이 먼저 있던 화면, 오른쪽이 뒤늦게 들어온 클라이언트. 같은 방을 보고 있는데 가구 배치가 서로 다르다

원인 — 잠들 때 복제 이동까지 껐다

놓인 가구는 완전히 멈추면 키네마틱으로 되돌린다. 시뮬레이션이 켜진 채로 두면 플레이어가 몸으로 밀고 그 반작용이 되돌아와 서로 비벼지기 때문이다. 그런데 이 “재우기”가 물리만 끈 게 아니었다.

1
2
FurnitureMesh->SetSimulatePhysics(false);
SetReplicateMovement(false);   // ← 이 한 줄

SetReplicateMovement(false)는 서버가 그 액터의 트랜스폼을 어떤 커넥션에도 보내지 않게 한다. 이미 접속해 있던 클라이언트는 가구가 옮겨지는 동안 값을 받아 뒀으니 화면이 맞다. 하지만 늦게 들어온 클라이언트는 레벨에 저작된 자리로 액터를 스폰한 뒤 갱신을 한 번도 받지 못한다. 그러다 누가 다시 잡으면 물리와 함께 복제가 켜지고, 그때 도착한 첫 갱신이 옛 자리와 실제 자리의 차이만큼 순간이동으로 나타난다.

껐던 이유는 대역폭이었다. 그런데 이 근거가 틀렸다 — 정지한 액터는 값이 변하지 않으므로 켜 둬도 번치가 나가지 않는다. 복제 이동은 “매 틱 보내라”가 아니라 “달라지면 보내라”다. 아끼려던 비용이 애초에 없었고, 대신 늦참 클라이언트의 초기 스냅샷에서 위치가 통째로 빠졌다.

해결 — 물리만 끈다

1
2
3
// 복제 이동은 끄지 않는다 — 끄면 옮겨진 위치가 늦참 클라의 초기 스냅샷에 실리지 않아
// 레벨 배치 자리로 보이고, 다시 잡는 순간에야 텔레포트한다
FurnitureMesh->SetSimulatePhysics(false);

수정 후 — 같은 절차로 뒤늦게 들어온 클라이언트가 입장 직후 프레임부터 호스트와 같은 배치를 본다

트러블슈팅 ② — 남이 든 아이템이 스케일 0으로 깨진다

증상 — 보이지 않거나, 들면 거대해지거나

늦게 들어온 클라이언트 화면에서 다른 사람이 들고 있는 배트·낚싯대가 스케일 0이 되어 사라진다. 그 상태에서 자기가 아이템을 집으면 이번엔 엉뚱한 크기로 커진다. 처음부터 같이 있었으면 멀쩡한데, 늦게 들어온 쪽에서만 그렇다.

원인 — 서버에서만 붙이고, 나머지는 엔진에 맡겼다

줍기 처리가 이렇게 생겨 있었다.

1
2
3
4
5
6
7
8
9
10
11
if (HasAuthority())
{
    if (Player->HeldItem == nullptr)
    {
        Player->HeldItem = this;
        ItemMesh->SetSimulatePhysics(false);
        ItemMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
        AttachToComponent(Player->GetMesh(),
            FAttachmentTransformRules::SnapToTargetNotIncludingScale, TEXT("hand_r"));
    }
}

어태치를 서버에서만 부르고, 클라이언트 쪽은 엔진의 어태치 복제가 알아서 해 주기를 기대한 코드다. 여기서 세 갈래로 깨진다.

  1. 클라이언트에서 물리를 끄지 않는다. 바디가 시뮬레이션 중이면 엔진이 어태치를 거부하고, 그 거부 경로에서 소켓의 월드 스케일이 액터의 상대 스케일로 굳어 버린다. 손 소켓 본 스케일이 아주 작으면 그 값이 그대로 0에 가까운 스케일이 된다.
  2. 크기를 엔진 규칙에 맡겼다. SnapToTargetNotIncludingScale은 상대 스케일을 유지하는데, 손 소켓의 월드 스케일이 1이 아니라서 결과 월드 크기가 저작값과 달라진다.
  3. 어태치 드라이버가 둘이다. 엔진의 AttachmentReplication과 우리 로직이 같은 대상을 건드리니, 늦참 클라이언트에서는 도착 순서에 따라 오염된 스케일이 그대로 굳는다.

여기에 근본 문제가 하나 더 있었다. 어태치는 서버의 지역 호출이라 그 자체로는 늦게 들어온 사람에게 전달되지 않는다. 그가 받는 건 접속 시점의 복제된 상태뿐인데, 그 상태에 “이 아이템은 누가 들고 있다”가 없었다(HeldItem은 복제 등록조차 안 돼 있었다).

해결 — 드라이버를 하나로, 크기는 명시 재설정

들고 있는 사람을 복제 프로퍼티로 올리고, 어태치를 그 하나가 전담하게 했다. 엔진 어태치 복제는 빈 오버라이드로 막는다.

1
2
3
4
5
6
// 주운 사람 — 멀티캐스트와 달리 늦참 클라의 초기 스냅샷에 실린다
UPROPERTY(ReplicatedUsing = OnRep_Holder)
TObjectPtr<AActor> Holder = nullptr;

// 어태치는 Holder 하나가 전담한다 — 엔진 어태치 복제와 겹치면 도착 순서에 따라 스케일이 굳는다
virtual void OnRep_AttachmentReplication() override {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void AItem_Base::ApplyHolder(AActor* NewHolder)
{
    if (ACharacter* Char = Cast<ACharacter>(NewHolder))
    {
        // 클라에서도 꺼야 한다 — 바디가 시뮬 중이면 엔진이 어태치를 거부하고
        // 그 경로에서 소켓의 월드 스케일이 액터 상대 스케일로 굳는다
        ItemMesh->SetSimulatePhysics(false);
        ItemMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);

        // 스케일은 KeepRelative — 저작 스케일을 어태치 계통이 건드리지 않게
        AttachToComponent(Char->GetMesh(),
            FAttachmentTransformRules(EAttachmentRule::SnapToTarget, EAttachmentRule::SnapToTarget,
                                      EAttachmentRule::KeepRelative, false),
            TEXT("hand_r"));

        SetActorScale3D(AuthoredScale);
    }
    ...
}

크기는 엔진 규칙 대신 명시 재설정으로 보존한다. PostInitializeComponents에서 어태치되기 전의 저작 스케일을 캐시해 두고, 어태치·디태치 직후에 그 값을 다시 써넣는다. 소켓 본 스케일이 1이 아니어도 월드 크기가 항상 저작값이고, 이미 오염된 스케일도 다음 어태치에서 스스로 복구된다.

함정: 서버에는 OnRep이 오지 않는다. 복제 프로퍼티를 단일 드라이버로 삼으면 클라이언트는 OnRep_Holder로 들어오지만 서버는 아무 콜백도 받지 못한다. 서버에서 값을 대입한 자리에서 같은 함수를 직접 한 번 불러 줘야 양쪽이 같은 경로를 탄다.

1
2
Holder = Player;
ApplyHolder(Player);   // 서버에는 OnRep이 오지 않는다

두 결함이 같은 한 줄이었던 이유

정리하면 이렇다.

 무엇을 뺐나늦참 클라가 받은 것
가구트랜스폼을 복제 대상에서 제외레벨 저작 위치 (갱신 없음)
아이템어태치를 서버 지역 호출로 처리바닥에 놓인 상태 (누가 들었는지 모름)

“모두에게 알리는 것”과 “상태로 두는 것”은 다르다. 이벤트·멀티캐스트·지역 호출은 그 순간 연결돼 있던 사람에게만 닿는다. 나중에 들어온 사람이 받는 것은 접속 시점에 복제 프로퍼티로 남아 있던 값뿐이다. 그래서 판정의 기준은 “이 변화를 지금 다들 봤나”가 아니라 “지금 접속하는 사람이 이 값을 읽을 수 있나”여야 한다.

검증 — 관측자를 늦게 만들어야 보인다

두 결함 모두 기존 기능 자동 검사 5종 371항목을 전부 통과한 상태였다. 당연하다. 그 검사들은 참가자가 처음부터 함께 있는 상태에서 도는 것이라 초기 스냅샷 경로 자체를 밟지 않는다. 복제 프로퍼티가 하나 빠져 있어도, 이미 접속해 있던 클라이언트는 그 뒤에 오는 갱신으로 화면이 맞아 버린다.

그래서 재현 절차를 검사가 아니라 손으로 만들었다. PIE 창 두 개를 띄우되 두 번째 창은 시작할 때 붙이지 않고, 호스트가 가구를 실컷 옮기고 아이템을 든 뒤에 세션 브라우저로 들어간다. 위 영상 두 편이 그 절차를 그대로 돌린 것이다. 검사로 안 잡히는 결함은 검사를 늘리기 전에 어떤 관측 조건이 검사에 없는지부터 찾는 편이 빨랐다.

결과

늦게 들어온 클라이언트가 입장 직후 프레임부터 옮겨 둔 가구 위치와 남이 들고 있는 아이템을 정상 크기로 본다. 아이템 쪽은 부수적으로 어태치 드라이버가 하나로 정리되면서 파생 아이템(뽁뽁이 등)도 같은 경로를 타게 됐고, 낚싯대에 없던 포커스 하이라이트도 배트와 같은 방식으로 채웠다.

핵심 요약 — 늦게 접속한 클라이언트가 받는 것은 그 시점의 복제 상태뿐이다. 대역폭을 아끼려고 정지 액터의 복제 이동을 끄거나, 어태치를 서버 지역 호출로만 처리하면 그 상태에서 값이 통째로 빠진다. 정지 액터는 값이 안 변해 복제를 켜 둬도 비용이 없고, 어태치처럼 “한 번 일어나고 계속 유지되는 변화”는 이벤트가 아니라 복제 프로퍼티로 남겨야 한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.