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

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

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

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

멀티플레이어 서버를 두는 것은 실제 문제도 해결해 줍니다. 예를 들어 P2P 설정에서 떠 있는 플랫폼 같은 것을 두 플레이어가 조작하면 상충되는 결과가 나올 수 있습니다. 각 클라이언트가 플랫폼을 서로 다른 높이로 설정하고, 어느 쪽이 맞는지 결정할 권한이 아무에게도 없습니다. 멀티플레이어 서버는 모든 변경을 한 곳에서 해결하므로 모든 클라이언트가 같은 상태로 수렴합니다.

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

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

## 설정

### 1. auth-server SDK 버전 설치

네이티브 멀티플레이어 서버 API(`isServer`, `registerMessages`, `스토리지`, `EnvVar`, 등)는 별도의 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(
    엔티티,
    [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("[SERVER] 관리자 목록을 가져오는 중 오류가 발생했습니다:", error)
      adminAddresses = new Set()
      return
    }
    adminAddresses = new Set(
      (response ?? []).map((admin) => admin.admin.toLowerCase())
    )
    console.log(
      "[SERVER] 관리자 주소 캐시를 업데이트했습니다:",
      Array.from(adminAddresses)
    )
  } catch (error) {
    console.error("[SERVER] 관리자 주소를 업데이트하는 중 오류가 발생했습니다:", 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(
          "[SERVER] 권한 없는 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 })

// 서버 → 하나의 특정 클라이언트(지갑 주소 기준)
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(`[Server] ${data.displayName}이 준비되었습니다 (${senderAddress})`)
})
```

서버에서는 수신한 모든 메시지에 `context` 보낸 사람의 검증된 지갑 주소가 담긴 객체가 포함됩니다. 이를 사용하여 어떤 플레이어가 메시지를 보냈는지 확인하세요(페이로드의 자기 보고된 신원에 절대 의존하지 마세요).

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

클라이언트는 접속 시 레이스 컨디션을 피하기 위해 첫 번째 메시지를 보내기 전에 씬 상태가 동기화될 때까지 기다려야 합니다:

```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" 또는 하트비트 메시지를 브로드캐스트하게 하고, 클라이언트는 게임플레이 메시지를 보내기 전에 그것을 기다리게 하세요.
{% endhint %}

### 사용 가능한 스키마 타입

모든 메시지 페이로드와 사용자 정의 컴포넌트는 `Schemas` 를 사용하여 이진 직렬화를 수행합니다. 사용 가능한 타입의 간단한 참고 목록은 다음과 같습니다:

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

// 기본 타입
Schemas.String // "hello"
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) // "hello" 또는 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 %}

## 데이터 저장

서버 재시작 간에도 데이터를 유지합니다. 저장소는 **서버 전용**, 항상 호출을 다음으로 감싸세요 `isServer()`. 서버는 이 데이터를 쓰고 읽을 수 있습니다.

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

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

* **월드**: 리더보드나 지속적인 환경 변경처럼 모든 플레이어와 관련된 데이터에 사용하세요.
* **플레이어**: 해당 플레이어의 진행 상황이나 선호도 저장처럼 플레이어별 데이터에 사용하세요.

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

에 기록됩니다 `node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json`.
{% endhint %}

{% hint style="warning" %}
**📔 참고**: `Storage.set()`, `Storage.player.set()`, 그리고 `삭제` variants는 단일 값으로 해결됩니다 **불리언**. 절대 예외를 던지지 않습니다 — 실패 시(네트워크 오류 또는 동시 요청이 너무 많은 경우, 아래 참조) 오류를 기록하고 다음으로 해결됩니다 `false`, 즉 값이 **아닙니다** 영구 저장되었습니다. 항상 결과를 확인하세요: 버려진 `false` 는 조용히 유실된 저장입니다.
{% endhint %}

### 체크포인트에서 저장하고, 모든 변경마다 저장하지 마세요

Storage는 서버 재시작과 재배포 후에도 살아남아야 하는 데이터의 영구 저장소입니다. 실시간 데이터 저장소가 아닙니다. 작업 중인 게임 상태는 서버 메모리에 보관하세요. 그게 더 빠르고, 서버에 맞는 올바른 패턴입니다. Storage에는 정말 필요할 때만, 의미 있는 체크포인트에서 쓰세요.

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

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

```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) // 나중에 플러시할 대상으로 표시, 여기서는 Storage 호출 없음
}

// 더티 키를 주기적으로(예: 약 30초마다) 그리고 체크포인트에서 플러시
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이면, 해당 키는 더티 상태로 남고 다음 플러시에서 다시 시도됩니다
  }
}

// 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")
```

또한 명령줄을 사용해 scene 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

# 모든 scene 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} — retry later`)
}

// 읽기
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을 통해서도 이 페이지에 접근할 수 있습니다. 다음을 여세요. **관리** 탭을 열고, 콘텐츠를 게시한 장소 옆의 점 3개를 클릭한 다음 **스토리지 보기**.

를 선택하세요. 그러면 씬을 게시할 수 있는 모든 월드와 land 목록이 표시됩니다.

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

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

![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)

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

### 데이터 구조 변경

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

반대로, 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` 필드를 처음부터 포함하세요. 데이터를 읽을 때는 버전에 따라 분기하고, 사용하기 전에 오래된 형식을 현재 형식으로 마이그레이션하세요. 이렇게 하면 나머지 코드가 하나의 현재 형식만 다루게 됩니다:

  ```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`를 통해 개별 레코드를 편집하거나 삭제할 수 있다는 점을 기억하세요. 스키마 변경 후 한 명의 플레이어가 잘못된 상태에 갇히더라도, 재배포 없이 해당 레코드를 직접 수정할 수 있습니다.

## 환경 변수

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

환경 변수는 서버 전용입니다 **서버 전용**. 다음으로 보호하세요 `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"
}
```

### 민감한 데이터

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

비밀 키를 서버의 storage에 저장하고, 서버만 이를 `isServer()`로 읽게 할 수 있습니다.

### 로컬 개발

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

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

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

### 환경 변수 변경

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

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

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

Creator Hub을 통해서도 이 페이지에 접근할 수 있습니다. 다음을 여세요. **관리** 탭을 열고, 콘텐츠를 게시한 장소 옆의 점 3개를 클릭한 다음 **스토리지 보기**.

를 선택하세요. 그러면 씬을 게시할 수 있는 모든 월드와 land 목록이 표시됩니다.

씬을 연 다음 **환경** 탭. 프로젝트의 모든 환경 변수가 보여야 합니다.

![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)

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

또한 명령줄을 사용해 환경 변수를 관리할 수 있습니다. `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"

// 나쁨 — 점수를 바꿔도 위치 배열까지 함께 전송됨
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초마다 현재 상태를 브로드캐스트하여 일관성을 유지하는 것이 가장 좋습니다.

## 서버 자원 제한

멀티플레이어 서버는 각 scene을 샌드박스화된 isolate에서 하드 리소스 상한과 함께 실행합니다. 이 한도를 넘으면 데이터가 조용히 유실되거나 scene의 모든 사람에 대해 서버가 종료될 수 있으므로, scene이 한도 내에서 충분히 여유 있게 동작하도록 설계하세요.

### 메모리

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

### CPU

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

* **동기식 실행**: 턴당 10초. 이 시간을 넘기는 무한 루프는 isolate를 종료시킵니다.
* **비동기 턴 정산**: 60초. Promise `체인이나` 이 `await` 이 시간보다 오래 걸려 해결되면 isolate가 종료됩니다.

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

### 수신 메시지 속도

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

### 메시지 크기

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

### 외부 fetch

동시 `signedFetch` 호출은 **32** 개로 제한됩니다. 추가 fetch는 슬롯이 생길 때까지 대기열에 들어갑니다. 각 fetch 시도에는 **15초** 의 타임아웃이 있습니다. fetch 응답은 **10MB**로 제한됩니다. WebSocket 연결은 **32** 개의 동시 소켓 **1 MB** 과 각 메시지당 최대 메시지 크기

### 입니다.

에서 설명한 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)
}
```

### 클라이언트가 제공한 값을 그대로 믿는 것

health, score, position 같은 중요한 데이터의 값을 클라이언트가 마음대로 정하게 두지 마세요:

```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 %}

## 전체 예제

가장 간단한 멀티플레이어 카운터입니다: 버튼을 클릭하면 서버가 동기화된 점수를 1 증가시킵니다. 서버는 카운터를 `스토리지` 에 저장해 서버 재시작 후에도 값이 유지되게 합니다. 서버는 씬에 플레이어가 없으면 종료되므로, 저장소가 없으면 씬에서 플레이어가 모두 떠날 때마다 카운트가 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(`카운트: ${data.count} (마지막 클릭: ${data.lastPlayer})`)
    })
  }
}
```

## 로컬에서 테스트하기

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

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

Creator Hub에서 미리보기 버튼을 두 번째로 클릭하면 두 번째 Decentraland explorer 창이 열립니다. 두 창 모두 서로 다른 주소로 연결해야 합니다. 같은 세션은 씬이 다시 로드되는 동안 계속 열려 있습니다.

대안으로, 다음을 브라우저 URL에 입력하여 두 번째 Decentraland 탐색기 창을 열 수 있습니다:

> `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] 동기화된 엔티티:", 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()` 출력이 실시간으로 표시되어, 재배포 없이 문제를 진단하는 데 유용합니다.

### 스토리지 데이터 보기

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

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

Creator Hub을 통해서도 이 페이지에 접근할 수 있습니다. 다음을 여세요. **관리** 탭을 열고, 콘텐츠를 게시한 장소 옆의 점 3개를 클릭한 다음 **스토리지 보기**.

를 선택하세요. 그러면 씬을 게시할 수 있는 모든 월드와 land 목록이 표시됩니다.

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

예를 들어 특정 플레이어가 씬을 플레이할 때 문제가 생긴다면, 주소로 이 플레이어를 찾아 어떤 데이터가 저장되어 있는지 확인해 상황을 파악할 수 있습니다. 아마도 서로 모순되는 데이터 상태에 빠진 엣지 케이스를 밟았을 수도 있습니다. 이 페이지에서 그 플레이어의 데이터를 지우거나 수정해서 안정적인 상태로 되돌릴 수도 있습니다.

## 버전 관리

씬의 게시된 각 버전은 고유한 해시 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는 다음을 통해 동기화되는 ECS 컴포넌트를 사용합니다 `syncEntity` 그리고 다음으로 보호됩니다 `validateBeforeChange`.
* *호스팅*: 별도의 서버 배포가 없습니다. 멀티플레이어 서버는 씬과 함께 자동으로 배포됩니다.


---

# 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 dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

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

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
