전면 광고가 화면을 덮으면 그 폰의 Unity 앱은 멈춘다. 같이 하기에서 "멈춘 폰"과 "끊긴 폰"은 구별되지 않는다.

1편에서 폰 한 대에 광고를 붙였다. 결과를 보고 「한 번 더!」나 「다른 게임」을 누를 때만, 세 판과 3분에 한 번 이하로. 그런데 같이 하기 방 안에서는 일부러 광고를 꺼 두었다. 폰 여러 대가 같은 결과 화면을 보고 있는데 한 폰에서만 광고를 띄우면 무슨 일이 생기는지 먼저 따져 봐야 했다.

한 폰만 광고를 보면

같이 하기는 방장 폰이 판정하고 게스트 폰은 방장이 보내는 상태를 그리는 구조다. 다음 판을 여는 것도 방장이다.

방장 폰에만 광고를 띄우면 게스트들은 방장이 광고를 닫을 때까지 아무 일도 일어나지 않는 결과 화면을 본다. 반대로 게스트마다 각자 판 수를 세서 광고를 띄우면, 방장이 다음 판을 시작했는데 누구 폰에는 광고가 떠 있다. 그 사람은 판 시작을 놓친다. 연타 대결이나 딱 멈춰처럼 신호에 맞춰 누르는 게임에서는 그것만으로 꼴등이 될 수 있다.

그래서 규칙을 이렇게 정했다. 판 수와 시각은 방장 폰만 센다. 방장이 넘어갈 때 띄울 차례면 모든 폰에 동시에 띄우고, 모든 폰의 광고가 닫힐 때까지 다음 화면으로 가지 않는다. 다만 무한히 기다리지는 않는다. 최대 30초다.

게스트는 애초에 판 결과를 기록하지 않으니, 1편에서 판이 끝날 때 판 수를 올리던 자리를 지나지 않는다. 방장만 센다는 규칙은 손대지 않아도 저절로 지켜졌다.

메인 스레드가 멈추면 연결이 끊긴다

메시지를 설계하기 전에 더 급한 문제가 있었다. 연결 확인이다.

같이 하기는 방장이 2초마다 게스트들에게 ping 을 보내고, 게스트는 pong 으로 답한다. 양쪽 모두 10초 동안 아무것도 받지 못하면 끊긴 것으로 본다. 게스트는 화면 위에 「방장과 다시 연결하는 중...」 배너를 띄우고 재접속을 시도하고, 방장은 그 게스트를 내보내고 자리를 3분 동안 기억한다.

문제는 이 주고받기가 전부 메인 스레드에서 돌았다는 점이다. 방장의 ping 은 프레임마다 시간을 더해 2초가 되면 보냈고, ping 을 받은 쪽도 소켓 스레드가 메시지를 NetPump 로 메인 스레드에 넘긴 뒤에야 pong 을 보냈다. 전면 광고는 보통 15~30초다. 그동안 메인 스레드가 멈추면 이렇게 된다.

  • 방장이 광고 중이면 ping 이 안 나가서, 10초 뒤 모든 게스트가 재접속 배너를 띄운다.
  • 게스트가 광고 중이면 pong 이 안 와서, 10초 뒤 방장이 그 게스트를 내보낸다.

모든 폰에 광고를 띄우는 순간 모두가 서로를 끊긴 것으로 보게 되는 구조였다. 그래서 연결 확인만 메인 스레드에서 떼어 냈다. 방장의 ping 은 전용 스레드가 2초마다 보내고, ping 을 받으면 읽기 스레드가 그 자리에서 pong 을 돌려준다. 게임 메시지는 지금처럼 메인 스레드에서 처리한다.

여기에 걸림돌이 하나 있었다. 메시지 봉투는 JsonUtility 로 만들고 푸는데, 이건 메인 스레드에서만 쓴다. 소켓 스레드는 바이트를 읽어 문자열로 만드는 데까지만 하기로 한 규칙이 있다. 그래서 ping 과 pong 은 Pack 이 만드는 것과 똑같은 JSON 을 글자로 적어 두고, 프레임도 미리 만들어 둔다.

const string PingJson = "{\"t\":\"ping\",\"p\":\"\"}";
const string PongJson = "{\"t\":\"pong\",\"p\":\"\"}";
public static readonly byte[] PingFrame = FrameOf(PingJson);
public static readonly byte[] PongFrame = FrameOf(PongJson);
public static bool IsPing(string json) { return json.StartsWith("{\"t\":\"ping\"", StringComparison.Ordinal); }

읽기 루프는 파싱하지 않고 앞부분만 본다.

if (json == null) break;
if (NetProtocol.IsPing(json)) { conn.Send(NetProtocol.PongFrame); continue; }
if (NetProtocol.IsPong(json)) continue;
NetPump.Post(() => Handle(conn, json));

이제 보내기가 여러 스레드에서 일어난다. 메인 스레드가 게임 메시지를 쓰는 중에 읽기 스레드가 pong 을 쓰면 두 프레임의 바이트가 섞일 수 있다. 쓰기는 연결마다 잠금 하나로 한 번에 하나씩만 하게 했다. 방장의 연결 목록도 이제 ping 스레드가 읽는다. 목록을 바꾸는 건 여전히 메인 스레드뿐이고, ping 스레드는 잠금 안에서 배열로 복사만 한 뒤 그 복사본에 보낸다. 잠금을 잡은 채로 소켓에 쓰지 않으려는 것이다.

이 수정은 메시지 모양을 바꾸지 않는다. 옛 버전 폰이 보내는 ping 도 같은 글자라 그대로 알아본다.

a_show 와 a_done

연결이 버티게 된 뒤에 광고 메시지를 넣었다. 접두어 a_ 로 두 개다.

  • a_show — 방장이 모두에게: 지금 광고를 띄우세요
  • a_done — 게스트가 방장에게: 광고를 닫았어요, 또는 띄울 광고가 없어요

둘 다 번호(seq) 하나만 싣는다. 방장이 광고를 띄울 때마다 하나씩 올리는 번호다.

방장 쪽은 AdSync.HostBreak 가 맡는다. 결과 화면에서 넘어갈 때 띄울 차례가 아니면 바로 다음 판으로 간다. 띄울 차례면 판 수를 처음부터 다시 세게 하고, 번호를 올리고, 지금 방에 있는 게스트 전원을 "기다릴 사람" 목록에 넣은 뒤 a_show 를 보내고 자기 광고를 띄운다.

게스트 쪽은 더 단순하다. a_show 를 받으면 자기 광고를 띄우고, 닫히면 받은 번호를 그대로 담아 a_done 을 보낸다. 1편의 약속 덕분에 따로 갈래를 만들 필요가 없었다. 불러 둔 광고가 없거나, 이미 다른 광고가 떠 있거나, PC·브라우저라 광고 SDK 가 없으면 Show 가 닫힌 뒤 할 일을 곧바로 부르니 a_done 도 곧바로 나간다.

AdService.Show(() => Room.SendToHost(Msg.AdDone, new AdMsg { seq = seq }));

방장은 a_done 이 올 때마다 그 게스트를 목록에서 지운다. 번호가 지금 것과 다르면 무시한다. 앞 광고의 a_done 이 아주 늦게 도착해 이번 기다림을 잘못 풀어 버리는 일을 막는 게 seq 의 역할이다.

늦게 닫는 폰, 나간 폰

기다림을 언제 끝낼지가 이 설계의 핵심이었다. 기준은 세 가지다.

첫째, 기다리는 시간은 a_show 를 보낸 시각부터 최대 30초다. 누가 광고를 닫지 않고 폰을 내려놓아도 나머지 사람들은 30초 뒤 다음 판으로 간다. 그 사람 폰은 광고를 닫은 뒤 방장 화면을 따라온다.

둘째, 방장 자신의 광고가 길었던 경우다. 방장이 28초짜리 광고를 보고 닫았는데 남은 시간이 2초라면, 게스트들은 거의 동시에 닫는 중일 텐데 방장이 먼저 떠나 버린다. 그래서 방장 광고가 닫힌 뒤에는 최소 5초를 더 준다.

static void AfterHostAd()
{
    float now = Time.realtimeSinceStartup;
    _deadline = Mathf.Max(_shownAt + MaxWait, now + GraceAfterHost);   // 30초, 5초
    _waiting = true;
    NetPump.Tick += Tick;
    Check();   // 다들 이미 닫았으면 바로 넘어간다
}

셋째, 방을 나간 게스트는 기다리지 않는다. 방의 참가자 목록이 바뀔 때마다 기다릴 사람 목록에서 지금 방에 없는 사람을 지운다. 광고를 보던 게스트가 정말로 끊겨 버린 경우에도 방장이 30초를 꽉 채워 기다리지 않는다. 목록이 비면 그 자리에서 다음 판으로 간다.

기다리는 동안 방장 화면에는 반투명한 검은 판을 덮고 「광고 보는 친구 기다리는 중... (2명)」처럼 남은 사람 수를 보여 준다. 판이 버튼 위를 덮으니 다른 버튼을 누를 수 없고, 뒤로 가기도 이때는 막았다. 기다리던 중에 「기록 보기」로 빠지면 다음 판으로 가는 이동이 엉뚱한 화면에서 실행된다.

규약 13 과 확인

a_show 와 a_done 은 새 메시지라 같이 하기 규약 버전을 12 에서 13 으로 올렸다. 게스트가 입장할 때 버전을 보내고 방장이 자기 버전과 다르면 「앱 버전이 달라요」로 거절하는 구조는 개발기에서 쓴 그대로다.

버전을 올리지 않고 넘어가는 길도 생각해 봤다. 하지만 1.8 폰은 a_show 를 모른다. 모르는 메시지에 답할 리 없으니 그 폰이 방에 있는 한 방장은 광고를 띄울 때마다 30초를 꽉 채워 기다린다. 기다림의 상한이 있으니 망가지지는 않지만, 매번 이유 없이 30초를 기다리는 방은 고장 난 방과 다르지 않다. 1.9 를 받은 폰끼리만 같이 하도록 막는 쪽이 낫다고 판단했다.

1.9 에는 캐릭터를 늘리는 일도 들어간다. 그것도 규약을 바꿔야 한다면 같은 13 으로 묶기로 했다. 한 버전에 규약을 두 번 올릴 이유가 없다. 출시 노트에는 "같이 하기는 모든 폰이 최신 버전이어야 함께할 수 있어요"를 넣는다.

확인은 두 방향으로 했다. 폰이 방장이고 PC 가 게스트인 방에서는 세 판째에 폰에 광고가 뜨고, PC 는 광고 SDK 가 없어 곧바로 a_done 을 보냈다. 폰에서 광고를 닫자 두 화면이 같이 다음 판으로 넘어갔다. PC 가 방장이고 폰이 게스트인 방에서는 폰에 광고가 뜨는 동안 PC 화면에 「광고 보는 친구 기다리는 중... (1명)」이 떴고, 폰에서 닫자 함께 넘어갔다. 게스트가 광고를 보는 동안 방장이 그 게스트를 내보내지 않는다는 것도 여기서 같이 확인됐다.

연결 확인을 떼어 낸 효과는 광고로 직접 보지 못했다. 폰 방장의 테스트 광고가 10초가 되기 전에 닫혔고, 3분에 한 번 규칙 때문에 바로 다시 띄울 수도 없었다. 대신 같은 상황을 만들었다. 폰 방장 앱을 adb 로 30초 동안 홈 화면으로 보냈다가 되돌렸다. 앱이 뒤로 가면 광고가 떠 있을 때처럼 메인 스레드가 멈춘다. PC 게스트에는 재접속 배너가 뜨지 않았고 로비도 그대로였다.

이 시험은 덤을 하나 알려 줬다. 같이 하기 중에 전화가 오거나 누가 홈 버튼을 눌러도 이제 연결이 끊기지 않는다. 광고 때문에 고친 것이지만 사람들이 실제로 느낄 개선이라 1.9 출시 노트에 한 줄로 넣기로 했다.

광고를 동시에 띄우는 일은 결국 메시지 두 개와 기다림 규칙 세 개였다. 진짜 일은 그 아래, 멈춘 폰과 끊긴 폰을 구별하게 만드는 쪽에 있었다. 다음 편은 코드 밖으로 나간다. 광고를 넣으면 스토어에 적힌 말과 처리방침도 함께 바뀌어야 한다.