프로젝트를 진행하면서 실시간으로 도움요청을 주고 받는 기능이 필요하여 해당 기술(웹소켓)을 사용하게 되었다.
학교에서 해본 프로젝트에서는 웹소켓이 아닌 소켓통신이었기때문에
다소 생소한 경험이되었다.
아직 테스트 전이라서 기본적인 기능 구현에 있어서의 고민 과정을 담아보았다.
요청 값
return
근데 이게 수락이 될때까지 계속 기다리는 건 어떻게 만들지?
→ 웹소켓
import React, { useEffect, useState } from 'react';
import Stomp from 'stompjs';
const UserA = () => {
const [stompClient, setStompClient] = useState(null);
const [helpRequest, setHelpRequest] = useState(null);
const [isAccepted, setIsAccepted] = useState(false);
useEffect(() => {
const socket = new WebSocket('ws://localhost:8080/socket');
const stomp = Stomp.over(socket);
stomp.connect({}, () => {
stomp.subscribe('/topic/request', (message) => {
const helpRequestData = JSON.parse(message.body);
setHelpRequest(helpRequestData);
});
stomp.subscribe('/user/queue/acceptHelp', (message) => {
setIsAccepted(true);
});
});
setStompClient(stomp);
return () => {
if (stomp) {
stomp.disconnect();
}
};
}, []);
const handleRequestHelp = () => {
// 도움 요청 로직
// stompClient.send('/app/requestHelp', {}, JSON.stringify({ userId: 'UserA', message: 'Help me!' }));
};
return (
<div>
<button onClick={handleRequestHelp}>Request Help</button>
{helpRequest ? <p>New Help Request: {helpRequest.message}</p> : null}
{isAccepted ? <p>Your help request has been accepted!</p> : null}
</div>
);
};
export default UserA;
사용자 A가 "Request Help" 버튼을 클릭하면 /app/requestHelp
경로로 도움 요청을 보내고,
도움 요청이 수락되었을 때 /user/queue/acceptHelp
경로로부터 메시지를 수신하여 isAccepted
상태를 업데이트한다. 이렇게 함으로써 사용자 A는 도움 요청의 수락 여부를 계속해서 확인하며 알림을 받을 수 있다.
요청 보낼때는 A의 정보를 같이 보내야함
기존 api 요청 값들과는 다르게 따로 선언해둬서
형식에 맞게 보내야 함
요청 값
return
true라면 a의 정보
a의 위치
false라면 db 값만 변경
import React, { useEffect, useState } from 'react';
import Stomp from 'stompjs';
const HelpRequest = () => {
const [stompClient, setStompClient] = useState(null);
const [helpRequest, setHelpRequest] = useState(null);
useEffect(() => {
const socket = new WebSocket('ws://localhost:8080/socket'); // WebSocket 서버 주소
const stomp = Stomp.over(socket);
stomp.connect({}, () => {
stomp.subscribe('/topic/request', (message) => {
const helpRequestData = JSON.parse(message.body);
setHelpRequest(helpRequestData);
});
});
setStompClient(stomp);
return () => {
if (stomp) {
stomp.disconnect();
}
};
}, []);
const handleAccept = () => {
if (stompClient && helpRequest) {
const helpId = helpRequest.helpId;
// 도움 요청 수락 처리 로직
stompClient.send('/app/acceptHelp/' + helpId, {}, helpId);
}
};
return (
<div>
{helpRequest ? (
<div>
<p>New Help Request: {helpRequest.message}</p>
<button onClick={handleAccept}>Accept</button>
</div>
) : (
<p>No new help requests</p>
)}
</div>
);
};
export default HelpRequest;
acceptHelp
함수는 사용자 B가 도움을 수락할 때 호출되며, 해당 도움 요청의 고유한 helpId 값을 인자로 받습니다. 그리고 해당 helpId 값을 /app/acceptHelp/{helpId}
경로로 전송하여 사용자 A에게 도움 요청 수락 메시지를 보낸다.
사용자 A의 입장에서는 이러한 도움 요청 수락 메시지를 웹 소켓을 통해 받아 처리하도록 설정한 후, 필요한 로직을 추가하면 된다. 이렇게 함으로써 사용자 B가 어떤 도움 요청을 수락하는지 정확하게 식별하고, 사용자 A에게 해당 정보를 실시간으로 알릴 수 있게 된다.
위의 코드에서 handleAccept
함수 내부에서 stompClient.send
를 호출할 때 도움 요청의 helpId
값을 사용하여 경로를 생성하도록 수정되었다. 이제 사용자 A는 도움 요청이 수락될 때 해당 helpId 값을 전송하며, 사용자 B가 수락한 도움 요청을 어떤 것인지 구별할 수 있다.
수락 보낼때 B의 정보 보내줘야한다. - 토큰을 이용해 해석한 유저 정보 보내면 됨
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>6.0.10</version>
</dependency>
TextWebSocketHandler를 상속받아 아래의 메서드를 사용할 수 있다.
Spring을 활용한 웹소켓 요청 처리
- WebSocketHandler 인터페이스를 구현한 핸들러 클래스를 작성해서 구현한다.
void handlerMwssage(WebSocketSession session, WebSocketMessage message)
- 클라이언트로부터 메세지가 도착하면 실행된다.
void afterConnectionEstablished(WebSocketSession session)
- 클라이언트와 연결이 완료되고, 통신할 준비가 되면 실행된다.
void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus)
- 클라이언트와 연결이 종료되면 실행된다.
void handleTransportError(WebSocketSession session, Throwable exception)
- 메세지 전송중 에러가 발생하면 실행된다.
- 웹소켓 요청을 처리할 때 자주 사용하는 클래스
- TextWebSocketHandler
- 텍스트 메세지 전용의 웹소켓핸들러를 구현할 때 사용한다.
- handlerTextMessage(WebSocketSession session, TextMessage message)
- 텍스트 메세지를 받았을때 실행된다.
- BinaryWebSocketHandler
- 바이너리 메세지 전용의 웹소켓핸들러를 구현할 때 사용한다.
- handleBinaryMessage(WebSocketSession session, BinaryMessage message)
- 바이너리 메세지를 받았을 때 실행된다.
최초 웹 소켓 서버에 연결하면, 웹 소켓 서버에 연결된 다른 사용자들에게 접속 여부를 전달해주는 로직을 구현해보자.
채팅방에 이미 들어와있는 사용자에게 신규 멤버가 들어왔다는 것을 알려주는 것이다.
해당 로직을 구현하기 위해서는 기존 접속 사용자의 웹 소켓 세션을 전부 관리하고 있어야 한다.
세션 아이디를 key, 세션을 value로 저장하는 map 자료구조 정의한다.
웹소켓 자체는 잘 동작한다.
이제 이걸 이용해서 api 를 작성해보자.
Spring Boot - WebSocket & JWT & Spring Security 토큰 인증
jwt 전달하는 방법을 찾다보니 이 시점에서 기존의 웹소켓 방식에서 벗어나...
자연스레 STOMP 활용을 해야한다는걸 깨닫게 되었다.....(막막)
STOMP는 HTTP와 비슷하게 frame 기반 프로토콜 command, header, body로 이루어져 있다.
(다른 블로그 긁어옴 출처는 아래 하이퍼링크)
<STOMP frame 구조>
COMMAND
header1:value1
header2:value2
Body^@
스프링이 STOMP 프로토콜을 사용하고 있을 떄의 동작 흐름에 대해 살펴보자
/topic
이라는 경로를 구독하고 있다./topic
을 destination 헤더로 넣어 메시지를 메시지 브로커
를 통해 구독자들에게 곧바로 송신할 수 있다/app
경로로 메시지를 송신할 수 있다.서버가 가공처리가 끝난 데이터를
/topic
이라는 경로를 담아메시지 브로커
에게 전달하면
메시지 브로커
는 전달받은 메시지를/topic
을 구독하는 구독자들에게 최종적으로 전달한다.
package com.websocket;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue","/topic");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/gs-guide-websocket")
.withSockJS();
}
}
configureMessageBroker
메소드는 STOMP에서 사용하는 메시지 브로커를 설정하는 메소드이다.enableSimpleBroker
: 내장 메시지 브로커를 사용하기 위한 메소드이다.파라미터로 지정한 prefix(/queue
또는 /topic
)가 붙은 메시지를 발행할 경우, 메시지 브로커가 이를 처리하게 된다./queue
prefix는 메시지가 1대1로 송신될 때,/topic
prefix는 메시지가 1대다로 브로드캐스팅될 때 사용하는게 컨밴션이다.setApplicationDestinationPrefixes
: 메시지 핸들러로 라우팅되는 prefix(/app
)를 파라미터로 지정할 수 있다.> 메시지 가공 처리가 필요한 경우, 가공 핸들러로 메시지를 라우팅 되도록하는 설정이다.
@Controller
public class ChatController {
@MessageMapping("info")
@SendToUser("/queue/info")
public String info(String message, SimpMessageHeaderAccessor messageHeaderAccessor) {
User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY);
return message;
}
@MessageMapping("chat")
@SendTo("/topic/message")
public String chat(String message, SimpMessageHeaderAccessor messageHeaderAccessor) {
User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY);
if(talker == null) throw new UnAuthenticationException("로그인한 사용자만 채팅에 참여할 수 있습니다.");
return message;
}
@MessageMapping("bye")
@SendTo("/topic/bye")
public User bye(String message) {
User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY);
return talker;
}
}
@MessageMapping 어노테이션에 발행하는 경로를, @SendTo와 @SendToUser 어노테이션에 구독 경로를 작성합니다. 예를 들어, 특정 사용자가 chat 이라는 경로로 메세지를 보내면 /topic/message 라는 토픽을 구독하는 사용자들에게 모두 메세지를 뿌리는 것이다.
여기서 주목할 것은 @SendTo와 @SendToUser 이다. SendTo 는 1 : n 으로 메세지를 뿌릴 때 사용하는 구조이며 보통 경로가 /topic 으로 시작한다. 반면에 SendToUser 는 1 : 1 으로 메세지를 보낼 때 사용하는 구조이며 보통 경로가 /queue 로 시작한다.
registry.enableSimpleBroker("/topic", "/queue");
: 메세지브로커를 등록하는 코드> 보통 /topic 과 /queue 를 사용하는데,
/topic 은 한명이 message 를 발행했을 때 해당 토픽을 구독하고 있는 n명에게 메세지를 뿌려야 하는 경우에 사용한다.
반면에 /queue 는 한명이 message 를 발행했을 때 발행한 한 명에게 다시 정보를 보내는 경우에 사용한다. 저는 두 개의 경우 모두 사용하기 때문에 /topic, /queue 를 모두 등록했다.
registry.setApplicationDestinationPrefixes("/");
: 도착경로에 대한 prefix 를 설정> 예를 들어,
registry.setApplicationDestinationPrefixes("/app"); 이라고 설정해두면 /topic/hello 라는 토픽에 대해 구독을 신청했을 때 실제 경로는 /app/topic/hello 가 되는 것이다.
예를 들어, 여러 사용자가 도움 요청 상태를 확인하고 도움을 수락하는 상황을 생각해보겠습니다. 각 사용자는 자신이 수락하려는 도움 요청의 고유 ID를 알고 있어야 합니다. 이를 위해 다음과 같은 방식으로 구성할 수 있습니다.
/topic/request/{helpRequestId}
경로로 메시지를 보냅니다./topic/request/{helpRequestId}
경로를 구독하여 도움 요청 상태를 확인합니다./app/acceptHelp/{helpRequestId}
경로로 메시지를 보냅니다./topic/request/{helpRequestId}
경로로 다시 보냅니다./topic/request/{helpRequestId}
경로를 통해 확인합니다.이렇게 하면 다수의 사용자가 서로 다른 도움 요청에 대한 정보를 구독하고 상호작용할 수 있게 됩니다. 사용자 B들이 /queue/acceptHelp
경로를 구독할 필요는 없습니다. 대신, 도움 요청의 고유 ID를 활용하여 해당 도움 요청에 대한 정보를 주고받을 수 있습니다.
사용자 A가 요청한 도움 요청의 수락 결과를 받기 위한 경로
: @SendTo("/topic/request/{helpRequestId}")
을 사용하여 사용자 A의 구독 경로인 /topic/request/{helpRequestId}
로 메시지를 보낼 수 있습니다. 이렇게 하면 사용자 A가 특정 도움 요청의 상태 업데이트를 실시간으로 받을 수 있습니다.
사용자 A의 도움 요청 수락 결과를 개별 큐로 보내기
: @SendToUser("/queue/acceptHelp")
은 특정 사용자의 개별 큐로 메시지를 보내는 역할을 합니다. 이 경우, 사용자 A가 도움 요청을 수락하면, 사용자 A의 개별 큐로 결과 메시지를 전송합니다. 다른 사용자들과 별개로 사용자 A만을 대상으로 메시지를 보낼 수 있습니다.
예를 들어, 사용자 A가 도움 요청을 수락하면 도움 요청 수락 결과를 @SendTo("/topic/request/{helpRequestId}")
경로로 모든 구독자에게 전송하면서 동시에 @SendToUser("/queue/acceptHelp")
경로로 사용자 A의 개별 큐로도 결과 메시지를 보낼 수 있습니다. 이렇게 하면 사용자 A는 전체 구독 경로를 통해 모든 사용자와 공유되는 메시지를 받으면서 동시에 개별 큐를 통해 자신에게만 해당 메시지를 받을 수 있게 됩니다.
도움 요청을 성공하면 문자 발송을 해야하기 때문에 해당 api 적용이 필요해서 일단 긁어뒀다 ( 다른 팀원께서 진행함 )
네이버 클라우드 플랫폼 문자 발송 API 사용하기 (Spring Boot)
[Spring Boot] SMS 전송 - NAVER SMS API 연동
(아직 테스트 중이라서 미첨부 합니다.)
-> 테스트 실패해서 다른 방법을 쓰기로 했습니다.
그건 다음 글에서...ㅠ
STOMP와 WebSocket으로 아주 간단한 메시징 시스템 만들기
스프링부트 웹소켓 stomp를 이용한 실시간 알림 구현
Spring Boot - WebSocket & JWT & Spring Security 토큰 인증
[개발일지7] 스프링, 소켓을 이용한 채팅 & 실시간 알림 구현
[Spring] WebSocket sockJS Q&A 실시간 알림 구현하기 (2)
공감하며 읽었습니다. 좋은 글 감사드립니다.