FlutterDartSocket멀티플레이게임개발

Flutter 게임에 서버 없이 멀티플레이 넣기 — LAN 소켓, UDP 방 찾기, 자동 재접속까지

2026년 7월 11일6 분 읽기

Flutter 게임에 서버 없이 멀티플레이 넣기

취미로 만드는 심플 마작 게임 "마작 조이"에 멀티플레이를 붙였다. 목표는 명확했다. 옆에 있는 친구랑 폰 두 대로 바로 한 판. 계정도, 서버 비용도, 매칭 시스템도 없이.

결론부터: 같은 Wi-Fi에서 방 만들기 → 방 목록에 자동으로 뜨고 → 탭 한 번으로 참가 → 최대 4인(빈자리는 AI) 대국까지 동작한다. 외부 서버는 한 줄도 없다. 이 글은 그 과정에서 내린 설계 결정들과, 실기기 테스트에서 얻어맞은 문제들의 해결기다.

왜 서버 없이 LAN인가

멀티플레이 하면 보통 Firebase나 WebSocket 서버부터 떠올리는데, "옆에 있는 친구랑"이라는 요구사항에는 과하다.

방식장점단점
같은 Wi-Fi (LAN)서버 비용 0, 빠름, 크로스 플랫폼같은 공유기에 있어야 함
Nearby Connections공유기 불필요사실상 Android 전용
온라인 서버어디서든 가능비용, 계정, 운영 부담

턴제 보드게임 + 같은 공간의 친구 = LAN이 정답이었다. 트래픽은 초당 몇 KB 수준이라 폰 한 대가 서버 역할을 해도 아무 부담이 없다.

핵심 설계 1: 호스트 권한 (Host-Authoritative)

가장 중요한 결정. 게임 엔진은 방장(호스트) 폰에서만 돈다.

[방장 폰]                          [참가자 폰]
게임 엔진 (유일한 심판)    ←TCP→    미러 (그리기만 함)
  ├─ 패 셔플/분배                    ├─ "3번 패 버릴게" 전송
  ├─ 규칙 검증                       └─ 상태 수신 → 화면 갱신
  └─ AI 좌석 진행

참가자는 행동("이 패 버림", "완성!")만 JSON으로 보내고, 호스트가 엔진에 적용한 뒤 결과 상태를 모두에게 뿌린다. 이 구조의 장점:

  • 동기화 버그가 원천적으로 없다. 진실은 호스트에 하나뿐이니까.
  • 치팅 방지가 공짜. 잘못된 행동은 엔진의 기존 검증 로직이 그냥 거절한다.
  • 기존 코드 재사용. 게임 로직이 원래 UI와 분리된 순수 Dart였어서, 네트워크 층만 얹으면 됐다.

전송은 TCP 위에 개행으로 구분한 JSON 한 줄 = 메시지 한 개. 프로토콜 파서가 이게 전부다:

Stream<Map<String, dynamic>> jsonMessages(Stream<List<int>> source) => source
    .cast<List<int>>()          // Socket은 Stream<Uint8List>라 cast 필요!
    .transform(utf8.decoder)
    .transform(const LineSplitter())
    .where((line) => line.trim().isNotEmpty)
    .map((line) => jsonDecode(line) as Map<String, dynamic>);

주석의 cast가 첫 삽질 포인트다. SocketStream<Uint8List>인데 utf8.decoderStream<List<int>>를 기대해서, cast 없이 붙이면 런타임에 type 'Utf8Decoder' is not a subtype... 에러가 난다. 컴파일 타임에 안 잡힌다.

핵심 설계 2: 좌석 회전 트릭

기존 UI는 "나는 항상 좌석 0(화면 아래)"이라는 가정으로 만들어져 있었다. 네트워크 대전에서 내가 실제로는 좌석 2라면? UI를 다 뜯어고쳐야 하나?

아니다. 호스트가 클라이언트마다 좌석을 회전시켜서 보내면 된다.

// 좌석 2인 참가자에게는 (실제좌석 - 2) mod 4 로 회전한 상태를 전송
int rot(int seat) => (seat - forSeat + n) % n;

모든 참가자는 "내가 좌석 0인 세계"를 수신한다. 점수 배열, 버림패, 현재 턴, 승자 번호까지 전부 회전시켜 보낸다. 덕분에 게임 화면 코드는 한 줄도 안 바꿨다. 대신 UI가 읽는 컨트롤러를 인터페이스(TableController)로 추상화해서, 로컬 AI 대전 / 호스트 / 클라이언트 세 구현이 같은 화면을 공유하게 했다.

보안 겸 트래픽 절약으로 남의 손패는 장수만 보낸다. 클라이언트 미러에는 자리 채움용 더미 타일을 넣는데, UI가 상대 패는 뒷면+장수로만 그리니 문제없다.

핵심 설계 3: UDP 브로드캐스트 방 찾기

"IP 주소를 입력하세요"는 게임 UX로 실격이다. 같은 네트워크의 방이 목록에 자동으로 떠야 한다.

mDNS(Bonjour) 패키지를 쓸까 하다가, 의존성 없이 UDP 브로드캐스트로 직접 구현했다. 원리는 초등학생 수준으로 단순하다:

  1. 참가자: 1초마다 255.255.255.255:47777로 "방 있어요?" 패킷을 뿌림
  2. 호스트: 그 포트를 듣고 있다가 "저요! (방 이름, TCP 포트, 인원)"라고 발신자에게 응답
  3. 참가자: 응답 모아서 목록 표시, 4초간 무응답이면 목록에서 제거
// 참가자 쪽 핑
_socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
_socket!.broadcastEnabled = true;   // 이거 안 켜면 브로드캐스트 전송 실패
_socket!.send(utf8.encode('MJJOY?'),
    InternetAddress('255.255.255.255'), 47777);

호스트의 TCP 서버는 포트 0으로 바인딩해서(OS가 빈 포트 할당) UDP 응답에 실어 보낸다. 포트 충돌 걱정이 없다.

Android는 매니페스트에 INTERNET 권한 한 줄이면 되고, iOS는 NSLocalNetworkUsageDescription을 Info.plist에 추가해야 로컬 네트워크 접근 팝업이 뜬다.

핵심 설계 4: 여러 사람의 동시 응답 처리

마작에는 "누가 버린 패를 가져갈 기회"가 여러 명에게 동시에 열리는 순간이 있다. 로컬 대전에서는 사람이 1명이라 간단했지만, 멀티에서는 전원의 응답을 모아서 우선순위로 재정해야 한다.

// 기회가 있는 좌석마다: AI는 즉시 결정, 사람은 대기 목록에
// 전원 응답 도착 → 우선순위(완성 > 뺏어오기 > 턴 순서)로 한 명만 처리
Map<int, ClaimResponse>? _responses;
final Set<int> _awaiting = {};

호스트는 아직 응답 안 한 좌석 목록(claimAwait)을 상태에 실어 보내고, 자기 좌석이 목록에 있는 클라이언트만 프롬프트를 띄운다. 응답 선택지(가능한 몸통 조합)는 클라이언트가 자기 손패로 재계산하는데, 호스트와 같은 함수를 쓰므로 결과가 반드시 일치한다. 순수 함수로 게임 로직을 짜두면 이런 데서 이득을 본다.

실기기 테스트에서 얻어맞은 것들

시뮬레이터 테스트는 다 통과했는데, 실제 폰 3대(삼성 2, LG 1)로 돌리자마자 문제가 나왔다.

문제 1: 폰 화면이 꺼지면 방이 통째로 사라진다

방장 폰 화면이 잠깐 꺼지거나 다른 앱으로 전환하면, 참가자 전원이 튕겼다. 원인은 화면 꺼짐 → Android Wi-Fi 절전 → 소켓 사망 콤보.

이중으로 대응했다:

1) wakelock으로 화면 꺼짐 자체를 방지. wakelock_plus 패키지로 로비와 게임 화면이 떠 있는 동안 화면을 켜 둔다. 화면이 겹칠 수 있어서(로비 위에 게임 화면) 참조 카운트로 관리:

class KeepAwake {
  static int _count = 0;
  static void acquire() { if (++_count == 1) WakelockPlus.enable(); }
  static void release() { if (_count > 0 && --_count == 0) WakelockPlus.disable(); }
}

2) 자동 재접속 + 자리 복귀. 그래도 끊기면 클라이언트가 2초 간격으로 재접속을 시도하고, 호스트는 이탈자의 이름 → 좌석 매핑을 기억해 뒀다가 같은 이름으로 돌아오면 자리를 돌려준다. 그동안 그 좌석은 AI가 대신 두므로 남은 사람들 게임이 멈추지 않는다.

여기서 미묘한 버그를 하나 밟았다. 재접속 직후 옛 소켓의 늦은 onDone 이벤트가 도착해서 방금 복귀한 새 연결을 끊어버리는 것. 좌석 번호로만 정리하면 안 되고, 소켓 객체 동일성으로 판별해야 한다:

void _onDisconnect(int? seat, Socket socket) {
  // 이미 재접속해 좌석을 되찾았다면(옛 소켓의 늦은 onDone) 무시
  if (_clients[seat]?.socket != socket) return;
  ...
}

단, 한계는 명확하다. 호스트 앱이 OS에 완전히 종료되면 게임 상태가 호스트에만 있으므로 복구 불가. 이게 호스트 권한 구조의 대가다. 더 견고하게 하려면 포그라운드 서비스가 다음 단계.

문제 2: 참가자가 전부 "Player"

이름 입력 필드를 진입 화면에 뒀는데, 실제 사용자(가족)는 그냥 지나쳐서 전원이 "Player"로 표시됐다. 입력 필드는 그것이 쓰이는 화면에 있어야 한다는 평범한 교훈. 방 목록 화면 맨 위로 옮기고, 입력값은 shared_preferences에 저장해 다음에 재사용한다.

통합 테스트: 에뮬레이터 없이 소켓으로

멀티플레이 코드의 진짜 검증은 위젯 테스트가 아니라 루프백 소켓 통합 테스트로 했다. flutter test의 test 환경에서 dart:io 소켓이 그대로 동작한다는 점을 이용:

test('LAN 대전: 호스트 1 + 클라이언트 2 + AI 1이 8판 대국을 완주한다', () async {
  final host = NetHostController(hostName: '방장', aiDelay: Duration.zero);
  await host.open(advertise: false);
  final client = NetClientController();
  await client.connect(InternetAddress.loopbackIPv4, host.port!, name: '친구0');
  // ... 전원 단순 전략으로 8판 완주 후, 클라이언트 미러와
  // 호스트 상태가 좌석 회전까지 정확히 일치하는지 검증
});

AI 딜레이를 Duration.zero로 주입할 수 있게 해둔 덕에 8판 대국이 2초 만에 끝난다. 순간 끊김 테스트용으로 @visibleForTesting void debugDropConnection() 훅도 하나 뚫어서, 끊김 → 재접속 → 자리 복귀 → 손패 동기화까지 자동 검증한다. 실기기 테스트 전에 이 테스트들이 프로토콜 버그를 다 잡아줬다.

덤: 3개 언어 지원과 초보자 모드

멀티플레이 전에 두 가지도 넣었다.

i18n (한국어/중국어/영어): 공식 flutter_localizations 대신, 문자열 클래스 하나에 언어별 const 인스턴스 3개를 만드는 수동 방식을 썼다. 화면이 10개 미만인 앱에는 코드 생성 도구보다 이게 단순하다. 첫 실행 시 PlatformDispatcher.instance.locale.languageCode로 기기 언어를 감지하고, 지원 언어(ko/zh)가 아니면 영어로 폴백. 모든 언어가 모든 키를 가졌는지는 테스트로 강제한다.

초보자 모드: 점수 계산이 아이에게 부담일 수 있어서, 설정 스위치 하나로 점수제를 끄고 승수만 기록하는 모드를 넣었다. 정산 함수에 scored: false 플래그 하나 추가한 게 로직 변경의 전부 — 점수 로직을 순수 함수로 분리해 둔 덕이다.

정리

서버 없는 Flutter LAN 멀티플레이의 핵심 흐름:

  1. 호스트 권한: 엔진은 방장 폰에서만, 참가자는 행동 전송 + 상태 미러링
  2. 개행 구분 JSON over TCP: 파서 5줄이면 충분 (단, Stream.cast 주의)
  3. UDP 브로드캐스트 방 찾기: mDNS 패키지 없이 30줄로 자동 방 목록
  4. 좌석 회전: 모두에게 "내가 좌석 0인 세계"를 보내면 UI 재사용 공짜
  5. wakelock + 이름 기반 자리 복귀: 모바일 LAN의 최대 적은 화면 꺼짐
  6. 루프백 통합 테스트: 실기기 들기 전에 소켓 레벨에서 완주 검증

폰 3대로 가족과 실전 테스트까지 마쳤다. 서버 비용 0원으로 거실 멀티플레이가 되는 경험은 생각보다 훨씬 만족스럽다. 턴제 게임을 만들고 있다면 온라인 서버로 직행하기 전에 LAN 방식을 먼저 고려해 보길 권한다 — 구현 난이도 대비 얻는 재미가 크다.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.