앞선 파일 송신에 이어 파일 수신 예제를 살펴보자.

이 코드도 프로토콜이 없는 파일 수신이다.

 

//수신할 파일을 생성한다.
puts("*** 파일 수신을 시작합니다. ***");
FILE *fp = NULL;
errno_t nResult = fopen_s(&fp, "Sleep away.zip", "wb");
if (nResult != 0)
	ErrorHandler("파일을 생성 할 수 없습니다.");

 

수신할 파일을 생성한다. .zip로 생성했는데, zip 파일로 생성하고 압축을 풀면 crc체크로 오류를 체크해주기 때문에 테스트 하기에 딱이다. 정석대로 하려면 MD5라는 해시 알고리즘을 사용해서 무결성을 체크해줘야한다.

 

//서버가 전송하는 데이터를 반복해 파일에 붙여 넣는다.
char byBuffer[65536];		//64KB
int nRecv;
while ((nRecv = ::recv(hSocket, byBuffer, 65536, 0)) > 0)
{
	//서버에서 받은 크기만큼 데이터를 파일에 쓴다.
	fwrite(byBuffer, nRecv, 1, fp);
	putchar('#');
}

 

서버로부터 데이터를 수신받아 파일에 쓴다

간단한 파일 송신 예제를 살펴보자. 

프로토콜이 없는 파일 송신이다.

 

//전송할 파일 개방
FILE *fp = NULL;
errno_t nResult = fopen_s(&fp, "Sleep Away.zip", "rb");
if (nResult != 0)
	ErrorHandler("전송할 파일을 개방할 수 없습니다.");

 

전송할 파일을 연다.

 

//파일송신
char byBuffer[65536];		//64KB
int nRead, nSent, i = 0;
while ((nRead = fread(byBuffer, sizeof(char), 65536, fp)) > 0)
{
	//파일에서 읽고 소켓으로 전송한다.
	//전송에 성공하더라도 nRead와 nSent 값은 다를 수 있다!!!
	nSent = send(hClient, byBuffer, nRead, 0);
	printf("[%04d] 전송된 데이터 크기: %d\n", ++i, nSent);
	fflush(stdout);
}

 

 

함수 호출을 덜하고 송신 자체를 적게 하므로 파일을 송신할때 송신하는 크기는 클수록 보통 좋다. 

위 코드에서는 64KB 만큼 송신하지만, 어느정도가 적당한지는 실제로 용량을 바꾸면서 직접 알아내야한다.

'네트워크' 카테고리의 다른 글

[네트워크] 파일 송신 개선  (0) 2025.04.20
[네트워크] 파일 수신  (0) 2025.04.20
[네트워크] TCP 장애 유형  (0) 2025.04.20
[네트워크] TCP/IP 연결의 의미  (0) 2025.04.20
[네트워크] select  (0) 2025.04.17
  • Packet Loss ( 패킷이 유실된 경우 )
  • TCP Out of order ( 패킷의 순서가 뒤바뀐 경우 )
  • Retransmission과 Dup ACK
  • Zero - window ( 수신측 버퍼에 여유 공간이 하나도 없는 경우 )

TCP 계층 즉, 4계층 부터 아래 1계층 까지 따져봤을 때 발생할 수 있는 장애 유형이 있다.

 

📘 Packet Loss ( 패킷이 유실된 경우 )

인터넷이라는 네트워크 환경은 기본적으로 불안정하다.

그래서 인터넷 수준, L3 혹은 그 이하에서 발생하는 것 중에 가장 흔한 게 패킷이 아예 유실되어 버릴 수 있다.

자주 일어나는 일은 아니지만 간간히 일어난다.

 

📘 TCP Out of order ( 패킷의 순서가 뒤바뀐 경우 ).

수신 측에서 예상한 순서와 다른 TCP 세그먼트가 도착하는 현상을 말한다.

 

TCP는 연결이라는 개념을 포함하고 있는데, 데이터들이 날아올 때 순서를 따진다.

연결이 있고 순서 개념이 있다 보니 데이터가 왔을 때, 1번이 오면 그다음은 2번 그다음은 3번이 와야하는데

가끔 순서가 뒤바뀔 수 있다.

 

발생하는 주요 원인

  • 멀티패스 라우팅
    • 하나의 TCP 연결이 여러 네트워크 경로를 통해 전송될 경우
    • 각 경로마다 지연이 다르기 때문에 순서가 바뀌기 쉽다.
  • 네트워크 혼잡
    • 일부 패킷이 네트워크 장비에서 큐잉되거나 드롭되면서 재전송이 발생
    • 나중에 보낸 패킷이 먼저 도착하는 상황이 생길 수 있다.
  • 리트랜스미션
    • ACK 손실 또는 지연으로 인해 송신 측이 재전송하게 되어, 그 패킷이 원래 패킷보다 먼저 도착할 수 있다.

 

📘 Dup ACK (  Duplicate ACK )

Dup ACK는 말 그대로 중복된 ACK 패킷을 의미한다.

 

TCP 수신 측이 같은 ACK 번호를 반복해서 보낼 때, 이를 송신 측에서 중복 ACK로 인식한다.

이는 일반적으로 데이터 손실 또는 Out of order 상황을 의미한다.

 

특정 패킷이 안와서 재전송을 요청했는데 해당 패킷이 도착하고, 재전송한 패킷이 도착하면 

Dup ACK가 발생할 수 있는 것이다.

 

발생하는 주요 원인

  • 패킷 손실 
    • 가장 흔한 원인. 특정 시퀀스의 세그먼트가 손실되었을 때 발생
  • 패킷 재정렬
    • 순서가 어긋난 패킷이 먼저 도착하여, 이전 시퀀스 번호에 대한 ACK가 반복되는 경우

 

📘 Zero-window ( 수신측 버퍼에 여유 공간이 하나도 없는 경우 )

윈도우 사이즈가 0이 된다는 것은 수신 측에서 더 이상 데이터를 받을 수 없는 상태를 의미한다.
다시 말해 수신 측 버퍼가 꽉 찼다는 의미

 

소켓 수신 버퍼에 데이터가 차곡차곡 쌓일 때, 

recv를 이용해 소켓 수신 버퍼에 있는 데이터를 L7에 있는 버퍼에 데이터를 담는 속도보다

소켓 수신 버퍼에 데이터가 담기는 속도보다 빠르지 않으면 윈도우 사이즈가 0이 된다.

 

이는 위 3가지의 오류 사항과는 성격이 다르다.

위 3가지의 오류 사항은 네트워크 인프라 수준에서의 오류라고 생각 할 수 있는 반면

Zero-window는 개발자가 구현하는 코드에 따라 해결이 가능하기 때문이다.

'네트워크' 카테고리의 다른 글

[네트워크] 파일 수신  (0) 2025.04.20
[네트워크] 파일 송신  (0) 2025.04.20
[네트워크] TCP/IP 연결의 의미  (0) 2025.04.20
[네트워크] select  (0) 2025.04.17
[네트워크] I / O 멀티플렉싱  (0) 2025.04.17

TCP/IP에서의 클라이언트와 연결은 3 way handshake라는 규칙이 만족되면

연결이 되었다고 서버와 클라가 주관적으로 판단하는 것으로, 단순히 논리적인 통신 채널이 확보된 상태라고 정의 할 수 있다.

 

다시 말해 서버 입장에서는 accept 함수가 리턴되고, 클라 입장에서는 connect 함수가 리턴을 하면 연결됐다라고 할 수 있는 것이다.

 

물리적인 통신 채널이 아닌 논리적인 통신 채널이 확보된 상태이기 때문에

TCP 연결이 끊어졌다는 것을 인지할 때 상황에 따라 다르다.

 

일반적인 정상적인 종료는 클라에서 FIN 패킷을 보내 연결증 종료하면 서버가 이를 수신하고,

4 way handshake를 우아한 종료를 진행하며 이를 통해 연결 해제를 명확히 감지할 수 있다.

하지만 클라가 랜케이블을 뽑거나 블루스크린처럼 갑자기 다운되는 경우, FIN이나 RST 신호를 보내지 못하므로

서버는 클라가 연결이 끊겼는지 즉시 알 수 없다.

 

이를 보완하기 위해 서버에서 아래와 같은 방법들을 사용한다.

  • TCP Keep-Alive 설정 : 일정시간 동안 응답이 없으면 OS 수준에서 연결을 끊는다.
  • Heartbeat / ping : L7 단계에서 주기적으로 메시지를 보내, 응답이 없으면 연결 해제가 되었다고 판단한다.
    • 데이터 통신이 원활히 이루어지고 있을 경우에는 굳이 Heartbeat를 주고 받을 필요 없기 때문에 이를 고려해 코드를 작성해야한다.

 

앞서 언급한 내용처럼 TCP에서의 연결은 착각이라고도 생각할 수 있겠다.

서버에서는 클라와 연결중이라고 생각 할 수 있지만,

클라가 랜케이블을 뽑았는지, 블루스크린이 떳는지 알 방법이 없기 때문이다.

 

따라서 클라이언트와의 연결은 단순한 통신 가능 여부를 넘어서, 식별 가능성, 상태 추적 까지 포함된 개념이라고 봐야한다.

'네트워크' 카테고리의 다른 글

[네트워크] 파일 송신  (0) 2025.04.20
[네트워크] TCP 장애 유형  (0) 2025.04.20
[네트워크] select  (0) 2025.04.17
[네트워크] I / O 멀티플렉싱  (0) 2025.04.17
[네트워크] 우아하지 않은 비정상 종료  (0) 2025.04.14
select()는 리눅스 / 유닉스 시스템에서 여러 파일 디스크립터를 동시에 감시하여, 읽기/쓰기/예외 이벤트가 발생한 파일 디스크립터를 감지할 수 있게 해주는 함수다.

 

  • 여러 클라이언트의 요청을 동시에 처리할 수 있게 해준다.
  • 여러 소켓을 동시에 감시할 수 있게 해준다.
  • 단일 스레드 기반의 네트워크 서버에서 효율적으로 동시성 처리가 가능하다.
  • 하지만 최대로 감지할 수 있는 클라의 수가 윈도우는 64개, 리눅스는 1024개 이기 때문에 제한이 있다.

 

✅ 예제

 

UINT nCount
FD_SET fdRead;
std::list<SOCKET>::iterator it;

// 클라 접속 및 정보 수신 변화 감시셋을 초기화 한다.
FD_ZERO(&fdRead);
for(it = g_listClient.begin(); it != g_listClient.end(); ++it)
{
    FD_SET(*it, &fdRead);
}

// 변화가 발생할 때까지 대기한다.
::select(0, &fdRead, NULL, NULL, NULL);

// 변화가 감지된 소켓을 확인한다.
nCount = fdRead.fd_count;
for(int nIndex = 0; nIndex < nCount; ++nIndex)
{
    // 소켓에 변화 감시 플래그가 설정 되었는지 확인한다.
    if(!FD_ISSET(fdRead.fd_array[nIndex], &fdRead))
    {
        continue;
    }
    
    // 감지된 소켓이 listen 소켓인지 확인한다.
    // 다시 말해, 클라가 연결을 시도했는지 여부를 확인한다.
    if(fdRead.fd_array[nIndex] == g_hSocket)
    {
        // 새 클라이언트의 접속을 받는다.
        SOCKADDR_IN clientaddr = { 0 };
        int nAddrLen = sizeof(clientaddr);
        SOCKET hClient = ::accept(g_hSocket, (SOCKADDR*)&clientaddr, &nAddrLen);
        if(hClient != INVALID_SOCKET)
        {
            FD_SET(hClient, &fdRead);
            g_listClient.push_back(hClient);
        }
    }
    else // 클라이언트가 전송한 데이터가 있을 경우
    {
        char szBuffer[1024] = { 0 };
        int nReceive = ::recv(fdRead.fd_array[nIndex], (char*)szBuffer, sizeof(szBuffer), 0);
    }
}
  • 여러 입 / 출력 요청이 한 채널에 동시에 혼재 할 수 있는 입/출력
  • 개발자 관점에서 보면 송신과 수신이 동시에 발생하는 것 처럼 보인다.
  • 모든 입 / 출력은 프로세스가 아니라 OS가 주도한다는 것이 핵심이다.

예를 들어 파일에 fwrite를 하면, 우리가 작성한 프로그램이 fwrite를 했다라고 생각할 수 있지만,

본질적으로는 OS한테 파일에 write 해달라고 요청한 것에 불과하다.

 

이처럼 I/O 멀티플렉싱 역시 OS가 처리해준다.

 

네트워크에서 굉장히 중요한 관점 중 하나는 송신하고 수신이 동시에 일어난다는 점인데,

단순히 생각해보면 위 작업을 멀티쓰레드로 하면 가능하다는 것을 생각할 수 있다.

 

송수신이 동시에 일어난다면, 이를 감시 할 수 있는 작업 또한 필요한데,

송수신이 완료되면 OS가 알려주는 방식이 어떤 특정 함수를 호출하는 방식이라면 callback 방식이 되는 것이고,

신호를 주는 방식이라면 Event 방식이 되는 것이다.

 

  • 연결된 클라이언트 혹은 서버 프로세스가 비정상 종료되면 발생하는 일 ( 메모리 폴트 등 )
  • 프로세스 종료 시 OS는 할당해준 자원을 강제 회수하며 여기에는 개방한 파일과 소켓이 포함된다.
  • 프로세스 강제 종료보다 더 심각한 경우

 

와이어 샤크로 비정상 종료 되면 어떤 패킷이 전송되는지 확인해보자.

 

클라를 강제로 종료하면 위 그림처럼 RST 를 전송하는 것을 확인할 수 있다.

RST을 받으면 그 즉시 TCP 연결은 끊어진다.

 

📌 프로세스 강제 종료보다 심각한 경우

  • 상대방이 랜 케이블을 뽑은 경우
  • 상대방이 블루스크린이 발생한 경우

위 2가지 모두 연결은 끊어졌지만 서버쪽은 상대방이 끊겨 있는지 확인할 방법이 없다.

위같은 연결 상태를 좀비세션이라고 부른다.

 

위 경우 말고도 라우터에 문제가 생겨서 좀비 세션이 생기기도 한다.

 

위와 같은 경우를 해결하기 위해 보통 하트비트라는 기법을 사용해 좀비세션을 탐지한다.

하트비트는 주기적으로 서버가 클라에게 특정 패킷을 전송하고 응답이 없을 경우 해당 클라와 연결을 끊어버리는 것을 말한다.

.

  • 채팅 메세지 사용자 입력 및 송신처리 전담 쓰레드를 만든다.
  • 서버가 보내주는 채팅 메세지 수신 및 처리 전담 쓰레드 분리

채팅을 받을 쓰레드를 따로 생성

// 채팅 메세지 수신 쓰레드 생성
DWORD dwThreadID = 0;
HANDLE hTread = ::CreateThread(NULL,
    0,
    ThreadReceive,
    (LPVOID)hSocket,
    0,
    &dwThreadID);
::CloseHandle(hTread);

 

ThreadReceive

// 서버가 보낸 메세지를 수신하고 화면에 출력하는 쓰레드 함수
DWORD WINAPI ThreadReceive(LPVOID pParam)
{
    SOCKET hSocket = (SOCKET)pParam;
    char szBuffer[128] = { 0 };
    while(::recv(hSocket, szBuffer, sizeof(szBuffer), 0) > 0)
    {
        printf("-> %s\n", szBuffer);
        memset(szBuffer, 0, sizeof(szBuffer));
    }
    
    puts("수신 쓰레드가 끝났습니다.");
    return 0;
}

 

서버로부터 FIN( 0 )을 받으면 끝낸다.

앞서 만든 Echo 멀티 쓰레드 코드에 약간의 변형을 주어 채팅 서버로 변환할 수 있다.
  • 클라이언트 TCP 세션을 관리하는 개별 쓰레드가 클라이언트 수 만큼 존재한다.
  • 연결된 모든 클라이언트를 관리하기 위한 자료구조가 필요하다.
  • 쓰레드에서 자료구조에 삭제, 추가, 접근 해야하기 때문에 쓰레드 동기화 작업이 필요하다.

 

서버에 접속하는 유저를 리스트로 관리한다.

// 새로 연결된 클라의 소켓을 리스트에 저장한다.
BOOL AddUser(SOCKET hSocket)
{
    // CriticalSection을 이용해 동기화를 시작한다.
    ::EnterCriticalSection(&g_cs); // 임계영역 시작
    g_listClient.push_back(hSocket);
    ::LeaveCriticalSection(&g_cs); // 임계영역 끝
    
    return TRUE;
}

 

CriticalSection을 이용해 동기화를 시작하면 쓰레드가 진입해 LeaveCriticalSection을 호출하기 전까지

다른 쓰레드는 g_listClient에 socket을 저장할 수 없다.

이처럼 다른 쓰레드의 임계영역이 끝나야만 다른 쓰레드가 작업할 수 있으므로 임계구간은 최소화하는것이 좋다.

 

 

연결된 클라들에게 메세지를 전송한다.

void SendChattingMessage(char *pszParam)
{
    int nLength = strlen(pszParam);
    std::list<SOCKET>::iterator it;
    
    ::EnterCriticalSection(&g_cs); // 임계영역 시작
    for(it = g_listClient.begin(); it != g_listClient.end(); ++it)
    {
        ::send(*it, pszParam, sizeof(char)* (nLength + 1), 0);
    }
    ::LeaveCriticalSection(&g_cs); // 임계영역 끝    
}

 

멀티쓰레드 기반 채팅 클라이언트 7분 강의

앞서 작성했었던 Echo Server를 멀티 쓰레드 환경으로 변경해보자.

 

main.c 

accept를 담당하고, 클라와 연결 되면 해당 클라와 통신하기 위한 쓰레드를 생성해준다.
// 클라 연결을 받고 새로운 소켓을 생성한다.
while((hClient = ::accept(hSocket,
    (SOCKADDR*)&clientaddr,
    &nAddrLen)) != INVALID_SOCKET)
{
    // 새 클라와 통신하기 위한 쓰레드를 생성한다.
    // 클라가 접속할 때마다 쓰레드가 하나씩 생성된다.
    hThread = ::CreateThread(
        NULL, // 쓰레드를 생성한 프로세스의 보안속성을 상속받는다.
        0,   // 스택 메모리를 기본 크기 값으로 가진다. ( 1MB )
        ThreadFunction, // 쓰레드로 실행할 함수의 이름을 적는다.
        (LPVOID)hClient, // 새로 생성된 클라 소켓을 넘긴다.
        0, // 생성 플래그는 기본값을 사용한다.
        &dwThreadID); // 생성된 쓰레드 ID가 저장될 변수의 주소를 담는다.
        
    ::CloseHandle(hTread);
}

 

 

ThreadFunction

클라의 recv, send를 담당할 쓰레드
DWORD WINAPI ThreadFunction(LPVOID pParam)
{
    char szBuffer[128] = { 0 };
    int nReceive = 0;
    SOCKET hClient = (SOCKET)pParam;
    
    puts("새 클라이언트가 연결되었습니다.");
    
    while((nReceive = ::recv(
                hClient, szBuffer, sizeof(szBuffer), 0)) > 0)
    {
        ::send(hClient, szBuffer, sizeof(szBuffer), 0);
        puts(szBuffer);
        memset(szBuffer, 0, sizeof(szBuffer));
    }
    
    puts("클라이언트 연결이 끊어졌습니다.");
    ::closesocket(hClient);
    return 0;
}

 

+ Recent posts