컴퓨터 공학(Computer Science & Engineering))/네트워크

[네트워크] TCP? HTTP? 그것이 알고싶다

멍개. 2022. 8. 28. 15:07

TCP와 HTTP를 다뤄보도록 하겠습니다. 근데 사실 어렵지 않아요!!!! 이걸 어렵게 설명하니깐 어려운거지..... 어렵지 않은 이유를 알려드릴게요

사실 어려운 개념은 맞습니다. 왜냐고요? 우리 눈에 보이지 않으니깐요?

하지만 TCP 레이어 위에서 HTTP를 직접 구현하여 브라우저를 통해 구현한 HTTP를 사용하는 과정을 통해 진입장벽을 낮췄습니다?

TCP라고 하면 흔히 소켓 프로그래밍, TCP/IP, UDP를 많이 떠올릴것입니다. 전공자가 아니신가요? 모르셔도 됩니다.

● 배경지식

21세기의 우리는 다양한 프로토콜 기반으로 네트워크를 이용하여 데이터를 주고받습니다.

http, websocket, webrtc 등 이를 제대로 이해하기 위해 이들이 어떻게 구현되는지 알아볼건데 필요한 배경지식을 알아보겠습니다.

· OSI 7계층

첫 번재로 OSI 7계층입니다. 우리가 네트워크를 공부하면 첫 번째 포기하는 지점입니다.

아래 내용 안외워도 됩니다. 아 이렇구나 하고 그냥 빠르게 넘어가세요 왜냐하면 이를 좀 더 함축적으로 4계층으로 표현하기 때문입니다.

 

▶ 1층 - 물리계층

해당 계층은 통신 케이블을 통해 전달되는 계층입니다.

▶ 2층 - 데이터 링크 계층

mac address(맥 주소)를 이용하여 정보의 전달을 수행하는 계층입니다.

모뎀의 역할이 1, 2층을 의미합니다.

▶ 3층 - 네트워크 계층

IP를 이용하여 데이터를 목적지까지 전달하는 기능을 수행하는 계층입니다.

▶ 4층 - 전송계층

PORT를 이용하여 응용프로그램간 통신을 할 수 있도록 수행하는 계층입니다.

네트워크 계층은 컴퓨터를 찾는거라면 전송계층은 찾은 컴퓨터에서 동작하는 특정 응용프로그램을 의미합니다.

▶ 5층 - 세션계층

데이터가 통신하기 위한 논리적 연결하는 계층입니다.

▶ 6층 - 표현계층

대표적인 기능으로 주고 받는 데이터를 암호화 하는 계층입니다.

SSL, TLS등이 여기에 포함됩니다.

▶ 7층 - 응용계층

응용 서비스를 수행하는 서비스를 제공하는 계층입니다.

근데 7층은 너무 많죠? 이걸 좀 더 추상화 하여 기능적으로 4계층으로 줄여서 표현합니다. 해당 층이 머라고 설명이 되어있는데 이해가 잘 안될겁니다. 지극히 정상입니다. 아마 계층이라는 단어가 가장 어렵게 느껴질것입니다. 이 계층이라는 개념은 뒤에서 자세히 다뤄볼겁니다.

· OSI 4 계층

OSI 7계층을 좀 더 개발자 관점에서 묶은 레이어가 OSI 4 계층입니다. 해당 계층을 TCP/IP라고 표현합니다.

7층이 우측처럼 4층의 모양으로 됩니다. 자! 이제 여기서 한 가지 더 나아가 보겠습니다. 우리는 TCP와 HTTP를 알아본다고 했습니다.

▶ L1 - 네트워크 액세스(MAC Address)

실제로 데이터가 전송되는 과정을 담당하는 계층입니다. 모뎀을 통해 외부로 통신되는 그 과정을 관리하는 계층입니다.

▶ L2 - 인터넷 계층(IP)

L2는 모뎀 밖으로 떠나간 데이터가 목적지 컴퓨터를 찾아가기 위한 방법을 제공합니다.

여기서 데이터를 패킷 또는 프레임이라고 표현합니다.

▶ L3 - 전송계층(PORT)

L3는 모뎀밖으로 떠나간 패킷은 L2에 의해 목적지 컴퓨터에 잘 도착했습니다. 하지만 해당 패킷이 어떤 프로그램으로 가야할 지 결정해야 하는데 L3가 도착한 목적지에서 어떤 프로그램으로 갈지 결정합니다.

▶ L4 - 응용계층

L4는 프로그램에 도착한 패킷이 어떻게 처리될지 결정합니다.

앞의 이미지에서 L1, L2, 이런 녀석을 하나의 마을이라고 생각하겠습니다. 마을이 있으면 누가 있어야죠? 시민이죠 TCP, HTTP가 바로 각 마을에 서식하는 시민입니다.

OSI 4계층을 중심으로 TCP는 L3 마을에 HTTP는 L4 마을에 살고있습니다.

여기서 2가지 관점에서 레이어를 분석할 수 있습니다. 네트워크 엔지니어는 L1, L2 레이어를 관리합니다.

일반적으로 우리가 서버를 만든다는 것은 사실 L4의 더 상위 레이어를 의미하지만 최적화 작업을 위해 L3, L4까지 다룹니다. 엇 그럼 OSI의 4계층에서 L3, L4는 OSI 7계층에서 L4, L5, L6, L7을 다 알아야 하나요라는 생각이 들 수 있는데 앞에서 기능적으로 7계층을 4계층으로 묶었다고 했기 때문에 개발하는 관점에서 L5, L6, L7이 나뉘진 않습니다. 물론 아주아주아주아주 로우한 영역으로 들어가면 나뉘어 지겠지만..... 이런 경우는 아주아주아주 특수한 케이스이기 때문에 대부분 OSI 7계층의 L5, L6, L7는 OSI 4계층의 L4 하나의 계층처럼 생각해보 됩니다.

정리하면 아 그럼 개발하는 관점에서 OSI 4계층의 L3, L4만 공부하면 됩니다. 전체적으로 내용이 줄어들긴 했지만 이 둘의 개념이 아직까지 명확하지 않을겁니다.

● L3 - 전송계층(OSI 4계층의 3층)

L3는 PORT를 사용하여 컴퓨터로 들어온 패킷이 특정 응용프로그램으로 전달되도록 합니다. L3의 핵심은 실행된 응용프로그램을 PORT로 구분한다는 점입니다.

해당 계층의 가장 대표적인 프로토콜은 TCP와 UDP입니다. 여기서 우리는 TCP만 다뤄보겠습니다.

· TCP의 기초개념

해당 계층에서 TCP를 다룰때 2가지 어려운 개념이 등장합니다 바로 3 way-handshake와 4 way-handshake입니다. 이는 두 응용프로그램이 데이터 전달 전 커넥션하는 과정을 3 way-handshake라고 하며, 데이터 전달 후 커넥션을 끊는 과정을 4 way-handshake라고 합니다.

이 과정 때문에 TCP는 신뢰도 있는 데이터 전송을 보장합니다. 여기서 신뢰란 전송한 데이터가 반드시 목적지에 도착 한다는 것을 의미합니다. UDP는 별도로 다루지 않지만 UDP는 연결 전/후로 커넥션 과정을 거치지 않기 때문에 신뢰도가 적으며 대신 TCP보다 빠르게 데이터 전송을 할 수 있습니다.

TCP와 UDP를 구현하는 행위를 소켓 프로그래밍이라고 합니다.

· TCP 서버 구현해보기

nodejs에서 네이티브로 제공하는 net 모듈이 있습니다. net 모듈을 통해 TCP 서버및 클라이언트를 구현할 수 있습니다.

const net = require('net');

const server = net.createServer(socket => {
  console.log(`cnnected address: ${socket.address().address}`);
  socket.on('data', (data) => { 
    console.log(data.toString())
    socket.write('test')
    socket.end();
  });
})

server.listen(5000, function() {
  console.log(`listen on port 5000`)
})

해당 코드를 통해 TCP 서버를 실행합니다.

$ sudo lsof -i -P | grep :$PORT | grep node

:$PORT는 응용프로그램이 바인딩한 포트를 입력해줍니다. 여기서는 5000을 입력하면 됩니다.

만약 ubuntu 기반의 리눅스 환경이라면 netstat를 사용하면 됩니다.

$ netstat -a

$ netstat -antp

네트워크(TCP, UDP) 상태를 조회하면 5000번 포트의 프로세스 상태가 listen인 것을 확인할 수 있습니다. ubuntu라면 netstat로 확인 가능합니다.

server.listen을 통해 5000번 포트로 연결을 수행합니다. 사용자는 IP:PORT를 통해 해당 어플리케이션과 데이터를 주고 받을 수 있습니다.

net.createServer는 다른 응용프로그램이 연결을 요청하면 내부적으로 3 way-handshake 이후 연결된 세션 객체를 콜백으로 전달합니다. 해당 코드에선 세션 객체를 socket으로 사용합니다.

socket.on('data')는 클라이언트가 데이터를 전송할 때 발생하는 이벤트입니다. socket.write()는 데이터를 전달합니다.

socket.end()가 호출되면 4 way-handshake가 발생하고 커넥션이 끊어집니다.

· TCP 클라이언트 구현해보기

const net = require('net');

// 서버 5000번 포트로 접속 
// 3 way-handshake
const socket = net.connect({ port: 5000 });

net.connect를 통해 TCP 커넥션을 생성합니다. 이때 3 way-handshake가 발생하여 커넥션을 생성합니다.

서버가 실행된 상태에서 클라이언트 코드를 실행하면 서버에서 콘솔에 클라이언트 주소가 출력되는 모습을 확인할 수 있습니다.

const net = require('net');

// 서버 5000번 포트로 접속 
// 3 way-handshake
const socket = net.connect({ port: 5000 });

socket.on('connect', function () {
  console.log('connected to server!');
  setInterval(function () {
    socket.write('mung mung~~');
  }, 1000);
});

// 서버로부터 받은 데이터를 화면에 출력 
socket.on('data', function (chunk) {
  console.log('receive: ' + chunk);
});

// 접속이 종료됬을때 메시지 출력 
// 4 way-handshake
socket.on('end', function () {
  console.log('disconnected.');
});

// 에러가 발생할때 에러메시지 화면에 출력 
socket.on('error', function (err) {
  console.log(err);
});

// connection에서 timeout이 발생하면 메시지 출력 
socket.on('timeout', function () {
  console.log('connection timeout.');
});

만약 해당 코드를 실행하면 어떻게 될까요??

1. 클라이언트가 서버에게 mung mung~~ 전송

2. 서버가 클라이언트에게 메시지를 받으면 test를 다시 클라이언트로 전송

3. 서버는 메시지 전송 후 socket.end()로 4 way-handshake가 발생하여 커넥션이 끊김

4. 클라리언트는 1초 후 mung mung~을 전송하려고 시도

5. 커넥션이 끊겼기 때문에 에러발생

1초마다 끊어진 커넥션을 이용하여 메시지 전송을 계속 시도하기 때문에 에러가 발생합니다. 서버에서 socket.end가 실행되고 커넥션이 끊기면 클라이언트에서 이벤트를 등록한 socket.on('end')가 실행된 모습을 확인할 수 있습니다.

· 4 way-handshake 발생하지 않고 데이터 지속적으로 주고받기

만약 한 번 만들어진 커넥션을 클라이언트가 1초마다 메시지를 전송하는 것을 에러없이 받고 싶다면 서버에서 커넥션을 종료하는 socket.end()을 없애면 됩니다. socket.end()를 지운 후 서버를 재실행한 후 클라이언트를 실행해보겠습니다.

생성된 커넥션을 끊지 않는다면 지속적으로 데이터를 주고받을 수 있습니다.

· 네트워크(TCP/UDP) 상태

두 응용프로그램이 커넥션이 끊어지지 않고 데이터를 주고받을 때 네트워크(TCP/UDP) 상태조회를 하면 다음과 같습니다.

3 way-handshake는 다음과 같이 패킷을 주고받으며 두 응용 프로그램의 네트워크 상태는 다음과 같이 전이합니다.

      TCP A                                                TCP B
  1.  CLOSED                                               LISTEN
  2.  SYN-SENT    --> <SEQ=100><CTL=SYN>               --> SYN-RECEIVED
  3.  ESTABLISHED <-- <SEQ=300><ACK=101><CTL=SYN,ACK>  <-- SYN-RECEIVED
  4.  ESTABLISHED --> <SEQ=101><ACK=301><CTL=ACK>       --> ESTABLISHED
  5.  ESTABLISHED --> <SEQ=101><ACK=301><CTL=ACK><DATA> --> ESTABLISHED

결과론적으로 established 상태의 두 응용프로그램은 커넥션을 유지하고 있어 데이터를 주고받을 수 있는 상태를 의미합니다.

// 서버 
net.createServer(socket => {})

// 클라이언트
const socket = net.connect({ port: 5000 });

우리가 작성한 코드에서 서버와 클라이언트 코드가 바로 저 복잡한 과정을 거쳐 established 상태를 가진 커넥션 객체를 만들어 주는 부분입니다.

하나의 클라이언트를 더 실행했습니다. 가장 위가 서버이며 그 아래2개는 클라이언트입니다.

네트워크(TCP/UDP) 상태를 조회하면 2개의 결과가 추가되었습니다. 여기서 9378은 TCP 서버이며 5000번 포트가 50071, 50073 포트와 연결된 상태를 의미합니다. 11711, 11796은 우리가 추가로 실행한 클라이언트입니다. 커넥션이 생성되면 OS는 해당 커넥션에게 랜덤하게 포트를 부여합니다. 그리고 로컬호스트의 5000 포트와 연결되어 데이터를 주고받을 수 있습니다.(established 상태이기 때문에)

● L4 - 응용 계층

응용 계층은 L3 위에서 동작하는 레이어입니다. 여기서 위에서 동작한다는 것은 L4에서 추가된 데이터가 L3를 통해 전송한다는 것을 의미합니다.

socket.write('test')

응용 계층은 established한 상태에서 데이터를 주고받을 때 서버와 클라이언트간 합의한 규칙입니다. 앞에서 L4의 대표적인 예로 HTTP가 있다고 했습니다.

· 프로토콜이란?

HTTP를 우리는 프로토콜이라고 부릅니다. 한가지 테스트를 해보겠습니다.

const net = require('net');

const server = net.createServer(socket => {
  console.log(`cnnected address: ${socket.address().address}`);
  socket.on('data', (data) => { 
    console.log(data.toString())
    socket.write('test')
    socket.end();
  });
})

server.listen(5000, function() {
  console.log(`listen on port 5000`)
})

클라이언트에게 요청을 받으면 클라이언트에게 전달받은 데이터를 출력한 후 test 문자열을 전송 후 4 way-handshake를 발생시켜 커넥션을 끊습니다.

해당 서버를 실행한 후 브라우저로 localhost:5000을 접속해보겠습니다. 아마 브라우저는 별다른 페이지를 띄우진 않습니다. 하지만 서버의 로그를 확인하면 다음과 같습니다.

서버의 로그를 확인하면 이상한 문자를 출력합니다.

· HTTP 프로토콜 규약에 맞는 서버

바로 이 부분이 우리가 얘기하는 HTTP 입니다. 네 HTTP 라는것은 바로 write()로 전달하는 데이터입니다. HTTP라는 규칙으로 작성된 문자열입니다.

여기서 HTTP 규약에 맞춰 write에 작성해주고 서버 재시작 후 브라우저로 접속해보겠습니다.

const net = require('net');

const server = net.createServer(socket => {
  console.log(`cnnected address: ${socket.address().address}`);
  socket.on('data', (data) => { 
    console.log('====== receive data======='); 
    const httpMsg = `
HTTP/1.1 200 Success
Connection: close
Content-Length: 1573
Content-Type: text/html; charset=UTF-8
Date: Mon, 20 Aug 2018 07:59:05 GMT

<!DOCTYPE html>
<html lang=en>
  <meta charset=utf-8>
  <title>Wow!!!!</title>
  <body>
    <h1>test</h1>
  </body>
</html>
`

    console.log(data.toString());
    socket.write(httpMsg)
    console.log('====== receive data======='); 
    socket.end();
  });
})

server.listen(5000, function() {
  console.log(`listen on port 5000`)
})

브라우저에서 서버가 응답한 HTML 코드를 정상적으로 렌더링한 모습을 확인할 수 있습니다.

· json 응답하기

const httpMsg = `HTTP/1.1 200 Success
Connection: close
Content-Length: 1573
Content-Type: application/json; charset=UTF-8
Date: Mon, 20 Aug 2018 07:59:05 GMT

{
  "name": "mung",
  "age": 30
}
`;

만약 socket.write로 전송하는 데이터가 앞의 문자열과 같다면 해당 데이터는 json으로 인식합니다. Content-Type에 따라 브라우저는 해당 데이터를 HTML 또는 json으로 인식할 수 있습니다.

· 그래서 HTTP 프로토콜은 어떻게 생겨먹었는데?

RFC를 통해 표준을 정의합니다. HTTP/1.1은 RFC 2616에 작성되어 있습니다.

HTTP1.1은 다음 링크에서 어떻게 생겼는지 확인할 수 있습니다.

https://datatracker.ietf.org/doc/html/rfc2616

 

RFC 2616 - Hypertext Transfer Protocol -- HTTP/1.1

 

datatracker.ietf.org

 

 

RFC를 기반하여 socket.write()으로 데이터를 전달하면 됩니다.

· HTTP 서버, TCP 클라이언트 통신

앞에서 TCP로 서버를 만든 후 HTTP 프로토콜을 응답했을 때 브라우저가 정상적으로 렌더링하고 json을 응답하는 서버를 구현해보았습니다. 이번엔 반대로 http 라이브러리로 만든 HTTP 서버를 net 라이브러리를 이용하여 요청/응답 과정을 살펴보겠습니다.

▶ 서버

const http = require('http');
const url = require('url');

const server = http.createServer( (req, res) => {
  console.log(req.method)
  console.log(req.url)
  res.writeHead(200);
  if (req.method === 'GET' && req.url === '/') {
    return res.end('mung ');
  } else{
    return res.end('mung mung');
  }
});

server.listen(5000)

▶ 클라이언트

const net = require('net');

// 서버 5000번 포트로 접속 
// 3 way-handshake
const socket = net.connect({ port: 5000 });

socket.on('connect', function () {
  console.log('connected to server!');
  socket.write('mung mung~~');
});

// 서버로부터 받은 데이터를 화면에 출력 
socket.on('data', function (chunk) {
  console.log('receive: ' + chunk);
});

// 접속이 종료됬을때 메시지 출력 
// 4 way-handshake
socket.on('end', function () {
  console.log('disconnected.');
});

// 에러가 발생할때 에러메시지 화면에 출력 
socket.on('error', function (err) {
  console.log(err);
});

// connection에서 timeout이 발생하면 메시지 출력 
socket.on('timeout', function () {
  console.log('connection timeout.');
});

서버를 실행 후 클라이언트를 실행하면 클라이언트는 다음과 같은 메시지를 응답받습니다. 400은 올바른 요청을 하지 않았다는 것을 의미합니다.

HTTP/1.1 400 Bad Reques

바로 "mung mung~~"는 HTTP 양식에 맞지않기 때문입니다.

클라이언트에서 socket.write(mung mung~)을 HTTP 규격에 맞춰서 다음과 같이 전송하면 HTTP 서버는 정상적으로 처리합니다.

socket.on('connect', function () {
  console.log('connected to server!');
  const msg = [
    'GET / HTTP/1.1',
    'Accept: */*',
    'User-Agent: my-test-agent',
    'Host: localhost:5000',
    '',
    '',
  ].join('\r\n')
  socket.write(msg);
});

GET 메서드의 /으로 요청한 결과를 확인할 수 있습니다.

/a로 요청하고 싶다면 다음과 같이 수정합니다.

socket.on('connect', function () {
  console.log('connected to server!');
  const msg = [
    'GET /a HTTP/1.1',
    'Accept: */*',
    'User-Agent: my-test-agent',
    'Host: localhost:5000',
    '',
    '',
  ].join('\r\n')
  socket.write(msg);
});

· 그 외의 프로토콜

현재 시점에서 HTTP 프로토콜을 제외하고 많이 사용하는 프로토콜은 웹소켓이 있습니다. 일반적으로 웹소켓은 HTTP를 좀 더 개량하여 사용한 방법입니다. 웹소켓도 HTTP와 개념은 동일합니다. HTTP는 한 번 데이터를 주고 받으면 커넥션을 끊지만 웹소켓은 끊지 않습니다. 웹소켓은 HTTP 통신을 한 번 주고받고 웹소켓으로 업그레이드 되는 형태로 프로토콜이 구성되어 있습니다.

즉 정리하면 HTTP나 웹소켓은 두 응용프로그램간 연결된 상태(ESTABLISHED)에서 데이터를 주고 받을 때 HTTP는 한 번의 데이터를 주고 받으면 커넥션을 끊으며, 웹 소켓은 끊지 않는 방법입니다.

GET / HTTP/1.1
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: J7dCBYk6vVIqwcI/0+CUWQ==
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Host: 127.0.0.1:5000

웹소켓의 경우는 커넥션 후 데이터를 전송할 때 Connection은 Upgrade, Upgrade는 websocket으로 설정된다는 특징을 가지고 있습니다.

정리하면 L3는 데이터를 전송하기 위한 커넥션을 만들면 L4에선 정해진 규격을 맞춰 데이터를 주고 받습니다. 여기서 정해진 규격을 프로토콜이라고 합니다.

응용계층을 정리하면 HTTP는 TCP위에서 정의한 데이터를 주고받는 규격입니다. 이때 HTTP를 좀 더 확장한 개념으로 웹소켓을 사용합니다. 웹소켓과 HTTP를 좀 더 유연하게 사용하기 위해 socket.io와 같은 라이브러리가 탄생했습니다.

또 UDP를 이용한 방식으로 요즘 많이 핫해지고 있는 webRTC가 있습니다.

● L5 - 라이브러리 계층

자 이제 본격적으로 HTTP 서버를 만든다고 생각해보겠습니다. 그럼 우리는 클라이언트가 전달한 매번 클라이언트한테 요청받은 데이터를 매번 파싱해서 쓰는건 말이 안됩니다.

그래서 앞에서 클라이언트에게 전달받은 HTTP 본문을 파싱하고 write를 이용하여 응답하는 부분을 좀 더 편리하게 추상화한 라이브러리인 http를 제공합니다.

const http = require('http');
const url = require('url');

const server = http.createServer( (req, res) => {
  console.log(req.method)
  console.log(req.url)
  res.writeHead(200);
  res.end('mung mung');
});

server.listen(5000)

http 라이브러리는 요청 객체와 응답 객체를 이용하여 클라이언트의 요청 데이터를 확인하고 응답할 수 있습니다.

(req, res) => {}

established가 되어 데이터를 주고받을 수 있는 상태가 되면 클라이언트가 전달한 HTTP 본문을 첫 번째 객체인 요청 객체로 만들어주며 socket.write를 추상화하여 사용할 수 있는 두 번째 인자로 전달한 응답 객체를 전달받습니다.

브라우저 및 HTTP 클라이언트 프로그램으로 localhost:5000, localhost:5000/a로 접속하면 서버는 다음과 같이 로그를 출력합니다.

GET
/a
GET
/

HTTP 서버를 만들 땐 method와 url을 이용하여 서로 다른 데이터를 응답하는 형태로 만들게 됩니다. 이를 API라고도 하죠

· method와 url에 따라 다른 데이터 응답하기

const http = require('http');
const url = require('url');

const server = http.createServer( (req, res) => {
  console.log(req.method)
  console.log(req.url)
  res.writeHead(200);
  if (req.method === 'GET' && req.url === '/') {
    res.end('mung ');
  } else{
    res.end('mung mung');
  }
});

server.listen(5000)

서로 다른데이터를 응답받습니다.

● L6 - 프레임워크

하지만 이 또한 귀찮습니다. 먼가 코드를 유지보수하기 힘든 부분도 있을것이고 좀더 체계화할 수 있는 여러 프레임워크가 나오고 있습니다. 자바의 스프링, 노드의 express, nest, koa 등, 파이썬의 django, flask, fastapi 등 여러 프레임워크가 쏟아져 나오고 있습니다.

예를들어 여기서 언급한 프레임워크들은 각 언어에서 제공하는 http를 기반으로 보다 편하게 사용할 수 있도록 기능이 추가된 것들입니다.

 
const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('mung!')
})

app.get('/a', (req, res) => {
  res.send('mung mung!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

 

● 기타

· socket.io

socket.io 라는 라이브러리가 있습니다. socket.io는 HTTP와 WebSocket을 둘 다 지원하는 라이브러리입니다. socket.io는 L3에 비하면 상당히 추상화 된 레이어입니다. socket.io는 L3(전송계층)를 구현하지 않습니다. HTTP와 WebSocket 위의 레이어에서 WebSocket을 지원하지 않는 환경에서 HTTP를 이용하여 주기적으로 요청하는 폴링 기법을 지원합니다.

 

· UDP

L3의 전송계층에서 TCP를 다뤄봤는데 UDP를 다루지 않으면 UDP가 삐질테니 UDP를 빠르게 살펴보겠습니다.

▶ 서버

const dgram = require('dgram');
const server = dgram.createSocket('udp4');
const port = 5000;
 
server.on('message', (message, info) => {
  console.log(`message: ${message.toString()}`);
  console.log(`from address: ${info.address} port: ${info.port}`);
});
 
server.on('listening', () => {
  const address = server.address();
  console.log(`listening ${address.address}:${address.port}`);
});
 
server.bind(port);

▶ 클라이언트

const dgram = require('dgram');
const toHost = '0.0.0.0';
const toPort = 5000;
const client = dgram.createSocket('udp4');
 
const data = Buffer.from('mung');
 
client.send(data, toPort, toHost, (err) => {
  if (err) console.log(err);
  else console.log('ok');
  client.close();
});

아무생각없이 보면 TCP랑 다를게 없는데? 할 수 있을텐데 잘 보면 클라이언트에서 connect하는 코드가 없습니다. UDP는 커넥션 생성 -> 데이터 전송 -> 커넥션 제거 과정없이 데이터 전송을 합니다.

엥? 그럼 데이터 전송이 안될 수 있지 않나?

네 맞습니다. UDP는 데이터 전송을 보장하지 않습니다. 그렇기 때문에 일반적으로 데이터 전송을 보장해야 하므로 TCP 기반으로 구현된 HTTP, WebSocket 등을 많이 사용합니다. 하지만 요즘 UDP를 활용한 프로토콜이 핫해지고 있습니다. 바로 webRTC 입니다. 머 사실 webRTC는 내부적으로 복잡한 통신을 주고 받는데 모든 통신을 UDP로 구성되어 있진 않습니다. TCP와 UDP를 혼용하여 사용합니다. webRTC의 UDP는 UDP로 구현된 SCTP를 사용합니다.

● HTTP/1.1, HTTP/2.0, HTTP/3.0

현재 가장 많이 사용하고 있는 HTTP 버전은 1.1입니다.

HTTP/1.1도 단점이 많아 이를 개선한 방법이 HTTP/2.0, HTTP/3.0입니다.

HTTP/2.0은 SPDY를 기반으로 구현합니다.

HTTP/3.0은 QUIC를 기반으로 구현합니다. QUIC는 UDP 기반으로 만들어져있습니다.

· HTTP/1.1

HTTP/1.1은 connection: keep-alive를 이용하여 커넥션을 제거하지 않습니다. 앞에서 만든 http 모듈로 구축한 HTTP 서버를 실행합니다.

const http = require('http');
const url = require('url');

const server = http.createServer( (req, res) => {
  console.log(req.method)
  console.log(req.url)
  res.writeHead(200);
  if (req.method === 'GET' && req.url === '/') {
    return res.end('mung ');
  } else{
    return res.end('mung mung');
  }
});

server.listen(5000)

응답을 정상적으로 마쳤지만 ESTABLISHED 상태를 유지합니다.

동일한 커넥션을 가지고 데이터를 지속적으로 주고 받는지 테스트 하기 위해 요청하는 코드를 작성합니다.

setInterval(async () => {
    await fetch('http://127.0.0.1:5000')
}, 300)

앞에서 127.0.0.1:5000으로 접속한 페이지에서 개발자 도구를 연 후 해당 스크립트 코드를 실행합니다. 해당 코드는 300ms 마다 127.0.0.1:5000로 요청하는 코드입니다.

주기적으로 네트워크 상태를 살펴보면 동일한 커넥션이 유지되고 있는 모습을 확인할 수 있습니다.

커넥션을 유지하여 데이터를 주고받는 과정을 Pipelining이라고 합니다.

▶ CLOSE_WAIT

CLOSE_WAIT 상태를 만들고 싶다면 HTTP 서버를 열고 브라우저에서 해당 서버를 접속합니다. 그러면 ESTABLISHED 상태가 되는데 이때 서버를 종료하고 네트워크 상태를 확인하면 앞의 이미지처럼 CLOSE_WAIT 상태로 바뀐 모습을 확인할 수 있습니다.

CLOSE_WAIT는 두 응용프로그램에서 한쪽이 커넥션을 끊은 상황을 의미합니다. 행아웃이라고도 합니다. CLOSE_WAIT는 이미 커넥션이 끊겼기 때문에 해당 커넥션을 다시 재사용할 수 없습니다.

CLOSE_WAIT말고 TIME_OUT도 있는데 이는 재사용 가능합니다. 네트워크 상태는 다음과 같습니다.

CLOSE	    커넥션 없음
LISTEN	    Passive open, SYN을 기다리는 상태
SYN-SENT	SYN을 보내고 ACK를 기다리는 상태
SYN-RCVD	SYN+ACK을 보내고 ACK를 기다리는 상태

ESTABLISHED	커넥션이 생성된 상태, 데이터를 전송할 수 있다.

FIN-WAIT-1	첫 FIN이 보내진 상태, ACK를 기다리고 있다.
FIN-WAIT-2	첫 FIN에 대한 ACK를 받은 상태, 2번째 FIN을 기다리고 있다.
CLOSE-WAIT	첫 FIN을 받고 ACK를 보낸 상태, 어플리케이션의 종료를 기다리고 있다.
TIME-WAIT	2번째 FIN을 받고 ACK를 보낸 상태, 동일 포트와 주소에 커넥션이 생성되지 않도록 하는 시간(2MSL time out)을 기다리는 상태
LAST-ACK	2번쨰 FIN을 보내고 ACK를 기다리는 상태
CLOSING	    양쪽이 동시에 닫기로 한 상태

TCP에서 Passive와 Active 개념이 있습니다. Active는 무언가 요청하는 대상을 의미하고 Passive는 요청을 받는 대상을 의미합니다.

Active Open은 연결 오픈을 요청하는 대상, Passive Open은 연결 오픈 요청을 받는 대상

Avtive Close은 연결 종료를 요청하는 대상, Passive Close은 연결 종료 요청을 받는 대상

· HTTP/1.1 문제점

HTTP/1.1은 크게 2가지 문제점을 가집니다.

HOLB: Head Of Line Blocking
RTT: Round Trip Time
Heavy Header

▶ HOLB(Head Of Line Blocking)

앞에서 HTTP/1.1은 ESTABLISHED를 계속 유지하여 TCP 연결을 최소화합니다. 하지만 해당 상태를 유지하더라도 문제점이 있습니다. 바로 HOLB입니다.

setInterval(async () => {
    await fetch('http://127.0.0.1:5000')
}, 300)

이런 형태로 300ms마다 서버로 요청을 합니다. 만약 응답하는데 300ms가 더 걸린다면 어떻게 될까요. 예를들어 10개의 요청을 발생했을 때 7번째 응답해야하는 데이터의 크기가 너무커서 지연이 되었다고 가정하겠습니다. 7번째 이후 응답은 7번째 응답이 완료될 때까지 지연됩니다. 왜냐하면 TCP는 클라이언트의 요청을 받고 데이터를 전달하는 과정을 블락킹하기 때문입니다. 블락킹하는 이유는 TCP 자체가 순서보장을 해야하기 때문입니다.3개의 요청은 반드시 요청한 순서대로 응답해야 합니다. 이를 보장하는것이 TCP의 역할입니다.

▶ RTT(Round Trip Time)

근본적으로 HTTP는 TCP 기반으로 하기 때문에 3 way-handshake, 4 way-handshake로 인한 네트워크 지연은 피할 수 없습니다. Connection: Keep-Alive으로 커넥션을 유지하지만 일정시간 데이터를 주고받지 않으면 연결을 끊고 다시 연결을 수행합니다. 연결상태를 계속 유지할 경우 서버의 입장에서 메모리 문제가 발생할 수 있음

▶ Heavy Header

앞에서 TCP 서버를 연 후 브라우저 및 HTTP 클라이언트 프로그램으로 커넥션하여 데이터를 전송할 때 실제 데이터보다 너무 방대한 헤더를 확인할 수 있습니다.

HTTP/1.1은 실제 사용될 데이터보다 반복적으로 헤더를 포함하기 때문에 데이터가 전체적으로 무거워집니다.

HTTP/1.1은 이러한 문제를 해결하기 위해 나름 여러 방법을 고안했습니다.

Image Spriting, Domain Sharding, Minified CSS/JavaScript, Load Faster, Data URI Scheme

· HTTP/2.0

HTTP/2.0은 HTTP/1.1의 가장 큰 문제인 HOLB와 Heavy Header 문제해결을 목적으로 합니다.

구글은 SPDY 프로토콜을 구현함으로써 HTTP/2.0을 구현했습니다.

▶ Multiplexed Streams

클라이언트는 요청 순서에 상관없이 응답을 받더라도 이를 처리할 수 있습니다.

또한 사이즈가 큰 패킷을 쪼개어 응답합니다. 간단한 개념으로 사이즈가 큰 응답 메시지를 작은 단위의 여러 프레임으로 쪼개어 응답합니다. 여기서 원본 응답 메시지를 스트림이라고 표현하고 하나의 응답 메시지 스트림을 여러개로 쪼갠 단위를 프레임이라고 합니다. 하나의 스트림은 여러 프레임을 가질 수 있습니다

응답 결과를 받은 대상은 여러 단위의 프레임을 하나의 메시지로 다시 조립하여 사용합니다.

HTTP/2.0은 해당 방식을 기반으로 동작합니다.

▶ Stream Prioritization

서버는 클라이언트의 여러 요청에 대해 우선순위에 따라 응답 순서를 바꿀 수 있습니다. Multiplexed Streams에서 스트림은 여러 프레임으로 쪼개어 전송을 한다고 했습니다. 이 말은 프레임 전달 순서에 따라 성능 결정을 한다는 의미입니다.

HTTP/2.0은 스트림의 종속성과 가중치 조합을 사용하여 클라이언트가 구성한 우선순위 지정 트리를 이용하여 통신합니다. 서버는 연결된 클라이언트의 우선순위 지정 트리에 따라 다른 순서로 프레임은 전송하게 됩니다.

▶ Server Push

서버는 클라이언트의 한번의 요청으로 필요한 리소스 파일을 socket.write()하여 주는것을 의미합니다.

▶ Header Compression

HTTP/2.0은 헤더를 HPACK 압축방식을 이용하여 압축합니다. 또한 중복된 값으로 크기가 증가하는 문제를 없애기 위해 static/dynamic header table을 이용하여 중복을 최소화합니다.

HTTP/1.1HTTP/2.0은 근본적으로 TCP를 기반으로 동작하기 때문에 handshake 문제를 피할 수 없습니다. 또한 multiplexed streams를 이용하더라도 HOLB 문제를 완벽하게 해결할 수는 없습니다.

 

·  HTTP/3.0

HTTP/3.0 UDP 기반으로 구현된 QUIC 프로토콜을 이용합니다. 즉, HTTP/3.0은 UDP를 사용합니다. TCP 대신 UDP를 사용하면서 handshake 과정이 없어지기 때문에 네트워크 레이턴시가 상당히 짧아집니다. 하지만 데이터의 보장이 100% 확신할 수 없는 프로토콜 입니다. TCP와 UDP 패킷만 까봐도 TCP는 데이터의 신뢰도를 보장하기 위해 엄청나게 많은 정보를 포함하고 있습니다.(여기서 신뢰도란 데이터를 전송할 때 완벽하게 목적지에 도달하는 것을의미) 하지만 UDP는 신뢰도를 보장하지 않기 때문에 패킷이 매우 가볍습니다.

TCP 패킷(출처:  http://www.ktword.co.kr/test/view/view.php?m_temp1=1889&id=1103 )
UDP 패킷(출처:  http://www.ktword.co.kr/test/view/view.php?m_temp1=323&m_search=UDP )

패킷만 봐도 TCP가 얼마나 복잡한지 알 수 있습니다.

또한 HTTP/3.0을 쓰기 위해서 반드시 HTTPS를 적용해야 합니다.

필자의 경우 아직 UDP 기반의 HTTP/3.0이 정말 기존의 HTTP를 완전히 대체할 수 있을지는 잘 모르겠습니다.

● 연결성과 상태유지

마지막으로 HTTP의 연결성 및 상태유지를 다뤄보고자 합니다.

· 연결성

HTTP는 연결지향(Connected oriented)이다 비연결성(Connectionless)이다 딱 말하기가 힘듭니다. 연결지향 비연결은 연결 상태를 유지하느냐 유지 하지 않느냐를 의미합니다. 연결지향은 연결을 유지하여 데이터를 주고받는 것을 의미하며 비연결은 데이터를 전송할 때마다 연결을 다시하여 전송합니다. 즉 데이터 전송할 때마다 3 way-handshake를 수행합니다.

기본적으로 TCP연결지향이 맞습니다. 3 way handshake로 연결하여 ESTABLISHED 상태를 만든 후 데이터를 주고 받고 4 way handshake를 이용하여 연결을 종료합니다. ESTABLISHED에선 연결된 상태를 유지합니다. 1:1 연결이 유지된 상태에서 데이터를 주고받을 수 있습니다.

하지만 HTTP는 초기 모델에서 비연결성으로 만들어 졌습니다. 그러다보니 매번 데이터를 전송할 때마다 3 way handshake가 발생하여 네트워크 레이턴시가 증가합니다.

시간이 흐르면서 HTTP도 발전해왔고 HTTP/1.1에선 Connection: Keep-Alive을 이용하여 커넥션을 끊지않고 연결을 일정시간 유지하므로 연결지향이 됩니다. 하지만 시간이 지나면 커넥션이 끊겨 데이터 전송시 다시 연결을 시도하는 비연결이 됩니다.

· 상태유지

상태 유지의 간단한 예 중 하나가 인증입니다. HTTP의 경우 세션을 유지할 때 redis를 사용하여 관리합니다. 그 이유는 클라이언트가 다른 서버로 요청하게 되면 세션을 가지고 있지 않기 때문에 비로그인 상태로 인식하기 때문입니다. 이렇듯이 HTTP는 자체적으로 상태를 가지고 있지 않습니다. 이를 무상태 프로토콜(stateless)이라고 합니다. 만약, A 서버에서 클라이언트의 세션을 가지고 있었는데 A 서버가 재시작되었다면? 세션은 날아가기 때문에 클라이언트는 로그인 상태가 유지되지 않습니다.

HTTP는 자체적으로 상태를 저장할 수 없기 때문에 쿠키, 세션 그리고 JWT를 이용하여 별도로 클라이언트의 상태를 포함하여 전송하게 됩니다.