> For the complete documentation index, see [llms.txt](https://docs.decentraland.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.decentraland.org/creator/content-creator-ko/sdk7/networking/authoritative-servers.md).

# 멀티플레이어 서버

헤드리스 멀티플레이어 서버를 사용해 멀티플레이어 Decentraland 씬을 구축합니다.

**📔 참고**: 멀티플레이어 서버는 이전에 다음과 같이 불렸습니다 **권한형 서버**. 이름만 바뀌었고, 기능은 동일합니다. 설치해야 할 SDK 브랜치 이름은 여전히 `auth-server`, 참조: [설정](#setup).

## 개요

Decentraland는 플레이어의 기기에서 씬을 로컬로 실행합니다. 기본적으로 플레이어는 서로를 보고 직접 상호작용할 수 있지만, 각자는 환경과 독립적으로 상호작용합니다. 기본적으로 환경의 변경 사항은 플레이어 간에 공유되지 않습니다.

모든 플레이어가 같은 상태의 같은 콘텐츠를 가진 씬을 보게 하는 것은 플레이어가 더 의미 있게 상호작용하는 데 매우 중요합니다. 이것이 없으면, 한 플레이어가 문을 열고 집 안으로 들어갈 때 다른 플레이어는 그 문이 여전히 닫혀 있다고 보게 되며, 첫 번째 플레이어는 다른 플레이어에게는 닫힌 문을 그대로 통과해 지나가는 것처럼 보이게 됩니다.

다음 **멀티플레이어 서버** 는 씬 코드를 실행하고 상태 변경을 검증한 뒤, 그 결과를 연결된 모든 플레이어에게 브로드캐스트하는 헤드리스 서버 프로세스입니다. 이는 권한형 서버 아키텍처를 따릅니다. 각 클라이언트가 자신의 동작을 보고하도록 신뢰하는 대신, 서버가 단일 진실의 원천 역할을 합니다. 그래서 멀티플레이어 씬 동기화에 권장되는 접근 방식입니다.

멀티플레이어 서버는 게임 메커니즘에서 공정성이 중요할 때 이상적입니다. 서버 측에서 실행되는 정교한 안티 치트 검증을 구현할 수 있기 때문입니다. 또한 개인 키와 기타 민감한 정보를 서버에 저장할 수 있어, 이를 사용자에게 직접 노출할 필요가 없습니다.

멀티플레이어 서버를 두는 것은 실제 문제도 해결합니다. 피어 투 피어 설정에서는 떠 있는 플랫폼 같은 것을 두 플레이어가 제어할 때 충돌하는 결과가 나올 수 있습니다. 각 클라이언트는 플랫폼을 서로 다른 높이로 설정하지만, 무엇이 올바른지 결정할 권한이 있는 주체가 없습니다. 멀티플레이어 서버는 모든 변경을 한 곳에서 해결하므로, 모든 클라이언트가 같은 상태로 수렴합니다.

또한 이를 위한 장소도 제공합니다 **세션 간에 데이터를 유지**: 리더보드, 플레이어 진행도, 잠금 해제된 업적, 또는 열린 문이나 놓인 아이템 같은 환경 변화. 플레이어가 돌아오면, 세계는 이전에 일어난 일을 반영합니다.

Decentraland가 서버를 호스팅하고 배포해 줍니다. 일반적인 절차로 씬을 게시하면 서버도 추가 단계나 호스팅 비용 없이 함께 원활하게 게시됩니다.

## 설정

### 1. auth-server SDK 버전을 설치합니다

기본 Multiplayer Server API들(`isServer`, `registerMessages`, `저장소`, `환경 변수`, 등)은 별도의 SDK 브랜치에서 사용할 수 있습니다. 표준 SDK 브랜치 대신 프로젝트에 설치하려면 다음 명령을 실행하세요:

```bash
npm install @dcl/sdk@auth-server
npm install @dcl/js-runtime@auth-server
```

### 2. scene.json 구성

멀티플레이어 서버는 다음 플래그로 활성화됩니다 `"authoritativeMultiplayer": true` 의 `scene.json`, 루트 수준에 추가됩니다. 수동으로 추가할 필요는 없습니다. SDK의 auth-server 브랜치를 사용하면 씬을 처음 빌드하거나 미리 볼 때 자동으로 추가됩니다. 단, 이 플래그를 제거하지 마세요. 이 플래그가 없으면 서버는 실행되지 않고 `isServer()` 항상 반환합니다 *false*.

선택적으로, 다음 항목도 `scene.json` 를 루트 수준에:

```json
{
  "logsPermissions": ["0xYourWalletAddress"]
}
```

추가 `logsPermissions` 를 추가하여 다음을 볼 수 있는 지갑 주소를 나열합니다 `console.log()` 서버에서. 나열된 사용자는 다음 명령을 실행하여 프로덕션에서 서버 로그를 볼 수 있습니다:

`npx sdk-commands sdk-server-logs`

### 3. 미리 보기를 실행합니다

표준 미리 보기 명령을 사용하면 됩니다. 추가 단계는 필요하지 않습니다. SDK의 auth-server 브랜치를 사용할 때는 미리 보기가 백그라운드에서 자동으로 멀티플레이어 서버의 로컬 버전을 시작합니다.

서버의 로컬 세션은 프로덕션의 세션과 연결되어 있지 않으므로, 게시된 씬의 플레이어에게 영향을 주지 않으면서 자유롭게 테스트할 수 있습니다.

## 서버 / 클라이언트 분기

프로젝트의 씬 코드는 `src` 폴더에서 서버와 클라이언트 모두에서 실행됩니다. 다음을 사용하여 `isServer()` 함수로 실행 경로를 분리합니다:

```typescript
import { isServer } from "@dcl/sdk/network"
import { initServer } from "./server/server"
import { initClient } from "./client/setup"
import { setupUi } from "./client/ui"

function main() {
  if (isServer()) {
    // 서버 전용: 게임 로직, 검증, 상태 관리
    initServer()
    return
  } else {
    // 클라이언트 전용: UI, 입력 처리, 메시지 전송
    initClient()
    setupUi()
  }
}
```

서버는 렌더링 없이 헤드리스로 씬을 실행합니다. 서버는 다음을 통해 모든 플레이어의 위치, 착용 아이템 및 기타 데이터에 대한 검증된 접근 권한을 가집니다: `PlayerIdentityData` 그리고 게임 상태에 대한 유일한 권한을 가집니다.

## 동기화된 컴포넌트와 검증

### 모든 클라이언트에 엔티티 동기화

다음을 사용하세요 `syncEntity` 를 사용하여 해당 엔티티의 지정된 컴포넌트에서 발생한 변경 사항을 브로드캐스트합니다:

```typescript
import { isServer, syncEntity } from "@dcl/sdk/network"

if (isServer()) {
  syncEntity(
    entity,
    [Transform.componentId, GameState.componentId],
    /* enumId */ 1
  )
}
```

문법은 다음에서 사용되는 것과 동일합니다 [서버리스 멀티플레이어](/creator/content-creator-ko/sdk7/networking/serverless-multiplayer.md) 기능으로, 이 아키텍처를 사용하는 씬을 멀티플레이어 서버로 손쉽게 업그레이드할 수 있습니다. 씬이 멀티플레이어 서버를 사용하면 상태 업데이트는 더 이상 모든 플레이어 간에 전송되지 않고, 모든 상태 업데이트가 서버를 통해 라우팅되고 검증됩니다.

{% hint style="warning" %}
**📔 참고**: 멀티플레이어 서버에서는 서버만 다음을 호출하도록 하는 것이 이상적인 패턴입니다 `syncEntity`. 이렇게 하면 엔티티 ID 일관성에 대해 걱정할 필요가 없습니다. 대신 엔티티는 서버에서 인스턴스화되어 공유되고, 모든 클라이언트는 해당 인스턴스에 대해 동기화됩니다. 항상 다음으로 보호하세요 `isServer()`. 이는 다음과는 다릅니다 [서버리스 멀티플레이어](/creator/content-creator-ko/sdk7/networking/serverless-multiplayer.md), 여기서는 모든 클라이언트가 다음을 호출합니다 `syncEntity` 각자 개별적으로.
{% endhint %}

### 변경 사항 검증

다음을 사용하세요 `validateBeforeChange()` 를 사용해 엔티티의 특정 컴포넌트에 대한 상태 업데이트를 제한합니다. 이를 통해 사용자 지정 검증 함수를 실행할 수 있으며, 검증 조건이 충족될 때만 변경이 성공합니다.

검증이 다음 값을 반환하면 *true*, 변경이 수락되어 모든 플레이어에게 전파됩니다. 검증이 다음 값을 반환하면 *false*, 변경은 거부됩니다. 거부된 변경은 다른 플레이어에게 전달되지 않으며, 변경을 시도한 플레이어에게는 되돌려집니다.

#### 값 검증

가장 간단한 경우는 새 *값이* 이 쓰여질 때 특정 매개변수 안에 있는지 검증하는 것입니다. 예를 들어, 다음에 대한 변경만 수락합니다 `Transform` 새 Y 위치가 0보다 클 때:

```typescript
import { engine, Transform } from "@dcl/sdk/ecs"
import { Vector3 } from "@dcl/sdk/math"
import { isServer } from "@dcl/sdk/network"

const entity = engine.addEntity()
Transform.create(entity, { position: Vector3.create(10, 2, 10) })

if (isServer()) {
  Transform.validateBeforeChange(entity, (value) => {
    // 엔티티가 Y = 0 이하에 위치하게 될 모든 업데이트를 거부
    return value.newValue.position.y > 0
  })
}
```

왜냐하면 `validateBeforeChange()` 는 서버에서만 의미가 있으므로, 항상 다음으로 보호하세요 `isServer()`. 클라이언트에서는 이 호출이 아무런 유용한 작업도 하지 않습니다.

이를 사용해 게임 로직에 어긋나는 변경을 방지할 수 있습니다. 안티 치트 메커니즘으로 사용할 수 있습니다.

#### 플레이어와의 근접성 검증

다음을 결합할 수 있습니다 `validateBeforeChange()` 를 서버에서 검증된 플레이어 위치와 결합하여, 플레이어가 상호작용하기 전에 오브젝트에 충분히 가까운지 확인할 수 있습니다. 예를 들어, 플레이어가 오브젝트의 위치를 변경하여 오브젝트를 집으려고 할 때 `Transform`, 오브젝트가 플레이어로부터 5미터 이상 떨어져 있으면 변경을 거부할 수 있습니다:

```typescript
import { engine, Transform, PlayerIdentityData } from "@dcl/sdk/ecs"
import { Vector3 } from "@dcl/sdk/math"
import { isServer } from "@dcl/sdk/network"

const pickableEntity = engine.addEntity()
Transform.create(pickableEntity, { position: Vector3.create(8, 1, 8) })

if (isServer()) {
  Transform.validateBeforeChange(pickableEntity, (value) => {
    // 이 변경을 보낸 플레이어를 찾습니다
    for (const [playerEntity, identity] of engine.getEntitiesWith(
      PlayerIdentityData
    )) {
      if (identity.address.toLowerCase() !== value.senderAddress.toLowerCase())
        continue

      const playerTransform = Transform.getOrNull(playerEntity)
      if (!playerTransform) return false

      // 변경 전 오브젝트의 현재 위치를 가져옵니다
      const objectTransform = Transform.getOrNull(pickableEntity)
      if (!objectTransform) return false

      const distance = Vector3.distance(
        playerTransform.position,
        objectTransform.position
      )

      // 플레이어가 5미터 이내에 있을 때만 변경을 허용
      return distance <= 5
    }

    // 보낸 사람이 연결된 플레이어 중에 없음 — 거부
    return false
  })
}
```

이 패턴은 안티 치트 메커니즘으로 유용합니다. 플레이어가 씬을 가로질러 상호작용할 수 없는 오브젝트를 가져가려는 것을 방지합니다.

#### 관리자만 변경을 허용

다음에 따라 검증할 수도 있습니다 *누가* 변경을 보내는지에 따라. 들어오는 모든 값에는 다음이 포함됩니다 `senderAddress` 필드가 있으며, 여기에는 보낸 사람의 지갑 주소가 들어 있습니다. 이를 사용해 특정 플레이어의 변경만 허용할 수 있습니다. 예를 들어, 씬 관리자만 다음을 수정할 수 있도록 하려면 `VideoPlayer` 컴포넌트:

```typescript
import { engine, VideoPlayer } from "@dcl/sdk/ecs"
import { isServer } from "@dcl/sdk/network"
import { isPreview } from "@dcl/asset-packs/dist/admin-toolkit-ui/fetch-utils"
import { getSceneAdmins } from "@dcl/asset-packs/dist/admin-toolkit-ui/ModerationControl/api"

// 씬 관리자 목록에서 새로 고침되는 관리자 지갑 주소 캐시
let adminAddresses = new Set<string>()

async function updateAdminAddresses() {
  if (isPreview()) return
  try {
    const [error, response] = await getSceneAdmins()
    if (error) {
      console.error("[서버] 관리자 목록을 가져오는 중 오류 발생:", error)
      adminAddresses = new Set()
      return
    }
    adminAddresses = new Set(
      (response ?? []).map((admin) => admin.admin.toLowerCase())
    )
    console.log(
      "[서버] 업데이트된 관리자 주소 캐시:",
      Array.from(adminAddresses)
    )
  } catch (error) {
    console.error("[서버] 관리자 주소 업데이트 중 오류 발생:", error)
    adminAddresses = new Set()
  }
}

export async function main() {
  const videoEntity = engine.addEntity()
  VideoPlayer.create(videoEntity, { src: "videos/intro.mp4" })

  if (isServer()) {
    // 검증을 연결하기 전에 캐시를 채웁니다
    await updateAdminAddresses()

    VideoPlayer.validateBeforeChange(videoEntity, (value) => {
      // 미리 보기 실행 중에는 항상 변경을 허용하므로 로컬 테스트가 더 쉽습니다
      if (isPreview()) return true

      const senderAddress = value.senderAddress.toLowerCase()
      if (!adminAddresses.has(senderAddress)) {
        console.log(
          console.error("[서버] 권한 없는 VideoPlayer 변경 차단:",
          senderAddress
        )
        return false
      }
      return true
    })
  }
}
```

참조 [씬 관리자](/creator/content-creator-ko/scene-editor/operate-live/scene-admin.md) 플레이어가 씬의 관리자가 되는 방식에 대한 자세한 맥락은 다음을 참조하세요.

#### 서버에서만 변경을 허용

가장 엄격한 경우는 서버 자체에서 시작된 쓰기만 허용하고, 클라이언트에서 오는 모든 변경을 거부하는 것입니다. 이는 완전히 권한형이어야 하는 상태, 예를 들어 점수, 게임 단계, 생성된 엔티티 등에 권장되는 패턴입니다.

들어오는 모든 값에는 다음이 포함됩니다 `senderAddress` 필드가 있습니다. 보낸 사람이 서버인 경우 이 필드는 상수와 일치합니다 `AUTH_SERVER_PEER_ID`, 다음에서 내보내는 `@dcl/sdk/network/message-bus-sync`.

아래 예제는 작은 `protectServerEntity()` 도우미를 정의합니다. 이 도우미는 주어진 엔티티의 하나 이상의 컴포넌트에 이 검사를 적용합니다. 다음과 같은 여러 컴포넌트를 보호하는 편리한 방법입니다( `Transform` 및 `GltfContainer`)을 한 번의 호출로 보호할 수 있습니다:

```typescript
import { engine, Entity, Transform, GltfContainer } from "@dcl/sdk/ecs"
import { Vector3 } from "@dcl/sdk/math"
import { isServer } from "@dcl/sdk/network"
import { AUTH_SERVER_PEER_ID } from "@dcl/sdk/network/message-bus-sync"

type ComponentWithValidation = {
  validateBeforeChange: (
    entity: Entity,
    cb: (value: { senderAddress: string }) => boolean
  ) => void
}

function protectServerEntity(
  entity: Entity,
  components: ComponentWithValidation[]
) {
  for (const component of components) {
    component.validateBeforeChange(entity, (value) => {
      return value.senderAddress === AUTH_SERVER_PEER_ID
    })
  }
}

if (isServer()) {
  // 서버에서 관리되는 엔티티를 만든 후:
  const entity = engine.addEntity()
  Transform.create(entity, { position: Vector3.create(10, 5, 10) })
  GltfContainer.create(entity, { src: "assets/model.glb" })
  protectServerEntity(entity, [Transform, GltfContainer])
}
```

{% hint style="warning" %}
**📔 참고**: 항상 다음을 호출하세요 `protectServerEntity()` 다음 안에서 `isServer()` 블록. 이는 다음을 감쌉니다 `validateBeforeChange()`, 서버에서만 의미가 있으므로 클라이언트에서 호출하면 오류가 발생합니다.
{% endhint %}

#### 사용자 지정 컴포넌트

다음에도 적용할 수 있습니다 `validateBeforeChange()` 씬에서 정의한 사용자 지정 컴포넌트에.

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"
import { isServer } from "@dcl/sdk/network"
import { AUTH_SERVER_PEER_ID } from "@dcl/sdk/network/message-bus-sync"

export const GameState = engine.defineComponent("game:State", {
  phase: Schemas.String,
  score: Schemas.Int,
  timeRemaining: Schemas.Int,
})

// 서버만 이 컴포넌트를 수정할 수 있습니다
if (isServer()) {
  GameState.validateBeforeChange((value) => {
    return value.senderAddress === AUTH_SERVER_PEER_ID
  })
}
```

## 메시지

동기화된 컴포넌트는 모든 플레이어가 계속해서 봐야 하는 상태에 아주 적합합니다. 예를 들어 위치, 점수, 게임 단계 같은 것들입니다. 하지만 모든 것이 이 모델에 맞는 것은 아닙니다. 때로는 플레이어가 서버에게 단지 "이 버튼을 클릭했다" 또는 "게임에 참가하고 싶다"고 알려야 하고, 서버는 "라운드가 시작됐다" 또는 "여기 당신의 통계입니다" 같은 일회성 응답으로 답해야 합니다. 이것들은 지속적인 상태가 아니라 이벤트입니다.

바로 이를 위해 메시지가 있습니다. 다음을 사용하세요 `registerMessages()` 클라이언트와 서버 간의 타입이 적용되고 스키마로 검증된 통신에 사용합니다. 메시지는 일회성 전송입니다. 클라이언트가 서버에 하나를 보내면, 서버는 이를 처리하고 선택적으로 하나를 다시 보냅니다. 메시지 자체가 지속적인 상태를 직접 만들지는 않습니다.

### 메시지 정의

서버와 클라이언트가 모두 가져오는 공유 파일에 모든 메시지를 정의하세요. 이렇게 하면 양쪽이 항상 어떤 메시지가 존재하고 어떤 데이터를 담는지에 대해 동일한 이해를 유지합니다. 각 메시지는 `Schemas.Map` 를 사용해 페이로드를 설명합니다:

```typescript
import { Schemas } from "@dcl/sdk/ecs"
import { registerMessages } from "@dcl/sdk/network"

export const Messages = {
  // 클라이언트 → 서버
  playerReady: Schemas.Map({ displayName: Schemas.String }),
  playerAction: Schemas.Map({ action: Schemas.String, targetId: Schemas.Int }),

  // 서버 → 클라이언트
  gameStarted: Schemas.Map({ roundNumber: Schemas.Int }),
  gameEnded: Schemas.Map({ winnerId: Schemas.String }),
}

export const room = registerMessages(Messages)
```

{% hint style="warning" %}
**📔 참고**: 호출하세요 `registerMessages()` 한 번, 공유 모듈의 최상위 수준에서, 위 예제처럼 호출하세요. 그러면 모듈이 처음 로드될 때 서버와 클라이언트 모두에서 실행됩니다. 조건문 안이나 함수 내부에서 호출하지 마세요. 양쪽 모두 항상 같은 메시지를 등록해야 합니다.
{% endhint %}

### 메시지 보내기

클라이언트는 서버에만 메시지를 보낼 수 있습니다. 클라이언트 간 직접 메시징은 없습니다. 서버는 모든 클라이언트에게 브로드캐스트하거나 주소로 특정 플레이어를 지정할 수 있습니다.

```typescript
import { room } from "./shared/messages"

// 클라이언트 → 서버(브로드캐스트, 서버가 수신)
room.send("playerReady", { displayName: "Alice" })

// 서버 → 모든 클라이언트
room.send("gameStarted", { roundNumber: 1 })

// 서버 → 특정 클라이언트 1명(지갑 주소 기준)
room.send("gameEnded", { winnerId: "Alice" }, { to: [playerAddress] })
```

{% hint style="warning" %}
**📔 참고**메시지의 최대 크기는 약 13KB입니다. 더 큰 메시지는 오류를 발생시키지 않고 전송 계층에서 조용히 삭제됩니다. 페이로드는 작게 유지하세요. 더 큰 데이터를 공유해야 한다면 여러 메시지로 나누거나 동기화된 컴포넌트를 사용하세요.
{% endhint %}

### 메시지 받기

```typescript
import { room } from "./shared/messages"

// 클라이언트가 서버로부터 수신
room.onMessage("gameStarted", (data) => {
  console.log(`라운드 ${data.roundNumber}가 시작되었습니다!`)
})

// 서버가 클라이언트로부터 수신
room.onMessage("playerReady", (data, context) => {
  if (!context) return
  const senderAddress = context.from // 검증된 지갑 주소
  console.log(`[서버] ${data.displayName} 준비 완료 (${senderAddress})`)
})
```

서버에서는 수신된 모든 메시지에 다음이 포함됩니다 `컨텍스트` 객체가 있으며, 여기에는 보낸 사람의 검증된 지갑 주소가 들어 있습니다. 이를 사용해 어떤 플레이어가 메시지를 보냈는지 파악하세요(페이로드에 들어 있는 자기 보고된 신원 정보에 의존하지 마세요).

### 보내기 전에 상태 동기화를 기다리기

가입 직후의 경합 상태를 피하려면, 플레이어는 첫 메시지를 보내기 전에 씬 상태가 동기화될 때까지 기다려야 합니다:

```typescript
import { engine } from "@dcl/sdk/ecs"
import { isStateSyncronized } from "@dcl/sdk/network"
import { room } from "./shared/messages"

engine.addSystem(() => {
  if (!isStateSyncronized()) return

  // 이제 메시지를 보내도 안전합니다
  room.send("playerReady", { displayName: "Alice" })
})
```

{% hint style="info" %}
**💡 팁**: `isStateSyncronized()` 클라이언트의 상태가 동기화되었는지만 알려줄 뿐, 서버 초기화가 끝났는지는 보장하지 않습니다. 더 견고한 준비 상태 확인을 위해서는 서버가 'ready' 또는 heartbeat 메시지를 브로드캐스트하고, 클라이언트가 게임플레이 메시지를 보내기 전에 이를 기다리게 하세요.
{% endhint %}

### 사용 가능한 스키마 유형

모든 메시지 페이로드와 사용자 지정 컴포넌트는 다음을 사용합니다 `Schemas` 를 사용해 이진 직렬화를 수행합니다. 사용 가능한 유형에 대한 간단한 참조는 다음과 같습니다:

```typescript
import { Schemas } from "@dcl/sdk/ecs"

// 기본 유형
Schemas.String // '안녕'
Schemas.Int // 42
Schemas.Float // 3.14
Schemas.Boolean // true / false
Schemas.Int64 // Date.now()

// 벡터 유형
Schemas.Vector3 // { x: 1, y: 2, z: 3 }
Schemas.Quaternion // { x, y, z, w }

// 복합 유형
Schemas.Array(Schemas.String) // ["a", "b", "c"]
Schemas.Entity // 엔티티 참조
Schemas.Optional(Schemas.String) // '안녕' 또는 undefined
Schemas.Optional(Schemas.Int) // 42 또는 undefined

// 중첩 객체
Schemas.Map({
  name: Schemas.String,
  health: Schemas.Int,
  position: Schemas.Vector3,
  playerId: Schemas.Optional(Schemas.String),
})
```

{% hint style="warning" %}
**📔 참고**: 메시지 *반드시* 다음을 사용해 정의되어야 합니다 `Schemas.Map(...)`. 일반 JavaScript 객체는 보낼 수 없으며, 이들은 이진 직렬화에 실패합니다.
{% endhint %}

## 서버에서 플레이어 위치 읽기

서버는 다음을 읽을 수 있습니다 **검증된** 플레이어 위치를 읽을 수 있으며, 클라이언트는 이를 위조할 수 없습니다. 이것이 위치 기반 안티 치트의 기반입니다:

```typescript
import { engine, PlayerIdentityData, Transform } from "@dcl/sdk/ecs"

engine.addSystem(() => {
  for (const [entity, identity] of engine.getEntitiesWith(PlayerIdentityData)) {
    const transform = Transform.getOrNull(entity)
    if (!transform) continue

    const address = identity.address
    const position = transform.position
    // 이 위치는 서버에서 검증됨 — 클라이언트가 보고한 위치는 절대 신뢰하지 마세요
  }
})
```

{% hint style="warning" %}
**📔 참고**: 항상 사용하세요 `PlayerIdentityData` + `Transform` 서버에서 플레이어 위치를 가져오세요. 클라이언트 자체가 보고한 값은 절대 신뢰하지 마세요.
{% endhint %}

## 데이터 저장

서버 재시작 사이에도 데이터를 유지하세요. Storage는 **서버 전용**, 항상 호출을 `isServer()`. 서버는 이 데이터를 쓰고 읽을 수 있습니다.

```typescript
import { Storage } from "@dcl/sdk/server"
```

데이터는 두 수준에서 저장할 수 있습니다:

* **월드**: 순위표나 지속되는 환경 변경처럼 모든 플레이어와 관련된 데이터에 사용하세요.
* **플레이어**: 해당 플레이어의 진행 상황이나 설정처럼 플레이어별 데이터에 사용하세요.

{% hint style="info" %}
**💡 팁**: Storage는 문자열만 허용합니다. 사용하세요 `JSON.stringify()` / `JSON.parse()` 객체에는 `String()` / `parseInt()` 숫자에는 사용하세요.

로컬 개발 중에는 storage가 다음에 기록됩니다: `node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json`.
{% endhint %}

{% hint style="warning" %}
**📔 참고**: `Storage.set()`, `Storage.player.set()`, 그리고 `delete` 변형은 다음 값으로 해결됩니다: **boolean**. 이들은 절대 throw하지 않습니다 — 실패(네트워크 오류 또는 너무 많은 동시 요청, 아래 참조) 시 오류를 기록하고 다음 값으로 해석됩니다: `false`, 즉 값이 **저장되지** 않았다는 뜻입니다. 결과를 항상 확인하세요: 버려진 `false` 은 조용히 사라진 저장입니다.
{% endhint %}

### 변경할 때마다가 아니라 체크포인트에서 저장하세요

Storage는 서버 재시작과 재배포를 견뎌야 하는 데이터에 대한 내구성 있는 저장소입니다. 실시간 데이터 저장소는 아닙니다. 작업 중인 게임 상태는 서버 메모리에 보관하세요. 그게 더 빠르고 서버에 맞는 방식입니다. 정말 필요할 때, 의미 있는 체크포인트에서만 Storage에 쓰세요.

{% hint style="warning" %}
**⚠️ 경고**: 서버 런타임은 최대 **동시에 진행 중인 호스트 호출 40개를** 한 번에 허용하며, 다음 전반에서 공유됩니다: *모든 것* 씬이 런타임에 요청하는 모든 작업: 모든 저장 요청, `signedFetch`, 그리고 다른 런타임 API 모두 같은 한도에 포함됩니다. 초과 호출은 **대기열에 들어가지 않습니다**. 즉시 다음 오류와 함께 거부됩니다: `동시 호스트 호출이 너무 많음` 오류입니다. SDK는 거부를 잡아 `Storage.set` Promise를 다음으로 해결합니다: `false` 대신 throw하지 않습니다. 코드가 그 boolean을 버리면 실패한 저장은 보이지 않게 되고, 저장된 데이터는 오래되거나 사라지게 됩니다. 모든 쓰기의 결과를 확인하세요(위의 참고 참조).
{% endhint %}

저장하기 좋은 시점:

* 게임 오버이거나 라운드가 끝났을 때.
* 플레이어가 떠날 때.
* 주기적인 디바운스 저장(예: 30초에 한 번), 매 프레임마다가 아닙니다.

재시작이나 재배포 후에도 남아야 하는 데이터만 유지하세요. 나머지는 모두 메모리에 둘 수 있습니다.

아래 예시는 점수를 메모리에 보관하고, 점수가 들어올 때마다 쓰는 대신 체크포인트에서만 기록합니다:

```typescript
import { Storage } from "@dcl/sdk/server"

// 작업 상태는 메모리에 유지되며, 모든 이벤트마다 업데이트됨
const scores: Record<string, number> = {}

function onPointScored(address: string) {
  scores[address] = (scores[address] ?? 0) + 1
  // 여기서는 Storage 호출 없음 — 메모리만 업데이트
}

// 플레이어가 떠날 때처럼 체크포인트에서만 유지
async function onPlayerLeave(address: string) {
  const saved = await Storage.player.set(address, "score", String(scores[address] ?? 0))
  if (!saved) {
    // 쓰기가 유지되지 않음 — 값을 메모리에 유지하고 나중에 다시 시도
    console.error(`Failed to save score for ${address}`)
  }
}
```

동시 플레이어가 많은 씬에서는, 더 견고한 방법으로 저장되지 않은 변경이 있는 키("dirty set")를 추적하고 다음 flush에서 실패한 쓰기를 다시 시도합니다:

```typescript
import { engine } from "@dcl/sdk/ecs"
import { Storage } from "@dcl/sdk/server"

// 작업 상태는 메모리에 유지되며, 모든 이벤트마다 업데이트됨
const scores: Record<string, number> = {}
const dirty = new Set<string>()

function onPointScored(address: string) {
  scores[address] = (scores[address] ?? 0) + 1
  dirty.add(address) // 나중에 flush하도록 표시, 여기서는 Storage 호출 없음
}

// 더러운 키를 주기적으로(예: 약 30초마다) 그리고 체크포인트에서 flush
async function flush() {
  for (const address of dirty) {
    const ok = await Storage.player.set(address, "score", String(scores[address]))
    if (ok) {
      dirty.delete(address) // 성공적으로 저장됨
    }
    // ok가 false면 키는 dirty로 남고 다음 flush에서 다시 시도됨
  }
}

// dt 누적값을 사용해 타이머로 flush 실행
let flushTimer = 0
engine.addSystem((dt) => {
  flushTimer += dt
  if (flushTimer > 30) {
    flushTimer = 0
    flush()
  }
})
```

### 월드 Storage — 모든 플레이어가 공유

```typescript
import { Storage } from "@dcl/sdk/server"

// 쓰기 — 값이 유지되지 않았다면 set()은 false로 해결됨
const ok = await Storage.set(
  "leaderboard",
  JSON.stringify([
    { name: "Alice", score: 100 },
    { name: "Bob", score: 85 },
  ])
)
if (!ok) {
  console.error("순위표 저장에 실패했습니다 — 나중에 다시 시도하세요")
}

// 읽기
const raw = await Storage.get<string>("leaderboard")
const leaderboard = raw ? JSON.parse(raw) : []

// 삭제
await Storage.delete("leaderboard")
```

명령줄에서도 다음을 사용하여 씬 storage를 관리할 수 있습니다: `npx sdk-commands storage scene`:

```bash
# 값 설정
npx sdk-commands storage scene set high_score --value 100

# 값 가져오기
npx sdk-commands storage scene get high_score

# 값 삭제
npx sdk-commands storage scene delete high_score

# 모든 씬 storage 데이터 삭제
npx sdk-commands storage scene clear --confirm
```

### 플레이어 Storage — 지갑 주소별

```typescript
import { Storage } from "@dcl/sdk/server"

// 쓰기 — 값이 유지되지 않았다면 set()은 false로 해결됨
const ok = await Storage.player.set(
  playerAddress,
  "progress",
  JSON.stringify({
    level: 5,
    coins: 250,
  })
)
if (!ok) {
  console.error(`Failed to save progress for ${playerAddress} — 나중에 다시 시도하세요`)
}

// 읽기
const saved = await Storage.player.get<string>(playerAddress, "progress")
const progress = saved ? JSON.parse(saved) : { level: 1, coins: 0 }

// 삭제
await Storage.player.delete(playerAddress, "progress")
```

명령줄에서도 다음을 사용하여 플레이어 storage를 관리할 수 있습니다: `npx sdk-commands storage player`:

```bash
# 특정 플레이어의 값 설정
npx sdk-commands storage player set level --value 10 --address 0x1234...

# 특정 플레이어의 값 가져오기
npx sdk-commands storage player get level --address 0x1234...

# 특정 플레이어의 값 삭제
npx sdk-commands storage player delete level --address 0x1234...

# 특정 플레이어의 모든 데이터 삭제
npx sdk-commands storage player clear --address 0x1234... --confirm

# 모든 플레이어 데이터 삭제
npx sdk-commands storage player clear --confirm
```

### 저장된 데이터에 액세스

다음 링크를 입력하면 storage UI를 통해 서버의 실시간 저장 데이터를 보고 편집할 수 있습니다:

[decentraland.org/storage](https://decentraland.org/storage)

Creator Hub에서도 이 페이지에 갈 수 있습니다. 다음을 여세요: **관리** 탭에서 콘텐츠를 게시한 장소 옆의 세 점을 클릭하고 다음을 선택하세요: **Storage 보기**.

여기에서 씬을 게시할 수 있는 모든 월드와 랜드 목록을 볼 수 있습니다.

씬을 연 다음 **씬** 또는 **플레이어** 탭을 여세요.

다음에서 **씬** 탭에서는 저장된 모든 변수 목록을 볼 수 있습니다. 여기에서 연필 또는 휴지통 아이콘을 클릭해 이 변수들을 편집하거나 제거할 수 있습니다.

![Activate stream](https://3980763956-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoPnXBby9S6MrsW83Y9qZ%2Fuploads%2Fgit-blob-cc204e102da9c2e63419b91f818f22a579e55ebf%2Fscene-data.png?alt=media\&token=c8cc218c-e4ea-4689-ba83-387115558db1)

다음에서 **플레이어** 탭에서는 서버에 데이터가 저장된 모든 플레이어 목록을 볼 수 있습니다. 주소나 이름으로 검색한 뒤 관련된 모든 데이터를 볼 수 있습니다. 연필 또는 휴지통 아이콘을 클릭해 이 데이터를 편집하거나 제거할 수도 있습니다.

### 데이터 구조 변경하기

프로덕션의 저장된 데이터는 **씬의 새 버전을 게시해도 지워지지 않습니다**. 이는 순위표, 플레이어 진행 상황, 그리고 플레이어가 씬의 작은 업데이트를 넘어 계속 유지되길 기대하는 지속적인 환경 변경에 아주 좋습니다.

반대로 storage에 있는 데이터는 이전 버전의 코드로 작성되었다는 뜻이기도 합니다. 새 코드가 다른 형태를 기대한다면, 그 오래된 데이터를 파싱하거나 읽을 때 미묘한 방식으로 실패할 수 있습니다. 이름을 바꾼 필드는 사라져 있을 것입니다. 문자열이었고 지금은 객체인 필드는 속성에 접근하려고 하면 오류가 납니다. 몇 달 동안 로그인하지 않은 플레이어는 코드가 더 이상 처리 방법을 모르는 구조 이전의 데이터를 불러올 수도 있습니다.

{% hint style="warning" %}
**📔 참고**: 스키마 변경은 배포 후 첫 번째 읽기만 영향을 주는 것이 아닙니다. 저장된 데이터는 덮어쓰거나 삭제될 때까지 남아 있으므로, 오래된 형식의 값은 언제든 나타날 수 있으며, 종종 잊고 있던 복귀 플레이어에게서 나타납니다.
{% endhint %}

#### 모범 사례

* *항상 방어적으로 파싱하세요*. 직접 썼더라도 storage에서 나오는 것은 무엇이든 신뢰할 수 없는 입력으로 취급하세요.  `JSON.parse()` 안에 `try/catch`를 감싸고, 읽기 전에 필드가 존재하는지 확인하며, 없을 때 사용할 합리적인 기본값을 준비하세요:

  ```typescript
  import { Storage } from "@dcl/sdk/server"

  const raw = await Storage.player.get<string>(playerAddress, "progress")
  let progress = { level: 1, coins: 0 }
  if (raw) {
    try {
      const parsed = JSON.parse(raw)
      progress = {
        level: typeof parsed.level === "number" ? parsed.level : 1,
        coins: typeof parsed.coins === "number" ? parsed.coins : 0,
      }
    } catch {
      // 오래되었거나 손상된 데이터 — 기본값으로 되돌림
    }
  }
  ```
* *필드를 추가하고, 이름을 바꾸거나 제거하지 마세요*. 가장 안전한 스키마 변경은 추가 방식입니다: 기본값이 있는 새 필드를 도입하고, 기존 필드는 그대로 두세요. 오래된 데이터에는 새 필드가 없을 뿐인데, 방어적 파싱이 이미 그 경우를 처리합니다. 필드 이름을 바꾸면 모든 오래된 레코드가 깨집니다.
* *저장된 객체에 버전을 두세요*. `version` 필드를 처음부터 포함하세요. 데이터를 읽을 때는 version에 따라 분기하고, 사용하기 전에 이전 형태를 현재 형태로 마이그레이션하세요. 이렇게 하면 나머지 코드가 하나의 현재 형태만 다루게 됩니다:

  ```typescript
  type ProgressV2 = { version: 2; level: number; coins: number; xp: number }

  function migrate(raw: any): ProgressV2 {
    const version = raw?.version ?? 1
    if (version === 1) {
      // v1에는 xp 필드가 없었음 — 기본값 지정
      return {
        version: 2,
        level: raw.level ?? 1,
        coins: raw.coins ?? 0,
        xp: 0,
      }
    }
    return raw as ProgressV2
  }
  ```
* *마이그레이션된 값을 다시 저장하세요*. 레코드를 메모리에서 업그레이드한 뒤 다시 저장하면 다음 읽기에서는 이미 새 형식이 됩니다. 시간이 지나면 일회성 마이그레이션 스크립트 없이도 오래된 형태의 레코드 풀이 점점 비워집니다.
* *호환되지 않는 변경에는 새 키를 사용하세요*. 새 구조가 정말 호환되지 않고 마이그레이션할 가치가 없다면 새 storage 키(예: `progress_v2`)를 사용하고 이전 것은 무시하세요. 이전 키는 storage에 무해하게 남아 있고, 이를 해석해야 하는 읽기 경로를 피할 수 있습니다. 나중에 다음을 통해 오래된 키를 정리할 수 있습니다: [storage UI](https://decentraland.org/storage) 또는 `npx sdk-commands storage` 명령을 통해.
* *실제 프로덕션 데이터로 테스트하세요*. 구조 변경을 배포하기 전에 storage UI에서 실제 레코드 몇 개를 가져와 새 파싱 코드를 그 데이터에 대해 실행해 보세요. 문제를 일으키는 특이 케이스는 보통 존재하는지도 몰랐던 레코드입니다.
* *우회 경로를 남겨 두세요*. storage UI나 다음을 통해 개별 레코드를 편집하거나 삭제할 수 있다는 점을 기억하세요: `npx sdk-commands storage`. 스키마 변경 후 단일 플레이어가 나쁜 상태에 갇혀 버린 경우, 재배포 없이 그 레코드를 직접 수정할 수 있습니다.

## 환경 변수

코드에 값을 하드코딩하지 않고 씬을 구성하세요. 환경 변수는 민감한 데이터뿐 아니라, 씬을 다시 게시하지 않고도 쉽게 바꿀 수 있는 기능 플래그나 매개변수에도 유용합니다.

환경 변수는 **서버 전용**. 다음으로 보호하세요: `isServer()`서버는 환경 변수를 읽을 수 있지만, 그 값을 변경할 수는 없습니다.

`EnvVar.get()` 다음을 반환합니다: `Promise<string>` 변수가 설정되지 않았을 때는 빈 문자열로 해결되므로, 없는 값에는 항상 대체값을 제공하세요:

```typescript
import { EnvVar } from "@dcl/sdk/server"
import { isServer } from "@dcl/sdk/network"

export async function main() {
  if (!isServer()) return

  const maxPlayers = parseInt((await EnvVar.get("MAX_PLAYERS")) || "4")
  const gameDuration = parseInt((await EnvVar.get("GAME_DURATION")) || "300")
  const debugMode = ((await EnvVar.get("DEBUG")) || "false") === "true"
}
```

### 민감한 데이터

환경 변수는 개인 키, 보상 청구 코드, 그리고 공개 씬의 컴파일된 코드에 노출되면 위험할 수 있는 기타 민감한 데이터를 저장하는 데 특히 유용합니다.

개인 키는 서버의 storage에 저장하고, 서버만 다음으로 이를 읽게 할 수 있습니다: `isServer()`이렇게 하면 민감한 데이터가 플레이어의 기기를 거치지 않습니다.

### 로컬 개발

프로젝트를 로컬에서 실행하는 동안 환경 변수를 사용하려면 다음을 만드세요: `.env` 프로젝트 루트의 파일:

```
MAX_PLAYERS=8
GAME_DURATION=300
DEBUG=true
```

중요: 다음을 추가하세요: `.env` 다음에: `.gitignore`를 추가하여, 이러한 잠재적으로 민감한 값이 공개 콘텐츠 서버에 절대 업로드되지 않도록 하세요.

### 환경 변수 변경하기

환경 변수 값을 바꾸는 가장 쉬운 방법은 storage UI를 사용하는 것입니다.

다음 링크를 입력하면 씬의 storage에 저장된 데이터에 접근할 수 있습니다:

[decentraland.org/storage](https://decentraland.org/storage)

Creator Hub에서도 이 페이지에 갈 수 있습니다. 다음을 여세요: **관리** 탭에서 콘텐츠를 게시한 장소 옆의 세 점을 클릭하고 다음을 선택하세요: **Storage 보기**.

여기에서 씬을 게시할 수 있는 모든 월드와 랜드 목록을 볼 수 있습니다.

씬을 연 다음 **환경** 탭입니다. 프로젝트의 모든 환경 변수를 볼 수 있어야 합니다.

![Activate stream](https://3980763956-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoPnXBby9S6MrsW83Y9qZ%2Fuploads%2Fgit-blob-3661fea25e77812e3b8eb0c4ec56a5ee808f9ff1%2Fenvironment-variables.png?alt=media\&token=b8c1a2c0-907d-402a-9e07-303bbf8732b2)

이 환경 변수들의 값은 읽을 수 없지만(민감한 데이터를 보호하기 위해서입니다) 그중 어느 것이든 삭제하거나 덮어쓸 수는 있습니다. 연필 또는 휴지통 아이콘을 클릭하세요.

명령줄에서도 다음을 사용하여 환경 변수를 관리할 수 있습니다: `npx sdk-commands storage env`:

```bash
# 변수 설정
npx sdk-commands storage env set MAX_PLAYERS --value 8

# 변수 삭제
npx sdk-commands storage env delete OLD_VAR

# 모든 환경 변수 삭제
npx sdk-commands storage env clear --confirm
```

다음을 사용해 특정 환경도 지정할 수 있습니다: `--target` 플래그:

```bash
# 스테이징에 배포
npx sdk-commands storage env set MY_KEY --value my_value --target https://storage.decentraland.zone

# 로컬 개발 서버에 배포
npx sdk-commands storage env set MY_KEY --value my_value --target http://localhost:8000
```

배포된 환경 변수가 다음보다 우선합니다: `.env` 값들.

## 권장 프로젝트 구조

서버, 클라이언트, 공유 코드를 분리하면 코드베이스가 커져도 가독성을 유지할 수 있습니다:

```
src/
├── index.ts              # 진입점 — isServer() 분기
├── client/
│   ├── setup.ts          # 입력 핸들러, 메시지 전송기
│   └── ui.tsx            # React ECS UI (동기화된 상태를 읽음)
├── server/
│   ├── server.ts         # 게임 루프, 메시지 핸들러, 상태 변경
│   └── gameState.ts      # 서버 상태를 위한 헬퍼 함수
└── shared/
    ├── schemas.ts        # 컴포넌트 정의 + validateBeforeChange
    └── messages.ts       # registerMessages() — 양쪽 모두에서 가져옴
```

{% hint style="info" %}
**💡 팁**: 모든 것을 유지하세요 `registerMessages()` 호출과 사용자 정의 컴포넌트 정의를 `shared/`에 두세요. 서버와 클라이언트가 모두 거기서 가져오므로 메시지 스키마에 항상 일치합니다.
{% endhint %}

## 성능 모범 사례

모든 컴포넌트 변경은 *전체* 컴포넌트 데이터를 네트워크로 전송합니다. 이는 diff만 보내는 Colyseus와는 다릅니다. 사용자 정의 컴포넌트를 설계할 때 이 점을 염두에 두세요. 최적의 해법은 변경 빈도에 따라 데이터를 별도의 컴포넌트에 저장하는 것일 수 있습니다.

### ❌ 거대한 단일 컴포넌트는 피하세요

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"

// 나쁨 — 점수를 바꾸면 positions 배열도 함께 전송됨
const GameState = engine.defineComponent("GameState", {
  playerAScore: Schemas.Int,
  playerBScore: Schemas.Int,
  timer: Schemas.Int,
  playerPositions: Schemas.Array(Schemas.Vector3), // 큰 페이로드
})
```

### ✅ 원자적 컴포넌트를 선호하세요

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"

// 좋음 — 각 업데이트가 작고 독립적임
const PlayerScore = engine.defineComponent("PlayerScore", {
  playerA: Schemas.Int,
  playerB: Schemas.Int,
})

const GameTimer = engine.defineComponent("GameTimer", {
  secondsLeft: Schemas.Int,
})
```

*경험칙*: 함께 그리고 비슷한 빈도로 바뀌는 필드끼리 묶으세요. 빠르게 바뀌는 데이터(타이머, 위치)와 느리게 바뀌는 데이터(점수, 설정)는 분리하세요.

### 빈번한 메시지는 제한하세요

매 프레임마다 메시지를 보내지 마세요. 가능하면 배치하거나 제한하세요:

```typescript
import { engine } from "@dcl/sdk/ecs"
import { room } from "./shared/messages"

let lastSend = 0
engine.addSystem((dt) => {
  lastSend += dt
  if (lastSend > 0.1) {
    // 100ms마다
    room.send("position", transform.position)
    lastSend = 0
  }
})
```

예를 들어 서버가 카운트다운 타이머를 제어한다면, 매초 모든 플레이어에게 업데이트를 보낼 필요는 없습니다. 각 클라이언트가 스스로 경과 시간을 계산하게 하고, 서버는 대략 30초마다 현재 상태를 브로드캐스트하여 일관성을 보장하는 것이 가장 좋습니다.

## 서버 리소스 제한

멀티플레이어 서버는 각 씬을 샌드박스화된 격리 환경에서 실행하며, 엄격한 리소스 상한이 적용됩니다. 이 제한에 도달하면 데이터가 조용히 유실되거나 씬의 모든 사용자에 대해 서버가 종료될 수 있으므로, 씬이 이 한도보다 충분히 여유 있게 동작하도록 설계하세요.

### 메모리

격리 환경에는 **256MB** 의 메모리 상한이 있습니다. 이를 초과하면 격리 환경이 폐기되고 연결된 모든 플레이어에 대해 서버가 종료됩니다. 작업 상태는 가볍게 유지하고, 플레이어가 나갈 때 플레이어별 데이터를 정리하세요.

### CPU

각 실행 턴에는 벽시계 기준 예산이 있습니다:

* **동기 실행**: 턴당 10초. 이 시간을 초과하는 무한 루프는 격리 환경을 종료합니다.
* **비동기 턴 정착**: 60초. 만약 `Promise` 체인 또는 `await` 가 이 시간 내에 해결되지 않으면 격리 환경이 종료됩니다.

무거운 작업은 여러 틱에 걸쳐 분산하세요. `dt` 누산기를 `engine.addSystem()`에서 사용하세요. 서버에서 무한한 동기 루프를 절대 실행하지 마세요.

### 수신 메시지 속도

연결된 각 피어는 대략 **1,000ms당 300개 메시지**까지 보낼 수 있습니다. 초과 데이터 프레임은 큐에 쌓이지 않고 버려집니다. 클라이언트에서 매 프레임마다 메시지를 보내지 마세요. 다음을 참조하세요. [성능 모범 사례](#performance-best-practices) 에서 속도 제한 패턴을 확인하세요.

### 메시지 크기

* 수신 패킷은 **128KB** 로 제한됩니다. 너무 큰 패킷은 완전히 버려집니다.
* 씬-통신 메시지는 대략 **30KB**로 제한됩니다. 실제 전송 계층에서는 동기화된 메시지를 **13KB** 보다 훨씬 작게 유지하세요( [메시지](#messages) 섹션 참조).

### 외부 fetch

동시 `signedFetch` 호출은 **32** 개까지 in-flight로 제한됩니다. 추가 fetch는 슬롯이 열릴 때까지 대기열에 쌓입니다. 각 fetch 시도에는 **15초** 의 타임아웃이 있습니다. fetch 응답은 **10MB**로 제한됩니다. WebSocket 연결은 **32** 개의 동시 소켓으로 제한되며, 메시지당 최대 크기는 **1MB** 입니다.

### in-flight 호스트 호출

에서 설명한 40회 호출 상한은 [데이터 저장](#data-storage) Storage, `signedFetch`및 기타 런타임 API를 포함하여 격리 환경 전체의 모든 호스트 호출에 적용됩니다. 초과 호출은 즉시 거부되며 큐에 쌓이지 않습니다.

## 흔한 함정

### 서버 전용 상태에 대한 검증을 잊음

없이 `validateBeforeChange`를 사용하지 않으면 클라이언트가 모든 컴포넌트에 쓸 수 있습니다:

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"
import { isServer } from "@dcl/sdk/network"
import { AUTH_SERVER_PEER_ID } from "@dcl/sdk/network/message-bus-sync"

// ❌ 나쁨 — 클라이언트가 속일 수 있음
const Score = engine.defineComponent("Score", { value: Schemas.Int })

// ✅ 좋음 — 서버 전용
if (isServer()) {
  Score.validateBeforeChange((v) => v.senderAddress === AUTH_SERVER_PEER_ID)
}
```

### 클라이언트가 제공한 값을 신뢰함

체력, 점수, 위치처럼 중요한 데이터에 대해 클라이언트가 자신의 값을 직접 정하게 두지 마세요:

```typescript
import { room } from "./shared/messages"

// ❌ 나쁨
room.onMessage("setHealth", (data) => {
  player.health = data.health // 클라이언트가 값을 제어함!
})

// ✅ 좋음 — 서버가 결과를 계산함
room.onMessage("takeDamage", (data) => {
  const damage = calculateDamage(data.source)
  player.health = Math.max(0, player.health - damage)
})
```

### 상태 동기화 전에 메시지 전송

클라이언트는 상호작용하기 전에 상태가 동기화될 때까지 기다려야 합니다:

```typescript
import { engine } from "@dcl/sdk/ecs"
import { isStateSyncronized } from "@dcl/sdk/network"

engine.addSystem(() => {
  if (!isStateSyncronized()) return
  // 메시지 전송 가능
})
```

### 서버가 시작될 때까지 기다리기

서버는 최소 한 명의 플레이어가 씬에 있을 때만 활성화됩니다. 마지막 플레이어가 나간 후 서버는 약 2분 동안 유지되다가 종료됩니다. 다음 방문에서는 새 인스턴스가 차갑게 시작되며, 이는 대략 **프로덕션에서 15초**가 걸립니다. 로컬 미리보기는 서버를 즉시 시작하므로, 차가운 시작 문제는 거의 항상 게시 후에만 드러납니다.

씬의 코드는 서버가 온라인이 될 때까지 기다릴 준비가 되어 있어야 합니다. 서버가 시작을 마치기 전에 보낸 메시지는 조용히 유실됩니다. 기다리는 동안 플레이어에게 "서버가 깨어나는 중" 메시지를 보여 주고, 초기 서버 요청에는 재시도 로직을 추가하세요.

{% hint style="info" %}
**팁:** 서버 준비 상태를 감지하는 신뢰할 수 있는 방법은 하트비트입니다. 서버가 `Date.now()` 를 2초마다 동기화된 컴포넌트 필드에 쓰고, 클라이언트가 마지막으로 값이 변경된 시점을 추적하게 하세요. 6초 안에 변화가 오지 않으면 서버가 오프라인이라고 간주합니다. 이는 `isStateSyncronized()`보다 더 견고한데, 후자는 전송 계층이 연결되어 있는지만 확인하고 서버가 실행 중인지는 확인하지 않기 때문입니다.
{% endhint %}

## 완전한 예제

최소한의 멀티플레이어 카운터: 버튼을 클릭하면 서버가 동기화된 점수를 증가시킵니다. 서버는 카운터를 `저장소` 에 저장하므로 값이 서버 재시작 후에도 유지됩니다. 플레이어가 없을 때 서버가 종료된다는 점을 기억하세요. 저장소가 없으면 씬에 플레이어가 비어 있을 때마다 카운트가 0으로 초기화됩니다.

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"
import { registerMessages, isServer, syncEntity } from "@dcl/sdk/network"
import { AUTH_SERVER_PEER_ID } from "@dcl/sdk/network/message-bus-sync"
import { pointerEventsSystem } from "@dcl/sdk/ecs"
import { Storage } from "@dcl/sdk/server"

// 1. 메시지 정의(공유)
const Messages = {
  increment: Schemas.Map({}),
  stateUpdate: Schemas.Map({
    count: Schemas.Int,
    lastPlayer: Schemas.String,
  }),
}

// 2. 서버 전용 컴포넌트 정의(공유)
const Counter = engine.defineComponent("Counter", {
  value: Schemas.Int,
  lastPlayer: Schemas.String,
})

// 3. 방 생성
const room = registerMessages(Messages)

export async function main() {
  if (isServer()) {
    // === 서버 ===

    // 서버만 이 컴포넌트를 수정할 수 있습니다
    Counter.validateBeforeChange(
      (v) => v.senderAddress === AUTH_SERVER_PEER_ID
    )

    // 서버가 재시작한 경우를 대비해 저장소에서 카운터 복원
    const savedCount = await Storage.get<string>("counter")
    const savedPlayer = await Storage.get<string>("lastPlayer")
    const initialCount = savedCount ? parseInt(savedCount) : 0
    const initialPlayer = savedPlayer ?? "none"

    const counterEntity = engine.addEntity()
    syncEntity(counterEntity, [Counter.componentId], 1)
    Counter.create(counterEntity, {
      value: initialCount,
      lastPlayer: initialPlayer,
    })

    room.onMessage("increment", async (_data, context) => {
      if (!context) return
      const counter = Counter.getMutable(counterEntity)
      counter.value += 1
      counter.lastPlayer = context.from

      // 서버 재시작 후에도 값이 유지되도록 저장소에 영구 저장
      await Storage.set("counter", String(counter.value))
      await Storage.set("lastPlayer", counter.lastPlayer)

      room.send("stateUpdate", {
        count: counter.value,
        lastPlayer: context.from,
      })
    })
  } else {
    // === 클라이언트 ===
    const button = engine.addEntity()
    // ... Transform, MeshRenderer 등을 추가

    pointerEventsSystem.onPointerDown(button, () => {
      room.send("increment", {})
    })

    room.onMessage("stateUpdate", (data) => {
      console.log(`Count: ${data.count} (last click by ${data.lastPlayer})`)
    })
  }
}
```

## 로컬에서 테스트하기

표준 미리보기가 모든 것을 처리합니다. SDK의 auth-server 브랜치를 사용할 때 로컬 서버는 클라이언트 미리보기와 함께 백그라운드에서 자동으로 시작됩니다.

로컬에서 멀티플레이어 상호작용을 테스트하려면 미리보기를 두 개의 별도 창에서 여세요. 각 창은 별도의 플레이어로 취급됩니다. 각 창을 다른 주소로 연결하세요. 두 클라이언트 모두 동일한 로컬 서버 인스턴스에 연결됩니다.

Creator Hub에서 다음을 켜세요 **다중 인스턴스 미리 보기** 다음에서 **데스크톱 클라이언트** 하위 메뉴의 **재생 옵션**, 그런 다음 다음을 클릭하세요 **재생** 버튼을 한 번 더 눌러 두 번째 디센트럴랜드 explorer 창을 엽니다. 두 창 모두 서로 다른 주소로 연결해야 합니다. 장면이 다시 로드되어도 동일한 세션이 유지됩니다.

대안으로, 다음을 브라우저 URL에 입력하여 두 번째 디센트럴랜드 explorer 창을 열 수 있습니다:

> `decentraland://realm=http://127.0.0.1:8000&local-scene=true&debug=true&multi-instance=true`

### 디버깅 팁

* *로그에 접두사를* 붙여 `[SERVER]` 또는 `[CLIENT]` 서 터미널에서 구분할 수 있습니다:

  ```typescript
  import { isServer } from "@dcl/sdk/network"

  if (isServer()) {
    console.log("[SERVER] Starting...")
  } else {
    console.log("[CLIENT] Starting...")
  }
  ```
* *컴포넌트 동기화 확인* 클라이언트에서 엔티티 개수를 기록하여 확인하세요:

  ```typescript
  import { engine } from "@dcl/sdk/ecs"

  engine.addSystem(() => {
    const entities = Array.from(engine.getEntitiesWith(MyComponent))
    console.log("[CLIENT] Synced entities:", entities.length)
  })
  ```

## 프로덕션에서 디버그하기

게시된 서버의 `console.log()` 출력을 보려면, 지갑 주소가 `logsPermissions` 배열에 `scene.json`:

```json
{
  "logsPermissions": ["0xYourWalletAddress"]
}
```

포함되어 있어야 합니다. 그렇지 않으면 씬 소유자에게도 프로덕션에서 서버 로그가 숨겨집니다.

프로젝트 폴더에서 다음을 실행하여 명령줄에서 실시간 서버 로그를 스트리밍할 수 있습니다

```bash
npx sdk-commands sdk-server-logs
```

다음과 같이 월드 이름을 수동으로 지정할 수도 있습니다:

```bash
npx sdk-commands sdk-server-logs --world WORLD_NAME.dcl.eth
```

멀티씬 월드나 Genesis City의 파셀에서 씬 로그를 볼 때는 다음도 전달하세요. `위치` 좌표에 대해:

```bash
npx sdk-commands sdk-server-logs --world WORLD_NAME.dcl.eth --position=x,y
```

인증을 위해 `logsPermissions` 에 나열된 지갑 중 하나로 메시지 서명을 요청받게 됩니다. 연결되면 서버 측 `console.log()` 출력을 실시간으로 볼 수 있어, 재배포 없이 문제를 진단하는 데 유용합니다.

### 저장소 데이터 보기

다음 링크를 입력하면 씬의 storage에 저장된 데이터에 접근할 수 있습니다:

[decentraland.org/storage](https://decentraland.org/storage)

Creator Hub에서도 이 페이지에 갈 수 있습니다. 다음을 여세요: **관리** 탭에서 콘텐츠를 게시한 장소 옆의 세 점을 클릭하고 다음을 선택하세요: **Storage 보기**.

여기에서 씬을 게시할 수 있는 모든 월드와 랜드 목록을 볼 수 있습니다.

각각에 저장된 정보를 보려면 월드 또는 플레이어 데이터를 여세요.

예를 들어 특정 플레이어가 씬을 플레이할 때 문제가 있다면 주소로 이 플레이어를 찾아 그에게 저장된 데이터를 확인하여 상황을 파악할 수 있습니다. 어떤 경계 사례에 걸려 모순된 데이터 상태가 되었을 수도 있습니다. 이 페이지에서 해당 플레이어의 데이터를 지우거나 수정하여 안정된 상태로 되돌릴 수도 있습니다.

## 버전 관리

씬의 게시된 각 버전에는 고유한 해시 ID가 있으며, 각 해시는 자체 서버 인스턴스와 짝지어집니다. 이는 클라이언트 코드와 서버 코드가 항상 함께 움직인다는 뜻으로, 오래된 로직을 실행하는 클라이언트가 새 로직을 실행하는 서버와 대화하는 시기(또는 그 반대)는 없습니다.

업데이트를 게시하면:

* *이미 씬에 있는 플레이어들은* 나갔다가 다시 돌아올 때까지 씬의 이전 버전을 계속 보게 됩니다. 이들의 클라이언트는 이전 해시와 일치하는 서버 인스턴스에 계속 연결되어 있습니다.
* *새로 들어오는 플레이어들은* 업데이트 후 새로운 씬 버전을 로드하고 새 서버 인스턴스에 연결합니다.

이렇게 하면 스키마 변경이나 컴포넌트 이름 변경 때문에 클라이언트와 서버 상태가 절대 어긋나지 않습니다. 업데이트가 이미 씬 안에 있는 플레이어의 세션을 깨뜨릴 수는 없습니다.

대신 배포 직후 짧은 시간 동안 플레이어가 두 개의 서로 다른 서버 인스턴스로 나뉠 수 있다는 점이 단점입니다. 이미 있던 플레이어와 방금 들어온 플레이어는 같은 씬에 있어도 서로를 보지 못하거나 씬을 통해 상호작용하지 못할 수 있으며, 이는 기존 플레이어들이 나갔다가 다시 들어올 때까지 계속됩니다.

{% hint style="info" %}
**💡 팁**: [저장소](#data-storage) 서비스를 통해 저장된 데이터(예: 리더보드, 플레이어 진행도, 영구적인 환경 변경)는 *저장되지* 버전 사이에서 지워집니다. 저장소는 위치 수준에서 유지되며 동일한 씬을 가리키는 모든 서버 인스턴스에서 공유되므로, 새 버전은 이전 버전이 중단된 지점부터 그대로 이어받습니다.
{% endhint %}

## Colyseus에서 마이그레이션하기

이미 Colyseus로 구축된 씬이 있다면, 아래 표는 일반적인 Colyseus 패턴을 SDK7의 대응 방식에 매핑합니다:

| Colyseus                   | SDK7 멀티플레이어 서버                       |
| -------------------------- | ------------------------------------ |
| `room.send(type, data)`    | `room.send(type, data)` — 동일한 API    |
| `room.onMessage(type, cb)` | `room.onMessage(type, cb)` — 동일한 API |
| `room.state.players` (스키마) | `syncEntity` + 사용자 지정 컴포넌트           |
| JSON 직렬화                   | 이진 직렬화(자동으로 `Schemas`)               |
| 별도의 서버 애플리케이션              | 동일한 코드베이스 — `isServer()` 브랜칭         |
| 사용자 지정 서버 호스팅              | 내장됨: 미리보기가 서버를 자동으로 실행               |

염두에 둘 주요 차이점:

* *직렬화*: Colyseus는 JSON diff를 전송하고, SDK는 변경될 때마다 전체 컴포넌트를 전송합니다. 컴포넌트를 작게 유지하세요( [성능 모범 사례](#performance-best-practices)).
* *상태 모델*: Colyseus는 자동 diffing이 있는 변경 가능한 상태 트리를 사용합니다. SDK는 `syncEntity` 를 통해 동기화되고 `validateBeforeChange`.
* *로 보호되는 ECS 컴포넌트를 사용합니다.*: 별도의 서버 배포가 없습니다. 멀티플레이어 서버는 씬과 함께 자동으로 배포됩니다.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.decentraland.org/creator/content-creator-ko/sdk7/networking/authoritative-servers.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
