문제
GameSceneUIController.RemoveCard는 lowerBar.transform의 자식을 순회하다가 CardUI.CardName이 일치하는 첫 자식에서 Destroy(child.gameObject)를 호출하고 return 한다. UnityEngine.Object.Destroy는 프레임 끝까지 지연되므로 해당 자식은 그 프레임 동안 계속 부모에 붙어 있고 cardUI != null(50행)도 여전히 true다. 같은 프레임에 이어지는 호출은 같은 오브젝트를 다시 찾아 다시 Destroy 할 뿐이다. 호출자인 SyncFrameHandler.cs:50은 한 번의 Handler() 안에서 for (int i = 0; i < Math.Abs(diff); i++) RemoveCard(card);를 동기적으로 실행하므로, 실제로는 sync 한 틱당 카드 종류별로 한 장만 제거된다. 서버 DTO(CardInfoDto)에는 added 리스트만 있고 제거 필드가 없어 이 sync 경로가 유일한 교정 수단이다. GetAllCards(63-68행)도 같은 프레임 동안 동일한 지연 Destroy 노출을 갖는다.
위치
client/Assets/Scripts/GameScene/GameSceneUIController.cs:52
client/Assets/Scripts/GameScene/GameSceneUIController.cs:45
재현 / 영향
플레이어가 3장짜리 마법을 제출하면 서버는 카드를 소모하지만, magicValid 응답이 유실되거나 3초를 넘겨 지연되면 CardInputSender.WaitInputResponseTimeout(197-212행)이 CardUI를 파괴하는 대신 선택 상태를 복구한다. 이 시점에서 클라이언트 손패에는 서버가 갖고 있지 않은 카드 3장이 남는다. 다음 sync가 해당 종류에 대해 diff = -3을 계산하지만 실제로는 CardUI 한 장만 제거되므로, 유령 카드 두 장이 최소 한 sync 틱 이상 클릭 가능한 상태로 남는다. 플레이어는 서버가 갖고 있지 않은 카드로 selectCard/useMagic을 보내 거절당하고 턴을 낭비한다. 차이가 클수록 수렴에 그만큼 더 많은 틱이 걸린다.
수정 방향
각 호출이 서로 다른 오브젝트를 대상으로 하게 만든다. 일치하는 자식을 리스트로 모아 요청된 개수만큼 한 번에 파괴하거나(예: lowerBar.GetComponentsInChildren<CardUI>()를 순회해 앞에서 count개를 파괴하는 RemoveCards(string cardName, int count)), 파괴 전에 계층에서 분리한다: child.SetParent(null, false); Destroy(child.gameObject);. 또는 transform 계층을 재탐색하는 대신 GameSceneUIController에 명시적인 List<CardUI>로 손패를 추적한다. GetAllCards의 child.GetComponent<CardUI>() null 체크도 함께 보완한다.
심각도: medium · 분류: correctness
자동 코드 리뷰(멀티 에이전트 검증 통과)에서 발견됨.
문제
GameSceneUIController.RemoveCard는lowerBar.transform의 자식을 순회하다가CardUI.CardName이 일치하는 첫 자식에서Destroy(child.gameObject)를 호출하고 return 한다.UnityEngine.Object.Destroy는 프레임 끝까지 지연되므로 해당 자식은 그 프레임 동안 계속 부모에 붙어 있고cardUI != null(50행)도 여전히 true다. 같은 프레임에 이어지는 호출은 같은 오브젝트를 다시 찾아 다시 Destroy 할 뿐이다. 호출자인SyncFrameHandler.cs:50은 한 번의Handler()안에서for (int i = 0; i < Math.Abs(diff); i++) RemoveCard(card);를 동기적으로 실행하므로, 실제로는 sync 한 틱당 카드 종류별로 한 장만 제거된다. 서버 DTO(CardInfoDto)에는added리스트만 있고 제거 필드가 없어 이 sync 경로가 유일한 교정 수단이다.GetAllCards(63-68행)도 같은 프레임 동안 동일한 지연 Destroy 노출을 갖는다.위치
client/Assets/Scripts/GameScene/GameSceneUIController.cs:52client/Assets/Scripts/GameScene/GameSceneUIController.cs:45재현 / 영향
플레이어가 3장짜리 마법을 제출하면 서버는 카드를 소모하지만,
magicValid응답이 유실되거나 3초를 넘겨 지연되면CardInputSender.WaitInputResponseTimeout(197-212행)이 CardUI를 파괴하는 대신 선택 상태를 복구한다. 이 시점에서 클라이언트 손패에는 서버가 갖고 있지 않은 카드 3장이 남는다. 다음 sync가 해당 종류에 대해 diff = -3을 계산하지만 실제로는 CardUI 한 장만 제거되므로, 유령 카드 두 장이 최소 한 sync 틱 이상 클릭 가능한 상태로 남는다. 플레이어는 서버가 갖고 있지 않은 카드로 selectCard/useMagic을 보내 거절당하고 턴을 낭비한다. 차이가 클수록 수렴에 그만큼 더 많은 틱이 걸린다.수정 방향
각 호출이 서로 다른 오브젝트를 대상으로 하게 만든다. 일치하는 자식을 리스트로 모아 요청된 개수만큼 한 번에 파괴하거나(예:
lowerBar.GetComponentsInChildren<CardUI>()를 순회해 앞에서count개를 파괴하는RemoveCards(string cardName, int count)), 파괴 전에 계층에서 분리한다:child.SetParent(null, false); Destroy(child.gameObject);. 또는 transform 계층을 재탐색하는 대신GameSceneUIController에 명시적인List<CardUI>로 손패를 추적한다.GetAllCards의child.GetComponent<CardUI>()null 체크도 함께 보완한다.심각도: medium · 분류: correctness
자동 코드 리뷰(멀티 에이전트 검증 통과)에서 발견됨.