간단하게 TypeScript + React + Webpack 구성하기
Node.js로 WebSocket, TCP, UDP 서버를 만들어서 TCP와 UDP의 특징을 확인해보자
2024-09-08
Explanation
즐거운 주말입니다! (사실, 홈 프로텍터라서 평일과 주말의 차이가 없..)
그래도 주말이니까, 오늘은 집에서 쉬면서 뭘 해볼까 고민하다가 TCP / UDP 서버를 만들고 직접 통신을 해보면서 각 프로토콜의 특징을 한번 간단하게 살펴보려고 합니다!
사실 무려, 4년 전에 “https://falsy.me/tcp-udp-서버-공부-1-node와-c로-tcp-udp-서버-만들기/” 이렇게 시도를 한 적이 있었는데요. 그때는 무지해서 (사실 지금도 크게 다르지 않지만..) 서버만 만들고 특징은 직접 구현 해보지 못했더라고요, 이번엔 간단하게라도 그 차이를 확인할 수 있는 환경을 구현해보려 합니다!
전체 인프라 구성은, 직접 값을 제어할 수 있는 클라이언트(브라우저)와 연결하는 WebSocket 서버 그리고 WebSocket 서버를 TCP 서버, UDP 서버와 연결해서 확인해 볼거에요.
작성된 코드는 깃허브에서 모두 확인하실 수 있습니다.
https://github.com/falsy/blog-post-example/tree/main/websocket-tcp-udp
간단하게 Node.js로 TCP 서버를 만들고 실행해줍니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// tcp-server/index.js const net = require("net") const tcpServer = net.createServer((socket) => { socket.on("data", (data) => { console.log(`TCP 요청: ${data}`) socket.write(`TCP 응답: ${data}`) }) socket.on("close", () => { console.log("TCP 중단") }) }) tcpServer.listen(8000, "localhost", () => { console.log("TCP 서버가 포트 8000에 실행 중입니다.") }) |
1 |
$ node ./tcp-server/index.js |
아주 간단하게 8000번 포트에 서버를 열고 요청이 오면 그 데이터를 그대로 응답하는 코드로 되어 있습니다.
Node.js로 UDP 서버도 간단하게 만들고 실행해줍니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// udp-server/index.js const dgram = require("dgram") const udpServer = dgram.createSocket("udp4") udpServer.on("message", (msg, rinfo) => { console.log(`UDP 요청: ${msg}`) const response = `UDP 응답: ${msg}` udpServer.send(response, rinfo.port, rinfo.address, (err) => { if (err) console.error("UDP 전송 오류:", err) }) }) udpServer.bind(7000, () => { console.log("UDP 서버가 포트 7000에 실행 중입니다.") }) |
1 |
$ node ./udp-server/index.js |
UDP 서버는 7000번 포트에 실행해 주었습니다.
이번에는 WebSocket 서버를 만들어줍니다. WebSocket 서버는 ‘ws’를 설치해 주어야 합니다.
1 |
$ node install ws |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// websocket-server/index.js const WebSocket = require("ws") const net = require("net") const dgram = require("dgram") const wss = new WebSocket.Server({ port: 8080 }) const tcpClient = new net.Socket() tcpClient.connect(8000, "localhost", () => { console.log("TCP 서버와 연결") }) const udpClient = dgram.createSocket("udp4") wss.on("connection", (ws) => { console.log("웹 소캣 연결") ws.on("message", (message) => { console.log(`클라이언트에게 받은 메시지: ${message}`) // TCP 서버에 메시지 전송 tcpClient.write(message) // UDP 서버에 메시지 전송 udpClient.send(message, 7000, "localhost", (err) => { if (err) console.error("UDP 서버에 전송 실패:", err) }) }) }) |
1 |
$ node ./websocket-server/index.js |
짠, 여기서 벌써 한가지 특징이 나왔네요. TCP 통신은 ‘연결 지향 프로토콜’이기 때문에 WebSocket 서버와 TCP Server 가 connect 메서드를 사용하여 연결을 설정해주고 있습니다. 하지만 UDP 서버는 ‘비연결 지향 프로토콜’이기 때문에 따로 연결 설정 과정이 없답니다.
코드는 간단하게 메시지를 수신 받으면 미리 열어둔 TCP 서버와 UDP 서버에 전송하고 있어요.
클라이언트는 간단하게 Webpack, React를 써서 만들었어요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
// client/App.jsx import React, { useEffect, useRef, useState } from "react" export default function App() { const ws = useRef(null) const [message, setMessage] = useState("") const handleChange = (e) => { setMessage(e.target.value) } const sendMessage = () => { ws.current.send(message) setMessage("") } useEffect(() => { ws.current = new WebSocket("ws://localhost:8080") ws.current.onopen = () => { console.log("connected") } ws.current.onmessage = (event) => { console.log("message", event.data) } return () => { if (ws.current) { ws.current.close() } } }, []) return ( <div> <div> <h1>TCP / UDP</h1> </div> <div> <input type="text" placeholder="message" value={message} onChange={handleChange} onKeyPress={(e) => { if (e.key === "Enter") { sendMessage() } }} /> <button onClick={sendMessage}>Send</button> </div> </div> ) } |
짠, 이제 input에 값을 메시지를 입력하고 send 버튼을 누르면 TCP 서버와 UDP 서버에 로그가 찍힌답니다.
저는 “2222” 라고 전송해봤어요.
1 2 |
// UDP 서버가 포트 7000에 실행 중입니다. // UDP 요청: 2222 |
1 2 |
// TCP 서버가 포트 8000에 실행 중입니다. // TCP 요청: 2222 |
이제 본격적으로 TCP와 UDP의 차이를 확인해볼까요?
우선 속도를 확인할 수 있도록 WebSocket 서버에서 TCP와 UDP서버에 메시지를 전송하고 응답받는 속도를 확인해 볼게요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
// websocket-server/index.js const WebSocket = require("ws") const net = require("net") const dgram = require("dgram") const wss = new WebSocket.Server({ port: 8080 }) const tcpClient = new net.Socket() tcpClient.connect(8000, "localhost", () => { console.log("TCP 서버와 연결") }) const udpClient = dgram.createSocket("udp4") let startTime = 0 // TCP 서버로부터 응답 수신 tcpClient.on("data", (data) => { const endTime = Date.now() console.log(`TCP 서버 응답 시간: ${endTime - startTime}ms`) wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(`TCP 서버 응답: ${data}`) } }) }) // UDP 서버로부터 응답 수신 udpClient.on("message", (msg, rinfo) => { const endTime = Date.now() console.log(`UDP 서버 응답 시간: ${endTime - startTime}ms`) wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(`UDP 서버 응답: ${msg}`) } }) }) wss.on("connection", (ws) => { console.log("웹 소캣 연결") ws.on("message", (message) => { console.log(`클라이언트에게 받은 메시지: ${message}`) startTime = Date.now() // TCP 서버에 메시지 전송 tcpClient.write(message) // UDP 서버에 메시지 전송 udpClient.send(message, 7000, "localhost", (err) => { if (err) console.error("UDP 서버에 전송 실패:", err) }) }) }) |
자, 이제 빠르게 메시지를 1, 2, 3 보내봅니다!
짠!
1 2 3 4 5 6 7 8 9 |
// 클라이언트에게 받은 메시지: 1 // TCP 서버 응답 시간: 3ms // UDP 서버 응답 시간: 10ms // 클라이언트에게 받은 메시지: 2 // TCP 서버 응답 시간: 1ms // UDP 서버 응답 시간: 3ms // 클라이언트에게 받은 메시지: 3 // TCP 서버 응답 시간: 1ms // UDP 서버 응답 시간: 4ms |
맙소사. TCP가 UDP보다 빠르네요..
생각해보니까, 일반적으로 UDP가 TCP보다 빠르다고 하는 건 다양한 이유가 있지만, 그중에 TCP는 신뢰성 있는 데이터를 보장하기 위해 데이터 전송 전에 동기화 패킷을 주고 받는 검증 절차(3-Way Handshake)가 있기 때문인데, 지금 연결은 WebSocket관 연결하다보니, 처음에 한번 이 절차를 수행하고 연결 후에는 바로 데이터를 전송하기 때문에 TCP가 더 빠른 결과가 나온 거 같아요.
TCP 연결은 통신마다 다시 연결하도록 코드를 수정하고 다시 테스트 해볼게요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
// websocket-server/index.js const WebSocket = require("ws") const net = require("net") const dgram = require("dgram") const wss = new WebSocket.Server({ port: 8080 }) const udpClient = dgram.createSocket("udp4") let startTime = 0 // UDP 서버로부터 응답 수신 udpClient.on("message", (msg, rinfo) => { const endTime = Date.now() console.log(`UDP 서버 응답 시간: ${endTime - startTime}ms`) wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(`UDP 서버 응답: ${msg}`) } }) }) wss.on("connection", (ws) => { console.log("웹 소캣 연결") ws.on("message", (message) => { console.log(`클라이언트에게 받은 메시지: ${message}`) startTime = Date.now() // TCP 서버에 연결 후 메시지 전송 const tcpClient = new net.Socket() tcpClient.connect(8000, "localhost", () => { tcpClient.write(message) }) // TCP 서버로부터 응답 수신 tcpClient.on("data", (data) => { const endTime = Date.now() console.log(`TCP 서버 응답 시간: ${endTime - startTime}ms`) ws.send(`TCP 서버 응답: ${data}`) // 통신 종료 후 TCP 연결 닫기 tcpClient.end() }) // UDP 서버에 메시지 전송 udpClient.send(message, 7000, "localhost", (err) => { if (err) console.error("UDP 서버에 전송 실패:", err) }) }) }) |
1 2 3 4 5 6 7 8 9 |
// 클라이언트에게 받은 메시지: 1 // UDP 서버 응답 시간: 16ms // TCP 서버 응답 시간: 18ms // 클라이언트에게 받은 메시지: 2 // UDP 서버 응답 시간: 2ms // TCP 서버 응답 시간: 2ms // 클라이언트에게 받은 메시지: 3 // UDP 서버 응답 시간: 5ms // TCP 서버 응답 시간: 7ms |
오! 큰 차이는 아니지만 UDP가 TCP 보다 약간 빨라졌어요. 지금은 아주 데이터가 작고 그리고 로컬 환경에서 TCP는 OS에서 더 많이 최적화가 되어 있어서 그렇고, 실제 서비스에서는 아마도 더 차이가 크게 나겠죠?!
TCP의 또 중요한 특징중에 하나는 패킷의 순서 보장인데, 이 패킷의 순서는 애플리케이션단에서는 확인이 어려울 거 같아서, 대신 UDP의 데이터 손실에 대해서 확인을 해보려고 합니다!
TCP라면, 데이터 전송 중 패킷이 손실이 발생하면 손실된 패킷을 다시 전송해서 무결성을 보장했겠죠?!
이전 응답 속도 테스트랑 같이 구현하려니 코드가 좀 복잡해져서 코드를 분리해서 따로 만들었어요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// image-udp-server/index.js const dgram = require("dgram") const udpServer = dgram.createSocket("udp4") const CHUNK_SIZE = 6 * 1024 // 6KB udpServer.on("message", (message, rinfo) => { let offset = 0 while (offset < message.length) { const chunk = message.slice(offset, offset + CHUNK_SIZE) udpServer.send(chunk, rinfo.port, rinfo.address, (err) => { if (err) { console.error("UDP 전송 오류:", err) } else { console.log(`청크 전송 완료: ${chunk.length} bytes`) } }) offset += CHUNK_SIZE } }) udpServer.bind(7000, () => { console.log("UDP 서버가 포트 7000에 실행 중입니다.") }) |
예전 기억에, MacOS에서 UDP 패킷이 최대 9KB 까지만 가능하다고 본 기억이 있어서 청크의 크기는 적당히 작은 6KB로 짤라서 보내도록 했어요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// image-websocket-server/index.js const WebSocket = require("ws") const dgram = require("dgram") const wss = new WebSocket.Server({ port: 8080 }) const udpClient = dgram.createSocket("udp4") const CHUNK_SIZE = 6 * 1024 // 6KB // UDP 서버로부터 응답 수신 udpClient.on("message", (msg, rinfo) => { wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(msg) } }) }) wss.on("connection", (ws) => { console.log("웹 소캣 연결") ws.on("message", (message) => { console.log("이미지 데이터를 수신했습니다. UDP 서버로 전송합니다.") let offset = 0 while (offset < message.length) { const chunk = message.slice(offset, offset + CHUNK_SIZE) udpClient.send(chunk, 7000, "localhost", (err) => { if (err) console.error("UDP 서버에 전송 실패:", err) }) offset += CHUNK_SIZE } }) }) |
WebSocket 서버에서도 동일하게 UDP 서버로 6KB 씩 나눠서 보내도록 했어요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
// image-client/App.jsx import React, { useEffect, useRef, useState } from "react" export default function App() { const ws = useRef(null) const timeout = useRef(null) const chunks = useRef([]) const [imageFile, setImageFile] = useState(null) const [uploadedImageURL, setUploadedImageURL] = useState(null) const [receivedImageURL, setReceivedImageURL] = useState(null) const handleFileChange = (e) => { const file = e.target.files[0] setImageFile(file) if (file) { const reader = new FileReader() reader.onload = (e) => { const url = URL.createObjectURL(file) setUploadedImageURL(url) } reader.readAsDataURL(file) } } const sendImage = () => { if (imageFile) { const reader = new FileReader() reader.onload = (e) => { const arrayBuffer = e.target.result ws.current.send(arrayBuffer) } reader.readAsArrayBuffer(imageFile) } } useEffect(() => { ws.current = new WebSocket("ws://localhost:8080") ws.current.onopen = () => { console.log("connected") } ws.current.onmessage = (event) => { // 수신한 Blob 청크를 배열에 추가 chunks.current = [...chunks.current, event.data] // 이전 타이머가 존재하면 클리어 if (timeout.current) { clearTimeout(timeout.current) } // 새로운 타이머 설정 (1초 후에 결합 시도) timeout.current = setTimeout(() => { const blob = new Blob(chunks.current, { type: "image/jpeg" }) // 모든 청크를 합침 const url = URL.createObjectURL(blob) console.log("received image url:", url) setReceivedImageURL(url) chunks.current = [] // 청크 배열 초기화 }, 1000) } return () => { if (ws.current) { ws.current.close() } } }, []) return ( <div> <section> <div> <h2>UDP 이미지 업로드</h2> </div> <div> <input type="file" accept="image/*" onChange={handleFileChange} /> <button onClick={sendImage}>Send Image</button> </div> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}> <div> <h2>업로드 이미지:</h2> {uploadedImageURL && ( <img src={uploadedImageURL} style={{ maxWidth: "300px", maxHeight: "300px" }} /> )} </div> <div> <h2>수신 이미지:</h2> {receivedImageURL && ( <img src={receivedImageURL} style={{ maxWidth: "300px", maxHeight: "300px" }} /> )} </div> </div> </section> </div> ) } |
마지막으로 클라이언트 코드입니다.
이미지를 업로드하면, 업로드한 이미지를 FileReader를 사용해서 출력하고 이미지를 WebSocket 서버로 보냅니다. 그리고 응답받은 데이터는 6KB씩 짤려서 오기 때문에, 간단한 구현을 위해서 setTimeout으로 1초 안에 새로운 데이터가 안오면 모든 데이터가 온걸로 간주하고 Blob으로 이미지를 합치도록 했습니다.
바로 테스트를 해보면!
짜잔! 패킷이 손실되서 이미지가 깨져서 돌아오는 것을 확인할 수 있답니다.
분명.. 일요일에 시작했는데, 월요일 아침인 건 안비밀..