언리얼 엔진/프로젝트

[언리얼 엔진] 멀티플레이 상품 Drop 중 HardSnap 문제 해결

dhlee-dev 2026. 6. 22. 21:56

팀 프로젝트에서 멀티플레이 게임을 만들면서
카트에 적재된 상품이 충돌에 의해 떨어질 때, 상품들이 위로 퍼지듯 튀어 오르는 연출이 필요했다.

구현 자체는 단순하게 생각했다.

서버에서 상품의 드롭 위치를 계산하고 상품 상태를 Falling으로 변경한 뒤,
메시 물리를 켜고 AddImpulse를 적용하면 된다고 생각했다.

서버에서 상품 Drop 처리
-> 상품 위치 설정
-> 상태를 Falling으로 변경
-> 메시 물리 활성화
-> AddImpulse 적용

하지만 테스트 중 클라이언트 화면에서 상품 위치가 순간적으로 튀는 문제가 발생했다.

처음에는 물리 충돌 문제라고 생각했지만,
로그를 찍어보면서 실제 원인은 상태 복제와 위치 복제 타이밍이 어긋난 문제라는 것을 알게 되었다.

이번 글에서는 멀티플레이 상품 Drop 연출 중 발생한 HardSnap 문제와,
이를 어떻게 추적하고 해결했는지 정리해보려고 한다.


HardSnap이라는 표현에 대해

이번 문제를 정리하면서 현상을 HardSnap이라고 표현했다.

네트워크 게임에서 클라이언트 위치가 서버 위치로 보정되면서
오브젝트가 되돌아가거나 튀어 보이는 현상을 넓게는 Rubber Banding이라고 부르기도 한다.

다만 이번 문제를 디버깅할 때 에디터 콘솔에서 다음 옵션을 켜고 확인했다.

p.LogPhysicsReplicationHardSnaps 1

이 옵션을 켠 상태에서 상품 위치 보정이 발생하자,
언리얼 엔진 로그에 HardSnap 관련 메시지가 출력되었다.

그래서 이 글에서는 해당 현상을 HardSnap이라고 표현했다.

즉, 이 글에서 말하는 HardSnap
클라이언트 화면에서 상품 위치가 서버 위치로 순간적으로 보정되며 튀어 보인 현상을 의미한다.

정확한 원인은 뒤에서 로그를 통해 확인했다.

클라이언트 화면에서 상품 Drop 연출 시작
-> 상품 위치가 순간적으로 서버 위치로 보정됨
-> HardSnap 발생

문제 현상

상품을 Drop할 때 서버에서는 상품을 드롭 위치로 옮기고,
Falling 상태로 바꾼 뒤 물리를 켜고 Impulse를 적용했다.

기획상으로는 상품들이 스프링클러나 분수처럼 위로 퍼지는 연출이 필요했다.

하지만 클라이언트 화면에서는 상품이 처음에 캐릭터 몸통 중간쯤에서 퍼지는 것처럼 보이다가,
약간의 시간이 지난 뒤 서버 위치로 순간 보정되는 현상이 발생했다.

처음 봤을 때는 상품이 캐릭터나 카트와 겹친 상태에서 물리가 켜지고,
충돌 밀어내기 때문에 위치가 튀는 문제라고 생각했다.

특히 상품이 퍼지는 순간 클라이언트에서 위치가 부자연스럽게 보정되었기 때문에,
처음에는 물리 충돌 문제를 가장 먼저 의심했다.


시도 1. Collision Ignore 처리

먼저 상품과 캐릭터의 충돌이 문제라고 생각했다.

상품 메시가 물리 시뮬레이션을 시작할 때 캐릭터와 겹쳐 있고,
그 충돌 결과가 서버와 클라이언트에서 다르게 계산되어 위치가 튀는 것처럼 보일 수 있다고 생각했다.

그래서 상품 메시의 콜리전 프리셋에서 Pawn을 무시하도록 설정했다.

ProductPhysics
- WorldStatic: Block
- WorldDynamic: Block
- Pawn: Ignore
- Product: Ignore

하지만 Pawn을 Ignore로 바꿔도 같은 문제가 계속 발생했다.
이 시점에서 단순히 상품과 캐릭터의 물리 충돌 때문에 발생하는 문제는 아닐 가능성이 높다고 판단했다.


시도 2. 클라이언트 물리 시뮬레이션 끄기

다음으로는 클라이언트가 자기 위치 기준으로 물리를 먼저 시뮬레이션하는 것이 문제라고 생각했다.
그래서 클라이언트에서는 물리를 끄고 서버에서만 물리를 켜는 방식으로 테스트했다.

if (HasAuthority())
{
    Mesh->SetSimulatePhysics(true);
}
else
{
    Mesh->SetSimulatePhysics(false);
}

클라이언트 물리 시뮬레이션 자체는 막을 수 있었지만,
그 결과 클라이언트 화면에서 상품 Drop 연출이 제대로 보이지 않았다.

현재 구조는 실제 ProductActor의 물리 이동 결과를 클라이언트에서도 보여주는 방식이었다.
따라서 클라이언트 물리를 완전히 꺼버리는 방식은 적합하지 않았다.


시도 3. NetUpdateFrequency를 높여보기

Collision 설정을 바꿔보고, 클라이언트 물리 시뮬레이션을 꺼보는 방식으로도 문제가 해결되지 않았다.

다음으로는 상품의 이동 복제 빈도가 낮아서 서버 위치 보정이 늦게 들어오는 문제일 수도 있다고 생각했다.

상품 Actor는 개수가 많아질 수 있었기 때문에,
평소에는 성능을 고려해 상태별로 NetUpdateFrequency를 낮게 조정해두고 있었다.

움직임이 거의 없거나 숨겨지는 상태에서는 NetUpdateFrequency를 1 정도로 낮추고,
물리 이동이 필요한 Falling 상태에서는 20~30 정도로 설정해두었다.

하지만 이 상태에서 상품이 순간적으로 보정되는 문제가 발생했다.

그래서 테스트로 Falling 상태의 NetUpdateFrequency를 150~200 정도까지 크게 올려봤다.

case EProductState::Falling:
    SetActorHiddenInGame(false);
    SetNetUpdateFrequency(200.f);

    Mesh->SetSimulatePhysics(true);
    Mesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
    SphereCollision->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    break;

하지만 NetUpdateFrequency를 크게 올려도 HardSnap 문제는 해결되지 않았다.

이 시점까지는 아직 정확한 원인을 확정하지 못했고,
단순히 물리 충돌이나 이동 복제 빈도만의 문제는 아닐 수 있다고 판단했다.


로그를 통한 원인 확인

세 가지 방법을 시도했지만 문제가 해결되지 않았다.

그래서 서버와 클라이언트에서 상품 위치 로그를 찍어보기로 했다.

서버에서는 상품을 Drop할 때 SetActorLocation 이후의 위치를 출력했다.
클라이언트에서는 OnRep_ProductState가 호출되는 시점의 상품 위치를 출력했다.

로그를 비교해보니 서버의 상품 위치와 클라이언트의 상품 위치가 크게 달랐다.
특히 Z 값이 많이 차이 났고 X/Y 값도 서버와 클라이언트가 서로 다른 경우가 있었다.

이때부터 문제를 단순한 물리 충돌이나 복제 빈도 문제가 아니라,
클라이언트가 서버의 최신 드롭 위치를 받기 전에 Falling 상태를 먼저 적용하는 문제로 보기 시작했다.

즉, 위치 로그를 찍어본 뒤에야
문제의 핵심이 상태 복제와 위치 복제 타이밍에 있다는 것을 확인할 수 있었다.


실제 원인

위치 로그를 비교한 뒤, 실제 원인을 다음과 같이 정리할 수 있었다.
서버 코드는 분명히 다음 순서로 실행되고 있었다.

서버
-> 상품 드롭 시작 위치 계산
-> SetActorLocation으로 상품 위치 이동
-> ProductState를 Falling으로 변경
-> Mesh에 AddImpulse 적용

그래서 처음에는 클라이언트에서도 이 순서대로 반영될 것이라고 생각했다.

하지만 멀티플레이 환경에서는 서버 코드의 실행 순서와
클라이언트에서 복제 데이터가 반영되는 순서를 동일하게 가정하면 안 됐다.

클라이언트에서는 다음과 같은 일이 발생할 수 있었다.

클라이언트
-> ProductState 복제를 먼저 받음
-> OnRep_ProductState 호출
-> Falling 상태 적용
-> Mesh 보임
-> SimulatePhysics true
-> 하지만 서버가 설정한 DropLocation은 아직 최신 상태가 아닐 수 있음
-> 클라이언트가 기존 위치 기준으로 물리 시뮬레이션 시작
-> 이후 서버 위치 복제가 도착
-> 위치 보정 발생
-> HardSnap

즉, 서버 코드에서는

드롭 위치 설정
-> 상태 변경
-> 물리 Impulse 적용

순서로 실행하고 있었지만,
클라이언트에서는 위치 복제와 상태 복제가 같은 타이밍에 도착한다고 보장할 수 없었다.

이번 문제는 바로 이 지점에서 발생했다.

Falling 상태를 적용하려면 상품의 시작 위치도 함께 정확해야 하는데,
기존 구조에서는 ProductState만 복제하고 있었다.


해결 방법. 상태와 드롭 위치를 함께 복제하기

기존에는 ProductStateRepNotify로 복제하고 있었다.

하지만 Falling 상태는 단순히 상태만 알아서는 부족했다.
클라이언트가 Falling 상태를 적용하기 전에 반드시 알아야 하는 값이 있었다.

Falling 상태 적용에 필요한 정보
- 상품 상태
- 드롭 시작 위치

그래서 상품 상태와 드롭 위치를 하나의 구조체로 묶어 RepNotify로 복제했다.

USTRUCT(BlueprintType)
struct FProductRepState
{
    GENERATED_BODY()

public:
    UPROPERTY(BlueprintReadOnly)
    EProductState State = EProductState::None;

    UPROPERTY(BlueprintReadOnly)
    FVector_NetQuantize10 DropLocation = FVector::ZeroVector;
};

여기서 DropLocation은 일반 FVector가 아니라 FVector_NetQuantize10을 사용했다.

FVector_NetQuantize10은 네트워크 전송을 위해 좌표 정밀도를 일부 줄이는 대신,
복제 시 전송되는 데이터 크기를 줄일 수 있는 벡터 타입이다.

이번 경우에는 상품 Drop 시작 위치를 맞추는 용도였기 때문에,
완전한 FVector 정밀도보다는 네트워크 전송 비용을 줄이는 쪽이 더 적합하다고 판단했다.

서버에서 상품을 떨어뜨릴 때는 드롭 위치를 계산한 뒤,
상태와 드롭 위치를 함께 저장했다.

SetActorLocation(DropLocation, false, nullptr, ETeleportType::TeleportPhysics);

ProductRepState.State = EProductState::Falling;
ProductRepState.DropLocation = DropLocation;

ApplyProductState();
ForceNetUpdate();

클라이언트에서는 OnRep가 호출되었을 때,
상태가 Falling이라면 먼저 서버가 계산한 DropLocation으로 위치를 맞췄다.

그다음 상태를 적용하도록 처리했다.

void AProductBase::OnRep_ProductRepState()
{
    if (ProductRepState.State == EProductState::Falling)
    {
        SetActorLocation(
            ProductRepState.DropLocation,
            false,
            nullptr,
            ETeleportType::TeleportPhysics
        );
    }

    ApplyProductState();
}

이렇게 변경하자 클라이언트가 기존 위치에서 먼저 물리를 시작하는 문제가 사라졌다.

즉, Falling 상태를 적용하기 전에
상태 적용에 필요한 시작 위치를 먼저 맞춰준 것이다.


Impulse는 복제하지 않은 이유

처음에는 DropLocation뿐 아니라 Impulse도 함께 복제해야 하는지 고민했다.

하지만 현재 구조에서는 서버에서 AddImpulse를 적용하고,
상품 Actor는 SetReplicateMovement(true)를 통해 서버의 물리 이동 결과를 클라이언트에 복제하는 방식이었다.

따라서 클라이언트에서 같은 Impulse를 다시 적용할 필요는 없었다.

오히려 클라이언트에서도 별도로 Impulse를 적용하면 서버와 클라이언트의 물리 결과가 달라질 수 있고,
이후 서버 위치 복제로 인해 다시 보정이 발생할 수 있다고 판단했다.

결과적으로 이번 문제에서는 DropLocation만 상태와 함께 명시적으로 복제하고,
물리 이동 결과는 서버의 Movement Replication을 따르도록 두었다.


해결된 상태

State와 DropLocation을 함께 동기화한 뒤에는
클라이언트에서 상품이 잘못된 위치에서 시작하는 문제가 사라졌고 Drop 연출도 자연스럽게 보이게 되었다.

아래는 문제를 해결한 뒤 상품 에셋을 적용한 상태에서 촬영한 모습이다.


정리

이번 문제는 처음에는 물리 충돌 문제처럼 보였다.

그래서 Pawn 충돌을 Ignore로 바꿔보거나, 클라이언트 물리 시뮬레이션을 꺼보거나,
NetUpdateFrequency를 크게 올려보는 방식으로 원인을 확인해봤다.

하지만 문제는 해결되지 않았고,
서버와 클라이언트의 위치 로그를 찍어본 뒤에야 실제 원인을 확인할 수 있었다.

핵심은 클라이언트가 서버의 최신 Drop 위치를 받기 전에
Falling 상태를 먼저 적용하고 있었다는 점이었다.

기존 방식은 ProductState만 복제했기 때문에 클라이언트가 잘못된 위치에서 물리를 시작할 수 있었다.

기존 방식
- ProductState만 RepNotify로 복제
- 클라이언트가 Falling 상태를 먼저 적용
- DropLocation은 아직 최신이 아닐 수 있음
- 이후 서버 위치 복제로 HardSnap 발생

해결 방식은 StateDropLocation을 하나의 구조체로 묶어 함께 복제하는 것이었다.

해결 방식
- State와 DropLocation을 구조체로 묶어 RepNotify
- 클라이언트 OnRep에서 DropLocation으로 먼저 이동
- 그다음 Falling 상태 적용
- 물리 이동 결과는 서버의 Movement Replication을 따라감

결국 이번 문제의 핵심은 NetUpdateFrequency를 높이는 것이 아니라,
Falling 상태를 적용하는 데 필요한 시작 위치를 상태와 함께 동기화하는 것이었다.

이번 작업을 통해 멀티플레이 환경에서는
서버 코드의 실행 순서와 클라이언트에서 복제 데이터가 반영되는 순서를
같다고 가정하면 안 된다는 점을 다시 확인했다.

특히 어떤 상태를 적용하기 위해 위치, 방향, 속도 같은 추가 정보가 필요하다면,
상태만 복제하지 말고 그 상태를 적용하는 데 필요한 데이터까지 함께 동기화해야 한다.


마무리

이번 문제는 처음에는 단순한 물리 충돌 문제처럼 보였다.

상품이 캐릭터 근처에서 튀어 오르다 보니 충돌 때문에 위치가 밀려나는 문제라고 생각했고,
Collision 설정이나 물리 시뮬레이션 방식부터 확인했다.

하지만 실제 원인은 로그를 찍어보기 전까지 명확히 보이지 않았다.

서버와 클라이언트의 위치를 직접 비교해보니,
클라이언트가 Falling 상태를 적용하는 시점의 위치가
서버에서 계산한 Drop 위치와 다르다는 것을 확인할 수 있었다.

이번 트러블슈팅을 통해 네트워크 문제는 겉으로 보이는 현상만 보고 판단하기 어렵다는 것을 느꼈다.

특히 물리, 상태 복제, 위치 복제가 함께 얽혀 있는 경우에는
충돌 문제인지, 이동 복제 문제인지, 상태 적용 타이밍 문제인지 구분하기 어려울 수 있다.

결국 중요한 것은 의심되는 부분을 하나씩 제거하고 서버와 클라이언트의 상태를 로그로 비교하면서,
실제 차이가 발생하는 지점을 찾는 것이라고 느꼈다.

앞으로 멀티플레이 기능을 구현할 때는 상태 값만 복제하면 충분한지,
그 상태를 적용하기 위해 추가 데이터도 함께 필요한지를 먼저 생각하면서 구조를 설계해야겠다.