채팅 방 구현에 앞서 먼저 SSE 와 Socket에 차이에 대해 설명하려고 합니다.
먼저 SSE ( Server-Send Events ) 는 단 방향 이벤트로 서버에서 받는 push Event에 사용되며 Socket 보다 가볍다는 특징을 갖고 있습니다.
단방향이기 때문에 서버 -> 클라이언트로만 통신이 가능하며 그의 반대인 클라이언트 -> 서버로의 통신은 불가합니다.
Socket은 양방향 통신으로 서버 <-> 클라이언트간의 통신이 가능하며 지속적인 TCP 라인을 통해 데이터를 실시간으로 주고받습니다. 연결지향 통신으로 채팅 , 게임 , 주식 차트 등에 사용되며 주기적인 HTTP 요청을 하는 Polling ( Client Pull ) 과는 다르게 연결을 유지합니다.
이때 SSE 의 특징은 다음과 같습니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class HandlerChatConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler , "/chat").setAllowedOrigins("*");
}
}
WebSocketConfigure 를 상속받는 클래스로 WebSocket을 사용하기 위한 기반이 됩니다.
addHandler를 통해 소켓을 연결할 URL를 설정할 수 있으며 , 접속을 허용할 Origin을 지정해 주면 됩니다.
@Component
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {
private Map<Long , List<WebSocketSession>> sessionList = new HashMap<>();
private final MeetingRepository meetingRepository;
private final ChatRepository chatRepository;
@Override
@Transactional
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ChatRequest chatMessage = objectMapper.readValue(message.getPayload(), ChatRequest.class);
long meetingId = chatMessage.getMeetingId();
if(chatMessage.getMessageType() == MessageType.ENTER) {
joinChatBySession(meetingId , session);
} else if(chatMessage.getMessageType() == MessageType.SEND) {
sendChatToSameRootId(meetingId , objectMapper , chatMessage);
}
}
private void joinChatBySession(long meetingId , WebSocketSession session) {
if(!sessionList.containsKey(meetingId)) {
sessionList.put(meetingId , new ArrayList<>());
}
List<WebSocketSession> sessions = sessionList.get(meetingId);
if(!sessions.contains(session)) {
sessions.add(session);
}
}
private void sendChatToSameRootId(long meetingId , ObjectMapper objectMapper , ChatRequest chatMessage) throws IOException {
List<WebSocketSession> sessions = sessionList.get(meetingId);
for(WebSocketSession webSocketSession : sessions) {
ChatResponse chatResponse = createChatResponse(chatMessage);
saveChatData(meetingId , chatResponse);
String result = objectMapper.writeValueAsString(chatResponse);
webSocketSession.sendMessage(new TextMessage(result));
}
}
private void saveChatData(long meetingId , ChatResponse chatResponse) {
Meeting meeting = meetingRepository.findMeetingAndChatById(meetingId)
.orElseThrow(() -> new MeetingException("모임 정보를 찾을 수 없습니다."));
Chat chat = Chat.createChat(chatResponse , meeting);
meeting.getChats().add(chat);
chatRepository.save(chat);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
for(Long meetingId : sessionList.keySet()) {
List<WebSocketSession> webSocketSessions = sessionList.get(meetingId);
for(int i = 0; i < webSocketSessions.size(); i++) {
WebSocketSession socket = webSocketSessions.get(i);
if(socket.getId().equals(session.getId())) {
webSocketSessions.remove(i);
break;
}
}
}
}
}
해당 클래스는 소켓이 실제로 사용되는 클래스로 방에 입장하거나 , 채팅을 보냈을 때 같은 세션에 등록되어 있는 사용자에게 똑같이 메세지를 보내는 로직이 구현되어 있습니다.
Map<Long , List<WebSocketSession>> sessionList
와 같이 세션을 분리한 이유는 채팅방은 하나만 있는 것이 아닌 여러개의 방이 있기 때문입니다.
handlerTextMessage
소켓에 메세지가 전달되었을 때 실행되는 메서드로 readValue()
메서드를 통해 전송된 메세지를 자바 파일로 변환합니다. 이때 ChatRequest.class 는 개발자가 만든 파일입니다.
joinChatBySession
처음 세션에 등록되었을 때 실행되는 메서드로 만약 처음 세션에 접속한 사람이면 세션을 만들고 , 그게 아니라면 세션에 사용자 정보를 넣습니다.
sendChatToSmaeRoodId
데이터 전송이 되었을 때 같은 방에 접속한 사람들에게 데이터를 전송하는 로직입니다.
saveChatData
필수는 아닌데 이전 채팅에 대한 정보를 저장하기 위한 메서드로 채팅이 전송될 때 마다 DataBase에 데이터가 저장됩니다.
afterConnectionClosed
연결이 끊어졌을 때 실행되는 메서드로 연결이 끊겼을 경우 세션을 제거합니다.
지금 까지 주 구현체만 작성했는데 관련된 구현체 ( Service , Controller , Entity )도 보고 싶다면 GitHub 링크 를 참고해주세요.
const [chatRoom, setChatRoom] = useState([]); // 방 번호
const [meetingId, setMeetingId] = useState(0); // 채팅방이 속한 채팅 방 번호
const [toogle, setToogle] = useState(true);
const [chatData, setChatData] = useState([]); // 전송된 데이터 목록
const [userInfo, setUserInfo] = useState({ userKey: 0, userId: "", profileImage: "" });
// Chatting 관련 끝
const webSocketUrl = `ws://localhost:8080/chat`;
let ws = useRef(null);
const [socketConnected, setSocketConnected] = useState(false);
// Chatting 관련 종료
const [inputData, setInputData] = useState(""); // 입력 데이터
const onChangeInputData = (e) => {
setInputData(e.target.value);
}
// 채팅을 전송했을 때 실행되는 로직으로 만약 소캣이 열려있다면 메세지를 전송합니다.
const onPushEnter = (e) => {
if (e.key == 'Enter') {
if(inputData == "") {
alert("메세지를 입력해 주세요.");
}
if (socketConnected) {
ws.current.send(
JSON.stringify({
sender: userInfo.userId,
message: inputData,
meetingId: meetingId,
senderImage: userInfo.profileImage,
messageType: "SEND"
})
);
setInputData("");
}
}
}
useEffect(() => {
if (toogle == false) {
// Socket 연결을 시도하는 코드이며 , 각각의 상황에 따라 이벤트 리스너를 등록하고 있습니다.
if (!ws.current) {
ws.current = new WebSocket(webSocketUrl);
ws.current.onopen = () => {
console.log("connected to " + webSocketUrl);
setSocketConnected(true);
};
ws.current.onclose = (error) => {
console.log("disconnect from " + webSocketUrl);
console.log(error);
};
ws.current.onerror = (error) => {
console.log("connection error " + webSocketUrl);
console.log(error);
};
ws.current.onmessage = (evt) => {
const data = JSON.parse(evt.data);
setChatData((prevItems) => [...prevItems, data]);
};
}
// 브라우저가 닫히거나 연결이 끊어졌을 경우 소켓을 종료하는 구문이 실행됩니다.
return () => {
console.log("clean up");
ws.current.close();
};
}
}, [toogle])
// 소켓의 값이 변경됐을 경우 ( 열렸을 때 ) 서버에 접속 메세지를 전송합니다.
useEffect(() => {
if (socketConnected) {
ws.current.send(
JSON.stringify({
sender: userInfo.userId,
message: "",
meetingId: meetingId,
senderImage: "",
messageType: "ENTER"
})
);
}
}, [socketConnected]);
// 채팅 방에 대한 정보를 가져오는 API , 소켓과는 무관합니다.
useEffect(async () => {
await axios({
method: "GET",
mode: "cors",
url: `/chat/participants`
}).then((response) => {
setChatRoom(response.data);
}).catch((err) => {
console.log(err.response.data)
});
await axios({ // 유저의 개인 정보
method: "GET",
mode: "cors",
url: `/users`
}).then((response) => {
setUserInfo({ userKey: response.data.data.userKey, userId: response.data.data.id, profileImage: response.data.data.profileImage });
}).catch((err) => {
console.log(err.response.data)
});
}, []);
// 이전 채팅에 대한 데이터를 가져옵니다.
const getChatData = async (roomId, meetingId) => {
setMeetingId(meetingId);
setToogle(false);
await axios({ // 데이터 가져오기
method: "GET",
mode: "cors",
url: `/chat/${meetingId}`
}).then((response) => {
setChatData(response.data);
}).catch((err) => {
console.log(err.response.data)
});
}
// 채팅 방에 대한 데이터를 화면에 출력합니다.
const ChatRoomContent = ({ room }) => {
return (
room.map(data => (
<div className="chatContent" key={data.chatId} onClick={() => getChatData(data.chatId, data.meetingId)}>
<img className="ellipse-chat" alt="image" src={data.meetingImage} />
<div className="text-wrapper-chat">{data.meetingTitle}</div>
<div className="text-wrapper-chat-last">마지막 대화 : {data.lastChat}</div>
</div>
))
)
}
// 채팅 데이터에 대한 데이터를 화면에 출력합니다.
const ChatDataContent = ({ data }) => {
return (
data.map(detail => (
<div className="overlap-group-chatData" key={detail.chatId == 0 ? Math.floor(Math.random() * 10_000_000_001) : detail.chatId}>
<img
className="mask-group-chatData"
alt="Mask group"
src={detail.senderImage}
/>
<div className="text-wrapper-7-chatData">{detail.sender}</div>
<span className="rectangle-chatData">
{detail.message}
</span>
<div className="text-wrapper-4-chatData">{detail.sendTime}</div>
</div>
))
)
}
참고 블로그 1 : https://dalili.tistory.com/125
참고 블로그 2 : https://velog.io/@bagt/Spring%EC%97%90%EC%84%9C-%EC%9B%B9-%EC%86%8C%EC%BC%93WebSocket-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0#handletextmessage---%EC%96%91%EB%B0%A9%ED%96%A5-%ED%86%B5%EC%8B%A0-%EB%A1%9C%EC%A7%81
참고 블로그 3 : https://lts0606.tistory.com/569
참고 블로그 4 : https://jcon.tistory.com/186