온라인 게임은 서로 떨어진 컴퓨터가 약속대로 말을 주고받아 굴러갑니다. 이 수업에서는 그 약속인 통신 규약, 객체를 줄 세워 보내는 직렬화, 주고받을 말의 모양인 패킷, 그리고 쓰레드를 쓸 때 조심할 점을 배웠습니다.
2022년 1학기 한국애니메이션고등학교 2학년 「게임 개발과 미들웨어」 4강 「네트워크 프로그래밍 #1」에서 가르친 내용입니다.
통신 규약
Blocking / NonBlocking
Blocking 은 전송이 끝날 때까지 프로그램이 멈춰 기다립니다. NonBlocking 은 기다리지 않고 다음 일을 합니다. 디스크에 파일을 쓸 때가 NonBlocking 의 예입니다.
Transfer Protocol
HTTP, TCP, UDP, FTP 처럼 용도에 따라 정한 통신 약속입니다. 이름 끝에 TP 가 붙는 표준이 많지만, 게임 전용으로 만든 약속도 프로토콜이라고 부릅니다.
Listen / Accept
통신하려면 누군가 연결을 기다려야 합니다(Listen). TCP 는 상대의 연결을 받아들인(Accept) 뒤에 대화가 시작됩니다.
Network Streaming
직렬화한 데이터를 멀리 있는 상대에게 흘려보내는 것입니다. 파일을 읽어 내려가는 File Streaming 과 같은 모양입니다.
Packet
약속을 지키며 오가는 가장 작은 단위입니다. 받는 쪽이 패킷의 시작과 끝을 알아야 말을 끊어 읽을 수 있습니다.
게임 업계에서는
TCP 와 UDP 를 모두 씁니다. 보통은 멀티 쓰레드 TCP 를 쓰고, 전용 서버나 P2P 같은 곳에는 UDP 를 씁니다.
TCP 와 UDP

TCP
- 연결을 맺고 통로를 만든 뒤 통신합니다
- 잃어버리는 데이터가 없고 순서도 지켜집니다
- Blocking 성격이라 느립니다
UDP
- 연결 없이 목적지 쪽으로 던집니다
- 받았는지, 순서가 맞는지 보장하지 않습니다
- 빠릅니다
TCP 의 가장 큰 약점은 기다림입니다. 한 사람과 주고받는 동안 다른 사람이 기다려야 하면 서버가 멈춥니다. 그래서 접속한 사람(Peer)마다 쓰레드를 하나씩 붙여 동시에 처리하는 멀티 쓰레드 TCP 를 씁니다.
동기와 비동기
동기 (Synchronized)
- 일을 맡기고 결과가 올 때까지 기다립니다
- 결과를 받은 뒤 다음 단계로 갑니다
- 예: 상점에서 값을 치르고 아이템을 가방에 옮긴 뒤 결과를 알려 줄 때
비동기 (Asynchronized)
- 일을 맡기고 기다리지 않고 다음 일을 합니다
- 비동기로 일하려면 쓰레드가 필요합니다
- 예: 기사가 검으로 치면서 동시에 방패로 상대의 검을 막을 때
상점 구매는 순서가 중요합니다. 값을 치르기 전에 아이템이 들어오면 안 됩니다. 전투는 동시성이 중요합니다. 공격과 방어가 차례를 기다리면 어색합니다. 같은 게임 안에서도 일의 성격에 따라 동기와 비동기를 골라 씁니다.
직렬화, 객체를 줄 세워 보내기

- Serialization객체를 앞에서 뒤로 읽을 수 있게 줄 세움
- Network Streaming줄 선 데이터를 물 흐르듯 보냄
- Deserialization받은 줄을 다시 객체로
디스크와 메모리의 데이터는 원래 줄지어 놓여 있습니다. 읽을 때는 커서가 맨 앞에서 맨 뒤로 움직이며 차례로 읽습니다. 이렇게 앞에서부터 차례로 읽을 수 있는 모양으로 데이터를 만드는 것이 직렬화입니다. Unity 의 SerializeField 가 같은 말에서 왔습니다.
보내는 쪽과 받는 쪽은 약속(프로토콜)으로 "몇 번째 자리에 무엇이 있는지" 를 맞춥니다. 받는 쪽이 줄을 풀어 객체로 되돌리는 과정이 역직렬화이고, 약속대로 했다면 처음 보낸 객체와 똑같은 객체가 만들어집니다.
[Serializable]
public class Character
{
public int ID;
public string Name;
public int AttackPoint;
public int HealthPoint;
public void Serialize(BinaryWriter writer) // 정한 순서대로 쓴다
{
writer.Write(ID);
writer.Write(Name);
writer.Write(AttackPoint);
writer.Write(HealthPoint);
}
public static Character Deserialize(BinaryReader reader) // 같은 순서대로 읽는다
{
return new Character
{
ID = reader.ReadInt32(),
Name = reader.ReadString(),
AttackPoint = reader.ReadInt32(),
HealthPoint = reader.ReadInt32(),
};
}
}쓰는 순서와 읽는 순서가 하나라도 어긋나면 이름 자리에 공격력이 들어갑니다. 직렬화에서 가장 중요한 것은 양쪽이 같은 약속을 지키는 일입니다.
암호화와 압축
암호화
패킷을 빼앗겼을 때 내용을 읽을 수 있으면 해킹 피해로 이어집니다. 암호화로 내용을 감추고, 받는 쪽에서 복호화합니다.
패킷 압축
보낼 양이 클 때 줄여 네트워크를 아낍니다. 파일 압축과 같은 알고리즘이지만 메모리 안에서 하므로 훨씬 빠릅니다.
대가
보낼 때와 받을 때마다 암호화 · 복호화, 압축 · 해제를 해야 하므로 CPU 와 메모리를 씁니다.
암호화와 압축은 반드시 필요한 구현이지만 공짜가 아닙니다. 어떤 패킷에 적용할지 고르는 것이 설계입니다.
패킷 설계
패킷은 클래스로 정의합니다. 공통 부모 GamePacket 을 두고, 주고받을 말마다 자식 클래스를 만들었습니다.
| 패킷 | 방향 | 담는 값 |
|---|---|---|
| SendLogin | 클라이언트 → 서버 | 아이디, 비밀번호 |
| SendLogin_Response | 서버 → 클라이언트 | 유저 ID, 닉네임, 로그인 성공 여부, 서버 점검 중 여부 |
| SendChat | 클라이언트 → 서버 | 유저 ID, 채널, 채팅 내용 |
| BroadcastChat | 서버 → 모든 클라이언트 | 유저 닉네임, 채널, 채팅 내용 |
SendChat 은 유저 ID 를, BroadcastChat 은 닉네임을 담는다는 차이를 보세요. 서버는 ID 를 받아 닉네임으로 바꿔 퍼뜨립니다. 받는 사람에게 필요한 것은 ID 가 아니라 화면에 띄울 이름이기 때문입니다.
- GamePacket 클래스를 만듭니다.
- PacketTypes enum 에 SendChat, SendChat_Response 를 선언합니다.
- ChatTypes enum 에 ToAll, ToUser 를 선언합니다.
- 모든 타입에
[Serializable]을 붙입니다.
[Serializable] public enum PacketTypes { SendChat, SendChat_Response }
[Serializable] public enum ChatTypes { ToAll, ToUser }
[Serializable]
public class GamePacket
{
public PacketTypes Type;
public override string ToString() => Type.ToString();
}
[Serializable]
public class GamePacket_SendChat : GamePacket
{
public long UserID;
public ChatTypes Channel;
public string Message;
}쓰레드를 쓸 때 조심할 것
공유 메모리 문제
쓰레드 여럿이 같은 데이터를 동시에 건드리면 값이 엉킵니다. 예: 재화가 빠지기 전에 아이템 구매가 연달아 들어오는 경우
Critical Section
공유 데이터를 쓰는 구간에는 한 번에 쓰레드 하나가 들어가게 막습니다. C# 에서는 lock 키워드를 씁니다.
Dead Lock
두 쓰레드가 서로의 일이 끝나기를 기다리며 영원히 멈추는 현상입니다. 잠금 구간을 잘못 짜면 생깁니다.
private readonly object goldLock = new object();
public bool Buy(Item item)
{
lock (goldLock) // 이 안에는 한 번에 쓰레드 하나가 들어온다
{
if (gold < item.Price) return false;
gold -= item.Price;
inventory.Add(item);
return true;
}
}lock 이 없으면 두 쓰레드가 동시에 gold < item.Price 를 통과해 돈이 하나 값으로 아이템 두 개를 살 수 있습니다. 공유 데이터를 바꾸는 코드는 언제나 "동시에 두 번 들어오면?" 을 먼저 물어봐야 합니다.
지금 우리 서버에서는
패킷을 클래스로 정의하고 공용 라이브러리로 나눠 쓰는 방식은 우리 C# 서버 라이브러리 ElServer 에서도 그대로입니다(ElServer 리뷰). 실제 서비스에서 네트워크가 끊겼을 때 무엇을 바꿨는지는 구조 읽기 6편에 정리했습니다.
정리
- TCP 는 확인하고, UDP 는 던집니다. 게임은 용도에 따라 둘 다 씁니다.
- 직렬화는 순서의 약속입니다. 쓰는 순서와 읽는 순서가 같아야 같은 객체가 돌아옵니다.
- 공유 데이터는 lock 으로. 동시에 두 번 들어오는 경우를 먼저 생각합니다.
다음 편에서는 소켓을 열고 JSON 으로 패킷을 주고받아 채팅 프로그램을 만듭니다.