Earn this, Earn it.

[네트워크] Socket 라이브러리 관점에서 TCP/IP 데이터 송수신 본문

[개발 공부]

[네트워크] Socket 라이브러리 관점에서 TCP/IP 데이터 송수신

Narastro 2022. 2. 26. 21:55

이 글은 네트워크 스터디에서 제가 정리한 내용을 그대로 옮긴 글입니다.

https://github.com/seiyon-algorithm/Study-Network

 

GitHub - seiyon-algorithm/Study-Network: '성공과 실패를 결정하는 1%의 네트워크 원리' 책 스터디📚

'성공과 실패를 결정하는 1%의 네트워크 원리' 책 스터디📚. Contribute to seiyon-algorithm/Study-Network development by creating an account on GitHub.

github.com

 

'성공과 실패를 결정하는 1%의 네트워크 원리' 2장 내용을 읽고 정리하였습니다.

 

Chapter 2. TCP/IP의 데이터를 전기 신호로 만들어 보낸다

개요

오늘 알아볼 내용은 TCP/IP 계층에 대해 다루며 전체적인 히스토리는 아래와 같습니다.

 

1. 소켓을 작성

2. 서버에 접속

3. 데이터를 송수신

4. 소켓 말소

5. IP와 이더넷 패킷

6. UDP 프로토콜

 

 

 Story 1. 소켓을 작성한다. 

(1) 프로토콜 스택의 내부 구성

- 패킷? 데이터를 작게 나눈 덩어리를 의미
- ARP? ‘아프' 수신처 라우터의 MAC 주소를 조사하는 프로토콜
- MAC주소? LAN방식의 기기가 사용하는 같은 형식의 물리적 주소
 

(2) 소켓의 실체는 통신 제어용 제어 정보

 
통신 상대의 IP 주소, 포트 번호 등 여러 가지 제어 정보를 기록한 메모리 영역이 소켓의 실체 
프로토콜 스택은 소켓에 기록된 제어 정보를 참조하면서 움직입니다.

 

윈도우에서 netstat을 쳤을 때

위에서 보는 한 행이 하나의 소켓에 해당합니다.

즉, 소켓을 만든다는 동작은 여기에 새로 한 행의 제어 정보를 추가하고 통신을 준비하는 작업입니다.

여기서 0.0.0.0인 것은 아직 통신이 시작되지 않았기 때문에 IP주소가 정해져 있지 않은 것입니다.

 

(3) Socket을 호출했을 때의 동작

 

<여기서 잠깐!>

'Socket'은 Socket 라이브러리를 가리키고,

'socket'은 그 라이브러리 안에 있는 socket이라는 프로그램을 가리키며,

'소켓'이라고 하면 파이프의 양끝에 있는 출입구를 가리킵니다.

 

브라우저가 socket이나 connect라는 Socket 라이브러리의 프로그램 부품을 호출했을 때 프로토콜 스택의 내부가 어떻게 움직이는지 살펴보겠습니다.

 

처음은 소켓을 만드는 단계입니다.

애플리케이션이 socket을 호출하면 프로토콜 스택은 의뢰에 따라 한 개의 소켓을 만듭니다.

그리고 소켓을 만들 때 한 개의 메모리 영역을 확보하고 초기 상태라는 것을 이 영역에 기록합니다.

소켓이 만들어지면 소켓을 나타내는 디스크립터를 애플리케이션에 알려줍니다. (디스크립터란 다수의 소켓 중 어느 것을 가리키는지 나타내는 번호표와 같은 정보입니다.)

디스크립터를 받은 애플리케이션은 프로토콜 스택에 송수신을 의뢰할 때 디스크립터를 통지합니다.

프로토콜 스택은 어느 소켓인지만 알면 필요한 정보를 모두 알 수 있기 때문에 일일이 통신 상대의 정보를 통지받을 필요가 없어집니다.

 

 

 Story 2. 서버에 접속한다. 

(1) 접속의 의미

 

이더넷이나 통신 회선 등의 케이블은 항상 접속된 상태에 있으므로 언제나 신호를 보낼 수 있습니다.

그러나 소켓을 만든 직후는 아직 아무 것도 기록되어 있지 않으므로 통신 상대가 누구인지 모릅니다.

이 상태에서는 송신 의뢰가 와도 데이터를 어디로 보내야 하는지 모릅니다. 브라우저는 URL을 바탕으로 서버의 IP 주소를 조사하고, 잘 알려진 포트 번호인 80번을 사용해야 한다는 것을 압니다.(HTTP의 경우) .

따라서 첫 번째로 프로토콜 스택에 이와 같은 정보를 알리는 동작이 필요합니다.

 

서버도 마찬가지입니다. 서버측은 심지어 상대를 알 수 없으므로 클라이언트에서 통신하려는 의사와 제어 정보(대표적으로 IP 주소와 포트 번호)를 서버측에 전달할 필요가 있습니다. 이러한 의사를 전달 받으면 서버측도 클라이언트의 정보를 가질 수 있습니다. 이러한 의사 전달 또한 접속의 동작의 역할 중 하나입니다.

 

또한 데이터 송수신 동작을 실행할 때는 송수신 데이터를 일시적으로 저장하는 버퍼 메모리가 필요합니다. 이 버퍼 메모리 확보도 접속 동작을 할 때 실행됩니다.

 

(2) 맨 앞부분에 제어 정보를 기록한 헤더를 배치한다

 

앞에서 나온 제어 정보에는 크게 두 가지가 있습니다.

하나는 클라이언트와 서버가 서로 연락을 절충하기 위해 주고받는 제어 정보입니다. 이러한 정보는 클라이언트와 서버 간에 주고받는 패킷 맨 앞부분 헤더에 기록됩니다. 접속 동작 단계에서는 아직 데이터 송수신이 이루어지지 않으므로 데이터를 제외한 제어 정보만으로 패킷이 이루어집니다. 이러한 헤더는 IP, 이더넷, TCP 앞부분에 배치되므로 각각을 잘 알 수 있도록 써야합니다.

즉 요약하자면 헤더의 형태로 이러한 제어 정보를 주고 받는 것입니다.

 

또한 제어 정보는 소켓에 기록하여 프로토콜 스택의 동작을 제어하기 위한 정보가 더 있습니다. 여기에는 애플리케이션에서 통지된 정보, 통신 상대로부터 받은 정보 등이 수시로 기록되며 이는 프로토콜 스택의 프로그램과 일체화 되어 있으며 프로토콜 스택이 어떤 정보를 필요로 하는지는 프로토콜 스택을 만드는 사람에 따라 달라집니다.

 

소켓에 기록한 제어 정보는 상대측에서 볼 수 없습니다. 윈도우와 리눅스라는 OS는 프로토콜 스택을 만드는 방법이 다르므로 필요한 제어 정보도 다릅니다. 하지만 양자가 문제 없이 통신할 수 있는 것은 어떠한 프로토콜(규칙)에 따르고 있기 때문입니다. 

 

요약하자면 통신 동작에 이용하는 제어 정보는
1) 헤더에 기입되는 정보,
2) 소켓(프로토콜 스택의 메모리 영역)에 기록되는 정보로 두 종류입니다.

 

(3) 접속 동작의 실제

 

이미지 출처:&nbsp;http://www.ktword.co.kr/test/view/view.php?m_temp1=1889

 

자 이제 접속 동작을 따라가 봅시다.

 

1) connect(<디스크립터>, <서버측의 IP주소와 포트 번호>, ... )

 

서버측 IP주소와 포트 번호를 쓰면 프로토콜 스택의 TCP 담당 부분에 명령이 전달됩니다.

포트 번호를 통해 클라이언트와 서버측의 소켓을 지정하고 접속해야 되는 소켓이 어느 것인지 확실히 하여 `컨트롤 비트`인 SYN 비트를 1로 만듭니다. 이외의 사항에 대해서는 나중에 다루도록 하겠습니다.

즉, TCP 헤더를 만들어 송신처와 수신처의 포트 번호로 접속하는 소켓을 지정하는 것입니다.

 

2) 서버측 TCP 응답 과정

 

이렇게 클라이언트측 TCP 패킷이 생성되면 IP 담당 부분에 전달되어 송신됩니다.

서버측 IP 담당 부분이 이 패킷을 받아 TCP 담당 부분에 넘겨주고 TCP 헤더를 조사하여 기록되어 있는 수신처 포트 번호에 해당하는 소켓을 찾아 필요한 정보를 기록하고 접속 동작을 '진행중'으로 변경하게 됩니다. 이 과정이 끝나면 서버의 TCP 담당 부분은 응답을 돌려보냅니다.

여기서 서버측도 클라이언트와 마찬가지로 SYN비트를 1로 만들고 응답을 나타내는 컨트롤 비트인 ACK도 1로 설정하여 IP담당 부분에 건네주어 클라이언트에 반송합니다.

 

3) 클라이언트의 접속 동작의 대화 끝

 

위의 패킷이 클라이언트에 돌아오면 IP 담당 부분을 경유하여 TCP 담당 부분에 절달되고 TCP 헤더를 조사하여 서버측의 접속 동작이 성공했는지 확인합니다. SYN이 1이면 성공이므로 소켓에 서버의 IP주소나 포트 번호 등과 함께 소켓에 접속 완료를 나타내는 제어 정보를 기록합니다. 그리고 ACK 컨트롤 비트를 1로 만들어 패킷을 반송하는데, 이것은 서버에도 패킷이 도착한 것을 알리기 위함입니다.

 

이로써 소켓은 데이터를 송수신할 수 있는 상태가 되며 실제로 무언가 연결되어 있는 것은 아니지만 이 파이프와 같은 것을 커넥션(세션이라고도 합니다)이라고 하며 이 커넥션은 데이터 송수신 동작이 close될 때까지 계속 존재합니다. 

이렇게 하여 커넥션이 이루어지면 프로토콜 스택의 접속 동작이 끝나므로 connect의 실행이 끝나면서 애플리케이션을 제어할 수 있게 됩니다.

 

 

 Story 3. 데이터를 송수신한다 

(1) 프로토콜 스택에 HTTP 리퀘스트 메세지를 넘긴다

 

connect에서 애플리케이션에 제어가 되돌아오면 데이터 송수신 동작에 들어갑니다.

이 동작은 애플리케이션이 write를 호출하여 송신 데이터를 프로토콜 스택에 건네주고 프로토콜 스택이 송신 동작을 실행합니다.

 

여기서 몇 가지 중요한 점이 있는데, 프로토콜 스택은 받은 데이터를 곧바로 송신하는 것이 아니라 일단 자체의 내부 송신용 버퍼 메모리에 저장하고 애플리케이션이 다음 데이터를 건네주기를 기다립니다.

이는 애플리케이션이 건네주는 데이터 길이는 그때그때 다르며 프로토콜 스택에서 제어할 수 있는 것이 아니기 때문입니다.

 

이미지 출처:&nbsp;https://www.cisco.com/c/ko_kr/support/docs/ip/transmission-control-protocol-tcp/200932-Ethernet-MTU-and-TCP-MSS-Adjustment-Conc.html

프로토콜 스택은 MTU라는 매개변수(한 패킷으로 운반할 수 있는 디지털 데이터의 최대 길이)와 MSS라는 매개변수(헤더를 제외한 하나의 패킷으로 운반할 수 있는 데이터의 최대 길이)를 바탕으로 이를 판단합니다.

또한 타이밍을 기준으로도 판단하는데, 프로토콜 내부의 타이머는 일정 시간 이상 경과하면 MSS에 가깝게 데이터가 모이지 않더라도 패킷을 송신합니다.

 

위의 두 가지 요소는 상반된 면이 있습니다. 이 둘을 절충하면서 송신 동작을 실행해야 하는데, 이는 전적으로 프로토콜 스택을 만드는 개발자에게 맡겨져 있습니다. 따라서 OS나 버전에 따라 달라집니다.

 

이와 같이 프로토콜 스택에만 이를 맡긴다면 좋지 않으므로 애플리케이션측에서 송신 타이밍을 제어하는 여지도 남겨두었습니다.

즉, 데이터 송신을 의뢰할 때 옵션을 지정할 수 있으며 옵션을 통해 버퍼에 머물지 않고 바로 송신하도록 지정할 수도 있습니다.

 

(2) 데이터가 클 때는 분할하여 보낸다

 

긴 데이터를 보낼 경우 한 개의 패킷에 들어가지 않을 수도 있습니다.

프로토콜 스택은 송신 버퍼에 들어있는 데이터를 차례대로 MSS 크기에 맞게 분할하고 하나씩 패킷에 넣어 송신합니다.

데이터 조각의 모습을 가늠하여 데이터 조각을 송신하면 맨 앞부분에 TCP 헤더를 부가하게 되고 이를 IP 담당 부분에 건네주어 송신 동작을 실행합니다.

 

(3) ACK 번호를 사용하여 패킷이 도착했는지 확인한다.

 

여러 패킷의 수신 확인 방법에 대해 알아보겠습니다.

TCP 담당 부분은 데이터를 분할할 때 조각이 몇 번째 바이트에 해당하는지를 세어둡니다. 그리고 이를 TCP 헤더의 시퀀스 번호라는 항목에 기록합니다. 수신측은 패킷 전체 길이에서 헤더 길이를 뺀 데이터 크기를 계산하여 크기를 산출하고 송신 데이터가 몇 번째 바이트부터 시작되는 것인지를 알 수 있게 됩니다.

 

이렇게 되면 수신측에서는 패킷이 누락되었는지 확인할 수 있습니다. 수신측은 데이터를 몇 번째 바이트까지 수신한 것인지를 TCP 헤더의 ACK 번호에 기록하여 송신측에 알려줍니다. 이를 수신 확인 응답이라고 부릅니다. 만약 송신한 데이터에 대응하는 ACK 번호가 돌아오지 않으면 패킷을 다시 보내게 됩니다.

 

실제로는 시퀀스 번호가 1부터 시작하지 않고 난수를 바탕으로 산출한 초기값으로 시작합니다. 항상 1부터 시작한다면 거기에 악의적인 공격을 가할 우려가 있기 때문입니다. 다만, 난수로 초기값을 설정하면 몇 번이 초기값인지 알 수 없으므로 데이터 송수신 전에 초기값을 상대에게 알리게 되어 있는데, 앞서 SYN에 1을 설정할 때 시퀀스 번호를 보내는 것입니다.

 

반대의 경우(서버가 클라이언트에 데이터를 보내는 경우)에도 마찬가지입니다.

 

이처럼 TCP에 맡겨두면 오류가 발생해도 데이터가 문제 없이 상대에게 도착하므로 애플리케이션의 송신 동작은 송신한 채로 끝납니다. 즉 LAN 어댑터, 버퍼, 라우터 모두 회복 조치를 따로 취하지 않습니다. 단 도중에 케이블이 분리되거나 서버가 다운되는 등의 이유로 TCP가 아무리 다시 보내도 데이터가 도착하지 않는 경우에는 몇 번 다시 보내보고 데이터 송신 동작을 강제 종료하고 애플리케이션에 오류를 통지합니다.

 

(4) 패킷 평균 왕복 시간으로 ACK 번호의 대기 시간을 조정한다.

 

실제 오류 검출과 회복의 원리는 꽤 복잡하므로 요점만 정리하겠습니다.

우선, ACK 번호가 돌아오는 것을 기다리는 시간타임아웃 값이라고 합니다.

ACK 번호의 반송이 지연되도록 하는 사태는 혼잡이 원인인 경우가 많으므로 거기에 헛된 패킷을 보내면 혼잡을 악화시킬 우려가 있습니다. 그렇다고 대기 시간이 길게 되면 패킷을 다시 보내는 동작이 지연되어 속도 저하의 원인이 됩니다.

따라서 길지도 짧지도 않은 값을 설정해야 하는데, 이를 일정한 값으로 설정하면 적절하지 않은 방법이 될 수 있습니다.

따라서 ACK 번호가 돌아오는 시간을 기준으로 동적으로 변경하도록 하여 ACK 번호가 돌아오는 시간이 지연되면 이것에 대응하여 대기 시간도 늘리는 방식을 사용합니다.

 

(5) 윈도우 제어 방식으로 효율적으로 ACK 번호를 관리한다.

 

한 개의 패킷을 보내고 ACK 번호를 기다리는 방법은 단순하지만 시간 낭비일 수 있습니다. 이러한 낭비를 줄이기 위해 TCP는 윈도우 제어라는 방식에 따라 연속해서 복수의 패킷을 보내는 방식입니다.

이렇게 하면 ACK 번호를 기다리는 시간 낭비를 줄일 수 있으나 주의할 점이 있습니다.

수신측의 능력을 초과하여 패킷을 보내느 사태가 일어날 수도 있다는 것입니다.

수신측은 패킷을 버퍼 메모리에 일시 보과하였다가 연결하여 원래 데이터를 복원한 후 애플리케이션에 건네줍니다.

이러한 수신 버퍼에 데이터가 넘쳐버리게 되는 것을 말합니다.

이를 방지하기 위해서는 수신측에서 송신측에 수신 가능한 데이터 양을 통지하고 이 양을 초과하지 않도록 송신 동작을 실행해야됩니다. 이것이 바로 윈도우 제어 방식의 개념입니다.

 

이미지 출처:&nbsp;http://www.ktword.co.kr/test/view/view.php?m_temp1=1889

이는 TCP 헤더의 윈도우 필드에서 조절하며,

수신측의 능력이 높아서 패킷이 도착하는 속도보다 빨리 수신 처리를 할 수 있으면 버퍼는 곧 비워지므로 이것을 윈도우 필드를 통해 통지합니다.

또한 수신 가능한 데이터 양의 최대값은 TCP 헤더의 윈도우 사이즈라는 값으로 알리게 됩니다.

 

(6) ACK 번호와 윈도우를 합승한다.

 

송수신 동자그이 효율성을 높이기 위해 ACK 번호와 윈도우를 통지하는 타이밍을 고려해야 합니다.

이 둘을 별개로 보고 별도의 패킷으로 보내게 된다면 어떻게 될까요?

송신측에서 보낸 데이터가 수신측에 도착하여 ACK 번호를 통지하고 데이터를 애플리케이션에 건네주었을 때 윈도우를 송신측에 통지하는 상태가 될 수 있습니다. 이렇게 해서는 수신측에서 보내는 패킷이 많아져 효율성이 저하됩니다.

 

수신측은 ACK번호나 윈도우를 통지할 때 소켓을 바로 보내지 않고 잠시 기다리는데 다음 통지 동작이 일어나면 양쪽을 상승시켜서 한 개의 패킷으로 묶어서 보냅니다.

예를 들어 ACK번호의 송신을 대조할 때 윈도우 통지가 일어나면 ACK 번호와 윈도우를 한 개의 패킷에 합승시켜 통지하여 패킷의 수를 줄일 수 있습니다.

 

ACK번호나 윈도우 모두 연속해서 일어난 경우에 최후의 것만 통지합니다.

 

(7) HTTP 응답 메세지를 수신한다

 

위의 동작으로 프로토콜 스택이 HTTP 리퀘스트 메세지를 보내는 일련의 동작이 끝났습니다.

이후 브라우저는 리퀘스트 메세지를 송신해 달라고 의뢰하고 이것이 끝나면 응답 메세지를 받기 위해 read 프로그램을 호출합니다.

그러면 read를 경유하여 프로토콜 스택으로 제어가 넘어가고 수신 버퍼를 사용하여 수신 데이터를 추출하여 애플리케이션에 건네주게 됩니다.

프로토콜 스택은 응답 메세지가 돌아올 때까지 수신 버퍼에서 데이터를 추출하여 애플리케이션에 건네주는 작업을 잠시 보류합니다.

이 때 프로토콜 스택이 쉬는 것이 아니라 다수의 애플리케이션에서 의뢰받은 작업을 실행합니다.

 

 

 

 Story 4. 서버에서 연결을 끊어 소켓을 말소한다 

(1) 데이터 보내기를 완료했을 때 연결을 끊는다

 

웹에서 서버가 클라이언트의 리퀘스트에 대한 응답 메세지를 반송 완료하면 데이터 보내기가 완료되고 이 경우 서버측은 연결 끊기 단계에 들어갑니다.

이 부분은 애플리케이션에 따라 다르므로 프로토콜 스택은 어느 쪽에서 먼저 연결 끊기 단계에 들어가도 좋게 만들어져 있습니다.

 

만약 서버측이 연결 끊기 단계에 들어가는 것으로 간주한다면

Socket라이브러리의 close를 호출하고 프로토콜 스택은 TCP 헤더를 만들어 연결 끊기 정보를 설정합니다.

구체적으로는 컨트롤 비트의 FIN 비트를 1로 설정하고 IP 담당 부분에 의뢰하여 송신해달라고 합니다.

클라이언트는 이를 받아 ACK번호를 반송하고 애플리케이션은 read를 호출하여 데이터를 가지러 옵니다.

서버에서 보낸 데이터를 전부 수신 완료했다는 사실을 애플리케이션에게 알리고 완료되면 클라이언트도 종료합니다.

 

그러면 클라이언트의 애플리케이션도 close를 호출하고 FIN 비트에 1을 설정한 TCP 헤더를 만들어 IP부분에 의뢰 후 송신합니다.

그 후 서버에서 ACK 번호가 돌아오면 서버와의 대화가 끝납니다.

 

 

(2) 소켓을 말소한다.

 

위의 과정이 끝난다고 하여도 바로 소켓을 말소하지 않고 잠시 기다린 후 소켓을 말소합니다.

오동작을 막기 위해 잠시 기다리는 것입니다.

 

이유는 다양하지만 한 가지 예시를 보면

클라이언트가 FIN을 송신하고 서버가 ACK송신, 그리고 서버가 FIN을 송신하고 클라이언트가 소켓을 말소한다면 거기에 기록되어 있던 제어 정보가 없어지므로 소켓에 할당되어 있던 포트 번호도 몇 번인지 알 수 없게 됩니다.

이 시점에서 다른 애플리케이션이 소켓을 작성하면 같은 포트 번호가 할당될 수 있습니다.

이 상태에서 서버가 다시 보낸 FIN이 도착하면 어떻게 될까요?

그래서 소켓을 즉시 말소하지 않고 기다리는 것입니다.

 

이때 기다리는 시간은 패킷을 다시 보내는 동작과 관계가 있습니다.

아까 패킷이 없어졌을 때 몇 번 다시 보내보고 회복 전망이 없으면 다시 보내는 동작을 멈춘다고 하였죠?

명확한 규정은 없지만 일반적으로 몇 분 정도 기다리고 나서 소켓을 말소합니다.

 

 

자... 여기까지 2장의 스토리 4까지의 내용을 정리해봤는데요

기존의 알고 있던 정보를 프로토콜 스택과 Socket라이브러리 관점에서 다시 생각해볼 수 있어서 새롭게 느껴졌던 것 같습니다.

 

이후 이더넷 패킷 송수신과 TCP, UDP에 대한 내용은 다음 시간에 다시 정리해보도록 하겠습니다.

벌써 토요일도 지나가고 있네요. 모두 즐거운 주말 보내시길 바랍니다😄