FlutterDart게임개발동시성테스트

마작 게임에서 "누가 먼저 가져가는가" — 우선순위 규칙을 반응 속도 경쟁으로 바꾼 이유

2026년 7월 13일6 분 읽기

마작 게임에서 "누가 먼저 가져가는가"

만들고 있는 마작 게임에서 이상한 버그(라기보다는 나쁜 사용자 경험)를 발견했다. AI 대전 중에 남이 버린 패를 뺏어올 수 있는 상황이 뜨면, 내가 뭘 누르기도 전에 AI가 이미 가져가 버리는 것이다. 게임 로그를 보면 분명 "이 패 가져갈까요?" 프롬프트가 뜨긴 뜨는데, 사람이 반응할 틈도 없이 결과가 확정돼 있었다.

원인은 단순했다. AI의 응답 판정 로직이 너무 빨랐다. 게임 엔진이 클레임(뺏어오기/완성) 기회를 계산하자마자, AI는 즉시 자기 선택을 결정해버렸다. 사람은 화면을 보고 판단하는 데 최소 몇백 밀리초는 걸리는데, AI는 그 사이 이미 결정을 끝낸 상태였다.

이 문제를 고치면서 마작의 오래된 규칙 하나도 다시 생각하게 됐다. "누가 그 패를 가져가는가"는 원래 속도가 아니라 자리 순서(턴 우선순위)로 정해지는 규칙이다. 그런데 게임 안에서 이 우선순위를 사람이 직관적으로 이해하기는 쉽지 않다. 그래서 이번 기회에 아예 "먼저 응답한 사람이 가져간다"는 더 단순하고 직관적인 규칙으로 갈아엎기로 했다.

기존 방식의 문제

마작 조이의 원래 판정 로직은 이랬다.

  1. 패가 버려지면 그 패로 뺏어올 수 있는 모든 좌석(사람+AI)을 계산한다.
  2. 각 좌석의 응답을 모은다.
  3. 전원이 응답했거나, 더 높은 우선순위(자리 순서)를 가진 좌석이 응답을 마쳤다는 게 확실해지면, 고정된 자리 순서대로 첫 번째로 "가져가겠다"고 한 사람에게 패를 넘긴다.

문제는 3번이다. AI는 항상 즉시 응답을 계산해서 저장해두니까, 사람이 아직 고민 중이어도 로직상으로는 "이미 다 모였다"는 조건을 순식간에 만족시켜버렸다. 결과적으로 사람이 프롬프트를 보고 손가락을 움직이기도 전에 라운드가 끝나 있는 경우가 많았다.

두 가지 설계 결정

이 문제를 고치면서 두 가지를 다시 정했다.

1. 완성(론)은 여전히 뺏어오기보다 우선한다

이건 속도의 문제가 아니라 가치의 문제라서 그대로 뒀다. 패를 완성하는 것(승리)이 몸통 하나 만드는 것(뺏어오기)보다 항상 중요하므로, 완성 가능한 좌석이 아직 응답하지 않았다면 — 설령 다른 좌석의 뺏어오기 응답이 먼저 도착했더라도 — 곧바로 확정하지 않고 기다린다.

2. 같은 등급 안에서는 "먼저 응답한 사람이 가져간다"

완성끼리 경쟁하거나, 뺏어오기끼리 경쟁할 때는 더 이상 자리 순서를 따지지 않는다. 누가 먼저 응답했는지로 결정한다. 트리플(퐁)과 스트레이트(치) 구분도 없다 — 어차피 이 게임은 처음부터 "치/퐁 구분 없이 아무 자리에서나 뺏어올 수 있다"는 단순화된 룰이었으니, 우선순위까지 단순화하는 게 일관성이 있었다.

이 두 가지를 합치면 규칙은 딱 한 줄로 설명된다: 완성이 항상 이기고, 그 외엔 먼저 누른 사람이 이긴다.

AI에게도 "고민하는 시간"을 주기

규칙을 바꾼다고 끝이 아니었다. AI가 여전히 즉시 응답하면 "먼저 응답한 사람이 이긴다"는 규칙 아래에서 AI가 항상 이기는 건 똑같다. 그래서 AI의 응답 타이밍 자체를 손봤다.

핵심 아이디어는 간단하다. 사람이 보고 있지 않은 결정은 빠르게, 사람이 관여하는 결정은 사람처럼 고민하게.

final opportunities = game.claimOpportunities;
final humanInvolved = opportunities.any((o) => o.seat == humanSeat);

if (!humanInvolved) {
  // AI끼리만 경쟁 — 아무도 안 보고 있으니 즉시 계산해서 우선순위로 처리
  for (final o in opportunities) {
    _responses![o.seat] = _aiAnswerFor(o);
  }
  return;
}

// 사람이 관여 — 모든 AI가 각자 무작위 시간만큼 "고민"한다
for (final o in opportunities) {
  _awaiting.add(o.seat);
  if (o.seat == humanSeat) {
    humanClaimOpportunity = o;
  } else {
    _aiThinkTimers[o.seat] =
        Timer(_randomAiThink(), () => _onAiThinkDone(o.seat, gen));
  }
}
  • 사람이 전혀 관여하지 않는 라운드(AI끼리만 경쟁)는 지연을 아예 없앴다. 이전엔 이 경우에도 700ms를 기다렸는데, 생각해보면 아무도 그 판정을 지켜보고 있지 않으니 늦출 이유가 없다.
  • 사람이 하나라도 관여하는 라운드에서는, 경쟁하는 AI 각각에게 0.5초~13초 사이의 무작위 고민 시간을 준다. 사람의 응답 제한시간(15초)과 비슷한 폭으로 맞췄다. AI마다 독립적인 타이머가 돌아가므로, 어떤 AI는 반사적으로 빨리 결정하고 어떤 AI는 오래 고민하는 것처럼 보인다.

이제 사람이 프롬프트를 보고 손가락을 움직일 최소한의 물리적 시간(AI의 최소 고민 시간 500ms)이 항상 보장된다. 게다가 사람이 먼저 응답하면 AI가 아직 "생각 중"이었어도 그 즉시 사람이 이긴다 — 진짜 반응 속도 경쟁이 된 것이다.

"응답이 도착할 때마다" 판정하기

기존 코드는 "모든 응답을 모은 뒤 한꺼번에 정렬해서 결정"하는 구조였다. 첫 클릭이 이기는 방식으로 바꾸려면 이 구조 자체를 "응답이 하나 도착할 때마다 즉시 판단"하는 이벤트 기반으로 바꿔야 했다.

void _onAwaitedAnswer(int seat, _ClaimAnswer answer) {
  if (_responses == null) return; // 이미 확정됨
  final opp = _opportunityOf(seat);
  if (opp == null) return;

  if (answer.win && opp.canWin) {
    _finalizeAwaitedWin(seat); // 완성은 도착 즉시 확정
    return;
  }
  if (answer.option != null) {
    _leadingClaimSeat ??= seat;   // 가장 먼저 온 뺏어오기만 기억
    _leadingClaimOption ??= answer.option;
  }
  _tryFinalizeAwaitedClaim();
}

void _tryFinalizeAwaitedClaim() {
  if (_responses == null) return;
  final winCapablePending = game.claimOpportunities
      .any((o) => o.canWin && _awaiting.contains(o.seat));
  if (winCapablePending) return; // 완성 가능자가 아직 남아있으면 대기

  if (_leadingClaimSeat != null) {
    _finalizeAwaitedClaim(_leadingClaimSeat!, _leadingClaimOption!);
  } else if (_awaiting.isEmpty) {
    _finalizeAwaitedPass();
  }
}

여기서 ??= 연산자가 핵심이다. _leadingClaimSeat는 처음 값이 할당된 이후로는 절대 덮어써지지 않으므로, 자연스럽게 "가장 먼저 도착한 뺏어오기 응답"만 기억하게 된다. 완성은 도착하는 즉시 확정하되(더 일찍 응답한 사람이 있을 수 없으므로), 뺏어오기는 "아직 완성 가능한 사람이 남아있는지"를 확인한 뒤에만 확정한다 — 이 부분이 앞서 정한 "완성이 항상 우선한다"는 계층을 지켜준다.

로컬 대전과 LAN 대전, 같은 패턴 두 번

이 게임은 두 가지 대전 모드가 있다. 혼자 AI 3명과 하는 로컬 모드, 그리고 같은 Wi-Fi에서 최대 4명이 겨루는 LAN 모드. 둘은 코드 경로가 완전히 분리되어 있어서(로컬은 즉시 함수 호출, LAN은 호스트가 소켓으로 상태를 방송하는 구조) 이 로직을 두 번 구현해야 했다.

다행히 구조는 거의 그대로 옮겨졌다. LAN 호스트 쪽은 여기에 한 가지가 더 필요했는데, 뺏어온 사람이 다른 참가자들에게도 보여야 한다는 점이다.

void _finalizeAwaitedClaim(int seat, Meld meld) {
  final opp = _opportunityOf(seat);
  _clearClaimRoundState();
  // ... 뺏어오기 적용 ...
  if (multiParty) _announceClaim(seat); // 다른 참가자들에게 "OO가 가져갔어요" 알림
}

로컬 모드는 화면이 하나뿐이라 이런 알림이 필요 없지만, LAN 모드에서는 여러 명이 같은 패를 동시에 노렸을 때 "내가 눌렀는데 왜 반영이 안 되지?"라는 혼란을 막기 위해 알림을 띄운다. 같은 개념(완성 우선 + 첫 응답 승리)을 로컬/네트워크 양쪽에 구현하면서, "사람에게 무슨 일이 일어났는지 보여줘야 하는" 부분만 LAN 쪽에 추가된 셈이다.

테스트: 실제 게임을 플레이하지 않고 경쟁 상황 재현하기

이 로직을 검증하려면 "여러 명이 동시에 같은 패를 원하는" 상황이 필요한데, 정상적인 게임 진행으로는 이런 상황이 우연히만 발생해서 테스트하기 까다롭다. 그래서 게임 상태를 직접 조작해서 원하는 상황을 강제로 만들었다.

void _setupClaimRace(GameController gc) {
  final game = gc.game;
  for (final pl in game.players) {
    pl.hand.clear();
    pl.melds.clear();
  }
  game.players[1].hand.add(m(7)); // 버릴 패
  game.players[0].hand.addAll([m(7), m(7), ...filler]); // 사람: 트리플 가능
  game.players[2].hand.addAll([m(8), m(9), ...filler]); // AI: 스트레이트 가능
  game.current = 1;
  game.discard(m(7));
  gc.debugPoke(); // 자동 진행 루프를 다시 깨운다
}

filler는 서로 절대 조합되지 않는 낱장들(자패 7종 + 삭즈 2·4·6·8)로 채웠다. 이렇게 하면 우연히 완성 손패가 되는 걸 막으면서 "이 패로는 뺏어오기만 가능하다"는 상황을 정확히 통제할 수 있다.

타이밍 검증은 이렇게 했다.

test('사람이 AI의 최소 고민 시간(500ms)보다 먼저 응답하면 사람이 가져간다', () async {
  final gc = GameController(seed: 1, aiThinkMin: 500, aiThinkRangeMs: 12500);
  _setupClaimRace(gc);

  await Future<void>.delayed(const Duration(milliseconds: 50));
  gc.humanRespondClaim(option: /* 트리플 옵션 */);

  await waitFor(() => gc.human.melds.isNotEmpty);
  // AI는 500ms보다 일찍 응답할 수 없으므로, 사람이 반드시 이긴다.
});

여기서 aiThinkMin/aiThinkRangeMs를 생성자로 주입할 수 있게 만든 게 중요했다. 실제 게임에서는 AI가 0.513초 사이 무작위로 고민하는데, 테스트에서 매번 13초를 기다릴 순 없다. 그래서 이 값을 테스트에서만 짧게(예: 30ms50ms) 주입해서, "AI가 아직 응답하지 못했을 시점"과 "AI가 이미 응답했을 시점"을 결정론적으로 만들었다. 실제 프로덕션 값은 그대로 두면서 테스트 속도만 따로 통제하는, 흔한 패턴이지만 이번에도 유용했다.

정리

이번 작업의 핵심 흐름:

  1. 문제 발견: AI가 항상 즉시 응답해서, 사람이 반응할 물리적 시간조차 없었다.
  2. 규칙 재설계: "완성은 항상 우선한다"는 계층은 유지하고, 같은 계층 안에서는 자리 순서 대신 먼저 응답한 사람이 이긴다로 단순화했다.
  3. AI 타이밍 조정: 사람이 안 보는 결정(AI끼리)은 지연 없이, 사람이 관여하는 결정만 AI에게 무작위 고민 시간(0.5~13초)을 줬다.
  4. 이벤트 기반 판정: "모아서 한꺼번에 정렬" 대신 "응답이 도착할 때마다 즉시 확인"하는 구조로 바꿔, 완성은 즉시 확정하고 뺏어오기는 첫 응답만 기억해뒀다가 조건이 맞을 때 확정한다.
  5. 두 번 구현: 로컬 대전과 LAN 대전에 같은 패턴을 각각 적용하고, LAN 쪽에는 "누가 가져갔는지" 알림만 추가했다.
  6. 결정론적 테스트: 게임 상태를 직접 조작해 경쟁 상황을 만들고, AI 고민 시간을 테스트용으로 짧게 주입해 타이밍을 검증했다.

"게임 규칙을 코드로 옮긴다"는 게 단순히 판정 로직만 옮기는 게 아니라, 그 규칙이 실제 사용자에게 어떻게 체감되는지까지 고려해야 한다는 걸 다시 느꼈다. 자리 순서 우선순위는 실제 마작에서는 합리적인 규칙이지만, 화면과 타이머로 진행되는 디지털 게임에서는 오히려 "AI가 부당하게 빠르다"는 오해를 낳았다. 규칙을 원본 그대로 옮기는 것보다, 그 규칙이 만들어내는 경험을 먼저 생각하는 게 나을 때가 있다.

PM

backtodev

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