블로그로 돌아가기
PharosRealtimeOxideSupabaseWebSocket삽질

새벽 3시, 킬피드가 안 떴다 - 게임 서버 실시간 연동 삽질기

Oxide 플러그인 에러부터 WebSocket 연결 끊김까지, 실시간 시스템을 만들면서 겪은 모든 실패와 해결 과정

Softopia
2024년 11월 19일
13 min read

프롤로그: "야 킬피드 왜 안 떠?"

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시간이 걸렸다.

이 글은 그런 삽질들의 기록이다.


실시간 연동이 뭔데?

간단히 말하면 이거다:

  1. 게임 서버에서 누가 누굴 죽임
  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을 활성화해야 했다.

  1. Database → Replication 메뉴로 이동
  2. Source 선택
  3. 원하는 테이블 체크

이것도 문서 안 읽고 삽질한 내 잘못이다.

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
);

문제들:

  1. Steam ID가 없어서 같은 이름 가진 사람 구분 불가
  2. 헤드샷인지 알 수 없음
  3. 거리 정보 없음
  4. 어느 서버인지 구분 불가 (나중에 서버 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