일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 웹소켓
- 검색 기능
- 무한 스크롤
- perspective api
- 초보 개발자
- CI/CD
- google font icon
- 욕설 및 비속어 필터링
- 웹개발
- Github Actions
- 초성 검색
- 카카오 간편 로그인
- Stomp
- 크롬 확장 프로그램
- 네이버 간편 로그인
- 웹 개발
- 웹서비스 배포
- CloudType
- firebase storage
- 1인 개발
- 파일 업로드
- 구글 간편 로그인
- Today
- Total
J2ong 님의 블로그
WebSoket-STOMP (Spring + React) 본문
안녕하세요!
이번 게시글에서는 웹소켓을 통한 실시간 메시지 전송 방법을 공유하려 합니다.
제 프로젝트에서 가장 많은 시간을 할애한 기능입니다.
📌 WebSoket과 STOMP의 이해
우선 WebSoket과 STOMP가 무엇인지에 대한 이해가 필요합니다.
저는 다른 관련 정보들을 읽어보고 제대로 이해하지 않은 채 WebSoket만을 통해 채팅 시스템을 구현했었는데요.
WebSoket만으로 구현했을 때는 사용자가 채팅방에 참여할 때마다 새로운 WebSocket 연결을 관리해야 했고, 특정 방의 메시지만 받아야 하는데 이를 수동으로 필터링해야 해서 코드가 복잡해졌습니다.
그제서야 검색을 통해서 STOMP에 대한걸 접해서 코드를 대폭 수정할 수 밖에 없었습니다. 그로 인해 시간을 많이 소비하게 되었네요.
저처럼 다른 사람들도 시간을 낭비시키지 않기 위해 저는 최대한 쉽고 간단하게 요약만 정리해서 전달해 드리겠습니다.
1. WebSocket이란?
간단하게 말하자면 WebSoket은 클라이언트와 서버 간에 양방향 실시간 통신이 가능한 통신 프로토콜입니다.
2. HTTP와 WebSocket의 차이
- HTTP
- 요청(Request)을 보내야만 응답(Response)을 받을 수 있습니다.
- 실시간 데이터 전송이 어렵습니다.
- 새로고침(F5)해야 최신 데이터 확인 가능합니다.
- 한 번 연결하면 클라이언트와 서버가 실시간으로 데이터를 주고받을 수 있습니다.
- 서버가 클라이언트에게 즉시 데이터를 전송 가능합니다.
- 예: 실시간 채팅, 주식 가격 변동 알림, 게임 서버 통신 등.
HTTP는 요청-응답 방식
WebSocket은 실시간 양방향 통신
3. WebSocket에서 STOMP란?
WebSocket은 기본적으로 텍스트나 바이너리 데이터를 주고받는 단순한 프로토콜이라서 메시지를 구조화하고 여러 클라이언트에게 쉽게 전달하려면 추가적인 메시징 프로토콜이 필요해요.
STOMP(Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 프로토콜로, 채팅방처럼 여러 사용자가 참여하는 메시징 시스템을 쉽게 구현할 수 있도록 도와줍니다.
- WebSocket만 사용하면 클라이언트가 특정 채널(채팅방)에 구독하는 기능을 직접 구현해야 합니다.
- WebSocket + STOMP를 사용하면 메시지를 특정 채널(예: /topic/chatroom/1)에 자동으로 배포할 수 있습니다.
WebSocket은 도로, STOMP는 신호등 역할을 한다고 생각하면 됩니다.
WebSocket만으로도 채팅을 만들 수 있지만, STOMP를 사용하면 더욱 구조적이고 관리하기 편한 메시징 시스템을 만들 수 있습니다.
WebSocket의 보안 문제
WebSocket은 기본적으로 클라이언트와 서버 간의 직접적인 연결을 제공하는데, 몇 가지 보안 취약점이 있습니다.
- 인증(Authentication) 및 권한 관리 부족
- WebSocket 자체에는 내장된 인증 및 권한 관리 기능이 없습니다.
- 즉, 특정 사용자가 특정 채팅방에 접근할 수 있는지 서버에서 직접 검증해야 합니다.
- 메시지 전송 시 암호화 부족 (기본 HTTP 대비 약한 보호)
- WebSocket은 ws:// 프로토콜을 기본적으로 사용하며, 이는 암호화되지 않은 연결입니다.
- 이를 보완하려면 wss://(TLS를 이용한 WebSocket Secure)로 사용해야 합니다.
- 메시지 브로드캐스팅 시 필터링이 어렵다
- WebSocket을 단독으로 사용하면 메시지를 원하는 클라이언트에게만 보내는 로직을 직접 구현해야 합니다.
- 하지만 이 과정에서 적절한 인증 및 권한 관리가 없다면,
- 원래 채팅방에 속하지 않은 사용자가 메시지를 받을 수 있는 문제 발생
- 악의적인 사용자가 임의의 메시지를 다른 클라이언트에게 전송할 수 있는 문제 발생
STOMP를 사용하면 보안이 강화되는 이유
- 세션 기반 인증 및 권한 관리 가능
- STOMP는 WebSocket을 확장하는 프로토콜이므로, 사용자 인증 정보를 메시지 헤더에 포함할 수 있습니다.
- 예를 들어, Authorization: Bearer {토큰}과 같은 방식으로 JWT 토큰을 포함하여 인증 가능.
- Spring Security와 연동하여 특정 사용자가 자신의 채팅방만 구독하도록 제한 가능.
- 메시지 목적지(destination) 기반 필터링
- STOMP는 /topic/chatroom/1과 같은 채널을 구독하는 방식이라
- 특정 유저가 허용된 경로만 구독할 수 있도록 설정 가능
- 예: /user/{userId}/queue/messages와 같이 개인 메시지 큐를 설정하여 다른 사람이 볼 수 없도록 보호
- STOMP는 /topic/chatroom/1과 같은 채널을 구독하는 방식이라
- 전송 메시지 유효성 검증 가능
- WebSocket만 사용할 경우, 클라이언트가 어떤 메시지를 보내든 서버에서 다 받아야 함.
- 하지만 STOMP는 메시지의 형식을 사전에 정의하고, Spring에서 인터셉터를 활용해 비정상적인 메시지를 차단 가능.
- Spring Security와의 연동 용이
- Spring에서 STOMP를 사용하면 WebSocket 핸드셰이크 과정에서 사용자 인증을 필수로 적용 가능.
- 예제 코드:
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
/user/{userId}/queue/messages를 사용하면 각 사용자의 개인 메시지만 받을 수 있도록 제한 가능.
📌 WebSoket-STOMP 구현 (Backend)
WebSocketConfig
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 // WebSocket 메시지 브로커를 활성화
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 사용자 전용 큐의 접두사 설정
config.setUserDestinationPrefix("/사용자");
// 클라이언트가 구독할 수 있는 메시지 브로커 설정
config.enableSimpleBroker("/메시지 구독");
// 클라이언트가 메시지를 보낼 때 사용할 프리픽스 지정
config.setApplicationDestinationPrefixes("/메시지 처리");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket 연결 엔드포인트
registry.addEndpoint("/ws")
.setAllowedOrigins("http://클라이언트 도메인 주소") // 클라이언트의 도메인 허용
.withSockJS();
}
}
-
Spring Boot에서 WebSocket과 STOMP를 활용한 실시간 메시징을 설정하는 클래스입니다.
- setUserDestinationPrefix("/사용자") : 특정 사용자에게 메시지를 전송할 때 사용할 접두사를 설정합니다.
- enableSimpleBroker("/메시지 구독") : 메시지 브로커를 설정하여 클라이언트가 /메시지 구독으로 시작하는 경로를 구독할 수 있도록 합니다.
- setApplicationDestinationPrefixes("/메시지 처리") : 클라이언트가 메시지를 전송할 때 /메시지 처리를 포함한 경로로 보내야 합니다.
- addEndpoint("/ws") : WebSocket 연결을 위한 엔드포인트를 생성합니다.
- setAllowedOrigins("http://클라이언트 도메인 주소") : CORS 정책을 적용하여 특정 도메인에서의 요청을 허용합니다.
- withSockJS() : WebSocket을 지원하지 않는 브라우저에서도 사용할 수 있도록 SockJS를 활성화합니다
Controller
// WebSocket 통신
@MessageMapping("/{roomId}/send")
public void postSendMessage(
@DestinationVariable("roomId") String roomId,
@Payload PostChatMessageRequestDto requestDto,
@AuthenticationPrincipal String email
) {
// 메시지 저장 및 처리
chatService.postSendMessage(roomId, requestDto, email);
// 특정 채팅방의 구독자들에게 메시지 전송
messagingTemplate.convertAndSend("/메시지 구독/chat/" + roomId, requestDto);
}
// 채팅방 메시지 전송 Rest Api
@PostMapping("/{roomId}/send")
public ResponseEntity<? super PostChatMessageResponseDto> postSendMessage(
@RequestBody PostChatMessageRequestDto requestBody,
@PathVariable("roomId") String roomId,
@AuthenticationPrincipal String email
) {
// 메시지 전송
ResponseEntity<? super PostChatMessageResponseDto> response = chatService.postSendMessage(roomId, requestBody, email);
return response;
}
- @MessageMapping("/{roomId}/send") : 클라이언트가 WebSocket을 통해 특정 채팅방에 메시지를 보낼 때 처리됩니다.
- messagingTemplate.convertAndSend("/메시지 구독/chat/" + roomId, requestDto); : 해당 채팅방을 구독하고 있는 모든 사용자에게 메시지를 전달합니다.
- @PostMapping("/{roomId}/send") : HTTP 요청을 통해 메시지를 전송하는 REST API입니다.
Service
@Override
public ResponseEntity<? super PostChatMessageResponseDto> postSendMessage(String roomId, PostChatMessageRequestDto dto, String email) {
ChatMessageEntity chatMessageEntity = null;
try {
// ChatEntity 생성 및 저장
chatMessageEntity = new ChatMessageEntity(
roomId,
dto.getChatMessage()
);
chatMessageEntity.setSenderEmail(email);
chatMessageEntity.setSentDatetime(LocalDateTime.now());
chatMessageRepository.save(chatMessageEntity);
return PostChatMessageResponseDto.success();
} catch (Exception exception) {
exception.printStackTrace();
return ResponseDto.databaseError();
}
}
PostChatMessageRequestDto
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PostChatMessageRequestDto {
private String roomId;
private String senderEmail;
private String chatMessage;
private String sentDatetime;
}
PostChatMessageResponseDto
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import com.example.sns_back.common.ResponseCode;
import com.example.sns_back.common.ResponseMessage;
import com.example.sns_back.dto.response.ResponseDto;
import lombok.Getter;
@Getter
public class PostChatMessageResponseDto extends ResponseDto {
public PostChatMessageResponseDto() {
super(ResponseCode.SUCCESS, ResponseMessage.SUCCESS);
}
public static ResponseEntity<PostChatMessageResponseDto> success() {
PostChatMessageResponseDto result = new PostChatMessageResponseDto();
return ResponseEntity.status(HttpStatus.OK).body(result);
}
}
- List<String> participants는 메시지 전송 시 컨트롤러에서 해당 채팅방을 구독하고 있는 모든 사용자에게 메시지를 전달하기 위해 정의합니다.
📌 WebSoket-STOMP 구현 (Frontend)
WebSocket과 STOMP를 활용하여 실시간 메시징을 처리하는 방법을 설명합니다. 이 코드는 여러 컴포넌트에서 WebSocket을 활용할 수 있도록 구성되었습니다.
※ 이해하기 쉽도록 코드에 주석을 달아놓았습니다. 참고해주세요.
설치
npm install @stomp/stompjs
npm install sockjs-client
1. WebSocket Context 정의
WebSocket 연결을 관리하기 위해 Context API를 활용합니다.
WebSocketContext 인터페이스
interface WebSocketContextType {
client: Client | null; // 현재 활성화된 STOMP 클라이언트 객체
isConnected: boolean; // 웹소켓 연결 상태
connect: () => void; // 웹소켓 연결을 시작하는 함수
disconnect: () => void; // 웹소켓 연결을 종료하는 함수
subscribe: (destination: string, callback: (message: any) => void) => void; // 특정 채널을 구독하는 함수
unsubscribe: (destination: string) => void; // 특정 채널 구독을 취소하는 함수
}
Subscription 인터페이스
interface Subscription {
id: string; // 구독 ID
unsubscribe: () => void; // 구독 해제 함수
}
Subscription 정보 인터페이스
interface SubscriptionInfo {
subscription?: Subscription; // 구독 객체
callback: (message: any) => void; // 수신된 메시지를 처리하는 콜백 함수
}
WebSocket Context 생성
export const WebSocketContext = createContext<WebSocketContextType | null>(null);
2. WebSocket Context Provider 구현
export const WebSocketProvider: React.FC<{
children: React.ReactNode;
autoConnect?: boolean;
}> = ({ children, autoConnect = true }) => {
// 연결 상태
const [isConnected, setIsConnected] = useState(false);
// WebSocket Client 참조 상태
const clientRef = useRef<Client | null>(null);
// 구독 정보 참조 상태
const subscriptionsRef = useRef<Map<string, SubscriptionInfo>>(new Map());
// 재연결 Timeout 참조 상태
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
// websocket 연결
const connect = () => {
if (clientRef.current?.active) return;
const client = new Client({
webSocketFactory: () => new SockJS('http://{백엔드 서버 주소}/ws'), // /ws는 WebSocketConfig에서 설정한 웹소켓 엔드포인트
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
onConnect: () => {
console.log('웹소켓 연결');
setIsConnected(true);
// 기존 구독 복구
subscriptionsRef.current.forEach((info, destination) => {
const subscription = client.subscribe(destination, info.callback);
subscriptionsRef.current.set(destination, { ...info, subscription });
});
},
onWebSocketClose: () => {
console.log('웹소켓 종료');
setIsConnected(false);
subscriptionsRef.current.forEach((info, destination) => {
subscriptionsRef.current.set(destination, { ...info, subscription: undefined });
});
// 자동 재연결 시도
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = setTimeout(() => {
if (!clientRef.current?.active) connect();
}, 5000);
},
onWebSocketError: (error) => {
console.error('웹소켓 연결 실패: ', error);
setIsConnected(false);
}
});
clientRef.current = client;
client.activate();
};
// websocket 연결 해제
const disconnect = () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
clientRef.current?.deactivate();
setIsConnected(false);
};
// 구독
const subscribe = (destination: string, callback: (message: any) => void) => {
if (!clientRef.current?.active) {
subscriptionsRef.current.set(destination, { callback });
connect();
return;
}
unsubscribe(destination);
const subscription = clientRef.current.subscribe(destination, callback);
subscriptionsRef.current.set(destination, {
subscription,
callback
});
};
// 구독 취소
const unsubscribe = (destination: string) => {
const info = subscriptionsRef.current.get(destination);
if (info?.subscription) info.subscription.unsubscribe();
subscriptionsRef.current.delete(destination);
};
// 자동 연결 설정
useEffect(() => {
if (autoConnect) connect();
return () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
disconnect();
};
}, [autoConnect]);
// WebSocket Provider 랜더링
return (
<WebSocketContext.Provider value={{
client: clientRef.current,
isConnected,
connect,
disconnect,
subscribe,
unsubscribe
}}>
{children}
</WebSocketContext.Provider>
);
};
3. WebSocket Context 커스텀 훅
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocket must be used within a WebSocketProvider');
}
return context;
};
4. App.tsx 설정
return (
<Routes>
<Route element={<WebSocketProvider />}>
<Route path={path} element={element} />
</Route>
</Routes>
);
4. WebSocket 사용 예시
// useWebSocket 커스텀 훅 사용
const { client, subscribe, unsubscribe, isConnected } = useWebSocket();
// 메시지 상태
const [message, setMessage] = useState<string>('');
// post chat message response 처리 함수
const postChatMessageResponse = (responseBody: PostChatMessageResponseDto | ResponseDto | null) => {
if (!responseBody) return;
const { code } = responseBody;
if (code === 'DBE') alert('데이터베이스 오류입니다.');
if (code === 'NCR') alert('존재하지 않는 채팅방입니다.');
if (code !== 'SU') return;
}
// 메시지 전송 처리 함수
const sendMessage = useCallback(async () => {
if (!loginUser || !cookies.accessToken || !roomId) return;
if (!message) return;
const chatMessage = {
roomId: roomId
senderEmail: loginUser.email,
chatMessage: message || '',
sentDatetime: new Date().toISOString()
};
// WebSocket 통신
if (client?.active && isConnected) {
// controller의 @MessageMapping("/{chatRoomId}/send")
client.publish({ destination: `/메시지 처리/${roomId}/send`, body:JSON.stringify(chatMessage) });
}
// REST API 통신
postSendChatMessageRequest(chatMessage, roomId, cookies.accessToken).then(postChatMessageResponse)
// 입력 상태 초기화
setMessage('');
}, [client, message, loginUser, cookies.accessToken]);
// 웹소켓 연결
useEffect(() => {
if (!isConnected) return;
const subscriptions = [
{
// controller의 messagingTemplate.convertAndSend("/topic/chat/" + chatRoomId, requestDto)
topic: `/메시지 구독/chat/${roomId}`,
callback: (message: any) => {
const receivedMessage = JSON.parse(message.body);
// 웹소켓을 통해 수신한 메시지
console.log(receivedMessage);
}
}
];
// 구독
subscriptions.forEach(sub => subscribe(sub.topic, sub.callback));
return () => {
// 구독 취소
subscriptions.forEach(sub => unsubscribe(sub.topic));
};
}, [isConnected, chatRoomId]);
📌 WebSoket-STOMP로 구현한 메신저 영상
이제 WebSocket과 STOMP를 활용한 실시간 메시징을 적용할 수 있습니다.
최대한 쉽게 설명한다고 했는데 어땠을지 모르겠네요.
아마 많은 분들이 송신할 때 경로와 수신할 때 경로를 많이 헷갈리실 거 같아요. 저는 이 부분이 많이 헷갈렸습니다.
제 게시물을 보고 좀 더 쉽게 이해가 되셨길 바라면서 이번 포스팅을 마치겠습니다.
오늘도 행복한 하루 되세요!
'개인 프로젝트' 카테고리의 다른 글
React + Spring 배포 (Cloudtype - 배포) (1) | 2025.03.27 |
---|---|
Perspective API - 욕설 및 비속어 필터링 (0) | 2025.03.22 |
구글 간편 로그인 구현 (react-oauth/google 라이브러리) (0) | 2025.03.18 |
네이버 간편 로그인 구현 (react-naver-login 라이브러리) (1) | 2025.03.18 |
카카오 간편 로그인 (react-kakao-login 라이브러리) (1) | 2025.03.17 |