[Web] React에서 setTimeout, setInterval, 그리고 setStateAction 잘 사용하기

sean·2022년 8월 23일
2

Web

목록 보기
4/22

🧐문제의 시작

후원사와 참가자간의 채팅 기능을 만들어야 했는데, HTTP GET, POST method를 활용하여 개발하는 방향으로 백엔드 팀과 의견을 맞췄다. 채팅의 전체 플로우는 다음과 같이 구성하였다.
(세부적으로는 일정 금액 이상 후원한 기업만 참가자에게 채팅으로 컨택할 수 있는 로직도 구현되어 있으나 이 글에서 다룰 주제만 집중적으로 다루기 위해 전체적으로 대략적인 플로우만 적겠습니다!)


1) CHAT 페이지 첫 렌더링 시, useEffect로 현재 접속자 정보 가져와서(GET /auth/user) Recoil state(loginUserState.user)에 저장.


//chatRooms state
const [chatRooms, setChatRooms] = useState<ChatRoomType[]>([]);

2) 1번에서 가져온 현재 접속자 정보를 바탕으로 해당 유저가 참여하고 있는 모든 채팅방들을 가져와서 (GET /chat) 채팅방리스트 state에 저장한다. 실시간에 가깝게 구현하기 위해 1초마다 서버로 GET 요청을 보내기로 했다.


3) 위에서 받아온 채팅방들(chatRooms)을 map함수를 이용하여 미리 만들어둔 '채팅방 컴포넌트(<ChatListItem>)'으로 가공하여 '채팅 목록들의 컨테이너(<ChatList>)' 안에 차곡차곡 넣어준다.


//selectedChatRoom state와 messages state
const [selectedChatRoom, setSelectedChatRoom] = useState<{roomid: string, name: string, img: string}>({roomid: '', name: '', img: ''});
const [messages, setMessages] = useState<MessageType[]>([]);

4) 채팅방 하나 클릭 시 selectedChatRoom state를 업데이트 해주며, 해당 채팅방에 관련된 모든 채팅 메시지들을 가져와서(GET /chat/selectedChatRoom.roomid) messages state에 넣어준다.


5-A) 또한, 메시지를 보낼 때마다(서버로 selectedChatRoom.roomid, 채팅내용, 보낸사람의 타입(후원사/참가자) 정보를 담아 POST 요청) 서버로 GET 요청을 보내 해당 채팅방의 메시지들을 가져와서 다시 setMessages를 해준다.

5-B) 상대가 언제 메시지를 보낼지 모르니, 메시지를 자신이 보내지 않더라도 selectedChatRoom.roomid가 empty string이 아니라면 (현재 선택되어 있는 채팅방이 있다면) 실시간에 가깝게 구현하기 위해 1초마다 서버로 GET 요청을 보낸다. 하지만, 현재 messages[0] (가장 최근에 받은 메시지)와 GET 요청의 결과인 res.data.data.msgs[0]을 각각 JSON.stringify하여 비교했을 때, 같으면 setMessage를 호출하지 않고, 다르면 setMessage를 통해 messages state를 업데이트해준다.

5-C) messages state가 업데이트 될 때마다 챗버블들을 담은 컨테이너의 스크롤을 내려준다. (새로운 메시지가 올라오는 효과를 주기 위해서)

    useEffect(() => {
        const chatDiv = document.getElementById('chatdiv') as HTMLDivElement;
        chatDiv.scrollTop = chatDiv?.scrollHeight;
    }, [messages]);

✍️처음에 setInterval을 이용하여 짠 코드

단순하게 위에 적혀있는 flow 그대로 setInterval을 이용하여 useEffect 하나에 써주었다.

useEffect(() => {
    const interval = setInterval(() => {
      //아무런 채팅방도 누르지 않았을 때는 채팅방 목록만 가져옴
        if (selectedChatRoom.roomid == '') {
            const fetchChatRooms = async () => {
                try {
                    await axiosInstance.get('/chat').then(res => {
                        if (JSON.stringify(chatRooms) == JSON.stringify(res.data.data)) {
                            return;
                        } else {
                            setChatRooms(res.data.data);
                        }
                    });
                } catch (err) {
                    console.log(err);
                }
            };

            fetchChatRooms();
        } else { //특정 채팅방을 눌렀을 때 해당 채팅방의 메시지를 가져옴
            axiosInstance.get(`/chat/${selectedChatRoom.roomid}`).then(res => {
                if (JSON.stringify(res.data.data.msgs[0]) === JSON.stringify(messages[0])) {
                    console.log('같습니다!');
                    return;
                } else {
                    console.log('다릅니다!');
                    console.log(`새로받은거의 0번째: ${JSON.stringify(res.data.data.msgs[0])}`);
                    console.log(`원래꺼의 0번째: ${JSON.stringify(messages[0])}`);
                    setMessages(res.data.data.msgs);
                }
            });
        }
    }, 1000);

    return () => clearInterval(interval);
}, [selectedChatRoom]);

하지만, 위의 코드를 실행시켜주었더니, 채팅방을 누르고 가만히 있었는데도 (즉, 현재 messages[0]res.data.data.msgs[0]과 같아야 함에도 불구하고) 계속 콘솔에 찍어 놓은 "다릅니다!"가 뜨는 것이었다. (다르기 때문에 계속 채팅방 컨테이너의 스크롤이 1초마다 내려가서 위의 채팅을 읽고 싶어도 1초마다 제일 밑으로 내려가 버리는 트러블이 생긴 것이다) 아니 처음엔 다르다 치더라도 마지막에 setMessages(res.data.data.msgs)를 해놨으니까 같아져야 하는 것 아니냐구요! 그래서 서로 0번째 인덱스에 대체 무엇이 들어있는지 알아보기 위해 콘솔을 또 찍어주었다.

그런데...? 결과가 이상했다.

  • console.log(messages[0])은 1초마다 계~속 undefined만 찍어내고 있었고,
  • console.log(res.data.data.msgs[0])은 당연히 받아온 메시지 배열의 0번째만 찍었다.

💡여기서 알게된 점!

setInterval 안에서 messages state가 setMessages로 업데이트가 되지 않는다는 것!!

🚨왜 setInterval 안에서 React.setStateAction 이 우리가 생각한대로 동작하지 않을까?

열심히 구글링하다가 다음과 같은 해답을 발견했다.

  • 리액트 컴포넌트의 Props와 state는 변할 수 있습니다. 변할 때 리액트는 지난 렌더링 상태에 대한 모든 것들을 지워버리며 다시 렌더링 할 것입니다.
  • useEffect Hook도 이전 렌더링에 대해서 잊어버리는 것은 마찬가지입니다. 지난 이펙트를 지워버리고 다음 이펙트를 설정합니다. 다음 이펙트는 새로운 props와 state를 거쳐 설정됩니다.
  • 하지만 setInterval"잊지 않습니다." setInterval은 직접 교체해주기 전까지는 이전의 props와 state를 계속 참조할 것입니다. 시간을 재설정하기 전까지는 교체도 불가능합니다.

💭이해를 돕자면 이런거에요

  • 가장 먼저 이걸 듣고 떠올랐던 게 바로 리그오브레전드 에코다... 궁극기를 쿨타임 없이, 무조건 setInterval에 설정한 delay마다 궁을 쓰는 에코라고 보면 될 거 같다! 그러니까 에코(우리가 업데이트 시키려고 하는 state)는 가고자 하는 위치에 도달을 하긴 하지만 계~속 똑같은 위치로 돌아갈 수 밖에 없다. (롤충이의 설명이 불편하셨다면 아래 설명을 읽어보세요!)

  • const [count, setCount] = useState<number>(0);이 있다고 칩시다. 이걸 setInterval안에서 1초마다 setCount(count + 1)을 해준다고 했을 때, 0에서 1로는 가긴 가지만 그 1이 다시 들어와서 2로 가지 못하고, 0에서 1, 0에서 1, 0에서 1을 계속 반복한다는 것!

🛠️에러를 고친 코드

위에서 문제가 되는 현상은 이렇게 고쳤습니다!
위의 현상을 역이용하여 setIntervalcall이라는 state만 1초마다 true로 바꿔주고(초기상태는 false로 설정하여 자동으로 다시 false로 돌아감), 나머지 일들은 calltrue일때만 작동하게 만들었습니다. 그러면 setInterval의 세계에 붙잡혀 있는 건 call 뿐이고 나머지 state들은 자유롭게 업데이트 될 수 있습니다!
이해하기 쉽게 그림으로 만들어 보았습니다! :-)

채팅 flow의 전체 완성 코드를 첨부하겠습니다! 해당 에러를 해결한 부분에는 주석이 있으니 거기서부터 보시면 됩니다!

useEffect(() => {
        const getSessionUser = async () => {
            try {
                const response = await axiosInstance.get('/auth/user');
                if (response.status != 401) {
                    if (response.data.type == 'user') {
                        setLoginUserState({
                            isLogin: true,
                            user: { ...response.data, name: response.data.name.first + (response.data.name.last || '') },
                        });
                    } else if (response.data.type == 'company') {
                        setLoginUserState({
                            isLogin: true,
                            user: response.data,
                        });
                    }
                }
            } catch (err) {
                alert('로그인이 필요한 서비스입니다.');
                router.push('/login');
            }
        };

        getSessionUser();
    }, []);

    //로그인한 유저가 참여중인 모든 채팅방을 불러와서 저장합니다.
    useEffect(() => {
        const fetchChatRooms = async () => {
            try {
                await axiosInstance.get('/chat').then(res => {
                    if (JSON.stringify(chatRooms) == JSON.stringify(res.data.data)) {
                        return;
                    } else {
                        setChatRooms(res.data.data);
                    }
                });
            } catch (err) {
                console.log(err);
            }
        };

        fetchChatRooms();
    }, [chatRooms, selectedChatRoom, messages]);

    //모든 참가자 리스트를 불러와서 저장합니다. (추후 New Conversation 모달에 전달할 배열)
    useEffect(() => {
        const fetchParticipants = async () => {
            try {
                await axiosInstance.get('/users').then(res => setUserList(res.data.data));
            } catch (err) {
                console.log(err);
            }
        };

        fetchParticipants();
    }, []);

	//여기서부터가 setInterval의 문제를 해결하는 코드입니다!!
    useEffect(() => {
        const interval = setInterval(() => {
            setCall(true);
        }, 1000);

        return () => clearInterval(interval);
    }, []);

	//call이 잠시 상태변화했을 때마다 우리가 원하는 작업을 수행합니다.
    useEffect(() => {
        if (selectedChatRoom.roomid == '') {
            const fetchChatRooms = async () => {
                try {
                    await axiosInstance.get('/chat').then(res => {
                        if (JSON.stringify(chatRooms) == JSON.stringify(res.data.data)) {
                            return;
                        } else {
                            setChatRooms(res.data.data);
                        }
                    });
                } catch (err) {
                    console.log(err);
                }
            };

            fetchChatRooms();
        } else {
            axiosInstance.get(`/chat/${selectedChatRoom.roomid}`).then(res => {
                if (JSON.stringify(res.data.data.msgs[0]) === JSON.stringify(messages[0])) {
                    console.log('같습니다!');
                    return;
                } else {
                    console.log('다릅니다!');
                    console.log(`새로받은거의 0번째: ${JSON.stringify(res.data.data.msgs[0])}`);
                    console.log(`원래꺼의 0번째: ${JSON.stringify(messages[0])}`);
                    setMessages(res.data.data.msgs);
                }
            });
        }

        setCall(false);
    }, [call]);

이렇게 해서 채팅방의 스크롤을 자유롭게 마음대로 위아래로 움직일 수 있게 되었답니다!

이 포스트도 참고해보면 정말 좋을 것 같다.
(https://mingule.tistory.com/65)


23.05.14 추가

최근에 구현 문제 준비를 하면서 다음과 같은 간단한 타이머 컴포넌트 구현 문제를 준비해보았다.

정수 n이 주어졌을 때, React로 n부터 1씩 감소하는 타이머를 만드시오.
또한, Stop 버튼도 만들어서 이 버튼을 누르면 타이머가 정지되게 만드시오.

내가 짠 코드는 다음과 같다.

import React, { useState, useEffect } from "react";

interface TimerProps {
  initialCount: number;
}

const Timer: React.FC<TimerProps> = ({ initialCount }) => {
  const [count, setCount] = useState<number>(initialCount);
  const [isActive, setIsActive] = useState<boolean>(true);

  useEffect(() => {
    let interval: NodeJS.Timeout | null = null;

    if (isActive && count > 0) {
      interval = setInterval(() => {
        setCount((count) => count - 1);
      }, 1000);
    } else if (!isActive && count !== 0) {
      if (interval) clearInterval(interval);
    }

    return () => {
      if (interval) clearInterval(interval);
    };
  }, [count, isActive]);

  const stopTimer = () => {
    setIsActive(false);
  };

  return (
    <div>
      <div>{count}</div>
      <button onClick={stopTimer}>Stop</button>
    </div>
  );
};

분명히 setInterval 내에서 setStateAction을 사용했을 때 안 됐던 기억이 있는데 이 코드는 내가 의도한대로 잘 돌아갔다. 이전에 프로젝트의 내 코드랑 차이점을 살펴본 결과, useEffect dependency 내부에 내가 변경하려는 state를 넣었느냐, 넣지 않았느냐의 차이가 있었다.

그래서 저 Timer 컴포넌트에서도 dependency에서 count를 빼고 돌려본 결과, setStateAction이 내가 의도한대로 돌아가지 않는 현상을 발견했다. 어쩌면 저 때도 dependency 안에 messages 배열을 넣어줬더라면 해결됐을 수도 있겠다는 생각을 했다.

profile
여러 프로젝트보다 하나라도 제대로, 깔끔하게.

1개의 댓글