Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(78일차) - 모바일 틱택토 게임 구현 (6), ParrelSync를 활용한 멀티플레이 확인

by 독기품은토끼 2025. 9. 9.
✅ 오늘의 학습 목표
1. 모바일 틱택토 게임 마무리
- 소켓 연결 및 이벤트 콜백
- 멀티 플레이 게임 로직 구현
- 로컬 클릭과 네트워크 클릭 구분
- ParrelSync를 활용한 멀티플레이 확인

1. 소켓 연결 및 이벤트 콜백 (클라이언트)

어제는 WebSocket 연결과 서버 이벤트명에 대응하는 콜백 등록까지 끝냈고

오늘은 각 콜백이 받은 데이터(페이로드)를 GameLogic/UI 갱신으로 이어지게 만들어 줄 것이다.

using System;
using System.Net.Sockets;
using Newtonsoft.Json;
using SocketIOClient;
using UnityEngine;

// joinRoom/createRoom 이벤트 전달할 때 전달되는 정보의 타입
public class RoomData
{
    [JsonProperty("roomId")]
    public string roomId { get; set; }
}

// 상대방이 둔 마커 위치
public class BlockData
{
    [JsonProperty("blockIndex")]
    public int blockIndex { get; set; }
}

public class MultiplayController : IDisposable
{
    private SocketIOUnity _socket;

    private Action<Constants.MultiplayControllerState, string> _onMultiplayStateChanged; // Room 상태 변화에 따른 동작을 할당하는 변수
    public Action<int> onBlockDataChanged; // 게임 진행 상황에서 Marker의 위치를 업데이트 하는 변수

    public MultiplayController(Action<Constants.MultiplayControllerState, string> onMultiplayStateChanged)
    {
        // 서버에서 이벤트가 발생하면 처리할 메서드를 _onMultiplayStateChanged에 등록
        _onMultiplayStateChanged = onMultiplayStateChanged;

        // Socket.io 클라이언트 초기화
        var uri = new Uri(Constants.SocketServerURL);
        _socket = new SocketIOUnity(uri, new SocketIOOptions
        {
            Transport = SocketIOClient.Transport.TransportProtocol.WebSocket,
            Reconnection = false,          // 자동 재접속 끄기
            ReconnectionAttempts = 0       // 혹시 모를 시도 수 0
        });

        _socket.On("createRoom", CreateRoom);
        _socket.On("joinRoom", JoinRoom);
        _socket.On("startGame", StartGame);
        _socket.On("exitRoom", ExitRoom);
        _socket.On("endGame", EndGame);
        _socket.On("doOpponent", DoOpponent);
        _socket.Connect(); // 서버 접속
    }

    private void CreateRoom(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();

        UnityThread.executeInUpdate(() =>
        {
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.CreateRoom,
                data.roomId);
        });
    }

    private void JoinRoom(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();
        UnityThread.executeInUpdate(() =>
        {
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.JoinRoom,
                data.roomId);
        });
    }

    private void StartGame(SocketIOResponse response)
    {
        var data = response.GetValue<RoomData>();
        UnityThread.executeInUpdate(() =>
        {
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.StartGame,
                data.roomId);
        });
    }

    private void ExitRoom(SocketIOResponse response)
    {
        UnityThread.executeInUpdate(() =>
        {
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.ExitRoom, null);
        });
    }

    private void EndGame(SocketIOResponse response)
    {
        UnityThread.executeInUpdate(() =>
        {
            _onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.EndGame, null);
        });
    }

    private void DoOpponent(SocketIOResponse response)
    {
        var data = response.GetValue<BlockData>();
        UnityThread.executeInUpdate(() =>
        {
            onBlockDataChanged?.Invoke(data.blockIndex);
        });
    }

    #region Client => Server
    public void LeaveRoom(string roomId) // Room을 나올 때 호출하는 메서드
    {
        _socket.Emit("leaveRoom", new { roomId });
    }

    public void DoPlayer(string roomId, int blockIndex) // 플레이어가 Marker를 두면 호출하는 메서드
    {
        _socket.Emit("doPlayer", new { roomId, blockIndex });
    }
    #endregion

    public void Dispose()
    {
        if (_socket != null)
        {
            _socket.Disconnect(); // 서버 연결 끊고
            _socket.Dispose(); // 소켓 삭제
            _socket = null;
        }
    }
}

 

[생성자 파라미터]

public MultiplayController(Action<Constants.MultiplayControllerState, string> onMultiplayStateChanged)

서버에서는 create, join, start, exit, end 등 여러 이벤트를 클라이언트에게 보낸다.

클라이언트는 각 이벤트 상태에 따라 다른 로직이 실행되도록 구현해야 하기 때문에 상태와 string 타입의 roomId를 파라미터로 갖는다.

상태만 가지고 와도 되지 않나? 싶지만 멀티플레이의 경우 

roomId를 갖고와서 정해진 공간 내의 정해진 인원만 수용하도록 하기 위함이다.

 

[UnityThread]

Socket.IO 콜백은 별도 스레드/시점에서 호출될 수 있다.

그런데 Unity의 GameObject, UI, State 전환은 메인 스레드에서만 안전하다.

(오브젝트 생성/파괴, Transform 변경, UI 갱신 등 메인 루프(Update)에서만 보장됨 → 소켓 콜백에서 바로 UI를 건들면 무시되거나 로그만 찍혀버림)

그래서 메인 스레드가 작업할 수 있도록 마샬링 해주는 역할이 바로 UnityTread.executeInUpdate()이다.

해당 작업을 해주지 않으면 플레이어가 두는 수가 UI에 표시되지 않는다.

 

[IDisposable]

Socket같은 운영체제 자원은 GC가 언제 쓰레기값을 버려야 할지 난감해한다.

그래서 이 종료/삭제 타이밍을 주기위해 IDisposable의 Dispose() 메서드를 활용한다.

  • 서버와의 연결 끊기(Disconnect)
  • 소켓 객체 자체 해제(Dispose)
  • 참조 제거(재진입 방지)

이 작업을 해주지 않으면 소켓 연결이 남아있어서 포트/스레드를 붙잡아 리소스 누수가 발생할 수 있고

이벤트 핸들러가 남아있어 중복 콜백, 유령 로그 같은 원치 않은 작업이 발생할 수 있다.

 

[소켓 옵션]

_socket = new SocketIOUnity(uri, new SocketIOOptions {
    Transport = TransportProtocol.WebSocket,
    Reconnection = false,
    ReconnectionAttempts = 0
});
  • WebSocket 고정: 실시간 상호작용에 적합
  • 자동 재접속 끔: 의도치 않은 재참여/이벤트 중복을 피하기 위함

 

[주의할 점]

public void DoPlayer(string roomId, int blockIndex) {
    _socket.Emit("doPlayer", new { roomId, blockIndex });
}

서버는 blockIndex라는 이름을 키로 받고 있는데

맨 처음 구현할 때 posiotion이라는 이름으로 구현을 하여 원하는 로직이 제대로 실행되지 않았다.

함수명만 동일하면 될 줄 알았는데 파라미터명까지 동일해야 했던 것이다..

 

Socket.IO는 이벤트 이름과 JSON 키 이름으로 통신하기 때문에

이벤트 이름이 다르면 콜백 자체가 안 불리고

JSON키가 다르면 서버/클라에서 해당 필드가 undefined/default가 되어 동작이 실패한다.

 

[Emit과 new 키워드]

Emit은 params object[] 가변 인자를 갖는다.

Emit("event", a, b, c) 처럼 여러 인자를 보낼 수도 있고

emit("event", someObject)처럼 하나의 객체만 보낼 수도 있다.

socket.on('doPlayer', function(playerInfo) {
  var roomId = playerInfo.roomId;
  var blockIndex = playerInfo.blockIndex;
});

 

서버에서는 하나의 객체를 받고 그 안에서 roomId와 blockIndex라는 키 이름으로 값을 꺼낸다.

만약 클라이언트 쪽에서 Emit("doPlayer", roomId, blockIndex)로 보내면 서버는 이벤트 하나와 인자 2개를 받게 되어 충돌이 발생된다.

그렇다고 Emit("doPlayer", {roomId, blockIndex}) 이렇게 보내버리면 이건 그냥 C# 문법 에러이다.

  • 배열 초기화 → 변수 선언과 함께 int[] a = { 1, 2 };처럼 new를 생략할 수 있음
  • 인자 →  Emit("e", new[] { 1, 2 })처럼 new[] { … }가 반드시 필요
  • 객체/컬렉션 초기화 → (new List<int> { 1, 2 }, new Payload { Prop = … })는 늘 new가 붙음

 

2. 게임 로직 + 턴 관리

이제 멀티플레이의 게임 진행을 처리해줄 것이다.

누구의 턴인지를 관리하고 서버의 이벤트 (create, join, start .. 등)에 따라 어느 슬롯이 로컬/원격인지 배치해 줄 것이다.

using System;
using UnityEngine;

public class GameLogic : IDisposable
{
    private MultiplayController _multiplayController; // 멀티플레이 기능
    private string _roomId;

    public GameLogic(BlockController blockController, Constants.GameType gameType)
    {
        // Game Type 초기화
        switch (gameType)
        {
            case Constants.GameType.MultiPlay:
                _multiplayController = new MultiplayController((state, roomId) =>
                {
                    _roomId = roomId;
                    switch (state)
                    {
                        case Constants.MultiplayControllerState.CreateRoom:
                            Debug.Log("## Create Room ##");
                            // TODO : 대기 화면 UI 표시
                            break;
                        case Constants.MultiplayControllerState.JoinRoom:
                            Debug.Log("## Join Room ##");
                            firstPlayerState = new MultiPlayerState(true, _multiplayController);
                            secondPlayerState = new PlayerState(false, _multiplayController, _roomId);
                            SetState(firstPlayerState);
                            break;
                        case Constants.MultiplayControllerState.StartGame:
                            Debug.Log("## Start Game ##");
                            firstPlayerState = new PlayerState(true, _multiplayController, _roomId);
                            secondPlayerState = new MultiPlayerState(false, _multiplayController);
                            SetState(firstPlayerState);
                            break;
                        case Constants.MultiplayControllerState.ExitRoom:
                            Debug.Log("## Exit Room ##");
                            // TODO : 팝업 띄우고 Main 화면으로 이동
                            break;
                        case Constants.MultiplayControllerState.EndGame:
                            Debug.Log("## End Game ##");
                            // TODO : 팝업 띄우고 Main 화면으로 이동
                            break;
                    }
                });
                break;
        }
    }

    public void Dispose()
    {
        _multiplayController?.LeaveRoom(_roomId);
        _multiplayController?.Dispose();
    }
}

 

  • 멀티플레이일 때 서버 통역사(= MultiplayController)를 만들고
  • 서버가 보내는 방 이벤트(create/join/start/exit/end)를 콜백으로 받아
  • 현재 턴의 상태(로컬 입력을 받을지, 원격 입력을 받을지)를 SetState(...)로 교체해 가며 게임을 진행
  • 씬을 나가거나 게임을 닫을 때 방 나가기 + 소켓 정리까지 책임짐

 

[joinRoom = 참가자 기준 = 상대가 먼저]

[내 화면 기준]
1P = 상대(원격)  → MultiPlayerState(true)
2P = 나  (로컬)  → PlayerState(false)
시작 상태 = firstPlayerState(=원격 차례)

 

참가자는 후입장이라 먼저 들어와 기다리던 상대(방장)가 선공한다.

따라서 첫 턴은 상대(원격)가 둔다.

 

[startGame = 방장 기준 = 내가 먼저]

[내 화면 기준]
1P = 나  (로컬)  → PlayerState(true)
2P = 상대(원격)  → MultiPlayerState(false)
시작 상태 = firstPlayerState(=내 차례)

방장은 선입장자이면서 선공이다.

첫 턴은 내가 클릭해야하니 로컬 입력 델리게이트가 연결되는 PlayerState(true) 값을 받고

원격(상대방)은 대기한다.

 

3. 플레이어 상태

public class PlayerState : BasePlayerState
{
    private MultiplayController _multiplayController;
    private string _roomId;
    private bool _isMultiplay;

    public PlayerState(bool isFirstPlayer) // 싱글 플레이용 생성자
    {
        _isFirstPlayer = isFirstPlayer;
        _playerType = _isFirstPlayer ?
            Constants.PlayerType.PlayerA : Constants.PlayerType.PlayerB;
        _isMultiplay = false;
    }

    public PlayerState(bool isFirstPlayer, MultiplayController multiplayController, string roomId) // 멀티 플레이용 생성자
        :this(isFirstPlayer)
    {
        // :this(isFirstPlayer)가 아래 주석문을 대신 함
        //_isFirstPlayer = isFirstPlayer;
        //_playerType = _isFirstPlayer ?
        //    Constants.PlayerType.PlayerA : Constants.PlayerType.PlayerB;

        _multiplayController = multiplayController;
        _roomId = roomId;
        _isMultiplay = true;
    }

    public override void HandleMove(GameLogic gameLogic, int row, int col)
    {
        ProcessMove(gameLogic, _playerType, row, col);

        if (_isMultiplay) // 서버에 Marker 정보 전달
        {
            _multiplayController.DoPlayer(_roomId, row * Constants.BlockColumnCount + col);
        }
    }
}

 

[두개의 생성자]

  • 싱글/로컬만: PlayerState(bool isFirstPlayer)
  • 멀티: PlayerState(bool isFirstPlayer, MultiplayController, roomId)
  • :this(isFirstPlayer) 명령어를 입력하면 이미 생성된 생성자를 사용한 후 추가 생성자를 사용하겠다는 뜻이다.

공통 룰 엔진을 타면서 유효성 검사, 보드(O/X) 갱신, 승패 확인, 다음 턴 전환을 하면서

멀티플레이인 경우에만 같은 수를 서버에게 알려 상대방에게도 보이도록 구현해 주었다.

  • 같은 수를 서버에도 알림 → 서버가 방의 상대에게 doOpponent 이벤트로 중계
  • 상대 클라이언트는 MultiplayController.DoOpponent → onBlockDataChanged → MultiPlayerState가 받음 → 같은 ProcessMove로 보드 반영
  • 그래서 로컬/원격 모두 최종적으로 동일한 파이프라인을 갖는다.

 

using UnityEngine;

public class MultiPlayerState : BasePlayerState
{
    private Constants.PlayerType _playerType;
    private bool _isFirstPlayer;
    private MultiplayController _multiplayController;

    public MultiPlayerState(bool isFirstPlayer, MultiplayController multiplayController)
    {
        _isFirstPlayer = isFirstPlayer;
        _multiplayController = multiplayController;
        _playerType = _isFirstPlayer ? Constants.PlayerType.PlayerA : Constants.PlayerType.PlayerB;
    }

    public override void OnEnter(GameLogic gameLogic)
    {
        _multiplayController.onBlockDataChanged = blockIndex =>
        {
            var row = blockIndex / Constants.BlockColumnCount;
            var col = blockIndex % Constants.BlockColumnCount;
            //UnityThread.executeInUpdate(() =>
            //{
            //    HandleMove(gameLogic, row, col);
            //});
            HandleMove(gameLogic, row, col);
        };
    }

    public override void OnExit(GameLogic gameLogic)
    {
        _multiplayController.onBlockDataChanged = null;
    }

    public override void HandleMove(GameLogic gameLogic, int row, int col)
    {
        ProcessMove(gameLogic, _playerType, row, col);
    }

    protected override void HandleNextTurn(GameLogic gameLogic)
    {
        if (_isFirstPlayer)
            gameLogic.SetState(gameLogic.secondPlayerState);
        else
            gameLogic.SetState(gameLogic.firstPlayerState);
    }
}
    • _isFirstPlayer : 이 상태가 1P인지(선공) 여부
    • _playerType : PlayerA / PlayerB 중 누구인지
    • 생성자에서 isFirstPlayer에 따라 _playerType을 A/B로 결정합니다. (선공/후공 고정)
_multiplayController.onBlockDataChanged = blockIndex => {
    var row = blockIndex / Constants.BlockColumnCount;
    var col = blockIndex % Constants.BlockColumnCount;
    HandleMove(gameLogic, row, col);
};
  • 서버에서 doOpponent가 오면 onBlockDataChanged 호출
  • 여기서 index → (row, col) 로 변환한 뒤 HandleMove(= 규칙 엔진으로 위임) 호출
  • 주석으로 남겨둔 UnityThread.executeInUpdate는 메인 스레드에서 실행시키는 래퍼인데
    컨트롤러 쪽에서 이미 메인 스레드로 마샬링하였으니 여기선 생략 가능
_multiplayController.onBlockDataChanged = null;
  • 상태를 떠날 때 반드시 해제
  • 해제하지 않으면 다음에 다시 들어왔을 때 중복 호출/메모리 붙잡힘 같은 문제 발생

 

4. 멀티 플레이 확인

🥕 예행 작업
1. ParrelSync 패키지 설치
 

GitHub - VeriorPies/ParrelSync: (Unity3D) Test multiplayer without building

(Unity3D) Test multiplayer without building. Contribute to VeriorPies/ParrelSync development by creating an account on GitHub.

github.com

 

나는 다행히.. 정상적으로 작동해 준다

이 멀티플레이 부분은 해봐도 해봐도 너무 어렵고 이걸 어떻게 내 걸로 만들지싶다ㅠㅠ..

오늘 정리 끄읏..