서버리스 실시간 매치메이킹 — Cloudflare Functions · Supabase

만든 것3분조회

여러 명이 실시간으로 붙는 대결 게임을 개인적으로 만들면서, 매치메이킹 서버를 상시 띄우지 않았습니다. 대신 Cloudflare Functions(요청이 올 때만 잠깐 실행되는 서버리스 함수 환경)와 Supabase(PostgreSQL 에 실시간 구독·인증을 얹은 백엔드 서비스)로 서버 없이 붙였습니다.

매치메이킹의 핵심은 대기열과 주기적 매칭

플레이어가 “게임 찾기” 를 누르면 대기열(matchmaking_queue) 테이블에 행이 하나 생깁니다. 그리고 2초마다 도는 함수가 대기열을 훑어 짝을 맞춥니다.

플레이어 A "게임 찾기" → 대기열에 INSERT
플레이어 B "게임 찾기" → 대기열에 INSERT
       ↓ (2초마다)
매칭 함수: 대기열 조회 → 실력순 정렬 → ±범위 안에서 짝 → 매치 생성 → 대기열에서 제거

Supabase Realtime 브로드캐스트 → 두 클라이언트가 매치 화면으로 이동

이 “2초마다 도는 함수” 를 상시 서버로 두면 24시간 켜둬야 합니다. Cloudflare Functions 로 만들면 호출될 때만 실행되고, 플레이어가 대기열에 들어온 직후에도 한 번 직접 부릅니다. 아무도 안 할 때는 아무것도 안 돕니다.

대기가 길어지면 매칭 범위 확대

실력이 정확히 같은 상대는 드물어서, 매칭 범위(±rating_range)를 대기 시간에 따라 넓힙니다.

처음에는 “실력 ±50” 안에서만 찾다가, 30초를 기다려도 짝이 없으면 ±100, ±200 으로 넓힙니다. 처음엔 최대한 비슷한 상대를 붙이려 하고, 오래 못 찾으면 “덜 비슷해도 일단 붙여주는” 쪽으로 타협하는 겁니다.

너무 엄격하면 매칭이 안 되고 너무 느슨하면 실력 차가 큰 게임이 나오니, 그 사이를 대기 시간으로 조절하는 셈입니다.

게임 종류마다 인원과 라운드가 다릅니다.

const GAME_CONFIG = {
  game_a: { min_players: 2, max_players: 2, total_rounds: 5 },
  game_b: { min_players: 2, max_players: 4, total_rounds: 5 },
  game_c: { min_players: 2, max_players: 4, total_rounds: 7 },
}

2인 대결도 있고 최대 4인이 붙는 것도 있어서, 대기열도 게임 종류별로 나눠서 매칭합니다.

매칭됐다는 걸 어떻게 알리나 — Supabase Realtime

함수가 짝을 맞춰 매치를 만들어도, 두 플레이어의 브라우저가 그걸 알아야 게임 화면으로 넘어갑니다. 여기서 Supabase Realtime 을 씁니다.

클라이언트는 자기가 매칭되길 기다리며 Realtime 채널을 구독하고 있습니다. 함수가 매치를 만들면 Supabase 가 그 변경을 구독 중인 클라이언트에 밀어줍니다. 폴링으로 “나 매칭됐나?” 를 계속 물어볼 필요가 없습니다.

동시에 두 번 매칭되는 걸 막기

여기서 한 번 사고가 났는데, 함수가 2초마다 도는 와중에 실행이 겹치거나 대기열에 들어오며 직접 부른 호출과 주기 실행이 동시에 돌면 같은 플레이어가 두 매치에 들어갈 수 있습니다.

Supabase 의 upsert 충돌(onConflict)로 이걸 막습니다. 대기열에서 빼내고 매치에 넣는 걸 원자적으로 처리해서, 이미 매칭된 플레이어는 두 번째 매칭에서 409 로 튕깁니다. 동시에 들어온 요청 중 하나만 성공합니다.

상시 서버 없는 실시간 — 주기 실행 + Realtime 구독

상시 서버 없이 실시간 매치메이킹을 만드는 핵심은 두 가지였습니다. 주기 실행(2초 함수)으로 대기열을 처리하고, Realtime 구독으로 결과를 밀어주는 것. 서버가 계속 돌며 소켓을 붙들고 있는 대신, 관리형 서비스가 그 역할을 나눠 맡았습니다.

실시간 기능을 붙일 때 소켓 서버부터 띄울 필요는 없습니다. 짧은 주기로 도는 함수로 처리하고 결과는 Realtime 구독으로 밀어주면, 상시 서버 없이도 대부분의 실시간 요구가 덮입니다.

  1. 불러오는 중