새벽 3시, 킬피드가 안 떴다 - 게임 서버 실시간 연동 삽질기
Oxide 플러그인 에러부터 WebSocket 연결 끊김까지, 실시간 시스템을 만들면서 겪은 모든 실패와 해결 과정
프롤로그: "야 킬피드 왜 안 떠?"
2024년 11월 어느 날 새벽, 디스코드에서 메시지가 왔다.
"야 킬피드 왜 안 떠?"
웹사이트 들어가보니 킬피드가 텅 비어있었다. 근데 게임 서버엔 사람들이 싸우고 있었다. 로그를 확인하니 Supabase 요청이 전부 실패하고 있었다.
[SupabaseSync] Kill batch failed: 0 -
[SupabaseSync] Kill batch failed: 0 -
[SupabaseSync] Kill batch failed: 0 -
응답 코드가 0이다. 이게 뭐지?
알고 보니 Oxide의 webrequests가 SSL 인증서 검증에서 막히고 있었다. 서버 시간이 어긋나서. w32tm /resync 한 줄로 해결됐지만, 그걸 찾는 데 2시간이 걸렸다.
이 글은 그런 삽질들의 기록이다.
실시간 연동이 뭔데?
간단히 말하면 이거다:
- 게임 서버에서 누가 누굴 죽임
- 웹사이트에 바로 뜸
끝. 개념은 단순한데, 이걸 실제로 구현하려니 예상치 못한 문제들이 쏟아졌다.
게임 서버 (Rust Dedicated)
│
│ 킬 발생 → OnEntityDeath 훅
▼
SupabaseSync.cs (내가 만든 Oxide 플러그인)
│
│ HTTP POST
▼
Supabase (PostgreSQL + Realtime)
│
│ WebSocket
▼
웹 브라우저 (React)
│
▼
화면에 킬피드 표시
총 지연시간: 보통 200ms, 최악의 경우 2초
이론상 완벽해 보이지만, 각 단계마다 지뢰가 있었다.
1단계: C# 플러그인 만들기
처음엔 쉬워 보였다
Oxide 플러그인은 C#으로 만든다. 게임 이벤트를 후킹해서 HTTP 요청 보내면 끝이라고 생각했다.
// 첫 번째 버전 - 너무 단순했음
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
if (!(entity is BasePlayer victim)) return;
var killer = info?.Initiator as BasePlayer;
if (killer == null) return;
// 바로 API 호출
SendKillToSupabase(killer, victim, info);
}
테스트 서버에서 혼자 돌려보니 잘 됐다. "오 쉽네?"
근데 실서버에 올리니까 문제가 터졌다.
문제 1: 요청이 너무 많다
금요일 저녁, 사람들이 몰려서 PvP가 터졌다. 킬이 1분에 20개씩 발생했다. 각 킬마다 HTTP 요청을 보내니까... Supabase에서 rate limit 걸렸다.
[SupabaseSync] Kill failed: 429 - Too Many Requests
[SupabaseSync] Kill failed: 429 - Too Many Requests
해결책: 큐 기반 배치 처리
// 실제 SupabaseSync.cs 구조
private Queue<object> eventQueue = new Queue<object>();
private bool isProcessing = false;
private void QueueEvent(string table, object data)
{
eventQueue.Enqueue(new { table, data });
if (!isProcessing)
{
ProcessQueue();
}
}
private void ProcessQueue()
{
if (eventQueue.Count == 0)
{
isProcessing = false;
return;
}
isProcessing = true;
var batch = new List<object>();
while (eventQueue.Count > 0 && batch.Count < config.BatchSize)
{
batch.Add(eventQueue.Dequeue());
}
SendToSupabase("kill_logs", batch);
}
이벤트가 큐에 쌓이고, config.BatchSize(기본 10개) 단위로 배치 전송된다. rate limit 해결.
문제 2: 무기 이름이 이상하게 나온다
킬피드에 무기가 "rifle.ak.entity" 이렇게 뜨더라. 플레이어한테 이게 뭔 소리야.
// 처음 코드
var weapon = info.Weapon?.ShortPrefabName;
// 결과: "rifle.ak.entity" - 뭔 개소리
Rust 게임 내부에선 프리팹 이름을 쓰는데, 이걸 사람이 읽을 수 있는 이름으로 바꿔야 했다.
// 실제 SupabaseSync.cs의 GetWeaponName
private string GetWeaponName(HitInfo info)
{
// 1순위: 아이템의 영어 표시 이름
if (info?.Weapon?.GetItem()?.info != null)
{
return info.Weapon.GetItem().info.displayName.english;
}
// 2순위: 무기 프리팹 이름
if (info?.WeaponPrefab != null)
{
return info.WeaponPrefab.ShortPrefabName;
}
return "Unknown";
}
매핑 테이블 없이 Rust 게임 자체의 displayName.english를 사용한다. 대부분 경우 이걸로 충분했다.
문제 3: 응답 코드 0
맨 처음에 말한 그 문제. HTTP 응답이 0으로 오는 건 "연결 자체가 안 됐다"는 뜻이다.
원인들:
- SSL 인증서 오류 (서버 시간 동기화 문제)
- DNS 확인 실패
- 방화벽 차단
- Supabase 서버 다운 (거의 없음)
나는 서버 시간이 5분 어긋나 있어서 SSL 인증서가 "아직 유효하지 않음"으로 처리됐다.
# Windows 서버에서 시간 동기화
w32tm /resync
이거 하나로 해결. 근데 이걸 찾는 데 새벽 2시간을 날렸다.
2단계: Supabase Realtime 설정
"Realtime 켜면 끝 아니야?"
Supabase 대시보드 가서 Realtime 토글 켜면 될 줄 알았다.
켰다. 안 됐다.
알고 보니 테이블마다 개별적으로 Realtime을 활성화해야 했다.
- Database → Replication 메뉴로 이동
- Source 선택
- 원하는 테이블 체크
이것도 문서 안 읽고 삽질한 내 잘못이다.
Row Level Security 문제
Supabase는 기본적으로 RLS(Row Level Security)가 켜져 있다. 보안상 좋은데, Realtime 구독할 때 걸린다.
-- 이거 안 하면 Realtime으로 데이터 안 옴
ALTER TABLE kill_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow public read" ON kill_logs
FOR SELECT
USING (true);
처음엔 "왜 INSERT는 되는데 Realtime은 안 오지?" 하고 한참 헤맸다. INSERT는 service role key로 하니까 RLS 무시하고 들어가는데, Realtime 구독은 anon key로 하니까 RLS에 막힌 거였다.
스키마 설계: 실패에서 배운 것
처음 만든 kill_logs 테이블:
-- v1: 너무 단순함
CREATE TABLE kill_logs (
id SERIAL PRIMARY KEY,
killer_name VARCHAR(64),
victim_name VARCHAR(64),
weapon VARCHAR(64),
timestamp TIMESTAMP
);
문제들:
- Steam ID가 없어서 같은 이름 가진 사람 구분 불가
- 헤드샷인지 알 수 없음
- 거리 정보 없음
- 어느 서버인지 구분 불가 (나중에 서버 2대로 늘리면?)
-- v3: 지금 쓰는 버전
CREATE TABLE kill_logs (
id BIGSERIAL PRIMARY KEY,
-- 킬러 정보
killer_steam_id VARCHAR(20) NOT NULL,
killer_name VARCHAR(64) NOT NULL,
-- 희생자 정보
victim_steam_id VARCHAR(20) NOT NULL,
victim_name VARCHAR(64) NOT NULL,
-- 킬 상세
weapon VARCHAR(64),
distance DECIMAL(10,2), -- 미터 단위, 소수점 2자리
is_headshot BOOLEAN DEFAULT FALSE,
body_part VARCHAR(32), -- "head", "chest", "legs" 등
-- 메타데이터
timestamp TIMESTAMPTZ DEFAULT NOW(),
server_id VARCHAR(32) NOT NULL,
map_seed INT, -- 맵 시드 (시즌 구분용)
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 최신순 조회가 99%라서 이 인덱스 필수
CREATE INDEX idx_kill_logs_timestamp
ON kill_logs(server_id, timestamp DESC);
교훈: 테이블 설계는 처음부터 넉넉하게. 나중에 컬럼 추가하면 기존 데이터가 NULL이라 지저분해진다.
3단계: 프론트엔드 구독
useEffect 지옥
React에서 Supabase Realtime 구독하는 거, 생각보다 까다로웠다.
// 첫 번째 시도 - 메모리 누수 폭탄
useEffect(() => {
const channel = supabase
.channel('kill-feed')
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'kill_logs' },
(payload) => {
setKills(prev => [payload.new, ...prev]);
}
)
.subscribe();
// return에서 unsubscribe 안 함 - 메모리 누수!
}, []);
페이지 왔다갔다 하면 구독이 계속 쌓인다. 탭 10번 왔다가면 같은 킬이 10번 뜬다.
// 수정 버전
useEffect(() => {
const channel = supabase
.channel(`kill-feed-${serverId}`)
.on('postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'kill_logs',
filter: `server_id=eq.${serverId}` // 서버 필터링
},
(payload) => {
const newKill = payload.new as KillLog;
setKills(prev => {
// 중복 방지 (혹시 모르니까)
if (prev.some(k => k.id === newKill.id)) {
return prev;
}
// 최대 50개만 유지
const updated = [newKill, ...prev].slice(0, 50);
return updated;
});
}
)
.subscribe();
// 클린업 필수!
return () => {
supabase.removeChannel(channel);
};
}, [serverId]); // serverId 바뀌면 재구독
연결 끊김 처리
WebSocket이 끊어지는 경우가 있다:
- 사용자가 탭을 백그라운드로 (브라우저가 WebSocket 끊음)
- 네트워크 불안정
- Supabase 서버 점검 (매우 드묾)
useEffect(() => {
const channel = supabase
.channel(`kill-feed-${serverId}`)
.on('postgres_changes', /* ... */)
.on('system', { event: 'disconnect' }, () => {
console.warn('Realtime disconnected, will auto-reconnect');
setConnectionStatus('disconnected');
})
.on('system', { event: 'reconnect' }, () => {
console.log('Realtime reconnected');
setConnectionStatus('connected');
// 재연결 시 놓친 킬 로그 가져오기
fetchRecentKills();
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
setConnectionStatus('connected');
}
});
return () => supabase.removeChannel(channel);
}, [serverId]);
// 놓친 킬 로그 복구
async function fetchRecentKills() {
const { data } = await supabase
.from('kill_logs')
.select('*')
.eq('server_id', serverId)
.order('timestamp', { ascending: false })
.limit(20);
if (data) {
setKills(prev => {
// 기존 데이터와 머지
const merged = [...data, ...prev];
const unique = merged.filter((kill, index, self) =>
index === self.findIndex(k => k.id === kill.id)
);
return unique.slice(0, 50);
});
}
}
실제로 터진 문제들
사건 1: 킬피드 3시간 동안 안 뜸
상황: 2024년 12월 어느 토요일, 킬피드가 갑자기 안 떴다. 서버엔 20명이 놀고 있었다.
증상:
- 게임 서버 로그: 정상 (킬 후킹됨)
- Supabase 요청 로그: 201 성공
- 웹사이트: 킬피드 안 뜸
원인: Supabase Realtime 서비스가 일시적으로 불안정했다. 구독은 연결된 것처럼 보이는데 실제로 이벤트가 안 오고 있었다.
해결:
// 연결 상태 체크 + 강제 재연결
useEffect(() => {
let pingInterval: NodeJS.Timer;
const channel = supabase.channel('kill-feed')
// ...
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// 30초마다 연결 상태 확인
pingInterval = setInterval(async () => {
const isAlive = await checkRealtimeHealth();
if (!isAlive) {
// 강제 재구독
channel.unsubscribe();
channel.subscribe();
}
}, 30000);
}
});
return () => {
clearInterval(pingInterval);
supabase.removeChannel(channel);
};
}, []);
사건 2: 킬이 2번씩 뜸
상황: 같은 킬이 킬피드에 2번 나타남.
원인: React Strict Mode가 useEffect를 2번 실행해서 구독이 2개 생김.
해결: 채널 이름을 고유하게 만들고, 구독 전에 기존 채널 제거.
const channelId = `kill-feed-${serverId}-${Date.now()}`;
// 기존 채널 정리
supabase.getChannels().forEach(ch => {
if (ch.topic.startsWith('kill-feed-')) {
supabase.removeChannel(ch);
}
});
const channel = supabase.channel(channelId)
// ...
사건 3: 헤드샷인데 헤드샷 아니라고 뜸
상황: 분명히 헤드샷으로 죽인 건데 is_headshot이 false.
원인: Oxide에서 info.isHeadshot이 항상 정확하지 않더라. 특히 원거리 사격에서.
해결: 히트 포인트 위치를 직접 계산해서 머리 영역인지 판단.
private bool IsActualHeadshot(HitInfo info)
{
// 1차: Oxide 값 확인
if (info.isHeadshot) return true;
// 2차: 히트 본 확인
var boneName = info.boneName?.ToLower() ?? "";
if (boneName.Contains("head") || boneName.Contains("neck"))
{
return true;
}
// 3차: 히트 포지션으로 추정
// 플레이어 키가 약 1.8m, 머리는 1.5m 이상
var hitHeight = info.HitPositionWorld.y - info.HitEntity.transform.position.y;
if (hitHeight > 1.5f)
{
return true;
}
return false;
}
성능 최적화
지연시간 줄이기
처음엔 킬 발생부터 화면 표시까지 1-2초 걸렸다. 너무 느리다.
병목 1: 배치 처리 대기 시간
- 5초마다 배치 전송 → 최악의 경우 5초 대기
- 해결: 킬 발생 시 즉시 전송 + 배치는 백업으로만
private void OnEntityDeath(...)
{
var killLog = CreateKillLog(...);
// 즉시 전송 (1개짜리 배치)
SendSingleKill(killLog);
// 실패 대비 버퍼에도 추가
killBuffer.Add(killLog);
}
private void SendSingleKill(KillLogEntry kill)
{
var url = $"{config.SupabaseUrl}/rest/v1/kill_logs";
var json = JsonConvert.SerializeObject(kill);
webrequests.Enqueue(url, json, (code, response) =>
{
if (code == 201)
{
// 성공하면 버퍼에서 제거
killBuffer.RemoveAll(k => k.timestamp == kill.timestamp);
}
// 실패하면 버퍼에 남아서 나중에 배치로 재전송
}, this, RequestMethod.POST, headers);
}
병목 2: React 렌더링
- 킬 로그 50개 다시 렌더링
- 해결:
React.memo로 개별 킬 아이템 메모이제이션
const KillFeedItem = React.memo(({ kill }: { kill: KillLog }) => {
return (
<div className="kill-item">
<span className="killer">{kill.killer_name}</span>
<span className="weapon">{kill.weapon}</span>
<span className="victim">{kill.victim_name}</span>
{kill.is_headshot && <span className="headshot">💀</span>}
</div>
);
});
결과: 1-2초 → 200-400ms로 단축.
지금 시스템 구조
삽질 끝에 안정화된 현재 구조:
┌─────────────────────────────────────────────────────────────┐
│ Production Architecture │
├─────────────────────────────────────────────────────────────┤
│ │
│ [Rust Server - Azure VM] │
│ │ │
│ │ OnEntityDeath, OnPlayerConnected... │
│ ▼ │
│ [SupabaseSync.cs v2.1] │
│ │ │
│ ├── 즉시 전송 (단건) │
│ │ │ │
│ │ ▼ │
│ │ Supabase REST API ─────────────────┐ │
│ │ │ │
│ └── 5초마다 배치 (실패분 재전송) │ │
│ │ │ │
│ ▼ ▼ │
│ Supabase REST API ──────► [PostgreSQL] │
│ │ │
│ │ Realtime │
│ │ (WebSocket) │
│ ▼ │
│ [Web Client] │
│ │ │
│ ▼ │
│ React State │
│ │ │
│ ▼ │
│ UI Render │
│ │
│ 평균 지연: 200ms │
│ 99 percentile: 800ms │
│ 가용성: 99.5% (Supabase 의존) │
│ │
└─────────────────────────────────────────────────────────────┘
돌이켜보면
잘한 것:
- Supabase 선택: 무료 티어로 충분, Realtime 내장
- 배치 + 즉시 전송 하이브리드: 속도와 안정성 둘 다 잡음
- 연결 끊김 복구 로직: 사용자가 끊김 모름
못한 것:
- 테이블 설계 3번 바꿈: 처음부터 제대로 설계했어야
- 에러 처리 미흡: 초기엔 실패해도 그냥 로그만 찍음
- 모니터링 없음: 문제 생기면 유저가 먼저 알려줌
다음에 할 것:
- Prometheus + Grafana로 실시간 모니터링
- 에러 발생 시 Discord 알림
- 킬 로그 통계 (시간대별, 무기별)
코드 전체
혹시 비슷한 거 만들 사람 있을까봐 전체 코드 공유한다.
SupabaseSync.cs 전체 (접기/펴기)
// SupabaseSync.cs v2.1
// 게임 서버 → Supabase 실시간 동기화 플러그인
using Oxide.Core;
using Oxide.Core.Plugins;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("SupabaseSync", "softopia", "2.1.0")]
[Description("Syncs game events with Supabase")]
public class SupabaseSync : RustPlugin
{
private Configuration config;
private Timer syncTimer;
private List<KillLogEntry> killBuffer = new List<KillLogEntry>();
#region Configuration
private class Configuration
{
public string SupabaseUrl { get; set; } = "";
public string SupabaseKey { get; set; } = "";
public string ServerId { get; set; } = "main";
public int BatchIntervalSeconds { get; set; } = 5;
public int MaxBufferSize { get; set; } = 100;
}
protected override void LoadDefaultConfig()
{
config = new Configuration();
SaveConfig();
}
protected override void LoadConfig()
{
base.LoadConfig();
config = Config.ReadObject<Configuration>();
}
protected override void SaveConfig() => Config.WriteObject(config);
#endregion
#region Data Classes
private class KillLogEntry
{
public string killer_steam_id { get; set; }
public string killer_name { get; set; }
public string victim_steam_id { get; set; }
public string victim_name { get; set; }
public string weapon { get; set; }
public float distance { get; set; }
public bool is_headshot { get; set; }
public string timestamp { get; set; }
public string server_id { get; set; }
}
#endregion
#region Hooks
private void Init()
{
LoadConfig();
if (string.IsNullOrEmpty(config.SupabaseUrl))
{
PrintWarning("Supabase URL not configured!");
return;
}
syncTimer = timer.Every(config.BatchIntervalSeconds, SendBatch);
Puts("SupabaseSync initialized");
}
private void Unload()
{
syncTimer?.Destroy();
SendBatch(); // 남은 데이터 전송
}
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
if (!(entity is BasePlayer victim)) return;
if (info?.Initiator == null) return;
var killer = info.Initiator as BasePlayer;
if (killer == null) return;
if (killer.userID == victim.userID) return; // 자살 제외
var killLog = new KillLogEntry
{
killer_steam_id = killer.UserIDString,
killer_name = SanitizeName(killer.displayName),
victim_steam_id = victim.UserIDString,
victim_name = SanitizeName(victim.displayName),
weapon = GetWeaponName(info),
distance = Vector3.Distance(
killer.transform.position,
victim.transform.position
),
is_headshot = IsActualHeadshot(info),
timestamp = DateTime.UtcNow.ToString("o"),
server_id = config.ServerId
};
// 즉시 전송 시도
SendSingleKill(killLog);
// 버퍼에도 추가 (실패 대비)
killBuffer.Add(killLog);
if (killBuffer.Count > config.MaxBufferSize)
{
killBuffer.RemoveAt(0);
}
}
#endregion
#region Helpers
private string SanitizeName(string name)
{
if (string.IsNullOrEmpty(name)) return "Unknown";
// 특수문자 제거, 길이 제한
return new string(name.Where(c => !char.IsControl(c)).ToArray())
.Substring(0, Math.Min(name.Length, 32));
}
private string GetWeaponName(HitInfo info)
{
var weapon = info.Weapon?.GetItem()?.info?.displayName?.english;
if (!string.IsNullOrEmpty(weapon)) return weapon;
var damageType = info.damageTypes.GetMajorityDamageType();
return damageType switch
{
DamageType.Explosion => "Explosive",
DamageType.Heat => "Fire",
DamageType.Bite => "Animal",
DamageType.Fall => "Fall",
_ => info.Initiator?.ShortPrefabName ?? "Unknown"
};
}
private bool IsActualHeadshot(HitInfo info)
{
if (info.isHeadshot) return true;
var boneName = info.boneName?.ToLower() ?? "";
if (boneName.Contains("head") || boneName.Contains("neck"))
return true;
return false;
}
#endregion
#region API Calls
private void SendSingleKill(KillLogEntry kill)
{
var url = $"{config.SupabaseUrl}/rest/v1/kill_logs";
var json = JsonConvert.SerializeObject(kill);
var headers = GetHeaders();
webrequests.Enqueue(url, json, (code, response) =>
{
if (code == 201)
{
// 성공하면 버퍼에서 제거
killBuffer.RemoveAll(k =>
k.timestamp == kill.timestamp &&
k.killer_steam_id == kill.killer_steam_id
);
}
else if (code != 0)
{
Puts($"Kill send failed: {code}");
}
// code 0은 연결 실패, 버퍼에 남겨서 재시도
}, this, RequestMethod.POST, headers);
}
private void SendBatch()
{
if (killBuffer.Count == 0) return;
var batch = killBuffer.Take(50).ToList();
var url = $"{config.SupabaseUrl}/rest/v1/kill_logs";
var json = JsonConvert.SerializeObject(batch);
var headers = GetHeaders();
webrequests.Enqueue(url, json, (code, response) =>
{
if (code == 201)
{
foreach (var kill in batch)
{
killBuffer.Remove(kill);
}
Puts($"Batch sent: {batch.Count} kills");
}
else
{
Puts($"Batch failed: {code}");
}
}, this, RequestMethod.POST, headers);
}
private Dictionary<string, string> GetHeaders()
{
return new Dictionary<string, string>
{
["apikey"] = config.SupabaseKey,
["Authorization"] = $"Bearer {config.SupabaseKey}",
["Content-Type"] = "application/json",
["Prefer"] = "return=minimal"
};
}
#endregion
}
}
마무리
"실시간 킬피드"라는 단순한 기능 하나에 이렇게 많은 삽질이 있었다.
- C#이라 타입 세이프한 건 좋았는데, Oxide 문서가 부실해서 소스 코드 뒤져야 했다
- Supabase Realtime은 진짜 편하다. 직접 WebSocket 서버 만들었으면 미쳤을 듯
- 테이블 설계는 처음에 시간 투자하자. 나중에 바꾸면 고통스럽다
누군가 비슷한 거 만들 때 이 글이 도움 됐으면 좋겠다. 적어도 같은 실수는 안 하게.
다음 편에서는 Wiki 시스템 만든 얘기 한다. 이것도 삽질 많이 했다...
작성: 2025년 1월 | 마지막 수정: 2025년 1월 질문 있으면 디스코드로: softopia