4편에서 약속을 배웠다면 이번에는 실제로 말을 주고받을 차례입니다. IP 와 포트로 소켓을 열고, 객체를 JSON 문자열로 바꿔 보내고, 보내는 쓰레드와 받는 쓰레드를 나눠 채팅 프로그램을 완성했습니다.
2022년 1학기 한국애니메이션고등학교 2학년 「게임 개발과 미들웨어」 5강 「네트워크 프로그래밍 #2」에서 가르친 내용입니다.
IP 주소와 포트
| 말 | 뜻 |
|---|---|
| IP 주소 | 세 자리 수 네 개로 이루어진 네트워크 주소입니다. TCP 소켓으로 Listen 을 시작하면 컴퓨터의 IP 주소 위에서 소켓이 기다립니다 |
| 포트 | 소켓마다 하나씩 붙이는 번호입니다. 1~65535 를 쓸 수 있고, 100번 아래는 운영체제나 표준이 예약한 번호가 많습니다. 같은 IP 에서 같은 포트를 동시에 두 번 Listen 할 수 없습니다(나가는 연결은 가능) |
| Inbound · Outbound | 밖에서 안으로 들어오는 트래픽과 안에서 밖으로 나가는 트래픽입니다. Outbound 는 같은 주소와 포트로 연결을 여러 개 만들 수 있습니다 |
| 방화벽 | 운영체제의 방화벽은 Inbound · Outbound 규칙으로 드나드는 길을 지킵니다. Windows 와 리눅스는 Inbound 규칙을 열지 않으면 Listen 이 되지 않습니다 |
서버 실습에서 가장 자주 막히는 곳이 방화벽입니다. 코드가 맞는데 연결이 안 되면 먼저 Inbound 규칙을 확인합니다.
연결의 흐름
서버는 연결을 기다리는 상태(Listen)로 시작합니다. 클라이언트가 서버 주소와 포트로 연결(Connect)하면 서버가 받아들이고(Accept) 그 클라이언트를 배열에 기록합니다. 서버가 "누구에게 보낼지" 를 알 수 있는 것은 이 목록 덕분입니다.
JSON, 객체를 글자로
4편에서는 BinaryWriter 로 직접 순서를 정해 직렬화했습니다. 이번에는 JSON 을 썼습니다. JSON 은 자바스크립트의 객체 표기법에서 나왔지만 거의 모든 언어가 라이브러리로 지원해서 사실상 표준처럼 씁니다. 객체는 JSON 문자열이 되고, JSON 문자열은 다시 객체가 됩니다.
{
"Title": "The Cuckoo's Calling",
"Author": "Robert Galbraith",
"Genre": "classic crime novel",
"Detail": {
"Publisher": "Little Brown",
"Publication_Year": 2013,
"ISBN-13": 9781408704004,
"Language": "English",
"Pages": 494
},
"Price": [
{ "type": "Hardcover", "price": 16.65 },
{ "type": "Kindle Edition", "price": 7.03 }
]
}| 모양 | 뜻 | 예 |
|---|---|---|
| 이름: 값 | 데이터는 이름과 값의 쌍입니다 | "Pages": 494 |
| { } | 객체. 안에 이름: 값 쌍이 들어갑니다 | "Detail": { … } |
| [ ] | 배열. 값 여러 개를 차례로 담습니다 | "Price": [ … ] |
| 값의 종류 | string, number, object, array, true, false, null | "English", 2013 |
- foreach 로 불러온 캐릭터를 JSON 문자열로 바꿉니다.
- 그 문자열을 json.txt 파일에 저장합니다.
- json.txt 를 읽어 문자열을 얻습니다.
- Type 을 Deserialize 에 넘겨 Character 객체로 되돌립니다.
// 객체 → JSON → 파일
string json = JsonConvert.SerializeObject(character);
File.WriteAllText("json.txt", json);
// 파일 → JSON → 객체
string loaded = File.ReadAllText("json.txt");
Character restored = (Character)JsonConvert.DeserializeObject(loaded, typeof(Character));보내고 받기
- System.Net 네임스페이스의 TcpClient 를 만들고 서버 주소 · 포트로 연결합니다.
- 연결로 네트워크 스트림을 열고 문자열을 보낸 뒤 스트림을 닫습니다.
Unity C# 에서도 TcpClient 는 NetworkStream 을 돌려줍니다.
// 클라이언트: 보내기
using (TcpClient client = new TcpClient("127.0.0.1", 9000))
using (NetworkStream stream = client.GetStream())
{
byte[] body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(packet));
stream.Write(body, 0, body.Length);
}
// 서버: 기다렸다가 받기
TcpListener listener = new TcpListener(IPAddress.Any, 9000);
listener.Start(); // Listen
TcpClient peer = listener.AcceptTcpClient(); // Accept (연결이 올 때까지 기다림)
using (StreamReader reader = new StreamReader(peer.GetStream(), Encoding.UTF8))
{
string json = reader.ReadToEnd();
Character received = JsonConvert.DeserializeObject<Character>(json);
}실습 순서는 "서버가 Listen → 클라이언트가 Connect → 서버가 스트림을 기다림" 이었습니다. 캐릭터 객체를 JSON 으로 보내고, 서버에서 같은 캐릭터로 되돌아오는지 확인했습니다.
우리만의 약속
Network Common
클라이언트와 서버가 같은 패킷 클래스를 써야 서로의 데이터를 이해합니다. 그래서 패킷을 정의하는 공용 라이브러리를 둡니다. 대부분의 클래스가 Serializable 입니다.
Transfer Protocol
스트림은 끝이 없는 물줄기라서 어디까지가 패킷 하나인지 약속해야 합니다. 머리(Header)에 몸통(Body)의 크기를 적어 보내는 방법을 주로 씁니다.
| Header | Body |
|---|---|
| 몸통의 길이 (예: 4바이트 정수) | 패킷 JSON 문자열 |
받는 쪽은 먼저 머리를 읽어 길이를 알고, 그 길이만큼 몸통을 읽습니다. 이 약속이 없으면 두 패킷이 붙어 오거나 패킷 하나가 둘로 나뉘어 올 때 구별할 수 없습니다.
보내는 쓰레드와 받는 쓰레드

보내는 쓰레드
- while(true) 로 계속 기다립니다
- 보낼 패킷이 들어오면 JSON 으로 바꿉니다
- 그 즉시 서버로 보냅니다
받는 쓰레드
- while(true) 로 계속 기다립니다
- 패킷이 오면 끝까지 읽습니다
- 읽은 문자열을 JSON 으로 보고 원래 객체로 되돌립니다
BlockingCollection<GamePacket> sendQueue = new BlockingCollection<GamePacket>();
void SendLoop() // 보내는 쓰레드
{
while (true)
{
GamePacket packet = sendQueue.Take(); // 들어올 때까지 기다림
WritePacket(stream, JsonConvert.SerializeObject(packet));
}
}
void ReceiveLoop() // 받는 쓰레드
{
while (true)
{
string json = ReadPacket(stream); // Header 로 길이를 읽고 Body 를 읽음
GamePacket packet = Deserialize(json);
Dispatch(packet);
}
}게임 화면을 그리는 메인 쓰레드는 이 두 쓰레드를 기다리지 않습니다. 보낼 것은 큐에 넣고 기다리지 않고 돌아오고, 받은 것은 큐에서 꺼내 처리합니다.
메시지 표
받은 패킷을 무엇으로 처리할지는 메시지 표로 정합니다. C# 의 delegate 는 함수를 담을 수 있는 타입입니다(Unity 는 더 쓰기 쉬운 Action 을 줍니다). 패킷 종류마다 처리할 함수를 미리 등록해 두고, 받은 패킷의 종류로 함수를 찾아 부릅니다.
Dictionary<PacketTypes, Action<GamePacket>> messageTable = new Dictionary<PacketTypes, Action<GamePacket>>();
void Init()
{
messageTable[PacketTypes.SendChat] = OnChat;
messageTable[PacketTypes.SendLogin_Response] = OnLoginResult;
}
void Dispatch(GamePacket packet) // 받는 쓰레드가 부른다
{
if (messageTable.TryGetValue(packet.Type, out Action<GamePacket> handler))
handler(packet);
}
void OnChat(GamePacket packet)
{
var chat = (GamePacket_BroadcastChat)packet;
chatLog.text += $"\n{chat.Nickname}: {chat.Message}";
}새 패킷이 생기면 표에 줄을 더하고 함수를 만들면 됩니다. 받는 코드는 고치지 않습니다.
채팅 프로그램 완성
- 네트워크 클래스보내기 · 받기 쓰레드를 가진 클래스를 Sources 에 넣음
- 채팅 UI여러 줄 Text, 한 줄 입력, 전송 버튼
- 연결Text 두 개를 GameScene 멤버로, 버튼 이벤트를 GameScene 함수로
- 전송입력한 글을 SendChat 패킷으로
- 표시BroadcastChat 을 받아 여러 줄 Text 에 쌓음
수업에서는 제가 직접 만든 네트워크 전송 클래스를 나눠 주고, 학생들은 그 위에 채팅 화면을 붙였습니다. 두 대의 컴퓨터에서 서로의 메시지가 뜨는 순간 교실이 시끄러워졌습니다.
| 응용 | 흐름 |
|---|---|
| 결투장 랭킹 | 클라이언트가 랭킹을 요청하면 서버가 결투장 DB 를 조회해 List.Sort() 로 정렬하고 1~50위를 보냅니다 |
| 캐릭터 생성 | 클라이언트가 이름과 기본 능력치를 담은 패킷을 보내면 서버가 DB 에 저장하고, 다음 로그인 때 그 캐릭터 정보를 보냅니다 |
지금 우리 서버에서는
패킷 종류로 처리 함수를 찾는 메시지 표는 우리 C# 서버 라이브러리 ElServer 에도 그대로 있습니다(ElServer 리뷰).
정리
- Listen, Connect, Accept. 서버가 기다리고, 클라이언트가 찾아오고, 서버가 받아 목록에 적습니다.
- JSON 과 길이 머리. 객체는 JSON 으로, 패킷의 경계는 Header 의 길이로 약속합니다.
- 보내기와 받기는 쓰레드를 나누고, 처리는 메시지 표로.
다음 편부터는 3D 게임 프로그래밍 수업입니다. 코드보다 먼저 그리는 설계, 순서도부터 시작합니다.