WebSocket을 이용해서 실시간 채팅 구현하기
2024. 12. 24. 15:38ㆍTIL
의존성 추가
더보기
dependencies {
implementation ("org.springframework.boot:spring-boot-starter-websocket")
}
websocketconfig
더보기
package hjp.hjchat.infra.security.jwt.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
import org.slf4j.LoggerFactory
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig(private val jwtWebSocketHandshakeInterceptor: JwtWebSocketHandshakeInterceptor) : WebSocketMessageBrokerConfigurer {
override fun configureMessageBroker(config: MessageBrokerRegistry) {
config.enableSimpleBroker("/topic")
config.setApplicationDestinationPrefixes("/app")
}
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:63342", "http://localhost:8080")
.addInterceptors(jwtWebSocketHandshakeInterceptor)
.withSockJS()
}
}
@EnableWebSocketMessageBroker: WebSocket 브로커 활성화
WebSockeMessageBrokerConfigurer : 해당 인터페이스를 구현함으로써 WebSocket 메세지 브로커 커스터마이징
configureMessageBroker
enableSimpleBroker("/topic"): /topic으로 시작하는 토픽에 메세지를 브로드캐스팅 하도록 설정
setApplicationDestinationPrefixes("/app"): 클라이언트가 요청을 보낼때 사용하는 destination prefix를 /app으로 설정
registerStompEndpoints
addEndpoint("/ws"): WebSocket 연결을 위한 엔드포인트 /ws 로 설정
** .setAllowedOrigins("*"): 참고로 이부분은 명시를 해주는것을 권고하고 있다. 개발 단계에서만 *을 쓸것
Intercepter
WebSocket은 Http 통신이 아니기때문에 요청을 가로채서 jwt토큰 검증을 하는 intercepter가 필요
더보기
package hjp.hjchat.infra.security.jwt.websocket
import hjp.hjchat.infra.security.jwt.JwtAuthenticationToken
import hjp.hjchat.infra.security.jwt.JwtTokenManager
import hjp.hjchat.infra.security.jwt.UserPrincipal
import org.springframework.http.server.ServerHttpRequest
import org.springframework.http.server.ServerHttpResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.socket.WebSocketHandler
import org.springframework.web.socket.server.HandshakeInterceptor
import org.springframework.stereotype.Component
import java.nio.file.AccessDeniedException
@Component
class JwtWebSocketHandshakeInterceptor(
private val jwtTokenManager: JwtTokenManager,
) : HandshakeInterceptor {
override fun beforeHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>
): Boolean {
val queryParams = request.uri.query
val token = queryParams?.substringAfter("token=")?.substringBefore("&")
if (token == null || token.isBlank()) {
throw AccessDeniedException("JWT Token is missing in query parameters")
}
jwtTokenManager.validateToken(token).onSuccess { claims ->
val memberId = claims.body.subject.toLong()
val memberRole = claims.body.get("memberRole", String::class.java)
val userPrincipal = UserPrincipal(
memberId = memberId,
memberRole = setOf(memberRole)
)
val authentication = JwtAuthenticationToken(
userPrincipal = userPrincipal,
details = null
)
SecurityContextHolder.getContext().authentication = authentication
attributes["userPrincipal"] = userPrincipal
}.onFailure {
throw AccessDeniedException("Invalid JWT Token")
}
return true
}
override fun afterHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
exception: Exception?
) {
}
}
웹소켓 연결 확인
http://localhost:8080/ws/info
html파일 작성(로그인과 채팅 기능만 있는 임시 페이지)
더보기
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket Chat</title>
<style>
body {
font-family: Arial, sans-serif;
}
#chatBox {
width: 400px;
height: 300px;
border: 1px solid #ccc;
margin: 20px 0;
padding: 10px;
overflow-y: auto;
}
#inputMessage {
width: 100%;
padding: 10px;
margin-top: 10px;
}
#chatRooms {
margin: 20px 0;
}
#createRoomBtn {
margin-top: 10px;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
</head>
<body>
<h1>WebSocket Chat</h1>
<!-- 로그인 섹션 -->
<div id="loginSection">
<h2>Login</h2>
<input type="text" id="username" placeholder="Username" required />
<input type="password" id="password" placeholder="Password" required />
<button onclick="login()">Login</button>
</div>
<!-- 채팅 섹션 -->
<div id="chatSection" style="display: none;">
<h2>Chat Room</h2>
<div id="chatRooms">
<button id="createRoomBtn" onclick="createChatRoom()">Create Chat Room</button>
<div id="roomList"></div>
</div>
<div id="chatBox"></div>
<input type="text" id="inputMessage" placeholder="Type a message..." />
<button onclick="sendMessage()">Send</button>
</div>
<script>
let ws = null; // WebSocket 객체 전역 선언
let accessToken = ''; // AccessToken 저장
let currentChatRoomId = null; // 현재 채팅방 ID
// 로그인 함수
function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
fetch('http://localhost:8080/api/oauth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
.then(response => {
// 응답 상태 확인
if (!response.ok) {
throw new Error(`로그인 실패: 서버 응답 상태 ${response.status}`);
}
// Authorization 헤더 추출
const token = response.headers.get('Authorization')?.split(' ')[1];
if (!token) {
throw new Error('로그인 실패: Authorization 헤더가 비어있음');
}
accessToken = token; // 토큰 저장
console.log('AccessToken:', accessToken);
// 로그인 성공 후 UI 업데이트
document.getElementById('loginSection').style.display = 'none';
document.getElementById('chatSection').style.display = 'block';
initializeWebSocket(); // WebSocket 초기화
loadChatRooms(); // 채팅방 목록 로드
})
.catch(error => {
console.error('로그인 실패:', error);
alert(error.message);
});
}
// WebSocket 초기화 함수
function initializeWebSocket() {
const socket = new SockJS(`http://localhost:8080/ws?token=${accessToken}`); // Query Parameter로 토큰 전달
ws = Stomp.over(socket);
ws.connect(
{},
function (frame) {
console.log("WebSocket 연결 성공:", frame);
if (currentChatRoomId) {
ws.subscribe(`/topic/chatroom/${currentChatRoomId}`, function (message) {
const receivedMessage = JSON.parse(message.body);
displayMessage(receivedMessage);
});
}
},
function (error) {
console.error("WebSocket 연결 실패:", error);
alert("WebSocket 연결 실패");
}
);
}
// 채팅방 목록 로드 함수
function loadChatRooms() {
fetch('http://localhost:8080/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
query: `query {
getChatRooms {
id
roomName
roomType
}
}`
})
})
.then(response => response.json())
.then(data => {
const roomList = document.getElementById('roomList');
roomList.innerHTML = ''; // 기존 목록 초기화
const rooms = data.data.getChatRooms;
rooms.forEach(room => {
const roomElement = document.createElement('button');
roomElement.innerText = room.roomName;
roomElement.onclick = () => joinChatRoom(room.id);
roomList.appendChild(roomElement);
});
})
.catch(error => {
console.error('채팅방 목록 로드 실패:', error);
alert('채팅방 목록 로드 실패: ' + error.message);
});
}
// 채팅방 입장 함수
function joinChatRoom(roomId) {
currentChatRoomId = roomId;
alert(`"${currentChatRoomId}" 채팅방에 입장했습니다.`);
document.getElementById('chatBox').innerHTML = ''; // 채팅창 초기화
// 기존 구독 해제 및 새로 구독
if (ws) {
ws.subscribe(`/topic/chatroom/${currentChatRoomId}`, function (message) {
const receivedMessage = JSON.parse(message.body);
displayMessage(receivedMessage);
});
}
}
// 메시지 전송 함수
function sendMessage() {
const messageContent = document.getElementById('inputMessage').value;
if (messageContent && currentChatRoomId && ws) {
const message = {
chatRoomId: currentChatRoomId,
content: messageContent,
};
ws.send(`/app/send`, {}, JSON.stringify(message)); // 메시지 전송
document.getElementById('inputMessage').value = ''; // 입력란 초기화
} else {
alert('메시지를 입력하거나 채팅방을 선택해주세요.');
}
}
// 메시지 표시 함수
function displayMessage(message) {
const chatBox = document.getElementById('chatBox');
const messageElement = document.createElement('div');
messageElement.innerText = `[${message.sender}] ${message.content}`;
chatBox.appendChild(messageElement);
chatBox.scrollTop = chatBox.scrollHeight; // 스크롤 하단으로 이동
}
// 채팅방 생성 함수
function createChatRoom() {
const roomName = prompt('채팅방 이름을 입력하세요');
const roomType = prompt('채팅방 유형을 입력하세요 (PUBLIC/PRIVATE)');
fetch('http://localhost:8080/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
query: `mutation {
createChatRoom(roomName: "${roomName}", roomType: "${roomType}") {
id
roomName
roomType
}
}`
})
})
.then(response => response.json())
.then(data => {
const chatRoom = data.data.createChatRoom;
alert(`채팅방 "${chatRoom.roomName}"이 생성되었습니다.`);
loadChatRooms(); // 채팅방 목록 갱신
})
.catch(error => {
console.error('채팅방 생성 실패:', error);
alert('채팅방 생성 실패: ' + error.message);
});
}
</script>
</body>
</html>
101 Switching protocols이 나왔다면 연결이 성공한것이다.
클라이언트 테스트
로그인
쿼리 파라미터로 토큰을 서버로 넘겨준다
'TIL' 카테고리의 다른 글
사용자 친구 추가 기능 구현 (1) | 2024.12.26 |
---|---|
𝒘𝒆𝒃𝒔𝒐𝒄𝒌𝒆𝒕에서 사용자 정보 가져오기 오류 (0) | 2024.12.25 |
채팅방 생성(유저 초대) 구현 (0) | 2024.12.18 |
Sᴘʀɪɴɢ ꜱᴇᴄᴜʀɪᴛʏ와 Sᴘʀɪɴɢ GʀᴀᴘʜQL 통합 (1) | 2024.12.17 |
GraphiQL (0) | 2024.12.16 |