본문 바로가기

- Programming/- C#

★ 5. c# 네트워크 개발 p4

반응형

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

모든 출처는

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

그리고 URL :

http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=64

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


C#으로 게임 서버 만들기 - 4. 데이터 전송.


2-2-3. 데이터 전송


전송 큐 사용
SendAsync라는 비동기 전송 메소드를 사용하여 데이터 전송을 구현해보도록 하겠습니다.
데이터 수신에 비해 전송 구현은 간단한 편이지만 몇가지 주의할 점이 있습니다.
이 강좌에서는 하나의 전송이 완료된 후 다음 전송을 보내는 흐름으로 코드를 작성하도록 하겠습니다. 이런 방식을 1-send라고 호칭하는 경우도 봤는데 공식 용어는 아닌 것 같습니다.

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
/// <summary>
/// 패킷을 전송한다.
/// 큐가 비어 있을 경우에는 큐에 추가한 뒤 바로 SendAsync매소드를 호출하고,
/// 데이터가 들어있을 경우에는 새로 추가만 한다.
/// 
/// 큐잉된 패킷의 전송 시점 :
///        현재 진행중인 SendAsync가 완료되었을 때 큐를 검사하여 나머지 패킷을 전송한다.
/// </summary>
/// <param name="msg"></param>
public void send(CPacket msg)
{
    lock (this.cs_sending_queue)
    {
        // 큐가 비어 있다면 큐에 추가하고 바로 비동기 전송 매소드를 호출한다.
        if (this.sending_queue.Count <= 0)
        {
            this.sending_queue.Enqueue(clone);
            start_send();
            return;
        }
 
        // 큐에 무언가가 들어 있다면 아직 이전 전송이 완료되지 않은 상태이므로 큐에 추가만 하고 리턴한다.
        // 현재 수행중인 SendAsync가 완료된 이후에 큐를 검사하여 데이터가 있으면 SendAsync를 호출하여 전송해줄 것이다.
        this.sending_queue.Enqueue(clone);
    }
}
cs

이 메소드는 CUserToken 클래스에 포함되어 있습니다.
사실 어느 클래스에 들어 있는지는 크게 중요하지 않기 때문에 내용에만 집중하여 설명하겠습니다.
전송 로직은 다음과 같은 흐름으로 진행됩니다.

1. 패킷을 만들어 전송 큐에 추가한다.
2. 큐가 비어 있다면 즉시 비동기 전송 메소드를 호출한다.
3. 전송 오퍼레이션이 완료된 후 다시 큐를 검사한다.
4. 데이터가 존재할 경우 비동기 전송 메소드를 호출한다.
5. 데이터가 없을 경우 더이상 아무런 작업을 하지 않고 리턴한다.

원래 제가 알고 있던 데이터 전송 로직은 스레드를 이용하는 방법이었습니다.
큐에 추가하는 것까지는 지금 강좌에서 설명드린 내용과 비슷하지만 큐에서 꺼내와 전송 메소드를 호출하는 부분이 별도의 스레드를 통해 구현되었던 것입니다.
스레드 하나에서 루프를 돌며 큐를 감시하다가 데이터를 하나씩 빼내어 전송하는 방식이죠. 하지만 가만히 생각해보니 스레드를 하나 더 사용하는 것이 웬지 내키지 않았습니다.
스레드를 어디에 둬야 하는지부터 명쾌하지 않았으며, 과연 성능이 더 좋게 나올까 하는 의구심도 들었기 때문이죠.
또한 스레드를 쓰면서 생기는 여러가지 복잡함도 제거해버리고 싶었습니다.

그래서 이번 강좌에서는 다른 방식을 생각해보기로 했습니다.
하나의 스레드에서 전송큐에 추가하고 닷넷 비동기 메소드인 SendAsync메소드까지 호출하는 방식입니다. 전송 요청시 바로 SendAsync 메소드를 호출하지 말고 큐가 비어 있는지 체크한 뒤 비어 있다면 큐에 추가하고 SendAsync 메소드를 호출해줍니다. 비어 있지 않다면 큐에 추가만 한 뒤 그냥 리턴합니다. 데이터는 앞서 큐에 추가된 데이터의 전송이 완료된 후 또 한번 큐를 체크하여 SendAsync 메소드를 호출할 때 보내지게 될 것입니다.
만약 하나의 전송이 완료된 후 큐가 비어 있다면 새로 추가된 데이터가 없다는 뜻이므로 그냥 리턴시킵니다. 그 이후에 또다른 데이터가 큐에 추가된다면 위에서 설명한 부분을 다시 반복하여 수행하도록 처리해줍니다.

이 로직을 구현하고 간단히 테스트를 해봤는데 별다른 문제점은 발견하지 못했습니다. 하지만 정밀하게 테스트해본 것은 아니기 때문에 더 좋다고 확신할 수는 없습니다.
일단 이 강좌에서는 이 방식으로 진행하도록 하겠습니다. 잘못된게 있으면 나중에 로직을 바꾸면 되니까요. 애써 코딩해 놓은 것을 바꾸는 것에 대해 큰 부담감을 갖을 필요는 없습니다. 언제든 코드를 바꿀 수 있다는 생각으로 코딩하면 중간 중간 어떠한 결정을 하는데 머뭇거림이 줄어 들게 될겁니다.
물론 그렇다고 대충 설계하고 코딩하는 것은 좋지 않은 방법입니다. 고민은 많이 하되 판단은 빠르게 하는 것이 좋다고 생각합니다.

Socket.SendAsync
이제 .Net의 비동기 메소드를 호출하는 부분을 살펴보겠습니다.

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
/// <summary>
/// 비동기 전송을 시작한다.
/// </summary>
void start_send()
{
    lock (this.cs_sending_queue)
    {
        // 전송이 아직 완료된 상태가 아니므로 데이터만 가져오고 큐에서 제거하진 않는다.
        CPacket msg = this.sending_queue.Peek();
 
        // 헤더에 패킷 사이즈를 기록한다.
        msg.record_size();
 
        // 이번에 보낼 패킷 사이즈 만큼 버퍼 크기를 설정하고
        this.send_event_args.SetBuffer(this.send_event_args.Offset, msg.position);
        // 패킷 내용을 SocketAsyncEventArgs버퍼에 복사한다.
        Array.Copy(msg.buffer, 0this.send_event_args.Buffer, this.send_event_args.Offset, msg.position);
 
        // 비동기 전송 시작.
        bool pending = this.socket.SendAsync(this.send_event_args);
        if (!pending)
        {
            process_send(this.send_event_args);
        }
    }
}
cs

전송 큐에 데이터를 넣은 뒤 호출되는 메소드입니다.
일단 전송할 데이터 하나를 큐에서 가져옵니다. 여기서 주의할 부분은 Dequeue 메소드를 사용하여 가져오지 말고 Peek 메소드를 사용하여 큐에 데이터를 유지시켜 두어야 한다는 점입니다.
왜냐하면 아직 데이터 전송이 완료된 것이 아닌기 때문입니다.
앞에서 한번에 하나의 전송을 처리하고 그 전송이 완료된 이후에 다음 전송을 처리한다고 설명해드렸습니다. 이 규칙을 깨지 않게 하려면 데이터 전송이 완료될 때까지는 큐에 유지시켜 두어야 합니다.
만약 Dequeue 메소드를 사용하여 데이터를 꺼내오는 것과 동시에 큐에서 제거해 버린다면 SendAsync 메소드가 연속으로 호출 될 가능성이 있습니다.
다음과 같은 시나리오를 예상해 봅시다.

1. 전송 요청 -> 큐에 데이터 추가.
2. 큐에서 데이터를 하나 꺼내온다(Peek대신 Dequeue사용)
3. Dequeue를 사용했으므로 큐는 empty 상태임.
4. SendAsync 호출 -> 비동기 메소드이기 때문에 다른 스레드에서 전송 작업이 진행됨.
5. 또 다른 데이터를 전송 요청함.
6. 큐가 비어 있기 때문에 즉시 start_send 메소드를 호출하여 그 안에서 SendAsync를 호출하게 됨.
7. 첫번째로 호출된 SendAsync가 아직 완료되지 않은 상태에서 또 SendAsync가 호출됨.

바로 이런 시나리오가 가능할 수 있기 때문에 위와 같이 큐를 통해서 흐름을 제어하게끔 구현한 것입니다. 그렇다면 SendAsync 메소드가 이중으로 호출되면 안되는 이유라도 있는 것일까요?
이 부분은 저도 이론적으로 아직 완벽히 정립하지 못한 부분이지만 연구하면서 알게된 몇 가지 사실들이 있습니다.

SendAsync 메소드를 중복하여 호출하는 것 자체는 문제가 없다.
하지만 우리가 구현한 코드를 보면 SocketAsyncEventArgs를
SendAsync 메소드 호출시 파라미터로 넘겨주는 것을 볼 수 있습니다.

1
2
// 비동기 전송 시작.
bool pending = this.socket.SendAsync(this.send_event_args);
cs

바로 이 부분입니다. this.send_event_args 변수는 SocketAsyncEventArgs 타입의 변수인데 비동기 전송 메소드를 호출할 때 매번 사용됩니다. 현재 진행중인 비동기 작업이 완료되지 않은 상태에서 이 변수를 재활용 하려고 하면 에러를 리턴합니다.
무슨 에러인지는 잘 기억이 나지 않지만 아직 완료되지 않은 오퍼레이션이라서 안된다 그런 뜻이었던 것으로 기억합니다.
그렇다면 SocketAsyncEventArgs를 여러 개 생성해놓고 사용하자! 하고 생각하시는 분들도 계실 것 같습니다. 저도 비슷한 생각을 해봤었는데 도대체 몇 개의 SocketAsyncEventArgs를 사용해야 할까요?
예상하기 힘드니 이것도 풀링하여 처리하면 될까요? 아니면 설정 값을 정해놓고 그 한도 내에서만 처리할 수 있도록 구현해 볼까요? 이런 고민들 속에서 저는 한번에 하나의 전송을 처리하도록 하는 방법을 택한 것입니다.
SendAsync 메소드를 동시 다발적으로 호출하게 될 경우 전송 순서가 꼬일 수 있지 않을까 하는 생각도 해봤습니다. 이럴 경우에는 애써 tcp 서버를 구현해 놓고 전송 순서가 뒤죽박죽 되는 바보같은 경험을 하게 될지도 모르겠네요. .Net 내부에서 어떻게 구현해 놓은 것 까지는 파악하지 못했기 때문에 뭐라 확답을 드릴 수는 없습니다.
이런 상황에서는 최대한 안전하게 처리하는 방법이 좋겠죠?
의심되면 테스트 코드 만들어서 뻗을 때까지 뺑뺑이 돌리면 되니까요.

Socket.SendAsync 완료 처리
SendAsync 메소드 역시 비동기 메소드입니다.
이번에도 역시 완료시 호출되는 콜백 메소드가 존재합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void process_send(SocketAsyncEventArgs e)
{
    ...
 
    // 전송 완료된 패킷을 큐에서 제거함.
    CPacket packet = this.sending_queue.Dequeue();
    CPacket.destroy(packet);
    this.sending_queue.Dequeue();
 
    // 아직 전송하지 않은 대기중인 패킷이 있다면 다시 한번 전송을 요청함.
    if (this.sending_queue.Count > 0)
    {
        start_send();
    }
 
    ...
}
cs

코드의 주요 부분만 보면 이렇게 구현되어 있습니다.
앞서 설명한 코드를 보면 큐에서 데이터를 꺼내올 때 Peek를 사용하여 큐의 상태를 유지시켜 주었는데요. 여기서 Dequeue 메소드를 사용하여 큐에서도 제거하도록 했습니다.
그리고 해당 패킷을 반환해주는 처리까지 들어갑니다.
CPacket 클래스는 풀링하여 사용하게끔 처리했기 때문에 반드시 반환 처리를 해줘야 메모리가 새지 않습니다. 마지막으로 큐에 데이터가 존재하는지 체크하여 계속 전송 처리를 수행하도록 합니다. 데이터가 없다면 그냥 리턴하고 끝납니다.

process_send 메소드에서 처리해줘야 할 일은 몇가지 더 있습니다.
에러코드도 확인해야 하고 일부만 전송했을 경우 나머지 데이터를 재전송 해주는 등의 처리까지 구현해야 합니다. 재전송 처리를 꼭 해줘야 하는가에 대해서는 저도 잘 모르겠지만 다른 서버 커뮤니티에서 토론한 내용을 보면 발생할 수도 있는 것 같더군요. 이 강좌에서는 일단 흐름 위주로 진행하기 때문에 세세한 부분은 건너 뛰겠습니다.

마지막으로 lock 처리 한 부분에 대해서 설명하겠습니다.
제가 이번 강좌에서 언급한 내용중 한번에 하나의 전송을 처리하기 위해서 별도의 스레드를 사용하지 않고 구현하겠다고 말씀 드렸습니다. 그럼에도 불구하고 큐에 넣고 뺄 때 lock 처리가 되어 있습니다.
.Net 비동기 소켓의 완료 처리시 호출되는 콜백 메소드에서 이 큐를 참조하기 때문에 lock으로 감싸준 것입니다. 전송 요청을 할 때 큐에 추가하는 부분과 실제로 SendAsync를 호출하는 코드는 동일한 스레드에서 이루어집니다. 따라서 이 부분까지는 lock이 필요 없습니다. 하지만 SendAsync 메소드의 완료 콜백 처리는 .Net의 스레드 풀에서 관리하는 스레드 중 하나에서 호출됩니다.

전송 요청 = A스레드
완료 콜백 = B스레드

이렇게 두 개의 스레드에서 동일한 큐에 접근하기 때문에 lock으로 감싸 놓은 것입니다.
스레드를 안쓴다고 해놓고 lock을 써서 구현했으니 더 나아진 것인지 아닌지는 모르겠네요.
비동기라 어쩔 수 없을 것 같습니다.
완료 콜백 메소드인 process_send가 수행되는 중에 또 다른 전송 요청이 이루어질 수 있으니까요.

이제 네트워크 관련 코드는 볼만큼 본 것 같습니다.
소켓 초기화, 버퍼 풀링, 패킷 설계, 데이터 전송, 수신 처리가 대부분이니까요.
다음 강좌에서는 지금까지의 라이브러리 코드를 사용하여
테스트 서버/클라이언트를 만들어보겠습니다.
더미 테스트를 통해서 오류가 없는지 검증하는 방법도 알아보도록 하죠.
감사합니다.


반응형

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

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