호스트 권위 기반 WebSocket 협동 릴레이 서버
DefenceSocketServer는 최대 4인 협동 플레이를 위한 릴레이(relay) 서버입니다. 쉽게 말해, 서버는 게임 내용을 전혀 이해하지 않고 같은 방 안의 플레이어들에게 메시지를 중계(전달)만 합니다. 대신 그 중계를 아주 빠르고 안전하게 하는 데 모든 공을 들였습니다.
한눈에 보기
이 서버의 철학을 한 문장으로 줄이면 “서버는 단순하게, 판단은 호스트가”입니다. 서버가 게임 규칙을 모르기 때문에, 게임 밸런스가 바뀌어도 서버를 다시 배포할 필요가 없습니다. 유지보수가 그만큼 쉽습니다.
여러 명이 화상회의를 한다고 생각해 보세요. 회의 서버는 사람들이 무슨 말을 하는지 이해하지 않습니다. 그냥 A가 말한 소리를 B·C·D에게 그대로 전달할 뿐이죠. 이 서버가 딱 그 역할입니다. “회의 내용”이 곧 게임 데이터고, “전달”이 곧 릴레이입니다.
서버가 하는 일
연결 관리 · 암호화 키 교환 · 방 만들기/입장/나가기 · 보낸 사람 빼고 방 전체에 메시지 전달 · 방장이 나가면 새 방장 뽑기 · 상태 모니터링.
서버가 하지 않는 일
메시지 내용(byte[] Data) 열어보기 · 게임 규칙 검증 · 데이터 저장 · 물리/판정 계산. 이 모든 판단은 방장(호스트) 클라이언트가 합니다.
- 호스트 권위(host-authoritative) 모델 — 방을 만든 사람이 게임 진행의 “주인”이고, 서버는 심부름꾼(전달자)입니다.
- MonoServer27의 고성능 코어를 그대로 이식 — 네트워크 전송 부분은 이미 성능 검증이 끝난 프로토타입 코드를 글자 하나 안 바꾸고(byte-identical) 가져왔습니다.
- Shared Project 구조 — 공용 코드(
DefenceG등)는 라이브러리(DLL)가 아니라 소스 코드째로 여러 서버에 포함됩니다.
시스템 아키텍처
프로세스 하나가 귀(리스너)를 두 개 열어둡니다. 하나는 게임 트래픽을 받는 WebSocket 서버(8023 포트),
다른 하나는 운영자가 상태를 확인하는 HTTP 서버(26677 포트)입니다. 정작 우리 코드는 얇고, 무거운 일은 전부 공용 라이브러리 DefenceG가 처리합니다.
부팅 순서 (Program.Main)
부팅은 비밀정보 → 설정 → 로깅 → 암호키 → 서버 시작 순서로 꼼꼼히 정해져 있습니다. 중간에 하나라도 실패하면 치명적 로그를 남기고 종료합니다.
// 1) AWS 금고(SecretsManager)에서 DB 접속정보 꺼내기
var accessKey = AccessKey.Load(Const.AwsAccessKey, Const.AwsEncryptionKey);
await SecretsManager.InitializeAsync(accessKey);
TblConnectionString.Initialize(SecretsManager.Get("CommonDB"));
// 2) 설정/데이터 핫리로드, Snowflake 로깅, Ip2Country, Redis 연결확인
await RefreshManager.InitializeAsync();
// 3) 프로세스 전체가 쓸 AES 키 확정 (이후 실행 중 교체 없음)
KeyIvChain.SetKey(Global.Shared.Key);
// 4) WebSocket 서버 시작 → 5) HTTP 호스트 실행(여기서 blocking)
var server = new Server();
Global.Shared.SocketServer = server;
server.Start(config.Port);
CreateHostBuilder(args).Build().Run();
접속 & 인증 흐름
연결은 두 단계 시한폭탄과 암호화 준비 과정으로 보호됩니다. 접속 직후엔 30초 안에 인사(핸드셰이크)를 안 하면 끊어버리는
TimeBomb이 걸리고, 인증을 마치면 180초 유휴 타임아웃으로 느슨해집니다. 그동안 7초마다 AliveTimer가 생존 신호를 주고받습니다.
TimeBomb은 “일정 시간 안에 아무 신호가 없으면 스스로 터지는(=연결을 끊는) 타이머”입니다. 수상한 접속(포트 스캐너 같은)은 30초 폭탄으로 빨리 정리하고, 정상 접속은 신호가 올 때마다 폭탄 시계를 다시 0으로 되돌려(Reset) 터지지 않게 합니다.
OnConnectAsync (접속)
Remote 객체를 재사용 풀에서 빌려옵니다. IV를 공용(Common)으로 초기화하고, 30초 시한폭탄을 장전, 7초 생존신호 타이머를 켭니다.
SetIv(Common) · TimeBomb 30sHandshakeQ → HandshakeA (암호화 준비)
아직 열쇠가 없으니 평문(암호화 안 함)으로 주고받습니다. 서버가 AES Key와 공용 CommonIv를 내려주어 암호 통신을 시작할 준비를 마칩니다. 이후 폭탄을 180초로 재장전.
AuthQ → AuthA (신원 확인)
유저 ID(SUID)/닉네임을 등록합니다. 응답에 이 연결만의 전용 IV(RemoteIv)를 실어, 1:1 통신용 암호 채널을 확정합니다.
RegisterRemote(suid) · RemoteIvRoom / Relay 사용
생존신호(AliveQ)를 받으면 서버 시간을 담아 응답합니다(클라이언트가 지연시간·시계 오차를 계산하는 용도). 어떤 메시지든 들어오면 시한폭탄이 리셋됩니다.
AliveQ→AliveA(ServerTime) · TimeBomb.Reset()OnDisconnectAsync (접속 종료)
ID 등록을 해제하고, 속해 있던 방에서 나갑니다(필요하면 방장 승계). 그리고 Remote 객체를 풀에 반납합니다(10초 뒤 재사용).
Unregister · LeaveAndNotify · CheckIn왜 시한폭탄을 30초 → 180초 두 단계로 나눴을까요? 인사도 안 하는 좀비 연결은 빨리 끊어 자원을 아끼고, 정상적으로 게임 중인 연결은 넉넉히 봐주기 위해서입니다. 7초 생존신호가 정상이면 180초 타이머는 계속 리셋되어 사실상 만료되지 않습니다.
프로토콜 & 직렬화
데이터를 선으로 보내기 전에 세 번 가공합니다: MessagePack(작게 만들기) → LZ4(압축) → AES-CBC(암호화). 포장을 풀 때 어떤 열쇠(IV)를 써야 하는지는, 메시지 앞에 붙는 1바이트짜리 꼬리표(keyIndex)가 알려줍니다.
직렬화(serialize)는 “C# 객체를 선으로 보낼 수 있는 바이트 뭉치로 바꾸는 것”입니다. JSON처럼 글자로 바꾸는 대신, MessagePack은 바이너리라 훨씬 작고 빠릅니다. 여기에 LZ4로 한 번 더 압축하고, AES로 잠급니다.
여러 종류의 메시지를 하나의 타입처럼 다루기 위해 MessagePack의 [Union](다형성)을 씁니다. 모든 메시지는 Protocol을 상속하고,
KeyIndex 값으로 “나는 어떤 암호 채널에 속한다”를 스스로 선언합니다(기본값 Remote).
private static readonly MessagePackSerializerOptions _options =
MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);
// 스레드마다 버퍼 1개를 계속 재사용 → 평소엔 메모리 새로 안 만듦(할당 0)
private static readonly ThreadLocal<ArrayBufferWriter<byte>> _writerPool =
new(() => new ArrayBufferWriter<byte>(256));
public static SerializedBuffer Serialize(Protocol protocol){
var writer = _writerPool.Value; writer.Clear(); // 비우고 다시 씀
MessagePackSerializer.Serialize<Protocol>(writer, protocol, _options);
return new SerializedBuffer(writer.WrittenMemory);
}
연결
AliveQ/A · HandshakeQ/A
AuthQ/A · Error
Room
Create · GetList · Join(+Notify)
Leave(+Notify) · Close · HostMigrateNotify
Relay
RelayQ → RelayA
{ FromSuid, byte[] Data }
받은 메시지의 분기 처리는 OnProcessAsync의 switch 하나로 끝납니다. 게임 도중 발생하는 예외(HaeginException)는
붙잡아 Error 메시지로 돌려주고, 연결은 끊지 않습니다.
try{ protocol = ProtocolSerializer.Deserialize(memory); }
catch(Exception){ throw new InvalidProtocolException(); } // 해석 불가 = 연결 차단
switch(protocol.ProtocolId){
case ProtocolId.AliveQ:
case ProtocolId.AliveA: TimeBomb?.Reset(); return await ProcessAliveAsync(...);
case ProtocolId.HandshakeQ: return await ProcessHandshakeAsync((HandshakeQ)protocol);
case ProtocolId.AuthQ: return await ProcessAuthAsync((AuthQ)protocol);
// ... CreateRoom / Join / Leave / Close / Relay
}
catch(HaeginException ex){ Send(new Error{ Result = ex.Result }); return true; }
Room & 호스트 권위
Room은 공용 코어의 Group을 상속해서 멤버 관리와 브로드캐스트를 공짜로 얻고,
거기에 게임 정보(방장, 스테이지, 상태)를 얹은 것입니다. 방 목록 전체는 RoomManager가 LockMan이라는 자물쇠로 안전하게 관리합니다.
Room 상태 머신
방 목록(GetRoomListQ)에는 Waiting 상태의 방만 보입니다. 정원(MemberMax)이 차면 곧바로 Playing이 되어 목록에서 사라집니다.
Relay — 핵심 데이터 통로
중계는 놀랄 만큼 단순합니다. 서버는 Data를 열어보지 않고, 보낸 사람만 빼고 방 전체에 그대로 흘려보냅니다. 이 단순함이 곧 서버의 확장성입니다.
private async Task<bool> ProcessRelayAsync(RelayQ protocol){
var room = Room;
if(room == null) return false;
await room.BroadcastAsync(new RelayA{
FromSuid = Suid,
Data = protocol.Data // 서버는 내용을 해석하지 않음
}, excludeSuid: Suid); // 보낸 사람은 제외
return true;
}
방 닫기와 “이중 퇴장” 방지
방장이 방을 닫는 CloseRoomQ는 순서가 중요합니다. 전원에게 “닫습니다”를 알린 뒤 방장 본인을 먼저 퇴장시킵니다. 이렇게 안 하면 곧이어 소켓이 끊길 때 OnDisconnect에서 또 한 번 퇴장 처리가 돌아 방장 승계가 꼬입니다.
CloseRoom은 “닫기 알림 → 방장 퇴장 → 나머지 정리 → 방 삭제” 순서를 강제해, 소켓 종료 콜백과 겹치는 경쟁 상황(race)을 원천 차단합니다.
또한 room.HostSuid != Suid 검사로 방장만 방을 닫을 수 있게 보장합니다.
호스트 마이그레이션
방장이 갑자기 나가도 방이 무너지면 안 됩니다. 남은 사람 중 가장 먼저 들어온 사람이 새 방장이 됩니다.
놀라운 점은 이 “가장 먼저”를 판단하려고 별도로 정렬하거나 시간을 비교하지 않는다는 것입니다. Group의 멤버 목록이 입장 순서를 그대로 유지하기 때문에 RemoteList[0]이 곧 최초 입장자입니다.
int count = await LeaveAsync(remote.RoomMembership);
if(count < 0) return;
if(remote.Suid == HostSuid && count > 0){ // 나간 사람이 방장이면
var list = RemoteList; // 변하지 않는 스냅샷(사본)
var newHost = (Remote)list[0]; // [0] = 가장 먼저 들어온 사람
HostSuid = newHost.Suid; HostNickname = newHost.Nickname;
await BroadcastAsync(new LeaveRoomNotify{ LeaverSuid = remote.Suid });
await BroadcastAsync(new HostMigrateNotify{ NewHostSuid = HostSuid, ... });
}
else if(count > 0) // 게스트가 나감: 방 유지, 알림만
await BroadcastAsync(new LeaveRoomNotify{ LeaverSuid = remote.Suid });
if(count == 0){ State = RoomState.Closed; await RoomManager.Shared.DestroyRoomAsync(RoomId); }
방장 이탈
새 방장 뽑기 → LeaveRoomNotify + HostMigrateNotify 방송.
게스트 이탈
방은 그대로, LeaveRoomNotify만 전송.
전원 이탈
count == 0 → 상태 Closed, 방 자동 삭제.
마이그레이션 코드에 정렬도, 시간 비교도 없습니다. 입장 순서가 자료구조(순서 있는 배열)에 이미 담겨 있어서, list[0] 한 줄이면 후계자가 정해집니다.
분산 락 없이도 정확한 이유는 이 배열이 다음 장에서 설명할 원자적 교체(CAS)로 관리되기 때문입니다.
Lock-free 동시성 — 이 서버의 심장
면접에서 가장 깊게 파고들 부분입니다. 이 서버의 전송 코어는 뜨거운 경로(hot path)에서 자물쇠(lock)를 쓰지 않습니다. 왜 그렇게 만들었고, 자물쇠 없이 어떻게 안전을 지키는지 차근차근 풀어보겠습니다.
여러 스레드가 같은 데이터를 동시에 건드리면 값이 깨질 수 있습니다. 그래서 보통 “한 번에 한 명만 들어가세요”라며 문에 자물쇠(lock)를 겁니다. 문제는, 사람(스레드)이 많아지면 자물쇠 앞에 줄이 생긴다는 것입니다. 게임 릴레이처럼 초당 수만 개의 짧은 메시지가 몰리는 상황에선, 이 줄 서서 기다리는 시간이 곧 렉(지연)이 됩니다.
Lock-free는 자물쇠 대신 CPU가 제공하는 “원자적 연산(atomic)”을 써서, 줄 서지 않고도 안전하게 데이터를 바꾸는 기법입니다. 아무도 기다리지 않으니 지연이 튀지 않습니다.
비교자물쇠 방식 vs Lock-free 방식
| 관점 | 일반 자물쇠(lock) | Lock-free (이 서버) |
|---|---|---|
| 경합이 심할 때 | 줄이 길어져 대기 폭증 | 기다림 없이 재시도로 통과 |
| 지연시간(latency) | 가끔 크게 튐(tail latency) | 고르고 예측 가능 |
| 데드락 위험 | 순서 꼬이면 발생 가능 | 기다리지 않으므로 없음 |
| 구현 난이도 | 쉽고 직관적 | 까다롭고 검증이 어려움 |
| 적합한 곳 | 저빈도 · 복잡한 정합성 | 초고빈도 · 짧은 작업 |
그래서 이 서버는 둘을 목적에 맞게 나눠 씁니다. 초고빈도인 메시지 송·수신은 lock-free로, 저빈도지만 복잡한 방 생성/삭제는 자물쇠(LockMan)로. “도구를 상황에 맞게 고른다”가 핵심입니다.
①메시지 보내기 — 우편함(채널)에 넣기
각 연결은 바운디드(크기 제한) 채널이라는 우편함을 하나 갖습니다. 여러 스레드가 이 우편함에 편지(메시지)를 자물쇠 없이 던져 넣고,
전담 배달부(sender task) 한 명이 순서대로 꺼내 실제 소켓으로 보냅니다. 이 구조를 MPSC(여러 명이 넣고, 한 명이 뺀다)라고 합니다.
소켓에 데이터를 쓰는 일은 순서가 뒤섞이면 안 됩니다. 배달부가 한 명이면 순서가 자연히 보장되고, 그 덕에 넣는 쪽은 여러 명이어도(SingleWriter=false) 안전합니다. 또 우편함이 가득 차면 이 서버는 “잠깐 기다렸다 재시도”가 아니라 연결을 끊습니다. 실시간 게임에서 밀린 옛날 메시지는 가치가 없으니, 질질 끌기보다 끊고 재접속시키는 게 낫다는 판단입니다.
Channel.CreateBounded<Frame>(new BoundedChannelOptions(SendChannelCapacity){
SingleReader = true, // 꺼내는 배달부는 1명 → 순서 보장
SingleWriter = false, // 넣는 스레드는 여럿 허용 (MPSC)
FullMode = BoundedChannelFullMode.Wait,
});
// 편지 봉투 만들기: [길이:4][열쇠번호:1][내용] 을 빌린 버퍼에 그 자리에서 암호화
byte[] rented = ArrayPool<byte>.Shared.Rent(total); // 버퍼를 새로 안 만들고 빌림
BinaryPrimitives.WriteUInt32LittleEndian(rented.AsSpan(0,4), (uint)total);
rented[4] = (byte)keyIndex;
_sendAes.Value.EncryptCbc(memory.Span, _keyIvChain.GetIv(keyIndex), rented.AsSpan(5, len));
if(ch.Writer.TryWrite(new Frame(rented, total))) handed = true;
else _ = DisconnectAsync(socketId); // 우편함이 꽉 차면 연결 종료
②멤버 목록 — “사본을 통째로 갈아끼우기”(CAS)
방의 멤버 목록은 절대 그 자리에서 수정하지 않습니다. 대신 “현재 목록을 복사 → 사본에 한 명 추가/삭제 → 원본을 사본으로 통째로 교체”합니다.
이 교체를 CPU의 원자적 명령인 CompareAndSwap(CAS)로 합니다.
“내가 봤을 때 값이 여전히 그대로면 새 값으로 바꿔줘. 그새 누가 바꿨으면 실패로 알려줘” — 이걸 쪼갤 수 없는 한 번의 동작으로 하는 명령입니다. 실패하면? 잠깐 숨 고르고(SpinWait) 최신 값으로 다시 시도합니다. 자물쇠처럼 남을 막지 않고, 지는 쪽만 다시 하는 방식이죠.
이 방식(copy-on-write)의 진짜 이점은 읽는 쪽에 있습니다. 브로드캐스트로 멤버 목록을 훑는 도중 다른 스레드가 입·퇴장을 해도, 내가 손에 든 사본은 변하지 않으므로 안전하게 끝까지 순회할 수 있습니다. (§05의 방장 승계가 정렬 없이 정확한 이유가 바로 이것)
if(gm.SetGroup(this) == false) return -4; // 한 사람은 한 방에만 (CAS 가드)
while(true){
var cur = Volatile.Read(ref _snapshot); // 현재 목록(원본) 읽기
if(cur.List.Length >= RemoteMax){ gm.ResetGroup(this); return -2; } // 정원 초과
var next = new Snapshot(cur.List.Add(remote), cur.Set.Add(remote)); // 사본에 추가
if(Interlocked.CompareExchange(ref _snapshot, next, cur) == cur){ // CAS 성공?
remote.SetIv(_keyIndex, Iv); // 입장과 동시에 방 IV 부여
return next.List.Length; // 반환값 = 새 멤버 수
}
Thread.SpinWait(1 << Math.Min(spinCount, 8)); // 졌으면 잠깐 쉬고 재시도
}
목록을 ImmutableArray(순서 유지)와 ImmutableHashSet(빠른 포함 검사, O(1))로 동시에 관리해, 순서도 지키고 “이미 방에 있나?” 검사도 빠르게 합니다.
③브로드캐스트 — 한 번 만들어 N명이 나눠 쓰기(zero-copy)
4명에게 같은 메시지를 보낼 때, 순진하게 만들면 4번 직렬화 + 4번 암호화 + 4개 버퍼가 됩니다. 이 서버는 딱 한 번만 만들어 버퍼 하나를 4명이 공유합니다.
공유 버퍼에 “아직 몇 명이 쓰는 중”이라는 카운터(refcount)를 붙여 4로 시작합니다. 각 연결이 다 보내면 카운터를 1씩 줄이고, 0으로 만든 마지막 한 명이 버퍼를 메모리 풀에 반납합니다. 덕분에 데이터 복사가 0번(zero-copy), 버퍼 생성도 1번뿐입니다.
internal sealed class SharedSendBuffer{
private readonly byte[] _buffer; private int _refCount; // 아직 쓰는 사람 수
public ReadOnlyMemory<byte> Memory => new(_buffer, 0, _length);
public void Release(){
if(Interlocked.Decrement(ref _refCount) == 0) // 내가 마지막이면
ArrayPool<byte>.Shared.Return(_buffer); // 불 끄고 반납
}
}
단, 상대마다 다른 열쇠(Remote IV)로 개별 암호화가 필요할 땐 공유가 불가능해 사람 수만큼 암호화합니다. 그래서 브로드캐스트는 방 공용 IV를 쓰도록 설계된 것입니다.
④복잡한 곳엔 자물쇠 — LockMan
전송은 lock-free지만, 방 목록처럼 여러 단계를 한 덩어리로 처리해야 하는 곳(정원 확인 → 방 생성 → 입장 → 실패 시 되돌리기)은 자물쇠가 더 안전하고 읽기 쉽습니다.
LockMan은 같은 흐름 안에서 다시 잠가도 데드락이 안 나는(재진입 가능) 비동기 자물쇠이고, 20초 타임아웃이 있어 영원히 멈추는 일도 막습니다.
private readonly LockMan _lock = new(TimeSpan.FromSeconds(20));
public async Task<Room> CreateRoomAsync(...){
using(await _lock.LockAsync()){ // 여기부터 임계구역
if(rooms.Count >= Config.Instance.RoomMax) return null;
var room = new Room(memberMax);
rooms[nextRoomId++] = room;
int count = await room.EnterAsync(remote.RoomMembership);
// ... 입장 실패하면 방을 다시 삭제(롤백)
}
}
⑤메모리 재활용 — GC에게 일 안 주기
C#은 안 쓰는 메모리를 가비지 컬렉터(GC)가 자동으로 치웁니다. 편하지만, 치우는 순간 잠깐 멈칫(stop)할 수 있어요. 초당 수만 개 메시지마다 버퍼를 새로 만들면 쓰레기가 산더미가 되고 GC가 자주 돌아 렉이 됩니다. 그래서 이 서버는 버퍼를 버리지 않고 빌려 쓰고 반납합니다(풀링).
객체 풀링
ConcurrentQueue Remote 풀 + SemaphoreSlim(RemoteMax)로 동시 접속 수 상한. 끊긴 연결은 10초 뒤 재사용.
버퍼 / 암호객체 풀
ArrayPool(송·수신 버퍼), ThreadLocal<Aes>·AesPool(암호 객체), LinearBufferStream(수신 버퍼 재사용).
용어한 번 더 짚고 가기
Interlocked.CompareExchange.“게임 릴레이는 짧은 메시지가 초당 수만 건 몰리는 워크로드다. 여기서 락 경합과 GC는 곧 지연이므로, 송·수신은 채널·CAS·풀링으로 lock-free하게, 저빈도·복잡한 방 관리는 재진입 락으로 처리해 tail latency를 눌렀다.”
Room 기반 실시간 서버의 정석 — Actor 모델과 격리
잠깐 이 서버 밖으로 나와서, Room 기반 실시간 서버가 일반적으로 어떤 형태를 지향하는지 살펴봅니다. 핵심 키워드는 두 개입니다 — 방별 격리(per-room isolation), 그리고 그 격리를 구현하는 대표 패턴인 액터(Actor) 모델.
맞습니다. Room 기반 서버의 큰 지향점은 “방을 서로 독립된 섬으로 만드는 것”입니다. 한 방에서 무슨 일이 생겨도(폭주·버그·크래시) 다른 방에는 번지지 않게 하고, 방 하나를 통째로 확장의 단위(샤딩)로 삼습니다. 이 격리를 가장 깔끔하게 실현하는 설계가 바로 Actor 모델입니다.
개념Actor 모델이란?
Actor는 “자기만의 우편함(mailbox)을 가진 작은 일꾼”입니다. 규칙은 세 가지뿐이에요.
① 자기 상태를 남과 공유하지 않는다 · ② 오직 메시지로만 소통한다(우편함에 넣기) · ③ 우편함의 메시지를 한 번에 하나씩 순서대로 처리한다.
③ 덕분에 액터 안에서는 락이 필요 없습니다. 애초에 동시에 두 가지가 실행되지 않으니까요 — 이걸 “싱글스레드 섬”이라고 부릅니다.
Room을 하나의 Actor로 만들면, 방마다 전용 우편함 + 처리 루프 + 자기 상태를 갖습니다. 같은 방에 이벤트가 아무리 몰려도 순서대로 하나씩 처리되니, 방 안의 게임 상태를 레이스(race) 걱정 없이 마음껏 바꿀 수 있습니다.
이유왜 게임 방에 잘 맞을까
- 락 없는 안전성 — 직렬 처리라 방 내부 상태 변경에 자물쇠가 필요 없다.
- 장애 격리 & 감독(supervision) — 한 방이 죽어도 그 방만 재시작. Erlang의 “let it crash” 철학.
- 위치 투명성 & 샤딩 — 방은 어느 노드에 있든 주소로 접근 가능하고, 방 단위로 노드에 흩뿌려 수평 확장한다.
사례업계에서 쓰는 것들
MS Orleans
가상 액터(grain) 모델. Halo·Gears of War 백엔드. .NET 진영 대표.
Akka / Akka.NET
고전 액터 프레임워크. 액터 계층 + 감독 트리(supervision).
Erlang / Elixir (BEAM)
프로세스 = 액터. Discord·WhatsApp이 대규모로 사용.
Colyseus (Node.js)
Room이 1급 추상화. 방마다 상태·수명주기 관리.
Photon / SmartFoxServer
방 기반 게임 서버의 상용 표준.
공통점
전부 방 = 격리된 상태 단위를 전제로, 메시지 기반으로 다룬다.
비교그래서 DefenceSocketServer는 Actor 모델인가?
절반만 그렇습니다. “방을 독립 상태 단위로 격리한다”는 목표는 공유하지만, 그 방식은 정통 액터 모델과 다릅니다.
| 관점 | 정통 Actor 모델 (방 = 액터) | DefenceSocketServer |
|---|---|---|
| 방 실행 방식 | 방마다 전용 우편함 + 단일 처리 루프 | 연결(Remote)별 수신 루프, 방 전용 루프 없음 |
| 방 내부 동시성 | 직렬 처리 → 락 불필요 | 여러 스레드 동시 접근 → lock-free 구조로 방어 |
| 멤버십 관리 | 액터 내부 상태라 단순 | copy-on-write 스냅샷 + CAS |
| 상태 격리 | O (방마다 독립) | O (방마다 독립) |
| 실행 격리(직렬화) | O | X (일부러 안 함) |
이 서버는 순수 릴레이라 방 안에 보호해야 할 가변 게임 상태가 거의 없습니다(멤버 목록 + 메타데이터뿐). 액터의 “한 번에 하나씩” 직렬 처리가 빛나는 건 레이스 없이 바꿔야 할 권위적 상태가 있을 때인데, 릴레이엔 그게 없죠. 그래서 무거운 액터 런타임 대신 가벼운 lock-free 자료구조로 충분했고, 오히려 더 단순하고 빠릅니다.
서버가 게임 상태를 들고 판정까지 하게 된다면(예: 랭킹전, 서버측 치트 방어), 방을 액터로 만드는 것(방당 단일 처리 루프)이 가장 자연스러운 다음 수순입니다. 그때는 방 내부 상태 변경이 직렬화되어 lock-free 저글링 없이도 안전해집니다. §12 발전 방향의 “수평 확장·서버 권위” 논의와 이어지는 지점입니다.
실시간 서버의 락 관리 — 일반적인 전략들
§06에서 이 서버의 lock-free를, §07에서 Actor(직렬 처리)를 봤습니다. 사실 이 둘은 실무 락 관리의 스펙트럼 양 끝입니다. “락을 최대한 잘게 쪼개기”부터 “아예 락이 필요 없게 만들기”까지, 실시간 서버가 흔히 쓰는 방법들을 한 줄에 세워보겠습니다.
동시성 문제를 푸는 길은 결국 두 갈래입니다. “공유하되 잘 지키거나(락)”, 아니면 “애초에 공유를 안 하거나(격리·불변).” 아래로 갈수록 공유가 줄고 격리가 강해져 락 의존이 사라집니다. 성능·확장성은 대체로 아래쪽이 유리하지만, 구현 난이도와 제약도 함께 커집니다.
스펙트럼락을 다루는 6가지 층위
| 전략 | 한 줄 설명 | 쓰는 곳 / 예 |
|---|---|---|
| ① 전역 락 (coarse) | 큰 자물쇠 하나로 전체 보호 | 단순하지만 전부 직렬화 = 병목. 초기 프로토타입 수준 |
| ② 방별 락 (fine-grained) | 방마다 락 1개 → 경합을 방 안으로 국한 | 대부분의 방 서버 기본기 |
| ③ 락 스트라이핑 | N개 락을 키 해시로 분배 (roomId % N) | ConcurrentDictionary 내부 방식 |
| ④ 읽기-쓰기 락 (RW) | 독자는 여럿 동시, 저자만 단독 | 읽기 많은 상태 (방 목록 조회) |
| ⑤ Lock-free (CAS) | 락 없이 시도→검증→재시도 | 이 서버 전송·멤버십 (§06) |
| ⑥ 직렬 실행 (Actor) | 동시에 안 돌려 락이 아예 불필요 | Actor·게임루프 스레드·Node/Redis 단일스레드 (§07) |
함정async 환경 — 일반 lock은 await를 못 넘는다
C#의 lock(obj){ … }은 중간에 await를 넣을 수 없습니다. 락을 잡은 스레드와 await 후 재개되는 스레드가 다를 수 있기 때문이죠.
그래서 비동기 서버는 락 대신 SemaphoreSlim(1,1)이나 전용 async 뮤텍스를 씁니다.
이 서버의 LockMan이 바로 그 async 락이고, 여기에 재진입 + 20초 타임아웃까지 얹은 것입니다.
실무락 관리 황금률 (체크리스트)
- 임계구역은 짧게, 락 잡고 I/O 금지 — 락을 쥔 채 네트워크·DB를
await하면 그 락이 “느린 락”이 되어 전체를 마비시킨다. - 락 순서를 고정 — 여러 락을 잡을 땐 항상 같은 순서로. 순서가 엇갈리면 데드락.
- 직접 짜기 전에 표준 도구부터 —
ConcurrentDictionary·Channels·Immutable*가 이미 검증된 락 관리를 대신해 준다. - 읽기:쓰기 비율로 고르기 — 읽기가 압도적이면 RW락/불변 스냅샷, 쓰기 경합이 심하면 샤딩/직렬화.
- 최고의 락 관리는 “공유 상태 줄이기” — 상태를 소유자 하나로 격리하면(Actor·샤딩) 락 자체가 사라진다.
종합그래서 이 서버는? — 경로별로 다르게 쓴다
정답은 “한 가지로 통일”이 아니라 “경로마다 특성에 맞는 전략을 섞는 것”입니다. 이 서버가 교과서적으로 그렇게 하고 있습니다.
방 딕셔너리
저빈도 · 다단계 정합성 → async 재진입 락(LockMan). 대신 임계구역을 짧게.
RoomManager
방 멤버십
중빈도 · 읽기 순회 많음 → lock-free COW 스냅샷 + CAS.
Group
메시지 송·수신
초고빈도 → 채널 + Interlocked, 무락.
NetSocket
“락 전략은 단일 정답이 아니라 워크로드별 선택이다. 이 서버는 저빈도·복잡한 방 관리엔 async 락을, 초고빈도인 전송엔 lock-free를 쓴다. 그리고 궁극적으로 가장 좋은 락은 ‘공유 상태를 없애는 것’ — 서버 권위가 필요해지면 방을 Actor로 격리해 락을 지우는 방향이 정석이다.”
보안 모델
암호화는 AES-CBC 하나로 통일하되, 용도(스코프)별로 IV를 따로 둡니다. 열쇠(Key)는 프로세스 전체가 공유하는 하나뿐이고, IV는 채널마다 다르게 관리됩니다.
AES 암호화에는 열쇠(Key)와 초기값(IV) 두 가지가 필요합니다. 열쇠가 같아도 IV가 다르면 암호문 모양이 완전히 달라집니다. 그래서 열쇠는 하나로 두되, “1:1 통신용 IV”, “방 통신용 IV”를 따로 써서 채널을 분리하고 보안을 높입니다.
None (0)
암호화 없음. Handshake 전용 — 아직 열쇠를 모르는 시점이라 평문.
Common (1)
핸드셰이크 직후 쓰는 공용 IV. Auth 단계에서 사용.
Remote (2)
연결마다 다른 IV. 1:1 통신용. 접속할 때마다 새로 생성.
Room / Group1 (3)
방마다 다른 IV. 브로드캐스트가 이 IV를 공유해 한 번 암호화로 N명에게 보낼 수 있음.
핸드셰이크 응답이 암호 통신의 시작점입니다. 서버가 열쇠와 공용 IV를 평문으로 내려준 뒤부터, 모든 통신이 암호화됩니다.
public class HandshakeA : Protocol{
public override KeyIndex KeyIndex { get; } = KeyIndex.None; // 평문으로 보냄
[Key(1)] public bool VersionOk { get; set; }
[Key(2)] public byte[] Key { get; set; } // AES 열쇠 전달
[Key(3)] public byte[] CommonIv { get; set; } // 공용 IV 전달
}
열쇠를 평문으로 내려주므로, 이 방식은 TLS(wss) 위에서 돌아간다는 전제가 있어야 도청에 안전합니다. 즉 AES 계층은 “TLS 통과 이후 내부 데이터 보호 + 프로토콜 난독화” 성격입니다. 면접에서 이 전제를 먼저 짚으면 좋은 인상을 줍니다.
운영 & 배포
게임용 WebSocket과 별도로, HTTP 서버가 운영자용 확인 창구를 제공합니다. 미들웨어 Route가 URL 경로를 IRoute 구현체로 연결합니다.
/ , /Test
헬스체크 → "OK"
/Monitor
ServerName · Connected · RoomCount · PublicRoomCount
/Refresh
설정 핫리로드 트리거
Config
AWS SecretsManager 로드 · [Refreshable] · 20초 스로틀
배포
deploy.sh는 dotnet publish로 런타임 포함 단일 실행파일(self-contained, linux-x64)을 만들어 tar로 묶고 대상 서버로 보냅니다. CI는 Jenkins(Ubuntu 24.04 + .NET SDK 10).
dotnet publish ${PROJ_PATH} \
--output ${BIN_DIR} --runtime linux-x64 \
--configuration Release --self-contained \
--property:PublishSingleFile=true
ImplicitUsings·Nullable이 모두 disable입니다. 모든 using을 직접 써야 하고 null 경고가 없습니다.
컴파일 상수 TRACE; LOG2SNOWFLAKE가 Debug/Release 공통으로 켜져 Snowflake 로깅이 활성화됩니다.
트레이드오프 & 한계
시니어 면접에서는 “무엇을 잘했나”보다 “무엇을 일부러 포기했나”가 더 중요합니다. 이 서버가 의도적으로 감수한 트레이드오프들입니다.
실행 중 키 교체 없음
프로세스 전체가 AES 열쇠 하나를 공유합니다. 덕분에 ThreadLocal<Aes> 캐싱이 안전하지만, 열쇠를 바꾸려면 재배포가 필요합니다(설계 문서 명시).
큐가 차면 = 연결 종료
느린 클라이언트로 송신 큐가 가득 차면 재시도·드롭이 아니라 연결을 끊습니다. 지연 폭증은 막지만, 순간적 버스트에 민감합니다.
중계 내용 무검증
서버가 Data를 안 열어보므로 치트 방어를 호스트에 맡깁니다. 악의적인 호스트를 막을 서버측 방어선이 없습니다.
인메모리 · 단일 프로세스
방 상태가 메모리에만 있습니다. 프로세스가 재시작되면 모든 방이 사라집니다. nextRoomId도 인스턴스별이라 서버 여러 대로 늘리려면 별도 계층이 필요합니다.
이 한계들은 대부분 “릴레이 서버”라는 정체성의 자연스러운 결과입니다. 상태를 갖지 않기로 했기에, 확장·복구·보안 책임이 클라이언트나 상위 계층으로 넘어갔습니다. 협동(co-op, 방당 4인) 규모에선 합리적 선택이며, 경쟁형 PvP였다면 서버 권위 모델로 갔어야 합니다 — 이 구분을 말할 수 있으면 강한 답변이 됩니다.
아쉬운 점 & 발전 방향
§11이 “일부러 포기한 것”이라면, 여기는 “지금 코드에서 개선하면 좋을 것”과 “다음 단계로 발전시킬 방향”입니다. 면접에서 “이 서버를 맡으면 뭘 먼저 하겠나?”라는 질문에 그대로 답이 되는 내용입니다.
아쉬운 점 — 지금 코드에서 눈에 밟히는 것들
에러가 뭉뚱그려져 있다
실패를 대부분 Result.UnknownError 하나로 돌려줍니다. “방이 꽉 참”인지 “방이 없음”인지 클라이언트가 구분하기 어려워 디버깅과 UX가 불리합니다.
개선: RoomFull / RoomNotFound / NotHost 등 구조화된 에러 코드 도입.
관측성(observability)이 얕다
/Monitor가 카운트 4개만 노출합니다. 프레임 처리 지연, 브로드캐스트 팬아웃, 에러율 같은 운영에 필요한 지표가 없어 문제 원인 추적이 힘듭니다.
개선: 메트릭·분산 트레이싱 계측(아래 로드맵 참조).
재접속/세션 복구가 없다
잠깐 네트워크가 끊기면 방에서 완전히 이탈합니다. 모바일 환경(터널 통과, 화면 전환)에서 체감이 좋지 않습니다.
개선: 재접속 토큰 기반 세션 재개(grace period).
과부하 방어가 없다
한 클라이언트가 RelayQ를 폭주시키면 방 전체로 증폭됩니다(팬아웃). 연결당 속도 제한·페이로드 크기 상한이 없습니다.
개선: per-connection rate limit + Data 최대 크기 검증.
자동화 테스트가 안 보인다
동시성 코드가 핵심인데 부하·통합 테스트 흔적이 없습니다. lock-free 코드는 눈으로 검증하기 가장 어려운 영역입니다.
개선: 다중 클라이언트 부하 시나리오 + 동시성 스트레스 테스트.
방 목록 확장성
GetPublicRoomsAsync가 매번 전체 방을 훑어 List로 반환합니다. 방이 많아지면 페이지네이션·필터가 없어 부담이 됩니다.
개선: 커서 기반 페이지네이션 + 스테이지/국가 필터.
발전 방향 — 우선순위별 로드맵
관측성 계측 (OpenTelemetry)
프레임 처리 지연 히스토그램, 방 수·동접, 에러율, 큐 포화로 인한 강제 종료 카운트를 메트릭/트레이스로 노출. “측정 없이는 개선도 없다.”
과부하 방어 & 입력 검증
연결당 릴레이 속도 제한, Data 크기 상한, 비정상 프레임 폭주 감지. 악성 호스트/버그 클라이언트로부터 방 전체를 보호.
재접속 & 세션 재개
짧은 단절 시 재접속 토큰으로 같은 방·같은 슬롯에 복귀. 모바일 사용자 경험 대폭 개선.
수평 확장 (매치메이킹 계층)
Redis에 방↔인스턴스 매핑을 두고, 한 방의 모든 멤버를 같은 인스턴스로 라우팅(sticky). 방 단위 샤딩으로 scale-out.
키 교환 강화 (ECDH)
평문으로 열쇠를 내려주는 대신 비대칭 키 교환으로 세션 키를 합의. TLS 의존도를 낮추고 실행 중 키 로테이션 여지 확보.
Graceful shutdown & 방 드레이닝
배포·스케일인 시 진행 중인 방을 안내 후 정리하거나 다른 인스턴스로 이관. 무중단 배포의 기반.
“구조 자체는 목적에 매우 잘 맞게(lean & fast) 설계돼 있습니다. 제가 맡는다면 코드를 갈아엎기보다, 먼저 관측성을 붙여 실제 병목을 측정하고, 그다음 과부하 방어와 재접속처럼 사용자 체감·안정성에 직결되는 것부터 손대겠습니다. 수평 확장은 트래픽이 그 지점에 도달했을 때 데이터에 근거해 진행하겠습니다.”
면접 예상 질문
위 내용을 근거로, 시니어 레벨에서 나올 법한 질문과 모범 답변 뼈대입니다. 펼쳐서 확인하세요.
호스트 권위 모델과 서버 권위 모델의 차이, 그리고 왜 여기선 호스트 권위인가?
서버 권위는 게임 상태·판정을 서버가 소유해 치트에 강하지만, 서버가 게임 로직을 알아야 하고 연산·대역폭 비용이 큽니다. 호스트 권위는 한 클라이언트(호스트)가 진행을 소유하고 서버는 중계만 합니다.
이 프로젝트는 협동(co-op) 4인이 타깃입니다. 참가자끼리 이해관계가 대립하지 않아 치트 동기가 낮고, 서버를 게임 규칙과 분리(decoupling)해 규칙이 바뀌어도 서버 재배포가 불필요합니다. PvP였다면 서버 권위가 맞습니다.
“lock-free 전송 코어”라는데, 구체적으로 어떤 자료구조로 락을 없앴나?
세 축입니다. ① 송신은 System.Threading.Channels의 바운디드 MPSC 채널(배달부 태스크 1개)로, 여러 스레드가 락 없이 TryWrite. ② 연결 상태 전이는 Interlocked.CompareExchange(CAS). ③ Remote 풀은 ConcurrentQueue + SemaphoreSlim.
그룹 멤버십은 ImmutableArray/ImmutableHashSet 스냅샷을 CAS로 통째로 교체하는 copy-on-write입니다. 경합하면 진 쪽이 지수 SpinWait 백오프 후 최신 값으로 재시도합니다.
CAS가 뭔지, 그리고 자물쇠(lock) 대비 언제 유리한가?
CAS는 “값이 내가 본 그대로면 새 값으로 바꾸고, 아니면 실패”를 쪼갤 수 없는 한 번의 원자적 동작으로 하는 CPU 명령입니다. 자물쇠와 달리 남을 막지 않고, 경합에서 진 쪽만 다시 시도합니다.
짧은 작업이 초고빈도로 몰릴 때 유리합니다. 락은 그럴 때 대기 줄이 길어져 지연이 튀지만, CAS는 기다림이 없어 tail latency가 고릅니다. 반대로 여러 단계를 원자적으로 묶어야 하는 복잡한 작업은 CAS로 표현하기 어려워 락이 더 안전하고 읽기 쉽습니다.
브로드캐스트에서 N명에게 보낼 때 메모리 복사를 어떻게 0으로 만드나?
SharedSendBuffer입니다. 방 전체에 같은 메시지를 보낼 때 직렬화·암호화·프레이밍을 한 번만 해서 ArrayPool 버퍼 하나에 담고, refcount를 N으로 둡니다. 각 소켓은 그 ReadOnlyMemory를 그대로 전송하고, 다 쓰면 Interlocked.Decrement. 0을 만드는 마지막 소비자가 버퍼를 풀에 반납합니다.
단, 상대별 IV로 개별 암호화가 필요한 경우엔 공유가 불가능해 per-remote 암호화로 폴백합니다. 브로드캐스트가 방(Group) 스코프 IV를 공유하도록 설계한 이유가 여기 있습니다.
copy-on-write 스냅샷이 브로드캐스트 도중의 멤버 변경과 어떻게 공존하나?
순회는 항상 변하지 않는 사본(Volatile.Read로 읽은 ImmutableArray)을 대상으로 합니다. 순회 중 다른 스레드가 입·퇴장하면 새 스냅샷이 만들어져 참조만 바뀔 뿐, 내가 든 배열은 그대로입니다. 그래서 락 없이 foreach가 안전합니다.
대가는 입·퇴장마다 배열 사본 생성(할당)이지만, 멤버 변경은 릴레이 프레임에 비해 극히 드물어 트레이드오프가 유리합니다.
호스트가 나가면 새 호스트를 어떻게 정하나? 왜 정렬이 필요 없나?
RemoteList[0], 즉 가장 먼저 입장한 남은 멤버입니다. EnterAsync가 항상 배열 끝에 append하므로 입장 순서가 자료구조에 담겨 있습니다. 따라서 타임스탬프 비교나 정렬 없이 list[0] 한 줄로 결정론적 후계자가 나옵니다.
선정 후 LeaveRoomNotify와 HostMigrateNotify를 방 전체에 방송합니다.
TimeBomb을 30초·180초 두 단계로 나눈 이유는?
접속 직후 30초는 인사(핸드셰이크)도 안 하는 좀비 연결(포트 스캐너 등)을 빨리 정리해 Remote 풀과 소켓 자원을 아끼기 위함입니다. 인증을 통과하면 180초 유휴 타임아웃으로 바꿉니다.
정상 세션은 7초 주기 AliveTimer가 생존신호를 보내고, 모든 수신 메시지가 TimeBomb.Reset()을 호출하므로 180초가 실제로 만료되는 건 연결이 끊긴 경우뿐입니다.
송신 큐가 가득 차면 왜 재시도가 아니라 연결을 끊나?
실시간 게임에서 밀린 옛날 프레임은 가치가 없습니다. 큐가 계속 찬다는 건 클라이언트가 소비 속도를 못 따라온다는 신호이므로, 무한 버퍼링으로 지연·메모리를 키우느니 끊고 재접속시키는 편이 전체 tail latency에 유리합니다.
트레이드오프: 순간적 네트워크 버스트에도 끊길 수 있으니 채널 용량(SendChannelCapacity) 튜닝이 중요합니다. (§12에서 rate limit·재접속으로 보완 제안)
AES 키를 평문 핸드셰이크로 내려주면 위험하지 않나?
그 자체로는 도청에 취약합니다. 따라서 이 방식은 전송 계층 TLS(wss)를 전제로 하고, AES는 그 위에서 “내부 데이터 보호 + 프로토콜 난독화 + 스코프 분리(IV)” 역할을 합니다.
개선한다면 핸드셰이크에서 비대칭 키 교환(예: ECDH)으로 세션 키를 합의하는 것이 정석입니다. 현재는 단순성·성능을 택했고, 실행 중 키 로테이션이 없다는 제약도 함께 안고 갑니다.
이 서버를 수평 확장(scale-out) 하려면 무엇이 필요한가?
방 상태가 인메모리·프로세스 로컬이고 nextRoomId도 인스턴스별이라, 인스턴스를 늘리려면 상위에 매치메이킹/디렉터리 계층이 필요합니다. 방→인스턴스 매핑을 공유 저장소(Redis 등)에 두고, 한 방의 모든 멤버를 같은 인스턴스로 라우팅(sticky)해야 합니다.
방은 짧게 살고 4인 규모라 방 단위 샤딩이 자연스럽습니다. 인스턴스 간 릴레이는 지연을 키우므로 피하는 게 맞습니다.
Room 기반 서버는 보통 Actor 모델(방별 격리)을 쓴다던데, 이 서버는 왜 안 썼나?
Actor 모델의 목표인 “방별 상태 격리”는 이 서버도 공유합니다. 다만 정통 액터는 방마다 전용 우편함(mailbox)과 단일 처리 루프를 둬서 “직렬 처리 → 락 불필요”를 얻는데, 이 서버는 방 전용 루프 없이 연결(Remote)별 수신 루프 + lock-free 멤버십으로 처리합니다.
이유는 순수 릴레이라 방에 보호할 가변 상태가 거의 없기 때문입니다. 액터 직렬화의 이점(레이스 없는 상태 변경)이 필요 없으니, 무거운 액터 런타임 대신 가벼운 lock-free 구조로 충분하고 더 단순·빠릅니다. 반대로 서버 권위 로직(랭킹전·치트 방어 등)이 생기면 방=액터 전환이 정석입니다.
동시성 제어 전략(락 · lock-free · actor)은 어떤 기준으로 고르나?
워크로드로 고릅니다. 접근 빈도, 읽기:쓰기 비율, 임계구역의 복잡도를 봅니다. ▸ 저빈도·다단계 정합성이면 락(가독성·안전이 우선) ▸ 초고빈도·짧은 작업이면 lock-free(CAS·채널) ▸ 읽기가 압도적이면 RW락 / 불변 스냅샷 ▸ 보호할 상태가 권위적이고 복잡하면 Actor로 직렬화.
공통 황금률은 임계구역 짧게 + 락 잡고 I/O 금지, 락 순서 고정(데드락 방지), 표준 concurrent 자료구조 우선, 그리고 궁극적으로 공유 상태 자체를 줄이기입니다. 이 서버가 방 관리=async 락 / 멤버십=lock-free / 전송=무락으로 셋을 섞어 쓰는 게 그 실전 예입니다. (근거는 §08)
이 서버를 맡게 되면 가장 먼저 무엇을 개선하겠나?
먼저 관측성부터 붙이겠습니다. 프레임 처리 지연·에러율·큐 포화 카운트 같은 지표가 없으면 어디가 병목인지 추측만 하게 됩니다. 측정이 되면 그다음 과부하 방어(rate limit)와 재접속처럼 안정성·사용자 체감에 직결되는 것부터 손대겠습니다.
lock-free 전송 코어 자체는 목적에 잘 맞으니 성급히 바꾸지 않고, 수평 확장은 트래픽이 실제로 그 지점에 도달했을 때 데이터를 근거로 진행하겠습니다. (근거는 §12 로드맵)