나만의 작은 도서관

[TIL][C++] 250519 MMO 서버 개발 20일차: 데이터 링크 계층에서 오류 검증을 했어도 전송 계층에서 다시 오류를 검증하는 이유(OSI 7계층), TCP 연결 종료시 FIN 패킷과 RST 패킷이 보내지는 상황은 언제인가 등등… 본문

Today I Learn

[TIL][C++] 250519 MMO 서버 개발 20일차: 데이터 링크 계층에서 오류 검증을 했어도 전송 계층에서 다시 오류를 검증하는 이유(OSI 7계층), TCP 연결 종료시 FIN 패킷과 RST 패킷이 보내지는 상황은 언제인가 등등…

pledge24 2025. 5. 19. 23:52
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

데이터 링크 계층에서 오류 검증을 했어도 전송 계층에서 다시 오류를 검증하는 이유(OSI 7 계층)

  • OSI 7 계층 기준으로 사용자가 상대 호스트에게 데이터를 전송하면 최상위 계층인 애플리케이션 계층부터 물리 계층까지 내려가면서 헤더를 추가하고, 상대 호스트는 해당 데이터를 물리 계층부터 애플리케이션 계층까지 올라가면서 헤더를 제거한다.
  • 의아한 점은 2 계층인 데이터 링크 계층과 4 계층인 전송 계층의 UDP, TCP 둘 다 오류를 검출하는 필드가 헤더에 존재한다는 것이다. 각 호스트가 데이터를 주고받을 때 모든 계층을 한 번씩 지나간다면 굳이 여러 계층에서 오류 검출을 할 필요가 없어 보였다.

데이터 링크 계층(L2)에서 오류 검출이 필요한 이유

  • 하지만 여기에는 이유가 있다. 확실히 종단점(endpoint)에 해당하는 호스트들은 모든 계층을 데이터가 지나가지만, 네트워크 속 중간 노드에 해당하는 라우터(L3), 스위치(L2)는 4 계층을 지나가지 않는다. 따라서 2 계층에 오류 검출 필드가 없다면 중간 노드 사이의 전송(hop by hop)에서 발생한 비트 오류를 잡아내지 못할 것이므로 2 계층의 오류 검출은 필요하다.
[host]---->[router]---->[router]---->[host]
           |-----hop by hop----|
|---------------end to end-----------------|

그렇다면 전송 계층(L4)은?

  • 데이터 링크 계층에서 오류를 검출해 준다면 전송 계층에서 오류 검출을 할 필요가 없다는 생각이 자연스레 들 것이다. 하지만, 데이터 링크 계층의 오류 검출만으로는 100% 정확한 데이터 전달을 보장할 수 없다.
  • 라우터(또는 스위치)는 오류가 검출되면 곧바로 해당 데이터를 폐기하고 재전송을 요구하는 방식으로 데이터의 신뢰성을 보장한다. 여기에는 맹점이 존재하는데, 수신받은 데이터가 멀쩡하다면 송신하기까지의 과정 사이에선 오류를 확인하지 않는다는 것이다. 즉,
    • 정상 수신(오류 없음) → 내부 로직 실행 중 비트 오류 발생 → 그대로 전송(오류 있음)
    • 과정이 발생할 수 있다는 것이다. 스위치를 예를 들면 아래와 같다.
    ┌─────────────────────────────────────────────────────────┐
    │ 스위치 내부                                             │
    ├─────────────────────────────────────────────────────────┤
    │ [수신 CRC] → [버퍼링] → [포워딩] → [큐잉] → [전송]       │
    │      ✓       ↑---------오류 감지 불가능---------↑       │
    └─────────────────────────────────────────────────────────┘
    
    • 심지어 라우터는 3 계층까지 올라갔다가 다시 내려가기 때문에 데이터 링크 계층의 헤더를 망가진 데이터를 기준으로 다시 만들어 전송하므로, 해당 데이터를 받는 다른 라우터는 멀쩡한 데이터로 보인다!

결론적으로…

  • 스위치든 라우터든 수신받은 이후에 네트워크 장비 자체에서 비트 오류가 발생하면 데이터 링크 계층의 오류 검출로 걸러낼 수 없다는 것이다. 따라서, 종단 지점까지 가서 TCP가 검증을 해줘야지 비로소 100% 무결성이 증명할 수 있게 된다. 지금까지의 내용을 정리하자면 아래와 같다.
    • 데이터 링크 계층(L2): 중간 노드(라우터나 스위치) 사이의 전송에서 발생하는 오류 검출. 데이터 무결성 100% 보장 X
    • 전송 계층(L4): 종단 노드(호스트) 사이의 전송에서 발생하는 최종 오류 검출. 데이터 무결성 100% 보장 O

오류 발견 시 TCP와 UDP에서 수행하는 행동 차이

  • TCP는 신뢰성을 보장하므로, 데이터에서 오류가 검출되면 헤더에 내장된 필드를 이용해 고쳐 쓰거나 재전송을 요구한다.
  • 반면 UDP는 checksum을 통해 오류를 검사하고, 검출되면 그냥 폐기한다.(일반적으로)

TCP 연결 종료 시 FIN 패킷과 RST 패킷이 보내지는 상황은 언제인가

  • TCP에서 연결을 끊는 방식은 2가지가 있다. 첫 번째로 1) payload가 0이고 FIN 플래그가 세워진 FIN 패킷 전송, 두 번째로 2) FIN대신 RST 플래그가 세워진 RST 패킷 전송이 있다.

FIN 패킷 전송의 경우

  • FIN 패킷을 전송하는 경우는 프로그램 내에서 정상적인 절차에 따라 연결을 끊은 경우로, 어찌 보면 굉장히 정중한 연결 끊음이다.
  • FIN 패킷은 linger설정을 하지 않고 closesocket()을 호출했을 때 전송되며, 이 경우 4-way handshake 방식으로 종료된다.
    • 4-way handshake 방식을 보면 알겠지만, 잔여 송신 데이터가 있으면 상대 호스트에게 전부 보낸다. 즉, FIN 패킷을 보냈다고 소켓을 바로 끊어버리는 게 아니라 상대가 데이터를 다 보내고 FIN 패킷을 보낼 때까지 기다린다. 이렇게 연결을 끊기 전에 상대에게 남은 잔여 송신 데이터들을 다 수신하고 종료하는 방식을 “gracful close(또는 graceful shutdown)”이라고 부른다.

RST 패킷 전송의 경우(+SO_LINGER)

  • 두 번째 방식인 RST 패킷은 비정상적인 종료가 발생했을 때 전송된다. 예를 들어 강제로 프로그램을 종료(kill명령어와 같은)한다던가, linger 옵션을 활성화한 경우가 있다.
    • (프로그램을 강제로 종료하면, 호스트는 연결을 RST로 정리하기 때문에 RST 패킷이 전송된다.)
  • linger 옵션은 잔여 송신 데이터를 보낼지 여부를 설정하는 옵션으로, 활성화 시 timeout을 걸어두고 timeout 되면 곧바로 hard close를 해버린다.
  • hard close를 할 경우 RST 패킷이 날아가는데, 이러한 이유로 timeout을 0으로 설정하면 closesocket()을 하자마자 잔여 송신 데이터가 있든 말든 handshake 없이 그대로 RST 패킷을 전송한다. linger옵션을 설정하는 SO_LINGER로 정리하자면 아래와 같다.

설정 상태 동작 유형 전송되는 패킷

SO_LINGER 비활성화 (기본값) 정상 종료 FIN
SO_LINGER 활성화 + timeout > 0 타임아웃 대기 후 정상 종료 FIN
SO_LINGER 활성화 + timeout = 0 비정상 종료 RST

shutdown() 설정은 전송되는 TCP 패킷(FIN, RST)에 영향을 줄까?

  • shutdown() 설정은 현재 호스트의 수신 또는 송신 버퍼 사용을 종료하는(즉, 문을 닫아버리는) 함수로, 설정 시 수신 버퍼는 수신을, 송신 버퍼는 송신을 더 이상 하지 않는다.
  • 결론적으로 shutdown() 함수의 설정을 했다고 RST 패킷이 추가로 발생하진 않는다. 대신, FIN이 발생하는 시점이 변경된다.
    • SD_SEND 설정 - 송신 종료
      • closesocket() 호출 시 송신 버퍼에 데이터가 남아있어도 그대로 FIN 패킷 전송(원래였다면 다보내고 FIN을 보냈어야 함)
      • 상대가 보낸 데이터는 수신 버퍼로부터 읽을 수 있음
    • SD_RECV 설정 - 수신 종료
      • 데이터가 수신돼도 받지 않음.
      • 상대가 보낸 데이터는 곧바로 폐기됨.
    • SD_BOTH - 송신 / 수신 종료
      • 송신 버퍼도, 수신 버퍼도 사용 안 함
      • closesocket() 호출 시 곧바로 FIN 전송

결론

  • closesocket() 호출 시 RST가 발생하는 경우는 linger옵션에 의해 hard close 된 경우뿐이다. (shutdown은 관련 없음)
  • 그 외의 경우는 closesocket() 호출 시 전부 FIN 패킷이 전송된다.
  • 프로그램을 강제 종료하면 RST 패킷이 전송된다.

WSAECONNRESET의 발동 조건

  • WSAECONNRESET은 winsock API의 오류 코드(10000~11999)들 중 하나로, 오류 코드는 10054이다.
  • WSARCONNRESET은 상대가 비정상 종료를 실행했을 때 전송되는 RST 패킷에 의한 오류로, 즉, 상대가 비정상 종료를 해야지만 발생하는 오류 코드이다.
  • 일반적으로 오류 코드를 알기 위해선 WSAGetLastError함수를 사용하는데, 이 함수는 발생한 마지막 오류 코드를 반환하므로, WSARCONNRESET이 발생했더라도 이후 다른 오류가 발생했다면 타이밍상 발생했음을 알기 어려운 상황이 있다.

상황에 따른 다른 오류 코드 발생(WSAECONNRESET이 아닌)

WSARecv 호출 시

  • WSARecv와 같이 winsock API에 해당하는 함수를 호출하면, 이에 대응하는 winsock API 오류 코드인 WSAECONNRESET을 WSAGetLastError() 함수로 알 수 있다.
    • 예를 들어, WSARecv() 함수는 넘겨받은 인자에 대해 유효한 경우인지 판단한 다음 recv이벤트를 등록하며, 만약 유효하지 않은 경우 recv 이벤트 등록을 거부하고 곧바로 SOCKET_ERROR를 반환한다.(즉, 유효한 등록인지 판단한다)
      • 따라서 이미 RST 패킷을 받아 리셋된 소켓을 WSARecv에 넣으면 false가 반환되고, WSAGetLastError를 하면 곧바로 WSAECONNRESET을 얻을 수 있다.
[상대 호스트가 강제 종료] -> [RST 패킷 전송] -> [호스트가 RST 패킷 수신] 
-> [해당 소켓 연결 종료 및 reset] -> [해당 소켓에 대해 WSARecv 호출]
-> [WSARecv false 반환] => WSAECONNRESET 오류 코드

GQCS 호출 시

  • GQCS의 경우, CP에 완료 패킷을 넣어주는 역할은 OS 이므로, rst 패킷을 수신했을 때 이를 처리할 때 발생하는 오류는 winsock 레벨의 오류가 아닌 더 낮은 수준의 오류인 시스템 오류가 발생할 수도 있다. 실제로 이미 WSARecv가 등록된 소켓에 대해 RST 패킷이 수신되면, GQCS는 false가 뜨고 WSAGetLastError는 ERROR_NET_NAME_DELETED(64) 오류 코드가 발생한다.
  • ERROR_NET_NAME_DELETED 오류 코드는 시스템 레벨에서 발생하는 오류이며, 파일/핸들 시스템 관점에서 네트워크 경로가 무효화되었음을 의미한다. iocp 관점에선 무효한 핸들에 대한 작업 완료를 처리했을 때 뜬다.

정리 코드

// 시나리오 1: 10054가 발생하는 경우
WSABUF wsaBuf;
DWORD flags = 0;
if (WSARecv(socket, &wsaBuf, 1, NULL, &flags, &overlapped, NULL) == SOCKET_ERROR) {
    if (WSAGetLastError() == WSAECONNRESET) { // 10054
        // 직접적인 Winsock API 호출에서 발생
    }
}

// 시나리오 2: 64가 발생하는 경우
if (!GetQueuedCompletionStatus(hIOCP, &bytes, &key, &pOv, timeout)) {
    if (GetLastError() == ERROR_NETNAME_DELETED) { // 64
        // IOCP에서 이미 무효화된 핸들의 완료 통지 처리 시
    }
}

GQCS 반환값의 의미와 오류가 발생한 시점 판별하기

  • GQCS의 반환값이 true가 되는 데에는, I/O 요청이 "성공적으로 수행되었는지"는 상관없다. 즉, 반환값으로 true가 나와도 그 의미는 해당 이벤트를 처리하는데 시스템적 문제는 없었다일 뿐, 상대 호스트에 문제가 생기지 않았음을 의미하진 않는다. 따라서, true가 반환되어도 연결에 문제가 생긴 경우가 있을 수 있으므로(ex. RST 패킷) 해당 완료 통지를 처리할 때에 아래와 같은 시점에서 전부 체크해줘야 한다.
    • GQCS가 false가 나왔는가?(내부 시스템에서 I/O 작업 처리에 오류가 발생했는가?)
    • 완료 통지의 결과가 비정상인가?(recv 0)
  • 추가적으로 유효한 이벤트 등록 체크를 위해 WSARecv와 같은 winsock 네트워크 함수의 리턴값이 SOCKET_ERROR가 나오는지를 체크해야 한다.

RST 패킷이 나중에 수신되어도 먼저 처리될 수 있다.

  • RST 패킷 같은 경우, 다른 데이터와 달리 순차적으로 처리되지 않고 즉시 처리될 수 있다. 이 경우 연결을 즉시 중단 상태로 변경하고, RST 패킷보다 먼저 수신된 데이터들은 무효화될 수 있다. (이후 대기 중인 I/O 작업들은 오류로 완료된다.)
  • 이는 Windows TCP 스택이 RST를 받으면 연결을 즉시 “aborted” 상태로 전환하기 때문.

상황별 RST 패킷 수신 시 결과

상황 GQCS 반환 에러 코드 비고

RST만 도착 FALSE 10054 or 64 수신할 데이터 없음
데이터 수신 후 RST- 1) RST 나중 처리 TRUE 없음 이후 WSARecv 등록 시 10054 발생
데이터 수신 후 RST- 2) RST 먼저 처리 FALSE 10054 or 64 수신할 데이터 없음
Recv 등록 안됨 + RST 수신 GQCS 호출되지 않음 없음 WSARecv 등록 시 10054 발생