카테고리 없음

WebSocket With Stomp, 동작원리 부터 설정, 실제 겪은 경험담으로 자세하게

kingmusung 2024. 9. 23. 17:32

목차

1. 개요

2. WebSocket+ Stomp 설정파일 작성 및 동작예시

3. WebSocket+ Stomp 브로드캐스팅을 위한 컨트롤러 작성 및 동작예시

      - 발행(publish), 구독(subscribe), 브로드캐스팅(broadcasting) 개념 알고 가기

4. 테스트

      - STOMP 란 무엇인가? 예시로 보기.(STOMP 프로토콜과 HTTP 프로토콜의 비교)

 

개념을 처음에 설명을 안 하고 중간중간에 껴놓은 이유는, 나 같은 경우는 개념만 봤을 때 머릿속에서 개념과 코드의 연관관계가 잘 맺어지지 않아서 이렇게 구성을 하였다.


개요.

이전 게시물에 Stomp 없이 Spring WebSocket만 써서 웹소켓을 구현을 해보았는데,

이번에는 Stomp를 같이 사용 후 같은 서비스를 구현할 예정이다.

 

원래는 Stomp를 사용 후 웹소켓을 구현하였으나, 테스트하기도 번거로워서 너무 찝찝했다.

 

그래서  WebSocket만 써봐서 해보니까 느낀 점이

WebSocket만 써본 후 Stomp를 써봐야 이게 왜 쓰는 건지, 설정을 이렇게 하는 건지 등 이해도 부분에서 차이가 많이 나는 거 같다.

 

물론 나는 코딩을 그렇게 잘하진 않는다 ㅠㅠㅠ 그냥 내가 시간 들여 공부하고 어려웠던 부분에 대해 기록을 남겨놓고 싶고

 

내가 이해하기 힘들었던 부분은 누군가 또한 이해하기 힘들 수 있기에 도움이 되길 바라며..

 

WebScoket만 쓰던 방식과 WebSocket + Stomp를 같이 쓰는 방식과의 차이점. 왜 Stomp가 편한 건지 비교 및 WebSocket + Stomp로 진짜 간단한 웹소켓을 하는 방법을 적어보겠다.

 

기존과 마찬가지고 백엔드 게시물에 무슨 프런트 코드냐 할 수 있지만, 이해를 위해 뇌를 빼고 동작원리를 이해했으면 좋겠어서 뇌 빼놓고 봐주시면 좋을 거 같다.라는 말을 하고 싶습니다 ㅠㅠ

“제가 이해하고 공부한 내용, 이해가 안 되었던 부분을 정리해서 그대로 작성했습니다!”


StompConfig 작성

 

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class StompConfig implements WebSocketMessageBrokerConfigurer {
}

 

먼저 StompConfig는 WebSocketMessageBrokerConfigurer의 구현체라고 생각하면 된다.

 

cmd + n을 눌러 어떤 메서드가 있는지 보자.

 

 

여러 개가 있지만 빨간색으로 밑줄 친 두 개를 사용할 것이다. 웹소켓이 동작하는데 필요한 필수 메서드이고 밑줄이 없는 나머지는 웹소켓에 대한 자세한 설정에 관한 것이다, 예를 들면 버퍼사이즈 조절, 인터셉터등록 등등 있는데 일단 이 부분은 나중에 고도화가 필요할 때 따로 찾아서 공부하면 될 듯하다.

 

일단! 빨간 밑줄 두 개를 오버라이딩 한다!

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class StompConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/ws")   //SockJS 연결 주소
			.setAllowedOrigins("http://localhost:5173","http://localhost:8080")
			.withSockJS(); //버전 낮은 브라우저에서도 적용 가능
		// 주소 : ws://localhost:8080/ws-stomp
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/send");       //클라이언트에서 보낸 메세지를 받을 prefix
		// 프론트단에서 메세지를 보낼때 사용 할 url임
		registry.enableSimpleBroker("/topic","/queue");//해당 주소를 구독하고 있는 클라이언트들에게 메세지 전달
		//프론트단에서 구독이란걸 하는데, 그대 /topic 혹은 /queue로 이렇개 받고 있으면 되는거 하지만 이거는 컨트롤러를 봐야 이해가 됌.
	}


}

 

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

@Configuration을 사용했을 것이다!

 

같은 맥락으로

 

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

 

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

 

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

 

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

 

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

 

이제 메서드에 대한 이해로 넘어가 보자.


registerStompEndPoints()

public void registerStompEndpoints(StompEndpointRegistry registry)

 

클라이언트와 서버 간의 WebSocket 연결을 위한 엔드포인트를 등록하는 메서드이다

 

즉 http => ws 프로토콜로 업그레이드, 핸드셰이킹을 하기 위한 경로를 설정해 주는 부분이다.

registry.addEndpoint("/ws")   //웹소켓 연결을 위한 부분, 꼭 ws가 아니여도 됌 !
.setAllowedOrigins("http://localhost:5173")
//.setAllowedOrigins("*") 로컬에서 개발할떄는 와일드카드를 써도 되지만, 나는 vite에서 테스를 해서 따로 설정을 한거다!
.withSockJS();
//프론트엔드에서 SockJS를 이용한 핸드셰이킹 요청을 받겠다 라고 명시함.

 

. addEndPoint 이 부분에 웹소켓 연결을 위한 엔드포인트를 작성해 주면 된다 그러면 어떻게 쓰이냐!

 

/ws라고 작성은 했는데, 호출은 어떻게 받는 건지 나는 개인적으로 너무 궁금했다.(그래서 만들어봤다..)

 

 

위는 프런트엔드에서 웹소켓 연결을 위해 요청을 보내는 방법이다, 저 뒤에 /ws 가 어떤 놈이냐! 백엔드코드에서 addEndPoint에 설정한 주소이다!.

 

간략하게 프런트코드가 어떤 식으로 웹소켓과 이어지는지 요약을 하자면, useEffect 훅에 의해서 페이지(컴포넌트)가 로딩될 때 즉 사용자가 웹소켓을 사용하는 페이지 -> 채팅방을 누르면 화면이 로딩되면서 자동적으로 실행되게 된다. useEffect안에 웹소켓연결을 위한 코드가 있는 것이다.

 

본론으로 돌아오면 페이지가 로딩되면서

const socket = new SockJS("http://localhost:8080/ws");

 

핸드셰이킹이 된다.


" 웹소켓 요청은 ws://localhost:8000/ws 이렇게 요청해야 하는 거 아니야? "

제목이 곧 답은 맞다,

WebSocket 요청을 ws://localhost:8000/ws와 같은 URL로 보내는 것이 맞지만, 그것은 WebSocket 프로토콜을 명시적으로 사용하려고 할 때의 형식이고,

 

WebSocket을 Stomp와 함께 사용하려면 방식이 조금 다르다.

 

   => 그래서 SockJS는 뭐야

 

STOMP + SockJS: http://로 시작하는 URL을 사용할 수 있으며, 초기에는 HTTP로 연결되었다가 내부적으로 WebSocket으로 업그레이드됩니다. 이 말이 무엇이냐.

 

SockJS를 사용할 때는 브라우저 호환성과 네트워크 문제를 고려하여 HTTP 폴백을 사용하므로, 초기 연결은 HTTP로 시작하지만 WebSocket을 사용할 수 있는 브라우저에서는 ws로 자동으로 업그레이드된다.

 

프런트엔드에 SockJS를 예시를 든 이유는 보편적으로 많이 쓰인다고 해서 예시를 들었다. 그리고 Stomp중개인 내부적으로도 SockJS를 명시적으로 받는 부분이 있기 때문이다.



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

 

아니요!

 

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

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

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

 

정리하자면

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

 

정리를 하자면 registerStompEndPoint()는 웹소켓 연결 요청, 핸드 셰이크 관련된 부분에 대한 설정이다.

 

연결만 되면 무엇하나! A가 보낸 메시지B, C한테 자동적으로 가진 않는다. 이러한 설정을 하는 부분은 아래에 이어서 보겠다.


configureMessageBroker()

public void configureMessageBroker(MessageBrokerRegistry registry)

 

registerStompEndPoint()에서 웹소켓을 위한 전화선을 깔았다면 이 부분에서는

 

A 가 메시지를 보냈는데 B, C에게도 실시간으로 메시지를 보내주고 싶지만. 전화선만 깔아서는 불가능하다.

 

일단 발행과 구독, 브로드 캐스팅에 대해 알고 가야 한다.

 

추가로 브로커에 대한 개념도 있어야 한다.

 

브로커에 대한 개념은 맨 아래 테스트 부분에서 설명을 하겠다, 먼저 발행, 구독, 브로드캐스팅에대해 알아보자.

 

 


 

발행, 구독, 브로드캐스팅에 대해 간단하게 알고 넘어가기.

 

발행

 

발행은 메시지를 특정 주제로 보내는 것을 의미한다는 게 정의로 알고 있는데, 쉽게 풀어서 이해하자면

 

일반적으로 api를 요청하는 예시를 생각해 보자, 예를 들어 프런트엔드에서 get 요청을 보낸다면 서버에서 get요청에 대한 request를 받고 알맞은 response를 리턴해줄 것이다, 여기서 발하는 발행은 "프런트엔드가 백엔드 서버로 요청을 보냈다" 까지가 발행이다.


 

구독

 

구독은 사용자가 특정 주제를 구독하여 해당 주제에서 발행된 메시지를 수신하는 걸 구독이라고 하는데, 쉽세 풀어서 이해하자면

 

발행의 예시에서 "프런트엔드가 백엔드 서버로 요청(request)을 보냈다" 까지가 발행였다면 "백엔드에서 프런트엔드로 응답(response)을 보내는 행위"를 구독이라고 할 수 있다


 

브로드캐스팅

 

브로드캐스팅은 한 사용자가 메시지를 발행했을 때, 그 메시지가 해당 주제를 구독하고 있는 다른 클라이언트들에게 전송되는 것을 말하는데.

 

"프런트엔드가 백엔드 서버로 요청을 보냈다" 까지가 발행

"백엔드에서 프런트엔드로 응답(response)을 보내는 행위를"  구독이라고 할 수 있다.

 

라고 설명을 했다. 정확하게 정리를 하자면 위 예시는 우리가 일반적으로 하는 rest api를 예시로 들었는데, rest api에서는 발행, 구독이라는 용어를 일반적으로 사용하지 않고 발행 => 요청(request) 구독 => 응답(response)라고 한다.

 

즉 A가 서버로 요청을 보내면 서버에서는 요청을 보낸 주체인 A에게 응답을 돌려주는데, 웹소켓에서는 이게 아니다.

 

Rest API에서는 A 가 요청을 보냈으면 A에게 응답이 돌아오지만

 

WebSocket에서는 A가 요청 즉 발행을 했으면 B, C에게 응답이 가는 것이다.

 

그래서 최종적으로 부언 설명을 하자면

 

1. A라는 사용자가 “안녕“이라는 메시지를 채팅방에서 보낸다 (발행에 해당, 위 예시에서는 응답)

 

2. A가 보낸 메시지가 서버로 전달.

 

3. 서버는 이 메시지를 단순히 A에게 응답하는 것이 아니라, 그 채팅방에 있는 모든 사용자들(B, C, D 등)에게 동시에 메시지를 보낸다. 이것이 브로드캐스팅이다.

 


 

registry.setApplicationDestinationPrefixes("/send");
//클라이언트에서 보낸 메세지를 받을 prefix
// 프론트단에서 메세지를 보낼때 사용 할 url임
// 즉 !! 발행을 하는 주소이다.

 

 

발행과 구독, 브로드캐스팅에 대한 설명을 읽고 왔으면 주석에 대한 부분에 크게 의문이 들지 않을 것이다.

 

즉 프런트엔드에서 요청을 보낼 때 요청에 " /send "를 포함하면 "아 메시지를 발행했구나"라고 서버가 인식하도록 하기 위함이다.

 

위 코드는 프런트엔드에서 서버로 발행을 했을 때 메시지를 보내는 코드인데, 코드를 이해할 필요는 없다 단지 빨간 밑줄을 보면

우리가 방금 설정했던 경로 /send 가 들어있음을 볼 수 있을 것이다.

 

저 뒤에 ${roomId}라고 보이는 부분은 지금은 무시해도 된다, 뒤에 천천히 설명을 해주겠다. 그다음으로

registry.enableSimpleBroker("/topic","/queue");
//해당 주소를 구독하고 있는 클라이언트들에게 메세지 전달
//프론트단에서 구독이란걸 하는데, 그대 /topic 혹은 /queue로 이렇개 받고 있으면 되는거 하지만 이거는 컨트롤러를 봐야 이해가 됌.

 

방금 프런트엔드에서 발행을 한 예시를 보았다, 그럼 반대로 프런트엔드단에서 메시지를 전송을 받으려면 구독이라는 걸 해야 한다,

 

이 부분은 " "/topic" "/queue"라는 엔드포인트로 구독을 하면 서버에서 메시지를 보내줄게! "라고 설정하는 부분이다.

 

그럼 구독은 어떻게 하고 있을까

 

프런트엔드에서는 위와 같이 구독을 한다, 코드를 이해하기보다 어떤 느낌으로 구독을 하고 있는지를 중점적으로 봐주면 좋겠다.

 

빨간 밑줄을 보면, 방금 설정한 "/topic"과 "/queue" 중에서 "/topic"을 사용하는 것을 볼 수 있을 것이다.

 

 


" /topic 이랑 /queue는 뭐가 다른 건가요? "

 

일단 주석 부분을 봤듯이, 무엇을 쓰던 웹소켓 연결은 성공적으로 잘된다, 하지만 관습적인 컨벤션 때문에 구분을 한다. 즉 동작을 위해서는 무엇을 써도 상관없고 이름을 바꿔도 상관이 없다는 것이다.

 

/topic은 발행 - 구독 모델에서 사용이 된다, 발행 - 구독 모델은 지금까지 설명했고, 지금 하고 있는 모델이다 그래서 /topic을 사용한 것이다.

 

/queue는 point to point 모델에서 사용한다.

 

간단하게 요약하자면  A가 메시지를 발행하면 단 한 명의 사용자에게만 전달이 되는 것이다. 이 부분은 나중에 다뤄보겠다.


 


MessageController(중계기)

 

이로 인해 웹소켓 사용을 위한 설정은 끝이 났다. 발행 주소, 구독 주소를 설정을 했다면, 중간에 중계기가 존재해야 한다.

 

중계기의 역할은 컨트롤러에서 담당을 한다,

Stomp를 사용하지 않는 웹소켓은 설정파일 + 핸들러만 있으면 별도의 컨트롤러가 없어도 웹소켓 연결이 되지만,

Stomp를 사용하는 WebSocket에서는 중계기(컨트롤러)가 있어야 한다.

 

"브로드 캐스팅을 위한 중계기 컨트롤러를 정의하자"

@RestController
public class MessageSocketController {

	@MessageMapping("/{roomId}") // 즉 얘는 수신기의 역할
	//StompConfig에서 설정한 /send 뒤에 붙음, 그럼 최종 결과물은 /send/{roomId} 가 되겠지 ?
	//일반적인 컨트롤러 클레스에 RequestMapping("/api/vi") 해놓고 매서드에 PostMapping("/create")를 한다고 가정라면
	//최종적 으로 프론트에서 요청하는 url은 /api/v1/create 가 되는거라고 생각하면 된다.
	@SendTo("/topic/{roomId}") // 얘는 발신기의 역할
	// @SendTo("/queue/room/{roomId}") // enableStompBroker에서 /topic과 /queue를 설정을 해둬서 둘중 하나 골라써야함, 이건 컨벤션에 불과하니 참고~
	public ResponseMessage enter(@DestinationVariable("roomId") Long roomId, RequestMessage message){
	return responseMessage;
	};
}

 

@MessageMapping부터 살펴보자면, "/{roomId}"를 받고 있는데, 우리가 REST API 개발을 할 때

클래스 위에 RequestMapping을 해주고 매써드 안에 GetMapping PostMapping을 해주는 거랑 비슷하다고 보면 된다.

 

즉 위에 StompConfig에서 프런트엔드에서 발행을 할 때 "/send"라는 url을 붙이면 된다고 했다, 그거에 덧붙여서 뒤에 식별을 위한 매개변수가 필요할 수도 있다, 그 부분을 담당한다.

 

정리를 하자면, 프런트앤드에서 발행을 하면 @MessageMapping 어노테이션에 의해 아래 enter라는 매더스가 실행이 되는 것이다.

 

 

 

위에서 설명할 때는 /send만 설명을 했는데, 그 뒤에 ${roomId}가 붙는 이유는 나중에 설명한다는 이유가 컨트롤러 즉 중계기와 연관이 있기 때문이다.

 

@SendTo는 발신기의 역할이다, @MessageMapping에 의해 메시지를 수신했다면, 그 메시지를 구독자들에게 보내주어야 한다,

 

즉 SendTo 안에 들어가는 주소는 구독자들을 위한 주소인 것이다.

 


테스트 및 STOMP가 무엇인지 테스트 결과로 확인하자.

 

 

 

웹소켓이 잘 연결이 되었고, 구독 또한 잘 되고 있음을 볼 수 있다.

 

 

안녕이라고 전송을 하면

 

위와 같은 로그가 뜨는데, 파란색, 빨간색으로 줄을 친 부분은 STOMP프로토콜의 생김새이다.

 

즉 STOMP를 알아야 의미가 있다고 생각한다.


"STOMP가 뭔데요 그래서!!! "

STOMP 프로토콜은 WebSocket을 편하게 쓰게 해주는 프로토콜인데, 솔직히 STOMP만 써봤다면 왜? 편한지 이해하기 힘들 수 있다.

둘 간의 비교는 다음 게시글에 이어 갈 예정이고, 다시 본론으로!

 

STOMP는 발행(Publish), 구독(Subscribe)의 개념을 사용하는 프로토콜이다.

=> 위에서 주야장천 말해서 이해가 잘 되셨을 거라 생각합니다!

 

STOMP 프로토콜은 명령, 헤더, 메시지로 구성되어 있다.



"HTTP 프로토콜이랑 STOMP프로토콜이랑 구조적으로 다른 게 뭐죠?"

HTTP 프로토콜이 일반적으로 많이 보는 프로토콜인데, 요청라인, 헤더, 본문으로 구성되어 있다.

예를 들어

" localhost:8080/example "이라는 도메인으로 POST 요청을 한다고 할 시.

요청본문에는 {"content":"안녕"} 을 넣어보겠다.

 

이 경우 서버로 날아가는 HTTP프로토콜은 아래와 같이 생겼을 것이다.

 

POST /example HTTP/1.1   // => 요청라인 (request line)
Host: localhost:8080	 	//	----------------- 헤더 시작
Content-Type: application/json
Content-Length: 15		 	//	----------------- 헤더 끝

{"content":"안녕"}		   //  ----------------- 요청본문(request body)

 

잠깐 ! 여기서 위 사진을 잠시봐보자. 뭔가 비슷한 느낌이 있다.

 

STOMP 프로토콜은, 명령, 헤더, 메시지로 이루어 진다고 했다, 그럼 위 로그에서 명령 헤더 메시지가 어느 부분인지 직감적으로 느낄 수 있을것이다.

 

SEND     //"명령"
destination:/publish/1 //"헤더", 백엔드 서버에서 설정한 주소를 가르키고있음, 목적지 !!
content-length:37

{"content":"안녕","sender":"User1"}// "메시지", HTTP의 본문과 유사함!
MESSAGE //명령, Message 명령은 Send의 반대 Recive라고 생각하면 된다.
destination:/topic/1 // 이 주소로 Recive
content-type:application/json
subscription:sub-0
message-id:jrbczj3y-9
content-length:37

{"sender":"User1","content":"안녕"} // 메시지

 

 

사실 우리는 STOMP프로토콜은 직접적으로 만진적이 없으나 알아서 설정이 되있다,

 

STOMP를 사용하지 않은 WebSocket은 위와같은 구조가 아닌

 

진짜 Message, 메시지 자체만 달랑 가는 모습을 보인다, 메시지의 도착지, 수신지, 추가적인 정보를 필요하다면 수동으로 입력을해야한다.



"STOMP는 프로토콜이지 브로커가 아니다"

STOMP는 프로토콜이지, 브로커가 아니다.

 

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/publish","/room");
		registry.enableSimpleBroker("/topic","/queue");
	}

 

다시 StompConfig를 살펴보면 위 메서드에는 MessageBrokerRegistry 타입의 인자가 들어간다.

 

위에서는 발행, 구독을 하는 주소이다. 라고 설명을 마쳤지만. 조금 더 정확히 말 하자면

 

브로커는 말 그대로 중계인 이라는 뜻이다, 브로커 한 씨만 봐도 보석의 원석을 주면 연구보고서를 준다고 중간에서 중계를 해주고있다.

 

STOMP라는 형식의 프로토콜을 사용자가 "발행"을 하면 "구독"을 한 사람에게 전달을 해주는 역할을 브로커가 담당한다.

 

STOMP는 그냥 프로토콜이다.

 

브로커 한씨가를 머릿속에 떠올려보자

 

"/publish, /room 이라는 엔드포인트에 메시지가 쌓였군 킄킄킄... 누구한테, 어디로 전달해야하지.. 가만 보자.."

 

"아, /topic /queue 라는 주소로 가면 되겠군, 근데 정확한 주소는 어디인거야"

 

"컨트롤러에 @SendTo 어노테이션에 있는 엔드포인트를 보고 정확한 주소를 찾아가면 되겠군"

 

최종적으로 브로커는 기본적으로 In-Memory 브로커를 사용한다.


마무리

이글로, STOMP를 사용해서 WebSocket을 구축하는 방법을 다 적어보았다.

 

구독,발행 개념, STOMP의 개념을 그냥 이론만 봤을때는 아~ 이런느낌이구나만 알고, 알맹이가 채워지는 느낌이 없었으나 직접 해보면서 보니까 머리에 쏙쏙 잘 박히는거 같다, 내가 이해 한 타임라인을 그대로 적어봤는데 이미 지식이 나보다 뛰어난 대부분의 사람들에게는 구구절절한 느낌이 들 수도 있겠다는 생각이 든다.

 

하지만! 누군가 도움을 받길 바라며 ...! 끄읏