Socket communication, Net Module

현준·2023년 8월 31일
2

Node.js

목록 보기
1/3
post-thumbnail

Socket communication (소켓 통신)

소켓 통신이란 무엇일까?

  • 네트워크에서 두 대의 디바이스가 데이터를 직접 주고받기 위한 기술이다.
  • 소켓은 IP 주소와, 포트 번호의 조합으로 구성된다.

소켓 통신의 특징

  1. 점대점 통신: 소켓 통신은 일반적으로 두 대의 기기 간의 직접적인 데이터 교환을 위해 사용된다.
  2. 양방향 통신: 데이터는 양쪽 방향으로 전송될 수 있다. 즉, 클라이언트와 서버 모두 데이터를 보내고 받을 수 있다.
  3. 실시간 통신: 소켓 통신은 거의 실시간으로 데이터를 전송하므로, 채팅 애플리케이션과 같은 실시간 응용 프로그램에 적합하다. (완전한 의미의 실시간은 불가능하다. 실시간에 가깝게라는 표현이 더 맞다.)
  4. TCP와 UDP: 소켓 통신은 주로 TCP(Transmission Control Protocol) 또는 UDP(User Datagram Protocol)를 사용하여 데이터를 전송한다. TCP는 연결 지향적이며 신뢰성 있는 데이터 전송을 보장하는 반면, UDP는 연결을 설정하지 않고 데이터를 전송하므로 빠르지만 신뢰성이 낮을 수 있다.
  5. 포트와 IP 주소: 소켓은 특정 IP 주소와 포트 번호에 바인딩 된다. IP 주소는 네트워크 상의 기기를 식별하는 데 사용되며, 포트 번호는 해당 기기 내의 특정 애플리케이션 또는 서비스를 식별하는 데 사용된다.

TCP와 HTTP의 소켓

  • TCP 연결: 클라이언트가 서버에 연결을 시도할 때, 서버는 이 연결을 위한 별도의 소켓을 생성한다. 이 소켓은 데이터의 양방향 통신을 위해 사용된다. 따라서, 동시에 여러 클라이언트가 서버에 연결하면, 서버는 각 클라이언트 연결마다 별도의 소켓을 가지게 된다.

  • HTTP 요청: HTTP는 TCP 위에서 동작하는 프로토콜이다. HTTP/1.1에서는 한 TCP 연결 위에서 여러 HTTP 요청과 응답을 순차적으로 처리할 수 있다. 따라서, 하나의 TCP 연결 (즉, 하나의 소켓) 위에서 여러 HTTP 요청과 응답이 이루어질 수 있다.

HTTP/2와 같은 최신 프로토콜에서는 하나의 TCP 연결 위에서 여러 요청과 응답을 동시에 처리할 수 있는 다중화 기능도 제공된다. 요약하면, 서버는 동시에 연결된 클라이언트 수만큼의 소켓을 가지고 있다. 그러나 각 소켓은 여러 HTTP 요청과 응답을 처리할 수 있다.

server.js

// Node.js의 기본 내장 모듈.
const net = require("net")


// 서버 객체를 반환한다.
const server = net.createServer();
  • 위의 코드는 서버에 대한 정보를 담은 객체를 생성했을 뿐 서버를 실행한 것은 아니다.
  • 현재 코드를 서버로 사용할 수 있게 Listen 상태로 만들어야 한다.

server.listen(3000, () => {
  	console.log(`Server Listening on port 3000`)
})
  • port를 3000번 port로 지정한다.
  • Listen 상태가 되면 Callback 함수를 실행한다.

이제 Node.js의 이벤트 기반의 아키텍처가 무엇인지를 알 수 있게 될 것이다.

이벤트 처리

connection 이벤트는 3-way handshake에서 클라이언트가 syn를 전달하고, 서버가 syn+ack를 응답한 후, 클라이언트가 ack를 응답하여 서버가 ack까지 받고 나면 발생한다. 즉, TCP 연결이 성립되고 나면 발생한다.

server.on("connection", (socket) => {
    console.log("ESTABLISHED")
    socket.write("서버 => 클라이언트")
})

3-way handshake에 관련해서는 아래의 글을 읽어보길 바란다.
https://velog.io/@hellas4/3-way-handshake

client.js

connect() 메서드는 3-way handshake의 첫 번째 요청인 syn를 보내고, syn+ack를 응답받고, ack를 응답하는 것까지 구현되어 있다. 즉 socket 객체에는 이미 TCP의 3-way handshake를 끝낸 객체가 담긴다.

const net = require("net");

const socket = net.connect({
    port: 3000,
    host: '127.0.0.1', // host: 'localhost'
});

서버클라이언트 마다의 TCP 요청 하나하나마다 별도의 소켓을 생성한다.


connect 메서드로 인해 연결이 수립되면 connect 이벤트가 발생한다. 즉, 마지막으로 ACK 패킷을 전송한 후, TCP 연결이 완전히 수립되었음을 알리기 위해 socket.on("connect") 이벤트 리스너가 발동된다.

socket.on("connect", () => {
    console.log("ESTABLISHED")
})
  • connect(): 이 메서드를 호출하면, Node.js는 대상 서버에 SYN 패킷을 전송하여 연결을 시도한다.
    이 메서드는 SYN, SYN+ACK, ACK의 3-way handshake 과정을 시작한다.
  • "connect" 이벤트 리스너: 이 리스너는 3-way handshake 과정 중 SYN+ACK 패킷을 성공적으로 수신하고, ACK 패킷을 서버에 전송한 후에 발동된다.
    즉, TCP 연결이 완전히 수립된 후에 이 이벤트가 발생한다.
    따라서, connect 메서드는 TCP 연결을 시작하고, "connect" 이벤트 리스너는 TCP 연결이 성공적으로 수립되었을 때 알려주는 역할을 한다.

data 이벤트를 통해 서버에서 데이터를 받을 수 있다.

socket.on("data", (chunk) => {
    console.log(chunk)
    console.log(chunk.toString())
});

이때, 3-way handshake가 이루어지고, 커넥션이 맺어진 후의 Socket을 활용한 통신은 Client의 요청과 상관없이 Server에서도 데이터를 전송할 수 있다. 이때 데이터(chunk)는 Buffer 객체로 오게 된다.

클라이언트에서도 write() 메서드를 사용하면 요청할 수 있다. 클라이언트에서는 connect 상태가 되거나, data를 받은 경우에 write가 가능하다.

socket.on("connect", () => {
    console.log("ESTABLISHED")
    console.log(socket.write("connect) 클라이언트 => 서버"))
})

socket.on("data", (chunk) => {
    console.log(chunk)
    console.log(chunk.toString())
    console.log(socket.write("data) 클라이언트 => 서버"))
});

server.js

서버에서도 마찬가지로 data 이벤트를 활용하여 데이터를 받을 수 있다.

server.on("connection", (socket) => {
    console.log("ESTABLISHED")
    socket.write("서버 => 클라이언트")

    socket.on('data', (chunk) => {
        console.log(chunk.toString())
    })
})

현재 Client에서는 기본적으로 요청과 응답을 마치고 나면 서버가 꺼져야 하지만 프로세스가 계속 돌아가고 있다. 서버 쪽에서 end() 메서드를 사용해 주면 통신이 끝났다는 것을 클라이언트에게 전달하고 클라이언트의 연결이 끊기게 된다.

socket.on('data', (chunk) => {
        console.log(chunk.toString())
        socket.end();
    })

혹은 클라이언트단에서 end() 메서드를 사용해도 된다.

socket.on("data", (chunk) => {
    console.log(chunk)
    console.log(chunk.toString())
    console.log(socket.write("data) 클라이언트 => 서버"))
    socket.end();
});

socket을 종료하지 않는다면 Client <-> Server 간 지속적으로 데이터를 공유할 수 있다.

이제 2가지 정도를 진행해 보려고 한다.

  • HTTP 프로토콜 Request Message, Response Message를 사용하여 브라우저에서 HTML 문서를 띄워보자.
  • Server에서 Request Message를 배열 메서드를 활용하여 조작해 보자.

HTTP Protocol

현재 브라우저(Client)에서 Server에 요청하면, 아래와 같이 잘못된 응답이 나온다.

  • 서버에서는 TCP Protocol로 통신하고 있다.
  • 브라우저는 7계층, Application Layer로써, HTTP Protocol을 응답받아야 브라우저에 표현할 수 있다.

코드를 조금 정리하고 아래처럼 HTTP Protocol을 지킨 Response Message를 응답했다.

server.js

const net = require("net");
const server = net.createServer();

const message = `HTTP/1.1 200 OK
Vary: Origin
Access-Control-Allow-Credentials: true
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Thu, 31 Aug 2023 02:09:17 GMT
ETag: W/"109-18a495a1d6c"
Date: Thu, 31 Aug 2023 02:10:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        Hello world!
</script>
</body>
</html>
`

server.listen(3000, () => {
    console.log(`Server Listening on port 3000`)
})

server.on("connection", (socket) => {
    socket.write(message)
    socket.on('data', (chunk) => {
    })
})

client.js

const net = require("net");

const socket = net.connect({
    port: 3000,
    host: '127.0.0.1', // host: 'localhost'
});

socket.on("connect", () => {

})

socket.on("data", (chunk) => {
    console.log(chunk.toString())
});

이제 브라우저에서 서버에 통신을 보내면 HTTP Protocol을 HTTP Response Message를 응답해 주고, 이를 해석하여 브라우저에서 표현해 준다.

파비콘 근처에 로딩이 돌아가는데 이것은, 서버에서 HTTP Response Message는 전달했지만, 소켓을 닫지 않아 아직 통신이 가능하다는 것을 의미한다. 이때 client.js에서 socket.end()를 하면 여전히 socket은 닫히지 않는다. 왜냐하면 브라우저(Client)와 Node.js에서 구현한 socket(Client)은 다르기 때문이다.

즉, server.js에서 socket을 닫아주어야 한다. 즉, 브라우저 클라이언트로 요청을 하면 client.js 코드는 아무런 영향을 미치지 않는다.

server.on("connection", (socket) => {
    socket.write(message)
    socket.end();
    socket.on('data', (chunk) => {
    })
})

여기서 헷갈리면 안 되는 것은 네트워크를 통하여 전달되는 데이터는 모두 바이너리 데이터이다. 즉 객체, 배열, ... 등은 우리가 프로그래밍 할 때의 자료구조로 사용하는 것이지 네트워크에서는 통용되지 않는다.

HTTP Request Message

이번에는 HTTP Request Message를 다뤄보자. 일단 브라우저 클라이언트 환경이 아닌, client.js, server.js에서 진행하려고 한다.

간단히 request라는 파일을 만들고 HTTP Request Message를 저장했다.

request

GET / HTTP/1.1
Host: 127.0.0.1:3000
Connection: keep-alive
Cache-Control: max-age=0
sec-ch-ua: "Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7

id=john&name=john&age=13

일단 간단하게 클라이언트에서 요청해 보고 서버에서 응답받아서 메시지를 확인해 보자.

client.js

const net = require("net");
const fs = require("fs").promises

const readFileContent = async (filePath) => {
    try {
        const content = await fs.readFile(filePath)
        return content
    } catch (e) {
        throw new Error("파일 읽기 실패")
    }
}

const socket = net.connect({
    port: 3000,
    host: '127.0.0.1', // host: 'localhost'
});

socket.on("connect", async () => {
    const requestMessage = await readFileContent("./request")
    socket.write(requestMessage)
})

server.js

const net = require("net");
const server = net.createServer();

server.listen(3000, () => {
    console.log(`Server Listening on port 3000`)
})

server.on("connection", (socket) => {
    socket.on('data', (chunk) => {
        console.log(chunk.toString())
    })
})

서버 쪽 콘솔에서 메시지가 잘 읽힌다.

단순 스트링인 현재 Request Message를 객체로 만들어보자. 만들어 보기 전, Request Message를 살짝 분석해 볼 필요가 있다.

  • GET / HTTP.1.1: Start Line이라고 한다.
  • 하단의 공백을 기준으로 윗부분은 Request Header, 아랫부분은 Request Body라고 칭한다.
  • Request Body가 존재하지 않을 경우 2칸의 공백이 존재하게 된다.

HTTP Request Message를 분석했을 때 Start Line과, Request Header, Request Body 이 정도가 형태가 다르다. 하여 이것을 구분하여 구현하여야 한다.

const net = require("net");
const server = net.createServer();

server.listen(3000, () => {
    console.log(`Server Listening on port 3000`)
})

server.on("connection", (socket) => {
    socket.on('data', (chunk) => {
        let buffer = Buffer.alloc(0);

        buffer = Buffer.concat([buffer, chunk])

        const headerEndIndex = chunk.indexOf("\n\n");
        const headerBuffer = chunk.slice(0, headerEndIndex)
        const bodyBuffer = chunk.slice(headerEndIndex + 2)

        console.log(headerBuffer.toString())
        console.log("======================")
        console.log(bodyBuffer.toString())

        const headerLine = headerBuffer.toString().split("\n") // 윈도우의 경우 \r\n
        const START_LINE_NAMES = ['method', 'uri', 'version'];

        const startLine = headerLine
            .shift()
            .split(" ")
            .map((value, index) => {
                    return [START_LINE_NAMES[index], value]
                }
            ).reduce((acc, line) => {
                const [key, value] = line
                acc[key] = value
                return acc
            }, {})

        const headers = headerLine.reduce((acc, line) => {
            const [key, value] = line.split(": ")
            acc[key] = value
            return acc;
        }, {});

        console.log(startLine)
        console.log("==================")
        console.log(headers)
        console.log("==================")
        console.log(bodyBuffer.toString())

    })
})




point

  • Listen 상태인 서버에만 요청을 보낼 수 있다.
  • 커넥션이 맺어진 상태에서는 Socket을 통한 양방향 통신이 가능하다.
  • 커넥션은 3-way handshake가 이루어지고 나면 A, B 각각의 운영체제 내부에서 관리하는 TCP 상태 머신에 해당 연결에 대한 상태를 ESTABLISHED로 변경한다. => 논리적으로 연결되어 있는 상태이다.
  • 이후에는 ESTABLISHED 상태인 호스트에게 TCP 요청을 보낸다면 3-way handshake 과정은 진행되지 않는다.
  • Timeout 시간 동안 네트워크 활동이 일어나지 않는다면, 연결이 자동으로 종료된다. (연결의 비활동 상태를 감지)
  • Keep-Alive 킵 얼라이브 패킷을 주기적으로 전송하여 연결을 유지할 수 있다. 킵 얼라이브 패킷에 대한 응답이 없다면 연결이 끊어진 것으로 간주하고 연결을 종료한다.(연결이 활성 상태로 유지되도록 하는 매커니즘)
  • 이러한 것들이 합쳐서 TCP의 효율성, 안정성, 연결의 지속성을 보장한다.
profile
Clean Code, 공식 문서 추구

0개의 댓글