WebSocket을 이용해서 실시간 채팅 구현하기

2024. 12. 24. 15:38TIL

 

의존성 추가

더보기
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이 나왔다면 연결이 성공한것이다.

 

클라이언트 테스트

로그인

 

쿼리 파라미터로 토큰을 서버로 넘겨준다