프로토콜 스택에 HTTP Request message 를 넘긴다.
connect() 호출로 인한 3-way-handshake가 완료되면 데이터 송/수신이 시작된다. 송/수신 동작은 다음과 같은 순서로 진행된다.
1. 애플리케이션이 write()를 호출해 송신 데이터를 프로토콜 스택에 건네준다.
2. 프로토콜 스택이 송신 동작을 실행한다.
위 동작에서 주의할 사항들이 몇 가지 존재한다.
첫째는 프로토콜 스택은 받은 데이터의 내용을 알지 못한다는 것이다. write 호출 시 송신 데이터 길이를 지정하지만, 프로토콜 스택 입장에서는 그저 1 바이트 단위로 순서대로 나열된 바이너리 데이터이다.
둘쨰, 프로토콜 스택은 받은 데이터를 내부 송신용 버퍼 메모리에 저장하고, 애플리케이션이 다음 데이터를 건네주기를 기다린다. 프로토콜 스택이 이런 방식을 사용하는 이유는 데이터 송신 시 애플리케이션 마다 건네주는 데이터 길이가 다르기 때문이다. 이때, 프로토콜 스택이 데이터를 곧바로 보낸다면, 작은 단위 데이터를 여러번 보낼 수 있다. 이로 인해 네트워크 이용 효율이 저하될 수 있다. 프로토콜 스택에 저장되는 데이터 양은 OS 종류와 버전에 기인하지만 다음과 같은 요소를 바탕으로 한다.
패킷 하나가 저장할 수 있는 데이터 크기가 판단 요소가 된다. 프로토콜 스택은 MTU(Max Transmission Unit - 한 패킷으로 운반할 수 있는 최대 데이터 길이) 이라는 매개변수를 기반으로 판단한다. MTU 는 IP header, TCP header를 포함하고 있으므로 이 두 헤더의 크기를 제외한 나머지 크기가 하나의 패킷으로 전달할 수 있는 실질 데이터 크기다. 이를 MSS(Maximum Segement Size) 라 한다. 프로토콜 스택이 저장하는 데이터 크기는 이 MSS 를 기반으로 한다.
또 다른 한가지 요소는 타이밍이다. 애플리케이션 송신 속도가 느려지면 MSS 만큼 데이터를 저장하기 위한 시간 만큼 송신 동작이 지연된다. 따라서 프로토콜 내부의 타이머를 통해 일정 시간이 지나면 버퍼에 데이터가 다 모이지 않아도 패킷을 전송한다.
위 두 요소는 서로 상반된 면이 있다. 첫 번쨰 요소를 중시한다면 네트워크 효율이 올라가지만 송신 동작이 지연될 수 있다. 두 번째 요소를 중시하면 네트워크 효율이 떨어진다. 따라서 두 요소 사이에서 절충점을 찾아야 하지만 TCP 프로토콜 사양에는 이에 관한 규정이 없다. 이때문에 OS 종류와 버전에 따라 프로토콜 스택에 저장되는 데이터 양이 달라진다.
애플리케이션측에서도 송신 타이밍을 제어할 수 있다. 데이터 송신을 의뢰할 때 옵션으로 버퍼 이용 없이 송신을 지정하면 프로토콜 스택은 버퍼에 머물지 않고 송신 동작을 실행한다.
데이터가 클 때는 분할하여 보낸다
블로그 글 등 한 개의 패킷에 들어가지 않을 만큼 긴 데이터를 보낸다면, 송신 버퍼에서 저장된 데이터는 MSS의 길이를 초과하므로 다음 데이터를 기다리지 않는다. 따라서 송신 버퍼에 들어있는 데이터를 맨 앞부터 MSS 크기에 맞게 분할하고, 분할한 조각을 한 개씩 패킷에 넣어 송신한다.
ACK 번호를 사용해 패킷이 도착했는지 확인한다.
이제 데이터를 입력한 패킷이 서버로 송신된다. 이 때, TCP 에는 송신한 패킷의 도착 여부 확인과 미도착시 재송신하는 기능이 있으므로 패킷 송신 후 확인 동작을 실행한다.
확인 동작의 개략적인 과정은 다음과 같다.
1. TCP 담당 부분은 데이터를 조각으로 분할 시 해당 조각이 통신 개시부터 몇번째 바이트에 해당하는지 TCP 헤더의 시퀀스 번호에 기록한다.
2. 송신하는 데이터 크기는 패킷 전체 길이에서 헤더 길이를 뺴는 방식으로 계산한다.
위 과정을 통해 송신한 데이터가 몇 번째 바이트부터 시작하는지, 몇 바이트 분의 것인지를 알 수 있다.
위 과정을 통해 수신측은 수신 완료한 데이터와 시퀀스 번호를 비교해 누락된 패킷이 존재하는지를 판단할 수 있다. 예컨대, 1,460번째 바이트까지 수신완료한 상태에서 다음 패킷의 시퀀스 번호가 1,461이라면 누락이 없다고 판단할 수 있다. 이제 몇번째 바이트까지 수신 완료했는지르 계산해 이값을 ACK 번호에 기록해 송신측에 알린다. 이 작업을 수신 응답 확인 이라 한다.
실제 상황에서 시퀀스 번호는 난수를 바탕으로 산출한 초기값으로 시작한다. 1부터 시작한다는 것이 명확하면 이로 인한 악의적 공격을 할 수 있기 때문이다. 따라서 초기값을 송수신 전에 알린다. 이 초기값을 알리는 부분이 컨트롤 비트 SYN을 1로해서 서버에 보내는 동작이다. 이 때, 시퀀스 번호에도 값을 설정하는대 이 값이 초기값을 의미한다. 실제로 와이어샤크를 통해 접속 동작 부분을 보면 다음과 같은 패킷이 전송된 것을 볼 수 있다.
그 후 3-way-handshake가 끝난 직후 실행되는 client-hello의 패킷의 시퀀스 번호를 보면 위 시퀀스 번호보다 1 증가된 값을 시퀀스 번호로 사용하는 것을 알 수 있다.
지금까지 서술한 방법은 오직 데이터가 단방향으로 흐른다는 것은 전재로 하고 있다. 하지만, 실제 TCP의 데이터 송/수신 동작은 양방향이다. 양방향으로 흐르는 데이터를 제어하기 위해선 위 과정을 반전시킨 동작을 추가하면 된다.
이제 위에서 서술한 개념들을 바탕으로 실제 동작 과정을 살펴보자.
1. 시퀀스 번호 초기값:
클라이언트에서 서버로 보내는 데이터에 대한 시퀀시 번호 초기값을 서버에 통지한다. 이 값은 클라이언트에서 산출한다.
2. ACK 번호 + 시퀀스 번호 초기값:
서버가 받은 시퀀스값으로 부터 ACK값을 산출하고 서버에서 클라이언트로 보내는 데이터에 대한 시퀀스 번호 초기값과 같이 보낸다. ACK 번호는 최초 초기값 통지가 유실되지 않았다는 것을 증명한다.
3. ACK 번호:
클라이언트가 받은 시퀀스 번호로부터 ACK 번호를 산출해 서버에 반송한다. 이제 시퀀스 번호와 ACK 번호가 준비되었다.
4: 시퀀스 번호 + 데이터, 5: ACK 번호:
웹은 최초에 클라이언트에서 서버로 메시지를 보내고, 이 때 데이터와 시퀀스 번호를 보낸다. 서버는 데이터 수신을 알리기 위해 ACK 번호를 반송한다.
6: 시퀀스 번호 + 데이터, 7: ACK 번호
서버에서 데이터를 보내는 경우는 그 반대가 된다.
TCP는 위와 같이 ACK 번호를 기반으로 상대가 데이터를 받았는지를 확인한다. 확인하는 과정에서 송신한 패킷을 송신용 버퍼 메모리에 보관한다. 만약 송신한 데이터에 대응하는 ACK 번호가 돌아오지 않으면 패킷을 재전송한다.
이러한 구조로 인해 네트워크의 다른 부분에서는 오류 회복 조치(패킷 재전송)를 할 필요가 없다. 따라서 LAN 어댑터, 버퍼, 라우터, 애플리케이션 까지 모든 곳은 오류를 검출하면 패킷을 버리기만 한다. 또 한, 애플리케이션의 송신 동작은 그저 송신만 한다. 만약 케이블 분리 등의 이유로 TCP 가 아무리 다시 보내도 데이터가 도착하지 않는다면 TCP 는 데이터 송신 동작을 강제 종료하고 애플리케이션에 오류를 통지한다.
패킷 평균 왕복 시간으로 ACK 번호의 대기 시간을 조정한다.
실제 오류 검출과 회복의 원리는 복잡하기 떄문에 요점이 되는 부분만 살펴보면 다음과 같다. ACK 번호가 돌아오는 것을 기다리는 시간을 타임아웃 값 이라 한다.
ACK 번호 반송 지연의 주된 원인은 네트워크 혼잡에 있다. 이때, ACK 번호가 돌아오기 전에 다시 전송이 발생하면 네트워크 혼잡을 더욱 증가시키므로 타임아웃 값을 적절히 길게 설정해야 한다. 하지만 너무 긴 대기 시간은 패킷을 다시 보내는 동작을 지연시켜 속도 저하의 원인이 된다.
타임아웃 값을 적절히 세팅하는 것은 상당히 까다로운 일이다. 서버의 물리적 거리에 따라 ACK 번호가 반환되는 시간 차이가 크고, 정체시 지연도 고려해야 하기 때문이다. 따라서 TCP는 대기 시간을 동적으로 변경하는 방법을 사용한다. ACK 번호가 돌아오는 시간을 기준으로 타임아웃 값을 판단한다. 데이터 송신 부터 ACK 번호가 돌아오는 대까지 시간을 계측한다. 그리고 ACK 번호가 돌아오는 시간이 지연 여부에 따라 이에 대응대 타임아웃 값을 연장하거나 단축한다.
윈도우 제어 방식으로 효율적으로 ACK 번호를 관리한다.
한 개의 패킷을 보내고 ACK 번호를 기다리는 방법을 핑퐁방식이라 한다. 이 방식은 ACK 번호가 반환될 때 까지 아무것도 하지 않으므로 시간이 낭비된다.
이를 해결하기 위해 하나의 패킷을 보내고, ACK 번호를 기다리지 않고, 연속해서 복수의 패킷을 보내는 윈도우 제어 방식을 사용한다. 하지만 이 방식은 ACK 번호 반환을 기다리지 않기 때문에, 수신측의 능력을 초과해 패킷을 보내는 사태가 발생할 수 있다.
조금 더 구체적으로 살펴보자. 수신측은 ACK 번호를 계산하거나 조각을 연결해 원래 데이터를 복원한 후 애플리케이션에 건네줘야 한다. 따라서 수신한 패킷을 우선 버퍼 메모리에 보관한다. 그래야 처리가 끝나지 않은 상태에서 패킷이 도착하는 것을 막을 수 있다. 만약 데이터를 복원해 애플리케이션에 거네주는 속도보다 데이터가 도착하는 속도가 빠르다면, 버퍼가 넘치고, 넘친 데이터는 사라진다. 이는 패킷이 도착했음에도 오류가 발생한 것과 같다. 따라서 수신측이 우선 송신측에 수신 가능한 데이터 양을 통지하고, 수신측은 이 값을 기준을 적절히 송신 동작을 실행한다. 이 방식이 윈도우 제어 방식이다.
위 과정에서 수신 버퍼에 빈 부분이 생겼을 때 그 만큼 수신할 수 있는 데이터 양을 늘린다. 늘리는 양은 TCP 헤더의 윈도우 필드에 기록해 송신측에 알린다.
위 그림은 수신 버퍼가 다 차면 비워지는 것으로 묘사했지만, 실제로는 수신측이 패킷을 수신하면 즉시 수신 처리를 할 수 있어 버퍼는 곧 비워진다. 또 한, 위 그림처럼 데이터를 일방적으로 송신하는 것이 아닌, 시퀀스 번호와 ACK 번호 같은 데이터를 수신측이 송신측으로 주기도 하는 등 양방향 대화가 된다.
ACK 번호와 윈도우를 합승한다.
송/수신 동작의 효율을 높이기 위해 ACK번호와 윈도우를 통지하는 타이밍을 고려해야 한다. 우선 각 통지가 언제 발행하는지 생각해보자. 윈도우 통지의 타이밍은 수신측에서 애플리케이션에 데이터를 건네주고 수신 버퍼의 빈 영역이 늘어났을 때 이다. ACK 번호 통지는 수신측에서 데이터를 받았을 때, 정상 수신을 확인할 수 있는 경우다.
위 두가지 케이스를 종합하면 송신측에서 보낸 데이터가 정상 도착해서 ACK 번호를 송신측에 통지한다. 그 후, 데이터를 애플리케이션에 건네주었을 때 윈도우 통지를 한다. 이런 방식은 수신측에서 송신측으로 보내는 패킷이 많기때문에 비효율적이다.
위 문제를 해결하기 위해 수신측은 ACK 번호와 윈도우 번호를 통지할 때 잠시 기다린다. 기다리는 사이 다음 통지 동작이 발생하면 그에 대한 값을 묶어서 하나의 패킷으로 통지한다. 기다린 직후 ACK 번호와 윈도우 번호를 모두 통지해야 한다면, 둘을 하나틔 패킷으로 통지한다. 기다리는 동안 여러번의 패킷 수신이 있었다면, 마지막으로 받은 패킷에 대한 ACK 번호만 통지한다. ACK 번호는 데이터를 어디까지 받았는지를 송신측에 알리는 역할만 하기 때문이다. 애플리케이션에 데이터를 건네주는 동작이 연속해 발생한 경우에도 최후의 윈도우 번호만 통지한다.
HTTP 응답 메시지를 수신한다.
브라우저는 리퀘스트 메시지를 송신하라는 의뢰를 하고, 이에 대한 응답 메시지를 받기 위해 read 프로그램을 호출한다. 그러면 read를 경유해 프로토콜 스택에 제어가 넘어가고, 프로토콜 스택이 움직인다. 데이터를 수신할 때도 수신 버퍼를 사용한다. 따라서 동작 과정은 다음과 같다.
프로토콜 스택은 수신 버퍼에 수신 데이터를 추출해 애플리케이션에 건네준다. 이때 리퀘스트 메시지의 송신 완료 후 응답이 돌아오지 않았다면, 수신 버퍼에 데이터가 들어가지 않는다. 따라서 수신 버퍼에 수신 데이터를 추출해 애플리케이션에 건네는 작업이 보류된다. 서버에서 응답 메시지의 패킷이 도착하면 이를 수신해 애플리케이션에 건네주는 작업을 재개한다.
출처 - 성공과 실패를 결정하는 1%의 네트워크 원리