🤔 useEffect 로 componentDidMount 동작을 흉내내려면 어떻게 하지?
🤔 `useEffect` 안에서 데이터 페칭(Data fetching)은 어떻게 해야할까? 두번째 인자로 오는 배열(`[]`) 은 뭐지?
🤔 이펙트를 일으키는 의존성 배열에 함수를 명시해도 되는걸까?
🤔 왜 가끔씩 데이터 페칭이 무한루프에 빠지는걸까?
🤔 왜 가끔씩 이펙트 안에서 이전 state나 prop 값을 참조할까?
모든 렌더링은 고유의 Prop과 State가 있다.
effect를 이야기하기 전에 렌더링에 대해서 이야기해야 한다.
1 | function Counter() { |
위 코드에서 count는 그저 숫자이다. 그저 아래 코드와 같이 단순한 숫자에 불과하다.
1 | const count = 42 |
- 처음으로 컴포넌트가 렌더링될 때, useState로부터 가져온 count변수는 0이다.
- setCount(1)을 호출하면, 다시 컴포넌트를 호출하고, 이 때 count는 1이 되는 식
1 | // 처음 랜더링 시 |
state를 업데이트할 때마다, 리액트는 컴포넌트를 호출한다.
매 렌더링 결과물은 고유의 counter 상태 값을 살펴본다. 이 값은 함수 안에 상수로 존재하는 값
따라서 위에 표시한 줄은 어떠한 데이터 바인딩도 수행하지 않았다.
1 | <p>You clicked {count} times</p> |
이것은 렌더링할 때 숫자 값을 내장하는 것에 불과함. 이 숫자 값은 리액트를 통해 제공된다.
setCount를 호출할 때, 리액트는 다른 count 값과 함께 컴포넌트를 다시 호출한다. 그러면 리액트는 가장 최신의 렌더링 결과물과 일치하도록 DOM을 업데이트한다.
명심해야할 것은 count는 시간이 지난다고 바뀌는 것이 아니라는 것이다.
컴포넌트가 다시 호출되고, 각각의 렌더링마다 고유의 count 값을 보는 것! 👉🏻 UI 런타임으로서의 React
모든 렌더링은 고유의 이벤트 핸들러를 가진다
이벤트 핸들러의 경우는 어떨까?
3초 뒤에 count
값과 함께 alert을 띄우는 코드 예시:
1 | function Counter() { |
- count를 3으로 증가시킨다.
- “Show alert”을 누른다.
- setTimeout이 실행되기 전에 count를 5로 증가시킨다.
이때 alert 창에는 어떤 값이 나타날까?
정답은 3이 나온다! 그 이유는?
setTimeout이 실행되는 시점에 count는 3을 가지고 있기 때문이다.
왜 이렇게 동작하는 걸까?
count는 별개의 함수 호출마다 존재하는 상수 값이라는 이야기를 했다.
함수는 여러번 호출되지만 각각의 렌더링에서 함수 안의 count 값은 상수이자 독립적인 값으로 존재한다.
리액트만 그런 것이 아니라 보통의 함수도 같은 방식으로 동작한다.
1 | function sayHi(person) { |
- 외부의 someone 변수는 여러 번 재할당되고 있지만, 함수 내부에서 person과 엮여있는 name 이라는 지역 상수가 존재.
- 이 상수는 지역 상수이기 때문에 각각의 함수 호출로부터 분리되어 있음
- 결과적으로 setTimeout 함수가 실행될 때마다 고유의 name을 기억한다.
이 설명을 통해 이벤트 핸들러가 count 값을 잡아두었는지 알 수 있게 되었다.
매 렌더링마다 각각의 count 값을 보는 것
1 | // 처음 랜더링 시 |
👉🏻 따라서 효과적으로 각각의 렌더링은 고유한 버전의 handleAlertClick을 리턴한다. 그리고 각각의 버전은 고유의 count를 기억한다.
1 | // 처음 랜더링 시 |
이벤트 핸들러가 특정 렌더링에 속해있으며, alert 표시를 클릭할 때 그 렌더링 시점의 count state를 유지한 채로 사용하는 것
특정 렌더링 시 그 내부에서 props와 state는 영원히 같은 상태로 유지된다.
🤔 만약 props와 state가 렌더링으로부터 분리되어 있다면?
: 이를 사용하는 어떠한 값도 분리되어 있는 것. 그러므로 props와 state도 렌더링에 속해있는 것이고, 이벤트 핸들러 내부의 비동기 함수라 할지라도 count 값을 보게 된다.
모든 렌더링은 고유의 effect를 가진다.
어떻게 effect가 최신의 count 상태를 읽어들일까?
1 | function Counter() { |
현재 count는 컴포넌트 렌더링에 포함되는 상수이므로 가변되는 값은 아닐테고..?
이벤트 핸들러는 그 렌더링에 속한 state를 본다. count는 특정 범위 안에 속하는 변수이기 때문.
effect에도 똑같은 개념이 적용된다!
effect 함수 자체가 매 렌더링마다 별도로 존재한다.
“변화하지 않는” effect 안에서 count 변수가 임의로 바뀐다는 뜻이 아님
각각의 effect 버전은 매번 렌더링에 속한 count 값을 본다.
1 | // 최초 랜더링 시 |
리액트는 effect 함수를 기억해 놨다가 DOM의 변화를 처리하고 렌더링하고 난 뒤에 실행한다.
하나의 개념으로서 effect를 설명하고 있지만, 사실 매 렌더링마다 다른 함수라는 의미이다.
그리고 각각의 effect 함수는 그 렌더링에 속한 props와 state를 본다.
자세하게 이해할 수 있도록 첫 번째 렌더링을 차례대로 설명해보면
1 | 리액트: state가 0일 때 UI를 보여줘. |
그렇다면 버튼을 클릭했을 때 무슨 일이 일어나는지 다시 한 번 생각해보자.
1 | 컴포넌트: 야 리액트 내 상태 변경 좀; |
모든 랜더링은 고유의… 모든 것을 가지고 있다
이제 effect는 렌더링 후에 실행되며, 컴포넌트 결과물의 일부로서 특정 렌더링 시점의 props와 state를 본다는 것을 알았다.
생각이 맞는지 실험해보자. 약간 시간차를 두고 버튼을 여러번 누르면 어떻게 될까?
1 | function Counter() { |
각각의 타이머는 특정 렌더링에 속해있기 때문에 버튼을 클릭하면 매번 다른 컴포넌트가 만들어진다. 그러므로 여러번 클릭하더라도 로그는 순서대로 출력될 것이다.
하지만 클래스 컴포넌트로 만들면 이렇게 동작하지 않는다.
1 | import React, { Component } from 'react'; |
1 | componentDidUpdate() { |
this.state.count
값은 특정 렌더링 시점의 값이 아니라 언제나 최신의 값을 가리키기 때문이다.
클로저를 사용하여 클래스 컴포넌트 예제를 고칠 수 있다.
1 | import React, { Component } from 'react'; |
클로저는 접근하려는 값이 절대 바뀌지 않을 때 유용하다.
반드시 상수를 참조하고 있기 때문에 생각을 하기 쉽도록 만들어준다. props와 state는 특정 렌더링 안에서 절대 바뀌지 않는다.
흐름을 거슬러 올라가기
컴포넌트의 렌더링 안에 있는 모든 함수(이벤트 핸들러, effect, setTimeout)는 렌더가 호출될 때 정의된 porps와 state 값을 잡아둔다.
다시 한 번 위 문장을 생각해보면 두 예제는 같다.
1 | function Example(props) { |
1 | function Example(props) { |
props나 state를 컴포넌트 안에서 일찍 읽어들였는지 아닌지는 상관없다. (값들은 변경되지 않기 때문!)
하나의 렌더링 스코프 안에서 props와 state는 변하지 않은 값으로 남아있다.
🤔 effect 안에 정의해둔 콜백에서 사전에 잡아둔 값을 쓰는 것이 아니라 최신의 값을 이용하고 싶다면?
: 제일 쉬운 방법은 ref를 사용하는 것. 👉🏻 함수형 컴포넌트와 클래스, 어떤 차이가 존재할까?
과거의 렌더링 시점에서 미래의 props나 state를 조회해야 할 필요가 있을 때 주의해야 할 것이 이런 방식은 흐름을 거슬러 올라가는 일이다. 잘못되진 않았지만, 패러다임에서 벗어나는게 덜 깨끗해 보일 수 있다.
1 | function Example() { |
리액트로 어떠한 값을 직접 변경하는 것이 꺼림칙해 보이지만, 리액트의 클래스 컴포넌트는 정확하게 이런 식으로 this.state
를 재할당하고 있다.
미리 잡아둔 props 및 state와는 달리 특정 콜백에서 latestCount.current
의 값을 읽어 들일 때 언제나 같은 값을 보장하지 않는다.
정의된 바에 따라 이 값은 언제나 변경할 수 있다. 그렇기 때문에 이런 사용 방법은 기본 동작이 아니다.
그러면 클린업(cleanup)은 뭐지?
어떤 effect는 클린업 단계를 가질 수도 있다.
클린업의 목적은 구독과 같은 이벤트를 되돌리는 것.
1 | useEffect(() => { |
첫 번째 렌더링에서 prop이 {id: 10}이고, 두 번째 렌더링에서 {id: 20}이라고 가정해보자.
- 리액트가 {id: 10}을 다루는 이펙트를 클린업한다.
- 리액트가 {id: 20}을 가지고 UI를 렌더링한다.
- 리액트가 {id: 20}을 다루는 이펙트를 실행한다.
이 멘탈 모델대로라면, 클린업이 리렌더링 되기 전에 실행되고 이전에 prop을 보고 그 다음 새 effect가 리렌더링 이후 실행되기 때문에 새 prop을 본다고 생각할 수 있다. 👉🏻 클래스의 라이프사이클
여기서는 잘못된 내용. 왜 그런지 알아보자.
리액트는 브라우저가 페인트하고 난 뒤에야 effect를 실행한다.
대부분의 effect가 스크린 업데이트를 가로막지 않기 때문에 앱을 빠르게 만들어준다. 마찬가지로 effect의 cleanup도 미뤄진다. 이전 effect는 새 prop과 함께 리렌더링 되고 난 뒤에 클린업 된다.
- 리액트가 {id: 20}을 가지고 UI를 렌더링한다.
- 브라우저가 실제 그리기를 한다. 화면 상에서 {id: 20}이 반영된 UI를 볼 수 있다.
- 리액트는 {id: 10}에 대한 이펙트를 클린업한다.
- 리액트가 {id: 20}에 대한 이펙트를 실행한다.
🤔 어떻게 prop이 {id: 20}으로 바뀌고 나서도 이펙트의 클린업이 이전 값인 {id: 10}을 보는거지?
: 이전 단락을 이용해보자면,
컴포넌트가 렌더링 안에 있는 모든 함수는 (이벤트 핸들러, 이펙트, 타임아웃이나 그 안에서 호출되는 API 등) 랜더가 호출될 때 정의된 props와 state 값을 잡아둔다.
즉, 이펙트의 클린업은 최신 prop을 읽지 않는다. 클린업이 정의된 시점의 렌더링에 있던 값을 읽는다.
1 | // 첫 번째 랜더링, props는 {id: 10} |
1 | // 다음 랜더링, props는 {id: 20} |
리액트는 페인팅 이후 effect를 다루는게 기본이며 그 결과 앱을 빠르게 만들어 준다.
라이프사이클이 아니라 동기화
리액트는 처음 렌더링 결과물과 업데이트를 통합하여 표현한다.
이로 인해 프로그램의 엔트로피를 줄일 수 있다. (이게 뭘 의미하는거지?)
“엔트로피”는 코드의 무질서 또는 예측 불가능성 정도를 나타낸다.
만약 프로그램이 명령형 스타일로 작성되면 경로를 추적하기가 어려워져 버그 및 기타 예기치 않은 동작이 발생할 수 있다. 리액트는 반대로 선언적 스타일로 작성하기 때문에 코드를 더 쉽게 이해하고 유지 관리할 수 있도록 한다.
1 | function Greeting({ name }) { |
<Greeting name="Dan" />
을 렌더링 한 다음에 <Greeting name="Yuzhi" />
를 렌더링하던지, 아예 <Greeting name="Yuzhi" />
만 렌더링하던지 모든 경우의 결과는 “Hello, Yuzhi”로 같다.
리액트는 우리가 지정한 props와 state에 따라 DOM과 동기화한다.
렌더링 시 “마운트”와 “업데이트”의 구분이 없다.
effect도 같은 방식으로 생각해야 한다. useEffect
는 리액트 트리 바깥에 있는 것들을 props와 state에 따라 동기화 할 수 있게 한다.
1 | function Greeting({ name }) { |
위 코드는 마운트/업데이트/언마운트 멘탈 모델과는 다르다. 만약 컴포넌트가 첫 번째로 렌더링할 때와 그 후에 다르게 동작하는 effect를 작성하려고하면 흐름을 거스르는 일을 하려고 하는 것.
렌더링 결과물이 목적지에 따라가는 것이 아니라 여정에 따른다면 동기화에 실패한다.
컴포넌트를 prop A, B, C 순서로 렌더링하던지, 바로 C를 렌더링하던지 별로 신경쓰이지 않아야 한다. 결국 마지막의 결과물은 같아야 한다.
당연하지만 여전히 모든 effect를 매번 렌더링마다 실행하는 것은 효율이 떨어질 수 있다. 그렇다면 이 문제를 어떻게 해결할 수 있을까?
리액트에게 effect를 비교하는 법을 가르치기
우리는 리액트가 DOM 전체를 매번 새로 그리는 것이 아니라 실제로 바뀐 부분만 DOM을 업데이트한다는 것을 배웠다.
아래의 컴포넌트를
1 | <h1 className="Greeting">Hello, Dan</h1> |
이렇게 바꾼다면
1 | <h1 className="Greeting">Hello, Yuzhi</h1> |
리액트는 두 객체를 비교
1 | const oldProps = { className: 'Greeting', children: 'Hello, Dan' }; |
각각의 prop을 짚어보고 children이 바뀌어서 DOM 업데이트가 필요하다고 파악했지만 className은 동일하므로
1 | domNode.innerText = 'Hello, Yuzhi'; |
위 코드만 호출된다.
🤔 이펙트를 적용할 필요가 없다면 다시 실행하지 않는 것이 좋을텐데, 이펙트에도 이런 방법을 적용하면 되지 않을까?
예를 들어, 아래의 컴포넌트는 상태 변화 때문에 다시 렌더링 될 것이다.
1 | function Greeting({ name }) { |
하지만 effect는 counter 상태값을 사용하지 않는다. 이펙트는 document.title
, name
prop을 동기화 하지만 name prop은 같다. document.title
을 매번 counter 값이 바뀔 때마다 재할당하는 것은 효율적이지 않아보인다.
그렇다면 이펙트를 비교하면 안될까?
1 | let oldEffect = () => { |
리액트는 함수를 호출해보지 않고 함수가 어떤 일을 하는지 알아낼 수는 없다.
그래서 특정한 이펙트가 불필요하게 다시 실행되는 것을 방지하고 싶다면 의존성 배열(”deps”) 을 useEffect 인자로 전달할 수 있는 것이다.
1 | useEffect(() => { |
이건 마치 우리가 리액트에게
“야 리액트 너가 이 함수의 안을 볼 수 없는건 아는데 렌더링 스코프에서 name 이외 값은 쓰지 않는다고 약속할게”
라고 말하는 것과 같다.
현재와 이전 이펙트 발동 시 이 값들이 같다면 동기화할 것은 없으니 리액트는 이펙트를 스킵할 수 있다.
1 | const oldEffect = () => { |
렌더링 사이에 의존성 배열 안에 있는 값이 하나라도 다르면 이펙트를 스킵할 수 없다.
리액트에게 의존성으로 거짓말하지 마라
의존성에 대해 리액트에게 거짓말을 할 경우 좋지 않은 결과를 가져온다.
1 | function SearchResults() { |
“하지만 저는 마운트 될 때만 이펙트를 실행하고 싶다고요!” 라고 할 수 있다.
그 이펙트에 허용될 값은 반드시 거기 있어야 한다는 것을 기억해두자.
위 코드는 문제를 일으킬 수 있다. 예를 들어 데이터 불러오는 로직이 무한 루프에 빠질 수도 있고, 소켓이 너무 자주 반응할 수도 있다. 이런 문제를 해결하는 방법은 의존성을 제거하는 것이 아니다.
일단 이 문제에 대해 더 자세히 알아보자.
의존성으로 거짓말을 하면 생기는 일
만약 deps가 이펙트에 사용하는 모든 값을 가지고 있다면 이펙트는 언제 다시 이펙트를 실행해야 할 지 알고 있다.
1 | useEffect(() => { |
👉🏻 의존성이 다르기 때문에 이펙트를 다시 실행한다.
만약 이펙트에 []
를 넘겨주었다면? 새 이펙트 함수는 실행되지 않는다.
1 | useEffect(() => { |
또 하나의 예를 들어보자.
예를 들어, 매 초마다 숫자가 올라가는 카운터를 작성한다고 해보자. 클래스 컴포넌트의 개념을 적용했을 때 우리의 직관은
“인터벌을 한 번만 실행하고, 한 번만 제거하자”
가 된다. 이 생각을 가지고 useEffect를 사용한 코드로 변환하게 되면, 직관적으로 deps에 []
를 넣게 된다.
1 | function Counter() { |
이 예제는 한 번만 증가한다.
의존성 배열은 내가 언제 이펙트를 다시 실행해야 할지 지정할 때 쓰인다.라고 생각하면 자가당착에 빠진다.
하지만 의존성 배열이 리액트에게 어떤 렌더링 스코프에서 나온 값 중 이펙트에 쓰이는 것 전부를 알려주는 힌트라고 인식한다면 말이된다.
count를 사용하지만 deps를 []
라고 정의하면서 거짓말을 했기 때문에 이런 오류가 발생하는 것이다.
첫 번째 렌더링에서 count는 0이다. 따라서 첫 번째 렌더링 이펙트에서 setCount(count + 1)
는 setCount(0 + 1)
이라는 뜻이 된다.
deps를 []라고 정의했기 때문에 이펙트를 다시 실행하지 않고 결국 그로 인해 매 초마다 setCount(0 + 1)을 호출하는 것이다.
1 | // 첫 번째 랜더링, state는 0 |
지금 우리는 리액트에게 이펙트는 컴포넌트 안에 값을 쓰지 않는다고 거짓말을 한 상태이다.
이펙트는 컴포넌트 안에 있는 값인 count 값을 쓰고 있다.
1 | const count = // ... |
따라서 []
을 의존성 배열로 지정하는 것은 버그를 만들 것이다. 리액트는 배열을 비교하고, 이 이펙트를 업데이트 하지 않는다.
이런 종류의 이슈는 떠올리기 어렵다. 따라서 언제나 이펙트에 의존성을 솔직하게 전부 명시하는 것을 중요한 규칙으로 받아들여야한다. 👉🏻 이 규칙을 강제하길 원할 때 쓸 수 있는 린트 규칙
의존성을 솔직하게 적는 두 가지 방법
의존성을 솔직하게 적는 두 가지 전략이 있다.
1. 컴포넌트 안에 있으면서 이펙트에 사용되는 모든 값이 의존성 배열 안에 포함되도록 수정
count를 deps 안에 추가하기
1 | useEffect(() => { |
이제 count 값은 이펙트를 다시 실행하고 매번 다음 인터벌에서 setCount(count + 1)
부분은 해당 렌더링 시점의 count 값을 사용할 것이다.
1 | // 첫 번째 랜더링, state는 0 |
이렇게 하면 문제를 해결하겠지만 count 값이 바뀔 때마다 인터벌은 해제되고 다시 설정될 것이다.
👉🏻 의존성이 다르기 때문에 이펙트를 다시 실행한다.
2. 이펙트의 코드를 바꿔서 자주 바뀌는 값을 요구하지 않도록 만드는 것
의존성을 더 적게 넘겨주도록 바꾸면 된다.
의존성을 제거하는 몇 가지 공통적인 기술을 살펴보자.
이펙트가 자급자족 하도록 만들기
이펙트의 의존성에서 count를 제거하도록 만들고 싶다면?
1 | useEffect(() => { |
우리는 지금 무엇때문에 count를 쓰고 있는 걸까? 오로지 setCount를 위해 사용하고 있다.
사실 이 경우는 count를 쓸 필요가 없다. 이전 상태를 기준으로 상태 값을 업데이트 하고싶을 때는 setState에 함수 형태의 업데이터를 사용하면 된다.
1 | useEffect(() => { |
- 이전 코드에서 count는 setCount(count + 1)이라고 썼기 때문에 이펙트 안에서 필요한 의존성이었다.
- 하지만 count를 count + 1로 변환하여 리액트에게 돌려주기 위해 원했을 뿐. (그러나 리액트는 현재의 count를 이미 알고있다.)
- 우리가 리액트에게 알려줘야 하는 것은? 지금 값이 뭐든 간에 상태 값을 하나 더하라는 것
👉🏻 그게 정확히 setCount(c ⇒ c + 1)이 의도하는 것
리액트에게 상태가 어떻게 바뀌어야 하는지 “지침을 보내는 것”이라고 생각할 수 있다. 이 “업데이터 형태” 또한 다른 케이스에서 사용할 수 있다. 👉🏻 여러개의 업데이트를 묶어서 처리해야할 때(batching)
이 이펙트가 한 번만 실행되었다 하더라도, 첫 번째 렌더링에 포함되는 인터벌 콜백은 인터벌이 실행될 때마다 c ⇒ c + 1
이라는 업데이트 지침을 전달하는데 완벽하게 들어맞는다. 더 이상 현재의 count 상태
함수형 업데이트와 Google Docs
동기화에 대해 생각할 때 흥미로운 부분은 종종 시스템 간의 ‘메세지’를 상태와 엮이지 않은 채로 유지하고 싶을 때가 있다는 것이다.
예를 들어, Google Docs에서 문서를 편집하는 것은 실제로 서버로 전체 페이지를 보내는 것이 아니다. 그 대신 사용자가 무엇을 하고자 했는지 표현한 것을 보낸다.
우리가 사용하는 경우는 다르겠지만, 이펙트에도 같은 철학이 적용된다.
오로지 필요한 최소한의 정보를 이펙트 안에서 컴포넌트로 전달하는게 최적화에 도움이 된다.
setCount(c ⇒ c + 1) 같은 업데이터 형태는 setCount(count + 1)보다 명백히 적은 정보를 전달한다. 현재의 카운트 값에 오염되지 않기 때문이다. 그저 증가해야한다라는 행위를 표현할 뿐이다.
하지만 setCount(c ⇒ c + 1)
조차도 그리 좋은 방법은 아니다.
조금 이상해 보이기도 하고 할 수 있는 일이 굉장히 제한적이다.
예를 들어 서로에게 의존하는 두 상태 값이 있거나 prop 기반으로 다음 상태를 계산할 필요가 있을 때는 도움이 되지 않는다. 다행히도 setCount(c ⇒ c + 1)
은 자매 패턴이 있다. 바로 useReducer
.
액션을 업데이트로부터 분리하기
이전의 예제를 count와 step 두 가지 상태 변수를 가지는 것으로 바꿔보자.
setInterval은 step 입력값에 따라 count 값을 더한다.
1 | function Counter() { |
이 예제의 현재 동작은 step이 변경되면 인터벌을 다시 시작한다. 이펙트를 분해하고 새로 설정하는데는 아무 문제가 없고, 특별히 좋은 이유가 있지 않다면 분해하는 것을 피하지 말아야 한다.
하지만 step이 바뀐다고 인터벌 시게가 초기화되지 않는 것을 원한다면 어떻게 해야할까?
이펙트의 의존성 배열에서 step을 제거하려면 어떻게 해야할까?
어떤 상태 변수가 다른 상태 변수의 값에 연관되도록 설정하려고 한다면, 두 상태 변수 모두 useReducer로 교체해야한다.
setSomething(something => ...)
같은 코드를 작성하고 있다면, 대신 reducer를 써보는 것을 고려하기 좋은 타이밍!
reducer는 컴포넌트에서 일어나는 ‘액션’의 표현과 그 반응으로 상태가 어떻게 업데이트 되어야 할지를 분리한다.
이펙트 안에서 step의 의존성을 dispatch로 변경하기
1 | const [state, dispatch] = useReducer(reducer, initialState); |
- 전체 코드
1 | import React, { useReducer, useEffect } from 'react'; |
useReducer를 사용하면 좋은 이유는?
: 리액트는 컴포넌트가 유지되는 한 dispatch
함수가 항상 같다는 것을 보장한다.
따라서 위의 예제에서 인터벌을 다시 구독할 필요가 없어지는 것.
(리액트가 dispatch
, setState
, useRef
컨테이너 값이 항상 고정되어 있다는 것을 보장하니까 의존성 배열에서 뺄 수도 있지만, 명시해서 나쁠 것은 없다.)
effect 안에서 상태를 읽는 대신 무슨 일이 일어났는지 알려주는 정보를 인코딩하는 액션을 dispatch한다. 이렇게 해서 이펙트는 step 상태로부터 분리되어 있게 된다.
effect는 어떻게 상태를 업데이트할지 신경쓰지 않고, 단지 무슨 일이 일어났는지 알려준다. 그리고 reducer가 업데이트 로직을 모아둔다.
1 | const initialState = { |
왜 useReducer가 Hooks의 치트 모드인가?
우리는 이펙트가 이전 상태를 기준으로 상태를 설정할 필요가 있을 때 어떻게 의존성을 제거하는지 살펴보았다.
하지만 다음 상태를 계산하는데 props가 필요하다면 어떨까? 예를 들어, API가 <Count step={1} />
이라면?
의존성으로 props.step을 설정하는 것을 피할 수 없나? 피할 수 있다.
reducer 자체를 컴포넌트 안에 정의하여 props를 읽도록 하면 된다.
1 | function Counter({ step }) { |
이 경우조차 랜더링간 dispatch
의 동일성은 여전히 보장된다. 그래서 원한다면 이펙트의 의존성 배열에서 빼버릴 수도 있다.
“이게 어떻게 가능하지?”, “어떻게 다른 렌더링에 포함된 이펙트 안에서 호출된 reducer가 props를 알고있지?”라는 생각이 들 수도 있다. 👉🏻 답은 dispatch
를 할 때에 있다.
리액트는 그저 액션을 기억해 놓는다.
하지만 다음 렌더링 중에 reducer를 호출할 것이다. 이 시점에서 새 props가 스코프 안으로 들어오고 이펙트 내부와는 상관이 없게되는 것.
이래서 useReducer를 Hooks의 치트모드라고 생각하는 것이다. 업데이트 로직과 그로 인해 무엇이 일어나는지 서술하는 것을 분리할 수 있도록 만들어준다. 그 다음은 이펙트의 불필요한 의존성을 제거하여 필요할 때 보다 더 자주 실행되는 것을 피할 수 있도록 도와준다.
함수를 이펙트 안으로 옮기기
흔한 실수 중 하나가 함수는 의존성에 포함되면 안된다는 것이다.
예를 들어보면, 이 코드는 동작하는 것처럼 보일 수 있다.
1 | function SearchResults() { |
일단 코드는 동작한다. 하지만 간단히 로컬 함수를 의존성에서 제외하는 해결책은 컴포넌트가 커지면서 모든 경우를 다루고 있는지 보장하기 아주 힘들다는 문제가 있다.
각 함수가 5배는 커져서 코드를 나누었다고 가정해보자.
1 | function SearchResults() { |
이제 나중에 이 함수들 중에 하나가 state나 prop을 사용한다고 생각해 보자.
1 | function SearchResults() { |
만약 이런 함수를 어떠한 이펙트에도 deps를 업데이트 하는 것을 깜빡했다면, 이펙트는 prop과 state의 변화에 동기화하는데 실패할 것이다.
다행히도, 이 문제를 해결할 수 있는 방법이 있다.
함수를 이펙트 안에서만 사용한다면 그 함수를 직접 이펙트 안으로 옮긴다.
1 | function SearchResults() { |
나중에 getFetchUrl을 수정하고 query state를 써야한다고 하면 이펙트 안에 있는 함수만 고치면 된다는 것을 발견할 수 있다. query를 이펙트의 의존성으로 추가만 하면 된다.
1 | function SearchResults() { |
하지만 저는 이 함수를 이펙트 안에 넣을 수 없어요.
때때로 함수를 이펙트 안에 옮기고 싶지 않을 수도 있다.
예를 들어, 한 컴포넌트에서 여러개의 이펙트가 있는데 같은 함수를 호출할 때, 로직을 복붙하고 싶지는 않다.
이런 함수를 이펙트의 의존성으로 정의하지 말아야할까?
다시 말하자면, 이펙트는 자신의 의존성에 대해 거짓말을 하면 안된다.
보통은 더 나은 해결책이 있다. 흔한 오해 중 하나가 ‘함수는 절대 바뀌지 않는다’이다. 하지만 글을 통해 배웠듯, 컴포넌트 안에 정의된 함수는 매 렌더링마다 바뀐다.
하지만 그로 인해 문제가 발생한다. 두 이펙트가 getFetchUrl을 호출한다고 가정해보자.
1 | function SearchResults() { |
이 경우 getFetchUrl
을 각각의 이펙트 안으로 옮기게 되면 로직을 공유할가 없는데..?
두 이펙트 모두 매 렌더링마다 바뀌는 getFetchUrl
에 기대고 있으니, 의존성 배열도 쓸모가 없다.
1 | function SearchResults() { |
어떻게 해결해야 할까? 간단한 해결책 두 가지가 있다.
함수가 컴포넌트 스코프 안의 어떠한 것도 사용하지 않는다면, 컴포넌트 외부로 끌어올려두고 이펙트 안에서 자유롭게 사용한다.
1 | **// ✅ 데이터 흐름에 영향을 받지 않는다 |
저 함수는 렌더링 스코프에 포함되어 있지 않으며, 데이터 흐름에 영향을 받을 수 없기 때문에 deps에 명시할 필요가 없다.
useCallback 훅으로 감싼다.
1 | function SearchResults() { |
useCallback
은 의존성 체크에 레이어를 하나 더 더하는 것이다.
다시 말해서 문제를 다른 방식으로 해결하는데, 함수의 의존성을 피하기보다 함수 자체가 필요할 때만 바뀔 수 있도록 만드는 것
이 접근 방식이 왜 유용한걸까?
아까 예제는 'react'
, 'redux'
라는 두 가지 검색 결과를 보여주었다. 하지만 입력을 받는 부분을 추가하여 임의의 query를 검색할 수 있다고 가정해보자. 그래서 query를 인자로 받는 대신 getFetchUrl
이 지역 상태로부터 이를 읽어들인다.
그렇게 수정하면서 즉시 query 의존성이 빠져있다는 사실을 파악할 수 있다.
1 | function SearchResults() { |
useCallback의 deps에 query를 포함하도록 고치면, getFetchUrl을 사용하는 어떤 이펙트라도 query가 바뀔 때마다 실행할 것
1 | function SearchResults() { |
useCallback
덕분에 query
가 같다면, getFetchUrl
또한 같을 것이며, 이펙트는 다시 실행되지 않을 것이다. 하지만 query
가 바뀐다면 getFetchUrl
또한 바뀌고 데이터를 다시 fetch한다.
(마치 스프레드 시트에서 어떤 셀을 바꾸면 다른 셀이 자동으로 다시 계산되는 것과 비슷하다.)
그저 데이터 흐름과 동기화에 대한 개념을 받아들인 결과
부모로부터 함수 prop을 내려보내는 것 또한 같은 해결책이 적용된다.
1 | function Parent() { |
fetchData
는 오로지 Parent
의 query
상태가 바뀔 때만 변하기 때문에, Child
컴포넌트는 앱에 꼭 필요할 때가 아니라면 데이터를 다시 fetch하지 않을 것.
함수도 데이터 흐름의 일부인가?
이 패턴은 클래스 컴포넌트에서 사용하면 제대로 동작하지 않는데, effect와 라이프사이클 패러다임의 결정적인 차이를 보여준다.
위 코드를 클래스 컴포넌트로 치환했다고 가정해보자.
1 | class Parent extends Component { |
하지만 이 로직은 componentDidUpdate
에선 동작하지 않는다.
1 | class Child extends Component { |
당연히도 fetchData
는 클래스 메서드이다. state가 바뀌었다고 저 메서드가 달라지지는 않는다.
따라서 this.props.fetchData는 prevProps.fetchData와 같기 때문에 절대 다시 데이터를 fetch하지 않는다. 그렇다면 아까 조건문을 제거하면 어떻게 될까?
1 | componentDidUpdate(prevProps) { |
이렇게 되면 매번 다시 렌더링할 때마다 데이터를 불러올텐데…?
혹시 특정한 query를 바인딩해두면?
1 | render() { |
이렇게 되면 query가 바뀌지 않았는데도 this.props.fetchData !== prevProps.fetchData
는 언제나 true가 되기 때문에 결국 매번 데이터를 다시 fetch한다.
진짜 클래스 컴포넌트로 이 문제를 해결하는 방법은 query 자체를 Child 컴포넌트에 넘기는 것 뿐
Child 컴포넌트가 query를 직접 사용하지 않음에도 불구하고 query가 바뀔 때 다시 데이터를 불러오는 로직은 해결할 수 있다.
1 | class Parent extends Component { |
클래스 컴포넌트에서 함수 prop 자체는 실제로 데이터 흐름에서 차지하는 부분이 없다.
메서드는 가변성이 있는 this 변수에 묶여 있기 때문에 함수의 일관성을 장담할 수 없게된다. 그러므로 우리가 함수만 필요할 때도 ‘차이’를 비교하기 위해 온갖 데이터를 전달해야 했다.
부모 컴포넌트로부터 내려온 this.props.fetchData가 어떤 상태에 기대고 있는지, 아니면 그냥 상태가 바뀌기만 하는 것인지 알 수가 없다.
useCallback을 사용하면 함수는 명백하게 데이터 흐름에 포함된다.
만약 함수의 입력값이 바뀌면 함수 자체가 바뀌고, 만약 그렇지 않다면 같은 함수로 남아있다고 말할 수 있다.
useCallback
덕분에 props.fetchData
같은 props 변화는 자동적으로 하위 컴포넌트로 전달된다.
useMemo 또한 복잡한 객체에 대해 같은 방식의 해결책을 제공한다.
1 | function ColorPicker() { |
useCallback
은 꽤 좋은 돌파구이며 함수가 전달되어 자손 컴포넌트의 이펙트 안에서 호출되는 경우 유용하다. 아니면 자손 컴포넌트의 메모이제이션이 깨지지 않도록 방지할 때도 쓰인다. 하지만 훅 자체가 콜백을 내려보내는 것을 피하는 더 좋은 방법을 함께 제공한다.
Review
useEffect가 뭔지 정말 모르겠다!라고 생각했었는데 Dan Abramov님이 쓰신 useEffect 완벽 가이드 글을 정리하면서 effect가 어떤 방식으로 동작하는지 조금은 개념이 잡힌 것 같다. 뿐만 아니라 클래스형 컴포넌트와 함수형 컴포넌트의 차이에 대해서도 간략하게 알 수 있게 된 것 같아서 도움이 되었다. (공부하는데 2일이 걸렸다… 너무 힘들었다..!)
+) 마지막은 제일 좋아하는 박명수 짤로 마무리..