[백로그] Message를 실시간으로 바꿔보자 : WebSocket (2)

Jade·2023년 2월 14일
1

프로젝트

목록 보기
17/28
post-thumbnail

에러 해결

해결해야 할 문제는 위 gif와 같이 새롭게 메시지를 입력하면 소켓으로 보냈던 이전의 메시지가 남아있지 않고, 새로운 메시지로 대체되는 것이었다.

get 요청으로 받아온 데이터(이미 주고 받은 메시지 데이터들)를 messages라는 상태에 담아주고 있었고, 웹소켓을 이용해 새롭게 주고 받은 메시지들은 subscribe의 두번째 인자인 콜백에서 받아 setMessages 함수를 통해 messages 맨 뒤쪽에 추가하는 방식을 사용했다.

(웹소켓 연결을 끊게 되면 데이터가 남아있는 것은 아니므로 서버단에서는 웹소켓으로 메시지를 주고 받을 때 인터셉트 해서 메시지 데이터들을 데이터베이스의 메시지 리스트에 추가해주고 있다.)

subscribe의 두번째 인자인 콜백함수는 구독한 location에서 메시지를 수신했을 때 처리해주고자 하는 동작들을 실행시킬 수 있다.

여기서 메시지를 수신한다는 건, 단순히 내가 상대방에게 메시지를 수신받았을 때 뿐만 아니라, 내가 메시지를 보냈을 때, 그 보낸 메시지 역시 받아볼 수 있음을 뜻한다. (처음에는 단순히 상대방이 보낸 것만 볼 수 있는 거 아닌가 해서 헷갈렸기 때문에 굳이 덧붙여 적어본다...)

아래 변경 전 코드를 보면, setMessages 상태 변경 함수를 이용해 messages에 담긴 내용을 spread 연산자를 이용해 꺼내와 새로운 배열에 담고, JSON.parse를 이용해 적절하게 바꿔준 응답 바디를 그 배열 마지막에 함께 담아주는 방식임을 알 수 있다.

하지만 콘솔을 찍어보았을 때 변경 전 방식을 사용할 경우 messages가 초기값인 빈 배열으로 들어오거나, 다른 경우네는 빈 배열이 아닌 get으로 받아온 DB 메시지 리스트 데이터만 찍히곤 했다.

팀원이 변경 후의 방법 처럼 setMessages에 콜백과 prev 값을 이용해 고쳐보니 해당 문제가 해결되었는데 사실 아직까지 그냥 messages를 가져오는 것과 prev를 사용하는 것이 크게 뭐가 다른지 잘 모르겠어서 이것 저것 찾아봤다.

//변경 전 
const [messages, setMessages] = useState([]);

const subscribe = () => {
    client.current.subscribe(`/sub/room/${currentRoomId}`, (res) => {
      console.log('변경 전', messages);
      setMessages([...messages, JSON.parse(res.body)]);
      console.log('변경 후', messages);
    });
  };


//변경 후
const [messages, setMessages] = useState([]);

const subscribe = () => {
    client.current.subscribe(`/sub/room/${currentRoomId}`, (res) => {
      setMessages((prev) => {
        console.log("messages 상태값", messages);
        console.log("변경 전 상태", prev);
        console.log("수신 데이터", res.body);
        return [...prev, JSON.parse(res.body)];
      });
    });
  };

변경 후 함수의 콘솔 내역을 살펴보면 messages 상태값은 get으로 받아온 값에서 변화가 없고, 변경 전 상태라는 prev값은 계속해서 변화 반영되고 있음을 알 수 있다. 🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔🤔

왜인지 알지 못하면 잠을 못 잘 거 같아서 이유를 찾는 여정을 시작해보았다...


useState 상태 변경 함수의 batch 처리

일단 처음 주목한 건 useState 상태 변경 함수에 그냥 값이 아닌 콜백 함수를 넣었다는 점이었다. 문제를 해결했던 방법이 그렇게 방식을 바꾼 것이기 때문.

우선 상태 변경 함수에 값을 넣는 것과, 콜백 함수를 넣는 것의 차이를 살펴봤는데, 후자를 '함수형 업데이트'라고 한다는 걸 발견했다. 많이들 드는 예시는 아래 카운터 예시인데,

export default function App() {
  const [value, setValue] =  useState(0)

  const onClick = () => {
    setValue(value+1)
    setValue(value+1)
    setValue(value+1)
  }
  
  return (
    <div className="App">
      <button onClick={onClick}>+</button>
      <h1>{value}</h1>
    </div>
  );
}

위와 같이 작성했을 때는 버튼을 클릭했을 때 1씩밖에 증가하지 않는다. 하지만 아래와 같이 onClick 함수를 바꿔서 작성하면 버튼을 눌렀을 때 한 번에 3씩 증가하는 것을 확인할 수 있다.

  const onClick = () => {
    setValue(prev => prev+1)
    setValue(prev => prev+1)
    setValue(prev => prev+1)
  }

리액트는 효율적으로 렌더링하기 위해 위 예시처럼 한 번의 클릭시 여러 개의 상태 값 변경 요청이 있을 경우 이를 일괄 처리하는데, 이걸 batch 혹은 batching이라고 한댄다...

이후 오브젝트 컴포지션 같은 개념까지 발을 담궜지만, 관련된 내용들에서는 변경 전 방식과 같은 spread 연산자 사용에 대해 주의하라는 말을 하고 있진 않아서 방향을 틀었다.


stale closure와 useEffect

한글로 검색해보는 건 한계가 있는 것 같아서 'why setstate is not working with array type and websocket' 따위의 말도 안 되는 영어로 검색어로 검색을 해보다가 스택 오버 플로우에서 우리 프로젝트와 비슷한 상황인 사람을 발견했다..!! (이럴 때마다 영어로 검색하는 게 어렵긴 한데 확실히 정보가 더 많다는 걸 느낌...)

StackOverFlow글 답변 중 첫번째로 보이는 답변이 우리가 해결한 방식인데, 이 방식이 왜 동작하게 만드는가에 대한 설명은 따로 해주지 않아서 아래쪽 답변을 참고했다.

(정확하지 않음) 해석해보기로 이 문제를 해결하는 방법은 'The solution is to always include all the variables you're going to use within the useEffect callback in the dependency list.' => '해결방법은 언제나 useEffect 콜백 내에서 사용되는 모든 변수들을 종속성 배열에 포함하는 것' 이라고 한다.

웹소켓 테스트 코드에서 우리는 useEffect에서 실행시키는 함수들을 useEffect 바깥에서 정의하고 있다.

initialChatSetting 함수는 axios get요청으로 특정 메시지 룸에 있는 모든 메시지 리스트를 조회해 가져오는 함수이고,
connect 함수는 웹소켓을 연결하고, 구독하는 함수이다.

 useEffect(() => {
    initialChatSetting();
    connect();
    return () => disconnect();
  }, [currentRoomId]);

그리고 이렇게 작성하면 실제로 eslint에서 이런 warning을 보여준다. 코드를 작성하면서 이 경고를 확인하기는 했지만, 종속성 배열을 잘못 입력하면 무한하게 실행되는 것을 여러번 겪었기 때문에... 괜찮겠지 하고 넘겼는데, 이게 문제였던 것이다.

useEffect 가 실행되는 시점...즉, currentRoomId가 종속성 배열에 담겨있는 시점은 실제로는 처음에 방에 입장할 때만 해당된다. 방에 입장한 뒤로 상대방과 대화를 하는 동안에는 currentRoomId가 바뀔 일은 없다. 그때만 useEffect 실행되니까 그때의 messages라는 상태값으로 함수를 실행시키게 된다. 예를 들어 connect 속의 subscribe 함수는 웹소켓으로 메시지를 주고 받을 때마다, 즉 메시지를 수신할 때마다 그 메시지를 가져오게 되는데, 그때도 useEffect는 다시 돌지 않으므로 계속해서 오래된 값을(stale closure) 사용하게 된다. 아마 새로운 메시지를 추가해주어도 messages가 제대로 업데이트되지 않고, 새로운 메시지만 계속해서 끝에 추가되었던 것은 그런 이유에서였던 것 같다.

우리가 해결을 위해 사용했던 함수형 업데이트 방식은 지금 이 상황에서 문제를 해결해줄 순 있겠지만, 그래도 여전히 오래된 값을 가지게 한다고 한다. 아마 완벽한 해결책이라고 보기 어려울듯.

이런 문제를 피하기 위해 stackOverFlow 답변에서 제시한 해결책은 다음과 같다.

1번 해결책 니가 useEffect의 콜백 안에 부를 함수를 그 안에서 정의해라 밖에서 말고

2번 해결책 니가 젤 좋아하는 린터를 사용해서 너한테 경고하도록 해라 useEffect의 잃어버린 종속성(missing dependencies)들을… 근데 니가 CRA 사용하면 이미 준비되어있을 거임

해당 질문글에서 참고하라고 한 공식문서 useEffect 부분을 살펴보면 아래 빨간 박스 내부에 '현재의 값이 아닌 이전의 렌더링 때의 값을 참고하게 됩니다'라는 말이 선명하게... 내 눈을 핥는다... 😂


에러에 대해 더 찾아본 건 프로젝트에 웹소켓을 적용해보고 난 뒤라 지금 프로젝트에는 임시방편인 함수형 업데이트 방식으로 웹소켓이 적용되어 있다.

일단은 클라이언트에서 테스트 코드를 기반으로 메시지 관련 컴포넌트 로직들을 수정했고, 웹소켓 관련해서 서버에서도 수정사항이 많아서 서버가 완성되고 나면 테스트를 해보면서 여러 에러들을 또 해결해나가야 할텐데... 그 전에 useEffect와 종속성 배열을 다시 잘 사용해서 문제를 해결할 방법을 찾아봐야겠다.

그래도 궁금한 게 조금이나마 풀려서 발 뻗고 잘 수 있을듯. 😴

profile
키보드로 그려내는 일

0개의 댓글