고래씌

[SpringBoot] 2-4. React를 스프링부트에 연동(메세지 추가) 본문

Server/SpringBoot

[SpringBoot] 2-4. React를 스프링부트에 연동(메세지 추가)

고래씌 2024. 2. 6. 16:16

1. 메세지 추가

 
① 웹소켓 작업을 위해 새터미널을 열고 다음과 같이 설치 진행

 
 
② StompConfig.java 클래스 파일 생성

 

Stomp

- MessageBroker방식 처리

- publih 발행 / subscribe 구독 패턴

   - 특정 url을 '구독'하는 사용자들에게 메세지를 '발행'해줌

 

shift+alt+ S 단축키를 이용해 Override/Implements Method...클릭후 아래 메소드 추가

 
▶ StompConfig.java

package com.kh.api.model.websocket.config;

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;


/*
 * Stomp
 * - MessageBroker방식 처리
 * - publih 발행 / subscribe 구독 패턴
 *   - 특정 url을 '구독'하는 사용자들에게 메세지를 '발행'해줌
 *   
 */

@Configuration
@EnableWebSocketMessageBroker  // 메시지를 대신 전달해주는 중개인
public class StompConfig implements WebSocketMessageBrokerConfigurer {

	
	/*
	 * 클라이언트에서 웹소켓 서버로 연결요청을 보낼 endPoint설정
	 * 
	 * http://localhost:8084/stompServer로 요청시 웹소켓 객체 생성
	 */
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry
		.addEndpoint("/stompServer")
		.setAllowedOrigins("http://localhost:3000")  // 프록시 설정을 위해서 설정
		.withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		
		// /chat으로 시작하는 url이 발행가능하도록 설정
		registry.enableSimpleBroker("/chat");
		
		// 접두어 자동 추가 기능
		registry.setApplicationDestinationPrefixes("/chat");  // 구독 url에 /chat을 자동으로 붙임
	}
	
	
}

 
 
③ SompContrller.java 클래스 파일 생성

 
 
▶ StompController.java

package com.kh.api.model.websocket.controller;


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;

import com.kh.api.model.service.ChatService;
import com.kh.api.model.vo.ChatMessage;

@Controller
public class StompController {
	
	@Autowired
	private ChatService service;
	
	private final Logger logger = LoggerFactory.getLogger(StompController.class);
	
	
	// 채팅방에 메세지 추가기능
	// http:~~/api/chat/sendMessage/chatRoomNo/{chatRoomNo}
	@MessageMapping("/sendMessage/chatRoomNo/{chatRoomNo}")  // 코드를 간결하게 쓸수있음
	@SendTo("/chat/chatRoomNo/{chatRoomNo}/message")
	public ChatMessage insertChatMessage(ChatMessage chatMessage) {
		
		// 1) db에 등록후
		logger.info("{chatMessage ?? {}", chatMessage);
		service.insertChatMessage(chatMessage);
		
		// 2) 같은방 사용자에게 다시 전달
		return chatMessage;
	}
}

 
 
 
▶ ChattingRoom.js
 
- 웹소켓 연결을 위해 useState 생성

 
- useEffect(() => {} 함수에 웹소켓을 연결할 수 있는 함수와 웹소켓 객체 생성
- 객체와 연결
=> stomp 같은 경우 웹서버와 통신을 할 때 frame단위로 동작을 함. frame를 메세지를 교환하는 틀이라고 생각할 것
=> 구독 url 설정
=> 내가 구독한 url로 데이터가 전달이 되면 실시간으로 감지하고 콜백함수를 실행함

 

    useEffect(() => {

        // 웹소켓 연결할 수 있는 함수
        // const createWebsocket = () => new SockJs("http://localhost:3000/api/stompServer");
        const createWebsocket = () => new SockJs("http://localhost:8084/api/stompServer");  // 프록시 설정


        // 웹소켓 객체 생성은 stomp가 주관할 예정
        const stompClient = Stomp.over(createWebsocket);

        stompClient.connect({}, (frame) => {
            console.log(frame);
            // stomp 같은 경우 웹서버와 통신을 할 때 frame단위로 동작을 함. frame를 메세지를 교환하는 틀이라고 생각할 것

            // 구독 url 설정
            // 내가 구독한 url로 데이터가 전달이 되면 실시간으로 감지하고 콜백함수를 실행함
            stompClient.subscribe(`/chat/chatRoomNo/${chatRoomNo}/message`, async(frame) => {
                console.log(frame.body); // JSON형태의 데이터. 가공처리는 직접해줘야함
                let jsonMessage = frame.body;
                let parsedMessage = await JSON.parse(jsonMessage);

                // 의존성배열이 비어있는 useEffect내부에서 state값은 항상 "초기 랜더링시의 값"만을 가지고있다.
                setChatMessages((preState) => [...preState, parsedMessage])  // 깊은복사
            })

        });  // 2번째 매개변수 : 연결이 완료되었을때 실행시킬 콜백함수 지정

        setWebSocket(stompClient); // 웹소켓은 stompClient로 작업할 수 있도록 지정


        // 1) CHAT_ROOM_JOIN 테이블에 참여자정보 추가
        let chatRoomJoin = {
            userNo : user.userNo,
            chatRoomNo,
            userStatus : 1
        }


        axios
        .post("/api/joinChatRoom", chatRoomJoin)
        .then((res) => {
            console.log('참여완료');
        })


        // 2) 채팅방 메세지 목록 가져오기
        axios
        .get("/api/chatMessage/chatRoomNo/" + chatRoomNo)
        .then((list) => {   // 만약 정상적으로 가져왔다면 배열형태의 메시지 가져옴
            setChatMessages(list.data);
        }).catch(err => console.log(err))


        // 3) 채팅방 참여자 정보 조회
        
        return () => {
            // 컴포넌트 소멸시 스톰프 클라이언트 연결해제
            stompClient.disconnect();
        }
    },[])

 
 
- 엔터를 치면은 <li></li>태그가 있는 곳까지 스크롤이 자동으로 내려가도록 설정

    // 스크롤 자동으로 아래로 내리기
    const bottomRef = useRef(null);
    // 메시지 전송할 때마다 개행처리되는 것 방지
    const textareaRef = useRef(null);

 
 
- textarea에 setMessages 함수 추가
- 전송버튼에 onClick 이벤트핸들러 추가
- enter키 눌렀을 때 이벤트 핸들러 발생되도록 onKeyDown 이벤트 핸들러 추가
- 스크롤이 자동으로 <li></li>있는 태그로 이동되도록 bottomRef 추가
- 개행처리 방지하도록 ref={textareaRef}추가

            <div className="chatting-area">
                <div className="chat-header">
                    <button className="btn btn-outline-danger">나가기</button>
                </div>

                <ul className="display-chatting">
                    <Message chatMessages={chatMessages} />
                    <li ref={bottomRef}></li>  {/* li태그 주소값을 계속 가져오기 위한 변수 */}
                </ul>
                <div className="input-area">
                    <textarea rows="3" name="message" ref={textareaRef}
                    onKeyDown={submitMessage}
                    onChange={(e) => {
                        setMessages(e.target.value)
                    }}
                        value={message}></textarea>
                    <button onClick={sendMessage}>전송</button>
                </div>
            </div>

 
 
- scrollToBottom 함수

    const scrollToBottom = () => {
        if(bottomRef.current){
            bottomRef.current.scrollIntoView({behavior: 'smooth'}); 
        }
    }

 
=> useEffect 안에도 스크롤이 자동으로 내려가도록 추가

 
- sendMessage 함수 

    const sendMessage = () => {
        const chatMessage = {
            message,
            chatRoomNo,
            userNo : user.userNo
        }

        if(!message){  // 메세지가 아무것도 입력하지 않았을 때
            alert("뭐든 입력하세요");
            return;
        }
        if(!user){
            alert("로그인 좀 하세요");
            return;
        }
        if(!webSocket){
            alert("웹소켓 연결중입니다");
            return;
        }

        // 웹소켓 서버로 데이터 전송
        webSocket.send("/chat/sendMessage/chatRoomNo/" + chatRoomNo, {}, JSON.stringify(chatMessage)); 
        // 다른 서버와 주고받을 때 반드시 JSON 문자열형태로 전달해야한다!
    }

 
 
 
- subitMessage 함수  (enter키 눌렀을 때 메시지 전송가능하도록)

    // enter키 누를때 
    const submitMessage = (e) => {
        if(e.key=== 'Enter' && !e.shiftKey){
            sendMessage();

            // 비동기 함수와 동일하게 작동시키기 위해 지연시간을 추가
            setTimeout(() => {
                textareaRef.current.value="";
            })

            setTimeout(() => {
                // 스크롤 내림
                scrollToBottom();
            }, 100);
        }
    }

 
 
 
☞ ChattingRoom.js 전체코드

import { useEffect } from "react";
import axios from 'axios';
import { useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import Message from "../components/Messages";
import { useState } from "react";
import SockJs from 'sockjs-client';
import {Stomp} from '@stomp/stompjs';
import { useRef } from "react";

export default function ChattingRoom (){

    // userNo를 가져와야하는데 redux에서 꺼내오는 방법이 있음. useSelector를 이용하여 꺼내옴
    let user = useSelector((state) => state.user)  // store에서 키값만 제시해서 꺼내오면 됨

    // 채팅방번호는 파라미터 값을 이용하여 가져올 예정
    const {chatRoomNo} = useParams();   // chatRoomNo로 넘어온 데이터를 변수로 꺼내어 사용

    let [chatMessages, setChatMessages] = useState([]);
    const [webSocket, setWebSocket] = useState(null);

    const [message, setMessages] = useState('');

    // 스크롤 자동으로 아래로 내리기
    const bottomRef = useRef(null);
    // 메시지 전송할 때마다 개행처리되는 것 방지
    const textareaRef = useRef(null);

    useEffect(() => {

        // 웹소켓 연결할 수 있는 함수
        // const createWebsocket = () => new SockJs("http://localhost:3000/api/stompServer");
        const createWebsocket = () => new SockJs("http://localhost:8084/api/stompServer");  // 프록시 설정


        // 웹소켓 객체 생성은 stomp가 주관할 예정
        const stompClient = Stomp.over(createWebsocket);

        stompClient.connect({}, (frame) => {
            console.log(frame);
            // stomp 같은 경우 웹서버와 통신을 할 때 frame단위로 동작을 함. frame를 메세지를 교환하는 틀이라고 생각할 것

            // 구독 url 설정
            // 내가 구독한 url로 데이터가 전달이 되면 실시간으로 감지하고 콜백함수를 실행함
            stompClient.subscribe(`/chat/chatRoomNo/${chatRoomNo}/message`, async(frame) => {
                console.log(frame.body); // JSON형태의 데이터. 가공처리는 직접해줘야함
                let jsonMessage = frame.body;
                let parsedMessage = await JSON.parse(jsonMessage);

                // 의존성배열이 비어있는 useEffect내부에서 state값은 항상 "초기 랜더링시의 값"만을 가지고있다.
                setChatMessages((preState) => [...preState, parsedMessage])  // 깊은복사
            })

        });  // 2번째 매개변수 : 연결이 완료되었을때 실행시킬 콜백함수 지정

        setWebSocket(stompClient); // 웹소켓은 stompClient로 작업할 수 있도록 지정


        // 1) CHAT_ROOM_JOIN 테이블에 참여자정보 추가
        let chatRoomJoin = {
            userNo : user.userNo,
            chatRoomNo,
            userStatus : 1
        }


        axios
        .post("/api/joinChatRoom", chatRoomJoin)
        .then((res) => {
            console.log('참여완료');
        })


        // 2) 채팅방 메세지 목록 가져오기
        axios
        .get("/api/chatMessage/chatRoomNo/" + chatRoomNo)
        .then((list) => {   // 만약 정상적으로 가져왔다면 배열형태의 메시지 가져옴
            setChatMessages(list.data);
        }).catch(err => console.log(err))

        setTimeout(() => {
            scrollToBottom();
        }, 100)


        // 3) 채팅방 참여자 정보 조회
        
        return () => {
            // 컴포넌트 소멸시 스톰프 클라이언트 연결해제
            stompClient.disconnect();
        }
    },[])

    const sendMessage = () => {
        const chatMessage = {
            message,
            chatRoomNo,
            userNo : user.userNo
        }

        if(!message){  // 메세지가 아무것도 입력하지 않았을 때
            alert("뭐든 입력하세요");
            return;
        }
        if(!user){
            alert("로그인 좀 하세요");
            return;
        }
        if(!webSocket){
            alert("웹소켓 연결중입니다");
            return;
        }

        // 웹소켓 서버로 데이터 전송
        webSocket.send("/chat/sendMessage/chatRoomNo/" + chatRoomNo, {}, JSON.stringify(chatMessage)); 
        // 다른 서버와 주고받을 때 반드시 JSON 문자열형태로 전달해야한다!
    } 
    
    const scrollToBottom = () => {
        if(bottomRef.current){
            bottomRef.current.scrollIntoView({behavior: 'smooth'}); 
        }
    }

    // enter키 누를때 
    const submitMessage = (e) => {
        if(e.key=== 'Enter' && !e.shiftKey){
            sendMessage();

            // 비동기 함수와 동일하게 작동시키기 위해 지연시간을 추가
            setTimeout(() => {
                textareaRef.current.value="";
            })

            setTimeout(() => {
                // 스크롤 내림
                scrollToBottom();
            }, 100);
        }
    }



    return (
        <>
            {/* 채팅방 참여자 목록을 보여주는 부분 */}
            <div className="chat-room-Members">
                <h4>참여자 목록</h4>
                <ul className="chat-room-members-ul">
                    <li>
                        {/* 사용자의 접속상태를 표시. 웹소켓과 연동해서 실시간으로 바뀌게 할 예정 */}
                        <span className="user-status online"></span>루피1
                    </li>
                    <li>
                        <span className="user-status offline"></span>루피2
                    </li>
                </ul>
            </div>
            <div className="chatting-area">
                <div className="chat-header">
                    <button className="btn btn-outline-danger">나가기</button>
                </div>

                <ul className="display-chatting">
                    <Message chatMessages={chatMessages} />
                    <li ref={bottomRef}></li>  {/* li태그 주소값을 계속 가져오기 위한 변수 */}
                </ul>
                <div className="input-area">
                    <textarea rows="3" name="message" ref={textareaRef}
                    onKeyDown={submitMessage}
                    onChange={(e) => {
                        setMessages(e.target.value)
                    }}
                        value={message}></textarea>
                    <button onClick={sendMessage}>전송</button>
                </div>
            </div>
        </>
    )
}

 
 
 
 
▶ ChatService.java

//	메시지 추가
	public void insertChatMessage(ChatMessage chatMessage) {
		dao.insertChatMessage(chatMessage);
	}

 
 
▶ ChatDao.java

//	메세지 추가
	public void insertChatMessage(ChatMessage chatMessage) {
		session.insert("chatMapper.insertChatMessage", chatMessage);
	}

 
 
▶ chatting-mapper.xml

	<!-- 채팅 메세지 추가 -->
	<insert id="insertChatMessage">
		INSERT INTO CHAT_MESSAGE
		VALUES (
			SEQ_CM_NO.NEXTVAL,
			#{message},
			SYSDATE,
			#{chatRoomNo},
			#{userNo}
		)
	</insert>

 
 
▶ 결과
=> 메세지가 계속 추가되는 것 확인