1. 서론
국비에 다니는 친구가 프로젝트에서 스프링과 리액트 간에 실시간 통신을 위해 소켓을 사용해야 하는데 잘 모른다고 저한테 가르쳐달라고 하여 급하게 공부하였고 해당 내용을 블로그에 작성하려고 합니다.
2. WebSocket, SockJS, STOMP란?
WebSocket
- WebSocket은 브라우저와 서버 간의 양방향 통신을 지원하는 프로토콜입니다. HTTP 요청과는 달리, WebSocket은 연결이 한 번 설정되면 클라이언트와 서버 간에 데이터를 자유롭게 주고받을 수 있습니다.
SockJS
- SockJS는 WebSocket을 지원하지 않는 브라우저 환경에서도 WebSocket처럼 동작할 수 있도록 해주는 폴리필 라이브러리입니다.
- WebSocket 연결이 실패하면 HTTP 기반의 다른 메커니즘(Long Polling 등)을 사용하여 비슷한 동작을 제공합니다.
STOMP
- STOMP는 WebSocket 상단에서 동작하는 간단한 텍스트 기반 프로토콜입니다.
- Pub/Sub 구조를 지원하여 클라이언트가 특정 주제를 구독하고, 서버가 해당 주제로 메시지를 발행할 수 있게 해줍니다.
3. Spring에서 WebSocket 서버 구현
WebSocket 설정 클래스
Spring에서는 @EnableWebSocketMessageBroker를 사용하여 WebSocket 메시징을 설정합니다. 아래 코드는 WebSocket 설정 클래스입니다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 클라이언트가 구독하는 엔드포인트 설정
config.enableSimpleBroker("/topic", "/queue");
// 클라이언트가 메시지를 보낼 때 사용하는 prefix 설정
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket 연결 엔드포인트 설정
registry.addEndpoint("/ws") // 클라이언트는 ws://localhost:8080/ws로 연결
.setAllowedOriginPatterns("*") // CORS 허용
.withSockJS() // SockJS를 사용하여 WebSocket이 지원되지 않는 환경에서도 작동
;
}
}
설명
- enableSimpleBroker("/topic", "/queue"): 클라이언트가 메시지를 받을 때 사용하는 경로를 정의합니다. 예: /topic/public
- setApplicationDestinationPrefixes("/app"): 클라이언트가 메시지를 보낼 때 사용하는 prefix를 정의합니다. 예: /app/chat/send
- addEndpoint("/ws"): WebSocket 연결 엔드포인트입니다. SockJS를 추가로 지원합니다.
WebSocket 메시지 컨트롤러
다음은 채팅 메시지를 처리하는 Spring 컨트롤러입니다.
@Controller
public class ChattingController {
@MessageMapping("/chat/send") // 클라이언트가 메시지를 보낼 때 사용하는 경로 (/app/chat/send)
@SendTo("/topic/public") // 클라이언트가 구독하는 주제 (/topic/public)
public SocketMessageDto sendMessage(SocketMessageDto socketMessageDto) {
System.out.println("Received message: " + socketMessageDto);
return socketMessageDto; // 모든 구독자에게 메시지 전달
}
}
설명
- @MessageMapping("/chat/send"): 클라이언트가 메시지를 전송할 때 사용하는 경로입니다.
- @SendTo("/topic/public"): 이 경로를 구독한 모든 클라이언트에게 메시지를 전송합니다.
- @DestinationVariable: 이 어노테이션을 사용하여 @PathVariable과 같은 동적주소로로 설정 가능합니다.
4. React 클라이언트에서 WebSocket 구현
StompProvider.jsx 작성
React에서는 @stomp/stompjs와 sockjs-client를 사용하여 WebSocket 연결을 관리합니다. 아래는 context api를 활용하여 provide로 감싼 영역에서는 1개의 connection만 갖게하는 코드입니다
import { Stomp } from "@stomp/stompjs";
import SockJS from "sockjs-client";
// Context 생성
const StompContext = createContext(null);
export function StompProvider({
children,
brokerURL,
connectHeaders,
onConnectCallback,
onDisconnectCallback,
onCloseCallback,
onErrorCallback,
reconnectDelay = 5000,
}) {
const stompClientRef = useRef(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
// brokerURL에 소켓 엔드포인트를 적어줍니다. 예: http://localhost:8080/ws
// 아래처럼 SockJS 인스턴스를 생성하도록 webSocketFactory에 전달
const client = new Client({
// brokerURL: "스프링의 withSockJS가 아닐때 사용 ws://",
webSocketFactory: () => new SockJS(brokerURL), // SockJS일때 사용
connectHeaders,
debug: (str) => {
console.log("[STOMP DEBUG]:", str);
},
reconnectDelay,
onConnect: () => {
setIsConnected(true);
console.log("setIsConnected(true);");
onConnectCallback && onConnectCallback();
},
onStompError: (frame) => {
console.error("Broker reported error: " + frame.headers["message"]);
console.error("Additional details: " + frame.body);
onErrorCallback && onErrorCallback(frame);
},
onDisconnect: () => {
setIsConnected(false);
onDisconnectCallback && onDisconnectCallback();
},
onWebSocketClose: () => {
setIsConnected(false);
onCloseCallback && onCloseCallback();
},
});
client.activate();
stompClientRef.current = client;
return () => {
if (stompClientRef.current) {
stompClientRef.current.deactivate();
}
};
}, [
brokerURL,
connectHeaders,
onConnectCallback,
onDisconnectCallback,
onCloseCallback,
onErrorCallback,
reconnectDelay,
]);
const subscribe = (destination, callback, headers = {}) => {
if (isConnected && stompClientRef.current) {
const subscription = stompClientRef.current.subscribe(
destination,
callback,
headers
);
return () => subscription.unsubscribe();
} else {
console.warn("STOMP not connected, cannot subscribe yet.");
return () => {};
}
};
const sendMessage = (destination, body = {}, headers = {}) => {
if (isConnected && stompClientRef.current) {
stompClientRef.current.publish({
destination,
body: JSON.stringify(body),
headers,
});
} else {
console.warn("STOMP not connected, cannot send message now.");
}
};
return (
<StompContext.Provider value={{ isConnected, subscribe, sendMessage }}>
{children}
</StompContext.Provider>
);
}
StompProvider.propTypes = {
children: PropTypes.node.isRequired,
brokerURL: PropTypes.string.isRequired,
connectHeaders: PropTypes.object,
onConnectCallback: PropTypes.func,
onDisconnectCallback: PropTypes.func,
onCloseCallback: PropTypes.func,
onErrorCallback: PropTypes.func,
reconnectDelay: PropTypes.number,
};
// Context를 쉽게 사용하기 위한 커스텀 훅
export function useStomp() {
const context = useContext(StompContext);
if (!context) {
throw new Error("useStomp must be used within a StompProvider");
}
return context;
}
StompProvider 사용
main.js에서 url을 등록하여 설정합니다.
<StompProvider
brokerURL="http://localhost:8080/ws"
reconnectDelay={5000}
>
<App />
</StompProvider>
구독과 발행은 다음과 같이 사용할 수 있습니다.
const { isConnected, subscribe, sendMessage } = useStomp();
// 구독
useEffect(() => {
let unsubscribe = () => {};
console.log("isConnected", isConnected);
if (isConnected) {
unsubscribe = subscribe("/topic/public", (message) => {
const parsed = JSON.parse(message.body);
console.log("Received message:", parsed);
setMessages((prev) => [...prev, parsed]);
});
}
return () => unsubscribe();
}, [isConnected, subscribe]);
// 발행
<button
type="button"
className={styles.sendButton}
onClick={() => {
sendMessage("/app/chat/send", {
username,
message: messageInput,
});
setMessageInput("");
}}
>
Send
</button>
5. 동작 확인
6. 결론
이 글에서는 Spring과 React를 사용해 WebSocket 기반의 실시간 채팅 기능을 구현하는 방법을 다뤘습니다.
Stomp를 사용하여 손쉽게 소켓통신을 사용할 수 있었습니다. 다음에 소켓을 사용해야하는 경우가 있다면 이번 코드를 바탕으로 살을 덧붙여 사용해야겠습니다. 감사합니다.