
12편에서는 서버가 만든 같은 Drop을 두 클라가 보게 했고 13편에서는 클라의 요청을 결과가 아닌 검증할 입력으로 다뤘다. 이제 두 클라가 같은 dropId를 보고 잇달아 ClickLoot를 시도하면 서버는 그 아이템의 주인을 하나만 정해야 한다. Loot of Legends라는 프로젝트의 가장 꽃인 기능이 아닐까.
문제는 Loot 버튼 클릭 자체가 아니다. 같은 아이템을 두 사람이 원할 때 첫 번째 성공만 소유권을 바꾸고 뒤따르는 시도는 이미 정해진 주인을 건드리지 않아야 한다.
즉 이번 14편에서는 이 질문에 대한 답을 찾는 글이다.
같은
dropId에 여러ClickLoot시도가 들어오면 어떻게 주인을 결정해야 하는가?

Drop 구조체를 처음 구상할 때는 dropId, itemId, quantity 정도를 먼저 생각했다. 하지만 누군가 집는 순간부터는 이 아이템이 아직 바닥에 있는지, 이미 주인이 정해졌는지도 서버 상태에 남아야 한다.
그래서 Drop에 두 필드를 더 만들었다.
struct Drop {
uint32_t dropId{0};
uint32_t itemId{0};
uint16_t quantity{0};
uint16_t unitWeight{1};
bool claimed{false};
uint64_t ownerSessionId{0};
};
claimed는 소유권이 이미 확정됐는지를 나타낸다. ownerSessionId는 그 결정으로 정해진 session을 가리킨다. 처음 생성된 Drop은 claimed == false, ownerSessionId == 0으로 초기화하고 성공한 claim 하나가 이 둘을 함께 바꾸는 식이다.
claimed는 이후 시도가 들어왔을 때 더 진행해도 되는지를 빠르게 판단하는 상태다. ownerSessionId는 이미 결정된 승자가 누구인지 보존하는 값이다.
내가 명확히 하고 싶었던 상태 경계는 아직 그 누구에게도 획득되지 않은 Drop에는 owner가 없고, 획득된 Drop에는 정확히 한 session의 id가 남아야 한다는 것이었다. 두 사람이 같은 아이템을 원하더라도 ownerSessionId가 두 개로 늘어나지 않는다. 하나의 Drop 안에는 오직 하나의 owner만 기록한다.
인벤토리도 이 소유권과 따로 놀면 안 될 것이다. 예를 들어 어떤 drop이 session 10의 소유라고 적혀 있는데 session 20의 인벤토리에도 같은 아이템이 들어간다면, 필드 하나만 맞고 게임 상태 전체는 정합성이 깨진 셈이다. 그래서 아이템을 루팅할 때는 성공한 Drop 상태와 승자 인벤토리, 거절된 플레이어의 변하지 않은 인벤토리를 함께 봐야 할 것이다.

Room::claimLoot()는 sessionId와 dropId를 받는다. 먼저 session이 이 Room에 진짜 있는지 확인하고 같은 dropId를 가진 Drop과 그 session의 인벤토리를 찾는다.
LootClaimResult Room::claimLoot(uint64_t sessionId, uint32_t dropId) {
LootClaimResult result{};
if (!contains(sessionId)) {
return result;
}
auto dropIt = std::find_if(
drops_.begin(),
drops_.end(),
[dropId](const Drop& drop) { return drop.dropId == dropId; });
if (dropIt == drops_.end()) {
return result;
}
// 반환 형식이 포인터인 이유는 수정하기 위해서!
InventorySnapshot* inventory = findMutableInventory(sessionId);
if (inventory == nullptr) {
return result;
}
result.found = true;
result.drop = *dropIt;
result.inventory = *inventory;
여기까지 왔다고 바로 승자는 아니다. found는 루팅 판정을 진행할 Drop과 인벤토리를 찾았다는 표시일 뿐이다. 인벤토리에 아이템을 반영할 수 있는지까지 확인한 뒤에야 소유권을 바꾼다.
성공 경로의 마지막은 아래와 같다.
inventory->currentWeight = static_cast<uint16_t>(
static_cast<uint32_t>(inventory->currentWeight) + addedWeight);
dropIt->claimed = true;
dropIt->ownerSessionId = sessionId;
result.claimed = true;
result.winnerSessionId = sessionId;
result.drop = *dropIt;
result.inventory = *inventory;
return result;
}
인벤토리에 아이템과 무게를 반영한 뒤 claimed를 true로 만들고 ownerSessionId에 승자의 session을 기록한다. 반환값에도 같은 승자와 변경된 Drop, 변경된 인벤토리를 담는다.
여기서 중요한 표현은 첫 번째 성공이다. 단순히 함수에 먼저 들어온 시도라는 이유만으로 이기는 게 아니라, Drop을 찾고 인벤토리에 반영할 수 있어 실제 상태 변경까지 끝낸 claim이 승자가 된다. 실패한 시도는 소유권에 흔적을 남기지 않는다.
코드 배치에서도 이 기준이 드러난다. Drop이나 인벤토리를 찾지 못한 경우와 인벤토리에 넣을 수 없는 경우는 dropIt->claimed = true에 도달하기 전에 빠져나간다. 따라서 시도했다는 사실만으로 claimed를 먼저 찍어두지 않고 서버가 받아들일 수 있는 유효한 claim만 owner 기록 지점까지 내려온다.
반대로 owner가 기록된 뒤에는 이번 성공 결과를 구성하는 일만 남는다. claimed, winnerSessionId, 변경된 Drop과 인벤토리가 같은 성공을 설명한다. “먹기 요청을 받았다”와 “아이템 소유권이 바뀌었다”를 동의어로 취급하지 않는 것이 중요하다고 생각한 부분이다.
한 번 루팅이 성공한 뒤 같은 dropId가 다시 들어오면 dropIt->claimed는 이미 true다. 이 분기에서는 owner를 새 session으로 덮어쓰지 않는다.
if (dropIt->claimed) {
result.rejected = true;
result.rejectReason = LootRejectReason::kAlreadyClaimed;
result.winnerSessionId = dropIt->ownerSessionId;
return result;
}
서버는 kAlreadyClaimed를 기록하고 기존 ownerSessionId를 winnerSessionId로 돌려준다. 이 경로에는 dropIt->ownerSessionId = sessionId도 없고 거절된 플레이어의 인벤토리를 바꾸거나 하는 로직도 없다.
즉 후속 시도는 새 결정을 만드는 입력이 아니다. 이미 끝난 결정을 읽고 거절 결과로 빠지는 입력이다. 승자를 한 명으로 유지하는 핵심은 두 번째 시도를 더 잘 비교하는 데 있지 않다. 첫 성공 뒤에는 소유권 변경 경로로 다시 들어가지 못하게 막는 멱등한 로직에 있다.
이때 winnerSessionId가 거절을 시도한 session으로 바뀌지 않는 점도 중요하다. 거절 결과는 “네가 실패했다”만 말하는 값이 아니라, “이미 결정된 승자는 그대로다”라는 현재 상태를 함께 들고 나온다. 후속 입력이 여러 번 이어져도 이 분기는 같은 owner를 읽을 뿐이다.
Room의 결과를 받은 RoomManager는 found를 먼저 본다. 이 구분이 있어야 존재하지 않는 dropId를 보낸 경우와, 존재하지만 이미 주인이 정해진 Drop을 누른 경우를 같은 실패로 뭉개지 않을 수 있다.
const LootClaimResult claim = room.claimLoot(sessionId, dropId);
if (!claim.found) { // dropId가 없거나, inventory를 못 찾는 등 루팅 판정 대상이 없다면
return RoomCommandResult(false, RoomCommandError::kNotFound, summarizeRoom(room));
}
RoomCommandResult result(
true,
RoomCommandError::kNone,
summarizeRoom(room),
room.playerSessionIds(),
false,
false,
false,
room.monster(),
room.drops());
// claimLoot 결과 -> RoomCommandResult의 루팅 필드로 옮기기
result.lootJustClaimed = claim.claimed;
result.lootRejected = claim.rejected;
result.lootRejectReason = claim.rejectReason;
result.winnerSessionId = claim.winnerSessionId;
result.drop = claim.drop;
result.inventory = claim.inventory;
return result;
}
found == false라면 명령을 판정할 대상 자체가 없으므로 RoomCommandResult(false, kNotFound, ...)가 된다. 반면 found == true인 상태에서 rejected == true라면 Drop은 실제로 존재했고 그 Drop의 현재 상태를 확인한 결과 claim이 거절됐다.
그래서 AlreadyClaimed는 RoomCommandResult 관점에서 ok == true일 수 있다. 명령이 정상적인 Room과 Drop까지 도달해 판정을 마쳤지만 도메인 결과가 "소유권 변경 없음"인 경우다. 명령 실패와 도메인 계층 거절을 분리하면 서버는 무엇을 찾지 못했는지와 무엇을 판단해 거절했는지를 잃지 않는다.
예를 들어 존재하지 않는 dropId 999는 비교할 소유권 상태 자체가 없다. 이 경우에는 found == false가 맞다. 반면 실제 Drop을 찾았고 그 안에 claimed == true가 들어 있다면, 서버는 대상을 찾은 뒤 현재 상태를 근거로 거절한 것이므로 found == true, rejected == true가 된다.
둘을 모두 단순 실패로 뭉개버리면 로그나 상위 처리에서 의미가 사라질 것이다. 잘못된 식별자를 보낸 것인지, 유효한 아이템이 이미 다른 사람에게 넘어간 것인지 구분할 수 없기 때문이다. RoomManager는 Room의 세부 판정을 그대로 노출하지 않으면서도 이 차이를 결과 필드로 보존한다.

첫 번째 테스트는 session 10이 Drop을 획득한 뒤, 반환된 Drop과 실제 Room 상태를 함께 확인한다.
const Game::RoomCommandResult claimed = manager.claimLoot(10, defeated.drops[0].dropId);
ASSERT_TRUE(claimed.ok);
EXPECT_TRUE(claimed.lootJustClaimed);
EXPECT_FALSE(claimed.lootRejected);
EXPECT_EQ(claimed.lootRejectReason, Game::LootRejectReason::kNone);
EXPECT_EQ(claimed.winnerSessionId, 10u);
EXPECT_TRUE(claimed.drop.claimed);
EXPECT_EQ(claimed.drop.ownerSessionId, 10u);
EXPECT_EQ(claimed.inventory.sessionId, 10u);
EXPECT_EQ(claimed.inventory.currentWeight, 1u);
EXPECT_EQ(claimed.inventory.maxWeight, Game::Room::kDefaultMaxInventoryWeight);
ASSERT_EQ(claimed.inventory.entries.size(), 1u);
EXPECT_EQ(claimed.inventory.entries[0].itemId, 1001u);
EXPECT_EQ(claimed.inventory.entries[0].quantity, 1u);
const Game::Room* room = manager.findRoom(created.room.roomId);
ASSERT_NE(room, nullptr);
ASSERT_EQ(room->drops().size(), 1u);
EXPECT_TRUE(room->drops()[0].claimed);
EXPECT_EQ(room->drops()[0].ownerSessionId, 10u);
반환값만 그럴듯하게 맞춘 게 아니다. Room 안의 Drop도 claimed == true, ownerSessionId == 10이어야 한다. 승자의 인벤토리에는 item 1001이 수량 1로 들어가고 현재 무게도 1이 된다.
여기서 반환된 claimed.drop만 확인했다면 복사된 결과가 맞는지만 알 수 있다. 실제 room->drops()[0]까지 다시 읽는 이유는 이후 claim이 참고할 원본 상태가 정말 바뀌었는지 보기 위해서다. 다음 입력은 이전 함수의 반환값이 아니라 Room 안에 남은 Drop을 다시 찾기 때문이다.
두 번째 테스트는 session 10의 claim을 먼저 적용한 뒤 session 20의 claim을 적용한다. session 20은 정상적으로 판정까지 도달하지만 결과는 kAlreadyClaimed다.
ASSERT_TRUE(manager.claimLoot(10, defeated.drops[0].dropId).ok);
const Game::RoomCommandResult rejected = manager.claimLoot(20, defeated.drops[0].dropId);
ASSERT_TRUE(rejected.ok);
EXPECT_FALSE(rejected.lootJustClaimed);
EXPECT_TRUE(rejected.lootRejected);
EXPECT_EQ(rejected.lootRejectReason, Game::LootRejectReason::kAlreadyClaimed);
EXPECT_EQ(rejected.winnerSessionId, 10u);
EXPECT_TRUE(rejected.drop.claimed);
EXPECT_EQ(rejected.drop.ownerSessionId, 10u);
EXPECT_EQ(rejected.inventory.sessionId, 20u);
EXPECT_EQ(rejected.inventory.currentWeight, 0u);
EXPECT_TRUE(rejected.inventory.entries.empty());
const Game::Room* room = manager.findRoom(created.room.roomId);
ASSERT_NE(room, nullptr);
ASSERT_EQ(room->drops().size(), 1u);
EXPECT_EQ(room->drops()[0].ownerSessionId, 10u);
검증 지점은 두 군데다. Drop의 owner는 여전히 session 10이고 거절된 session 20의 인벤토리는 무게 0과 빈 entries를 유지한다. 기존 승자를 보존하는 것과 패자의 인벤토리를 건드리지 않는 것을 함께 확인해야 “하나만 이긴다”는 말이 완성된다.
승자 테스트와 거절 테스트는 서로 다른 절반을 담당한다. 전자는 첫 성공이 owner와 승자 인벤토리를 실제로 바꾸는지 확인하고 후자는 이미 확정된 owner를 후속 시도가 바꾸지 못하며 패자 인벤토리에도 아이템이 생기지 않는지 확인한다. 성공만 검사하면 중복 획득을 놓칠 수 있고, 거절 값만 검사하면 원래 승자가 보존됐는지 놓칠 수 있다.
여기서 first는 서버 애플리케이션이 상태 변경을 적용한 순서에서 앞선 성공을 뜻한다. 테스트도 claimLoot(10, ...)을 호출한 뒤 claimLoot(20, ...)을 호출한다. 어느 사용자가 화면에서 더 일찍 눌렀는지, 두 입력 중 무엇이 실제 네트워크에서 더 빨랐는지를 판정하는 기준은 이 코드와 테스트에 없다.
슬프게도 여기에는 분명한 한계가 있다. 이 테스트가 증명하는 건 Room 상태 변경을 하나씩 적용하는 실행 경로에서 owner가 한 명만 남는다는 사실이다. 여러 스레드가 같은 Room::claimLoot()를 동시에 호출해도 안전하다는 뜻은 아니다. 그런 상황까지 다루려면 한 Room의 상태 변경을 누가 어떤 순서로 실행하는지 별도의 보장이 필요하다.
같은 dropId를 여러 번 눌러도 Drop의 소유권 상태는 한 번만 바뀐다. 첫 성공은 승자의 인벤토리와 claimed, ownerSessionId를 함께 갱신하고 이후 시도는 AlreadyClaimed로 빠지면서 기존 owner와 거절된 플레이어의 인벤토리를 그대로 둔다.
이제 서버 안에서는 누가 이겼는지가 정해졌다. 남은 질문은 결정된 결과를 게임 밖으로 어떻게 드러낼 것인가다.