처음 만든 「오늘 누가 쏘냐?」는 폰 하나를 가운데 놓고 돌려 가며 하는 게임이었다. 9월 21일, 여럿이 실제로 해 보니 폰 하나에 모이는 방식의 한계가 보였다. 사람이 많으면 기기 하나로는 부족하다.

바로 블루투스나 Wi-Fi 같은 근거리 통신을 쓸 수 없을지 검토했다. 결론은 짧았다. 배포는 Android 앱만 한다. 그렇다면 같은 네트워크 안에서 소켓으로 직접 붙는 LAN 방식이 가장 단순하다. 외부 서버를 두지 않고, 방을 만든 폰(방장)이 서버 역할을 한다. 다 같이 같은 Wi-Fi에 붙거나, Wi-Fi가 없으면 방장 폰의 핫스팟에 붙으면 된다.

브라우저(WebGL) 버전은 소켓 서버도 브로드캐스트도 쓸 수 없어서 「같이 하기」가 동작하지 않는다. 코드는 컴파일만 되게 두었다.

이 글에서는 연결 계층과 방 찾기까지를 정리한다. 방장이 판정하는 구조와 재접속은 다음 글로 넘긴다.

구조: 방장 폰 하나가 서버

포트는 두 개다.

포트 용도
TCP 47801 게임 연결. 방장 폰이 TcpListener로 받는다
UDP 47800 방 알림(비콘). 방장 폰이 1초마다 브로드캐스트한다

클래스는 역할별로 나눴다.

  • LanHost — 방장. TCP 서버와 비콘
  • LanClient — 게스트. 방장으로 가는 TCP 연결 하나
  • LanDiscovery — 게스트. UDP 47800을 열고 비콘을 모아 방 목록을 만든다
  • LanScanner — 게스트. 비콘이 안 올 때 쓰는 서브넷 스캔
  • NetProtocol — 프레임, 메시지 규약, 주소 계산
  • NetPump — 소켓 스레드에서 메인 스레드로 넘기는 펌프
  • Room — 세션 상태와 이벤트

비콘은 전역 브로드캐스트(255.255.255.255) 하나만 보내지 않고, 인터페이스마다 서브넷 브로드캐스트 주소를 계산해 같이 보낸다. 대상 목록은 5초마다 다시 구한다. 비콘 내용은 방 이름, 인원, 정원, 규약 버전이 든 작은 JSON이다.

while (_running)
{
    if (targets == null || n++ % 5 == 0) targets = NetProtocol.BroadcastTargets(); // 5초마다 주소 갱신
    var data = Encoding.UTF8.GetBytes(_beaconJson);
    foreach (var ep in targets)
    {
        try { _beacon.Send(data, data.Length, ep); }
        catch { }
    }
    Thread.Sleep(1000);
}

메시지: 길이 4바이트 + JSON

TCP는 스트림이라 메시지 경계가 없다. 그래서 프레임을 길이 4바이트(리틀엔디언) + UTF-8 JSON으로 정했다. JSON은 { t: 종류, p: 본문 JSON } 모양의 봉투(Envelope) 하나다. 본문은 종류별 [Serializable] 클래스를 JsonUtility로 직렬화한 문자열이다.

public static byte[] Pack(string type, object payload = null)
{
    var env = new Envelope { t = type, p = payload == null ? "" : JsonUtility.ToJson(payload) };
    var body = Encoding.UTF8.GetBytes(JsonUtility.ToJson(env));
    var frame = new byte[4 + body.Length];
    int len = body.Length;
    frame[0] = (byte)len;
    frame[1] = (byte)(len >> 8);
    frame[2] = (byte)(len >> 16);
    frame[3] = (byte)(len >> 24);
    Buffer.BlockCopy(body, 0, frame, 4, len);
    return frame;
}

읽는 쪽은 4바이트를 다 읽고, 길이가 0 이하이거나 64KB(MaxFrame)를 넘으면 끊긴 것으로 본다. 그다음 본문을 길이만큼 다 읽어 문자열로 돌려준다. Stream.Read는 요청한 만큼 한 번에 채워 준다는 보장이 없으므로 ReadExact로 반복해서 읽는다.

입장할 때는 게스트가 join에 규약 버전(NetProtocol.Version)을 실어 보내고, 방장은 버전이 다르면 「앱 버전이 달라요」로 거절한다. 그 밖에 2초마다 ping을 보내고, 10초 동안 아무것도 안 오면 끊긴 것으로 본다.

소켓 스레드는 문자열만, 나머지는 메인 스레드에서

JsonUtility와 대부분의 Unity API는 메인 스레드에서만 써야 한다. 그래서 규칙을 하나 정했다. 소켓 스레드는 바이트를 읽어 문자열로 만드는 데까지만 한다. JSON 파싱과 이벤트 호출은 NetPump에 넘겨 다음 Update에서 실행한다.

public class NetPump : MonoBehaviour
{
    static readonly ConcurrentQueue<Action> _queue = new ConcurrentQueue<Action>();

    /// <summary>아무 스레드에서나 호출 가능. 다음 Update 에서 실행된다.</summary>
    public static void Post(Action action)
    {
        if (action != null) _queue.Enqueue(action);
    }

    void Update()
    {
        Action a;
        while (_queue.TryDequeue(out a))
        {
            try { a(); }
            catch (Exception e) { Debug.LogException(e); }
        }
        // ...
    }
}

방장의 읽기 루프는 이렇게 짧다.

while (_running)
{
    string json;
    try { json = NetProtocol.ReadFrame(conn.Stream); }
    catch { json = null; }
    if (json == null) break;
    NetPump.Post(() => Handle(conn, json));
}
NetPump.Post(() => Drop(conn));

덕분에 LanHost, LanClient, Room이 내는 이벤트는 모두 메인 스레드에서 불린다. 게임 화면 코드는 스레드를 신경 쓰지 않는다. 보낼 프레임(Pack)도 JsonUtility를 쓰므로 메인 스레드에서 만든다. 스캔 스레드가 보낼 hello 프레임은 스캔을 시작할 때 메인 스레드에서 미리 만들어 넘긴다.

Android: MulticastLock과 권한

Android 기기 중에는 Wi-Fi 칩이 브로드캐스트 패킷을 걸러내는 것이 있다. 그래서 방 찾기 화면이 열려 있는 동안 WifiManager의 MulticastLock을 잡는다. Unity에서는 AndroidJavaObject로 Java 쪽을 부른다.

using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = player.GetStatic<AndroidJavaObject>("currentActivity"))
using (var wifi = activity.Call<AndroidJavaObject>("getSystemService", "wifi"))
{
    _multicastLock = wifi.Call<AndroidJavaObject>("createMulticastLock", "whopays");
    _multicastLock.Call("setReferenceCounted", false);
    _multicastLock.Call("acquire");
}

이 코드 때문에 첫 Android 빌드가 실패했다. AndroidJavaObject는 내장 모듈 com.unity.modules.androidjni에 들어 있는데, 프로젝트 Packages/manifest.json에 이 모듈이 없었다. 에디터에서는 #if UNITY_ANDROID && !UNITY_EDITOR로 감싼 코드가 컴파일되지 않으니 문제가 드러나지 않다가, Android 빌드에서야 터졌다. 모듈 한 줄을 추가하고 첫 APK 빌드에 성공했다.

권한은 빌드 때 생성되는 매니페스트에 에디터 스크립트(AndroidManifestPatcher)가 넣는다. 같이 하기에 필요한 것은 네 개다.

  • INTERNET, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE — 소켓과 주소 조회
  • CHANGE_WIFI_MULTICAST_STATE — MulticastLock

모두 설치할 때 자동으로 주어지는 일반 권한이라 실행 중에 권한 요청 창이 뜨지 않는다.

방 찾기 3단계

비콘만으로 끝나면 좋겠지만, 공유기나 기기에 따라 브로드캐스트가 걸러지는 경우가 있다. 그래서 방 찾기는 세 단계로 내려간다.

1단계: UDP 비콘. 같이 하기 화면을 열면 LanDiscovery가 UDP 47800을 열고 비콘을 기다린다. 비콘이 4초 동안 안 오는 방은 목록에서 뺀다.

2단계: 서브넷 스캔. 비콘이 2초 안에 하나도 안 오면 LanScanner가 내 주소의 앞부분(예: 192.168.0.)에 1~254를 붙여 254개 주소의 TCP 47801에 짧게 접속해 본다. 스레드 64개가 큐를 나눠 처리하고, 접속 대기는 700ms, 응답 대기는 1.5초다. 연결되면 hello를 보내고, 방장은 입장하지 않은 연결의 hello에 방 정보(info, 비콘과 같은 내용)만 돌려주고 끊는다.

// 방장: 스캔 문의는 방 정보만 알려주고 끊는다 (입장 아님)
if (env.t == Msg.Hello)
{
    conn.Send(NetProtocol.Pack(Msg.Info, _beaconMsg));
    conn.Close();
    return;
}

목록이 비어 있는 동안은 8초마다 스캔을 반복하고, 「다시 찾기」 버튼으로 직접 돌릴 수도 있다. 스캔으로 찾은 방은 비콘보다 길게 20초 동안 목록에 남긴다. 다만 공용 Wi-Fi처럼 기기끼리 통신을 막는 AP 격리 환경에서는 스캔도 막힌다.

3단계: 방 번호. 그래도 안 보이면 직접 입력한다. 처음에는 IP 주소를 통째로 입력하게 했는데, 직접 써 보니 폰으로 IP를 입력하기가 어려웠다. 그래서 방장 로비에는 주소의 마지막 숫자만 「방 번호」로 크게 보여 준다. 방장 주소가 192.168.0.146이면 방 번호는 146이다. 같은 Wi-Fi라면 앞부분은 게스트와 같으니, 게스트 쪽에서 자기 주소 앞부분에 붙이면 된다.

int n;
if (!int.TryParse(input, out n) || n < 1 || n > 254) { error = "방 번호는 1~254 사이 숫자예요"; return null; }
var mine = PrimaryAddress();
if (mine.Length == 0) { error = "내 폰의 WiFi 주소를 못 찾았어요. WiFi 연결을 확인하세요"; return null; }
return SubnetPrefix(mine) + n;

점이 들어간 입력은 전체 주소로 보고 그대로 쓴다. 이 기능과 스캔은 함께 들어갔다.

주소가 여러 개 뜨던 로비

방 번호를 만들기 전, 로비의 첫 화면은 이랬다.

초기 로비: 주소 입력 안내에 가상 어댑터 주소까지 여러 개 표시 (주소는 가림)

개발 PC를 방장으로 띄웠더니 Docker·WSL 같은 가상 어댑터 주소(172.17.0.1 등)까지 안내에 나열됐다. 폰에서는 보이지도 않는 주소다. 그래서 안내에는 대표 주소 하나만 보여 주기로 했다. 고르는 방법은 두 단계다.

  1. 주소 목록(LocalAddresses)을 만들 때 루프백, 169.254. 자동 주소, 그리고 이름이나 설명에 virtual, vethernet, wsl, hyper-v, docker, vmware 같은 말이 든 어댑터를 뺀다. 사설 대역 주소를 앞에 둔다.
  2. PrimaryAddress는 UDP 소켓을 8.8.8.8에 Connect해 본다. UDP는 Connect만으로 패킷이 나가지 않지만, 운영체제가 고른 나가는 인터페이스의 주소가 LocalEndPoint에 들어온다. 그 주소가 1번 목록에 있으면 쓰고, 아니면 목록의 첫 번째를 쓴다.
using (var s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
{
    s.Connect("8.8.8.8", 9);
    var ep = s.LocalEndPoint as IPEndPoint;
    if (ep != null && !IPAddress.IsLoopback(ep.Address))
    {
        var ip = ep.Address.ToString();
        if (list.Count == 0 || list.Contains(ip)) return ip;
    }
}

일부 Android 기기에서 인터페이스 열거가 비어 나올 때도 같은 방법을 폴백으로 쓴다.

PC가 방장이면 폰이 못 들어오던 문제

PC에서 시험하는 방법은 이렇다. Unity 에디터를 방장으로 띄우고, 메뉴 「WhoPays > Windows 테스트 빌드」로 만든 실행 파일을 게스트로 띄운다. 같은 PC에서는 방 찾기가 안 뜰 수 있어 방 번호 칸에 127.0.0.1을 입력하면 된다.

문제는 폰과 PC를 섞을 때 생겼다. PC가 만든 방에 폰으로 들어가려 하자 연결 시간이 초과됐다.

같이 하기 화면: 방 번호 146 입력 후 "연결 시간이 초과됐어요"

목록에는 방이 떠 있는데 접속만 안 된다. PC에서 나가는 비콘은 폰에 도착하지만, 폰에서 PC로 들어오는 TCP 연결은 막힌다는 뜻이다. PC → 폰 TCP 연결은 정상이었다. 원인은 Windows 방화벽이었다. PC 네트워크가 「개인」 프로필일 때 Unity 에디터의 인바운드 연결이 기본으로 차단돼 있었다.

에디터 실행 파일을 예외로 넣는 대신 포트 기준으로 여는 스크립트(Tools/setup-lan-firewall.ps1)를 만들었다. 관리자 권한으로 한 번 실행하면 되고, 이미 규칙이 있으면 건너뛴다.

$rules = @(
    @{ Name = "WhoPays LAN 게임 (TCP 47801)"; Protocol = "TCP"; Port = 47801 },
    @{ Name = "WhoPays LAN 방 찾기 (UDP 47800)"; Protocol = "UDP"; Port = 47800 }
)
foreach ($r in $rules) {
    if (Get-NetFirewallRule -DisplayName $r.Name -ErrorAction SilentlyContinue) {
        Write-Host "이미 있음: $($r.Name)"
    } else {
        New-NetFirewallRule -DisplayName $r.Name -Direction Inbound -Protocol $r.Protocol -LocalPort $r.Port -Action Allow -Profile Private,Domain | Out-Null
        Write-Host "추가: $($r.Name)"
    }
}

「공용」 프로필에서는 열지 않고, 현재 프로필이 공용이면 「개인」으로 바꾸라는 안내만 출력한다. 스크립트를 실행하자 폰에서 PC 방으로 접속됐다. 폰끼리 할 때는 해당 없는 문제다.

정리

  • 외부 서버 없이 방장 폰이 TCP 47801 서버와 UDP 47800 비콘(1초)을 맡는다.
  • 프레임은 길이 4바이트(리틀엔디언) + UTF-8 JSON 봉투. 규약 버전이 다르면 입장을 거절한다.
  • 소켓 스레드는 문자열까지만, JSON 파싱과 이벤트는 NetPump로 메인 스레드에서 한다.
  • Android는 MulticastLock과 권한 네 개가 필요하고, AndroidJavaObject를 쓰려면 androidjni 모듈이 있어야 빌드된다.
  • 방 찾기는 비콘 → 서브넷 스캔(hello/info) → 방 번호 입력 순서로 내려간다.
  • 안내 주소는 가상 어댑터를 빼고, 나가는 인터페이스의 주소 하나만 보여 준다.
  • PC를 방장으로 시험하려면 방화벽 인바운드(TCP 47801, UDP 47800)를 개인 프로필에서 열어야 한다.

다음 글 7편 (10월 6일 공개)에서는 방장 폰이 게임을 판정하는 구조와 끊긴 게스트의 재접속을 다룬다.