개발

socket.io 토이플젝으로 맛보았다. - ws

배우겠습니다 2024. 6. 5. 22:19

레포 링크

 

서론

곧 sw마에스트로에서 험난한 기획평가가 끝나 개발을 시작할 예정이다.

프로젝트에 실시간 통신 기술이 사용될 예정이라 미리 맛보고 싶었다.

그래. 이름 들어본 web-socket으로 가자

 

웹소켓

TCP/IP를 통해 서버와 브라우저간 양방향 연결을 유지한 채로 패킷 데이터를 교환하는 프로토콜

브라우저와 서버간의 통신을 "웹"소켓이라고 한다.

ws:// 프로토콜과 TSL layer을 거쳐 송신자 데이터가 암호화되는 wss:// 프로토콜로 나뉜다. https://처럼 후자가 권장된다.

web socket은 hand shake 단계에서 클라이언트는 req를 보내 서버로부터 res를 받은 뒤 data를 송수신하는 과정으로 이루어진다.

 

socket.io

해당 라이브러리 없이도 ws를 통해 web socket 기능을 구현할 수 있다.

하지만 더 편하게 구현하기 위해 해당 라이브러리를 사용했다.

이벤트 기반으로 간단하게 기능을 구현할 수 있다.

socket.on(event,(msg) => {
	// 기능 작성	
});

이외 장점은 socket io 홈페이지에 소개돼있다.

1. 브라우저 및 네트워크 호환성

웹소켓이 지원되지 않는 환경에선 자동으로 http long polling으로 대체된다.

2. 안정성

연결이 끊어지는 경우 클라이언트는 자동으로 재연결 시도한다.

3. 확장성

미들웨어와 클러스터링을 지원한다.

 

socket.io-client

node.js나 react-native와 같은 환경에서 사용하기 위한 라이브러리

https://socket.io/docs/v4/client-api/#manager

manager은 engine.io 클라이언트 인스턴스를 관리해준다. 대표적으로 재연결 로직을 핸들링한다. 대부분의 경우엔 manager를 직접 사용하지 않고 맨오른쪽 socket 인스턴스를 사용한다.

engine.io는 http long polling과 같이 서버에 대한 연결을 설정하게 해준다. 

 

http long polling

polling은 서버와 지속적 커넥션을 지속하기 위한 방법이다.

기본적인 polling 방식은 특정 Interval마다 서버에 요청을 보내 응답을 받는 형식이다. 하지만 메시지가 없어도 서버는 interval마다 요청을 받아 과부하의 위험성이 존재한다.

이를 개선한 long polling은 interval을 사용하지 않는다.

1. 요청받으면 서버는 보낼 메시지가 있을 때까지 연결을 계속 유지한다.

2. 서버가 보낼 메시지가 생기면 응답한다.

3. 그리고 즉시 클라이언트는 다시 요청한다.

 

시작

클라이언트에서 메시지를 보내면 서버가 설정한 대답중 하나를 랜덤하게 응답하고 일정 주기로 서버가 메시지를 보내는 토이 프로젝트를 제작해보자

클라이언트는 5174 port에서 vite-react + socket.io-client 프로젝트

서버는 3000 port에서 node.js + socket.io 프로젝트

로 제작해볼 것이다.

따로 ws는 쓰지 않고 http로 우선 구현해보고 추후 wss로 구현해볼 것이다.

 

What to do

클라이언트는 메시지를 보낼 수 있다.

서버는 미리 설정한 응답중 랜덤한 응답을 보낸다.

일정 랜덤 시간 주기마다 "놀아주세요"를 보낸다. 

 

서버

import { createServer } from "http";
import { Server } from "socket.io";


const server = createServer();
const port = 3000;
const io = new Server(server, {
  cors: {
    origin: "*",
  },
});

1. createServer로 http 서버를 생성한다.

2. http 서버와 같은 포트를 사용할 socket.io server를 생성한다.(server대신 port num을 직접 넣어줘도 된다.)

3. 클라이언트는 다른 포트에서 동작하므로 cors 설정을 해준다. 어차피 배포 안할거니까 * 사용

const stringArray = [
  "당연하지!",
  "싫은데?",
  "고민해볼게",
  "나중에^^",
  "좋은 생각인걸",
];

서버 응답으로 가능한 대답이다.

io.on("connection", (socket) => {
  console.log("a user connected");
  socket.on("client-msg", (msg) => {
    console.log("message: " + msg);
    const randomIndex = Math.floor(Math.random() * stringArray.length);
    socket.emit("server-msg", stringArray[randomIndex]);
  });
  const intervalSend = () => {
    setTimeout(() => {
      socket.emit("server-msg", "놀아주세요");
      intervalSend();
    }, Math.floor(Math.random() * 300000) + 10000);
  };
  intervalSend();
  socket.on("disconnect",(reason)=>{
    console.log("disconnect: ",reason)
  })
});

io.on("connection")에 새로운 연결시 동작할 이벤트를 작성한다.

연결되면 user connected 로그가 찍힌다.

clinent-msg 이벤트는 클라이언트에서 메시지를 보냈을 때 동작한다. 랜덤한 대답을 클라이언트에 emit한다.

10초 + 0~30초 랜덤시간마다 "놀아주세요"를 클라이언트에 emit한다.

disconnect는 socket.io에 예약된 특별한 이벤트이다. 연결이 끊어질 때 동작한다.

server.listen(port, () => {
  console.log(`[server]: Server is running at http://localhost:${port}`);
});

http를 3000번 포트에서 시작하고 로그를 찍는다.

"dev": "nodemon index.ts"

npm run dev로 서버를 동작시킨다. 수정사항을 반영하기 위해 nodemon를 사용하고 typescript로 개발해 ts-node도 사용했다.

 

클라이언트

import { io } from "socket.io-client";

const socket = io("http://localhost:3000");

 

io를 통해 3000번 포트와 연결해 socket 객체를 생성한다.

export default function App() {
  const [msg, setMsg] = useState("");
  const [data, setData] = useState<{ user: "c" | "s"; msg: string }[]>([]);
}

input에 작성할 메시지를 담는 msg 상태와 채팅창에 표시할 데이터를 담는 data 상태를 만든다.

data 원소의 user은 서버인지,클라이언트인지 나타내고 msg는 내용을 나타낸다.

  useEffect(() => {
    socket.on("connect", () => {
      console.log("Connected to the server");
      setSocketOn(true);
    });
  }, []);

페이지가 로드되면 소켓에 연결됐다고 로그를 찍는다.

connect는 라이브러리에 제공되는 예약어다. 연결되면 발생할 이벤트를 작성한다.

연결될 때 마다 이벤트가 발생하므로 내부 코드 성능에 신경써야할 것이다.

  useEffect(() => {

      socket.on("server-msg", (msg) => {
        setData([...data, { user: "s", msg: msg }]);
      });
      return () => {
        socket.off("disconnect-server");
        socket.off("server-msg");
        socket.off("client-msg");
        socket.off("disconnect");
        socket.off("connect");
      };
    
  }, [data]);

서버로부터 emit된 데이터를 받는다. "놀아주세요" 또는 랜덤 대답을 받을 수 있다.

페이지가 언마운트되면 off를 통해 이벤트리스너를 제거한다. off의 두번째 인자로 특정 리스너를 넣을 수 있어 선택적 제거도 지원한다.

          <Form
            onSubmit={(e) => {
              e.preventDefault();
              socket.emit("client-msg", msg);
              setData([...data, { user: "c", msg: msg }]);
            }}
          >
            <Input
              placeholder="메시지 전송"
              onChange={(e) => {
                setMsg(e.target.value);
              }}
              value={msg}
            />
            <Button type="submit">
              <Typography>전송</Typography>
            </Button>
          </Form>

 submit하면 Input의 메시지가 서버로 emit된다. 그럼 서버는 랜덤 대답을 클라이언트로 emit해서 대답이 올 것이다.

채팅창의 경우는 data에 map돌려서 구현하면 된다.

import { Typography, styled } from "@mui/material";

interface PropType {
  data:{ user: "c" | "s"; msg: string }[]
}

export default function MsgShow({ data }: PropType) {
  
  return (
    <Container>
      {data.map((e, i) => (
        <Message user={e.user} key={i}>{e.msg}</Message>
      ))}
    </Container>
  );
}

const Container = styled("div")(() => ({
  overflow: "scroll",
  flex:1,
  backgroundColor: "white",
  padding: "0.5rem",
  display: "flex",
  flexDirection:"column",
  gap: "0.5rem",
  maxWidth: "700px",
}));



const Message = styled(Typography)<{user:string}>(({user,theme})=>({
    alignSelf: user === "s"? "flex-end" : "flex-start",
    backgroundColor: user==="s"? theme.palette.secondary.main : theme.palette.primary.main,
    color: "white",
    borderRadius: "8px",
    width: "300px",
    padding: "0.5rem 0.75rem"
}))

 

네트워크 분석

1. http://localhost:3000/socket.io/?EIO=4&transport=polling&t=foobar&sid=foobar

polling 방식으로 연결을 시도한다. 일정 주기로 반복되지 않으므로 long polling이다.

2. ws://localhost:3000/socket.io/?EIO=4&transport=websocket&sid=foobar

web socket(ws) 프로토콜로 통신한다.

왜냐하면 socket.io는 호환성을 위해 우선 long polling으로 연결한 뒤 ws로 업그레이드하는 방식이기 때문이다.

이는 처음부터 web socket으로 통신하는 WebSocket 객체와 차이있다.