C#/TIL(Today I Learned)

2025-04-18 <C# 비동기 멀티스레드 서버 구현하기>

프린스 알리 2025. 4. 18.

1. 개요

TCP 서버는 클라이언트와의 안정적인 연결을 제공하기 위한 기본적인 통신 구조이다.

특히 C#에서는 System.Net.Sockets 네임스페이스를 통해 비교적 간단하게 TCP 서버를 구현할 수 있다.

그러나 단일 스레드로는 여러 클라이언트를 동시에 처리하기 어렵기 때문에,

멀티스레드 구조를 도입하여 동시성(concurrency) 을 확보하는 것이 중요하다.

이 글에서는 C#으로 멀티스레드 TCP 서버를 구성하는 방법을 설명한다.

2. 기본 구조

Node.js로 서버를 구현할 때와 마찬가지로, 소켓 프로그래밍의 흐름은 언제나 비슷하다.

 

 

  1. 소켓을 만들고 서버가 사용할 IP 주소와 포트 번호를 결합한다.
  2. 해당 포트에서 연결 요청이 수신되는지 주시(listen)한다.
  3. 클라이언트가 접속하면 전용 게임 세션을 생성하고 데이터를 송수신한다.
  4. 클라이언트 연결 종료 시, 소켓을 해제한다.

3. 일반적인 코드 예시(비동기X)

서버

 

클라이언트

 

출처 : jacking75/edu_CSharpNetworkProgramming: 컴투스 C# 네트워크 프로그래밍 학습

 

 

4. 비동기 서버 코드 예시

이 서버는 마치 "낚시터"에서 물고기를 기다리는 구조와 유사하다.
서버는 낚시꾼(Listener) 이 되어 클라이언트(물고기) 가 물기를 기다리고, 물면 바로 세션(Session) 을 할당해 처리한다.

전체 흐름 요약

1. 서버 준비 (주소 설정 → 소켓 생성 → Bind → Listen)
2. 비동기 Accept 대기 (낚시대를 던짐)
3. 클라이언트가 접속하면 OnAcceptCompleted 호출
4. 세션 할당 → 처리 시작
5. 다시 낚시대를 던져 다음 클라이언트를 기다림

 

1. 서버 시작 (Main())

string host = Dns.GetHostName(); // 현재 컴퓨터의 호스트명
IPHostEntry ipHost = Dns.GetHostEntry(host); // 호스트의 IP 목록 조회
IPAddress ipAddr = ipHost.AddressList[0]; // 그 중 하나 선택
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // IP + 포트를 묶어서 끝점 생성
  • Dns 관련 함수는 로컬 환경에서 서버가 어떤 IP로 열릴지 정한다.
  • 7777 포트로 서버를 열 준비를 함.
_listener.Init(endPoint, () => { return new GameSession(); });
  • 실제 서버를 여는 함수 Init() 호출
  • GameSession은 클라이언트별 연결을 처리할 객체이며, 이후에 연결되면 이걸로 세션을 할당한다.

2. Init() – 낚시터 개장

_listenSocket = new Socket(...);
_listenSocket.Bind(endPoint);
_listenSocket.Listen(10);
  • 서버 소켓 생성 → 해당 포트에 바인딩 → 최대 10명까지 대기 설정
  • 이제 클라이언트를 받을 준비가 되었다.
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
  • SocketAsyncEventArgs는 비동기 작업 결과를 담는 컨테이너다.
  • 이벤트 핸들러 등록 (OnAcceptCompleted)
  • 바로 첫 낚시대를 던짐 (RegisterAccept())

3. RegisterAccept() – 낚시대 던지기

args.AcceptSocket = null;
bool pending = _listenSocket.AcceptAsync(args);
  • 이 객체는 재사용되므로 반드시 .AcceptSocket = null 초기화
  • AcceptAsync()는 비동기 함수로, 클라이언트 접속을 기다림
  • 반환값이 true면 아직 물고기가 안 물린 상태 (이벤트로 기다림)
  • 반환값이 false면 즉시 물고기가 물린 상태이므로 콜백을 수동 호출
if (pending == false)
    OnAcceptCompleted(null, args);
  • 이미 연결되었다면 직접 OnAcceptCompleted를 호출해서 처리 시작

4. OnAcceptCompleted() – 물고기 낚임! 세션 처리

if (args.SocketError == SocketError.Success)
{
    Session session = _sessionFactory.Invoke(); // GameSession 생성
    session.Start(args.AcceptSocket); // 클라이언트 소켓 할당
    session.OnConnected(args.AcceptSocket.RemoteEndPoint); // 연결된 클라이언트 정보 로그
}
else
    Console.WriteLine(args.SocketError.ToString());
  • 연결 성공 여부를 확인한 뒤, Session을 생성
  • 세션에 소켓을 할당하고 OnConnected()로 후처리 시작
RegisterAccept(args); // 다음 연결을 위해 낚시대를 다시 던진다
  • 한 명 처리 후 다음 손님을 받기 위해 낚시대를 다시 던진다 (재귀적으로 Accept)

 

 


 

추가 공부 - <세션>에 대해서

세션(Session)의 정의

세션(Session)클라이언트 한 명과의 연결을 추상화한 객체이다.

즉, 클라이언트가 서버에 접속하면, 서버는 Socket 객체를 통해 해당 클라이언트와 연결되고, 이를 처리하기 위해 하나의 Session 인스턴스를 만들어 관리하게 된다.


왜 세션이라는 개념이 필요한가?

단순히 Socket만 사용해서 클라이언트를 처리할 수도 있다. 하지만 이렇게 하면 다음과 같은 문제가 생긴다.

  1. 클라이언트마다 어떤 데이터를 주고받고 있는지 추적하기 어렵다.
  2. 클라이언트별 상태, 예: 로그인 여부, 위치, 플레이어 정보 등을 보관할 공간이 없다.
  3. 클라이언트 연결이 끊어질 경우, 어떤 처리를 해줘야 할지 분리하기 어렵다.

그래서 Session 클래스를 따로 만들어 클라이언트 개별 로직을 전담시키는 구조로 분리하는 것이다.

 

session은 다음 역할을 수행한다.

  • 세션은 "클라이언트 한 명을 담당하는 객체" 이다.
  • 내부에는 Socket 객체가 들어 있으며, 수신/송신, 상태 관리, 연결 종료 등을 책임진다.
  • 하나의 서버는 여러 개의 세션을 만들어 여러 클라이언트를 동시에 처리한다.
  • 서버 입장에서는 “몇 번 접속한 누구”가 아니라, “이 세션을 통해 들어온 사용자”로 구분한다.

예시로 만들어볼 수 있는 Session 클래스 구조

abstract class Session
{
    Socket _socket;
    int _disconnect = 0;

    object _lock = new object();

    Queue<byte[]> _sendQueue = new Queue<byte[]>();
    List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>();

    SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();
    SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs();

    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);
    
    // ... 세부 구현
}

 

 

댓글