언리얼 엔진/프로젝트

[언리얼 엔진] 멀티플레이 상품 상태를 enum으로 관리하기

dhlee-dev 2026. 6. 24. 22:45

이번 팀프로젝트에서는 BumperCart라는 캐주얼 멀티 대전 게임을 만들고 있다.

게임의 기본 흐름은 마트 안에서 카트를 조작하면서 로봇손으로 상품을 잡아 카트에 담고,
계산대에서 상품을 정산해 점수를 얻는 방식이다.

나는 이 프로젝트에서 상품 관련 시스템을 담당하고 있고,
추가로 맵 제작과 로봇손 구현도 함께 진행하고 있다.

이번 글에서는 상품 시스템 중에서 상품의 상태를 나누고,
멀티플레이 환경에서 서버 기준으로 상태를 관리한 내용을 정리해보려고 한다.


상품 상태를 나눈 이유

실제 게임 흐름을 생각하면 상품은 여러 상태를 가진다.

진열대에 놓여 있음
-> 로봇손으로 잡힘
-> 카트에 적재됨
-> 충돌로 바닥에 떨어짐
-> 다시 로봇손으로 회수 가능
-> 계산대에서 정산됨

이 상태마다 상품의 동작도 달라진다.

예를 들어 진열 중인 상품은 로봇손으로 잡을 수 있어야 하지만,
이미 카트에 적재된 상품은 다시 잡히면 안 된다.

또 바닥에 떨어진 상품은 물리 시뮬레이션이 필요하지만,
정산이 끝난 상품은 더 이상 월드에 보일 필요가 없다.

이런 상태를 여러 개의 bool 값으로 관리할 수도 있다.

bIsGrabbed
bIsLoaded
bIsFalling
bIsPaid

하지만 bool 값이 많아질수록 서로 동시에 true가 되면 안 되는 상태를 직접 관리해야 한다.
예를 들어 상품이 Loaded이면서 동시에 Falling인 상태가 되면 이상하다.

그래서 상품의 현재 상태는 하나만 가질 수 있도록
enum으로 분리하기로 했다.


EProductState 정의하기

상품 상태는 EProductState로 정의했다.

각 상태의 의미는 다음과 같다.

상태 의미
None 아직 유효한 상태가 아닌 기본 상태
Display 진열 중인 상태
Grabbed 로봇손이 잡기로 확정한 상태
Loaded 카트에 적재된 상태
Falling 충돌로 떨어지는 상태
Paid 계산대에서 정산이 끝난 상태

이렇게 상태를 나누면 상품이 현재 어떤 흐름에 있는지 코드에서 더 명확하게 판단할 수 있다.

Display
-> 로봇손으로 잡을 수 있음

Grabbed
-> 이미 누군가 잡기로 확정했으므로 다시 잡을 수 없음

Loaded
-> 카트에 들어간 상태

Falling
-> 바닥에 떨어졌지만 다시 회수 가능

Paid
-> 정산 완료

Falling 상태에서 위치 정보도 함께 복제한 이유

상품 상태는 기본적으로 EProductState만으로 구분할 수 있었다.
하지만 Falling 상태는 조금 달랐다.

상품이 카트에서 떨어질 때는
서버가 계산한 드롭 시작 위치를 클라이언트도 같은 기준으로 알아야 했다.

그래서 상품 상태와 함께 드롭 시작 위치를 FProductRepState 구조체로 묶어 복제했다.

USTRUCT(BlueprintType)
struct FProductRepState
{
    GENERATED_BODY()

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

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

이 부분은 이전에 상품 Drop 중 발생한 HardSnap 문제를 해결하면서 정리한 내용이다.
아래 링크에서 해당 문제를 해결하는 과정, 구조체로 묶은 이유를 확인할 수 있다.

이전 글: 멀티플레이 상품 Drop 중 HardSnap 문제 해결


ProductState를 RepNotify로 복제하기

상품 Actor에서는 FProductRepState를 RepNotify 변수로 가지고 있다.

UPROPERTY(ReplicatedUsing = OnRep_ProductState, VisibleAnywhere, BlueprintReadOnly, Category = "Product")
FProductRepState ProductState;

그리고 GetLifetimeReplicatedProps()에서 복제 대상으로 등록했다.

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

    DOREPLIFETIME(ThisClass, ProductState);
}

이렇게 하면 서버에서 ProductState가 변경되었을 때
클라이언트에서 OnRep_ProductState()가 호출된다.

이 구조를 사용한 이유는
상품 상태가 바뀔 때 단순히 값만 바뀌는 것이 아니라,
상태에 맞게 물리, 충돌, 표시 여부도 함께 바꿔야 했기 때문이다.

서버
-> ProductState 변경

클라이언트
-> OnRep_ProductState 호출
-> 상태에 맞는 물리 / 충돌 / 표시 처리 적용

상태 변경은 서버에서만 처리하기

멀티플레이 게임에서는 상품 상태를 클라이언트가 마음대로 바꾸면 안 된다.
특히 이 게임에서는 같은 상품을 여러 플레이어가 동시에 노릴 수 있다.

만약 클라이언트가 직접 상품을 GrabbedLoaded로 바꾼다면
서로 다른 클라이언트에서 다른 결과가 나올 수 있다.

그래서 상품 상태 변경은 서버에서만 처리하도록 했다.

void AProductBase::SetProductState(EProductState NewState)
{
    if (!HasAuthority()) return;

    if (ProductState.State == NewState) return;

    ProductState.State = NewState;

    // 서버 또한 State에 따른 변화를 적용해야 함
    ApplyProductState();
}

HasAuthority()가 없으면 바로 return하도록 해서
서버가 아닌 곳에서는 상태를 변경하지 못하게 했다.

또 서버에서도 상태가 바뀌면 ApplyProductState()를 바로 호출했다.

RepNotify는 클라이언트에서 상태 변경을 반영하기 위한 것이고,
서버 자신도 상태에 따른 물리나 충돌 처리를 적용해야 하기 때문이다.

서버
-> SetProductState
-> ProductState 변경
-> ApplyProductState 직접 호출

클라이언트
-> ProductState 복제 수신
-> OnRep_ProductState
-> ApplyProductState 호출

상태별 동작 적용하기

상태에 따른 실제 처리는 ApplyProductState()에서 담당하도록 했다.

상품 상태가 바뀔 때마다
숨김 여부, 물리 시뮬레이션, 충돌, 로봇손 획득 판정용 Collision을 조정한다.

예를 들어 Display 상태에서는 상품이 월드에 보여야 하고,
물리와 획득 판정도 가능해야 한다.

case EProductState::Display:
    SetActorHiddenInGame(false);
    SetNetUpdateFrequency(20.f);

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

    GrabCollision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
    break;

반대로 Loaded 상태에서는 카트에 적재된 상태이기 때문에
월드에서는 숨기고, 물리와 충돌도 꺼준다.

case EProductState::Loaded:
    SetActorHiddenInGame(true);
    SetNetUpdateFrequency(1.f);

    Mesh->SetSimulatePhysics(false);
    Mesh->SetPhysicsLinearVelocity(FVector::ZeroVector);
    Mesh->SetPhysicsAngularVelocityInDegrees(FVector::ZeroVector);
    Mesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);

    GrabCollision->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    break;

Falling 상태에서는 다시 월드에 보여야 하고,
바닥에 떨어지는 연출을 위해 물리 시뮬레이션을 켠다.

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

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

    GrabCollision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
    break;

상태별 흐름을 정리하면 다음과 같다.

상태 표시 여부 물리 획득 판정
Display 보임 켜짐 가능
Grabbed 보임 꺼짐 불가능
Loaded 숨김 꺼짐 불가능
Falling 보임 켜짐 가능
Paid 숨김 꺼짐 불가능

Grabbed 상태

Grabbed는 로봇손이 상품을 잡기로 확정한 상태다.

아직 손에 완전히 붙은 상태라고 보기보다는,
서버 기준으로 이 상품은 누군가가 가져가기로 예약된 상태에 가깝다.

로봇손은 조준 방향으로 Trace를 수행하고,
상품에 맞으면 서버에서 상품을 Grabbed 상태로 바꾼다.

bool AProductBase::TrySetGrabbed()
{
    if (!HasAuthority()) return false;

    if (!CanGrab()) return false;

    SetProductState(EProductState::Grabbed);
    ForceNetUpdate();
    return true;
}

여기서 CanGrab()은 현재 상품이 잡을 수 있는 상태인지 확인한다.

bool AProductBase::CanGrab() const
{
    return ProductState.State == EProductState::Display ||
        ProductState.State == EProductState::Falling;
}

즉, 진열 중인 상품과 바닥에 떨어진 상품만 잡을 수 있다.
이미 Grabbed, Loaded, Paid 상태인 상품은 다시 잡을 수 없다.

이렇게 처리한 이유는
멀티플레이에서 여러 플레이어가 같은 상품을 동시에 잡으려는 상황을 막기 위해서다.

서버에서 먼저 Trace에 성공한 플레이어가 상품을 Grabbed 상태로 바꾸면,
다른 플레이어는 더 이상 그 상품을 잡을 수 없다.


Loaded 상태

Loaded는 상품이 카트에 적재된 상태다.

로봇손이 상품을 잡은 뒤 일정 시간이 지나면 카트 적재 컴포넌트에서 상품을 적재하려고 시도한다.
상품은 Grabbed 상태일 때만 Loaded 상태로 바뀔 수 있도록 했다.

bool AProductBase::TrySetLoaded()
{
    if (!HasAuthority()) return false;

    if (!CanLoad()) return false;

    SetProductState(EProductState::Loaded);
    ForceNetUpdate();
    return true;
}

CanLoad()는 다음처럼 구현했다.

bool AProductBase::CanLoad() const
{
    return ProductState.State == EProductState::Grabbed;
}

즉, 상품이 바로 Display에서 Loaded가 되는 것이 아니라 Grabbed 상태를 거친 뒤 Loaded가 된다.

흐름으로 보면 다음과 같다.

Display 또는 Falling
-> 로봇손 Trace 성공
-> Grabbed
-> 로봇손 회수 완료
-> Loaded

이렇게 상태 전환 조건을 함수로 분리해두면 잘못된 상태 전환을 막기 쉽다.

예를 들어 이미 정산된 상품이 다시 Loaded 되거나,
진열 상태의 상품이 로봇손 처리 없이 바로 Loaded 되는 흐름을 막을 수 있다.


Falling 상태

Falling은 카트에 실려 있던 상품이 충돌로 인해 바닥에 떨어지는 상태다.

상품이 떨어질 때는 서버에서 드롭 위치를 계산하고 상품을 해당 위치로 이동시킨 뒤,
랜덤 방향으로 Impulse를 적용한다.

ProductState.DropLocation = DropLocation;

SetProductState(EProductState::Falling);

Mesh->WakeAllRigidBodies();
Mesh->AddImpulse(ImpulseDirection, NAME_None, true);

Falling 상태는 Display와 비슷하게 월드에 보이고, 물리 시뮬레이션이 켜져 있으며,
로봇손으로 다시 회수할 수 있다.

다만 멀티플레이에서는 서버가 계산한 드롭 시작 위치를 기준으로
클라이언트도 같은 상태를 적용해야 했다.

이 부분은 HardSnap 트러블슈팅 글에서 자세히 다뤘기 때문에,
이번 글에서는 Falling이 “떨어지는 중이며 다시 회수 가능한 상태”라는 점만 정리하고 넘어가려고 한다.


Paid 상태

Paid는 계산대에서 정산이 끝난 상태다.

정산이 끝난 상품은 더 이상 로봇손으로 획득되거나, 물리 충돌 대상으로 남아 있을 필요가 없다.

그래서 Paid 상태에서는 상품을 숨기고 충돌을 비활성화한 뒤,
바로 제거하지 않고 SetLifeSpan()을 이용해 일정 시간 뒤 삭제되도록 했다.

LoadedProducts[i]->SetProductState(EProductState::Paid);
LoadedProducts[i]->SetLifeSpan(2.f);

이렇게 하면 Paid 상태는 “정산이 끝났고 제거될 예정인 상태”라는 의미를 가지게 된다.


상태 전환 흐름 정리

현재 상품 상태 흐름을 정리하면 다음과 같다.

Display
-> 진열 중
-> 로봇손으로 획득 가능

Grabbed
-> 로봇손이 잡기로 확정
-> 다른 플레이어가 다시 잡을 수 없음

Loaded
-> 카트에 적재됨
-> 월드에서는 숨김

Falling
-> 충돌로 바닥에 떨어짐
-> 물리 활성화
-> 다시 로봇손으로 획득 가능

Paid
-> 계산대에서 정산 완료
-> 숨김 / 제거 대상

전체 흐름은 다음과 같이 볼 수 있다.

Display
-> Grabbed
-> Loaded
-> Paid

Loaded
-> Falling
-> Grabbed
-> Loaded

즉, 상품은 한 번 카트에 적재된 뒤에도 충돌로 떨어질 수 있고,
떨어진 상품은 다시 회수될 수 있다.

이런 흐름을 enum 상태로 관리하니
상품이 현재 어떤 단계에 있는지 더 명확하게 표현할 수 있었다.


enum으로 관리했을 때의 장점

이번 구조에서 가장 좋았던 점은
상품의 상태와 상태별 처리를 한 흐름으로 묶을 수 있었다는 점이다.

처음에는 상태별로 필요한 처리를 각각 다른 함수에서 할 수도 있었다.

잡힐 때 충돌 끄기
적재될 때 숨기기
떨어질 때 물리 켜기
정산될 때 제거하기

하지만 이런 처리가 여기저기 흩어지면
나중에 상태가 추가되거나 수정될 때 빠뜨리는 부분이 생기기 쉽다.

이번에는 EProductState로 상태를 구분하고,
ApplyProductState()에서 상태별 처리를 모아두었다.

상태 변경
-> SetProductState
-> ApplyProductState
-> 물리 / 충돌 / 표시 여부 적용

또 상태 변경을 서버에서만 처리하도록 해서
멀티플레이에서 상품 상태가 클라이언트마다 다르게 바뀌는 문제를 줄일 수 있었다.

특히 Grabbed 상태는
로봇손으로 상품을 잡기로 확정한 상태로 사용했기 때문에,
동시에 여러 플레이어가 같은 상품을 가져가는 상황을 막는 데 도움이 되었다.


마무리

이번에 상품 상태를 enum으로 나누어 관리하면서,
멀티플레이에서는 단순히 기능이 동작하는 것뿐만 아니라
현재 상태를 명확하게 표현하는 것도 중요하다는 것을 느꼈다.

아직 상태 수가 많지 않기 때문에
상태 패턴까지 적용하지는 않고 switch문으로 처리했고,
현재 프로젝트 규모에서는 이 방식이 더 단순하고 읽기 좋다고 판단했다.

앞으로 로봇손 획득 판정이나 카트 적재 시스템을 정리할 때도
이번에 만든 상품 상태 흐름을 기준으로 이어서 설명할 수 있을 것 같다.