아이템을 루팅하는 그 찰나의 판정

반짇고리·2026년 5월 3일

게임 서버 제작기

목록 보기
15/15


12편에서 같은 Drop을 Room 안의 두 클라가 보게 만들었다. 13편에서는 클릭을 결과가 아니라 검증할 입력으로 다뤘고, 14편에서는 같은 dropId를 여러 클라가 동시에 루팅할 때 소유자는 한 명만 남겨야 한다고 정리했다.

그런데 승자가 정해진 순간에도 일이 끝나지 않았다.

서버가 claimed = true를 바꿨다고 해서, Room 안의 두 클라가 같은 장면을 보고 있는 것은 아니기 때문이다. 한 명은 바닥의 아이템이 사라졌다는 사실을 알아야 하고, 한 명은 자기 인벤토리가 바뀌었다는 사실을 알아야 한다. 늦게 누른 사람은 왜 실패했는지도 알아야 했다.

하나의 ClickLoot 판정 뒤에, 누구에게 무엇을 알려야 할까?

이번 포스팅에서는 결과를 세 갈래로 나눈 이유에 대해 돌아보려고 한다.


결과가 하나라고, 응답도 하나일 필요가 있을까

처음 구상할 때는 클라 측의 루팅 요청에 대한 서버의 응답으로는 ClickLootResponse 하나면 될 것 같았다.

성공인지 실패인지 넣고, 성공했다면 아이템 정보와 인벤토리까지 같이 넣으면 된다. 요청 하나에 응답 하나.

하지만 이 응답은 수신자에 따라 다르게 설계해야 했다.

루팅에 성공하면 Room 전체가 알아야 할 사실은 이 Drop의 주인이 정해졌다는 것이다. 다른 플레이어 화면에서도 바닥 아이템을 더 이상 잡을 수 없어야 하니까. 반면 인벤토리의 무게와 보유 아이템은 승자 한 명의 상태다. Room 전체가 받을 정보는 아니다.

실패도 마찬가지다. 이미 다른 사람이 가져간 Drop을 다시 누른 플레이어에게는 실패 이유가 필요하다. 그렇다고 그 거절을 Room 전체에 broadcast할 이유는 없다. 끝난 판정으로 다른 플레이어 화면을 한 번 더 흔들 필요가 없었다.

그래서 결과를 다음처럼 여러 분기로 나눴다.

Room이 함께 알아야 하는 사실     -> LootResolved
요청자만 알아야 하는 거절 이유    -> LootRejected
승자만 알아야 하는 개인 상태 변경 -> InventorySnapshot

사실 그래서 LootResolved는 승자에게만 주어지는 증명서라기보다는 Room에서 공유되는 Drop 상태가 바뀌었다는 이벤트라고 보는 게 맞다.

InventorySnapshot도 승리 알림 같은 것이 아니라 실제로 바뀐 한 세션의 아이템 보유 상태다.

같은 루팅 결과에서 나왔지만, 두 패킷은 같은 수신자를 가리키지 않는다.


Room 안에서 끝난 판정의 분기

14편에서 만든 claimLoot는 Drop을 찾고, 이미 주인이 있는지 확인하고, 무게를 확인한 뒤에야 소유권과 인벤토리를 바꾼다.

중요한 건 서버가 먼저 판정한다는 점이다. 클라가 내가 먹었다고 말하면 서버가 옳다구나 하고 그 결과를 다른 클라에게 무작정 퍼뜨리는 구조가 아니다. 클라는 바닥에 떨어진 dropId 위에서 루팅 버튼을 클릭했다고 서버에게 요청을 보낼 뿐이다. Room은 그 요청이 성공인지, 이미 늦었는지, 무게가 넘는지를 결정한다.

판정 결과에는 Room 정보, Drop 정보, 승자 session, 요청자의 inventory snapshot이 함께 들어 있다. 하지만 이 내용을 통째로 한 패킷에 넣지는 않았다.

if (result.lootRejected) {
    return sendLootRejected(connection.clientFd(), result, disconnectedClients);
}

if (result.lootJustClaimed &&
    !broadcastLootResolved(result, disconnectedClients)) {
    return false;
}

if (result.lootJustClaimed &&
    !sendInventorySnapshot(connection.clientFd(), result.inventory, disconnectedClients)) {
    return false;
}

connection.clientFd()는 요청을 보낸 연결 하나다. 거절과 인벤토리 snapshot은 여기로 간다.

반대로 broadcastLootResolved는 결과에 들어 있는 Room 구성원을 확인한 뒤, 성공한 첫 claim을 그 Room에만 알린다. Room 밖 연결은 이 사실을 받을 필요가 없다.

성공한 사람과 지켜본 사람은 같은 LootResolved를 받는다. 다만 성공한 사람만 바로 뒤에 자신의 InventorySnapshot을 받는다.


루팅 거절도 하나일 필요가 있을까

LootRejected가 나온다고 해서 모든 실패가 이 패킷으로 끝나는 것은 아니다.

Room에 들어 있지 않은 클라가 ClickLoot를 보냈다면, 게임 상태 안에서 경쟁하다 진 경우가 아니다. 명령을 처리할 Room 자체가 없기 때문이다. 이 경우에는 기존 command error 경로로 돌려보내도록 설계했다.

반대로 Room 안에서 같은 Drop을 늦게 누르거나, 인벤토리 무게가 넘는 경우에는 요청 형식과 Room 모두 맞다. 다만 현재 게임 상태가 claim을 허용하지 않는 것이다. 이 경계가 LootRejected가 맡을 일이다.

Room 밖 요청 -> command error
이미 다른 사람이 획득한 Drop -> LootRejected(AlreadyClaimed)
무게가 넘는 claim -> LootRejected(Overweight)

이 구분이 없으면 클라는 "패킷이 잘못됐는지", "내가 너무 늦었는지", "인벤토리가 찼는지"를 같은 실패로 보게 된다. 서버도 어떤 상태를 보존해야 하는지 판단하기가 어려울 것이다.

특히 무게 초과에서는 Drop의 owner를 비워 둬야 했다. 실패한 요청 때문에 아이템이 사라지거나, 누군가의 인벤토리에만 애매하게 들어가면 안 된다. 실패도 상태를 보존하는 방식으로 끝나야 한다.


패킷은 상태를 복사하는 방법이기도 하다

InventorySnapshot에는 아이템 목록만 넣지 않았다. 어떤 session의 인벤토리인지, 현재 무게와 최대 무게도 같이 보낸다.

처음에는 아이템을 하나 얻은 거니 itemId, quantity만 생각했다. 하지만 인벤토리는 아이템 하나의 변화만으로 정해지지 않는다. 같은 아이템이 이미 있다면 quantity가 합쳐야 하고, 무게는 여러 아이템의 합으로 바뀐다. 클라가 이전 상태에 변화 하나를 얹으려면, 그 이전 상태부터 정말 같아야 한다.

그래서 이 단계에서는 승자의 현재 인벤토리 전체를 snapshot으로 보냈다. 서버가 가진 상태를 한 번 직렬화해 보여주는 편이, 클라가 추측으로 덧셈하는 것보다 경계가 분명하기 때문이다.


테스트

이 구조는 패킷 serializer만 통과한다고 끝나지 않는다. LootResolved, LootRejected, InventorySnapshot은 각각 직렬화하고 다시 읽는 테스트로 필드를 확인했다. InventorySnapshot은 count와 packet 크기가 맞지 않으면 parser가 거절했고, 최대 크기를 넘는 항목은 serializer가 만들지 않았다.

더 중요한 건 두 클라가 붙은 통합 흐름이었다.

첫 번째 클라가 ClickLoot에 성공하면 두 클라는 같은 LootResolved를 받는다. 테스트에서는 승자만 InventorySnapshot을 받고, 다른 클라에는 추가 패킷이 오지 않았다. 그 뒤 두 번째 클라가 같은 dropId를 누르면 그 클라만 LootRejected(AlreadyClaimed)를 받는다. 거절 뒤에는 두 클라 모두에게 관계없는 패킷이 더 오지 않는지도 확인했다.

Room 밖에서 들어온 ClickLoot 요청은 LootRejected로 꾸며 보내지 않았다. command error로 거절해, 게임 상태 안의 패배와 명령을 처리할 수 없는 상태를 나눴다.

맺으며

다시 보니, 루팅 결과를 세 패킷으로 나눈 일은 패킷 개수를 늘린 일이 아니었다. Room의 공통 상태, 요청자의 실패, 승자의 개인 상태를 섞지 않기 위한 경계였다.

Drop의 owner는 한 명만 남는다.

이 한 번의 판정은 Room에는 LootResolved로, 늦은 요청자에게는 LootRejected로, 승자에게는 InventorySnapshot으로 이어진다. 같은 결과여도 각 클라가 받아야 할 상태는 같지 않았다.

profile
Sapere Aude!

0개의 댓글