배경
Roblox는 데이터 저장소를 통해 데이터를 인터페이스하기 위한 세트의 API를 제공합니다. 가장 일반적인 사용 사례는 데이터 저장소에 저장하고, 로드하고, 복제하는 데 사용됩니다 DataStoreService. 즉, 플레이 세션 간에 플레이어의 진행, 구매 및 기타 세션 특성과
Roblox의 대부분의 경험은 이러한 API를 사용하여 플레이어 데이터 시스템의 일부 형태를 구현합니다. 이러한 구현은 접근 방식이 다르지만 일반적으로 동일한 세트의 문제를 해결하려고 노력합니다.
일반적인 문제
다음은 플레이어 데이터 시스템이 해결하려는 몇 가지 가장 일반적인 문제 중 일부입니다.
메모리 액세스에서: DataStoreService 요청은 웹 요청을 비동기적으로 처리하고 속도 제한을 적용받는 서버요청을 수행합니다. 이것은 세션 시작 시 초기 로드를 위한 것이지만, 게임 플레이 시
- 세션 시작 시 초기 읽기
- 세션이 끝나면 최종 쓰기
- 최종 쓰기가 실패하는 시나리오를 완화하기 위해 간격을 두고 작성합니다.Periodic writes at an interval to mitigate the scenario where the final write fails
- 구매 작업을 처리하는 동안 데이터가 저장되도록 합니다.Write to ensure data is saved while processing a purchase
효율적인 저장: 모든 플레이어 세션 데이터를 단일 테이블에 저장하면 여러 값을 원자적으로 업데이트하고 하나의 요청에 대해 동일한 데이터를 처리할 수 있습니다. 또한 중간 값 비동기화를 제거하고 롤백을 쉽게 이해할 수 있습니다.
일부 개발자는 사용자 생성 콘텐츠를 저장하기 위해 사용자 정의 인터리티를 구현하기도 합니다(일반적으로 게임 내 사용자 생성 콘텐츠를 저장하기 위해).
복제: 클라이언트는 플레이어의 데이터에 정기적으로 액세스해야 합니다(예를 들어, UI를 업데이트하기 위해). 클라이언트에 대한 일반적인 복제 방법을 사용하면 모든 데이터 구성 요소에 대해 맞춤형 복제 시스템을 만들지 않고 이 정보를 전송할 수
오류 처리: 데이터스토어에 액세스할 수 없으면 대부분의 솔루션이 재시도 메커니즘과 기본 데이터로 대체를 구현합니다. 특별한 주의를 기울여야 하여 재시도 데이터가 '실제' 데이터를 덮어쓰지 않도록 하고 이를 플레이어에게 적절하게 통신하는지 확인합니다.
재시도: 데이터 저장소가 액세스할 수 없으면 대부분의 솔루션이 재시도 메커니즘과 기본 데이터로의 전환을 구현합니다. 특히 재시도 데이터가 "실제" 데이터를 덮어쓰지 않도록 하고 플레이어에게 상황을 적절하게 통신하는 것이 중요합니다.
세션 잠금: 한 플레이어의 데이터가 여러 서버에 로드되고 메모리에 저장되면 세션 잠금이 발생할 수 있습니다. 이로 인해 하나의 서버에서 오래된 정보를 저장하지 않도록 하여 데이터 손실 및 일반 아이템 중복 문제가 발생할 수 있습니다.
원자 구매 처리: 구매, 보상 및 레코드 원자를 검증하여 아이템을 잃거나 여러 번 보상하지 않도록 합니다.
샘플 코드
Roblox에는 플레이어 데이터 시스템을 설계하고 구축하는 데 도움이 될 참조 코드가 있습니다. 이 페이지의 나머지는 배경, 구현 세부 정보 및 일반적인 caveats에 대해 검토합니다.
Studio에 모델을 가져온 후 다음 폴더 구조를 볼 수 있어야 합니다.
아키텍처
이 고도 수준의 다이어그램은 샘플 및 나머지 경험에서 코드와 상호 작용하는 키 시스템을 보여줍니다.
다시 시도
클래스: DataStoreWrapper
배경
Class.DataStoreService는 웹 요청을 캐릭터에 숨기지만, 요청은 성공하지 않을 수 있습니다. 이 경우 DataStore 메서드가 오류를 발생시키고, 해결할 수 있습니다.
이와 같은 데이터 저장소 오류를 처리하려고 시도하면 일반적인 "otay"가 발생할 수 있습니다.
local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end
이것은 일반적인 함수에 대한 완전히 유효한 재시도 메커니즘이지만 DataStoreService 요청에는 적용되지 않습니다, 왜냐하면 요청이 순서대로 작성되지 않기 때문입니다. 요청의 순서를 유지하는 것이 DataStoreService 요청에 대한 중요한 전략이기 때
- 요청 A는 키의 값을 K 로 설정하기 위해 수행됩니다.
- 요청이 실패하여 2초 후 다시 시도가 예약되어 있습니다.
- 다시 시도가 발생하기 전에 요청 B에서 K 값을 2로 설정하지만, 요청 A의 다시 시도는 즉시 이 값을 덮어씁니다 및 K 를 1로 설정합니다.
Class.GlobalDataStore:UpdateAsync()|UpdateAsync 은 키의 값이 최신 버전에 작동하지만, UpdateAsync 요청은 여전히 처리되어야 하므로 잘못된 트랜잭션 상태(예: 코인 추가 처리 후 코인 하이브를 뺄 수 있음)를 피할 수 없습니다.
우리의 플레이어 데이터 시스템은 새로운 클래스, DataStoreWrapper 를 사용하여 키별 처리를 보장하는 재시도 생성을 제공합니다.
접근
DataStoreWrapper 은 Class.GlobalDataStore|DataStore 메서드에 해당하는 메서드를 제공합니다. Class.GlobalDataStore:GetAsync()|DataStore:GetAsync() , 0> Class.GlobalDataStore:SetAsync()
이 메서드, 호출 시:
요청을 큐에 추가합니다. 각 키에는 큐가 있으며, 요청이 순차적으로 처리됩니다. 요청을 완료하면 요청 스레드가 생성됩니다.
이 기능은 ThreadQueue 클래스에 기초합니다, 이는 코루틴 기반 작업 일정 예약기이자 속도 제한자입니다. 약속을 반환하지 않고 ThreadQueue 는 작업이 완료될 때까지 현재 스레드를 생성하고 오류가 발생하면 오류를 던져줍니다. 이는 idiomatic 비동기 Lua 패
요청이 실패하면 구성 가능한 비율 백오프를 사용하여 다시 시도합니다. 이 다시 시도 형식은 대기열에 제출된 ThreadQueue 의 일부이므로 이 키에 대한 다음 요청이 시작될 때까지 완료할 수 있습니다.
요청이 완료되면 요청 메서드가 success, result 패턴을 반환합니다.
DataStoreWrapper 또한 지정된 키에 대한 대기 길이를 가져오고 만료된 요청을 제거하는 메서드를 노출합니다. 이 옵션은 서버가 종료되고 처리할 요청이 없지만 가장 최근 요청만 있을 때 특히 유용합니다.
동굴
DataStoreWrapper 는 모든 데이터 스토어 요청이 완료되어야 하는 것이 아닌, 극단적인 시나리오가 아닌 한 모든 데이터 스토어 요청이 완료될 수 있도록 허용하는 원칙을 따릅니다(성공적
대기열에서 요청을 제거할 때 안전하도록 규칙을 결정하기는 어렵습니다. 다음 대기열을 고려하십시오.
Value=0, SetAsync(1), GetAsync(), SetAsync(2)
예상되는 동작은 GetAsync() 가 1 을 반환하지만, 최신 요청을 대기열에서 제거하면 SetAsync() 요청을 제거하고 나머지는 1>01> 를 반환합니다.
논리적 진행 방식은 새 쓰기 요청이 추가될 때 오직 가장 최근의 읽기 요청으로부터 즉시 오래된 요청만 수정하도록 하는 것입니다. UpdateAsync() , 현재 시스템에서 가장 일반적인 작업(이 시스템에서 사용되는 유일한 작업)인
DataStoreWrapper 는 읽기/쓰기 요청이 허용되는지 여부를 지정하도록 요구할 수 있지만, 세션 잠금 메커니즘(후에 자세히 설명) 때문에 플레이어 데이터 시스템에 적용할 수 없습니다.
이 규칙을 제거하면 대기열에서 쉽게 결정할 수 없습니다. 어떻게 이 작업을 처리할지 결정할 때 현재 스레드가 완료될 때까지 생성됩니다. DataStoreWrapper 요청이 만들
궁극적으로, 우리의 의견은 간단한 접근(모든 요청을 처리하는)이 여기에서 좋습니다. 이를 통해 더 명확한 환경을 만들어 복잡한 문제(세션 잠금과 같은)에
세션 잠금
클래스: SessionLockedDataStoreWrapper
배경
플레이어 데이터는 서버의 메모리에 저장되며 필요할 때만 기본 데이터 저장소에 읽고 쓰기가 가능합니다. 필요할 때 즉시 메모리에서 플레이어 데이터를 읽고 업데이트할 수 있으며 DataStoreService 제한을 초과하지 않습니다.
이 모델이 제대로 작동하려면 하나의 서버 이상이 동시에 DataStore 에서 플레이어의 데이터를 메모리에 로드할 수 없습니다.
예를 들어, 서버 A가 플레이어의 데이터를 로드하면 서버 B는 서버 A가 잠금을 해제하기 전에 서버 A에서 해당 데이터를 로드할 수 없습니다. 잠금 메커니즘이 없으면 서버 B는 서버 A가 가지고 있는 최신 버전의 데이터를
Roblox는 클라이언트가 한 번에 하나의 서버에 연결할 수 있지만, 데이터가 하나의 세션에서 항상 저장되기 전에 연결할 수 없습니다. 다음 세션을 시작하기 전에 플레이어가 서버 A에서 떠날 수 있는 다음 시나리오를 고려하십시오.
- 서버 A는 데이터를 저장하려고 DataStore 요청을 만들지만 요청이 실패하고 몇 번이나 다시 시도해야 합니다. 재시도 기간 동안 플레이어는 서버 B에 합니다.
- 서버 A에서 너무 많은 UpdateAsync() 호출을 동일한 키에 만들고 속도가 느려집니다. 최종 저장 요청이 대기열에 배치됩니다. 요청이 대기열에 있는 동안 플레이어는 서버 B에 합니다.
- 서버 A에서 PlayerRemoving 이벤트에 연결된 일부 코드는 플레이어의 데이터가 저장되기 전에 생성됩니다. 이 작업이 완료되면 플레이어가 서버 B에 합니다.
- 서버 A의 성능이 저하되어 최종 저장이 플레이어가 서버 B에 참여할 때까지 지연되는 경우가 있습니다.
이러한 시나리오는 희귀해야 하지만, 플레이어가 한 서버에서 연결하고 다른 서버로 빠르게 연결하는 경우에는 발생합니다. 예를 들어, 플레이어가 이동하는 동안 발생합니다. 일부 악성 사용자는 이 동작을 악용하여 아이템 중복 작업을 완료하지 않고
세션 잠금은 플레이어의 DataStore 키가 서버에서 먼저 읽히면 서버 내 키 메타데이터에 잠금을 기록하도록 하여 이 취약점을 악용합니다. 다른 서버가 키를 읽거나 쓰려고 시도하면 서버는 진행하지 않
접근
SessionLockedDataStoreWrapper 은 DataStoreWrapper 클래스의 메타 래퍼입니다. DataStoreWrapper 는 큐 및 다시 시도 기능을 제공하며, 이는 0> SessionLockedDataStore0> 에 세션 잠금을 보충합니다.
SessionLockedDataStoreWrapper 패스는 모든
변환 함수는 각 요청에 대해 UpdateAsync로 다음과 같은 작업을 수행합니다.
키가 안전한지 액세스, 키가 아닌 경우 작업을 포기합니다. "안전한지 액세스"은 다음을 의미합니다.
키의 메타데이터 개체에는 잠금 만료 시간보다 이전에 업데이트된 LockId 값이 포함되어 있지 않습니다. 이는 다른 서버에 배치된 잠금을 존중하고 만료된 잠금을 무시하는 데 사용됩니다.
이 서버가 이전에 키의 메타데이터에 자체 LockId 값을 배치했다면 이 값은 여전히 키의 메타데이터에 있습니다. 이는 다른 서버가 이 서버의 잠금(만료 또는 강제)을 수신한 후 나중에 해제
UpdateAsync 은 DataStore 작업을 수행합니다. 예를 들어, SessionLockedDataStoreWrapper 은 0> function(value) return value 종료0> 에 번역됩니다.
요청에 전달된 매개 변수에 따라 UpdateAsync 키를 잠긴 상태로 유지 또는 잠긴 상태로 해제합니다.
키가 잠겨 있으면, UpdateAsync 는 키의 메타데이터에 있는 LockId를 GUID로 설정합니다. 이 GUID는 서버의 메모리에 저장되므로 다음에 키에 액세스할 때
키가 잠금을 해제해야 하는 경우, UpdateAsync 는 키의 메타데이터에서 LockId를 제거합니다.
사용자 정의 재시 처리 핸들러가 기본 DataStoreWrapper 로 전달되어 세션이 잠긴 상태에서 작업이 중단된 경우 재시 작업이 수행됩니다.
사용자 데이터 시스템에서 세션 잠금을 클라이언트에 보고할 경우 사용자 정의 오류 메시지도 클라이언트에 반환되어 플레이어 데이터 시스템이 세션 잠금을 보고할 수 있습니다.
동굴
세션 잠금 메커니즘은 키가 있을 때마다 서버가 잠금을 해제하는 키를 항상 키에 해제하도록 하는 서버 잠금 기능을 사용합니다. 이 메커니즘은 PlayerRemoving 또는 Class.DataModel:BindToClose()|BindToClose() 을 통해 최종 쓰기의 일부
그러나 잠금 해제는 특정 상황에서 실패할 수 있습니다. 예를 들어:
- 서버가 충돌했거나 DataStoreService 는 모든 시도에서 키에 액세스할 수 없었습니다.
- 논리 오류 또는 유사한 버그 때문에 키를 잠금 해제하는 지침이 제공되지 않았습니다.
키에 잠금을 유지하려면 메모리에 로드된 동안 정기적으로 액세스해야 합니다. 이 작업은 대부분의 플레이어 데이터 시스템에서 배경에서 자동 저장 루프의 일부로 수행되지만 이 시스템은 수동으로 수행해야 하는 경우 refreshLockAsync 메서드를 노출합니다.
잠금 시간이 업데이트되지 않고 잠금이 만료된 경우 서버는 잠금을 인수할 수 있습니다. 다른 서버가 잠금을 가져갈 경우 현재 서버의 키를 읽거나 쓰기 시도는 새 잠금을 설정하지 않는 한 실패합니다.
개발자 상품 처리
싱글턴: ReceiptHandler >
배경
ProcessReceipt 콜백은 구매를 완료할 때를 결정하는 중요한 작업을 수행합니다. ProcessReceipt는 매우 특정 시나리오에서 호출됩니다. 그 보장 집합에 대해서는 MarketplaceService.ProcessReceipt를 참조하십시오.
구매를 처리하는 정의는 경험 간에 다를 수 있지만, 다음 기준을 사용합니다.
이전에 구매하지 않은 구매 항목입니다.
구매는 현재 세션에 반영됩니다.
다음 작업을 수행해야 합니다 PurchaseGranted 을 반환하기 전에 :
- PurchaseId 이 처리되지 않은 상태에 대해 확인하십시오.
- 플레이어의 인-메모리 플레이어 데이터에서 구매를 보상합니다.
- 플레이어의 인-메모리 플레이어 데이터에서 PurchaseId 을 처리하는 방식으로 기록합니다.
- 플레이어의 인-메모리 플레이어 데이터를 DataStore 에 작성합니다.
세션 잠금은 이 흐름을 간소화하므로 다음과 같은 시나리오에 대해 걱정할 필요가 없습니다.
- 현재 서버의 메모리 플레이어 데이터가 오래되었으므로 최신 값을 검색하려면 DataStore에서 최신 값을 검색해야 합니다. PurchaseId 기록을 검증하기 전에
- 다른 서버에서 동일한 구매를 실행하는 경우, 모두 읽고 쓰기 위해 PurchaseId 기록을 읽고 업데이트된 플레이어 데이터를 저장하여 경쟁 조건을 방지하도록 쿼드 원자 수준으로 구매를 반복합니다.
세션 잠금은 플레이어의 DataStore 에 쓰기 시도가 성공하면 다른 서버에서 플레이어의 DataStore 에 쓰기 시도가 성공하지 못하도록 보장합니다. 간단히 말해, 이 서버의
접근
ReceiptProcessor 의 댓글은 접근 방식을 설명합니다:
플레이어의 데이터가 현재 이 서버에 로드되었는지 확인하고 오류 없이 로드되었는지 확인하십시오.
세션 잠금을 사용하기 때문에 이 검사는 또한 메모리 내 데이터가 가장 최신 버전인지 확인합니다.
플레이어의 데이터가 아직 로드되지 않았다면(플레이어가 게임에 참여할 때 기대되는 것) 플레이어의 데이터를 불러오다기다리십시오. 시스템은 데이터가 로드되기 전에 플레이어가 게임을 종료하지 않도록 경고하며, 이 데이터가 다시 호출되지 않도록 이 서버에서 이 콜백을 지속적
플레이어 데이터에 이미 처리된 PurchaseId 이 아니라는 것을 확인하십시오.
세션 잠금으로 인해 시스템의 메모리에 있는 PurchaseIds 배열은 가장 최근의 버전을 나타냅니다. 만약
이 서버에서 플레이어 데이터를 로컬로 업데이트하여 "보상"을 구매하세요.
ReceiptProcessor는 일반적인 콜백 접근 방식을 사용하고 각 DeveloperProductId에 대해 다른 콜백을 할당합니다.
이 서버에서 플레이어 데이터를 로컬로 업데이트하여 PurchaseId 을 저장합니다.
요청을 제출하여 메모리 데이터를 DataStore 에 저장하고, 요청이 성공하면 PurchaseGranted 을 반환합니다. 그렇지 않으면 NotProcessedYet 을 반환합니다.
이 저장 요청이 성공하지 않으면 나중에 플레이어의 인-메모리 세션 데이터를 저장하는 다른 요청이 성공할 수 있습니다. 동안의 다음 ProcessReceipt 호출 동안 단계 2는 이 상황을 처리하고 PurchaseGranted 를 반환합니다.
플레이어 데이터
싱글턴: PlayerData.Server > , PlayerData.Client >
배경
게임 코드에 대한 인터페이스를 제공하여 플레이어 세션 데이터를 동기적으로 읽고 쓰는 모듈은 Roblox 경험에서 일반적입니다. 이 섹션에서는 PlayerData.Server 및 PlayerData.Client 를 설명합니다.
접근
PlayerData.Server 및 PlayerData.Client는 팔로잉처리합니다.
- 플레이어의 데이터를 메모리에 로드하고, 불러오지 못하는 경우 처리 케이스를 포함하여
- 서버 코드를 검색하고 플레이어 데이터를 변경하는 인터페이스 제공
- 클라이언트가 클라이언트 코드에 액세스할 수 있도록 플레이어의 데이터에 변경 사항을 적용하는 클라이언트
- 클라이언트에 오류 대화 상자를 표시할 수 있도록 로딩 및/또는 저장 오류를 클라이언트에 복제하는
- 플레이어가 떠나고 서버가 종료될 때 플레이어의 데이터를 정기적으로 저장합니다.
플레이어 데이터 로드 중
SessionLockedDataStoreWrapper 는 데이터 상점대한 getAsync 요청을 만듭니다.
이 요청이 실패하면 기본 데이터가 사용되고 프로필이 "errored"로 표시되어 데이터 저장소에 쓰이지 않도록 합니다.
대체 옵션은 플레이어를 킥하는 것입니다, 하지만 플레이어가 기본 데이터로 플레이하고 메시지를 지우는 것을 권장하며, 경험에서 제거하는 대신 플레이어가 어떤 일이 발생했는지 알 수 있습니다.
초기 데이터 전송은 데이터를 로드한 상태와 오류 상태(있으면)를 포함하는 PlayerDataClient로 수행됩니다.
플레이어에 대해 waitForDataLoadAsync를 사용하여 생성된 모든 스레드가 재개됩니다.
서버 코드에 대한 인터페이스 제공
- PlayerDataServer 는 동일한 환경에서 실행되는 모든 서버 코드에 필요하고 액세스할 수 있는 싱글입니다.
- 플레이어 데이터는 키 및 값의 사전으로 구성됩니다. 이 값은 서버에서 setValue 를 사용하여 조작할 수 있습니다. 이 메서드는 모두 동기적으로 작동하지 않고 제공됩니다. getValue 및 updateValue 메서드를 사용하면 모두 동기적으로 작동합니다.
- hasLoaded 및 waitForDataLoadAsync 메서드는 데이터를 로드하기 전에 사용할 수 있습니다. 클라이언트에서 다른 시스템을 시작하기 전에 로딩 화면에서 이 작업을 한 번 수행하는 것이 좋습니다. 데이터에 대한 모든 클라이언트 인터랙션에 대해 데이터 오류를 확인하기 전에 로드 오
- 플레이어의 초기 로드가 실패하면 hasErrored 메서드를 호출하여 플레이어가 기본 데이터를 사용하도록 허용하기 전에 플레이어가 구매를 사용할 수 있는지 확인할 수 있습니다. 플레이어가 구매를 저장하지 않도록 데이터에 로드할 수 없는 불러오다허용하려면 플레이어를 사용하기 전에 이 메서드를 확인하십
- 플레이어 데이터 업데이트됨 신호는 playerDataUpdated , player 및 key 으로 발생합니다. 개별 시스템은 이것에 서브스크립트할 수 있습니다.
클라이언트에 변경 사항 복제
- 플레이어 데이터에 대한 변경 사항은 PlayerDataServer 의 모든 플레이어에 적용되지만, 해당 키가 setValueAsPrivate를 사용하여 비공개로 표시되지 않은 경우 PlayerDataClient로 복제됩니다.
- setValueAsPrivate 는 클라이언트에게 보내지 않아야 하는 키를 나타냅니다.
- PlayerDataClient 에는 키의 값을 가져오는 메서드(get) 및 업데이트된 후 발생하는 신호(업데이트됨)가 포함되어 있습니다. 클라이언트가 시스템을 시작하기 전에 데이터를 로드하고 복제하기 위해 hasLoaded 메서드와 loaded 신호가 포함되어 있습니다.
- PlayerDataClient는 동일한 환경에서 실행되는 모든 클라이언트 코드에 필요하고 액세스할 수 있는 싱글입니다.
클라이언트에 오류 복제
- 플레이어 데이터를 복제하거나 PlayerDataClient 에 저장하는 경우 오류 상태가 발생합니다.
- 이 정보는 getLoadError 및 getSaveError 메서드, 그리고 loaded 및 1> saved1> 신호와 함께 액세스할 수 있습니다.
- 이 이벤트를 사용하여 클라이언트 구매 프롬프트를 비활성화하고 경고 대화 상자를 구현합니다. 이 이미지는 예시 대화 상자를 표시합니다.
플레이어 데이터 저장
플레이어가 게임을 떠나면 시스템은 다음과 같은 단계를 수행합니다:
- 플레이어의 데이터를 데이터 상점안전하게 쓰는지 확인하십시오. 안전하지 않은 시나리오는 플레이어의 데이터가 로드되지 않거나 여전히 로드되는 경우입니다.
- 현재 메모리 데이터 값을 데이터 스토어에 기록하고 세션 잠금을 해제하려면 SessionLockedDataStoreWrapper를 통해 요청하십시오.
- 플레이어의 데이터(메타데이터 및 오류 상태와 같은 다른 변수)를 서버 메모리에서 지우십시오.
주기적 루프에서 서버는 각 플레이어의 데이터를 데이터 저장소에 쓸립니다(저장하는 것이 안전하다는 것을 제외하고는). 이 환영 비용은 서버 충돌 시 손실을 완화하고 세션 잠금을 유지하는 데 필요합니다.
서버를 종료하기로 요청을 받으면 BindToClose 콜백에서 다음이 발생합니다.
- 서버에서 각 플레이어의 데이터를 저장하도록 요청하는 프로세스는 플레이어가 서버를 떠날 때 일반적으로 진행되는 프로세스와 동일합니다. 이러한 요청은 BindToClose 콜백에 대해 30초만 완료될 수 있습니다.
- 저장을 간소화하기 위해 각 키의 대기열에 있는 다른 모든 요청이 기본 DataStoreWrapper (참조 다시 시도)에서 제거됩니다.
- 모든 요청이 완료되기까지 콜백을 반환하지 않습니다.