Notice
Recent Posts
Recent Comments
Link
«   2024/10   »
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
Archives
Today
Total
관리 메뉴

JustDoEat

Spring,웹소켓만 써서 통신을 해보자 동작원리 까지 아주아주 자세하게! 본문

카테고리 없음

Spring,웹소켓만 써서 통신을 해보자 동작원리 까지 아주아주 자세하게!

kingmusung 2024. 9. 21. 20:40

개요.

stomp라는 서브 프로토콜을 이용해서 웹소켓을 구현을 해보았는데, stomp에 대해 자세하게 공부하기 전 그냥 웹소켓만 써보면 왜 stomp를 쓰는지 더 알 수 있을 거 같아서 그냥 웹소켓만 써서 같은 기능을 구현해 봄.

 

이번 프로젝트를 하면서 웹소켓을 사용할 일이 있는데 프로젝트 시작하기 전 공부한다는 느낌으로 해봄!

 

개인적으로 이론과 지식에 대해서는 어느 정도 숙지를 하였지만, 이걸 코드로 옮겼을 때 어떤 모양이 나오는지 전체적인 그림이 잡히지 않았다..

 

postman이나 크롬 확장 툴 중에 웹소켓을 테스트해 볼 수 있는 툴이 있지만,  이것만으로는 찝찝해서 리엑트+vite로 서버를 올린 후 테스트를 진행하였다.

 

이로 인해 발행/구독 및 브로드케스트 등 의 개념이 머릿속에서 흩어져서 조립이 잘 되지 않았는데 조립이 어느 정도 되었다. 이러한 과정을 상세하게 기록해보려고 한다.

 

"설정파일, 핸들러 쓰는 법만 기록해 둔 게 아닌 내가 진짜 머리 박으면서 느낀 점들을 기록하였기에 글이 길게 나왔습니다.. 끝까지 봐주시면 감사하겠습니다!"

 

회고? 는 맨 마지막에..


WebSocketConfig

웹 소켓 설정 파일이다.

 

파일의 목적과 굴러가는 방식을 차근히 보자.

 

@Configuration에 의해 스프링부트 서버가 올라갈 때 이 부분을 먼저 탐색 후 설정을 도와주는데, 흔히 의존성 주입 할 때도

@Configuration을 사용했을 것이다!

 

같은 맥락으로

 

“설정파일 여기 있어” 하고 알려주는 거다.

@EnableWebSocket
@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		registry.addHandler(new WebSocketHandler(),"/ws").setAllowedOrigins("<http://localhost:5173>");
	}
}

 

@Configuration은 “설정 파일 여기 있어 ~!” 

 

@EnableWebSocket 어노테이션은 “나 서비스에서 WebSocket을 이용할 거야 “라고 알려주는 것이다.

 

이렇게 되면 Spring은 웹소켓을 사용하도록 준비를 알아서 해준다.

 

WebSocketConfig는 사용자 마음대로 이름을 지어도 상관은 없다.

 

다만 주목할 부분은 implement이다.

 

알다시피 implement는 인터페이스의 구현체를 작성하겠다는 말인데 웹소켓을 사용할 때는

위와 같은 인터페이스를 사용해야 한다

 

cmd+n으로 확인을 해보니 구현할 부분은 1개밖에 없다. 좋다!!

 

오버라이딩 해준다.

 

WebSocketHandlerRegistry 타입의 registry를 매개변수로 받아준다.

 


 

"registry의 인자는 누가 넣어줘요? 내가 필요할 때 메서드를 호출하는 건가요?"

 

아니요!

 

맨 위에서 어노테이션을 설명할 때,

스프링이 인식을 한 후 WebSocketHandlerRegistry 타입의 객체를 만든 후 메서드에 대입합니다,

즉 스프링부트 서버가 올라갈 때 이미 위 메서드는 실행이 됩니다.

 

정리하자면

  • 스프링이 서버가 시작될 때 WebSocketHandlerRegistry 객체를 생성.
  • 생성된 객체를 registerWebSocketHandlers 메서드의 매개변수로 넘겨줌.
  • 이 과정은 스프링이 자동으로 처리하므로, 개발자는 메서드만 정의해 두면 됩니다.

 

 

"그러면 스프링이 자동으로 메서드에 객체를 대입만 해주면 뭐 하는가… 들어온 객체를 입맛에 맞게 요리를 해야 하지!"

registry
.addHandler(new WebSocketHandler(),"/ws")
.setAllowedOrigins("<http://localhost:5173>");
//.setAllowedOrigins("*"); 

 

순서대로

  • 핸들러 추가 (핸들러도 별도로 만들어야 함, 이름은 내 마음대로 지은 거임.)
  • 웹소켓 요청을 할 엔드포인트 설정. (보통은 /ws)
  • CORS 설정. (나는 VITE로 할 거라 5173만 열어 놓았는데 편하게 테스트할 때는 와일드카드를 쓰는 것도 괜찮다.)

 

(WebSocketHandler에 관해서는 차차히 설명을 할 테니 기다려주세요!)

 

 


 

" http://localhost:8080/ws 이렇게 요청을 하면 되는 건가?

get, post? 어떻게 요청을 받는다는 거지?

아니지 웹소켓이니까 ws://localhost:8080/ws 이렇겠지.. 근데 어떻게 받는다는 거야 "

 

위와 같은 질문이 머릿속에서 떠나지가 않아서 너무 답답했다.

그래서 간단하게 프런트엔드에서 요청하는 부분을 구현을 했다.

 

프런트 코드를 모르더라도 그냥 흐름을 알면 이해도 향상에 좋을 거 같다

registry
.addHandler(new WebSocketHandler(),"/ws")
.setAllowedOrigins("<http://localhost:5173>");
//.setAllowedOrigins("*"); 

 

 

 

위 사진은 간단히 보자면 프런트엔드에서 웹소켓을 연결하는 로직인데,

기본적으로 웹소켓은 http가 아닌 ws 프로토콜을 사용하기 때문에 http ⇒ ws 가 되었고

맨뒤에 /ws는 바로 좀 전에 핸들러 설정하면서 옆에 적어준 그 /ws 맞다!

 

useEffect(()⇒{웹소켓 로직이 있음.},[]);

 

위 프런트 코드는 이런 느낌인데 

  • useEffect라는 훅 안에 웹소켓 연결 설정, 종료, 전송 등등의 로직이 있다.
  • 쉽게 말하면 위 코드는 사용자가 특정 url의 웹사이트(웹소켓이 이용되는)로 이동을 하게 되면. 화면이 처음 켜질 때 자동으로 useEffect안에 즉 위에 보이는 로직이 실행이 된다.
  • “ws://localhost:8080/ws”라는 url로 “나 웹소켓 쓸 거니까 핸드셰이킹 하자”라는 요청을 보내게 된다.

 


성공적으로 웹소켓 연결이 되었으면 이제 핸들러의 역할이 중요하게 된다.

 

위에서 설명하다만 “new WebSocketHandler()”에 대해 설명을 해보겠다.

 

WebSocketHandler

아직 까지는 양방향 통신을 위한 일종의 전화선만 연결이 되고 전화내용(메시지)을 처리하는 부분은 handler에서 담당하게 된다.

 

 

웹소켓이 성공적으로 연결된 이후에는 서버 쪽의 **핸들러(WebSocketHandler)**가 메시지를 주고받는 중요한 역할을 하게 된다.

 

new WebSocketHandler()는 서버에서 클라이언트로부터 메시지를 받아 처리하거나,

 

서버가 클라이언트에게 메시지를 전송할 때 필요한 로직을 정의하는 부분입니다.

 

핸들러는 클라이언트가 보낸 메시지를 처리하고, 필요하다면 모든 연결된 클라이언트에게 메시지를 다시 브로드캐스트 할 수 있다.

 

핸드셰이킹이 성공하면 클라이언트와 서버는 이 핸들러를 통해 메시지를 송수신하게 된다.

 

WebSocketHandler 클래스에서 handleTextMessage 같은 메서드가 그 역할을 담당하게 되는데 차차히 알아보자.

 

 

이름은 자유지만… 그래도 모두가 알아볼 수 있게 WebSocketHandler라는 이름의 클래스를 생성해 보자.

그 후 TextWebSocketHandler를 상속받아보자.

 

@Component
public class WebSocketHandler extends TextWebSocketHandler {
}

 


" TextWebSocketHandler는 뭔데요.. ㅡㅡ "

 

이 클래스는

웹소켓 세션이 열리거나 닫힐 때,

그리고

텍스트 메시지가 수신될 때

어떻게 처리할지 정의할 수 있는 메서드들을 제공합니다.

즉, 클라이언트와 서버 사이의 텍스트 기반 통신을 담당하게 되는 거죠.

 

이해가 안 되어도 일단 쭉쭉 내려가봅시다!


 

 

WebSocketConfig에서 전화선을 깔아줬다면 WebSocketHandler 에서는 규칙을 정하는 느낌이라고 생각하면 좋을듯하다.

 

부모 클래스(TextWebSocketHandler)가 어떤 메서드를 가지고 있는지 확인을 해보자.

 

이번엔 뭐가 엄청 많은데,

 

우리가 당장 쓸건 3개밖에 없다. 주황색 밑줄 친 거 3개이다.

오버라이딩 해주고.

 

나는 아래와 같이 코드를 작성을 하였다.

@Component
public class WebSocketHandler extends TextWebSocketHandler {
	private final static Set<WebSocketSession> sessions = Collections.synchronizedSet(new HashSet<>());
	// Set은 session들을 저장하기위해 내가 만들어준거임.
	
	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		sessions.add(session); // 새 클라이언트 세션 추가
	}

	@Override
	public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
		// 모든 세션에 메시지 전송
		for (WebSocketSession s : sessions) {
			s.sendMessage(new TextMessage("서버에서 응답: " + message.getPayload()));
		}
	}

	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		sessions.remove(session); // 세션 종료 시 클라이언트 제거
	}
}

 

 

 

잠깐 설명전에 !


WebSocketSession sessions는 무엇인가

 

설명을 이어가기 전에 session에 대해서 설명을 먼저 하겠다.

 

웹소켓 연결이 되었다면 여러 클라이언트가 서비스를 이용할 수 있는데, A, B, C 사용자가 웹소켓에 접속을 하였다면. 서버는 각 사용자를 구분을 해야 할 것이다.

 

또한 A 가 웹소켓을 이용하여 메시지를 보냈다면 실시간으로 B, C에게도 전송이 되어야 할 것인데.

 

이때 B와 C의 세션정보가 없다면 전송(브로드케스트)이 되지 않을 것이다. 위와 같은 이유로 클라이언트의 session을 유지를 하는 것이다.

private final static Set<WebSocketSession> sessions = Collections.synchronizedSet(new HashSet<>());

 

이러한 세션들을 집합(Set) 형태로 클라이언트의 세션정보를 저장하도록 정의를 하였다.


 

 

본론으로 돌아와서 이제 오버라이딩 한 메서드들을 살펴보자.

 

afterConnectionEstablished()

  • 이 메서드는 웹소켓이 최초로 연결되었을 때 실행이 된다.(자동으로)
  • 세션을 위에서 설명을 먼저 했는데, 처음 전화선(웹소켓)이 연결이 되었으면 해당 사용자 정보를 저장을 해야 할 것이다.
  • 웹소켓을 연결한 사용자의 정보(세션)를 유지하기 위해 “sessions.add” 로 클라이언트의 정보를 유지한다

handleTextMessage()

  • 모든 세션에 메시지를 전송하는 역할을 하게 되는데, 세션을 설명했을 때와 마찬가지로 A 가 메시지를 전송을 했고 이 메시지를 B, C가 실시간으로 전송을 받기 위해서는 일종의 중계기가 있어야 하는데, 해당 역할을 담당해 준다.
  • 로직을 보면 for-each 문으로 집합에 있는 세션을 하나씩 빼면서 A가 보낸 message를 B, C한테 전달해주고 있는 것이다.

afterConnectionClosed()

  • 이 메서드는 웹소켓 연결이 끊겼을 때 실행이 된다.(자동으로)

여기까지 했다면 웹소켓 연결은 끝났다.

 

통신이 잘 되는지 확인을 하고 내가 구현을 하면서 어떤 부분에서 이해가 안 되었는지 아래에다 작성해볼예정이다. 나와 같은 성향을 가진 분

 

들이 이 글을 보고 도움을 받았으면 좋겠다.

 


내가  궁금했던 점.

 

REST API처럼 요청을 쏴야 하나..?

 

그래.. 핸들러까지 다 만든 건 알겠는데. 통신은 어떻게 해보는 거야 ;;

 

"axios.get(http://localhost:8080/ws)" 뭐 이렇게 하는 거야..?

 

프런트엔드에서 요청을 어떤 식으로 보내는지를 모르니 이해가 정확히 되지 않았었다.

 

많은 자료들을 참고했을 때는 보통 크롬의 WebSocket 확장프로그램이나, 쌩으로 되어있는 자바스크립트 코드만 있어서 테스트해 보기 힘들었고 기존 방식과 괴리감이 느껴져서 싫었다.

 

그래서 예전 프런트 찍먹 경험을 살려..

 

TS + React + Vite 환경에서 간단하게 코드를 짜서 만들었다.

 

백엔드 관련 글을 쓰는데 뜬금없이 프런트엔드 코드가 있어서 난해할 수도 있지만, 목적은 프런트엔드에서 처음에 웹소켓을 연결하는 과정이 이런 식으로 되기 때문에 WebSocketConfig 작성할 때 왜 “ /ws “ 로 하는 건지 이해가 편했으면 좋을 거 같아서 첨부해 본다.

 

나 같은 경우는 느낌은 알겠는데 찜찜해서 이런 설명글도 있었으면 좋겠다고 생각해서 내가 불편함을 느꼈던 점을 정리한 것이다.

import React, { useEffect, useState } from "react";

const OnlyWebSocketPage: React.FC = () => {
  const [webSocket, setWebSocket] = useState<WebSocket | null>(null);
  const [message, setMessage] = useState<string>("");
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    // WebSocket 연결 설정
    const ws = new WebSocket("ws://localhost:8080/ws");
    ws.onopen = () => {
      console.log("WebSocket 연결됨");
    };

    ws.onmessage = (event) => {
      console.log("Received message:", event.data);
      setMessages((prevMessages) => [...prevMessages, event.data]);
    };

    ws.onclose = () => {
      console.log("WebSocket 연결 끊김");
    };

    setWebSocket(ws);

    return () => {
      ws.close();
    };
  }, []);

  const sendMessage = () => {
    if (webSocket) {
      webSocket.send(message); // 서버로 메시지 전송
      setMessage("");
    }
  };

  return (
    <div>
      <h1>Chat Page</h1>
      <div>
        <h2>Messages:</h2>
        <ul>
          {messages.map((msg, index) => (
            <li key={index}>{msg}</li>
          ))}
        </ul>
      </div>
      <input
        type="text"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        placeholder="Type your message..."
      />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
};

export default OnlyWebSocketPage;


마치며

stomp를 같이 쓰는 정보가 있는 반면에 안 쓰고 그냥 하는 글이랑 같이 봐서 그런가 이해할 때 혼동이 좀 있었다.

 

처음 구현은 stomp를 이용하여 구현했는데 뭐.. 처음에 말한 것처럼 stomp를 안 쓰고 해 봐야 왜 stomp를 쓰는지 알 거 같아서 해보았다.

 

백엔드 개발자를 희망하지만, 얼떨결에 프런트엔드도 찍먹으로 경험을 해봤던 게 이번에 이해도를 높여주는데 진짜 1000% 도움이 되었다.

 

stomp를 사용하면, 통신을 할 때 WebSocketHandler 외에 Controller도 따로 만들어 주는데, 그냥 순수한 웹소켓에서도 이게 가능한지

 

실험을 하다가 가능하길래 신기해서 다음글에 적어 볼 예정이다.

 

추가로 순수 웹소켓만 쓰는건 postman 테스트가 가능하지만 Stomp를 먼저 사용해서 웹소켓을 접했는데

 

Stomp를 사용하면 postman지원이 안되어서 내가 직접 이해하기위해 만들다 보니 이렇게 까지 온거같다. 하~~