티스토리 뷰

componentDidMount와 useEffect

useEffect로 componentDidMount를 비슷하게 흉내낼 수 있는 방법은 "useEffect(function, [])"을 사용하는 것이다.

이는, prop와 state를 잡아두기 때문에 초기 prop과 state를 확인할 수 있다는 것이 차이점이다.

즉, 비슷하지만, componentDidMount를 포함한 다른 라이프사이클 모델과는 다르다.

 

useEffect 기본

두 번째 인자의 배열은 빈 의존성 배열로, 리액트 데이터 흐름에 관여하는 어떠한 값도 사용하지 않겠다는 뜻이다.

즉, 다른 props와 state에 영향을 받지 않고 처음 컴포넌트가 새로 생성되는 시점에 한 번 실행된다.

 

만약, 잘못된 방식으로 의존성 체크를 생략하는 것보다 의존성을 필요로 하는 상황을 제거하는 몇 가지 전략으로 useReducer, useCallback을 사용하는 것이 권장된다.

 

데이터 페칭이 무한루프에 빠지는 이유

두 번째 인자로 의존성 배열을 전달하지 않으면, 무한루프에 빠질 수 있다. 의존성 배열이 없다면 useEffect는 매 렌더마다 실행되기 때문이다.

 

또한, 항상 바뀌는 값을 의존성 배열안에 넣으면 무한루프에 빠질 수 있다.

함수 때문에 무한 루프에 빠지면, 이펙트 안에 함수를 넣거나 호이스팅으르 하거나, useCallback을 사용할 수 있다.

객체일 경우에는 useMemo를 사용할 수 있다.

 

그리고, 가끔식 이전의 prop과 state를 사용하는 것을 방지하고 싶다면, 가변성 ref에 넣어 관리할 수 있다는 것도 알고 있으면 좋다.

 

리액트에서 이펙트 비교

리액트는 매번 리렌더링마다 DOM 전체를 새로 그리는 것이 아닌, 리액트가 실제로 변경된 부분만 DOM을 업데이트한다.

 

// 변경 전
<h1 className="Greeting">
	Hi, Dan
</h1>

// 변경 후
<h1 className="Greeting">
	Hi, Yuzhi
</h1>

만약, 위 처럼 className은 같지만, children의 값이 달라질 경우에 DOM 업데이트가 필요하다고 파악되지만, className은 그렇지 않기 때문에 아래의 코드만 호출된다.

domNode.innerText ="Hi, Yuzhi";

 

이처럼, 이펙트에도 위 처럼 적용시킬 수 있는 방법은 "의존성 배열"로 불필요한 useEffect 실행을 방지할 수 있다.

객체 비교 방식과는 다르게, 이펙트끼리는 비교가 불가능하다. 리액트는 함수를 호출해보지 않고 함수가 어떤 일을 하는지 알아낼 수 없기 때문이다.

 

그래서 특정한 이펙트가 불필요하게 다시 실행되는 것을 방지하기 위해 의존성 배열을 useEffect의 인자로 전달하면 된다.

useEffect(() => {
	document.title = "Hi, " + name;
, [name]};

이는, 렌더링 스코프에서 "name" 외의 값은 쓰지 않겠다는 약속과 같다.

따라서, 현재와 이전 이펙트 발동 시, 이 값들이 같다면 동기화할 것은 없기 때문에 이펙트를 스킵할 수 있게 된다.

 

useEffect의 의존성

const count = // ...

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

위 처럼, 코드를 작성한다면 빈 의존성 배열을 적용하여 다음 이펙트를 업데이트하지 않을 것이다. 따라서 한 번만 적용이 되고 종료가 될 것이다. 이를 개선하기 위해서는 두 가지 방법을 사용할 수 있다.

 

const count = // ...

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

변화가 감지되는 두 번째 인자에 count를 뎁스에 추가하여 의존성 배열을 올바르게 만듦으로써 개선할 수 있다.

count는 이펙트를 다시 실행하고, 매번 다음 Interval에서 count의 값을 올리는 부분은 해당 렌더링 시점의 count 값을 사용하게 된다.

 

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

또는 위 처럼, 의존성을 더 적게 넘겨주고 바꾸도록 하면 된다. 즉, 이전 값을 기억하고 값을 계산하게 만드는 것이다. 그래서 setCount에 함수 형태의 업데이터를 사용하면 된다. 그러면 이펙트는 더 이상 렌더링 스코프에서 count 값을 읽지 않는다.

 

위의 해결 방법으로 의존성을 제거하지 않고도 문제를 해결할 수 있을 것이다.

 

액션을 업데이트로부터 분리

위의 예시에서 "step" 상태 변수를 추가한다면? "step" 입력값에 따라 "count" 값을 더하도록 변경한다면 어떻게 될까?

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);
  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

위의 코드는, "step"을 이펙트 안에서 사용하고 있기 때문에 의존성 배열에 추가하고 있다. 그렇기 때문에, "step"이 변경되면 "Interval"을 다시 시작하게 된다.

 

만약, "step"이 변경되어도 "Interval"이 초기화되지 않는 것을 원하고, 의존성 배열에서 "step"을 제거한다면?

어떤 상태 변수가 다른 상태 변수의 현재 값에 연관되도록 설정하고 싶다면, 두 상태 변수 모두 "useReducer"로 교체할 수 있다.

 

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' }); // setCount(c => c + step) 대신에
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();

위 처럼, 수정할 경우에는 리액트는 컴포넌트가 유지되는 한 "dispatch" 함수가 항상 같다는 것을 보장한다.

따라서, 위의 코드에서 "Interval"을 다시 구독할 필요가 없다. 의존성 배열에서 제거할 수 있고, 명시할 수도 있다.

 

useReducer가 하는 일은?

  • 이펙트 안에서 상태를 읽는 대신 무슨 일이 일어났는지 알려주는 정보를 인코딩하는 액션을 디스패치하는 것이다.
  • useReducer를 사용하면 위의 코드에서 "step" 상태로부터 분리되게 만들 수 있다.
  • 이펙트는 어떻게 상태를 업데이트할 지 신경쓰지 않고, 무슨 일이 일어났는지 알려준다. 그리고 Reducer가 업데이트 로직을 모아둔다.

함수를 이펙트 안으로 옮기자!

많은 사람들이 함수는 의존성에 포함되면 안 된다는 생각을 가지고 있는 것이다.

이펙트 밖에서 함수를 생성했을 때, 코드는 제대로 동작하나 함수가 더 커지거나, "state/props"를 사용한다면 동기화가 되지 않는 문제가 있다.

 

그렇기 때문에, useEffect 안에서만 사용하는 함수는 이펙트 안에 넣는 것이 좋다.

// 이펙트 밖에 함수
function SearchResults() {
  const [query, setQuery] = useState('react');

  // fetch 함수
  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

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

}

// 이펙트 안에 함수
function SearchResults() {
  
  // fetch 함수 안으로 이동
  useEffect(() => {
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=react';
    }
    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }
    fetchData();
  }, []);

}

함수를 이펙트 안으로 옮김으로써 변동하는 "의존성"에 신경을 쓸 필요가 없게 된다.

만약, "query"라는 새로운 "state"를 사용한다면, 해당 상태만 의존성 배열에 추가하면 된다. 이렇게 수정하면, "query"가 바뀔 때마다 데이터를 다시 불러올 수 있다.

 

그러나, 함수를 이펙트 안으로 넣고 싶지 않을 때는?

만약, 함수를 이펙트 안으로 넣고 싶지 않다면, 한 컴포넌트에서 여러 개의 이펙트에서 같은 함수 또는 "prop" 호출이 필요하지만 로직을 복사/붙여넣기 하고 싶지 않을 때처럼 말이다.

// 첫 번째 예시, 의존성 배열 없음
function SearchResults() {
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  useEffect(() => {
    const url = getFetchUrl('react');
    // ...
  }, []); 

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 
  }, []);

}

// 두 번째 예시, 의존성 배열 있음
function SearchResults() {
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 
  }, [getFetchUrl]); 

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 
  }, [getFetchUrl]); 

}

위의 코드에서 첫 번째 코드는 "getFetchUrl"이 의존성 배열에 제외되어 있기 때문에 동기화가 되지 않는다.

두 번째 코드는, 의존생 배열이 추가되었고, 변경이 있을 때마다 렌더링이 너무 자주 일어나게 된다. 그래서 좋은 방법이 아니다.

 

그렇다면, 위의 문제들을 어떤 방법으로 개선할 수 있을까?

방법은 두 가지다.

  • 함수를 컴포넌트 스코프 외부에 작성하기
  • useCallback Hook 사용하기

 

function getFetchUrl(query) {
  return 'https://hn.algolia.com/api/v1/search?query=' + query;
}

function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react');
    // ...
  }, []);

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 
  }, []);

}

위 코드는 첫 번째 방법을 활용해서, "SearchResults" 함수에 있었던 "getFetchUrl" 함수를 외부로 옮김으로써 렌더링 스코프에 포함되지 않아 데이터 흐름에 영향을 받지 않는다. 그래서 "deps"에 명시할 필요가 없다.

 

function SearchResults() {
const [query, setQuery] = useState('react');

  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 
  }, [getFetchUrl]); 

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 
  }, [getFetchUrl]); 

  // ...
}

두 번째 방법으로, "getFetchUrl" 함수를 "useCallback"으로 감싸 사용할 수 있다. 그래서 "query"값이 변경될 때 함수가 다시 실행하도록 한다.

 

useCallback은 무슨 일을 할까?

"useCallback"은 의존성 체크에 레이어를 하나 더 더한다고 생각하면 된다. 함수의 의존성을 제거하는 것보다, 함수가 필요할 때만 변경할 수 있도록 한다.

위 코드에서는 "useCallback deps"에 "query"를 포함한다. 즉, "query"가 변경될 때마다 "getFetchUrl" 함수를 사용하는 이펙트가 다시 실행된다.

반대로, "query" 값이 이전과 같으면 "getFetchUrl" 함수는 이전과 동일하기 때문에 이펙트도 실행하지 않는다.

 

데이터 흐름에서 함수도 일부분일까?

데이터 패턴은 클래스 컴포넌트에서 사용하면 제대로 동작하지 않는다. 즉, 이펙트와 라이프 사이클은 동작이 다르다는 것이다.

 

위의 코드를 클래스 컴포넌트로 치환한다면 어떻게 작성할 수 있을까?

class Parent extends Component {
  state = {
    query: 'react'
  };
  fetchData = () => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
    ...
  };
  render() {
    return <Child fetchData={this.fetchData} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  render() {
    // ...
  }
}

// componentDidUpdate가 있을 때
class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    // 항상 거짓
    if (this.props.fetchData !== prevProps.fetchData) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

첫 번째 코드에서 보면, useEffect는 "componentDidMount"와 "componentDidUpdate"가 섞여 있다고 알려져 있으나, "componentDidUpdate"에서는 동작하지 않는 것으로 보인다.

 

두 번째 코드를 보면, "fetchData"는 클래스 메소드이며, "state"가 수정되어도 이 메소드는 달라지지 않는다.

"this.props.fetchData"는 "prevProps.fetchData"와 같기 때문에 절대 다시 데이터를 불러오지 않는다.

만약, 위의 조건문을 제거한다면 매번 렌더링을 할 때마다 데이터를 불러오게 된다. 이것도 해결 방법이 아니다.

 

이를 해결하기 위해서는, "query" 자체를 "Child" 컴포넌트에 넘겨서 "Child" 컴포넌트가 "query"를 직접 사용하지 않아도 "query"가 바뀔 때 다시 데이터를 불러오는 로직으로 해결할 수 있을 것이다.

 

class Parent extends Component {
  state = {
    query: 'react'
  };
  fetchData = () => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
    ...
  };
  render() {
    return <Child fetchData={this.fetchData} query={this.state.query} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    if (this.props.query !== prevProps.query) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

 

클래스 컴포넌트에서 함수 "prop" 자체는 실제로 데이터 흐름에 차지하는 부분이 없다. 메소드는 가변성이 있는 "this" 변수에 묶여 함수의 일관성을 보장할 수 없게 되고, 함수만 필요할 때도 이전 데이터와 비교하기 위해 많은 다른 데이터를 전달해야 할 것이다.

 

이 때, "useCallback"을 사용하면 함수는 명백하게 데이터 흐름에 포함하게 된다. 만약, 함수의 입력값이 변경되면 함수 자체가 변경되고, 그렇지 않다면 같은 함수로 유지하게 된다.

 

useMemo 또한 복잡한 객체에 대해 같은 방식의 해결책을 제공한다.

function ColorPicker() {
  // color가 진짜로 바뀌지 않는다면, Child의 얕은 props 비교는 그대로
  const [color, setColor] = useState('pink');
  const style = useMemo(() => ({ color }), [color]);
  return <Child style={style} />;
}

 

경쟁 상태(Race Condition)은?

클래스로 데이터를 불러오는 일반적인 방법은 무엇일까?

class Article extends Component {
  state = {
    article: null
  };
  componentDidMount() {
    this.fetchData(this.props.id);
  }
  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }
  // ...
}

위의 코드는 버그가 존재하는데, 컴포넌트 업데이트를 다루지 않았다.

"componentDidUpdate"를 추가하면 아래처럼 수정할 수 있을 것이다.

 

class Article extends Component {
  state = {
    article: null
  };
  componentDidMount() {
    this.fetchData(this.props.id);
  }
  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.fetchData(this.props.id);
    }
  }
  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }
  // ...
}

 

그러나, 수정된 코드에도 "순서"를 보장할 수 없는 오류가 있다.

만약, "id" 값이 "10"으로 데이터를 요청하고, 이 후 "20"으로 변경했다면 "id"의 값이 "20"인 요청이 먼저 시작된다. 그래서 먼저 요청된 것이 더 늦게 끝나서 원하는 결과가 아닌 잘못된 결과가 나타날 수 있다.

  • { id: 10 } => { id: 20 }

 

이를, "경쟁 상태"라고 한다. 보통 비동기 호출의 결과가 돌아올 때까지 기다린다고 여기는 것과 같다.

위에서 아래로 데이터 흐름이 이어지면서 "async" / "await"이 있는 코드에 흔히 나타난다.

 

이를 해결하기 위해서, 비동기 접근 방식에 "취소" 기능을 추가하여, 클린업 함수에서 바로 비동기 함수를 취소하는 방법을 사용하면 된다. 또는, "Boolean" 값으로 흐름을 멈추어야 할 타이밍을 조절할 수 있다.

 

function Article({ id }) {
  const [article, setArticle] = useState(null);

  useEffect(() => {
    let didCancel = false;
    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [id]);

}

 

참조 - useEffect 완벽 가이드 블로그

 

useEffect 완벽 가이드 요약

본 게시글은 useEffect 완벽 가이드를 정리 & 요약하는 글입니다.

velog.io