React useEffect와 정리 함수: 외부 시스템 동기화
채팅방 화면이 방에 접속하는 코드입니다.
function ChatRoom({ roomId }) {
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
}, []);
return <h1>{roomId} 방</h1>;
}「화면이 처음 나타날 때 한 번 접속한다」는 의도로 의존성 배열을 비웠습니다. 사용자가 방 목록에서 general 에서 travel 로 옮기면 제목은 travel 로 바뀌는데, 연결은 여전히 general 입니다. 개발 환경에서는 처음 들어오자마자 연결이 두 개 생긴 것도 로그에 보입니다.
useEffect 는 마운트될 때 실행하는 곳이라는 생각
클래스 컴포넌트 시절의 componentDidMount, componentDidUpdate, componentWillUnmount 를 떠올리며 useEffect 를 생명주기 훅으로 이해하는 경우가 많습니다. 의존성 배열이 비어 있으면 마운트, 값이 있으면 업데이트, 정리 함수는 언마운트라고 대응시킵니다.
이 대응표로 짜면 앞의 코드가 나옵니다. 「마운트 때 접속」을 했으니 방이 바뀌는 「업데이트」는 따로 처리해야 할 것 같고, 개발 환경에서 두 번 실행되는 것은 React 가 이상하게 동작하는 것처럼 보입니다.
효과는 외부 시스템과 맞추는 동기화
Synchronizing with Effects 문서는 효과를 이렇게 소개합니다.
Effects let you run some code after rendering so that you can synchronize your component with some system outside of React.
핵심 단어는 「동기화」와 「React 바깥의 시스템」입니다. 채팅 서버 연결, 브라우저 API, 지도나 차트 같은 React 가 아닌 위젯이 외부 시스템입니다. 효과는 지금의 props 와 state 에 맞게 그 외부 시스템을 맞춰 두는 장치입니다.
Lifecycle of Reactive Effects 문서는 더 분명하게 적습니다.
Components may mount, update, or unmount. An Effect can only do two things: to start synchronizing something, and later to stop synchronizing it.
효과에는 마운트나 업데이트라는 개념이 없습니다. 동기화를 시작하는 일(효과 본문)과 멈추는 일(정리 함수) 두 가지뿐입니다. 그리고 이 둘은 필요할 때마다 여러 번 반복됩니다.
방이 바뀌면 끊고 다시 잇는 흐름
동기화로 생각하면 앞의 코드는 이렇게 바뀝니다.
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect();
}, [roomId]);roomId 가 general 에서 travel 로 바뀌면 React 는 지난 렌더링의 정리 함수를 불러 general 연결을 끊고, 새 렌더링의 효과를 실행해 travel 에 연결합니다. 화면이 사라지면 마지막으로 정리 함수를 한 번 더 부릅니다.
이 코드에는 「마운트일 때」와 「업데이트일 때」를 가르는 분기가 없습니다. 「지금 roomId 에 연결돼 있어야 한다」는 한 가지 사실만 적었고, 언제 시작하고 멈출지는 React 가 정합니다. 효과를 쓸 때 물어야 하는 질문은 「언제 실행되는가」가 아니라 **「무엇과 동기화하는가, 그리고 멈출 때 무엇을 되돌리는가」**라고 봅니다.
의존성 배열은 고르는 것이 아니라 코드에서 읽히는 것
의존성 배열을 「효과를 언제 다시 실행할지 정하는 설정」으로 이해하면, 원하는 시점에 맞춰 값을 넣거나 빼고 싶어집니다. 앞의 코드에서 [] 를 쓴 것이 그 경우입니다.
실제로는 방향이 반대입니다. 효과 안에서 읽는 props 와 state 는 모두 의존성입니다. roomId 를 읽었으니 roomId 는 목록에 있어야 하고, 그 목록이 곧 「이 값이 바뀌면 다시 동기화해야 한다」는 뜻이 됩니다. React 의 린트 규칙이 코드와 목록이 맞는지 검사해 줍니다.
useEffect 문서는 이 경고를 주석으로 끄는 것을 따로 경고합니다. 코드와 의존성이 맞지 않으면 버그가 생길 위험이 크고, 린트를 끄는 것은 효과가 읽는 값에 대해 React 를 속이는 일이라는 설명입니다. 앞 글의 setInterval 이 1 에서 멈춘 예도 같은 이유였습니다. seconds 를 읽으면서 의존성에서 뺐기 때문에 효과는 첫 렌더링의 사진을 계속 봤습니다.
다시 실행되는 게 싫다면 목록을 고치지 말고 코드를 고칩니다. 그 타이머라면 setSeconds((s) => s + 1) 로 바꿔 seconds 를 읽지 않게 하면 의존성에서 자연스럽게 빠집니다.
개발 환경에서 두 번 실행되는 이유
Strict Mode 의 개발 환경에서 React 는 컴포넌트를 마운트한 직후 한 번 언마운트했다가 다시 마운트합니다. 그래서 효과가 「실행, 정리, 실행」 순서로 불립니다. 운영 빌드에서는 일어나지 않습니다.
문서는 이 동작의 목적과 대응을 이렇게 적습니다.
The right question isn't "how to run an Effect once", but "how to fix my Effect so that it works after remounting".
앞의 첫 코드에서 연결이 두 개 생긴 것은 정리 함수가 없었기 때문입니다. 정리 함수로 연결을 끊었다면 개발 환경에서도 「접속, 끊기, 접속」이 되어 최종적으로 연결은 하나입니다. 사용자가 화면을 떠났다가 돌아오는 경우도 똑같이 동작합니다. 두 번 실행을 막을 방법을 찾기보다, 두 번 실행돼도 결과가 같은 효과로 만드는 것이 이 검사가 원하는 방향입니다.
데이터 요청의 정리는 결과를 무시하는 것
서버에서 데이터를 불러오는 효과는 정리할 것이 없어 보입니다. 이미 보낸 요청은 되돌릴 수 없기 때문입니다. 그래도 정리가 필요합니다.
userId 가 1 에서 2 로 빠르게 바뀌면 요청 두 개가 나갑니다. 1 의 응답이 2 의 응답보다 늦게 도착하면, 화면에는 사용자 2 를 보고 있는데 사용자 1 의 데이터가 그려집니다. 문서의 예는 정리 함수로 지난 요청의 결과를 무시합니다.
useEffect(() => {
let ignore = false;
async function load() {
const json = await fetchTodos(userId);
if (!ignore) setTodos(json);
}
load();
return () => { ignore = true; };
}, [userId]);userId 가 바뀌면 지난 효과의 정리 함수가 ignore 를 true 로 만들고, 늦게 온 응답은 버려집니다. 가능하면 AbortController 로 요청 자체를 취소해도 됩니다.
다만 문서는 효과로 데이터를 불러오는 방식에 캐시가 없고 경쟁 상태 같은 함정이 많다고 설명하면서, 프레임워크를 쓴다면 프레임워크의 데이터 불러오기 방식이나 전용 라이브러리를 권합니다. 이 예는 효과의 정리 원리를 보여 주는 데 의미가 있고, 실무의 기본 선택지로 권하는 것은 아닙니다.
효과가 필요 없는 두 가지 경우
동기화할 외부 시스템이 없다면 효과가 필요 없습니다. You Might Not Need an Effect 문서가 대표적인 두 경우를 꼽습니다.
첫째, 렌더링에 쓸 값을 계산할 때. 목록을 걸러 보여 주려고 효과에서 걸러진 목록을 state 에 넣으면, 렌더링이 한 번 끝난 뒤 state 가 바뀌어 렌더링이 한 번 더 일어납니다. 렌더링 중에 바로 계산하면 됩니다.
// ✗ 효과로 계산
const [visible, setVisible] = useState([]);
useEffect(() => setVisible(items.filter(isVisible)), [items]);
// ✓ 렌더링 중에 계산
const visible = items.filter(isVisible);둘째, 사용자 행동에 반응할 때. 「구매」 버튼을 누르면 요청을 보내고 알림을 띄우는 일은 버튼의 이벤트 핸들러에 둡니다. 효과가 실행될 때는 사용자가 무엇을 했는지 알 수 없습니다. 효과는 「화면이 이 상태이기 때문에」 일어나야 하는 일에, 이벤트 핸들러는 「사용자가 이것을 했기 때문에」 일어나야 하는 일에 씁니다.
저는 효과를 쓰기 전에 이 문장을 채워 보는 편이 좋다고 봅니다. 「이 효과는 ___ 와 동기화하고, 멈출 때 ___ 를 되돌린다.」 첫 칸에 React 바깥의 무언가가 들어가지 않으면 대개 효과가 아닌 다른 자리가 맞습니다.
여기까지가 확실한 부분
효과의 정의, 동기화의 시작과 멈춤, 의존성 린트와 린트 끄기 경고, Strict Mode 의 재마운트, ignore 로 결과를 무시하는 예, 효과가 필요 없는 두 경우는 react.dev 문서 기준입니다. 효과는 서버 렌더링 중에는 실행되지 않고 클라이언트에서만 실행됩니다. 효과 안에서 최신 값을 읽되 다시 동기화는 하지 않고 싶은 경우를 위한 useEffectEvent 도 문서에 있지만, 이 글에서는 다루지 않았습니다.
함께 읽기
- React useMemo와 useCallback은 언제 써야 할까?리뷰에서 이런 코드를 자주 만납니다.
- React Error Boundary와 try/catch의 차이대시보드에 위젯이 여섯 개 있습니다. 그중 매출 차트 위젯이 서버에서 예상과 다른 모양의 데이터를 받아 렌더링 중에 오류를 냅니다.
- React useActionState와 useOptimistic으로 폼 다루기견적 요청 폼을 보내는 코드입니다.
- React 커스텀 훅은 state를 공유할까?장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.
- React Context와 리렌더링로그인한 사용자 정보와 장바구니를 앱 어디서나 쓰려고 컨텍스트 하나를 만들었다고 해 보겠습니다.