소켓 통신이란 무엇일까?
거의 실시간
으로 데이터를 전송하므로, 채팅 애플리케이션과 같은 실시간 응용 프로그램에 적합하다. (완전한 의미의 실시간은 불가능하다. 실시간에 가깝게라는 표현이 더 맞다.)TCP 연결: 클라이언트가 서버에 연결을 시도할 때, 서버는 이 연결을 위한 별도의 소켓을 생성한다. 이 소켓은 데이터의 양방향 통신을 위해 사용된다. 따라서, 동시에 여러 클라이언트가 서버에 연결하면, 서버는 각 클라이언트 연결마다 별도의 소켓을 가지게 된다.
HTTP 요청: HTTP는 TCP 위에서 동작하는 프로토콜이다. HTTP/1.1에서는 한 TCP 연결 위에서 여러 HTTP 요청과 응답을 순차적으로 처리할 수 있다. 따라서, 하나의 TCP 연결 (즉, 하나의 소켓) 위에서 여러 HTTP 요청과 응답이 이루어질 수 있다.
HTTP/2와 같은 최신 프로토콜에서는 하나의 TCP 연결 위에서 여러 요청과 응답을 동시에 처리할 수 있는 다중화 기능도 제공된다. 요약하면, 서버는 동시에 연결된 클라이언트 수만큼의 소켓을 가지고 있다. 그러나 각 소켓은 여러 HTTP 요청과 응답을 처리할 수 있다.
// Node.js의 기본 내장 모듈.
const net = require("net")
// 서버 객체를 반환한다.
const server = net.createServer();
server.listen(3000, () => {
console.log(`Server Listening on port 3000`)
})
이제 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
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")
})
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) 클라이언트 => 서버"))
});
서버에서도 마찬가지로 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가지 정도를 진행해 보려고 한다.
현재 브라우저(Client)에서 Server에 요청하면, 아래와 같이 잘못된 응답이 나온다.
코드를 조금 정리하고 아래처럼 HTTP Protocol을 지킨 Response Message를 응답했다.
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) => {
})
})
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를 다뤄보자. 일단 브라우저 클라이언트 환경이 아닌, client.js, server.js에서 진행하려고 한다.
간단히 request라는 파일을 만들고 HTTP Request Message를 저장했다.
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
일단 간단하게 클라이언트에서 요청해 보고 서버에서 응답받아서 메시지를 확인해 보자.
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)
})
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를 살짝 분석해 볼 필요가 있다.
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())
})
})