메이플스토리월드 :: 인벤토리 구현 04

chooha·2026년 7월 28일

메이플스토리월드

목록 보기
4/15

📝 개발일지 - 클라이언트 아이템 데이터 로딩 타이밍 트러블슈팅

👨‍💻 오늘의 개발 작업

인벤토리 UI(슬롯 렌더링)를 테스트하다가 아이콘이 전혀 표시되지 않는 문제를 발견함. 처음엔 단순 배선 실수인 줄 알았으나, 파고들수록 "서버 권위 원칙을 어디까지 적용해야 하는가"라는 설계 판단 문제와, "서버-클라이언트 간 초기화 순서는 보장되지 않는다"는 구조적 문제, 두 겹으로 얽힌 이슈였음을 확인함


💡 오늘의 기록

1. 문제 상황 파악

인벤토리를 열면 슬롯은 정상적으로 생성되는데, 아이템을 넣어도 아이콘이 뜨지 않음. UIItemSlot.SetItem에서 _ItemData.itemTable[itemId]를 조회해 아이콘 리소스를 가져오는 구조인데, 여기서 뭔가 어긋나고 있다고 추정함

2. 1차 원인 확인 - _ItemData가 클라이언트에서 아예 로드되지 않음

바로 코드를 고치기 전에, 실제로 클라이언트 쪽 _ItemData 상태가 어떤지부터 로그로 직접 확인함

-- UIInventory.OnBeginPlay에 임시로 삽입
log("[TEST] 클라 isInitialized = " .. tostring(_ItemData.isInitialized))
log("[TEST] 클라 itemTable 데이터 있음 = " .. tostring(next(_ItemData.itemTable) ~= nil))

결과: 둘 다 false. ItemData.OnBeginPlay가 ServerOnly로 설정돼있어서, 클라이언트 쪽 _ItemData 인스턴스는 애초에 OnBeginPlay 자체가 실행된 적이 없었고, 그래서 초기값({}, false)에 계속 머물러 있었던 것

3. 설계 판단 - 마스터 데이터를 클라이언트에서 직접 로드해도 되는가

ExecSpace를 바꾸기 전에, 이게 서버 권위 원칙을 깨는 건 아닌지부터 짚어봄

  • 서버 권위가 지켜야 하는 건 "플레이어가 실제로 무엇을 얼마나 갖고 있는가" 같은 개별 상태값 (예: PlayerInventory의 슬롯 데이터)
  • ItemData.itemTable은 "이 게임에 어떤 아이템이 존재하고, 아이콘/이름/최대스택이 뭔지" 같은 정적 정의(마스터 데이터)
  • 이건 애초에 숨길 이유가 없는 공개 정보임 — 클라이언트 UI가 아이콘을 그리려면 어차피 클라가 이 정보를 들고 있어야 하고, 서버가 숨긴다 해도 결국 화면에 다 드러나는 정보라 숨길 실익이 없음

→ "숨겨야 하는 상태값" vs "공개돼도 되는 규칙 정의"를 구분하는 기준을 세우고, ItemData.OnBeginPlay의 ExecSpace를 ServerOnly → "사용 안 함"(제한 없음)으로 변경. 서버/클라가 각자 독립적으로 같은 CSV를 읽어 itemTable을 캐싱하게 됨(네트워크 전송이 아니라 각자 로드하는 방식이라 서버 부하나 보안 노출도 없음)

4. 그런데 여전히 남아있는 문제 - 초기화 순서 레이스 컨디션

ExecSpace를 고쳐도, "서버가 보낸 인벤토리 이벤트가 클라이언트 자신의 _ItemData 로딩 완료보다 먼저 도착할 수 있다"는 문제는 그대로 남음. 서버의 _ItemData 완료 여부와 클라이언트의 _ItemData 완료 여부는 완전히 별개의 비동기 프로세스라서, 어느 쪽이 먼저 끝날지 보장할 방법이 없음

5. 해결 - 서버 쪽에서 이미 쓰던 방어 패턴을 클라이언트에 대칭 적용

사실 이 문제는 처음이 아니었음. PlayerInventory(서버)도 ItemData(Logic)와의 OnBeginPlay 순서가 보장 안 돼서, 이미 아래 패턴으로 방어하고 있었음

-- PlayerInventory.OnBeginPlay (서버, 기존 코드)
if _ItemData.isInitialized then
	self:InitInventory()
end
-- 아직이면 아무것도 안 함 — HandleOnItemDataInitialized가 늦게 온 초기화 완료를 대신 처리

같은 구조를 클라이언트(UIInventory)에도 그대로 대칭 적용함. 다만 서버 쪽은 "초기화를 지연시키는" 방식이었다면, 클라이언트 쪽은 이미 들어온 슬롯 데이터를 버릴 수 없어서 "캐시해두고 나중에 몰아서 그리는" 방식으로 변형함

method void ApplySlotData(integer slotIdx, integer itemId, integer itemCnt)
-- pendingSlots 캐시엔 무조건 기록. _ItemData가 아직 준비 안 됐으면 렌더링은 건너뛰고
-- 나중에 HandleOnItemDataInitialized가 캐시 전체를 몰아서 그림
self._T.pendingSlots = self._T.pendingSlots or {}
self._T.pendingSlots[slotIdx] = { itemId = itemId, itemCnt = itemCnt }

if _ItemData.isInitialized then
	self:RefreshSlot(slotIdx, itemId, itemCnt)
end
end

@EventSender("Logic", "ItemData")
handler HandleOnItemDataInitialized(OnItemDataInitialized event)
-- _ItemData가 인벤토리 이벤트보다 늦게 준비된 경우 대비: 캐시된 전체 슬롯을 한꺼번에 렌더링
if self._T.pendingSlots == nil then return end
for slotIdx, slotData in pairs(self._T.pendingSlots) do
	self:RefreshSlot(slotIdx, slotData.itemId, slotData.itemCnt)
end
end

6. 검증 범위

이 방어 로직이 실제로 "아직 준비 안 된 상태"를 캐치해 렌더링을 미룬 사례를 로그로 직접 관측하진 못함 — 어디까지나 "타이밍이 보장 안 되니 생길 수 있는 상황"이라는 논리적 추론에 따라 미리 방어적으로 설계한 것. 참고로 테스트 도중 OnInventoryModified 이벤트가 Initialized보다 먼저 도착함 경고를 본 적은 있으나, 이건 별개의 원인 — 임시 테스트 코드에서 BroadcastInitialized()보다 AddItem()을 먼저 호출해 생긴 순서 실수였고 _ItemData 로딩 타이밍과는 무관함

📚 성과와 개선 효과

  • ✅ 원인 특정: 추측 대신 상태값을 직접 로그로 찍어서 "클라에서 로드가 아예 안 되고 있다"는 사실을 먼저 확정한 뒤 수정에 들어감
  • ✅ 설계 기준 확립: "서버 권위로 지켜야 할 상태"와 "공개돼도 되는 규칙 정의"를 구분하는 기준을 세워, 이후 비슷한 판단(다른 마스터 데이터 추가 시)에도 재사용 가능
  • ✅ 구조적 일관성: 서버/클라 양쪽에 동일한 "초기화 순서 미보장 대응 패턴"을 대칭적으로 적용해, 앞으로 이런 종류의 Logic-Component 간 로딩 순서 문제가 생겨도 같은 패턴을 재사용할 수 있음

📚 개발 참고

이번 트러블슈팅은 특정 공식 문서보다는, 서버 권위 원칙의 적용 범위(상태값 vs 규칙 정의)를 직접 판단하며 진행함. 별도 참고 문서 없음

0개의 댓글