# Mirror

### 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](https://docs.edgegap.com/docs.edgegap.com-ko/docs/sample-projects/unity-netcodes/broken-reference).

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

#### 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 레지스트리를 사용하려면 [이 문서](https://docs.edgegap.com/docs.edgegap.com-ko/learn/advanced-features/edgegap-container-registry) 를 참고하세요. 다른 개인 레지스트리를 사용할 수도 있습니다.

### 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="https://1562312210-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FsR0dHSFv9ymoC0DO5G8J%2Fuploads%2Fgit-blob-583d6356cdf560e89fc338bde5c125a81c77a9ee%2Fmirror-app-creation.png?alt=media" 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
}
```
