# 미러

### Edgegap에서의 Mirror

이 가이드는 를 사용하여 Unity 프로젝트에서 Edgegap에 헤드리스 서버를 생성하는 데 도움을 줍니다 [Mirror](https://mirror-networking.com/) 를 네트워킹 솔루션으로 사용합니다.

이 가이드는 오픈 소스 샘플 프로젝트를 사용합니다 `Tanks`는 이미 Mirror 샘플의 Assets/Mirror/Examples/Tanks 위치에 있습니다.

최종 샘플은 우리의 [GitHub](https://github.com/edgegap/netcode-sample-unity-mirror).

### 게임 서버 빌드하기

게임 준비가 완료되면 Unity 에디터의 `Build` 화면으로 이동하세요. 상단 메뉴의 `File -> Build Settings` 에서 확인하십시오. 사용 중인 Unity 버전에 따라 올바른 프리셋을 선택했는지 확인하세요.

* 버전 2021.2 이전:
  * 다음으로 설정 `Target Platform` 을 `Linux`;
  * 다음으로 설정 `Architecture` 을 `x86_64`;
  * 다음을 확인하세요 `Server Build` 옵션.
* 그렇지 않으면:
  * 다음으로 설정 `Platform` 을 `Dedicated Server`;
  * 다음으로 설정 `Target Platform` 을 `Linux`.

그런 다음 빌드를 누르고 파일 대상로 `linux_server` 라는 새 빈 폴더를 선택하세요. 를 두 번째 빈 폴더로 복사하세요. 이 문서에서는 이를 `linux_server` 폴더라고 부릅니다. `[SERVER BUILD]` 폴더.

### 전용 게임 서버 컨테이너화하기

이 부분에서는 전용 게임 서버를 포함하는 도커 이미지를 생성합니다. 또한 읽어보는 것이 도움이 될 수 있습니다 [Docker의 Unity Server](broken://pages/02a2e8d85c674170b16164e93c101d8b88dce78d).

Edgegap에서 Docker에 대한 자세한 정보가 필요하면 다음을 참조하세요 [이](/docs.edgegap.com-ko/docs/tools-and-integrations/container/docker.md) 문서.

#### Dockerfile

```
FROM ubuntu:bionic
MAINTAINER <author_detail>

ARG debian_frontend=noninteractive
ARG docker_version=17.06.0-ce

RUN apt-get update && \
    apt-get install -y libglu1 xvfb libxcursor1 ca-certificates && \
    apt-get clean && \
    update-ca-certificates

EXPOSE 3389/TCP
EXPOSE [GAME PORT]/TCP
EXPOSE [GAME PORT]/UDP

COPY linux_server/ /root/linux_server/
COPY boot.sh /boot.sh

WORKDIR /root/
ENTRYPOINT ["/bin/bash", "/boot.sh"]
```

네트워크 통신에 사용하는 포트를 메모하세요. 본 문서에서는 이를 `[GAME PORT]`라고 합니다. 기본적으로 사용되는 포트는 `7777`입니다. 이 정보는 Unity 에디터의 `NetworkManager` 게임 오브젝트의 `Transport` 컴포넌트에서 찾을 수 있습니다.

* 위 줄들을 복사하여 Dockerfile 안에 붙여넣으세요. Dockerfile은 `[SERVER BUILD]`안에 위치해야 합니다. 플레이스홀더를 게임 포트에 맞게 수정하세요. `[GAME PORT]` 플레이스홀더를 게임 포트로 수정하세요.

가 `[GAME PORT]` TCP와 UDP에서 열려 있으면 Mirror 컴포넌트에서 선호하는 어떤 전송 방식도 사용할 수 있습니다. 마지막으로 `NetworkManager` 라는 파일을 생성하세요. `boot.sh` 를 `[SERVER BUILD]` 폴더의 루트에 생성하세요. 이미지를 컨테이너에서 시작할 때 실행됩니다.

* 다음 두 줄을 복사하되, 생성된 파일의 이름으로 `[YOUR GAME]` 플레이스홀더를 반드시 교체하세요.

```
xvfb-run --auto-servernum --server-args='-screen 0 640X480X24:32' /root/build/[YOUR GAME].x86_64 -batchmode -nographics
```

이 시점에서 다음과 같은 계층 구조가 있어야 합니다:

> * `[SERVER BUILD] 폴더` > > - `Dockerfile` > > - `boot.sh` > > - `linux_server` 폴더 > > > - Unity가 생성한 파일들

* 폴더에서 명령 프롬프트를 시작하고 다음 Docker 명령들을 실행하세요: `[SERVER BUILD]` 다음 Docker 명령들을 실행하세요:

{% hint style="warning" %}
ARM CPU(Mac M1, M2 등) 사용자의 경우 빌드 명령에 `--platform linux/amd64`  옵션을 추가하세요.
{% endhint %}

#### Linux 사용 시

```bash
# 이미지 빌드
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# 로그인, 프롬프트가 비밀번호를 물어봅니다
docker login -u '<REGISTRY_USERNAME>' <REGISTRY_URL>

# 이미지에 레지스트리에 해당하는 태그 추가
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

# 이미지 푸시
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

#### cmd 사용 시

```bash
# 이미지 빌드
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# 로그인, 프롬프트가 비밀번호를 물어봅니다
docker login -u <REGISTRY_USERNAME> <REGISTRY_URL>

# 이미지에 레지스트리에 해당하는 태그 추가
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

# 이미지 푸시
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

#### Powershell 사용 시

```bash
# 이미지 빌드
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# 로그인, 프롬프트가 비밀번호를 물어봅니다
docker login -u '<REGISTRY_USERNAME>' <REGISTRY_URL>

# 이미지에 레지스트리에 해당하는 태그 추가
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

# 이미지 푸시
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

이 명령들을 실행한 후 Edgegap 컨테이너 레지스트리를 사용 중이라면 Edgegap 웹사이트에서 업로드한 이미지를 확인할 수 있어야 합니다. Edgegap 레지스트리를 사용하려면 [이 문서](/docs.edgegap.com-ko/learn/advanced-features/edgegap-container-registry.md) 를 참고하세요. 다른 개인 레지스트리를 사용할 수도 있습니다.

### Edgegap에 배포하기

웹사이트의 `Applications & Games` 페이지로 이동하세요. 우측 상단의 `Create New` 버튼을 클릭하여 애플리케이션 폼에 접근하세요. 다음은 필드와 올바르게 작성하는 방법입니다:

* Application name : 다른 애플리케이션들 사이에서 쉽게 식별할 수 있도록 원하는 이름을 사용하세요.
* Image : 애플리케이션을 쉽게 식별하기 위해 사용할 특정 이미지를 설정할 수 있습니다.
* Version name : 배포하는 버전의 범위를 설명하기 위해 버전 이름을 사용할 수 있습니다. 예: “demo”, “production”, “v1”, “v2”
* Container :
  * Registry : “\[URL]”, 여기서 \[URL]은 Container Repository 페이지에서 표시할 수 있는 자격 증명의 값입니다.
  * Image repository : “\[PROJECT]/\[YOUR GAME]”, 여기서 \[PROJECT]와 \[YOUR GAME]은 도커 이미지를 푸시할 때 사용한 값들입니다.
  * Tag : “\[TAG]”, 여기서 \[TAG]는 도커 이미지를 푸시할 때 사용한 값입니다.
  * “Using a private repository”에 체크하세요
  * Private registry username : “\[USERNAME]”, 여기서 \[USERNAME]은 자격 증명에서 가져온 값입니다.
  * Private registry token : “\[TOKEN]”, 여기서 \[TOKEN]은 자격 증명에서 가져온 값입니다.
  * Requirements : 그대로 두세요.
  * Ports :
    * 새 포트를 추가하려면 `+ Add port` 링크를 클릭하고 다음 항목들을 추가하세요 :
      * `[GAME PORT]` - `TCP/UDP` - 검증 비활성화
      * 3389 - TCP - 검증 비활성화

애플리케이션이 생성되면 `Deploy` 버튼을 눌러 게임 서버 배포를 진행할 수 있습니다. 배포할 리전을 선택하고 게임에 따라 생성할 무작위 플레이어 수를 입력하세요. 다음을 확인하여 모든 것이 원활하게 실행되는지 확인하세요:

* Latest Status는 `Ready`.
* 로 설정되어야 합니다. `Port Mapping` 탭에서 애플리케이션 생성 폼에 설정한 포트를 확인할 수 있어야 합니다:

<figure><img src="/files/76dd16a3d36ebdcea83ec8631bbc029b9e649903" alt=""><figcaption></figcaption></figure>

### 클라이언트 애플리케이션에 샘플 HUD 추가하기

* 다음 값을 설정하세요: `Port` 값을 `Transport` 컴포넌트의 `NetworkManager` 값을 배포의 `Port Mapping` 탭에서 정의된 외부 포트로 설정하세요.

이 예제에서는 포트가 `31887`로 설정되었습니다. 이는 주로 개발 중인 게임에 따라 달라지며, 대부분 게임 코드베이스에서 프로그래밍 방식으로 설정됩니다.

* 다음 값 설정: `Network Address` 의 `Network Manager` 를 귀하의 배포의 `Host`로 설정하세요. 이 URL은 대시보드의 `Deployment Summary` 에서 또는 API로 찾을 수 있습니다.

이 예제에서는 주소가 `0ace560706a5.pr.edgegap.net`로 설정되었습니다. 다시 말하지만, 이 값은 매치메이커/마스터 서버 또는 API와의 통신 중에 클라이언트 쪽에서 프로그래밍 방식으로 설정되는 경우가 많습니다.

정확한 정보가 있으면 게임 서버에 정상적으로 연결할 수 있으며 바로 플레이를 시작할 수 있습니다.

이제 온디맨드로 배포할 수 있는 Mirror 프로젝트가 준비되었습니다!

좌석 기반 배포에서는 플레이어가 서버에서 연결이 끊겼을 때 대기 중인 Edgegap 세션을 자동으로 제거하는 시스템을 Mirror로 만들 수 있습니다. 이는 `NetworkManager` 콜백 함수 `와 플레이어 프리팹에 부착된` NetworkBehaviour

스크립트에서 원격 프로시저 호출(RPC) 함수와 커맨드 함수를 사용하는 방식으로 구현할 수 있습니다. `NetworkManager` 서버가 시작되면, `는 해당 배포에 연결된` 세션 ID들 `목록을 Edgegap API에서 조회하여 저장합니다. 이후 새로운 플레이어가 서버에 연결되면, 클라이언트측 함수가` RPC `을 통해 시작되어` 플레이어의 IP 주소 `를 서버로 보내는`command `를 실행합니다. 플레이어의 IP로 서버는 각 세션 데이터에서 일치하는 IP를 확인합니다; 서버는 캐시된 ID로 Edgegap API를 사용하여 세션 데이터를 가져옵니다. 일치하는 항목이 발견되면,` session ID `가 해당 플레이어의`.

NetworkConnectionToClient

에 매핑됩니다. `가 해당 플레이어의` 서버 시작 후에도 새로운 세션이 추가될 수 있으므로 세션 ID 목록이 업데이트되며, 처음에 매칭되지 않았던 경우 새 세션들이 다시 확인됩니다. `를 실행합니다. 플레이어의 IP로 서버는 각 세션 데이터에서 일치하는 IP를 확인합니다; 서버는 캐시된 ID로 Edgegap API를 사용하여 세션 데이터를 가져옵니다. 일치하는 항목이 발견되면,`마지막으로 플레이어가 서버에서 연결을 끊으면, 서버는 해당 플레이어의

#### 를 사용하여 연관된

```cs
을 조회한 다음 Edgegap API를 사용하여 해당 세션을 삭제합니다. 이렇게 하면 배포에서 소켓이 해제되어 새로운 플레이어가 참여할 수 있습니다.
{
    PlayerNetworkBehaviour
    public class PlayerNetworkBehaviour : NetworkBehaviour
    {
        [ClientRpc]
    }

    public void RpcSendClientIpAddress()
    {
        StartCoroutine(FetchPublicIpAndSendToServer());
        private IEnumerator FetchPublicIpAndSendToServer(" alt=""><figcaption></figcaption></figure>

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");
        {
            yield return request.SendWebRequest();
            if (request.result == UnityWebRequest.Result.Success)
            string responseText = request.downloadHandler.text;
        }
        string clientIp = JObject.Parse(responseText)["ip"].ToString();
        {
            CmdSendIpAddressToServer(clientIp);
        }
    }

    else
    Debug.LogError("공용 IP 주소를 가져오지 못했습니다.");
    {
        [Command]
        public void CmdSendIpAddressToServer(string clientIp)
        {
            NetworkConnectionToClient conn = connectionToClient;
        }
        string clientIp = JObject.Parse(responseText)["ip"].ToString();
        {
            if (conn != null)
        }
    }
}
```

#### CustomNetworkManager.Instance.StoreClientIpAddress(conn, clientIp);

```cs
Debug.LogError("연결을 찾을 수 없습니다.");
{
    CustomNetworkManager
    public class CustomNetworkManager : NetworkManager
    private static readonly string edgegapUrl = "https://api.edgegap.com/v1";
    private string apiToken;
    private string deploymentId;

    private Dictionary<NetworkConnectionToClient, string> connectionToSessionIdMap = new Dictionary<NetworkConnectionToClient, string>();
    private Dictionary<NetworkConnectionToClient, bool> mappingInProgress = new Dictionary<NetworkConnectionToClient, bool>();

    private ArrayList currentDeploymentSessions = new();

    private ArrayList newSessions = new();
    {
        public static CustomNetworkManager Instance;
        public override void Awake()
    }

    base.Awake();
    {
        Instance = this;
        public override void OnStartServer()
        base.OnStartServer();
        deploymentId = System.Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");
        {
            apiToken = System.Environment.GetEnvironmentVariable("API_TOKEN");
        }

        if (string.IsNullOrEmpty(apiToken))
    }

    Debug.LogError("환경 변수에서 Edgegap API 토큰을 찾을 수 없습니다.");
    {
        StartCoroutine(GetNewSessionList());
        public override void OnServerAddPlayer(NetworkConnectionToClient conn)

        base.OnServerAddPlayer(conn);
        Debug.Log($"연결에 플레이어 추가 중: {conn.connectionId}");
        // 플레이어 오브젝트에서 PlayerNetworkBehaviour 가져오기
    }

    PlayerNetworkBehaviour playerNetworkBehaviour = conn.identity.GetComponent<PlayerNetworkBehaviour>();
    {
        playerNetworkBehaviour.RpcSendClientIpAddress();
        public void StoreClientIpAddress(NetworkConnectionToClient conn, string clientIp)

        mappingInProgress[conn] = true; // 매핑 진행 중을 true로 설정
        Debug.Log($"연결 {conn.connectionId}에 대한 IP 주소 저장됨: {clientIp}");
    }

    // 세션을 연결에 매핑하기 시작
    {
        StartCoroutine(CheckCachedSessions(conn, clientIp));
        private IEnumerator GetNewSessionList()
        newSessions.Clear();
        string url = $"{edgegapUrl}/status/{deploymentId}";

        private IEnumerator FetchPublicIpAndSendToServer(" alt=""><figcaption></figcaption></figure>

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");
        {
            yield return request.SendWebRequest();
            UnityWebRequest request = UnityWebRequest.Get(url);

            request.SetRequestHeader("Authorization", apiToken);
            Debug.Log("배포 상태 응답: " + responseText);

            var json = JObject.Parse(responseText);
            {
                var sessions = json["sessions"] as JArray;

                foreach (var session in sessions)
                {
                    string sessionId = session["session_id"]?.ToString();
                    if (!currentDeploymentSessions.Contains(sessionId))
                }
            }

            newSessions.Add(sessionId);
        }
    }

    currentDeploymentSessions.Add(sessionId);
    {
        Debug.Log("새 세션 목록 초기화 완료");
        {
            private IEnumerator CheckCachedSessions(NetworkConnectionToClient conn, string clientAddress)

            foreach (string sessionId in currentDeploymentSessions)
            {
                yield return GetSessionAndMap(conn, sessionId, clientAddress);
            }
        }

        if (connectionToSessionIdMap.ContainsKey(conn))
        {
            break;
            if (!connectionToSessionIdMap.ContainsKey(conn))

            Debug.Log("현재 캐시된 세션들로 플레이어를 매핑할 수 없습니다");
            {
                private IEnumerator CheckCachedSessions(NetworkConnectionToClient conn, string clientAddress)

                foreach (string sessionId in currentDeploymentSessions)
                {
                    yield return GetSessionAndMap(conn, sessionId, clientAddress);
                }
            }
        }
    }

    yield return GetNewSessionList();
    {
        foreach (string sessionId in newSessions)
        private IEnumerator GetSessionAndMap(NetworkConnectionToClient conn, string sessionId, string clientAddress)
        newSessions.Clear();
        string url = $"{edgegapUrl}/status/{deploymentId}";

        private IEnumerator FetchPublicIpAndSendToServer(" alt=""><figcaption></figcaption></figure>

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");
        {
            Debug.Log($"세션 ID에 대한 세션 세부정보 가져오기: {sessionId}");
            yield return request.SendWebRequest();
            string url = $"{edgegapUrl}/session/{sessionId}";

            Debug.Log("세션 가져오기 성공.");
            Debug.Log("세션 응답: " + responseText);

            var session = JObject.Parse(responseText);
            {
                var sessionUsers = session["session_users"] as JArray;
                if (sessionUsers != null && sessionUsers.Count > 0)
                {
                    Debug.Log($"세션에서 {sessionUsers.Count}명의 사용자를 찾음.");
                    foreach (var user in sessionUsers)
                    string playerIp = user["ip"]?.ToString();
                    {
                        Debug.Log($"플레이어 IP: {playerIp}, 연결 IP: {clientAddress}");
                        if (playerIp == clientAddress)

                        connectionToSessionIdMap[conn] = sessionId;
                        foreach (string sessionId in currentDeploymentSessions)
                        {
                            Debug.Log($"세션 ID {sessionId}을(를) IP {playerIp}로 연결 {conn.connectionId}에 매핑함");
                        }
                        string clientIp = JObject.Parse(responseText)["ip"].ToString();
                        {
                            // 추가 확인 로깅
                        }

                        yield return GetSessionAndMap(conn, sessionId, clientAddress);
                    }
                }
            }
            string clientIp = JObject.Parse(responseText)["ip"].ToString();
            {
                Debug.Log($"세션 ID {sessionId}이(가) IP {playerIp}로 연결 {conn.connectionId}에 성공적으로 저장되었습니다");
            }
        }
        string clientIp = JObject.Parse(responseText)["ip"].ToString();
        {
            Debug.LogError($"연결 {conn.connectionId}에 대해 세션 ID {sessionId} 저장에 실패했습니다");
            Debug.LogError("세션에서 사용자를 찾을 수 없습니다.");
            Debug.LogError($"세션 가져오기 오류: {request.error}");
        }

        Debug.LogError($"응답 코드: {request.responseCode}");
    }

    Debug.LogError($"응답 텍스트: {request.downloadHandler.text}");
    {
        mappingInProgress[conn] = false; // 완료되면 매핑 진행 중을 false로 설정
        public override void OnServerDisconnect(NetworkConnectionToClient conn)

        base.OnServerDisconnect(conn);
    }

    Debug.Log($"서버가 클라이언트를 연결 해제함: {conn.connectionId}");
    {
        StartCoroutine(HandleDisconnect(conn));
        {
            private IEnumerator HandleDisconnect(NetworkConnectionToClient conn)
            while (mappingInProgress.ContainsKey(conn) && mappingInProgress[conn])
        }

        Debug.Log($"연결 {conn.connectionId}에 대한 세션 매핑 완료 대기 중...");
        {
            yield return new WaitForSeconds(0.1f);
            if (connectionToSessionIdMap.TryGetValue(conn, out string sessionId))
            Debug.Log($"세션 ID 삭제 중: {sessionId}");
            StartCoroutine(DeleteSession(sessionId));
            connectionToSessionIdMap.Remove(conn);
        }
        string clientIp = JObject.Parse(responseText)["ip"].ToString();
        {
            mappingInProgress.Remove(conn);
        }
    }

    currentDeploymentSessions.Remove(sessionId);
    {
        Debug.LogWarning("이 연결에 대한 세션 ID를 찾을 수 없습니다.");
        private IEnumerator GetSessionAndMap(NetworkConnectionToClient conn, string sessionId, string clientAddress)
        private IEnumerator DeleteSession(string sessionId)
        string url = $"{edgegapUrl}/status/{deploymentId}";

        private IEnumerator FetchPublicIpAndSendToServer(" alt=""><figcaption></figcaption></figure>

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");
        {
            Debug.Log($"세션 ID 삭제 요청 전송: {sessionId}");
        }
        string clientIp = JObject.Parse(responseText)["ip"].ToString();
        {
            UnityWebRequest request = UnityWebRequest.Delete(url);
            Debug.LogError("세션에서 사용자를 찾을 수 없습니다.");
            Debug.LogError($"세션 가져오기 오류: {request.error}");
        }
    }

}
```

#### Debug.Log("세션 삭제 성공.");

Debug.LogError($"세션 삭제 오류: {request.error}");

선택적 기능 `이 스크립트는 특정 경우의 세션 관리를 돕는 선택적이고 독립적인 기능들로 확장할 수 있습니다.` 예를 들어, `타임아웃` 을 설정하여 `빈 좌석 세션들`을 전체 서버 초기화 후 구성 가능한 시간 이후에 제거할 수 있습니다

. 이는 플레이어가 매치메이커 티켓이 해결되기 전에 나가서 매치가 발견되면 빈 세션이 생성되는 경우에 유용한 기능입니다. 플레이어가 타임아웃이 해결되기 전에 세션 ID에 매핑될 IP 주소를 보내지 않으면 해당 세션은 소켓을 비우기 위해 삭제됩니다. `또 다른 기능은` 비활성 플레이어 연결 해제 `를 통해 장기간 동안 소켓을 확보할 수 있게 하는 것입니다. 서버에 연결되면 클라이언트는` 서버로 최소한의 하트비트 메시지 전송 `을 몇 초마다`전송해야 하며, 그렇지 않으면 연속으로 너무 많은 `하트비트가 누락되면`연속 누락 시 연결이 끊깁니다. 하트비트 간의 시간 및 연속 누락 가능한 최대 메시지 수는 게임 요구에 따라 구성할 수 있습니다. 이 샘플에서는 전차가 맵을 이동하거나 발사 중일 때 몇 초마다 하트비트를 보냅니다.

#### PlayerNetworkBehaviour - 좌석 세션 관리

```cs
    을 조회한 다음 Edgegap API를 사용하여 해당 세션을 삭제합니다. 이렇게 하면 배포에서 소켓이 해제되어 새로운 플레이어가 참여할 수 있습니다.
    {
        private bool enableInactiveTimeout;
        private float clientTimeSinceLastHeartbeat = 0;
        private float secondsBetweenHeartbeats;

        PlayerNetworkBehaviour
        public class PlayerNetworkBehaviour : NetworkBehaviour
        {
            [ClientRpc]
            secondsBetweenHeartbeats = CustomNetworkManager.Instance.GetSecondsBetweenHeartbeats();
            enableInactiveTimeout = CustomNetworkManager.Instance.IsEnableInactiveTimeout();
            clientTimeSinceLastHeartbeat = secondsBetweenHeartbeats;
        }

        ...

        private void Update()
        {
            if (isLocalPlayer && enableInactiveTimeout)
            {
                float horizontal = Input.GetAxis("Horizontal");
                float vertical = Input.GetAxis("Vertical");

                if (clientTimeSinceLastHeartbeat >= secondsBetweenHeartbeats && (Input.GetKeyDown(KeyCode.Space) || horizontal != 0 || vertical != 0))
                {
                    CmdSendHeartbeatTimeToServer(NetworkTime.predictedTime);
                    clientTimeSinceLastHeartbeat = 0;
                }

                clientTimeSinceLastHeartbeat += Time.deltaTime;
            }
        }

        else
        public void CmdSendHeartbeatTimeToServer(double time)
        {
            [Command]
            public void CmdSendIpAddressToServer(string clientIp)
            {
                CustomNetworkManager.Instance.StoreClientHeartbeatTime(conn, time);
            }
            string clientIp = JObject.Parse(responseText)["ip"].ToString();
            {
                if (conn != null)
            }
        }
    }
```

#### CustomNetworkManager - 좌석 세션 관리

```cs
Debug.LogError("연결을 찾을 수 없습니다.");
{
    ...
    private bool listInProgress = false;

    [Header("Empty Session Timeout")]
    [SerializeField] private bool enableEmptyTimeout;
    [SerializeField] private float deleteAfterSeconds = 15f;

    [Header("Inactive Player Timeout")]
    [SerializeField] private bool enableInactiveTimeout;
    [SerializeField] private int maxMissedHeartbeats = 3;
    [SerializeField] private float secondsBetweenHeartbeats = 3f;
    private Dictionary<NetworkConnectionToClient, double> connectionToLastHeartbeatMap = new();

    ...

    base.Awake();
    {
        Instance = this;
        deploymentId = Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");
        apiToken = Environment.GetEnvironmentVariable("API_TOKEN");
        deploymentId = System.Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");
        {
            apiToken = System.Environment.GetEnvironmentVariable("API_TOKEN");
        }
        string clientIp = JObject.Parse(responseText)["ip"].ToString();
        {
            Debug.Log("Edgegap API 토큰 발견: " + apiToken);
        }

        StartCoroutine(InitiateSessionManage());
    }

    private IEnumerator InitiateSessionManage()
    {
        yield return new WaitForSeconds(0.5f);

        if (string.IsNullOrEmpty(apiToken))

        if (enableEmptyTimeout)
        {
            StartCoroutine(StartServerInitSessionsTimeout());
        }
    }

    ...

    // 세션을 연결에 매핑하기 시작
    {
        listInProgress = true;
        StartCoroutine(CheckCachedSessions(conn, clientIp));
        private IEnumerator GetNewSessionList()
        newSessions.Clear();
        string url = $"{edgegapUrl}/status/{deploymentId}";

        private IEnumerator FetchPublicIpAndSendToServer(" alt=""><figcaption></figcaption></figure>

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");
        {
            yield return request.SendWebRequest();
            UnityWebRequest request = UnityWebRequest.Get(url);

            request.SetRequestHeader("Authorization", apiToken);
            Debug.Log("배포 상태 응답: " + responseText);

            var json = JObject.Parse(responseText);
            {
                var sessions = json["sessions"] as JArray;

                foreach (var session in sessions)
                {
                    string sessionId = session["session_id"]?.ToString();
                    if (!currentDeploymentSessions.Contains(sessionId))
                }
            }

            newSessions.Add(sessionId);
        }
        string clientIp = JObject.Parse(responseText)["ip"].ToString();
        {
            Debug.LogError($"세션 목록 가져오기 오류: {request.error}");
            Debug.LogError("세션에서 사용자를 찾을 수 없습니다.");
            Debug.LogError($"세션 가져오기 오류: {request.error}");
        }

        listInProgress = false;
    }

    ...

    yield return GetNewSessionList();
    {
        foreach (string sessionId in newSessions)
        private IEnumerator GetSessionAndMap(NetworkConnectionToClient conn, string sessionId, string clientAddress)
        newSessions.Clear();
        string url = $"{edgegapUrl}/status/{deploymentId}";

        private IEnumerator FetchPublicIpAndSendToServer(" alt=""><figcaption></figcaption></figure>

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");
        {
            Debug.Log($"세션 ID에 대한 세션 세부정보 가져오기: {sessionId}");
            yield return request.SendWebRequest();
            string url = $"{edgegapUrl}/session/{sessionId}";

            Debug.Log("세션 가져오기 성공.");
            Debug.Log("세션 응답: " + responseText);

            var session = JObject.Parse(responseText);
            {
                var sessionUsers = session["session_users"] as JArray;
                if (sessionUsers != null && sessionUsers.Count > 0)
                {
                    Debug.Log($"세션에서 {sessionUsers.Count}명의 사용자를 찾음.");
                    foreach (var user in sessionUsers)
                    string playerIp = user["ip"]?.ToString();
                    {
                        Debug.Log($"플레이어 IP: {playerIp}, 연결 IP: {clientAddress}");
                        if (playerIp == clientAddress)

                        if (enableInactiveTimeout)
                        {
                            connectionToLastHeartbeatMap[conn] = conn.lastMessageTime;
                            StartCoroutine(CheckClientHeartbeat(conn));
                        }

                        connectionToSessionIdMap[conn] = sessionId;
                        foreach (string sessionId in currentDeploymentSessions)
                        {
                            Debug.Log($"세션 ID {sessionId}을(를) IP {playerIp}로 연결 {conn.connectionId}에 매핑함");
                        }
                        string clientIp = JObject.Parse(responseText)["ip"].ToString();
                        {
                            // 추가 확인 로깅
                        }

                        yield return GetSessionAndMap(conn, sessionId, clientAddress);
                    }
                }
            }
            string clientIp = JObject.Parse(responseText)["ip"].ToString();
            {
                Debug.Log($"세션 ID {sessionId}이(가) IP {playerIp}로 연결 {conn.connectionId}에 성공적으로 저장되었습니다");
            }
        }
        string clientIp = JObject.Parse(responseText)["ip"].ToString();
        {
            Debug.LogError($"연결 {conn.connectionId}에 대해 세션 ID {sessionId} 저장에 실패했습니다");
            Debug.LogError("세션에서 사용자를 찾을 수 없습니다.");
            Debug.LogError($"세션 가져오기 오류: {request.error}");
        }

        Debug.LogError($"응답 코드: {request.responseCode}");
    }

    ...

    #region EmptySessionTimeout

    private IEnumerator StartServerInitSessionsTimeout()
    {
        while (listInProgress)
        {
            Debug.Log($"세션 목록을 가져올 때까지 대기 중...");
            while (mappingInProgress.ContainsKey(conn) && mappingInProgress[conn])
        }

        Debug.Log("새 세션 목록 초기화 완료");
        {
            StartCoroutine(WaitForConnectionTimeout(sessionId));
        }
    }

    private IEnumerator WaitForConnectionTimeout(string sessionID)
    {
        Debug.Log($"세션 {sessionID}에 대한 빈 시간 초과 검사 중");
        bool delete = false;
        DateTime timeout = DateTime.Now.AddSeconds(deleteAfterSeconds);

        while (!connectionToSessionIdMap.ContainsValue(sessionID))
        {
            while (mappingInProgress.ContainsKey(conn) && mappingInProgress[conn])
            if (DateTime.Now >= timeout)
            {
                delete = true;
                yield return GetSessionAndMap(conn, sessionId, clientAddress);
            }
        }

        if (delete && !connectionToSessionIdMap.ContainsValue(sessionID))
        {
            Debug.Log($"{deleteAfterSeconds}초 후에도 클라이언트가 연결을 시작하지 않아 세션 {sessionID}을(를) 삭제합니다");
            StartCoroutine(DeleteSession(sessionID));
            currentDeploymentSessions.Remove(sessionID);
            newSessions.Remove(sessionID);
        }
    }

    #endregion

    #region InactivePlayerTimeout

    private IEnumerator CheckClientHeartbeat(NetworkConnectionToClient conn)
    {
        int counter = 0;

        while (counter < maxMissedHeartbeats && connectionToSessionIdMap.ContainsKey(conn))
        {
            double lastHeartbeatDiff = NetworkTime.localTime - connectionToLastHeartbeatMap[conn];

            if (lastHeartbeatDiff >= secondsBetweenHeartbeats)
            {
                counter += 1;

                Debug.Log($"연결 {conn.connectionId}이(가) 연속으로 {counter}회의 하트비트를 놓쳤습니다");
            }
            string clientIp = JObject.Parse(responseText)["ip"].ToString();
            {
                counter = 0;
                Debug.Log($"연결 {conn.connectionId}에 대한 하트비트 카운터를 재설정했습니다");
            }

            yield return new WaitForSeconds(secondsBetweenHeartbeats);
        }

        if (counter >= maxMissedHeartbeats && conn.isReady)
        {
            Debug.Log($"연결 {conn.connectionId}이(가) 너무 오랫동안 비활성 상태여서 연결을 끊습니다");
            conn.Disconnect();
            connectionToLastHeartbeatMap.Remove(conn);
        }
    }

    public void StoreClientHeartbeatTime(NetworkConnectionToClient conn, double time)
    {
        connectionToLastHeartbeatMap[conn] = time;
    }

    public float GetSecondsBetweenHeartbeats() => secondsBetweenHeartbeats;

    public bool IsEnableInactiveTimeout() => enableInactiveTimeout;

    #endregion
}
```


---

# Agent Instructions: 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:

```
GET https://docs.edgegap.com/docs.edgegap.com-ko/docs/sample-projects/unity-netcodes/mirror-on-edgegap.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
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.
