본문 바로가기

- Programming/- C#

★ 6. c# 네트워크 개발 p5

반응형

==================================================================

모든 출처는

- 유니티 개발자를 위한 C#으로 온라인 게임 서버 만들기 - 저자 이석현, 출판사 한빛미디어

그리고 URL :

http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=65

==================================================================


C#으로 게임 서버 만들기 - 5. 서버, 클라이언트 구현(에코서버, 클라이언트)

- 3장. 실전 -

3-1. 서버, 클라이언트 테스트 코드 구현
지금까지 작성한 네트워크 라이브러리를 이용하여 에코 서버를 구현해 보겠습니다.
클라이언트가 던져 주는 메시지를 다시 되돌려 주는 아주 간단한 서버이지만 지금까지 공부한 라이브러리를 활용하는데 아주 좋은 예제가 될 것이라 생각됩니다.

에코 서버
윈도우 콘솔 프로그램으로 에코 서버를 만들어보도록 하겠습니다.
비주얼 스튜디오를 이용하여 CSampleServer라는 이름으로 새 프로젝트를 하나 생성합니다. 프로그램의 기본 골격이 되는 코드가 생성이 되며 Program 클래스의 Main 함수가 보일 것입니다. 여기서 만들 서버도 윈도우 프로그램중 하나이므로 Main 함수가 시작점이 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void Main(string[] args)
{
    CPacketBufferManager.initialize(2000);
    userlist = new List<CGameUser>();
 
    CNetworkService service = new CNetworkService();
    // 콜백 메소드 설정
    service.session_created_callback += on_session_created;
 
    // 초기화
    service.initialize();
    service.listen("0.0.0.0"7979100);
 
    Console.WriteLine("Started!");
    while (true)
    {
        System.Threading.Thread.Sleep(1000);
    }
 
    Console.ReadKey();
}
cs

첫 번째로 CPacketBufferManager를 초기화 하여 패킷을 미리 생성해 놓습니다.
파라미터로 넘기는 값은 적당한 수치를 넣어주면 되는데 여기서는 2000으로 설정하였습니다. 동시에 처리할 수 있는 패킷 클래스의 인스턴스가 최대 2000개까지 가능하다는 뜻입니다. 사용이 끝나 반환된 패킷은 초기화 하여 재사용되기 때문에 갯수가 무한정 늘어나지는 않습니다.
단, 너무 많이 설정할 경우 필요 이상의 메모리를 잡아 먹을 수 있으니 테스트 후 적당한 수치를 넣어주는 것이 좋습니다.

다음으로 접속할 유저들을 관리할 리스트를 생성합니다.
그 아랫줄 부터는 네트워크 초기화를 위한 코드입니다.
CNetworkService 객체를 생성한 뒤 클라이언트가 접속할 때 마다 호출될 콜백 메소드를 설정해줍니다. 클라이언트 한명이 접속 성공할 때마다 지정된 콜백 메소드가 호출되게 됩니다.
그리고 CNetworkService 초기화를 수행한 뒤 listen 메소드를 호출하여 클라이언트의 접속을 기다리는 상태로 만들어줍니다. listen 메소드의 파라미터는 host ip, port, backlog 값으로 구성되어 있습니다.
host ip는 서버의 IP주소를 의미하며 "0.0.0.0"으로 넣어주면 모든 데이터를 다 받아들입니다. 서버에서 여러 개의 IP 주소를 설정하여 사용할 경우도 있는데 이 설정과 관계 없이 어떤 IP 주소라도 해당 포트로 들어오는 데이터는 모두다 수신하겠다는 뜻입니다.

포트는 서버 어플리케이션에서 사용할 포트 번호입니다.
이미 알려진 포트 (21, 80 등등)는 제외하고 사용해야 다른 어플리케이션과 충돌하지 않습니다. 마지막으로 backlog 값을 넣어줍니다. 이 값은 accept 처리 도중 대기 시킬 연결 갯수를 의미합니다.
accept 처리가 아직 끝나지 않은 상태에서 또 다른 연결 요청이 들어온다면 backlog로 설정된 값 만큼 대기 큐에 대기시켜 놓습니다. accept 처리가 끝나면 대기큐에서 하나씩 빼내어 다음 연결 처리를 진행시켜 주게 됩니다. 이 값을 무턱대고 크게 설정하면 리소스를 잡아 먹을 수도 있으니 테스트 후 적당한 값을 넣는 것이 좋겠습니다.

네트워크 초기화가 완료되면 프로그램이 중지되지 않도록 무한 루프를 돌려줍니다.
메인 스레드가 블러킹되면 안되기 때문에 Sleep을 통해서 적당히 쉬어주는 코드도 넣었습니다.

보시다시피 Main 함수의 내용은 특별할 것이 없습니다.
나머지 코드들도 간단하기 그지없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// <summary>
/// 클라이언트가 접속 완료 하였을 때 호출
/// n개의 워커 스레드에서 호출될 수 있으므로 공유 자원 접근시 동기화 처리
/// </summary>
/// <param name="token"></param>
static void on_session_created(CUserToken token)
{
    CGameUser user = new CGameUser(token);
    lock (userlist)
    {
        userlist.Add(user);
    }
}
 
public static void remove_user(CGameUser user)
{
    lock (userlist)
    {
        userlist.Remove(user);
    }
}
cs

CNetworkService의 콜백 메소드로 설정해준 on_session_created 메소드입니다.
클라이언트의 접속이 성공할 때마다 네트워크 라이브러리에서 호출해 주게 됩니다. CGameUser 클래스의 인스턴스를 하나 생성해 주는데 이 클래스는 에코 서버에서 사용할 간단한 유저 객체를 나타냅니다. 그리고 현재 접속하고 있는 유저를 관리하기 위해서 앞서 만든 userlist에 새로운 유저를 추가합니다. 유저의 접속이 끊길 때 호출할 remove_user 메소드에서도 userlist 관리를 위한 코드만 들어가 있습니다.
에코 서버는 클라이언트가 보내온 메시지를 그대로 돌려주는 일만 수행하기 때문에 유저와 관련된 내용은 복잡할게 없습니다.

단, 여기서 주의할 점은 userlist에 변화가 생길 때 lock으로 묶어줘야 한다는 것입니다. userlist는 여러 스레드에서 사용하는 공유 자원이기 때문에 반드시 lock 처리를 해줘야 리스트가 깨지지 않습니다. 물론 아주 운이 좋다면 lock 처리를 해제하여도 잘 돌아가는 경우도 있을겁니다. 하지만 멀티 스레드 환경에서는 한두번 잘 돌아간다고 계속 잘 되리라는 보장은 절대 없습니다.
코드의 흐름을 잘 파악하여 lock이 필요한 부분은 데이터가 깨지지 않도록 보호 조치를 해주는 것이 반드시 필요합니다.

유저 객체
Main 함수 구현 부분에서 CGameUser리스트를 생성하여 관리하는 코드를 보셨을겁니다.
클라이언트와 1:1 관계로 매칭되는 유저 객체라고 생각하시면 됩니다.
동시 접속자가 1,000명이면 CGameUser 인스턴스도 1,000개 생성되는 것입니다.
CGameUser 클래스는 길이가 얼마 되지 않으니 전체 코드를 살펴보도록 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
namespace CSampleServer
{
    using GameServer;
 
    /// <summary>
    /// 하나의 session 객체를 나타냄
    /// </summary>
    class CGameUser : IPeer
    {
        CUserToken token;
 
        public CGameUser(CUserToken token)
        {
            this.token = token;
            this.token.set_peer(this);
        }
 
        void IPeer.on_message(Const<byte[]> buffer)
        {
            // ex)
            CPacket msg = new CPacket(buffer.Value, this);
            PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id();
            Console.WriteLine("------------------------------------------------------");
            Console.WriteLine("protocol id " + protocol);
            switch (protocol)
            {
                case PROTOCOL.CHAT_MSG_REQ:
                    {
                        string text = msg.pop_string();
                        Console.WriteLine(string.Format("text {0}", text));
 
                        CPacket response = CPacket.create((short)PROTOCOL.CHAT_MSG_ACK);
                        response.push(text);
                        send(response);
                    }
                    break;
            }
        }
 
        void IPeer.on_removed()
        {
            Console.WriteLine("The client disconnected.");
 
            Program.remove_user(this);
        }
 
        public void send(CPacket msg)
        {
            this.token.send(msg);
        }
 
        void IPeer.disconnect()
        {
            this.token.socket.Disconnect(false);
        }
 
        void IPeer.process_user_operation(CPacket msg)
        {
        }
    }
}
 
cs

생성자에서는 메시지 송, 수신시 사용할 CUserToken 객체를 멤버 변수로 보관해 놓습니다.
그리고 CUserToken 객체에 IPeer 인터페이스를 구현한 자기 자신의 인스턴스를 넘겨줍니다.
네트워크 모듈에서 클라이언트의 접속 요청, 종료 등의 처리시 해당 인터페이스를 통해서 CGameUser의 메소드를 호출해주기 위함입니다. 네트워크 모듈에서 이런 저런 처리를 한 뒤 어플리케이션으로 그 사실을 통보해줄 때 필요한 부분입니다.

IPeer.on_message 메소드는 클라이언트로 메시지가 수신되었을 때 호출됩니다. 파라미터로 byte 배열이 넘어오기 때문에 이것을 패킷 객체로 변환하여 사용하는 것이 좋습니다. 프로토콜 아이디를 읽어온 뒤 해당 아이디에 맞는 로직으로 분기시켜 줍니다. 여기서는 에코 서버 이므로 PROTOCOL.CHAT_MSG_REQ 프로토콜에 대한 처리를 해주게 됩니다.

1
2
3
4
5
6
string text = msg.pop_string();
Console.WriteLine(string.Format("text {0}", text));
 
CPacket response = CPacket.create((short)PROTOCOL.CHAT_MSG_ACK);
response.push(text);
send(response);
cs

에코 서버는 클라이언트가 전송한 내용을 그대로 돌려주는 역할이기 때문에 pop_string()으로 꺼내온 데이터를 그대로 다시 push하여 응답해줍니다.
이 때 프로토콜은 CHAT_MSG_REQ에 대응하는 CHAT_MSG_ACK를 사용하여 클라이언트에서 인지할 수 있도록 처리해줍니다.

그 다음으로 클라이언트와의 연결이 끊겼을 때 호출되는 on_removed와 데이터 전송시 사용할 send 메소드가 있습니다.
on_removed는 네트워크 모듈에서 자동으로 호출되는 메소드이므로 우리는 통보 사실을 전달 받아 어플리케이션의 로직 처리만 수행해주면 됩니다.
send 메소드는 생성자에서 보관해 놓은 CUserToken 객체의 send 메소드를 호출하여 데이터 전송을 요청합니다.

코드를 보시다가 CUserToken 클래스와 CGameUser 클래스 두 가지가 왜 구분되어 있는지 궁금해 하실 수도 있을겁니다. 그 이유는 각각의 역할 분담을 명확히 하기 위해서 입니다.
CUserToken은 네트워크 모듈에 속해 있는 클래스이며 소켓API와 좀 더 가까운 컨셉으로 설계된 클래스입니다. 반면 CGameUser는 네트워크 모듈보다는 어플리케이션 로직과 가까운 클래스이며 구현하려는 서버마다 각기 다른 내용으로 채워져 있을 수 있습니다.
이 말은 그만큼 변경사항이 많이 생길 수도 있다는 말이기도 합니다. 따라서 데이터 송, 수신 등의 처리에 필요한 CUserToken 클래스와는 별도로 구성하는 것이 바람직하겠죠.

IPeer.disconnect() 메소드는 서버에서 클라이언트의 연결을 강제로 끊을 때 사용합니다. 메소드 내용도 아주 간단하죠.

void IPeer.process_user_operation(CPacket msg) 메소드는 에코 서버에서는 사용되지 않는 메소드입니다. 이 메소드는 실전 예제 강좌에서 활용하도록 하겠습니다. 일단은 비워둡시다.

여기까지 에코 서버의 구현이 모두 완료되었습니다.
네트워크 처리 부분은 모두 라이브러리로 구현되어 있기 때문에 어플리케이션에서는 별로 복잡한 내용이 없습니다. 다음으로 클라이언트를 구현해보도록 하겠습니다.

클라이언트
클라이언트 역시 간단합니다. 서버에 접속한 뒤 키보드 입력을 받아 전송하고 수신된 데이터를 화면에 출력해주면 끝입니다. CSampleClient라는 이름으로 새로운 프로젝트를 생성합니다.
먼저 Main함수를 살펴보죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static void Main(string[] args)
{
    CPacketBufferManager.initialize(2000);
    // CNetworkService 객체는 메시지의 비동기 송, 수신 처리를 수행함
    // 메시지 송, 수신은 서버, 클라이언트 모두 동일한 로직으로 처리될 수 있으므로
    // CNetworkService 객체를 생성하여 Connector 객체에 넘겨준다.
    CNetworkService service = new CNetworkService();
 
    // endpoint 정보를 갖고 있는 Connector생성. 만들어둔 NetworkService 객체를 넣어준다.
    CConnector connector = new CConnector(service);
    // 접속 성공시 호출될 콜백 메소드 지정.
    connector.connected_callback += on_connected_gameserver;
    IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 7979);
    connector.connect(endpoint);
 
    while (true)
    {
        Console.Write("> ");
        string line = Console.ReadLine();
        if (line == "q")
        {
            break;
        }
 
        CPacket msg = CPacket.create((short)PROTOCOL.CHAT_MSG_REQ);
        msg.push(line);
        game_servers[0].send(msg);
    }
 
    ((CRemoteServerPeer)game_servers[0]).token.disconnect();
 
    Console.ReadKey();
}
cs

서버 구현 내용에서 보았듯이 패킷 객체를 미리 생성해 놓습니다.
사실 에코 클라이언트에서는 1~2개로 설정해놔도 아무 무리가 없습니다.

클라이언트도 CNetworkService 객체를 생성해야 합니다. 이렇게 생성한 객체를 CConnector의 생성자로 넘겨줍니다. CConnector 클래스는 원격지 서버에 접속을 수행하기 위해 구현된 클래스입니다. 접속하려는 서버의 IP주소와 포트번호를 입력하고 접속 성공시 호출될 콜백 메소드를 설정한 뒤 connect 메소드를 호출해줍니다.

접속 요청을 한 뒤에는 키보드 입력을 받을 수 있도록 ReadLine 메소드를 호출해주고 입력된 텍스트를 서버에 전송합니다.
"q" 한 글자를 입력할 경우에는 프로그램을 종료합니다.

1
2
3
4
5
6
7
8
9
10
// 접속 성공시 호출될 콜백 
static void on_connected_gameserver(CUserToken server_token)
{
    lock (game_servers)
    {
        IPeer server = new CRemoteServerPeer(server_token);
        game_servers.Add(server);
        Console.WriteLine("Connected!");
    }
}
cs

서버에 접속이 완료되면 CConnector 클래스에 설정해 놓은 콜백 메소드가 호출됩니다. IPeer 인터페이스를 구현한 CRemoteServerPeer 객체를 생성하여 서버 리스트에 추가해줍니다.
클라이언트 입장에서 보면 서버도 데이터 송, 수신의 대상이 되는 객체로 볼 수 있습니다.
따라서 CRemoteServerPeer의 구현은 서버에서 클라이언트 객체를 구현한 CGameUser 클래스와 비슷합니다.
CRemoteServerPeer의 전체 소스 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
namespace CSampleClient
{
    using GameServer;
 
    class CRemoteServerPeer : IPeer
    {
        public CUserToken token { get; private set; }
 
        public CRemoteServerPeer(CUserToken token)
        {
            this.token = token;
            this.token.set_peer(this);
        }
 
        void IPeer.on_message(Const<byte[]> buffer)
        {
            CPacket msg = new CPacket(buffer.Value, this);
            PROTOCOL protocol_id = (PROTOCOL)msg.pop_protocol_id();
            switch (protocol_id)
            {
                case PROTOCOL.CHAT_MSG_ACK:
                    {
                        string text = msg.pop_string();
                        Console.WriteLine(string.Format("text {0}", text));
                    }
                    break;
            }
        }
 
        void IPeer.on_removed()
        {
            Console.WriteLine("server removed.");
        }
 
        void IPeer.send(CPacket msg)
        {
            this.token.send(msg);
        }
 
        void IPeer.disconnect()
        {
            this.token.socket.Disconnect(false);
        }
 
        void IPeer.process_user_operation(CPacket msg)
        {
        }
    }
}
cs

서버에서와 마찬가지로 IPeer 인터페이스를 구현하였으며 생성자에서 하는 일은 동일합니다. 단, 여기서의 CUserToken 객체는 클라이언트가 아닌 서버를 의미한다는 것이 다를 뿐입니다. 따라서 당연하게도 메시지가 수신되면 on_message 메소드가 호출됩니다.
CHAT_MSG_ACK 프로토콜 아이디에 대한 코드로서 수신된 내용을 화면에 출력해 주는 것이 전부입니다. 이 코드를 통해서 내가 보낸 데이터가 제대로 돌아 오는지 확인할 수 있겠죠.
그 외 다른 부분은 에코 서버의 CGameUser 클래스와 동일합니다.

이제 에코 서버, 클라이언트 모두 구현했습니다. 실행된 모습입니다.


네트워크 라이브러리와 이것을 활용한 에코 서버, 클라이언트를 만들어 봤습니다.
이제 온라인 게임을 개발하기 위한 첫 걸음을 내딛은 것입니다.
상용으로 서비스 하기에는 아직 갈 길이 멀지만
첫 술에 배부를 수는 없겠죠.
한줄 한줄 코딩해 나가다 보면 자신만의 라이브러리와 서버가 만들어져 있을 겁니다.
다음 강좌부터는 유니티 엔진과 연동하여 안드로이드 환경에서 돌아가는
실시간 온라인 게임을 개발해보도록 하겠습니다.
감사합니다.


반응형

'- Programming > - C#' 카테고리의 다른 글

★ 8. c# 네트워크 개발 p7  (0) 2017.02.13
★ 7. c# 네트워크 개발 p6  (0) 2017.02.12
★ 5. c# 네트워크 개발 p4  (0) 2017.02.09
★ 4. c# 네트워크 개발 p3  (0) 2017.02.09
★ 3. c# 박싱과 언박싱  (0) 2017.02.08