예시로 알아보는 서버 검증

# 학습 과정 소개

플레이어 엔티티에서 서버 함수를 호출할 경우 '패킷'을 통해 데이터를 전송합니다. 중요한 데이터를 패킷에 담아 보낼 경우, 서버는 받은 패킷을 바로 반영하지 않고 한 번 더 검증해 보안을 강화해야 합니다. 이번 가이드에서는 서버 검증 스크립트를 작성하고, 안정적인 서버를 구성하는 방법을 알아봅니다.

##### 참고 가이드

  * [패킷 변조 대비](/docs/?postId=1102)
  * [WorldConfig](/docs/?postId=1101)



# 검증 시 유의 사항

클라이언트 변조 및 패킷 변조 예방을 위해 서버 검증 장치를 추가해야 합니다. 월드 제작 시 모든 판정을 서버에서 할 필요는 없으나, 중요한 데이터를 다루는 판정은 서버에서 이뤄져야 합니다. 만약 클라이언트에서 판정을 거친 후 서버로 요청이 온 경우일지라도 중요한 데이터라면 서버에서 다시 검증하길 권장합니다. UI엔티티의 경우 클라이언트에만 존재하므로 UI 동작은 서버에서 클라이언트로 전송하고, 반영하도록 구현하길 권장합니다. 또한 클라이언트에서 보낸 패킷에 월드의 중요한 데이터를 바로 담는 것은 삼가야 합니다. 클라이언트에서 보낸 패킷에 골드, 경험치 같은 중요한 데이터를 담아서는 안 됩니다.

# 패킷 변조를 예방하는 서버 구성 예시

NPC와 상호 작용해 열쇠를 메소로 교환하는 시스템을 만들어봅시다. 교환 과정에서 변조 될 수 있는 경우의 수를 파악해 검증 방식을 추가합니다.

#### UI 만들기

  1. DefaultGroup에 UISprite, UIText를 2개씩 추가해 골드와 키 보유량을 알 수있는 UI를 만듭니다. [png](1706514331040538a923e7d024f43a83255f2192034cc) [png](1713870204930fa91eb3313cd42b5932269d713784fe1)
  2. DefaultUI에 UISprite, UIInputText, UIText를 추가해 교환용 UI를 만들고 아래와 같이 이름을 변경합니다. [png](170607103930210c9ec96e8d7461392a7468ded173cda) [png](1713924692487c458e7057dcf4f598ab8fae58a1c1e3d)
  3. 생성한 교환용 UI의 Enable을 해제해 보이지 않게 만듭니다.



#### NPC 배치하기

  1. Scene에 NPC 프리셋을 배치하고, TriggerComponent를 추가합니다. [png](1706070955652fdf1e41bb11243df8e185796b7f9c95a)
  2. CollisionGroup에서 새로운 Npc 그룹을 추가합니다. Npc 그룹은 Npc, Default와 충돌할 수 있도록 아래와 같이 Matrix를 설정합니다. [png](1706514288932b68b705f4d6e49df8b8be935dc051b67)
  3. Npc의 TriggerComponent의 CollisionGroup을 Npc로 변경합니다.



#### 클라이언트 함수 작성하기

NPC와 플레이어가 상호작용할 수 있게 만들어봅시다. NPC의 trigger 범위에 플레이어가 있을 때 `E` 키를 누르면 UI가 열리고, `ESC` 키를 누르면 UI가 닫히도록 만듭니다.

  1. 새로운 MyPlayerComponent를 생성하고, DefaultPlayer에 추가합니다.

  2. MyPlayerComponent에 아래와 같이 작성합니다.
         
         @Component
         script PlayerInteractionHandler extends Component
         property Entity triggerNpcEntity = nil
         @Sync
         property number key = 0
         property number lastExchangeTime = 0
         property number talkableDistance = 1
         @Sync
         property number gold = 0
         property Entity goldUI = "/ui/DefaultGroup/GoldUI"
         property Entity keyUI = "/ui/DefaultGroup/KeyUI"
         
         @ExecSpace("ClientOnly")
         method void OnSyncProperty(any name, any value)
         if name == "key" then
             -- key UI 갱신
             self.keyUI.TextComponent.Text = tostring(self.key)
         end
         
         if name == "gold" then
             -- gold UI 갱신
             self.goldUI.TextComponent.Text = tostring(self.gold)
         end
         end
         
         @EventSender("Service", "InputServie")
         handler HandleKeyDownEvent(KeyDownEvent event)
         --------------- Native Event Sender Info ----------------
         -- Sender: InputService
         -- Space: Client
         ---------------------------------------------------------
         -- Parameters
         local key = event.key
         ---------------------------------------------------------
         
         -- 로컬 플레이어인지 확인
         if self.Entity ~= _UserService.LocalPlayer then
             return
         end
         
         -- 키 입력 확인
         if key == KeyboardKey.E then
             -- 대화 가능한 NPC가 있는지 확인
             if self.triggerNpcEntity == nil then
                 log('trigger 없음')
                 return
             end
         
             self.triggerNpcEntity.NpcComponent:OnRequestOpenDialog(true)
         elseif key == KeyboardKey.Escape then
             if self.triggerNpcEntity ~= nil then
                 self.triggerNpcEntity.NpcComponent:OnRequestOpenDialog(false)
             end
         end
         end
         
         @ExecSpace("ClientOnly")
         @EventSender("Self")
         handler HandleTriggerLeaveEvent(TriggerLeaveEvent event)
         --------------- Native Event Sender Info ----------------
         -- Sender: TriggerComponent
         -- Space: Server, Client
         ---------------------------------------------------------
         log('triggerLeave')
         -- Parameters
         local TriggerBodyEntity = event.TriggerBodyEntity
         ---------------------------------------------------------
         if self.triggerNpcEntity == TriggerBodyEntity then
             self.triggerNpcEntity.NpcComponent:OnRequestOpenDialog(false)
             self.triggerNpcEntity = nil
         end
         end
         
         @ExecSpace("ClientOnly")
         @EventSender("Self")
         handler HandleTriggerEnterEvent(TriggerEnterEvent event)
         --------------- Native Event Sender Info ----------------
         -- Sender: TriggerComponent
         -- Space: Server, Client
         ---------------------------------------------------------
         log('triggerEnter')
         -- Parameters
         local TriggerBodyEntity = event.TriggerBodyEntity
         ---------------------------------------------------------
         if TriggerBodyEntity.TriggerComponent.CollisionGroup == CollisionGroups.Npc then
             self.triggerNpcEntity = TriggerBodyEntity
         end
         end
         end
         



    
    
    2. 새로운 **NpcComponent**를 생성하고, **NPC** 엔티티에 추가합니다.
    3. 교환 버튼을 누르면 열쇠를 골드로 교환할 수 있도록 **NpcComponent**에 아래와 같이 작성합니다.
    
        ```lua
    @Component
    script ExchangeDialogController extends Component
        property Entity DialogEntity = ui/DefaultGroup/DialogBackground
        property any QuantityInput = ui/DefaultGroup/DialogBackground/Dialog/Count
    
        @ExecSpace("ClientOnly")
        method void OnRequestOpenDialog(boolean isOpen)
        self.DialogEntity.Enable = isOpen
        end
    
        @EventSender("Self")
        handler HandleButtonClickEvent(ButtonClickEvent event)
        --------------- Native Event Sender Info ----------------
        -- Sender: ButtonComponent
        -- Space: Client
        ---------------------------------------------------------
        -- Parameters
        local Entity = event.Entity
        ---------------------------------------------------------
    
        -- 쿨타임 확인
        local myPlayerComponent = _UserService.LocalPlayer.MyPlayerComponent
        if DateTime.UtcNow.Elapsed - myPlayerComponent.lastExchangeTime < 1000 then
            log('지난 요청으로부터 1초가 지나지 않음')
            return
        end
    
        local keyQuantity = tonumber(self.QuantityInput.Text)
        if keyQuantity > 0 then
            myPlayerComponent.lastExchangeTime = DateTime.UtcNow.Elapsed
    
            myPlayerComponent:VerifyExchangeKeyToGold(keyQuantity, myPlayerComponent.triggerNpcEntity)
        end
        end
    end
    

#### 서버 함수 검증 요소 알아보기

열쇠 아이템을 소지한 플레이어가 열쇠를 메소로 교환해주는 NPC와 상호 작용할 때의 서버 검증 예시를 살펴봅시다. 플레이어가 NPC에게 열쇠를 1000메소로 교환할 때 확인해야 할 서버 검증 요소는 4가지입니다.

  1. 로컬 Client가 아닌 다른 Client에서 요청을 보내지는 않았는지 검증합니다. 검증할 수 있는 방법은 두 가지 입니다. 첫 번째는 [WorldConfig](/docs/?postId=1101)의 PlayerEntityAuthority를 활용해 대비합니다. 두 번째는 senderUserId를 사용해 패킷을 보낸 플레이어 엔티티가 교환을 요청한 플레이어 엔티티와 일치하는지 확인합니다. senderUserId를 사용하여 패킷을 보낸 플레이어 엔티티가 교환하려는 플레이어 엔티티와 일치하는지 확인합니다.
         
         @Component
         script PlayerSenderVerifier extends Component
         @ExecSpace("ServerOnly")
         method void OnBeginPlay()
             -- 로컬 Client가 아닌 다른 Client에서 요청을 보내지는 않았는지 검증
                 if senderUserId ~= self.Entity.PlayerComponent.UserId then
                     log('로컬 Client가 아닌 다른 Client에서 요청을 보냄')
                     return
                 end
         end
         end
         



    
    
    2. **비정상적으로 많은 반복 요청이 있는지 검증합니다.**
    패킷을 임의적으로 반복해 보내는 경우를 대비하기 위해 클라이언트에서 **재사용 대기 시간**을 검사합니다. 또한 서버에서 한번 더 검사해야 합니다. 
    만약, 크리에이터가 지정한 시간이 지나지 않았을 때 서버 함수가 호출된 경우 비정상적인 패킷으로 의심할 수 있습니다. 정상적인 클라이언트라면 재사용 대기 시간이 지나기 전에 서버 함수를 호출하지 않기 때문입니다.
    
    
        ```lua
    @Component
    script ExchangeCooldown extends Component
        @ExecSpace("ServerOnly")
        method void OnBeginPlay()
            -- 요청이 비정상적으로 반복되어 보내지지는 않았는지 재사용 대기 시간을 추가해 검증
                if DateTime.UtcNow.Elapsed - self.lastExchangeTime < 1000 then
                    log('지난 요청으로부터 1초가 지나지 않았습니다.')
                    return
                end
        end
    end
    

  3. 플레이어 엔티티가 해당 NPC의 트리거가 발동되는 위치에 있는지 검증합니다. 플레이어 엔티티가 NPC 트리거 위치에 있을 때 패킷을 보내도록 작성합니다. 또한 `ExchangeKeyToGold()` 함수를 트리거가 발동될 수 없는 위치에서 호출해 패킷을 보내는 경우를 대비하기 위해 서버에서 한 번 더 플레이어와 NPC 거리를 계산합니다. 트리거가 발동될 수 없는 거리에 플레이어 엔티티가 있지만, 패킷 요청이 온다면 비정상적인 패킷 요청으로 의심할 수 있습니다.
         
         @Component
         script InteractionRangeValidator extends Component
         @ExecSpace("ServerOnly")
         method void OnBeginPlay()
             -- 플레이어 엔티티가 해당 NPC의 트리거가 발동되는 위치에 있는지 검증
                 if npcEntity == nil then
                     log('트리거 npc가 없음')
                     return
                 end
         
                 local playerPos = self.Entity.TransformComponent.WorldPosition
                 local npcPos = npcEntity.TransformComponent.WorldPosition
                 local distance = Vector2.Distance(Vector2(playerPos.x, playerPos.y), Vector2(npcPos.x, npcPos.y))
         
                 if distance > self.talkableDistance then
                     log("Trigger 발생 범위 내에 플레이어가 위치하지 않습니다. 비정상적 패킷입니다.")
                     return
                 end
         end
         end
         



    
    
    4. **교환 요청 온 열쇠 개수가 플레이어가 가진 열쇠 개수보다 적지 않은지 검증합니다.**
    플레이어가 교환 버튼을 누를 때 열쇠 개수가 교환 요청한 개수보다 적은 경우 교환할 수 없도록 한 번 더 교환 조건을 확인합니다.
    
        ```lua
    @Component
    script KeyExchangeValidator extends Component
        @ExecSpace("ServerOnly")
        method void OnBeginPlay()
            -- 교환 요청 온 열쇠의 개수가 플레이어가 가진 열쇠의 개수보다 적지는 않은지 검증
                if self.key < keyQuantity then
                    log('플레이어의 열쇠 개수가 부족합니다.')
                    return
                end
        end
    end
    

#### 서버 함수 작성하기

위의 4가지 검증 요소 확인하고, 열쇠를 메소로 교환할 수 있는 서버 함수들을 작성해봅시다.

  1. MyPlayerComponent에 `OnBeginPlay()` 함수를 생성합니다. 월드 입장 시 열쇠를 10개를 지급하고, 골드는 0으로 초기화할 수 있도록 아래와 같이 작성합니다.
         
         @Component
         script ResourceInitializer extends Component
         @ExecSpace("ServerOnly")
         method void OnBeginPlay()
         -- key, gold 값 세팅
         self.key = 10
         self.gold = 0
         end
         end
         



    
    
    2. **MyPlayerComponent**에 새로운 `VerifyExchangeKeyToGold()` 함수를 생성합니다. 실행제어를 **server**로 설정하고, 매개변수 **keyQuantity, npcEntity**를 추가합니다.
    서버에서 검증에 성공하면 메소를 지급하도록 아래와 같이 작성합니다.
    
        ```lua
    @Component
    script KeyToGoldValidator extends Component
        @ExecSpace("ServerOnly")
        method void VerifyExchangeKeyToGold(number keyQuantity, Entity npcEntity)
        -- 로컬 Client가 아닌 다른 Client에서 요청을 보내지는 않았는지 검증
        if senderUserId ~= self.Entity.PlayerComponent.UserId then
            log('로컬 Client가 아닌 다른 Client에서 요청을 보냄')
            return
        end
    
        -- 요청이 비정상적으로 반복적으로 보내지지 않았는지 검증
        -- 재사용 대기 시간 추가해 검증
        if DateTime.UtcNow.Elapsed - self.lastExchangeTime < 1000 then
            log('지난 요청으로부터 1초가 지나지 않음')
            return
        end
    
        -- 플레이어 엔티티가 해당 NPC의 트리거가 발동되는 위치에 있는가
        if npcEntity == nil then
            log('Trigger npc가 없음')
            return
        end
    
        local playerPos = self.Entity.TransformComponent.WorldPosition
        local npcPos = npcEntity.TransformComponent.WorldPosition
        local distance = Vector2.Distance(Vector2(playerPos.x, playerPos.y), Vector2(npcPos.x, npcPos.y))
    
        if distance > self.talkableDistance then
            log("Trigger 발생 범위 내에 플레이어가 위치하지 않습니다. 비정상적인 패킷입니다.")
            return
        end
    
        -- 교환 요청 온 열쇠의 개수가 플레이어가 가진 열쇠의 개수보다 적지는 않은가
        if self.key < keyQuantity then
            log('플레이어의 열쇠 개수가 부족합니다.')
            return
        end
    
        -- 검증 성공 시 교환 함수 호출
        self:ExchangeKeyToGold(keyQuantity)
        end
    end
    
    
    
    

  4. 새로운 `ExchangeKeyToGold()`함수를 생성합니다. 실제로 골드를 지급하는 함수이므로 실행제어를 server only로 설정합니다. 검증에 성공했다면, 키 수량에 맞는 골드를 지급할 수 있도록 작성합니다.
         
         @Component
         script KeyGoldExchanger extends Component
         @ExecSpace("ServerOnly")
         method void ExchangeKeyToGold(number keyQuantity)
         self.key = self.key - keyQuantity
         self.gold = self.gold + keyQuantity * 1000
         self.lastExchangeTime = DateTime.UtcNow.Elapsed
         end
         end