useEffect

윤뿔소·2023년 2월 27일
0

React

목록 보기
1/2

프로젝트를 수행하며 가장 이해가 안됐던 게 useEffect다. 아니 사이드이펙트를 관리하기 위해 함수를 넣고, 관련 변수가 변한다면 실행되게 의존성 배열에 변수를 넣어 실행시키는 건 아는데, 의존성 배열에 잘못 넣으면 무한 새로고침, 무한 요청(Ajax 호출 관련이라면) 등이 일어난다.
내가 이해한 건 그 변수가 '변경할때만'이 아니라 존재한다면 계속 실행되는 건가? 변수의 값 변경이 아니라?? 뭐지? 싶었다.

이게 리액트 Hook의 단점이다. 추상화(Abstraction)가 꽤 깊게 되어 있어서 그 내부 동작 원리를 잘 알지 못하고 사용하는 경우가 많다는 것이다.
그래서 동작원리를 충분히 알고 써야 다음에 이런 문제를 겪지 않을 거 같았다.

useEffect란?

리액트의 내장 Hook으로 사이드 이펙트의 수행을 위한 Hook이다.
1. 첫 번째 인자로 콜백 함수를 전달
2. 사이드 이펙트를 수행할지 여부를 결정하는 값들의 의존성 배열을 두 번째 인자로 전달

사이드 이펙트는 React 컴포넌트가 화면에 렌더링된 이후에 비동기로 처리되어야 하는 부수적인 효과들을 뜻한다. 즉, 비동기 코드들을 위한 Hook이다.

왜 사이드 이펙트를 관리해야할까? 일단 화면에 렌더링이 될 수 있는 것들(정적 데이터)을 먼저 렌더링 하고 API의 호출로 늦어지는 데이터들(서버 통신 데이터)을 나중에 1차적으로 렌더링이 끝난 뒤 가져오게 만들어 사용자의 부정적 영향을 최소화 시킬 수 있어서 사용자 경험 측면에서 유리하기 때문이다.

왜 만들어졌을까?

먼저 알아야할 것은 리액트의 Life Cycle을 알아야한다.

리액트의 전 버전에선 명령형 프로그래밍, 즉 OOP로 써야했었다. 위 사진은 그 구조와 라이프 사이클 함수와 함께 나타낸 것. 라이프사이클의 역할을 크게 3가지가 있고 함수 순서대로 보자면 이렇다.

라이프 사이클 구동 순서

마운트 (생성)

컴포넌트의 인스턴스가 생성되어 DOM에 삽입될 때 순서대로 호출

  1. constructor() : 컴포넌트를 새로 만들 때마다 호출되는 클래스 생성자 메서드. this.props, this.state에 접근할 수 있으며 리액트 요소를 반환한다. setState()를 사용할 수 없으며 DOM에 접근해선 안된다.
  2. getDerivedStateFromProps() : props에 있는 값을 state에 동기화 시킬 때 사용하는 메서드
  3. render() : UI를 렌더링하는 메서드
  4. componentDidMount() : 컴포넌트가 웹 브라우저 상에 나타난 후 즉, 첫 렌더링을 마친 후에 호출하는 메서드. 라이브러리나 프레임워크의 함수를 호출하거나 이벤트 등록, setTimeout(), setInterval()과 같은 비동기 작업을 처리하면 되고, setState() 호출도 이 메서드에서 호출하는 경우가 많다.

업데이트

props나 state가 변경되면 렌더가 진행되며 순서대로 호출.

  1. getDerivedStateFromProps() : 이 메서드는 마운트 과정에서 호출되며 업데이트가 시작하기 전에도 호출된다. props의 변화에 따라 state 값에도 변화를 주고 싶은 경우에 사용한다.
  2. shouldComponentUpdate() : props 또는 state를 변경했을 때, 리렌더링을 시작할지 여부를 지정하는 메서드. true를 반환하면 다음 라이프사이클 메서드를 계속 실행하고, false를 반환하면 작업을 중지한다.
  3. render() : 컴포넌트 리렌더링
  4. getSnapshotBeforeUpdate() : 컴포넌트 변화를 DOM에 반영하기 바로 직전에 호출하는 메서드
  5. componentDidUpdate() : 컴포넌트 업데이트 작업이 끝난 후 호출하는 메서드.

언마운트 (마운트 해제)

컴포넌트를 DOM에서 제거하는 과정

componentWillUnmount() : 컴포넌트를 DOM에서 제거할 때 실행한다. 이후에 컴포넌트는 다시 렌더링 되지 않으므로, 여기에서 setState()를 호출하면 안된다.

참고: 함수형의 라이프 사이클

예시: 클래스 컴포넌트 작성 및 useEffect 필요 이유

import React, { Component } from "react";

class UserListClass extends Component {
  state = {
    loading: true,
    users: [],
  };

  componentDidMount() {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((response) => response.json())
      .then((users) => this.setState({ users, loading: false }));
  }

  render() {
    const { loading, users } = this.state;
    if (loading) return <div>Loading...</div>;
    return (
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  }
}

위 코드는 API를 활용하여 사용자 목록을 가져오는 React 컴포넌트에서, loadingusers 속성에 로딩 여부와 사용자 목록을 저장하는 코드를 보여준다.

초기 렌더링 시 loading 값이 true로 설정되어 Loading... 메시지가 화면에 나타난다. 그러나 componentDidMount() 함수가 호출되면 API를 호출하고, users 속성에 데이터가 할당되며 loading 값이 false로 업데이트 된다. 이로 인해 Loading... 메시지는 사라지고 사용자 이름들이 화면에 나타나게 된다.

이렇게 만들 수 있지만 코드 길이도 길고, 간단한 사이드 이펙트 구현조차도 클래스 컴포넌트로 작성하는게 귀찮았다. 왜냐면 클래스 컴포넌트 특성 상 함수 기반 컴포넌트에 비해 복잡해 오류가 발생하기 쉽고 유지 보수가 힘들기 때문.(라이프사이클 메소드에는 관련 없는 로직이 자주 섞여 들어가서 버그가 쉽게 발생하고, 무결성을 쉽게 해침)

그래서 리액트 작성법이 함수형 프로그래밍으로 바뀌면서 Hook이 생겼고, 위의 문제로 useEffect가 생겼다.

어떻게 써야할까?

위 클래스형에서 썼던 코드를 대조하여 함수형 프로그래밍 속 useEffect로 써보겠다.

import { useState, useEffect } from "react";

function UserListFunction() {
  const [loading, setLoading] = useState(true);
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((response) => response.json())
      .then((users) => {
        setUsers(users);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Loading...</div>;
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

가장 큰 차이점이라면 use들의 hook 사용이다. useEffect 뿐만 아니라 useState 사용으로 state와 라이프 사이클 메소드를 연결짓는 코드 양이 줄었고, 좀 더 직관적으로 변했다.

즉, hook들은 함수형 컴포넌트에서 React state와 생명주기 기능을 “연동(hook into)“할 수 있게 해주는 함수라고 볼 수 있다. 그래서 가장 핵심적인 개념인 라이프 사이클의 개념이 들어가면 더욱 구동 원리가 파악이 될 것이다.

어떻게 구동되는건데?(라이프사이클의 useEffect)

작동 방법을 봤으니 원리를 이제 보도록 하자. 위에서 보던 것처럼 hook은 함수형에서 클래스 컴포넌트의 라이프 사이클 메소드 등의 기능을 사용하기 위해 태어났으므로, 기능을 살펴보자.
useEffect는 총 4가지의 기능을 사용한다.

  1. componentDidMount
  2. componentDidUpdate
  3. componentWillUnmount
  4. getDerivedStateFromProps

useEffect는 크게 4가지 형태로 구분된다.

  1. useEffect(callBack);
  2. useEffect(callBack, []);
  3. useEffect(callBack, [state1, state2]);
  4. useEffect(()=>{ return(() => func()) });
  1. 컴포넌트가 마운트 된 후, 업데이트되고 난 후, 언마운트 되기 전에 실행된다. 따라서, 네 가지 생명주기 메소드를 모두 사용한 것과 같다.
  2. 컴포넌트가 최초 렌더링 될 때만 실행된다. 따라서, componentDidMount의 역할을 수행한다.
  3. 최초 렌더링 + state1 또는 state2가 변경될 때 실행된다. 따라서, componentDidUpdategetDerivedStateFromProps의 역할을 수행한다.
  4. useEffect는 clean-up 함수를 return할 수 있는데, 이를 활용해 컴포넌트가 Unmount될 때 정리하거나 unsubscribe 해야할 것을 처리한다. 따라서, clean-up 함수는 componentWillUnmount의 역할을 수행한다.

4번의 return이 개념이 애매해서 추가 설명하자면

useEffect(()=>{
	console.log("hello");     
    return(() => exampleAPI.unsubscribe());
})

위의 경우 컴포넌트가 렌더링될 때마다 console.log("hello")를 수행하고, 컴포넌트가 unmount될 때 console.log("hello")exampleAPI.unsubscribe()를 수행한다.

추가로 렌더링되는 타이밍은 당연하게도 화면이 나오고 난 뒤인 컴포넌트 렌더링 - 화면 업데이트 - useEffect실행순으로 렌더링된다.
만약 그리기 이전에 동기화해야한다면 useLayoutEffect를 써서 화면 업데이트 이전에 렌더링되도록 해보자.

왜 무한루프?

이제 구동원리를 알았으니 이제 근본적 이유인 useEffect의 콜백 무한루프, 무한실행의 원인에 대해서 알아보자

우선 어떻게 구동되는건데?의 항목을 봤을때 형태가 4가지로 나뉘었다.

  1. useEffect(callBack);
  2. useEffect(callBack, []);
  3. useEffect(callBack, [state1, state2]);
  4. useEffect(()=>{ return(() => func()) });

여기서 무한 루프가 일어나는 곳은 1, 3이 가장 크고, setState와 함께 있을 때다.(참고: state가 변경되면 리액트는 리렌더링됨)

1번은 컴포넌트가 마운트, 업데이트, 언마운트때마다 실행되는 경우인데 만약 state가 변경되는 함수가 있다면 리렌더링이 일어나고 => 1번은 라이프 사이클 과정에 의해 실행 => state 관련 리렌더링 => ...이 일어남

useEffect(() => {
  setCount(count + 1);
});

3번은 최초 렌더링 + 종속성 배열의 변수 변경에 따라 실행되는데 state가 종속성 배열로, setStateuseEffect안에 있다면? 무한 루프다.

useEffect(() => {
  setCount(count + 1);
}, [count]);

위의 코드로 봐서는 당연하지 않나 싶지만 코드양이 커지고 구조를 한눈에 파악하지 못해 생기는 불상사들이 일어날 수 있다. 그러므로 useEffect를 쓰면서 렌더링이 되는지 안되는지와 마운트, 업데이트, 언마운트 시점을 생각해야한다.

해결법은 어떤게 있을까?

해결법

참고로 object(객체, 배열 등) 자체가 들어가게 되면 무조건

1. 관련 state와 종속성 배열의 분리: 변수 선언 및 이벤트핸들러 조작

위 코드를 가져온다면

import { useEffect, useState } from "react";

function CountInputChanges() {
  const [value, setValue] = useState("");
  const [count, setCount] = useState(-1);

  useEffect(() => setCount(count + 1), [value]);

  const onChange = ({ target }) => setValue(target.value);

  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
      <div>Number of changes: {count}</div>
    </div>
  );
}

이런 식으로 value라는 상태를 따로 선언해 set을 실행시키는 변수를 따로 만들어서 지정하면 된다.. 또 boolean 타입으로 만들어서 이벤트 핸들러에 할당해 할 수도 있다. 이벤트 핸들러에 콜백함수로 써줘 바로 실행 안되게도 한다.
핵심은 1. 종속성 배열에 써줄 변수 선언 2. 리렌더링을 원하는 컴포넌트의 이벤트 핸들러 같은 곳에 set을 써줌

2. useRef를 사용해서 해결

import { useState, useRef } from "react";

function CountInputChanges() {
  const [value, setValue] = useState("");
  const countRef = useRef(0);

  const onChange = ({ target }) => {
    setValue(target.value);
    countRef.current++;
  };
  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
      <div>Number of changes: {countRef.current}</div>
    </div>
  );
}

useRef가 반환하는 객체의 current 속성은 state처럼 변경될 때마다 컴포넌트가 다시 렌더링 되지 않는다는 점을 이용해서 위와 같이 구현할 수 있다.

3. 성능 문제이면 useMemo, useCallback을 사용

웹 성능을 높이겠다고 한번만 렌더링되게 useEffect를 쓰는 경우도 있다. 그러면서 무한 루프에 빠지게되는 경우가 있는데 useEffect를 빼고 useCallback을 써서 같은 호출을 여러번 반복하는 것을 방지하는 방법이 있다.

아니면 useEffect를 감싸는 방법도 있다.

const fetchData = useCallback(() => {
    async function fetchAndSetCategory() {
      const response = await fetch(`${API.MenuList}${location.search}`);
      const data = await response.json();
      setCategory(data.results);
    }
    fetchAndSetCategory();
  }, [location.search]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);
...

이런 식으로 말이다. 참고로 useCallback은 의존성 배열이 변경되는 경우, 이전에 기억하고 있던 함수 자체와 비교해서 다른 경우에만 리랜더시키는 hook이다.

useCallback의 의존성 배열 변수인 location.search의 값이 전 참조값과 비교했을 때 변하지 않았다면 호출을 하지 않는다.
내가 처음 의도했던 대로, useCallbackuseEffectuseCallback의 의존성 배열 값이 변경되지 않는 한 재호출 되지 않게 된다. 무한루프 탈출이다!

profile
코뿔소처럼 저돌적으로

5개의 댓글

comment-user-thumbnail
2023년 2월 27일

정말 useEffect는 알고 쓴다 생각할 때 쯤이면, 시련을 주더군요. '넌 아직 날 아직 몰라'라고 말하는 거 처럼.

답글 달기
comment-user-thumbnail
2023년 2월 27일

useEffect와 연관지어서 라이프 사이클까지 설명해주셔서 감사해요 항상 정리한다고 생각하는데 자꾸 까먹는걸까요 다시 복습하고 갑니다!

답글 달기
comment-user-thumbnail
2023년 3월 4일

useEffect 블로깅 시작해야하는데 이걸 보니 또 두렵습니다 ㅋㅋㅠ

답글 달기
comment-user-thumbnail
2023년 3월 4일

동작 원리를 잘 모르고 써서 무한 루프의 쓴 맛을 봤었습니다.. 다시 공부하고 가요 !!

답글 달기
comment-user-thumbnail
2023년 3월 5일

무작정 쓰기만 했던 저를 반성하게 되는 글이네요... 멋진 포스팅입니다 참고해서 다시 공부하겠습니당.. 포스팅할 때 참고해도 될까요? ㅎㅎ

답글 달기