고성능 비동기 소켓 서버의 핵심, SocketAsyncEventArgs
.NET에서 수많은 클라이언트의 연결을 동시에 처리하려면 비동기 네트워크 프로그래밍이 필수이다.
특히 서버 사이드 개발에서는 효율성과 성능이 무엇보다 중요하다.
이를 위해 .NET은 IOCP(입출력 완료 포트) 기반의 고성능 네트워크 프로그래밍을 제공하며,
이때 중심에 있는 클래스가 바로 SocketAsyncEventArgs이다.
이번 글에서는 이 객체가 실제로 어떻게 활용되는지 예제 코드를 바탕으로 설명하고, 그 기반이 되는 스레드와 IOCP에 대한 개념도 함께 정리한다.
1. 비동기 네트워크 구조의 배경: 스레드와 IOCP
스레드는 왜 중요한가?
스레드는 프로그램이 CPU에서 작업을 수행하는 실행 단위이다. 일반적인 소켓 서버에서 각 연결당 하나의 스레드를 할당하게 되면, 수천 개의 연결을 동시에 처리하기 위해 그만큼의 스레드가 필요하다. 하지만 운영체제에서 처리할 수 있는 스레드에는 물리적 한계가 존재하며, 스레드 수가 많아질수록 다음과 같은 문제점이 발생한다:
- 컨텍스트 스위칭 비용 증가: 스레드 간 전환 시 레지스터, 스택 등을 백업/복원하는 데 자원이 소모된다.
- 메모리 사용량 증가: 스레드당 스택 메모리가 기본 1MB 이상 소모된다.
- 성능 저하: CPU는 스레드를 계속 전환하느라 실질 작업 시간 감소.
이러한 한계를 해결하기 위해 등장한 것이 바로 IOCP이다.
IOCP (I/O Completion Port)
IOCP는 윈도우에서 제공하는 고성능 I/O 처리 메커니즘으로, 커널에서 입출력 완료 이벤트를 큐에 등록하고, 이를 사용자 공간의 작업자 스레드(Worker Thread)가 가져가서 처리하는 방식이다.
IOCP의 핵심 특징
- 비동기적: 작업 완료 알림만 처리하면 되므로, 기다리지 않는다.
- 적은 수의 스레드로 많은 연결 처리 가능 (예: 수천 개 연결을 수십 개 스레드로 처리)
- 커널과 유저 모드 간 효율적인 통신을 지원
.NET의 SocketAsyncEventArgs 방식은 IOCP 기반 비동기 통신을 추상화한 것으로, IOCP의 성능 이점을 활용하면서도 비교적 쉽게 코드를 작성할 수 있도록 돕는다.
2. SocketAsyncEventArgs는 왜 필요한가?
비동기 소켓 프로그래밍에서 SocketAsyncEventArgs는 다음과 같은 역할을 수행한다:
- 비동기 네트워크 요청 상태를 저장한다.
- 네트워크 작업이 완료되었을 때 이벤트 핸들러를 호출한다.
- Send, Receive 각각에 대해 별도의 인스턴스를 만들어 사용한다.
- Buffer 또는 BufferList를 통해 데이터 송수신 버퍼를 설정한다.
쉽게 설명하자면, SocketAsyncEventArgs는 소켓 이벤트 처리를 위한 객체라고 생각해도 좋다.
실제 서버 개발에서는 이 객체를 재사용(pooling)하여 GC 부하를 줄이고, 작업자 스레드 수를 최소화하여 처리 효율을 높인다.
3. 실전 코드 예제: 세션 기반 송수신
아래 예시는 서버 세션 하나를 표현하는 Session 클래스의 구조이다:
SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();
SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs();
2. 초기화: Start()
서버가 클라이언트의 연결을 수락하면 Start() 메서드로 해당 세션을 초기화한다. 이때 _recvArgs, _sendArgs 각각의 Completed 이벤트에 콜백 핸들러를 등록한다.
public void Start(Socket socket)
{
_socket = socket;
_recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
_recvArgs.SetBuffer(new byte[1024], 0, 1024);
_sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);
RegisterRecv(); // 수신 대기 시작
}
EventHandler란?
C#에서 EventHandler는 이벤트 콜백 메서드를 나타내는 델리게이트 타입이다.
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);
이 정의에 의하여 OnAcceptCompleted는 아래와 같은 형태를 가져야 한다.
void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
주요 포인트
- _recvArgs.SetBuffer()는 수신 시 사용할 고정 버퍼를 설정한다.
- 송신에는 BufferList를 사용하므로 _sendArgs.SetBuffer()는 사용하지 않는다.
- 각 이벤트의 완료 처리 함수는 OnRecvCompleted, OnSendCompleted이다.
3. 수신: ReceiveAsync()
ReceiveAsync()는 비동기로 데이터를 수신하며, 완료되면 Completed 이벤트가 발생한다.
만약 대기 없이 바로 완료되면(false 반환), 직접 OnRecvCompleted()를 호출해야 한다.
void RegisterRecv()
{
bool pending = _socket.ReceiveAsync(_recvArgs);
if (pending == false)
OnRecvCompleted(null, _recvArgs);
}
void OnRecvCompleted(object sender, SocketAsyncEventArgs recvArgs)
{
if (recvArgs.BytesTransferred > 0 && recvArgs.SocketError == SocketError.Success)
{
OnRecv(new ArraySegment<byte>(recvArgs.Buffer, recvArgs.Offset, recvArgs.BytesTransferred));
RegisterRecv(); // 계속해서 다음 수신 예약
}
else
{
Console.WriteLine(recvArgs.SocketError.ToString());
}
}
4. 송신: SendAsync()와 BufferList
송신은 Queue<byte[]>로 보낼 데이터를 누적해두고, RegisterSend()에서 BufferList로 바꿔 SendAsync()를 수행한다.
void RegisterSend()
{
while (_sendQueue.Count > 0)
{
byte[] buff = _sendQueue.Dequeue();
_pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length));
}
_sendArgs.BufferList = _pendingList;
bool pending = _socket.SendAsync(_sendArgs);
if (!pending)
OnSendCompleted(null, _sendArgs);
}
BufferList는 List<ArraySegment<byte>>만 받기 때문에, 반드시 리스트를 직접 만들어 설정해야 하며 Add()는 불가능하다.
송신 완료 콜백
void OnSendCompleted(object send, SocketAsyncEventArgs args)
{
lock (_lock)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
{
_sendArgs.BufferList = null;
_pendingList.Clear();
OnSend(_sendArgs.BytesTransferred);
if (_sendQueue.Count > 0)
RegisterSend(); // 남은 메시지가 있다면 계속 송신
}
else
Console.WriteLine(args.SocketError.ToString());
}
}
BufferList는 사용 후 반드시 null로 초기화해야 하며, _pendingList도 비워야 재사용할 수 있다.
5. 연결 해제 처리
Disconnect()는 Interlocked.Exchange()를 통해 중복 실행을 방지하며, Shutdown() 및 Close()로 자원을 해제한다.
public void Disconnect()
{
if (Interlocked.Exchange(ref _disconnect, 1) == 1)
return;
OnDisconnected(_socket.RemoteEndPoint);
_socket.Shutdown(SocketShutdown.Both);
_socket.Close();
}
6. 확장 포인트
이 클래스는 추상 클래스이며, 실제 세션 로직은 상속받는 쪽에서 구현해야 한다.
public abstract void OnConnected(EndPoint endPoint);
public abstract void OnRecv(ArraySegment<byte> buffer);
public abstract void OnSend(int numOfBytes);
public abstract void OnDisconnected(EndPoint endPoint);
출처 :
[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버 강의 | Rookiss - 인프런
[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버 강의 | Rookiss - 인프런
Rookiss | , MMORPG 개발에 필요한 모든 기술, C# + Unity로 Step By Step! 🕹️ [사진] 기초부터 끝판왕까지, MMORPG 개발하기 🎮 아무런 지식도 없다는 가정하에 누구나 부담없이 차근차근 수강할 수 있도
www.inflearn.com
2025-03-20 (2) <함수의 매개변수화 - 콜백함수와 delegate>
2025-03-20 (2) <함수의 매개변수화 - 콜백함수와 delegate>
함수의 매개변수화 - 콜백함수와 delegateJavaScript와 C# 비교하기JS는 함수를 1급 객체 취급하므로, 함수 자체를 그냥 변수처럼 넘길 수 있다.function sayHello() { console.log("Hello!");}// JavaScript는 함수 이름
princeali.tistory.com
2024-12-23 <복잡한 IOCP 쉽게 이해하기>
IOCP란 멀티 프로세서 환경에서 다수의 비동기 입출력(I/O)을 처리하기 위해 고안된 모델입니다.그 동안 우리가 비동기 함수에 대해서 배웠던 걸 기억하시나요? setTimeout같은 비동기 함수의 실행
princeali.tistory.com
ChatGPT
'C# > TIL(Today I Learned)' 카테고리의 다른 글
2025-04-23 <const와 readonly> (0) | 2025.04.23 |
---|---|
2025-04-19 <ArraySegment> (0) | 2025.04.19 |
2025-04-18 <C# 비동기 멀티스레드 서버 구현하기> (0) | 2025.04.18 |
2025-04-07 <캐시 철학에 대하여> (1) | 2025.04.07 |
2025-04-03 <DotNetEnv 패키지 활용법> (0) | 2025.04.03 |
댓글