socket통신을 구현하며 경험했던 이벤트 기획, 구현과정 중 경험했던 이슈와 해결방안에 대해 서술하고 결과 코드를 작성한 글입니다.
당시 얼레벌레 기획하고 작성을 하게 되었는데, 틀린 부분에 대한 조언과 관심 대🌟환🌟영 입니다. 🙌🏻
우리가 기획한 채팅 서비스는 유저와 관리자의 1:n채팅이었다. 위 내용과 같이 유저가 첫 메세지를 보낼 때 방이 생성되는 걸로 기획을 잡고 갔다.
클라이언트 측 보낼 데이터 분기
즉 채팅 모달창이 떴을 때만 socket연결을 하면 되었기에 굳이 전역으로 관리하지 않았다.
채팅룸 입장 : ('enterChatRoom', userEmail)
채팅 리스트 반환 : ('AllMessages', allMessages)
채팅방 생성 : ('createChatRoom', userEmail)
클라이언트측 채팅 전송 : ('message', memberEmail, senderEmail, message)
서버측 가장 최신 채팅 반환 : ('latestMessage', latestMessage)
클라이언트측 접속 조회 요청 전송 : (’isOnlineStatus’, member_email, admin_email)
서버측 접속 조회 결과 전송 : ('onlineStatus', connectionData)
[일반 유저, 관리자]
로 반환된다.[관리자]
로 반환된다.다음은 리액트 훅과 소켓 통신을 적절하게 사용하지 못해 클라이언트측에서 발생한 버그들이다!
채팅창에 메세지를 입력하고 전송하면 화면에 똑같은 메세지가 2개, 4개, 8개... 2의 제곱으로 늘어나며 출력되는 현상이 발생했다.
이럴 때는 백엔드 코드의 문제인 지 내 코드의 문제인지 알아보기 위해 postman으로 테스트 하면서 진행했다. postman으로 테스트했을 때는 메세지 반환은 정상적으로 1개씩 반환되고 있었고, 이는 프론트 코드의 문제임을 확실히 알 수 있었다.
구글링을 통해 살펴보니 나와 같은 문제를 겪고 있던 사람들 몇몇을 확인할 수 있었다.
위의 링크들을 보면, 결국 메세지가 중복 렌더링 된다는 것은 이벤트가 중복으로 읽히고 있다는 것이다.
가장 최신의 메세지를 받게 되는 이벤트가 어디서 작성이 되고 있는지가 관건이었고, 나와 같은 경우는 다음과 같이 작성하여 해결하였다.
/*...*/
function ChatModal() {
/*...*/
useEffect(() => {
onMessage(); // 가장 최신 메세지를 받는 이벤트 함수를 useEffect내에 작성한다.
getOnline();
return () => {
socket.off('message');
socket.off('latestMessage'); // 가장 최신 메세지를 받는 이벤트를 한 번 실행했으니 리스너를 제거해준다.
socket.off('onlineStatus');
};
}, [addChat, setOnlineEmailList]);
// addChat은 채팅 하나를 서버로부터 받으면 리덕스의 리듀서에 해당 채팅을 기존 채팅 리스트에 추가해주는 액션 함수이다.
// 채팅을 하나 주고 받을 때 실행되는 함수들을 의존성 배열에 걸어줘서 채팅을 딱 한 번만 주고 받게 실행하게끔 코드를 작성했다.
/*...*/
/* 메세지를 보내는 함수 */
function handleSend(value: string) {
if (value.trim().length === 0) {
return;
}
sendMessage(value);
sendOnline();
}
/*...*/
/* 메세지를 보내는 이벤트 함수 */
function sendMessage(message: string) {
if (userEmail === adminEmail) {
socket.emit('message', chatRoomDetail.email, adminEmail, message);
} else {
socket.emit('message', userEmail, userEmail, message);
}
}
/* 가장 최신 채팅을 받는 이벤트 함수 */
function onMessage() {
socket.on('latestMessage', (data: IChatMessage[]) => {
console.log('latestMessage: ', data);
const newChatMessage = {
sender_email: data[0].sender_email,
name: data[0].name,
generation: data[0].generation,
message: data[0].message,
sentAt: data[0].sentAt,
};
dispatch(addChat({ chatMessage: newChatMessage }));
});
}
/*...*/
}
여기서 나는 소켓 관련 함수들을 화살표 함수를 사용하지 않고, function을 사용하였는데, 이는 useEffect의 의존성 배열로 함수를 넣어주기 위해 작성했었다.
이런식으로 의존성 배열을 적절히 넣어 이벤트를 딱 한 번만 실행되게 하는 것이 관건이었다.
소켓 연결을 분명히 채팅 모달창이 열릴 때 딱 한 번만 연결되게 코드를 짰는데, 연결이 계속해서 무한으로 연결이 되는 현상이 발생했다.
서버쪽 콘솔에도, 클라이언트쪽 콘솔에도 connect가 계속해서 찍히고 있었는데, 잘 살펴보니 채팅창 인풋에 한 글자씩 입력할 때마다 connect가 찍히고 있었다.
채팅 인풋은 채팅 모달창의 자식 컴포넌트였는데, 채팅 모달창 컴포넌트에 소켓 연결 시도하는 로직과 input의 state값을 관리하는 로직이 있다.
그리고 자식 컴포넌트인 채팅 인풋 컴포넌트로부터 input의 값과 set함수를 props로 받아와 onChange 이벤트로 값을 업데이트하는 방식으로 구성되고 있었다.
import { useMemo } from 'react';
import styles from './chatInput.module.scss';
import darkStyles from './chatInputDark.module.scss';
import { ReactComponent as Send } from 'assets/Send.svg';
import { useSelector } from 'react-redux';
import { RootState } from 'store/configureStore';
import { IChatInput } from 'types/chat';
function ChatInput({
inputValue,
handleInputChange,
handleClick,
handleEnter,
}: IChatInput) {
const isDarkMode = useSelector(
(state: RootState) => state.checkMode.isDarkMode,
);
const selectedStyles = useMemo(() => {
return isDarkMode ? darkStyles : styles;
}, [isDarkMode]);
return (
<div className={selectedStyles.chatInputContainer}>
<textarea
className={selectedStyles.chatInput}
value={inputValue}
maxLength={500}
onChange={handleInputChange}
onKeyPress={handleEnter}
/>
<button className={selectedStyles.sendButton} onClick={handleClick}>
<div className={selectedStyles.sendIconWrapper}>
<Send />
</div>
</button>
</div>
);
}
export default ChatInput;
/*...*/
function ChatModal() {
const [inputValue, setInputValue] = useState<string>('');
/*...*/
function handleInputChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
setInputValue(e.target.value);
}
function handleSend() {
if (inputValue.trim().length === 0) {
return;
}
sendMessage(inputValue);
sendOnline();
setInputValue('');
}
function handleEnter(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter') {
e.preventDefault();
handleSend();
}
}
/*...*/
return(
/*...*/
<ChatInput
inputValue={inputValue}
handleInputChange={handleInputChange}
handleClick={handleSend}
handleEnter={handleEnter}
/>
/*...*/
);
}
문제는 자식 컴포넌트인 채팅 인풋에서 값을 업데이트 할 때마다, 글자를 한 글자씩 칠 때마다 부모 컴포넌트가 리렌더링이 일어나면서 socket연결을 계속 시도하게 되었다.
부모 컴포넌트의 리렌더링을 해결하면 되는 문제라고 생각되어 useRef로 input값을 업데이트 하는 방법을 생각했다.
Javascript 를 사용할때, 특정 DOM 을 선택하여 정보를 얻거나 임의로 조작해야 할때, getElementById 혹은 querySelector 과 같은 DOM Selector 함수를 사용하여 DOM 을 선택하였다. 하지만, React 는 이 기능을 대체할 수 있는 useRef 훅을 제공한다.
useState는 state가 변경되면 내부의 모든 변수들이 초기화 되는 반면에, useRef 컴포넌트가 리렌더링 되지않는다. 값을 입력 할때, 값을 저장만 할뿐 컴포넌트를 리렌더링 시키지않는다.
즉, 현재는 props로 받은 state값이 업데이트가 되어서 부모 컴포넌트까지 리렌더링이 되고 있으니 ref를 사용하면서 state 관리를 채팅 인풋 컴포넌트에서 제어해서 자식 컴포넌트만 리렌더링이 가능하게 코드를 수정하면 해결이 되는 것이다!
다음은 해결한 부분의 수정된 부분을 나타내는 코드이다.
chatInput.tsx
chatModal.tsx
백엔드 담당하신 분이랑 협업을 진행하며 이벤트 기획부터 구현까지 그 사이에 정말 많은 버그들이 발생했었다. 안타깝게도 백엔드 분의 당시 포스트맨이 먹통이 되는 바람에 프론트 코드를 동시에 작성해서 백엔드분께서 테스트를 할 수 있게끔 계속 보내줬었어야 했다. 동시에 코드를 작성하고 각자의 코드를 넘겨받고 프론트와 백엔드를 경계짓지 않고 버그를 잡는데에 온 집중을 했었던 것 같다. 약간 온라인으로 하는 페어프로그래밍 느낌도 나면서 쏠쏠한 재미가 있었다.
어느정도의 백엔드 지식을 확실히 갖춰야하구나라고 느꼈던 협업이었다. 소켓 통신 구현을 시작하기 전날에 유투브에서 밤을 새워 인도형님 영상을 보며 클론 코딩을 했던 것이 서버와 클라이언트 사이 소켓 통신의 전반적인 동작을 알게 되는 데에 큰 도움이 되었다. 다만 그 영상에 너무 포커싱하여 우리의 개발환경과 서비스 로직을 고려하지 않고 영상의 이벤트 기획과 흐름, API 따라하게 된 것은 큰 오산이었다. 차후 스택오버플로우와 공식문서 등을 살펴보며 socket.emit을 io.to.emit으로 고치는 등 적절한 emit API를 사용하고, 이벤트 명을 받는 이벤트명과 주는 이벤트명을 따로 두게 됨으로써 버그를 고칠 수 있었던 것 같다. 정말 고생 많으셨습니다 백엔드 최고🥹
버그를 마주하고 해결하는 과정을 다시 회고해보니 블로그와 유투브보다 공식문서가 제일 정확하고 답을 알려주고 있구나를 깨닫게 되었다. 소켓 공식문서에 진짜 친절하게 설명이 되어있었더라.. 하지만 영어 너무 어려운걸...
마지막으로 결과 코드가 있는 레포지토리 주소를 남기며 글을 마무리 하겠다.
잘봤습니다 소중한 경험 공유 감사합니다!