useEffect 동작 원리 (feat. 프로젝트)

📅 TIL #141




🎯 Achievement Goals

  1. useEffect와 생명주기
  2. useEffect clean-up 함수
  3. 프로젝트에서 useEffect 사용 경험







Intro.


React를 사용하면서 가장 많이 접했던 단어는 바로 생명주기(Life Cycle)이다. 클래스 컴포넌트를 사용하던, 함수형 컴포넌트를 사용하던 생명주기란 단어는 빼놓을 수가 없다.


난 실제로 프로젝트에서 실시간 채팅 기능을 구현할 때, useEffectsocket 그리고 Redux를 한 번에 사용한 경험이 있다.


오늘은 useEffect를 보다 깊게 공부하고, 프로젝트를 하면서 어떤 면에서 어려움을 겪었었는지 블로깅을 하려고 한다.




useEffect와 생명주기


react-생명주기



클래스 컴포넌트를 활용했을 땐, componentDidMount, componentDidUpdate, componentWillUnmount 등의 메서드를 활용해서 렌더링 시에 필요한 데이터를 보여주고, 변경해주고, 제거해줄 수 있다.


하지만 함수형 컴포넌트에서는 이 3가지를 직접 사용하기보다는, 생명주기 메서드들을 useEffect을 통해 생명주기를 표현할 수 있다.


리액트의 class 생명주기 메서드에 친숙하다면, useEffect Hook을 componentDidMount와 componentDidUpdate, componentWillUnmount가 합쳐진 것으로 생각해도 좋습니다.
-React 공식 문서-


useEffect를 사용해서 생명주기 메서드와 비슷한 동작을 만들 수는 있지만, 실제 생명주기와 다르다는 것은 인지해야 한다.


useEffect는 리액트에게 컴포넌트가 렌더링 이후에 어떤 일을 수행해야하는 지를 정의한다. 하지만 필요에 따라 clean-up 함수를 반환하도록 작성해 이펙트 실행 이전에 실행시킬 수도 있다.


그리고 useEffect에는 두 종류의 side effects가 있으며 clean-up이 필요한 경우그렇지 않은 경우로 구분지어 있다고 나와있다. 여기서 side effects란 외부 데이터 가져오기 또는 수동으로 컴포넌트의 DOM을 수정하는 것 등등을 side effects 라고 한다.


정리하자면, useEffect를 사용해서 생명주기의 역할과 같이 동작하게 할 수는 있지만, 렌더링 후 사이드 이펙트를 실행하는 방법이라고 생각하면 좋다.






useEffect clean-up 함수


useEffect를 사용하여 마운트/언마운트가 됐을 때, 컴포넌트가 어떻게 동작하는지 나타내려면 다음과 같이 사용하면 된다.


// 마운트때 한번만 실행
React.useEffect(() => {
  console.log("mounted");

  //언마운트 시
  return () => console.log(unmounting);
}, []);


useEffect의 return값으로 effect를 고의적으로 해지했다. 이 함수를 clean-up 함수라고 부른다.


//실제로 내가 프로젝트 때 사용했던 코드

useEffect(() => {
  window.addEventListener("scroll", onScroll);
  // TODO: 컴포넌트가 언마운트 되기 직전에 이벤트를 끝낸다. 메모리 누수 방지
  return () => window.removeEventListener("scroll", onScroll);
}, []);


실제로 프로젝트 때 사용했던 코드를 가져와봤다. clean-up 함수는 메모리 누수 방지 용도로도 사용할 수 있다. addEventListener는 동기적으로 수백번, 수천번 계속 실행이 되기 때문에 이 이펙트를 정리(clean-up)해주지 않으면 메인 스레드에 영향이 간다.


하지만 clean-up 함수를 반드시 사용하는 것은 아니다. 네트워크 요청, DOM을 수동으로 조작하거나 또는 로깅 등의 effect는 return값을 명시하지 않는다.


그렇다면 clean-up 함수가 실행되는 시점은 언제일까? 위의 코드 주석에 이미 답이 나와버렸지만, 정답은 컴포넌트가 마운트 해제되기 직전에 실행이 된다. 즉, 다음 차례의 effect를 실행하기 전에 이전의 effect를 정리한다.






프로젝트에서 useEffect 사용 경험


프로젝트 내에 실시간 채팅 서비스 기능을 구현한 적이 있다. 채팅 기능을 구현하기 위해 socket.io라이브러리를 사용했었고, useEffectredux를 동시에 사용하였다. 이 부분에서 어려움을 느낀 적이 있었는데, 어떤 어려움이였으며, 어떻게 극복했는지까지 기억을 되살리며 기록해보려고 한다. (각 스택에 대한 설명은 생략)


사실 처음 채팅 기능을 구현했을 때는 redux를 사용하지 않았다. socket.io도 처음 써보는데 redux까지 같이 사용하자니 러닝커브가 클 것이라고 생각했기 때문이다. 처음에 구현했던 코드를 가져와봤다.


// 채팅 데이터
const [chat, setChat] = useState("");

// TODO: 해당하는 채팅 Room과 연결 시도
connectionSocket();

useEffect(() => {
  socket?.emit("joinRoom", room);

  return () => {
    disconnectSocket();
  };
}, []);

useEffect(() => {
  // TODO: 메세지를 받으면 재렌더링 한다.
  socket?.on("sendMessage", (chat: ChatDataType) => {
    if (chat) {
      setChat(chat);
    }
  });
}, [chat]);


일단 먼저 useEffect가 두 번 사용된 것을 볼 수 있다. 이건 useEffect의 장점이기도 하다. 여러개의 state가 있으면 각 state에 따라 컴포넌트의 일부만 렌더링이 되게 할 수도 있다.


그리고 첫번째 useEffect 내부에 clean-up 함수를 사용해서 언마운트시에는 socket연결을 해제하는 부분을 구현하였다. 의존성 배열을 빈 배열로 넣고, 마운트시에만 socket연결을 해주는 모습까지 볼 수 있다.


하지만 내가 어려움을 느꼈던 부분은 두번째 useEffect부터 였다. 의존성 배열의 역할을 제대로 모른 채, 무조건 빈 배열로만 넣고 구현을 했었다. 채팅을 입력하면 chat state가 update 되면서 채팅창에 채팅이 입력이 되어야 하는데 입력이 되지 않았다. 그리고 새로 고침을 해야만 입력이 되었다.


이 문제를 해결하고자 나는 useEffect의 내부 작동 원리에 대해 하루종일 공부한 경험이 있다. 공부를 하고 나니 원인은 쉽게 찾을 수 있었고, 문제 해결 능력이 상승한건 덤이다.


useEffect는 기본적으로 render 될 때마다 실행이 된다. 즉, props나 state가 변경되지 않고 부모 컴포넌트가 리렌더링 될 때에도 호출이 된다. 여기서 특정 상황에만 이 함수를 실행시켜서, 렌더링마다 실행되는 것이 아니라 값이 변할 때만 이펙트 함수를 한 번만 실행되게 해야 한다. 이 역할을 하는 것이 바로 의존성 배열이다.


마찬가지로 나는 채팅을 입력했을 경우, 채팅 데이터를 담고 있는 useEffect만 실행시키고 싶었다. 그래서 의존성 배열이 빈 배열이 아니라, chat 변수를 담아주는 것이 올바른 방법이다.




Redux의 state 감지


프로젝트가 다 끝나고 나는 곧바로 redux를 사용하여 채팅 기능을 리팩토링 했다. 그 이유는 채팅 state를 props로 계속 전달해 주다보니, 불필요한 렌더링 때문에 성능이 많이 안 좋다는 것을 깨달았다.


그래서 redux로 리팩토링을 했는데, 이 과정에서도 어려움을 느꼈고, redux를 더 공부한 끝에 원인을 찾고 겨우 해결할 수 있었다.


먼저 채팅 기능 부분을 전면 리팩토링한 후의 일부 코드를 가져와봤다.


useEffect(() => {
  // TODO: 메세지를 받으면 재렌더링 한다.
  socket?.on("sendMessage", (chat: ChatDataType) => {
    if (chat) {
      dispatch(sendMessage([chat]));
    }
  });

  // TODO: 채팅 수정
  socket?.on("editMessage", ({ chat, index }: ChatDataType) => {
    if (chat) {
      dispatch(editMessage({ chat, index }));
    }
  });
}, []);


리팩토링한 코드의 핵심은 dispatch를 사용했다는 점과 의존성 배열이 빈 배열이라는 점이다. 아까 useEffect에는 의존성 배열안에 chat 변수를 넣어주었는데, 이번에는 왜 빈 배열일까?


의존성 배열은 state의 변화를 감지하는데, 여기서 재밌는 사실은 redux도 state의 변화를 감지한 다는 것이다. 난 이 사실을 모른채 의존성 배열에 chat을 넣었다가.. 채팅 한 번 입력할 때 상대방에게 채팅이 3개, 5개, 10개 씩 전송되는 기괴한 현상을 목격했다..


이유는 의존성 배열과 redux의 중복 감지였다. 나는 immer가 탑재되어 있는 redux-toolkit을 사용하였고, 일부 코드를 가져와보았다.


// reducer
export const chatSlice = createSlice({
  name: 'chat',
  initialState,
  reducers: {
    sendMessage: (state, { payload }: PayloadAction<ChatDataType[]>) => {
      return [...state, ...payload];
    },
    editMessage: (state, { payload }: PayloadAction<ChatDataType>) => {
      const copyState = [...state];

      if (payload.chat) {
        copyState.splice(payload.index, 1, payload.chat);
      }
    }

    return [...copyState];
  }
});


코드를 보면 redux는 직접 state를 관리한다. 이 부분이 의존성 배열과 중복되었기에 오류가 발생했던 것이다. useEffectRedux를 동시에 사용한다면 이러한 중복 감지는 조심해야 한다는 것을 깨달았다.


채팅 기능을 구현하면서 각 스택에 대한 깊은 공부를 할 수 있어서 좋았지만, 무엇보다도 문제 해결 능력이 더 상승한 부분에 있어서 가장 뿌듯하다.


기능 구현을 위해 무작정 메서드들을 사용하기 보다는 내부 작동 원리를 이해하고 사용하면 더 효율적으로 코드를 작성할 수 있고, useEffectredux를 같이 쓴 것 처럼 매끄럽게 여러 스택들을 잘 사용할 수 있다는 것을 깨달았다.