애플리케이션에서 초기에 한번 설정해야 하는 경우 useEffect 는 사용하지 않는 편이 좋다.
예를 들어 useEffect 에서 dependency array 에 빈 배열을 넣을 경우 한번만 실행된다 하여 다음과 같이 넣을 수도 있다.
function App() {
// 🔴 Avoid: 전체를 통틀어 단 한번만 실행되어야 하는 로직을 Effect 에 사용하는 경우
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
production 환경이면 몰라도 development 환경이라면 이 Effect 는 2회 실행된다. 예를 들어 인증 토큰의 경우 두번 호출되는 것을 상정하고 설계되지 않았기에 이를 무효화할 수도 있다.
리엑트는 의도적으로 버그를 찾기 위해 개발환경에서 컴포넌트를 remount 한다. 이때
어떻게 Effect 한번만 실행수 있을까?
보다는 remount 뒤에 동작하기 위해 Effect 를 어떻게 수정할까?
라는 의문을 갖는게 더 바람직하다.
일반적으로는 clean up 함수를 실행하는게 방법일 수 있다. 이 clean up 함수는 Effect 가 무엇을 하고있었든지 간에 이를 멈추게 한다.
useEffect(() => {
...
return () => { // clean up 함수
...
}
}, [])
가장 좋은 방법은 사용자가 한번만 실행되는 것과 setup
-> cleanup
-> setup
의 차이를 구분하지 못하게 하는 것이다.
어떤 로직이 반드시 컴포넌트가 마운트될때마다 한번이 아니라 앱이 로딩될때 한번만 실행되어야 한다면 이 로직이 이미 실행되었는지 여부를 추적할 수 있게 합당한 변수를 top-level 에 추가해야 한다.
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ app 로드시 한번만 실행된다.
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
혹은 app 이 렌더되기 전에 모듈이 초기화 되는 중에 실행하도록 할 수도 있다.
if (typeof window !== 'undefined') { // 브라우저에 실행중인지를 확인
// ✅ app 로드시 한번만 실행된다.
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
그러나 공식문서에서는 임의의 컴포넌트를 import 하는 중에 느려지거나 예상치 못한 동작을 피하기 위해선 이러한 패턴을 피하라고 안내한다.
켜져있는지 여부를 나타내는 내부 isOn
상태를 갖는 Toggle 컴포넌트를 만들고 있는 상황을 가정하자. 이때 Toggle 컴포넌트의 내부 상태가 바뀌었는지 여부를 부모 컴포넌트에게 알려야 하는 상황에 useEffect 를 사용해야 할까?
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 Avoid: onChange 핸들러가 너무 늦게 동작한다.
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
사실 props 는 부모에서 자식으로 흐른다. 로직을 확인하기 위해서는 자식에서 부모 컴포넌트로 거슬러 올라가는게 흐름에 맞다. 부모 컴포넌트의 변화를 자식 컴포넌트에 전달하는 것은 자연스럽다. 그러나 자식 컴포넌트에서 부모 컴포넌트의 상태를 변경하기 위해 부모 컴포넌트의 상태를 변경하는 setState 를 props 로 전달할 수도 있는 상황에서 이를 useEffect 로 처리하는 것은 피하자는 내용이다.
이는 이상적이지 않다.
어떻게 동작할지 생각해보자. Toggle 에서 isOn 상태가 바뀌었다. -> 자식 컴포넌트인 Toggle 이 재렌더링 된다. -> useEffect 의 Effect 가 실행된다. -> 부모 컴포넌트의 상태를 onChange 로 바꾼다. -> 부모 컴포넌트가 재렌더링 된다 -> 자식 컴포넌트도 다시 재렌더링된다.
이런 불필요한 재렌더링을 방지하고자 하나의 이벤트 핸들러 내에서 상태를 한번에 바꾸도록 하자는 것이다.
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// ✅ Good: 토글을 유발하는 이벤트 도중 모든 사항을 업데이트하자
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}
useEffect 대신 하나의 이벤트 내에서 다수의 상태 변화를 한번에 처리하니 React는 한번만 렌더링하게 된다.
혹은 상태관리를 모두 부모 컴포넌트에서 처리하고 자식컴포넌트는 props로 상태와 상태변경 이벤트 핸들러를 전달받아 처리할 수도 있다. ( Lifting state up
)
// ✅ Also good: 컴포넌트는 전적으로 부모 컴포넌트에 의해 제어된다.
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
// ...
}
React가 상태 업데이트를 어떻게 일괄 처리할까?
React는 상태를 업데이트 하기 전에 이벤트 핸들러 내의 모든 코드가 실행될 때까지 기다린다.
위의 코드에서 onClick 이라는 이벤트가 발생하여 상태를 업데이트할때 +3 이 아닌 +1 이 되는것은 이벤트가 동작할때 각 렌더링은 그 순간의 상태를 snapshot 으로 갖기에 0 + 1 이 세번 실행되는 것으로 보여 결과적으로 1만 증가하도록 동작한다.
그런데 구체적으로는 이벤트 핸들러의 모든 코드가 실행될 때까지 기다리고 그 이후에 상태를 업데이트하니 상태가 3번 바뀐게 아니라 재렌더링은 한번만 이루어진다.
공식문서의 예시가 참 알아듣기 좋았는데 React에서 다수의 상태변화를 일괄처리하는 것을 식당에서 종업원이 메뉴를 주문받는 것으로 예를 들었다. 하나의 이벤트 핸들러가 이벤트를 처리하는 것은 마치 종업원이 손님으로부터 여러 주문을 받고 한번에 주방에 전달하는 것처럼 React는 상태변화의 일괄처리를 수행한다.
이는 너무 많은 재렌더링을 하지 않고도 다수의 컴포넌트에서 다수의 상태변수 update 를 가능하게 한다. 이는 이벤트 핸들러와 그 안의 코드가 완료되기 전까지 UI 가 갱신되지 않는 다는 것을 의미하기도 한다. 이렇게 React 앱을 더 빠르게 동작하게 해주는 동작을 batching
이라고 부른다. 이는 또한 렌더링중 일부 변수만 update 되는 반만 바뀌는 것처럼 혼란스러운 상황을 피할 수 있게 해준다.
물론 React도 버튼 클릭처럼 의도적인 이벤트를 batching 하지 않고 일괄처리해도 안전한 것들만 batching 처리한다.
그래서 앞의 경우 이벤트 핸들러 내의 setIsOn(nextIsOn); onChange(nextIsOn); 는 한번에 일괄처리되고 재렌더링은 한번만 이루어지게 된다.
두개 이상의 상태변수를 동기화하여 관리하고 싶다면 상태를 부모 컴포넌트에서 제어하도록 하자
지금까지 Effect 를 쓰지 말아야 하는 경우들에 대해 찾아봤다.
그럼 이젠 Effect 를 어떻게 쓰는지 알아보자.
기본적으로 매 렌더링 이후 Effect 가 실행된다.
매번 컴포넌트가 렌더링 될때마다 React는 화면을 갱신하고 그 뒤에 useEffect 내의 코드를 실행한다. 즉, useEffect 는 렌더링이 화면에 반영될 때까지 특정 코드들을 지연시킨다.
side effect 란?
화면을 업데이트하는것, 애니메이션을 시작하는것, 데이터를 변경하는 것과 같은 변경들을 side effect 라 하는데 렌더링 도중에 발생하는게 아니라 측면에서 발생하는 것들을 지칭한다.
적합한 예시를 들어보자.
VideoPlayer 라는 컴포넌트가 있다. 이 컴포넌트는 built-in browser <video>
태그를 렌더링하는데 video 의 경우 play() 와 pause() 를 호출하여 DOM 요소를 제어할 수 있다. 그러나 문제는 이 매서드들이 DOM 노드가 렌더링 되는 도중에 실행되려 하다보니 오류가 발생할 수 있다.
일반적으로 React에서 렌더링은 JSX 의 순수 연산이어야 하며 DOM 수정과 같은 side effect 를 포함해서는 안된다. 그러니 위의 경우 side effect 를 방지하기 위해 렌더링 이후 실행되도록 하기 위해 useEffect 내로 코드를 넣는 것이다.
import { useEffect, useRef } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
이 경우 React 상태로 동기화한 외부 시스템은 browser media API 이다.
기본적으로 Effect 는 매 렌더링 이후 실행된다.
대체로 Effect 는 매 렌더링 이후에 실행되는 것보다 필요할때만 다시 실행되길 원한다.
만약 불필요한 재렌더링을 방지하고 싶다면 종속성 배열에 빈 배열을 넣자.
useEffect(() => {
// ...
}, []);
주의점
종속성에 거짓말을 하면 안된다.
종속성 배열은 React에 언제 다시 Effect를 실행해야 할지 알려준다. 빈 배열을 전달한다는 것은 첫 렌더링 이후 Effect를 실행하고 재렌더링 이후엔 Effect 를 실행하지 않는다는 것을 말한다.
위의 코드의 경우 한번만 실행되길 원한다고 코드를 구성했으나 출력화면과 콘솔을 보자.
첫 번째 렌더링에서 count 는 0 이고 첫 번째 렌더링의 이펙트에서 setCount(count + 1) 는 setCount(0 + 1) 이라는 뜻이 된다.
deps 를 [] 라고 정의해서 이펙트를 다시 실행하지 않고 매 초마다 setCount(0 + 1) 을 호출하게 된다.
실제로 Effect 에서 count 라는 state 값을 쓰고 있다. 그런데 컴포넌트 안에 있는 값을 쓰지 않는다고 거짓말을 한 상황인 것이다.
React Hook useEffect has a missing dependency
오류는 Effect 코드가 특정 prop 가 어떻게 동작할지 의존하고 있는 prop 이 종속성 배열에 선언되지 않는 경우에 발생한다.
이 경우 다음과 같이 추가해줘야 한다.
useEffect(() => {
if (isPlaying) { // It's used here...
// ...
} else {
// ...
}
}, [isPlaying]); // ...so it must be declared here!
이 코드는 isPlaying
이라는 상태가 이전 렌더링과 같다면 Effect를 다시 실행하지 않는다.
종속성 배열은 다수의 종속성을 가질 수 있다. React는 이전 렌더링과 비교할때 모든 의존성이 동일할때만 Effect 를 다시 실행하지 않는다.
useEffect(() => {
// Effect 가 매 렌더링 이후에 실행된다.
});
useEffect(() => {
// 첫 렌더링(mount) 이후에만( 컴포넌트가 화면에 반영될 때 ) Effect 가 실행된다.
}, []);
useEffect(() => {
// 첫 렌더링(mount) 이후와 a 또는 b 가 이전 렌더링과 비교시 달라졌을때 Effect 가 실행된다.
}, [a, b]);
다음 코드에서 ref 는 종속성 배열에 넣지 않았다. 사실 넣어도 상관은 없는데 ref 객체는 stable identity 를 갖기에 항상 동일하다. 이는 바뀔수 없기에 종속성 배열에 넣지 않은 것이다. 마찬가지로 useState 의 setState 도 stable identity 를 갖기에 오류를 반환하지 않는다면 종속성 배열에서 제거해도 된다. 물론 이게 부모 컴포넌트로부터 props 로 전달받았을 경우 항상 동일한 객체를 전달한다는 보장이 없다면 상황에 따라 달라질 수 있다.
ref, setState 등은 stable identity 를 갖기에 값이 변경되지 않으므로 dependency 에서 생략할 수 있다.
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
몇몇 Effect 는 어떻게 중단하거나 clean up 하는지 지정할 필요가 있다. connect 와 disconnect, subscribe 와 unsubscribe, fetch 와 cancel / ignore 등의 관계를 예로 들 수 있다.
위의 코드를 보자. 화면을 옆으로 당겨 실행화면을 보면 콘솔에 mount 되는 순간 한번만 실행될 것이라 생각했지만 Connecting...
이 두번 출력되고 있음을 볼 수 있다.
개발단계에서 버그를 빠르게 찾기 위해 React는 초기 마운트 이후 즉시 모든 컴포넌트를 한번 remount 한다. 그래서 Connecting...
만 두번 출력된다는 것을 보면 connection 을 unmount 할때 끊지 않았다는 것을 알 수 있다.
React는 Effect 가 다시 실행( 다음 상태 참조 )되기 전에 매번 cleanup 함수를 호출하고 (이전 상태 참조) 또한 최종적으로 component 가 unmount 될때 cleanup 함수를 호출한다.
콘솔에 출력되는 것을 보자.
맨 처음에 Effect가 참조하고 있는 count 상태
- cleanup 함수가 참조하고 있는 count 상태
- Effect가 참조하고 있는 count 상태
가 출력된다.
이때 첫 렌더링후 Effect 가 실행되어 Effect가 참조하고 있는 count 상태
가 한번 출력되었고 StrictMode 로 인해 추가적인 setup + cleanup 이 실행되어 cleanup 함수가 참조하고 있는 count 상태
- Effect가 참조하고 있는 count 상태
가 출력된 것이다.
그 다음에 버튼을 클릭해서 재렌더링이 될때의 상황을 보자
cleanup 함수가 참조하고 있는 count 상태
- Effect가 참조하고 있는 count 상태
가 출력된다.
이때 cleanup 함수는 이전상태의 state 를 참조하고 setup 함수는 다음상태의 state 를 참조하고 있음을 알 수 있다.
다시 정리하자면 cleanup 함수가 이펙트가 실행되기 전 매번 호출되며 unmount 될때 역시 호출된다.
React는 이렇게 컴포넌트를 의도적으로 unmount 시킴으로서 실제 production 에서 코드의 동작에 문제가 있는지 여부를 미리 development 환경에서 확인할 수 있게 해준다. 이렇게 두번 실행해주는 것은 cleanup 이 필요한 effect 가 무엇인지 확인할 수 있게 한다. 이는 Strict Mode 가 가능하게 해주는 동작인데 개발환경에서 한번만 실행하나독 Strict Mode 를 제거하는 것은 권장되지 않는다. 이렇게 unmount 해주는 것이 많은 버그를 찾을 수 있게 해준다.
이전 이펙트는 새 prop 과 함께 리랜더링 되고 난 뒤에 cleanup 된다.
실행순서 :
- React가 바뀐 state 를 갖고 UI 를 렌더링
- 브라우저가 실제 그리기를 하여 바뀐 state 를 반영한 UI 를 볼 수 있다.
- React는 이전 state 에 대한 Effect 를 cleanup 한다.
- React가 새 state 에 대한 Effect 를 실행한다.
<StrictMode>
<App />
</StrictMode>
Strict Mode 는 개발환경에서 미리 컴포넌트에서 버그들을 찾을 수 있게 해준다.
Strict Mode 가 개발환경에서만 가능한 동작들
Strict Mode 를 사용하기 위해서는 App 컴포넌트를 StrictMode 로 감싸야 하는데 기본적으로 감싸는 형태로 프로젝트가 생성된다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
다시 말하지만 React는 모든 컴포넌트가 순수함수로 작성되어야 한다고 가정한다. 그래서 함수가 두번 실행되더라도 순수함수라면 변화가 없어야 하나 그렇지 않다면 버그를 미리 찾을 수 있게 된다.
Strict Mode 는 Effect 에서 버그를 찾는데 도움이 된다.
모든 Effect 는 setup code 를 갖고 일부는 cleanup 코드를 갖는다. 만약 종속성이 이전 렌더링과 비교시 달라졌다면 React는 cleanup 함수와 setup 코드를 다시 호출한다. Strict Mode 가 켜져있으면 React 는 개발환경에서 모든 Effect 에서 하나의 extra setup + cleanup 사이클을 추가로 실행한다. 이는 수동으로 파악하기 어려운 미묘한 버그를 찾는데 도움이 된다.
순서가 조금 뒤섞인 느낌이 든다..
useEffect 는 외부 시스템과 컴포넌트를 동기화하는 React 훅이다.
useEffect(setup, dependencies?)
setup
: Effect 의 로직.
선택적으로 cleanup 함수를 반환할 수 있다. 컴포넌트가 DOM 에 추가된 후 setup 함수가 실행된다. 종속성이 변하여 발생하는 모든 재렌더링 이후 React는 먼저 unmount 할때 이전 value 와 함께 cleanup 함수를 실행하고 이후에 새 value 로 setup 함수를 실행한다. 컴포넌트가 DOM 으로부터 제거된 이후 React는 cleanup 함수를 실행한다.
dependencies?
: setup
코드에서 참조하고 있는 모든 reactive value.
reactive value 는 props, state, 컴포넌트에서 바로 선언된 모든 변수와 함수를 포함한다.
useEffect 는 undefined 를 반환한다.
Effect 를 선언하기 위해선 useEffect 를 컴포넌트 상단에서 호출하자. useEffect 도 훅이기 때문에 컴포넌트 상단에서 호출해야 하며 조건문이나 반복문 내에서 호출할 수 없다.
import { useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('<https://localhost:1234>');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
// ...
}
외부 시스템과 동기화할 목적이 아니라면 Effect 를 사용하는 것에 다시한번 생각해봐야 한다.
만약 종속성중 일부가 컴포넌트에서 정의된 객체나 함수라면 필요로 한 것보다 더 자주 Effect 를 다시 실행하도록 할 수 있다. 이를 막기 위해 불필요한 객체와 함수 종속성을 제거하자.
만약 Effect 가 클릭과 같은 상호작용에 의한 것이 아니라면 React는 렌더링 이후 Effect 를 실행하려 한다. 만약 Effect 가 시각적으로 보이는 무언가를 하거나 flickers 같은 지연을 하려할때 useEffect
대신 useLayoutEffect
를 사용하자.
Effect 가 클릭과 같은 상호작용에 의한 것이라도 브라우저가 Effect 내의 상태 업데이트를 하기 전에 화면을 repaint 한다. 보통은 이게 원하는 동작이겠으나 만약 화면을 repaint 하기 전에 Effect 를 실행하고 싶다면 useLayoutEffect
를 대신 사용하자.
useEffect 는 비동기적으로 실행된다. useEffect 는 paint 후 (렌더링 후) 에 실행되기 때문에 DOM 에 영향을 미치는 코드가 있으면 다시 그려서 화면 깜빡임이 발생한다.
- useLayoutEffect 는 동기적으로 실행된다. 따라서 paint 되기전에 실행되기에 DOM 에 영향을 미치는 코드를 넣어도 화면이 깜빡이지 않는다. 단 오래 걸리는 경우 해당 시간동안 빈 화면을 보게 된다. [공식문서에서도 useEffect 를 되도록 사용하라고 안내하고 있다.](https://react.dev/reference/react/useLayoutEffect#:~:text=useLayoutEffect can hurt performance. Prefer useEffect when possible.)
일부 컴포넌트는 페이지에 표시되는 동안 네트워크와 연결, 브라우저 API, 혹은 서드파티 라이브러리와의 연결상태를 유지해야 한다. 이러한 시스템은 React에 의해 제어되지 않으니 external 외부 시스템이라고 표현한다.
import { useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('<https://localhost:1234>');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
// ...
}
외부 시스템 사례
외부 시스템을 컴포넌트의 몇몇 prop, state 와 동기화하고 싶을때 useEffect 를 사용한다.
만약 Effect 가 렌더링중 생성된 객체나 함수에 의존할때 이는 너무 자주 실행될 것이다. 왜냐하면 이런 객체나 함수같은 Reference Type 은 매 렌더링마다 다른 주소를 갖기 때문이다. 대신 Effect 내에서 object 를 생성하자.
const serverUrl = '<https://localhost:1234>';
function <ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const options = { // 🚩 이 객체는 모든 재렌더링마다 생성된다.
serverUrl: serverUrl,
roomId: roomId
};
useEffect(() => {
const connection = createConnection(options); // Effect 내에서 사용된다.
connection.connect();
return () => connection.disconnect();
}, [options]); // 🚩 결과적으로 이 종속성은 재렌더링마다 항상 다르다.
// ...
위의 코드를 아래와 같이 수정한다.
객체 종속성과 마찬가지로 함수 종속성의 경우 너무 자주 실행된다. 함수 역시 매 렌더링마다 다르기 때문이다.
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
function createOptions() { // 🚩 이 함수는 모든 재렌더링마다 생성된다.
serverUrl: serverUrl,
return {
serverUrl: serverUrl,
roomId: roomId
};
}
useEffect(() => {
const options = createOptions(); // Effect 내에서 사용된다.
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🚩 결과적으로 이 종속성은 재렌더링마다 항상 다르다.
// ...
마찬가지로 Effect 내에서 함수를 생성하자.
prop 나 state 를 반드시 요구하지 않는 함수는 컴포넌트 바깥에 선언하여 호이스팅하고 이펙트 내에서만 사용되는 함수는 이펙트 함수 내부에 선언한다. 만약 렌더 범위 안에 있는 함수를 이펙트가 사용중이라면 구현부를 useCallback 으로 감싸자.
Effect 가 state 를 업데이트하려고 하고 그 state 가 바뀌어 재렌더링을 야기할때 Effect 의 종속성이 바뀌는 경우일 것이다.
이 문제를 해결하기전 Effect 가 외부 시스템(DOM, network, 서드파티 위젯 등)에 연결되어 있는지 여부를 확인해보자. 왜 Effect 가 state 를 설정해야 할까? 이 코드가 외부 시스템과 동기화하는데 사용되고 있는가? 혹은 애플리케이션의 데이터 흐름을 관리하고 있나?
만약 외부 시스템이 없다면 Effect 를 제거하여 로직을 간략화하자.
만약 일부 외부 시스템과 동기화중이라면 Effect 가 상태를 업데이트해야 하는 이유와 조건에 대해 다시 생각해보자. 바뀐 부분이 컴포넌트의 시각적 요인에 영향을 끼치나? 렌더링에 사용되지 않는 데이터를 추적해야 하는 경우 ref 를 사용하는게 더 적절할 수 있다.
만약 그래도 무한루프에 빠져있다면 상태 변경이 Effect 의 종속성을 바꾸고 있기 때문이다.
모든 랜더링은 고유의 prop, state, 이벤트 핸들러, Effect 를 갖는다.
- 각각의 Effect 는 매번 렌더링에 속한 state, prop 등을 본다.
docs
react.docs - useEffect
blog
leehyunho2001 - useEffect & useLayoutEffect
velopert - 리액트 Hooks 완벽 정복하기