효과적으로 DataStorage 사용하기

# 학습 과정 소개

DataStorage는 간단하게 데이터를 저장하고 불러올 수 있습니다. 그러나 DataStorage를 사용하며 다양한 문제가 발생할 수 있습니다. 이번 가이드에서는 권장하는 DataStorage 사용 방법 중 일부를 소개합니다. 크리에이터가 만드는 월드 특성에 따라 알맞은 해결 방법이 필요합니다.

# DataStorage 에러 코드 처리

다양한 이유로 인해 데이터를 DataStorage로부터 불러오지 못하는 상황이 발생할 수 있습니다. 예를 들어 요청에 시간이 너무 오래 걸려 실패하거나(TimedOut), 호출 횟수 한도를 초과해서(ResourceExhausted) 실패할 수 있습니다. 요청 함수의 결과 에러 코드를 이용해 데이터를 정상적으로 불러오지 못한 상황에서 발생할 수 있는 문제들을 대비하며 기능을 구현하는 것이 좋습니다. 에러 코드는 [DataStorage 활용하기](/docs/?postId=692)에서 확인할 수 있습니다.

아래의 그림과 같이 Currency 데이터를 불러오는 데 실패한 상황을 생각해 봅시다. 이 상황에서 변경된 Currency 데이터를 저장한다면 이전부터 쌓아온 유저의 Currency 데이터가 사라지는 상황이 발생할 수 있습니다.

[png](1687246048345dc8c94e5d9da44528947721abb2d0849)

#### 유저 추방하기

다른 유저들은 정상적으로 입장해 데이터를 불러오지만 특정 유저는 여러 이유로 인해 데이터를 불러오지 못할 수 있습니다. 예를 들어 월드 플레이에 필수적인 데이터나 초기 유저 데이터를 불러오는 데 실패한 경우 유저 추방 방법을 사용할 수 있습니다. 유저가 월드 입장하더라도 정상적으로 플레이할 수 없거나, 이후의 처리가 매우 복잡해질 것이기 때문입니다. 다음은 유저 입장 시 meso 데이터 불러오기에 실패하면 유처를 추방하는 예제입니다.
    
    
    @Component
    script PlayerMesoLoader extends Component
        @Sync
        property integer meso = 0
    
        @ExecSpace("ServerOnly")
        method void OnBeginPlay()
        if self:LoadMesoData() == false then
            _UserService:KickUser(self.Entity.PlayerComponent.UserId, KickReason.WorldError) -- 유저 내보내기
            return
        end
        end
    
        @ExecSpace("ServerOnly")
        method boolean LoadMesoData()
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
            return false
        end
    
        local errorCode, mesoString = userDataStorage:GetAndWait("Meso")
        if errorCode ~= 0 then
            return false
        end
    
        if _UtilLogic:IsNilorEmptyString(mesoString) == false then
            self.meso = math.tointeger(tonumber(mesoString))
        end
    
        return true
        end
    end
    

> 더 알아보기 데이터를 불러오지 못한 원인에 따라 유저는 재접속 시 데이터 불러오기에 성공할 수도 있고, 재입장에 실패할 수도 있습니다.

#### 재시도하기

크리에이터는 정상적으로 요청했지만 원인이 불명확한 일시 장애가 발생해 데이터 불러오기를 실패할 수 있습니다. 이런 상황을 대비해 데이터 불러오기 요청을 여러 번 시도할 수 있습니다.

아래 예시 코드는 데이터 불러오기에 실패하면 2초 뒤에 재시도합니다. 그 이후 일정 횟수 이상 실패하면 유저를 월드에서 추방합니다. 더불어 과도한 요청으로 인한 에러 코드 ResourceExhausted: 1000005가 발생하면, 재시도하지 않고 유저를 추방합니다.
    
    
    @Component
    script MesoDataInitializer extends Component
        @Sync
        property integer meso = 0
        @Sync
        property boolean initialized = false
    
        @ExecSpace("ServerOnly")
        method void OnBeginPlay()
        local retryCount = 2    -- 2번 재시도하게 설정. 총 3번 저장 시도.
    
        while retryCount >= 0 do
            if self:LoadMesoData() == true then
                break
            end
    
            wait(1) -- 입력한 수만큼 기다렸다 다시 시도한다.
            retryCount = retryCount - 1
        end
    
        if self.initialized == false then
            _UserService:KickUser(self.Entity.PlayerComponent.UserId, KickReason.WorldError)    -- 유저 내보내기
            return
        end
        end
    
        @ExecSpace("ServerOnly")
        method boolean LoadMesoData()
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
            return false
        end
    
        local errorCode, mesoString = userDataStorage:GetAndWait("Meso")
        if errorCode == 0 then
            self.meso = math.tointeger(tonumber(mesoString))
            self.initialized = true
            return true
        elseif errorCode == 1000005 then
            return true -- ResourceExhausted: 호출 횟수가 한도를 초과해 에러 발생. 재시도하지 않는다.
        end
    
        return false
        end
    end
    

#### 데이터 저장하지 않기

월드에 필수적인 데이터가 아닌 경우 데이터를 불러오는 데 실패했다면 데이터를 저장하지 않는 방식도 고려할 수 있습니다.

[png](1687246084866d9ba4addc3994d82964dc03a62961995)

아래의 예제는 Component에서 요청 성공 여부를 Property에 저장하고, Logic을 이용해 데이터를 저장하지 않도록 구현합니다.
    
    
    @Component
    script PlayerMesoDataManager extends Component
        @Sync
        property boolean initialized = false -- 이 프로퍼티를 사용해 저장 막기.
        @Sync
        property integer meso = 0
    
        @ExecSpace("ServerOnly")
        method void OnBeginPlay()
        if self:LoadMesoData() == false then
            return
        end
    
        self.initialized = true
        end
    
        @ExecSpace("ServerOnly")
        method boolean LoadMesoData()
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
            return false
        end
    
        local errorCode, mesoString = userDataStorage:GetAndWait("Meso")
        if errorCode ~= 0 then
            return false
        end
    
        if _UtilLogic:IsNilorEmptyString(mesoString) == false then
            self.meso = math.tointeger(tonumber(mesoString))
        end
    
        return true
        end
    
        @ExecSpace("ServerOnly")
        method void SaveMesoData()
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
        	return
        end
    
             -- self.initialized가 false일 경우 관련 데이터에 접근하지 않는 처리가 필요합니다.
        if self.initialized == false then
        	log("info is not initialized") -- Load 실패 시 Save 안 함.
        	return
        end
    
        local mesoString = tostring(self.meso)
        userDataStorage:SetAndWait("Meso", mesoString)
        end
    
        @EventSender("Self")
        handler HandleUserLeaveEvent(UserLeaveEvent event)
        self:SaveMesoData()
        end
    end
    

#### 연관된 데이터들 중 일부를 불러오지 못했을 때

크리에이터는 월드를 만들 때 일반적으로 다수의 Key를 사용해 데이터를 저장합니다. 이렇게 저장한 데이터끼리 연관이 있다면 관련 기능 차단 또는 데이터 저장 차단을 고려해야 합니다. 연관된 데이터 중 하나를 저장하지 못하면 문제가 발생할 수 있습니다.

아래 그림은 연관된 두 값 중 하나의 데이터를 정상적으로 불러오지 못했을 때의 상황입니다. Item 데이터 값을 DataStorage로부터 불러오지 못해서 서버에 Item 1, 2, 3 없습니다. 불러온 데이터가 없기 때문에 Item 4를 유저가 구매했더라도 정상적으로 저장을 하면 안 됩니다. 이전에 쌓은 Item 데이터 1, 2, 3은 사라지고 4만 저장될 수 있기 때문입니다. 반면 Currency 데이터는 다른 key를 사용하고 있으므로 300이 100으로 줄어들었습니다. 서버는 Item 데이터 저장은 실패하고, Currency 데이터는 저장에 성공했으므로 유저는 자신이 돈을 썼지만 구매한 아이템은 없는 상황이 발생합니다.

[png](168724611102948c5f2739484476183ad594c3120171f)

이처럼 서로 연관된 데이터들 중 하나의 데이터 불러오기에 실패한다면, 모두 값을 저장하지 않게 데이터 저장을 차단하게 만드는 것을 권장합니다. 연관된 모든 데이터 불러오기 성공 여부를 확인한 뒤, 아이템 구매 기능을 허용하고 저장하는 것이 좋습니다.

[png](1687246125168fbe541c356234b0185d19cb7dea6a590)

다음은 Cash, Item 데이터 불러오기에 성공한 경우에만 저장을 허용하는 예제입니다.
    
    
    @Component
    script PlayerDataManager extends Component
        @Sync
        property boolean initialized = false
        @Sync
        property integer meso = 0
        @Sync
        property integer cash = 0
        property table items = {}    -- ItemIDs
    
        @ExecSpace("ServerOnly")
        method void OnBeginPlay()
        self:LoadData()
        end
    
        @ExecSpace("ServerOnly")
        method void LoadData()
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
            return
        end
    
        if self:LoadCurrency() == false then
            return
        end
    
        if self:LoadItems() == false then
            return
        end
    
        local itemInitialitedEvent = OnUserDataItemInitialized()
        for i, id in pairs(self.items) do
            table.insert(itemInitialitedEvent.items, id)
        end
    
        self:SendItemInitializedEventToClient(itemInitialitedEvent)  -- 이벤트를 이용해 Client에 items 동기화.
        self.initialized = true  -- Currency와 Items 모두 Load 성공.
        end
    
        @ExecSpace("ServerOnly")
        method boolean LoadCurrency()
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
            return false
        end
    
        local errorCode, currencyString = userDataStorage:GetAndWait("Currency")
        if errorCode ~= 0 then
            return false
        end
    
        if _UtilLogic:IsNilorEmptyString(currencyString) == false then
            local currency = _HttpService:JSONDecode(currencyString)
    
            if currency.meso ~= nil then
                self.meso = currency.meso
            end
    
            if currency.cash ~= nil then
                self.cash = currency.cash
            end
        end
    
        return true
        end
    
        @ExecSpace("ServerOnly")
        method boolean LoadItems()
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
            return false
        end
    
        local errorCode, itemString = userDataStorage:GetAndWait("Items")
        if errorCode ~= 0 then
            return false
        end
    
        if _UtilLogic:IsNilorEmptyString(itemString) == false then
            self.items = _HttpService:JSONDecode(itemString)
        end
    
        return true
        end
    
        @ExecSpace("ClientOnly")
        method void SendItemInitializedEventToClient(any event)
        self.Entity:SendEvent(event)
        end
    
        @ExecSpace("ServerOnly")
        method string GetCurrencyString()
        local currency = {}
        currency.meso = self.meso
        currency.cash = self.cash
    
        return _HttpService:JSONEncode(currency)  -- property들을 하나의 table에 합친 뒤 Json String으로 변환하여 저장.
        end
    
        @ExecSpace("ServerOnly")
        method string GetItemsString()
        return _HttpService:JSONEncode(self.items)  -- table을 Json String으로 저장.
        end
    
        @ExecSpace("ServerOnly")
        method void SaveData()
        if self.initialized == false then
            log("info is not initialized") -- Load 실패 시 저장하지 않음.
            return
        end
    
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
            return
        end
    
        local currencyString = self:GetCurrencyString()
        local itemsString = self:GetItemsString()
    
        local errorCode = userDataStorage:SetAndWait("Items", itemsString)
        if errorCode ~= 0 then
            log("ErrorCode : "..errorCode)
        end
    
        errorCode = userDataStorage:SetAndWait("Currency", currencyString)
        if errorCode ~= 0 then
            log("ErrorCode : "..errorCode)
        end
        end
    
        @ExecSpace("ClientOnly")
        @EventSender("Self")
        handler HandleOnUserDataItemInitialized(any event)
        local items = event.items
    
        for i, id in pairs(event.items) do
            table.insert(self.items, id)
        end
        end
    end
    

Initialized 프로퍼티에 따라 상점 구매 로직 진행 여부를 결정하는 Logic 스크립트 예제입니다.
    
    
    @Component
    script ItemPurchaseHandler extends Component
        @ExecSpace("ServerOnly")
        method void RequestBuyItemFromClient(string userId, integer buyItemId)
        local userEntity = _UserService:GetUserEntityByUserId(userId)
            if isvalid(userEntity) == false then
                return
            end
    
        local userDataComponent = userEntity.UserDataComponent
        if isvalid(userDataComponent) == false then
            return
        end
    
        if userDataComponent.initialized == false then
            log("userDataComponent not initialized") -- Load 실패 시 상점 구매 진행하지 않음.
            return
        end
    
        -- 상점 구매 진행...
        end
    
        @EventSender("Self")
        handler HandleUserLeaveEvent(UserLeaveEvent event)
        self:SaveData()
        end
    end
    

# DataStorage 사용량 제한과 문자열

DataStorage는 사용량 제한이 있습니다. 제한으로 인해 Credit을 사용하는 DataStorage 류의 함수들을 너무 자주 호출하면 문제가 발생할 수 있습니다. 사용량 제한의 자세한 내용은 [DataStorage 사용량 제한 알아보기](docs/?postId=1044), [DataStorage 사용량 제한 활용하기](docs/?postId=1045)를 참고하세요.

#### 하나의 String으로 저장

Key를 사용하면 Credit을 사용합니다. 아래 예시처럼 많은 Key를 사용하면 데이터 불러오거나 저장할 때 많은 Credit을 사용하게 됩니다. 월드 제작 초기에는 Key 수가 적을 수 있지만, 월드를 계속 만들수록 프로퍼티를 계속 추가하게 될 수도 있습니다. 이렇게 되면 Credit을 더 많이 사용하게 됩니다. Credit을 효율적으로 사용하기 위해 하나의 String으로 데이터들을 묶고, 하나의 Key를 사용해 저장하는 방식을 사용할 수 있습니다. 연관있는 데이터들을 하나로 모아 묶는 것을 권장합니다. 이 방식을 사용해 데이터를 저장하고 불러오면 데이터 관리가 쉬워집니다.

  1. Json String 변환 함수 사용 `_HttpService:JSONEncode()` 와 `_HttpService:JSONDecode()`를 사용하여 저장 및 불러오기 합니다. Json과 Table 간의 변환이 가능합니다.
         
         @Component
         script TableJsonLogger extends Component
         method void PrintTable()
         local testTable = {}
         testTable.meso = 10
         testTable.money = 200
         testTable.innerTable = {}
         testTable.innerTable.val = "string value"
         
         log(_HttpService:JSONEncode(testTable))
         end
         end
         



    
    
    2. **UtilLogic의 변환 함수 사용**
    `_UtilLogic:TableToString()` 와 `_UtilLogic:StringToTable()` 사용해 하나의 String으로 묶을 수 있습니다.
    Table이 Table을 가지고 있는 경우 정상적으로 변환되지 않습니다.  Key, Value의 Type도 String으로 출력하므로 String 길이가 길어질 수 있습니다.
    
        ```lua
    @Component
    script TableLogger extends Component
        method void PrintTable()
        local testTable = {}
        testTable.meso = 10
        testTable.money = 200
        testTable.innerTable = {}
        testTable.innerTable.val = "string value"
    
        log(_UtilLogic:TableToString(testTable))
        end
    end
    

#### 직접 문자열로 합치고 나누기

크리에이터는 사전 정의된 String 형식을 따르는 함수들을 사용하는 대신 직접 string을 만들어 사용할 수 있습니다. 직접 만들 경우 데이터를 더 짧은 문자열로 저장할 수 있습니다. 아래의 예제와 같이 `_UtilLogic:Split()`과 `문자열 합치기(..)`를 사용해 String을 문자열로 나누고, 합칠 수 있습니다. Table 데이터는 table.concat을 사용할 수 있습니다.
    
    
    @Component
    script CurrencyManager extends Component
        property integer meso = 0
        property integer cash = 0
    
        @ExecSpace("ServerOnly")
        method boolean LoadCurrency()
        local userDataStorage = _DataStorageService:GetUserDataStorage(self.Entity.PlayerComponent.UserId)
        if userDataStorage == nil then
            return
        end
    
        local errorCode, currencyString = userDataStorage:GetAndWait("Currency")
        if errorCode ~= 0 then
            log(errorCode)
            return false
        end
    
        if _UtilLogic:IsNilorEmptyString(currencyString) == false then
            local split = _UtilLogic:Split(currencyString, ",")
            if split[1] ~= nil then
                self.meso = tonumber(split[1])
            end
    
            if split[2] ~= nil then
                self.cash = tonumber(split[2])
            end
        end
    
        return true
        end
    
        @ExecSpace("ServerOnly")
        method string GetCurrencyString()
        local currencyString = tostring(self.meso) .. "," .. tostring(self.cash)
        return currencyString   -- 이 String을 value로 저장한다. SetAndWait("Currency", currencyString)
        end
    end
    

# DataStorage 사용량 제한과 저장 주기

사용량 제한이 걸린 DataStorage에 데이터를 자주 저장하게 하면 Credit이 부족할 수 있습니다. 그러므로 값이 변경될 때마다 데이터를 저장할 필요가 없는 값들은 Property를 사용해 특정 상황일 때만 저장해 Credit 소모량을 줄일 수 있습니다. 주로 아래 3가지 상황에서 저장하는 것을 권장합니다.

  1. 유저 접속 종료 시 저장: 유저가 접속을 종료하면 데이터를 DataStorage에 저장합니다.

  2. 일정 시간마다 저장: 유저의 갱신된 정보는 주기적으로 저장해야 합니다. 서버에 문제가 생겨 저장에 실패할 수도 있기 때문입니다. 이때 적절한 저장 주기를 결정해 사용하는 것이 좋습니다. 자주 저장을 요청하면 DataStorage 보관 안정성은 높아지지만 Credit을 더 많이 소모하기 때문입니다.
         
         @Component
         script UserDataAutoSaver extends Component
         property integer saveDataTimer = 0
         
         @ExecSpace("ServerOnly")
         method void OnBeginPlay()
         local intervalSeconds = 300 -- 5분 간격으로 data를 저장.
         local startDelaySeconds = 300
         local userId = self.Entity.PlayerComponent.UserId
         
         local callback = function()
             _UserDataLogic:SaveData(userId) -- Logic의 Save 함수 호출.
         end
         
         self.saveDataTimer = _TimerService:SetTimerRepeat(callback, intervalSeconds, startDelaySeconds)
         end
         end
         



    
    
    3. **예외 처리 시 저장**: 예외 상황이 발생했을 때 데이터를 DataStorage에 저장합니다. 크리에이터가 만든 월드에서 무조건 데이터가 갱신될 때 저장해야만 하는 값이 있다면 그때만 예외적으로 저장하게 처리할 수 있습니다.
    [png](1687236625674b61431ae399148babb9dc746dd18b986)
    
    #  DataStorage 사용량 제한 시 고려할 점
    
    #### 유저가 직접 DataStorage 류 함수 실행 피하기
    유저가 데이터 갱신 버튼을 눌렀을 때 DataStorage 류 함수를 사용하게 만들지 않는 것이 좋습니다. 크리에이터의 의도로 다른 유저의 행동으로 인해 생각보다 많은 Credit을 소모하게 될 수도 있기 때문입니다. 만약 유저의 행동으로 DataStorage류 함수를 사용해야 한다면 해당 기능을 사용할 수 있는 주기를 설정해 의도하지 않게 Credit을 사용하게 되는 경우를 줄일 수 있습니다. 
    
    유저가 UI 버튼을 눌러서 DataStorage에서 데이터를 매번 불러오는 코드를 작성했다 생각해 봅시다. 특정 유저가 크리에이터의 예상과 달리 매우 빈번하게 해당 버튼을 누르게 된다면 Credit이 매우 많이 사용하고, 낭비하게 됩니다.
    
    아래 스크립트는 유저가 저장버튼을 이용해 데이터를 저장합니다.
    쿨다운을 추가하여 유저가 짧은 시간동안 여러번 저장하는 행동을 방지하여 과도한 Credit 사용을 막습니다.
    

@Component script SaveManager extends Component property number lastSaveTime = 0
    
    
    @ExecSpace("ServerOnly")
    method void SaveData()
    local cooldown = 120	-- 120초 동안 저장 불가
    
    if _UtilLogic.ServerElapsedSeconds < self.lastSaveTime + cooldown then
    	-- cooldown을 이용해 유저가 짧은 시간 동안 저장을 여러번 시도하는 것을 방지합니다.
    	return
    end
    
    self.lastSaveTime = _UtilLogic.ServerElapsedSeconds
    
    -- DataStorageService를 이용해 데이터 저장...
    end
    
    @ExecSpace("ClientOnly")
    @EventSender("Self")
    handler HandleButtonClickEvent(ButtonClickEvent event)
       --------------- Native Event Sender Info ----------------
       -- Sender: ButtonComponent
       -- Space: Client
       ---------------------------------------------------------
    
       -- Parameters
       local Entity = event.Entity
       ---------------------------------------------------------
    
    self:SaveData()
    end
    

end
    
    
    #### 인스턴스 룸, DataStorage 류 함수 Credit 사용량 고려
    인스턴스 룸을 사용한 월드는 인스턴스 룸의 특성까지 고려해 Logic에서 DataStorage류 함수들을 사용해야 합니다. 예를 들어 월드 전체의 랭킹을 주기적으로 DataStorage에서 랭킹 데이터를 가져와 계산하고, 클라이언트 요청에 따라 랭킹을 보여주게 만들었다 생각해봅시다. 유저가 인스턴스 룸에 입장한 경우 인스턴스 룸 개수만큼 랭킹을 계산하기 때문에 Credit 사용량을 고려해야 합니다. 이때도 적절한 업데이트 주기를 설정해 Credit 소모를 막을 수 있습니다.
    [png](1687236642601ff167d6dd21a4f70b957245677259615)
    
    
    #### 유저 퇴장 시 UserLeaveEvent Handler에서 Data 저장하기
    유저가 퇴장할 때 DataStorage에 저장하는 기능 구현은 UserLeaveEvent Handler에 추가되어야 합니다.
    OnEndPlay에서 AndWait 함수를 사용해 데이터를 저장하면, 유저가 월드에 재진입할 때 이전에 저장한 데이터를 정상적으로 불러오지 못할 수 있습니다.
    OnEndPlay에서 SetAndWait나 SetAsync를 이용해 데이터를 저장한 경우, 유저가 월드 재진입 시 잘못된 데이터를 가져오는 상황이 발생할 수 있습니다.
    [png](169215377846091c995734f724ee9b07fbc0a4a025456)
    
    OnEndPlay 대신 UserLeaveEvent Handler에서 DataStorage류 함수를 호출하면, 유저가 재접속 했을 때 이전의 SetAndWait, SetAsync 함수의 실행 완료를 보장합니다.
    [png](1692158087433fe8a72406fa04e0fbea3eaf4f1fc6ffa)
    단, 아래처럼 Async Callback을 이용하여 DataStorage 저장 함수를 호출하는 경우 함수 실행 완료를 보장되지 않으므로 주의합니다.
    
    

@Component script PersistentDataSaver extends Component @ExecSpace("ServerOnly") method void OnBeginPlay() local userDataStorage = _DataStorageService:GetUserDataStorage(userId) if userDataStorage == nil then return end
    
    
    local callBack = function()
        userDataStorage:SetAsync("DataKey2", "222", nil)    -- 유저 재접속 전에 "222" 저장 보장 X
    end
    
    userDataStorage:SetAsync("DataKey1", "111", callBack)    -- 유저 재접속 전에 "111" 저장 보장 O
    end
    
    @EventSender("Self")
    handler HandleUserLeaveEvent(UserLeaveEvent event)
    self:SaveData()
    end
    

end
    
    
    [UserLeaveEvent](/apiReference/Events/UserLeaveEvent) 핸들러는 아래와 같은 특징을 가지고 있습니다.
    
    * 유저가 떠날 때 UserService에서 발생하는 이벤트입니다.
    * UserLeaveEvent 핸들러 실행이 완료된 뒤 유저 엔티티 삭제가 진행됩니다.
    * UserLeaveEvent 핸들러 실행이 오래 걸리면서 일정 시간 이상이 소요되게 되면 유저 엔티티 삭제가 실행이 완료되기 전에 진행됩니다.