프로젝트를 수행하며 가장 이해가 안됐던 게 useEffect
다. 아니 사이드이펙트를 관리하기 위해 함수를 넣고, 관련 변수가 변한다면 실행되게 의존성 배열에 변수를 넣어 실행시키는 건 아는데, 의존성 배열에 잘못 넣으면 무한 새로고침, 무한 요청(Ajax 호출 관련이라면) 등이 일어난다.
내가 이해한 건 그 변수가 '변경할때만'이 아니라 존재한다면 계속 실행되는 건가? 변수의 값 변경이 아니라?? 뭐지? 싶었다.
이게 리액트 Hook의 단점이다. 추상화(Abstraction)가 꽤 깊게 되어 있어서 그 내부 동작 원리를 잘 알지 못하고 사용하는 경우가 많다는 것이다.
그래서 동작원리를 충분히 알고 써야 다음에 이런 문제를 겪지 않을 거 같았다.
리액트의 내장 Hook으로 사이드 이펙트의 수행을 위한 Hook이다.
1. 첫 번째 인자로 콜백 함수를 전달
2. 사이드 이펙트를 수행할지 여부를 결정하는 값들의 의존성 배열을 두 번째 인자로 전달
사이드 이펙트는 React 컴포넌트가 화면에 렌더링된 이후에 비동기로 처리되어야 하는 부수적인 효과들을 뜻한다. 즉, 비동기 코드들을 위한 Hook이다.
왜 사이드 이펙트를 관리해야할까? 일단 화면에 렌더링이 될 수 있는 것들(정적 데이터)을 먼저 렌더링 하고 API의 호출로 늦어지는 데이터들(서버 통신 데이터)을 나중에 1차적으로 렌더링이 끝난 뒤 가져오게 만들어 사용자의 부정적 영향을 최소화 시킬 수 있어서 사용자 경험 측면에서 유리하기 때문이다.
먼저 알아야할 것은 리액트의 Life Cycle을 알아야한다.
리액트의 전 버전에선 명령형 프로그래밍, 즉 OOP로 써야했었다. 위 사진은 그 구조와 라이프 사이클 함수와 함께 나타낸 것. 라이프사이클의 역할을 크게 3가지가 있고 함수 순서대로 보자면 이렇다.
컴포넌트의 인스턴스가 생성되어 DOM에 삽입될 때 순서대로 호출
this.props
, this.state
에 접근할 수 있으며 리액트 요소를 반환한다. setState()
를 사용할 수 없으며 DOM에 접근해선 안된다.setTimeout()
, setInterval()
과 같은 비동기 작업을 처리하면 되고, setState()
호출도 이 메서드에서 호출하는 경우가 많다.props나 state가 변경되면 렌더가 진행되며 순서대로 호출.
컴포넌트를 DOM에서 제거하는 과정
componentWillUnmount() : 컴포넌트를 DOM에서 제거할 때 실행한다. 이후에 컴포넌트는 다시 렌더링 되지 않으므로, 여기에서 setState()를 호출하면 안된다.
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 컴포넌트에서, loading
과 users
속성에 로딩 여부와 사용자 목록을 저장하는 코드를 보여준다.
초기 렌더링 시 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)“할 수 있게 해주는 함수라고 볼 수 있다. 그래서 가장 핵심적인 개념인 라이프 사이클의 개념이 들어가면 더욱 구동 원리가 파악이 될 것이다.
작동 방법을 봤으니 원리를 이제 보도록 하자. 위에서 보던 것처럼 hook은 함수형에서 클래스 컴포넌트의 라이프 사이클 메소드 등의 기능을 사용하기 위해 태어났으므로, 기능을 살펴보자.
useEffect
는 총 4가지의 기능을 사용한다.
componentDidMount
componentDidUpdate
componentWillUnmount
getDerivedStateFromProps
useEffect
는 크게 4가지 형태로 구분된다.
- useEffect(callBack);
- useEffect(callBack, []);
- useEffect(callBack, [state1, state2]);
- useEffect(()=>{ return(() => func()) });
componentDidMount
의 역할을 수행한다.state1
또는 state2
가 변경될 때 실행된다. 따라서, componentDidUpdate
와 getDerivedStateFromProps
의 역할을 수행한다.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가지로 나뉘었다.
- useEffect(callBack);
- useEffect(callBack, []);
- useEffect(callBack, [state1, state2]);
- useEffect(()=>{ return(() => func()) });
여기서 무한 루프가 일어나는 곳은 1, 3이 가장 크고, setState
와 함께 있을 때다.(참고: state
가 변경되면 리액트는 리렌더링됨)
1번은 컴포넌트가 마운트, 업데이트, 언마운트때마다 실행되는 경우인데 만약 state
가 변경되는 함수가 있다면 리렌더링이 일어나고 => 1번은 라이프 사이클 과정에 의해 실행 => state
관련 리렌더링 => ...이 일어남
useEffect(() => {
setCount(count + 1);
});
3번은 최초 렌더링 + 종속성 배열의 변수 변경에 따라 실행되는데 state
가 종속성 배열로, setState
가 useEffect
안에 있다면? 무한 루프다.
useEffect(() => {
setCount(count + 1);
}, [count]);
위의 코드로 봐서는 당연하지 않나 싶지만 코드양이 커지고 구조를 한눈에 파악하지 못해 생기는 불상사들이 일어날 수 있다. 그러므로 useEffect
를 쓰면서 렌더링이 되는지 안되는지와 마운트, 업데이트, 언마운트 시점을 생각해야한다.
해결법은 어떤게 있을까?
참고로 object(객체, 배열 등) 자체가 들어가게 되면 무조건
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
을 써줌
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
처럼 변경될 때마다 컴포넌트가 다시 렌더링 되지 않는다는 점을 이용해서 위와 같이 구현할 수 있다.
웹 성능을 높이겠다고 한번만 렌더링되게 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
의 값이 전 참조값과 비교했을 때 변하지 않았다면 호출을 하지 않는다.
내가 처음 의도했던 대로, useCallback
안 useEffect
는 useCallback
의 의존성 배열 값이 변경되지 않는 한 재호출 되지 않게 된다. 무한루프 탈출이다!
정말 useEffect는 알고 쓴다 생각할 때 쯤이면, 시련을 주더군요. '넌 아직 날 아직 몰라'라고 말하는 거 처럼.