logo
github
useEffect 완벽 가이드 정리
February 21, 2022

useEffect 완벽 가이드


1. 모든 렌더링은 고유의 Prop과 State가 있다

명심하셔야 할 점은 여느 특정 랜더링 시 그 안에 있는 count 상수는 시간이 지난다고 바뀌는 것이 아니라는 것입니다. 컴포넌트가 다시 호출되고, 각각의 랜더링마다 격리된 고유의 count 값을 “보는” 것입니다.

  • 함수가 호출될 때마다 count는 상수이자 독립적인 값(특정 랜더링 시의 상태)으로 존재한다는 뜻이다. 예를 들어 처음에 0이었다면 그 다음 호출됐을 땐 1, 다음은 2… 이런 식으로 말이다.

2. 모든 렌더링은 고유의 이벤트 핸들러를 가진다

특정 렌더링 시 그 내부에서 props와 state는 영원히 같은 상태로 유지됩니다.

3. 모든 렌더링은 고유의 이펙트를 가진다

이펙트 함수 자체가 매 렌더링마다 별도로 존재합니다. 각각의 이펙트 버전은 매번 렌더링에 “속한” count 값을 “봅니다”.

리액트는 여러분이 제공한 이펙트 함수를 기억해 놨다가 DOM의 변화를 처리하고 브라우저가 스크린에 그리고 난 뒤 실행합니다. 개념적으로, 이펙트는 렌더링 결과의 일부라 생각할 수 있습니다.

  • 즉, 이펙트는 매 렌더링 후 실행되며, 컴포넌트 결과물의 일부로서 특정 렌더링 시점의 prop과 state를 “본다는” 것이다.

4. 모든 랜더링은 고유의… 모든 것을 가지고 있다

본문에 함수형/클래스형 컴포넌트로 구현할 때 렌더링의 차이점을 보여주는 예시가 있다.

클래스형 컴포넌트에서 this.state.count 값은 특정 렌더링 시점의 값이 아니라 언제나 최신의 값을 가리킵니다.

이펙트 안에 정의해둔 콜백에서 사전에 잡아둔 값을 쓰는 것이 아니라 최신의 값을 이용하고 싶을 때가 있습니다. 제일 쉬운 방법은 ref를 이용하는 것인데 링크의 글 마지막 단락에 설명되어 있습니다. 참고

  • useRef 함수는 current 속성을 가지고 있는 객체를 반환하는데, 인자로 넘어온 초기값을 current 속성에 할당합니다. 이 current 속성은 값을 변경해도 상태를 변경할 때 처럼 React 컴포넌트가 다시 랜더링되지 않습니다. React 컴포넌트가 다시 랜더링될 때도 마찬가지로 이 current 속성의 값이 유실되지 않습니다.

단, 과거의 렌더링 시점에서 미래의 props나 state를 조회할 필요가 있을 때 주의하셔야 하는게, 이런 방식은 흐름을 거슬러 올라가는 일입니다. 잘못 되진 않았지만 (어떤 경우에는 반드시 필요하고요) 패러다임에서 벗어나는게 덜 “깨끗해” 보일 수 있습니다.

5. 그러면 클린업(cleanup)은 뭐지?

이펙트의 클린업은 “최신” prop을 읽지 않습니다. 클린업이 정의된 시점의 랜더링에 있던 값을 읽는 것입니다.

  • 예를 들어 id 값이 10에서 20으로 변경되었다 했을 때, 위에서 말한 대로 < {id: 20}을 가지고 UI를 렌더링 -> 브라우저가 그림 -> {id: 10}에 대한 이펙트를 클린업 -> {id: 20}에 대한 이펙트를 실행 > 순서로 진행된다.

6. 라이프사이클이 아니라 동기화

useEffect 는 리액트 트리 바깥에 있는 것들을 propsstate에 따라 동기화 할 수 있게 합니다.

여전히 모든 이펙트를 매번 랜더링마다 실행하는 것은 효율이 떨어질 수 있습니다. (그리고 어떤 경우에는 무한루프를 일으킬 수 있지요.)

7. 리액트에게 이펙트를 비교하는 법을 가르치기

우리는 이미 DOM 그 자체에 대해서는 배웠습니다. 매번의 리랜더링마다 DOM 전체를 새로 그리는 것이 아니라, 리액트가 실제로 바뀐 부분만 DOM을 업데이트 합니다. 이펙트에도 이런 방법을 적용할 수 있을까요? 이펙트를 적용할 필요가 없다면 다시 실행하지 않는 것이 좋을 텐데요.

그래서 특정한 이펙트가 불필요하게 다시 실행되는 것을 방지하고 싶다면 의존성 배열을(“deps” 라고 알려진 녀석이죠) useEffect 의 인자로 전달할 수 있는 것입니다. 이건 마치 우리가 리액트에게 “이봐, 네가 이 함수의 안을 볼 수 없는 것을 알고 있지만, 랜더링 스코프에서 name 외의 값은 쓰지 않는다고 약속할게.” 라고 말하는 것과 같습니다.

현재와 이전 이펙트 발동 시 이 값들이 같다면 동기화할 것은 없으니 리액트는 이펙트를 스킵할 수 있습니다. 랜더링 사이에 의존성 배열 안에 있는 값이 하나라도 다르다면 이펙트를 스킵할 수 없습니다. 모든 것을 동기화해야죠!

  • 즉, 리액트는 함수 안을 살펴볼 수 없지만, deps를 비교할 수 있다. 모든 deps가 같으므로 새 이펙트를 실행할 필요가 없다.

8. 리액트에게 의존성으로 거짓말하지 마라

그냥 의존성 배열을 비워 두면 [] 다른 값이 바뀌어도 배열 안의 값이 바뀌지 않았다고 판단해서 문제가 생길 수도 있다.

의존성 배열이 리액트에게 어떤 랜더링 스코프에서 나온 값 중 이펙트에 쓰이는 것 전부를 알려주는 힌트라고 인식한다면 말이 됩니다. count 를 사용하지만 deps 를 [] 라고 정의하면서 거짓말을 했지요. 이 거짓말 때문에 버그가 터지는 것은 시간 문제입니다!

첫 번째 랜더링에서 count 는 0 입니다. 따라서 첫 번째 랜더링의 이펙트에서 setCount(count + 1)setCount(0 + 1) 이라는 뜻이 됩니다. deps 를 [] 라고 정의했기 때문에 이펙트를 절대 다시 실행하지 않고, 결국 그로 인해 매 초마다 setCount(0 + 1) 을 호출하는 것입니다. 우리는 리액트에게 이 이펙트는 컴포넌트 안에 있는 값을 쓰지 않는다고 거짓말을 했습니다, 실제로는 쓰는데도 말이죠!

이펙트는 컴포넌트 안에 있는 값인(하지만 이펙트 바깥에 있는) count 값을 쓰고 있습니다. 따라서 [] 을 의존성 배열로 지정하는 것은 버그를 만들 것입니다. 리액트는 배열을 비교하고, 이 이펙트를 업데이트 하지 않을 것입니다.

9. 의존성을 솔직하게 적는 두 가지 방법

일반적으로 첫 번째 방법을 사용해 보시고, 필요하다면 두 번째 방법을 사용하세요.

1) 컴포넌트 안에 있으면서 이펙트에서 사용되는 모든 값이 의존성 배열 안에 포함되도록 고치는 것입니다.

의존성 배열에 count 값을 추가하면 이제 count 값은 이펙트를 다시 실행하고 매번 다음 인터벌에서 setCount(count + 1) 부분은 해당 랜더링 시점의 count 값을 사용할 겁니다. 이렇게 하면 문제를 해결하겠지만 count 값이 바뀔 때마다 인터벌은 해제되고 다시 설정될 것입니다. 아마 원하지 않던 동작이겠죠.

2) 두 번째 전략은 이펙트의 코드를 바꿔서 우리가 원하던 것 보다 자주 바뀌는 값을 요구하지 않도록 만드는 것입니다.

10. 의존성을 제거하는 몇 가지 공통적인 기술

1) 이펙트가 자급자족 하도록 만들기

무엇 때문에 count 를 쓰고 있나요? 오로지 setCount 를 위해 사용하고 있는 것으로 보입니다. 이 경우에 스코프 안에서 count 를 쓸 필요가 전혀 없습니다. 이전 상태를 기준으로 상태 값을 업데이트 하고 싶을 때는, setState 에 함수 형태의 업데이터를 사용하면 됩니다.

count 는 우리가 setCount(count + 1) 이라고 썼기 때문에 이펙트 안에서 필요한 의존성이었습니다. 하지만 진짜로 우리는 countcount + 1 로 변환하여 리액트에게 “돌려주기 위해” 원했을 뿐입니다. 하지만 리액트는 현재의 count 를 이미 알고 있습니다. 우리가 리액트에게 알려줘야 하는 것은 지금 값이 뭐든 간에 상태 값을 하나 더하라는 것입니다. 그게 정확히 setCount(c => c +1) 이 의도하는 것입니다. (이때 의존성은 같으므로 이펙트는 스킵한다.)

2) 함수형 업데이트와 Google Docs

오로지 필요한 최소한의 정보를 이펙트 안에서 컴포넌트로 전달하는게 최적화에 도움이 됩니다. 리액트로 생각하기 문서에 최소한의 상태를 찾으라는 내용이 포함되어 있습니다. 그 문서에 쓰인 것과 같은 원칙이지만 업데이트에 해당된다고 생각하세요.

하지만 setCount(c => c + 1) 조차도 그리 좋은 방법은 아닙니다. 좀 이상해 보이기도 하고 할 수 있는 일이 굉장히 제한적입니다. 예를 들어 서로에게 의존하는 두 상태 값이 있거나 prop 기반으로 다음 상태를 계산할 필요가 있을 때는 도움이 되지 않습니다. 다행히도 setCount(c => c + 1) 은 더 강력한 자매 패턴이 있습니다. 바로 **useReducer**입니다.

어떤 상태 변수가 다른 상태 변수의 현재 값에 연관되도록 설정하려고 한다면, 두 상태 변수 모두 useReducer 로 교체해야 할 수 있습니다. 리듀서는 컴포넌트에서 일어나는 “액션”의 표현과 그 반응으로 상태가 어떻게 업데이트되어야 할지를 분리합니다.

dispatch 함수를 의존성 배열에 넣으면 리액트는 컴포넌트가 유지되는 한 dispatch 함수가 항상 같다는 것을 보장합니다. 따라서 위의 예제에서 인터벌을 다시 구독할 필요조차 없습니다. (리액트가 dispatch, setState, useRef 컨테이너 값이 항상 고정되어 있다는 것을 보장하니까 의존성 배열에서 뺄 수도 있습니다. 하지만 명시한다고 해서 나쁠 것은 없습니다.)

이펙트 안에서 상태를 읽는 대신 무슨 일이 일어났는지 알려주는 정보를 인코딩하는 액션을 디스패치합니다. 이렇게 하여 이펙트는 step 상태로부터 분리되어 있게 됩니다. 이펙트는 어떻게 상태를 업데이트 할지 신경쓰지 않고, 단지 무슨 일이 일어났는지 알려줍니다. 그리고 리듀서가 업데이트 로직을 모아둡니다.

3) 왜 useReducer가 Hooks의 치트 모드인가

리듀서 그 자체를 컴포넌트 안에 정의하여 props를 읽도록 하면 됩니다.

이 패턴은 몇 가지 최적화를 무효화하기 때문에 어디서나 쓰진 마세요. 하지만 필요하다면 이렇게 리듀서 안에서 props에 접근할 수 있습니다. 이 경우조차 랜더링간 dispatch 의 동일성은 여전히 보장됩니다. 그래서 원한다면 이펙트의 의존성 배열에서 빼버릴 수도 있습니다. 이펙트가 재실행되도록 만들지 않을테니까요.

흔한 실수 중 하나가 함수는 의존성에 포함되면 안된다는 것입니다. 다행히도, 이 문제를 해결할 쉬운 방법이 있습니다. 어떠한 함수를 이펙트 안에서만 쓴다면, 그 함수를 직접 이펙트 안으로 옮기세요.

때때로 함수를 이펙트 안에 옮기고 싶지 않을 수도 있습니다. 예를 들어 한 컴포넌트에서 여러개의 이펙트가 있는데 같은 함수를 호출할 때, 로직을 복붙하고 싶진 않겠죠. 아니면 prop 때문이거나요. 이런 함수를 이펙트의 의존성으로 정의하지 말아야 할까요? 그렇게 생각하지 않습니다. 이펙트는 자신의 의존성에 대해 거짓말을 하면 안됩니다. 보통은 더 나은 해결책이 있습니다.이 글을 통해 배웠듯 컴포넌트 안에 정의된 함수는 매 랜더링마다 바뀝니다!

  • 대신 더 간단한 해결책이 두 개 있습니다. 먼저, 함수가 컴포넌트 스코프 안의 어떠한 것도 사용하지 않는다면, 컴포넌트 외부로 끌어올려 두고 이펙트 안에서 자유롭게 사용하면 됩니다. 저 함수는 랜더링 스코프에 포함되어있지 않으며 데이터 흐름에 영향을 받을 수 없기 때문에 deps에 명시할 필요가 없습니다. 우연히라도 propsstate를 사용할 수 없습니다.

  • 혹은 useCallback 훅으로 감쌀 수 있습니다. 함수의 의존성을 피하기보다 함수 자체가 필요할 때만 바뀔 수 있도록 만드는 것입니다. 제가 useCallbackdepsquery 를 포함하도록 고치면, getFetchUrl 을 사용하는 어떠한 이펙트라도 query 가 바뀔 때마다 다시 실행될 것입니다. useCallback 덕분에 query 가 같다면, getFetchUrl 또한 같을 것이며, 이펙트는 다시 실행되지 않을 것입니다.

  • 부모로부터 함수 prop을 내려보내는 것 또한 같은 해결책이 적용됩니다.

  • 비슷하게, useMemo 또한 복잡한 객체에 대해 같은 방식의 해결책을 제공합니다.

11. 함수도 데이터 흐름의 일부인가?

흥미롭게도 이 패턴은 클래스 컴포넌트에서 사용하면 제대로 동작하지 않는데, 이게 이펙트와 라이프사이클 패러다임의 결정적인 차이를 보여줍니다.

아마도 이런 생각을 하실겁니다. “이봐요 댄, 이제 우리 모두 useEffectcomponentDidMountcomponentDidUpdate 가 섞인 것이라는 것을 알고 있어요. 이렇게 계속 뒷북을 칠 필요는 없다구요!” 하지만 이 로직은 componentDidUpdate 에선 동작하지 않습니다.

진짜 클래스 컴포넌트로 이 수수께끼를 해결하는 방법은 이 꽉 깨물고 query 자체를 Child 컴포넌트에 넘기는 것 뿐입니다. Child 컴포넌트가 query 를 직접 사용하지 않음에도 불구하고 query 가 바뀔 때 다시 데이터를 불러오는 로직은 해결할 수 있습니다.

12. 마치며

장기적인 관점에서 데이터 불러오는 로직을 해결하기 위해 Suspense가 도입되면 서드 파티 라이브러리들이 기본적으로 리액트에게 어떤 비동기적인 행위(코드, 데이터, 이미지 등 모든 것)가 준비될 때까지 랜더링을 잠시 미룰 수 있도록 지원하게 될 것입니다.

corinthionia
🍀 Winning Mentality💭 능동적인 사고를 통한 성장을 위해 노력합니다
© Joohyun Kim (김주현) Corinthionia