본문 바로가기

- Programming/- C#

★ 1. c# 네트워크 개발 p1

반응형

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

모든 출처는

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

그리고 URL :

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

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


유니티 엔진으로 게임을 한번 만들어보면서 네트워크에 관한 궁금증으로 인해 구글링 해보니 좋은 강좌를 공유해주시는 분이 계셔서 그 정보를 바탕으로 작성해보겠습니다.
카피 하는 방식으로 포스팅하지만 전부 직접 타이핑이고 Ctrl C + Ctrl V 가 아님을 밝힙니다.
내용 일부분이 약간 수정될 뿐 그 외에는 전부 출처와 거의 다름 없습니다.

# 차례

- 1장. 네트워크 기반 -
1. 네트워크 기본 모듈 작성
2. 클라이언트의 접속 처리하기
3. 스레드를 통한 Accept 처리
4. 클라이언트 접속 이후의 처리

- 2장. TCP에서의 메시지 처리 방법 -
1. 메시지 경계 처리
2. 패킷 구조 설계

- 3장. 실전 -
1. 서버, 클라이언트 테스트 코드 구현
2. 유니티 엔진과 연동하는 방법
3. 실시간 네트워크 게임 개발


C#으로 게임 서버 만들기 - 1. 네트워크 기반 코드 작성

- 1장. 네트워크 기반 -

1-1. 네트워크 기본 모듈 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class CNetworkService
{
    // 클라이언트의 접속을 받아들이기 위한 객체
    CListener client_listener;
 
    // 메시지 수신, 전송시 필요한 오브젝트
    SocketAsyncEventArgsPool receive_event_args_pool;
    SocketAsyncEventArgsPool send_event_args_pool;
 
    // 메시지 수신, 전송시 .Net 비동기 소켓에서 사용할 버퍼를 관리하는 객체
    BufferManager buffer_manager;
 
    // 클라이언트의 접속이 이루어졌을 때 호출되는 델리게이트
    public delegate void SessionHandler(CUserToken token);
    public SessionHandler session_created_callback { get; set; }
}
cs

----------------------------------------------------------------------------------------------------------------

클라이언트의 접속을 받아들이는 CListener 객체가 선언되어있습니다.

SocketAsyncEventArgs 라는 클래스는 .Net 비동기 소켓에서 사용하는 개념으로

비동기 소켓 메소드를 호출할 때 항상 필요한 객체입니다.

매번 IAsyncResult를 생성하지 않고 풀링하여 사용할 수 있어 메모리 재사용이 가능한 것이 장점입니다.


MSDN 문서중에 객체를 풀링하여 쓰는 것이 기존 native c++에서 하는 풀링 만큼의

효율을 가져오지 않지만 서버가 살아있는 동안 계속 사용할 메모리이기 때문에 풀링을 사용합니다.


BufferManager는 이름에서도 알 수 있듯이 데이터를 송, 수신할 때 사용할 버퍼를 관리하는 매니저 객체입니다.

소켓 관련된 책을 보면 송, 수신 버퍼 이야기를 자주 듣게 되는데 TCP에서 데이터를 보내고 받을 때

소켓마다 버퍼라는 것이 할당되는 것입니다.

이것은 OS에서 구현되어 있는 부분이라 따로 신경 쓸 필요는 없습니다.

이 소켓 버퍼로부터 메시지를 복사해오고(수신) 밀어 넣는(전송) 작업을 할 때 사용할 버퍼를 설정해주면 됩니다. 이 버퍼 역시 네트워크 통신이 지속되는 동안에 계속해서 사용하는 메모리입니다.


.Net 환경에서는 가비지 컬렉션이 작동되므로 꼭 풀링하지 않아도 되지만

  버퍼라는건 거의 매순간 쓰인다고 봐도 되니 풀링하기로 합니다.


그 다음 델리게이트가 정의되어 있는데 클라이언트가 접속했을 때 어딘가로 통보해주기 위한 수단입니다.

----------------------------------------------------------------------------------------------------------------


1-2. 클라이언트의 접속 처리하기


클라이언트의 접속을 처리하기 위한 코드를 작성하겠습니다.

TCP서버 구현의 흐름은 bind -> listen -> accept 순으로 진행됩니다.


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
class CListener
{
    // 비동기 Accept를 위한 EventArgs
    SocketAsyncEventArgs accept_args;
 
    // 클라이언트의 접속을 처리할 소켓
    Socket listen_socket;
 
    // Accept 처리의 순서를 제어하기 위한 이벤트 변수
    AutoResetEvent flow_control_event;
 
    // 새로운 클라이언트가 접속했을 때 호출되는 콜백
    public delegate void NewclientHandler(Socket client_socekt, object token);
    public NewclientHandler callback_on_newclient;
 
    public CListener()
    {
        this.callback_on_newclient = null;
    }
 
    public void start(string host, int port, int backlog)
    {
        // 소켓 생성
        this.listen_socket = new Socket(AddressFamily.InterNetwork,
            SocketType.Stream, ProtocolType.Tcp);
 
        IPAddress address;
        if (host == "0.0.0.0")
        {
            address = IPAddress.Any;
        }
        else
        {
            address = IPAddress.Parse(host);
        }
        IPEndPoint endpoint = new IPEndPoint(address, port);
 
        try
        {
            // 소켓에 host 정보를 바인딩 시킨 뒤 Listen 메소드를 호출하여 준비
            listen_socket.Bind(endpoint);
            listen_socket.Listen(backlog);
 
            this.accept_args = new SocketAsyncEventArgs();
            this.accept_args.Completed += new EventHandler<SocketAsyncEventArgs>(on_accept_completed);
 
            // 클라이언트가 들어오기를 기다림
            // 비동기 메소드이므로 블로킹 되지 않고 바로 리턴
            // 콜백 메소드를 통해서 접속 통보를 처리
            this.listen_socket.AcceptAsync(this.accept_args);
        }
        catch (Exception e)
        {
            //Console.WriteLine(e.Message);
        }
    }
}
cs

----------------------------------------------------------------------------------------------------------------
Listen 처리 코드의 일부분입니다.
cListener라는 클래스를 선언하였습니다. 앞서 보여드린 CNetworkService 클래스에 통합하지 않고
코드를 따로 분리하였는데 그 이유는 Listener를 여러개 두는 구조로 설계하였기 때문입니다.
경우에 따라서 클라이언트의 접속을 받는 Listener, 서버간 통신을 위해서 다른 서버의 접속을
받아들이는 Listener등 여러개의 Listener가 존재할 수 있습니다.
이런 부분을 대비해 분리된 구조를 채택한 것입니다.

하나의 클래스에 여러가지 기능을 다 넣어도 상관 없지만 코딩 중에 분리할 필요성을 느낄 때가
있습니다. 처음 작성을 시작했을 때는 CNetworkService클래스에 Listen코드까지 포함되어 있었지만
여러가지 테스트를 해보고 다른 사람이 작성한 코드도 살펴보니 대부분 분리되어 있었습니다.
이 강좌를 읽어 보시는 분들도 그냥 따라오지 마시고 왜 이렇게 설계하였는지
더 좋은 방법은 없는지 생각해보시면 좋을 것 같습니다.


IPEndPoint 라는 객체는 끝점이라고 말할 수 있는데 도착 지점으로 이해하시면 됩니다.

클라이언트가 도착할 지점은 서버가 됩니다. 서버의 IP, Port 정보로 IPEndPoint가 구성됩니다.

이 서버가 Host가 되며 클라이언트는 Peer라고 말할 수 있습니다.


그 다음 줄은 bind -> listen순으로 진행됩니다.
소켓 프로그래밍 책에서 많이 보셨 듯 서버 정보를 소켓에 bind시키고 listen을 호출하여
준비 작업을 마칩니다.


1
2
this.accept_args = new SocketAsyncEventArgs();
this.accept_args.Completed += new EventHandler<SocketAsyncEventArgs>(on_accept_completed);
cs


SocketAsyncEventArgs 객체를 사용할 때가 왔습니다.

Completed 프로퍼티에 이벤트 핸들러 객체를 연결시켜 주고 AcceptAsync호출시 파라미터로

넘겨주기만 하면 됩니다. Completed라는 이름에서 알 수 있듯이 accept처리가 완료 되었을 때

호출되는 델리게이트입니다.

.Net 비동기 소켓에서는 이처럼 메소드 호출 -> 완료 통지 개념으로 이루어집니다.


1
this.listen_socket.AcceptAsync(this.accept_args);
cs


이제 accept처리 부분입니다. 이 강좌에서는 비동기 메소드를 사용하기로 하였으므로 AcceptAsync를

호출하게 됩니다. 이 메소드는 호출한 직후 바로 리턴되며 accept결과에 대해서는 콜백 메소드로 통보하게 됩니다. 따라서 프로그램이 블로킹 되지 않고 통보를 기다리며 다른 일을 할 수 있게 됩니다.


AcceptAsync까지 호출하면 클라이언트의 접속을 받아들일 수 있는 상태가 됩니다.
간단하지만 방금 우리는 서버를 하나 만들었습니다
온라인 게임, 트위터, 유튜브 등 엄청난 서버들도 다 이런 작은 코드로부터 시작되었을 겁니다.
----------------------------------------------------------------------------------------------------------------

1-3. 스레드를 통한 Accept 처리
위의 AcceptAsync호출 부분을 스레드를 통해 처리하도록 바꾸겠습니다.
꼭 스레드를 통하지 않고도 accept처리는 가능합니다.
특정 OS 버전에서 콘솔 입력이 대기중일 때 accept 처리가 안되는 버그가 있다는 문서를 발견한 적이 있습니다.

메인 스레드가 입력을 위해 대기 상태에 있더라도 accept 처리를 별도의 스레드에서 돌아가도록 구성한다면 위의 문제를 회피할 수 있을 것이므로 이렇게 구현합니다.


1
this.listen_socket.AcceptAsync(this.accept_args);
cs


이 부분을 아래처럼 바꾸겠습니다.


1
2
Thread listen_thread = new Thread(do_listen);
listen_thread.Start();
cs


스레드를 생성하고 do_listen이라는 메소드를 스레드에서 처리하도록 합니다.

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
void do_listen()
{
    // accept처리 제어를 위해 이벤트 객체를 
    this.flow_control_event = new AutoResetEvent(false);
 
    while (true)
    {
        // SocketAsyncEventArgs를 재사용하기 위해서 null로 만듦
        this.accept_args.AcceptSocket = null;
        bool pending = true;
        try
        {
            // 비동기 accept를 호출하여 클라이언트의 접속을 받아들임
            // 비동기 메소드이지만 동기적으로 수행이 완료될 경우도 있으니
            // 리턴 값을 확인하여 분기시켜야함
            pending = listen_socket.AcceptAsync(this.accept_args);
        }
        catch (Exception e)
        {
            //Console.WriteLine(e.Message);
            continue;
        }
 
        // 즉시 완료되면 이벤트가 발생하지 않으므로 리턴 값이 false일 경우 콜백 메소드를 직접 호출
        // pending상태라면 비동기 요청이 들어간 상태이므로 콜백 메소드를 기다림
        // http://msdn.microsoft.com/ko-kr/library/system.net.sockets.socket.acceptasync%28v=vs.110%29.aspx
        if (!pending)
        {
            on_accept_completed(nullthis.accept_args);
        }
 
        // 클라이언트 접속 처리가 완료되면 이벤트 객체의 신호를 전달받아 다시 루프를 수행하도록함
        this.flow_control_event.WaitOne();
    }
}
cs

ㄹㅇㄴㅁㄹㅇㄴㅁ

1
this.flow_control_event = new AutoResetEvent(false);
cs

스레드의 시작 부분에 이벤트 객체를 생성하는 코드가 있습니다.
하나의 접속 처리가 완료된 이후 그 다음 접속 처리를 수행하기 위해 스레드의 흐름을 제어할
필요가 있는데 그때 사용될 이벤트 객체입니다.

그 후 While문 루프를 돌며 클라이언트의 접속을 받아들입니다.

1
pending = listen_socket.AcceptAsync(this.accept_args);
cs


AcceptAsync의 리턴 값에 따라 즉시 완료처리를 할 것인지
통보가 오기를 기다릴 것인지 구분해야 합니다.

accept처리가 동기적으로 수행이 완료될 경우에는 콜백 메소드가 호출되지 않고 false를 리턴합니다.
따라서 이 경우에는 완료 처리를 담당하는 메소드를 직접 호출해야 합니다.
그 외의 경우(true 리턴)에는 .Net에서 콜백 메소드를 호출해주기 때문에 직접 호출 할 필요가 없습니다. 아마 대부분 후자의 경우가 발생할 것입니다.

이벤트 객체에 대해서 잘 모르시는 분들은 인터넷에 이해하기 쉽게 설명된 자료가 많으니
꼭 찾아서 읽어보시길 바랍니다. 이벤트 객체에는 두 가지가 있는데
AutoResetEvent는 한번 Set이 된 이후 자동으로 Reset 상태로 만들어주며,
ManualResetEvent는 직접 Reset 메소드를 호출하지 않는 한 계속 Set 상태로 남아있습니다.
위 두 가지 이벤트 객체는 이러한 차이점이 있으니 상황에 맞게 쓰시면 됩니다.

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
void on_accept_completed(object sender, SocketAsyncEventArgs e)
{
    if (e.SocketError == SocketError.Success)
    {
        // 새로 생긴 소켓을 보관
        Socket client_socket = e.AcceptSocket;
 
        // 다음 연결을 받음
        this.flow_control_event.Set();
 
        // 이 클래스에서는 accept까지의 역할만 수행하고 클라이언트의 접속 이후의 처리는
        // 외부로 넘기기 위해서 콜백 메소드를 호출해주도록 함
        // 이유는 소켓 처리부와 컨텐츠 구현부를 분리하기 위함임
        // 컨텐츠 구현부분은 자주 바뀔 가능성은 있지만, 소켓 Accept 부분은 상대적으로 변경이 적은 부분이기 때문에
        // 양쪽을 분리시켜 주는 것이 좋음.
        // 또한 클래스 설계 방침에 따라 Listen에 관련된 코드만 존재하도록 하기 위한 이유도 있음
        if (this.callback_on_newclient != null)
        {
            this.callback_on_newclient(client_socket, e.UserToken);
        }
 
        return;
    }
    else
    {
        //todo:Accept 실패 처리
        //Console.WriteLine("Failed to accept client.");
    }
 
    // 다음 연결을 받아들임
    this.flow_control_event.Set();
}
cs

AcceptAsync 호출 결과를 통보 받을 메소드입니다.
파라미터로 넘어온 값을 비교해 성공, 실패에 대한 처리를 구현해주면 됩니다.
성공했을 경우 자동으로 소켓이 하나 생성되고 이 소켓을 잘 보관해 두었다가
클라이언트와 통신할 때 사용하도록 합니다.
그리고 콜백 메소드를 호출하여 성공을 알린 뒤 또 다른 연결을 받아들이기 위해
이벤트 객체를 Set 상태로 만들어줍니다.
코드 흐름을 따라가보면 잠시 대기 중이던 스레드가 진행되면서 다시 AcceptAsync 메소드를
호출하게 됩니다. 이렇게 하나의 접속을 처리하고 다음 접속을 처리하기 위해 무한 루프를 돌며
accept를 수행하는 방식으로 구현되어 있습니다.

하나의 스레드에서 순차적으로 accept를 처리하는 구현 방식과 여러 스레드에서 동시에
처리하는 구현 방식 중 어느 방식이 더 성능이 좋은가 하는 부분은 확실히 테스트하지 못한
부분입니다. 따라서 이 강좌에서는 하나의 accept를 완료한 뒤 다음 accept를 처리하는 순차적인
방식으로 설명하였습니다.





-- 다음 강좌는 접속 이후의 로직에 대해 설명하겠습니다 --


반응형

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

★ 6. c# 네트워크 개발 p5  (0) 2017.02.10
★ 5. c# 네트워크 개발 p4  (0) 2017.02.09
★ 4. c# 네트워크 개발 p3  (0) 2017.02.09
★ 3. c# 박싱과 언박싱  (0) 2017.02.08
★ 2. c# 네트워크 개발 p2  (0) 2017.02.08