호춘쿠키 2023. 4. 25. 21:08

3way Handshake

통신을 하려면 상대와 제어 정보를 교환하여 각자의 소켓에 정보를 기록해야 한다.

TCP/IP 3way Handshake (출처: wiki)

 

TCP/IP 패킷 구조

그림1. TCP/IP 패킷 구조 (출처: www.oreilly.com)

 

TCP 커넥션을 식별하는 값

<발신지 IP 주소, 발신지 포트, 수신지 IP 주소, 수신지 포트>

이 네 가지 값으로 유일한 커넥션을 생성한다. 서로 다른 두 개의 TCP 커넥션은 네가지 주소 구성요소의 값이 모두 같을 수 없다.

 

TCP 성능 관련 중요 요소

  • TCP 핸드쉐이크
  • 확인응답 지연 알고리즘
  • TCP 느린 시작(slow-start)
  • 네이글 알고리즘
  • TIME_WAIT 지연과 포트 고갈

TCP 핸드쉐이크
크기가 작은 HTTP 트랜잭션은 SYN, SYN+ACK, ACK 로 구성되는 핸드쉐이크 과정이 전체 통신 시간의 50% 이상을 차지하기도 한다.

 

확인응답 지연 알고리즘
각 TCP 세그먼트는 순번을 가진다. 각 세그먼트의 수신자는 세그먼트를 온전히 받으면 확인응답 패킷을 송신자에게 반환한다. 만약 송신자가 특정 시간 안에 확인응답 메시지를 받지 못하면 패킷이 파기되었거나 오류가 있는 것으로 판단하고 데이터를 다시 전송한다.
확인응답은 크기가 작기 때문에, TCP는 같은 방향으로 송출하는 데이터 패킷에 확인응답을 '편승(piggyback)'시킨다.
확인응답이 같은 방향으로 가는 데이터 패킷에 편승되는 경우를 늘리기 위해서, 많은 TCP 스택은 '확인응답 지연' 알고리즘을 구현한다.
확인응답 지연은 송출할 확인응답을 특정 시간 동안(보통 0.1~0.2초) 버퍼에 저장해 두고, 확인응답을 편승시키기 위한 송출 데이터 패킷을 찾는다. 만약 일정 시간 안에 송출 데이터 패킷을 찾지 못하면 확인응답은 별도 패킷을 만들어 전송된다. 막상 편승할 패킷을 찾으려고 하면 해당 방향으로 송출될 패킷이 많지 않기 때문에, 확인응답 지연 알고리즘으로 인한 지연이 자주 발생한다. 운영체제에 따라 확인응답 지연 기능을 비활성화할 수 있다.

 

TCP 느린 시작 (slow-start)
TCP의 데이터 전송 속도는 TCP 커넥션이 만들어진 지 얼마나 지났는지에 따라 달라질 수 있다. TCP 커넥션이 처음 만들어졌을 때는 최대 속도를 제한하고 데이터가 성공적으로 전송됨에 따라서 속도 제한을 높여나간다. 이는 인터넷의 급작스러운 부하와 혼잡을 방지하는데 쓰인다. HTTP 트랜잭션에서 전송할 데이터의 양이 많으면 모든 패킷을 한 번에 보낼 수 없다. 그 대신 한 개의 패킷만 전송하고 확인응답을 기다려야 한다. 확인응답을 받으면 2개의 패킷을 보낼 수 있으며, 그 패킷 각각 의 확인응답을 받으면 총 4개의 패킷을 보낼 수 있게 된다.
이 혼잡제어 기능 때문에 새로운 커넥션은 이미 어느 정도 데이터를 주고 받은 '튜닝'된 커넥션보다 느리다. '튜닝'된 커넥션은 더 빠르기 때문에, HTTP에는 이미 존재하는 커넥션을 재사용하는 기능이 있다.

 

네이글 알고리즘
네이글 알고리즘은 네트워크 효율을 위해서, 패킷을 전송하기 전에 많은 양의 TCP 데이터를 한 개의 덩어리로 합치는 방법을 말한다.
전송되고 나서 확인응답을 기다리던 패킷이 확인응답을 받았거나 전송하기 충분할 만큼의 패킷이 쌓였을 때 버퍼에 저장되어 있던 데이터가 전송된다. 네이글 알고리즘은 HTTP 성능에 저하를 발생시킨다. 첫 번째로, 크기가 작은 HTTP 메시지는 패킷을 채우지 못하기 때문에, 앞으로 생길지 생기지 않을지 모르는 추가적인 데이터를 기다리며 지연될 것이다. 두 번째로 네이글 알고리즘은 확인응답 지연과 함께 쓰일 경우 형편없이 동작한다. 네이글 알고리즘은 확인 응답이 도착할 때까지 데이터를 전송을 멈추고 있는 반면, 확인응답 지연 알고리즘은 확인응답을 100~200 밀리초 지연시킨다. HTTP 애플리케이션은 성능 향상을 위해서 네이글 알고리즘을 비활성화하기도 한다.

 

TIME_WAIT 지연과 포트고갈
TCP 커넥션을 먼저 끊는 종단은 커넥션의 IP 주소와 포트 번호를 메모리에 기록해 놓는다. 일반적으로 클라이언트가 active close 하도록 설계하므로 TIME_WAIT는 클라이언트측에 발생한다. 하지만 웹서비스에서 사용하는 HTTP 프로토콜은 구현의 특성상 여러 클라이언트 커넥션을 빠르게 받기 위해서 서버측에서 active close를 시도하는 상황이 일반적이다. 그래서 대부분의 TIME_WAIT 이슈는 웹서비스에서 주로 발생한다. 이 정보는 같은 주소와 포트 번호를 사용하는 새로운 TCP 커넥션이 일정 시간 동안에는 생성되지 않게 하기 위한 것으로, 세그먼트의 최대 생명주기에 두 배 정도('2SML'이라고 불리며 보통 2분 정도)의 시간 동안만 유지된다. 그 커넥션과 같은 주소, 포트번호를 가지는 새로운 커넥션에 삽입되는 문제를 방지한다. 2SML 동안 지연 메시지를 마저 처리하며 이 상태를 TIME_WAIT 상태라고 표현한다.

그림2. SEQ=3 패킷이 지연됐다가 나중에 새로운 커넥션에 삽입 (출처: docs.likejazz.com)

 

매우 드문 경우이긴 하지만 때마침 시퀀스까지 동일하다면 잘못된 데이터를 처리하게 되고 데이터 무결성 문제가 발생한다.

<발신지 IP 주소, 발신지 포트, 목적지 IP 주소, 목적지 포트> 이 중에서 세 개는 고정되어 있고 발신지 포트만 변경할 수 있다. 클라이언트가 서버에 접속할 때마다, 유일한 커넥션을 생성하기 위해서 새로운 발신지 포트를 쓴다. 하지만 사용할 수 있는 발신지 포트의 수는 제한되어 있고 (16비트, 65,536개) 2SML동안 커넥션이 재사용될 수 없으므로, 초당 500개(65,536 / 120 = 500)로 커넥션이 제한된다. 서버가 초당 500개 이상의 트랜잭션을 처리한다면 클라이언트의 포트가 고갈되어 더 이상 서버와 커넥션을 맺을 수 없는 문제가 일어난다. 이 문제를 해결하기 위해 부하를 생성하는 장비를 더 많이 사용하거나 더 많은 커넥션을 맺을 수 있도록 여러 개의 가상 IP 주소를 쓸 수도 있다.

 

참고로 서버의 하나의 프로세스는 하나의 포트를 가진 채로 여러 소켓을 열 수가 있다. (즉, 포트와 소켓은 반드시 1:1 매치가 되진 않는다. 포트 대 소켓은 1:N이 성립한다.) 하나의 프로세스는 같은 프로토콜, 같은 IP주소, 같은 포트 넘버를 가지는 여러개(수십개)의 소켓을 가질 수 있다. 그렇기 때문에 하나의 프로세스는 하나의 포트만으로 다른 여러 호스트에 있는 프로세스의 요청을 처리할 수 있고 게임 서버의 동시 접속자 수가 수십수백만이 될 수 있는 것이다.

 

순차적인 트랜잭션 처리에 의한 지연

3개의 이미지가 있는 웹 페이지가 있다고 해보자. 브라우저가 이 페이지를 보여주려면 네 개의 HTTP 트랜잭션을 만들어야 한다. 각 트랜잭션이 커넥션을 필요로 한다면, 커넥션을 맺는데 발생하는 지연과 함께 느린 시작 지연이 발생할 것이다.

그림3. 순차적인 트랜잭션 처리에 의한 지연 (출처: https://www.timegambit.com/blog/http/04)

 

지연을 개선하기 위한 기술

  • 병렬 커넥션
  • 지속 커넥션
  • 파이프라인 커넥션

병렬 커넥션
HTTP는 클라이언트가 여러 개의 커넥션을 맺음으로써 여러 개의 HTTP 트랜잭션을 병렬로 처리할 수 있게 한다. 이 예에서는 세 개의 이미지를 각 커넥션상의 트랜잭션을 통해 병렬로 내려받는다.

그림4. 병렬 커넥션 (출처: https://www.timegambit.com/blog/http/04)

 

지속 커넥션
HTTP/1.0은 keep-alive 커넥션 스펙을 지원한다. 같은 네 개의 HTTP 트랜잭션을 하나의 커넥션에서 처리한다. 각 트랜잭션마다 커넥션을 맺고 끊지 않아도 되므로 이에 소요되는 시간이 단축되었다.

그림5. 지속 커넥션 (출처: https://www.timegambit.com/blog/http/04)

 

하지만 keep-alive는 설계상의 문제점으로 인해 HTTP/1.1에서 빠졌다. 대신 지속 연결(persist connection)을 지원한다. HTTP/1.0의 keep-alive 커넥션은 선택사항인 반면, HTTP/1.1의 지속 커넥션은 기본으로 활성화된다. HTTP/1.1 에서는 별도 설정을 하지 않는 한, 모든 커넥션을 지속 커넥션으로 취급한다. 애플리케이션은 트랜잭션이 끝난 다음 커넥션을 끊으려면 Connection: close 헤더를 명시해야 한다. HTTP/1.1 클라이언트는 Connection: close 헤더가 없으면 응답 후에도 HTTP/1.1 커넥션을 계속 유지하자는 것으로 추정한다. 하지만 클라이언트와 서버는 언제든지 커넥션을 끊을 수 있다.

 

파이프라인 커넥션
HTTP/1.1은 지속 커넥션을 통해서 요청을 파이프라이닝할 수 있다. 이는 지속 커넥션의 성능을 더 높여준다. 여러 개의 요청은 응답이 도착하기 전까지 큐에 쌓인다.

그림6. 파이프라인 커넥션 (출처: https://www.timegambit.com/blog/http/04)

 

HTTP 클라이언트는 커넥션이 언제 끊어지더라도, 완료되지 않은 요청이 파이프라인(큐)에 있으면 언제든 다시 요청을 보낼 준비가 되어 있어야 한다. 클라이언트가 커넥션을 맺고서 바로 10개의 요청을 보낸다고 하더라고 서버는 5개의 요청만 처리하고 커넥션을 임의로 끊을 수 있다. 남은 5개의 요청은 실패할 것이고 클라이언트는 예상치 못하게 끊긴 커넥션을 다시 맺고 요청을 보낼 수 있어야 한다.
HTTP 클라이언트는 POST 요청같이 반복해서 보낼 경우 문제가 생기는 요청은 파이프라인을 통해 보내면 안된다. POST와 같은 비멱등 요청을 재차 보내면 문제가 생길 수 있기 때문이다.

참고로 GET, HEAD, PUT, DELETE, TRACE 그리고 OPTIONS 메서드들은 멱등하다고 표현한다.

 

출처

HTTP 완벽 가이드