리액트 코드를 작성할 때 useState만큼이나 자주 쓰이는 훅이 바로 useEffect다.
위의 useEffect에 대한 정의는 어느 정도 옳지만 완전히 정확하지는 않다. 자주 쓰지만 사용하기 쉬운 훅이 아니고, 생명주기 메서드를 대체하기 위해 만들어진 훅도 아니다.
➡️ useEffect는 애플리케이션 내 컴포넌트의 여러 값들을 활용해 동기적으로 부수효과를 만드는 메커니즘이다. 그리고 이 부수 효과가 '언제' 일어나는지보다 어떤 상태값과 함께 실행되는지 살펴보는 것이 중요하다. (❓그래요?)
useEffect의 일반적인 형태
🖥️ function Component() {
// ...
useEffect(() => {
// do something
}, [props, state])
// ...
}
첫 번째 인수로는 실행할 부수 효과가 포함된 함수를, 두 번째 인수로는 의존성 배열을 전달. 이 의존성 배열은 어느 정도 길이를 가질 수도, 아무런 값이 없는 빈 배열일 수도, 배열 자체가 없을 수도 있다.
의존성 배열이 변경될 때마다 useEffect의 첫 번째 인수인 콜백을 실행하는데, useEffect는 어떻게 의존성 배열이 변경된 것을 알고 실행될까? 여기서 기억해야 할 사실은 '함수 컴포넌트는 매번 함수를 실행해 렌더링을 수행한다'는 것이다.
버튼을 클릭하면 counter에 값을 1씩 올리는 컴포넌트
🖥️ function Component() {
const [counter, setCounter] = useState(0);
function handleClick() {
setCounter((prev) => prev + 1)
}
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
버튼을 클릭하면 이 함수 컴포는트는 useState의 원리에 따라 다음과 같이 작동한다고 볼 수 있다.
🖥️ function Component() {
const counter = 1;
// ...
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
);
}
❓이게 무슨 설명? 코드블록 두 개 순서 바뀐 듯?
즉, 함수 컴포넌트는 렌더링 시마다 고유의 state와 props 값을 갖고 있다. 여기에 useEffect가 추가된다면 아래와 같은 형태가 된다.
🖥️ function Component() {
const counter = 1;
useEffect(() => {
console.log(counter) // 1, 2, 3, 4...
})
// ...
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
);
}
❓아니 뭘 생략하신 거예요 이걸 안 써주면 어떻게 알아요
➡️ useEffect는 자바스크립트의 proxy나 데이터 바인딩, 옵저버 같은 특별한 기능을 통해 값의 변화를 관찰하는 것이 아니고, 렌더링할 때마다 의존성에 있는 값을 보면서 이 의존성의 값이 이전과 다른 게 하나라도 있으면 부수 효과를 실행하는 함수라 볼 수 있다. 따라서 useEffect는 state와 props의 변화 속에서 일어나는 렌더링 과정에서 실행되는 부수 효과 함수라고 본다.
그렇다면 클린업 함수라 불리는 useEffect내에서 반환되는 함수는 정확히 무엇이고, 어떤 일을 할까? 일반적으로 클린업 함수는 이벤트를 등록하고 지울 때 사용해야 한다고 알려져 있다.
🖥️ export default function App() {
const [counter, setCounter] = useState(0);
function handleClick() {
setCounter((prev) => prev + 1);
}
useEffect(() => {
function addMouseEvent() {
console.log(counter)
}
window.addEventListener('click', addMouseEvent);
//클린업 함수
return () => {
console.log('클린업 함수 실행!', counter)
window.removeEventListener('click', addMouseEvent)
}
}, [counter])
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
위 useEffect가 포함된 컴포넌트를 실행해 보면 다음 결과를 얻을 수 있다.
클린업 함수 실행! 0
1
클린업 함수 실행! 1
2
클린업 함수 실행! 2
3
클린업 함수 실행! 3
4
// ...
위 로그를 살펴보면 클린업 함수는 이전 counter 값,
즉 이전 state를 참조해 실행된다는 것을 알 수 있다. 클린업 함수는
새로운 값과 함께 렌더링된 뒤에 실행되기 때문에 위와 같은 메시지가 나타난다.
여기서 중요한 것은, 클린업 함수는 비록
새로운 값을 기반으로 렌더링 뒤에 실행되지만
이 변경된 값을 읽는 것이 아니라,
함수가 정의됐을 당시에 선언됐던 이전 값을 보고 실행된다는 것이다.
이런 사실을 코드로 직관적으로 표현하면 다음과 같다.
렌더링 수행 마다 counter가 어떤 값으로 선언돼 있는지 보여주는 코드
🖥️ // 최초 실행
useEffect(() => {
function addMouseEvent() {
console.log(1);
}
window.addEventListener("click", addMouseEvent);
// 클린업 함수
// 그리고 이 클린업 함수는 다음 렌더링이 끝난 뒤에 실행된다.
return () => {
console.log('클린업 함수 실행!', 1)
window.addEventListener("click", addMouseEvent);
}
}, [counter])
//
// ...
// 이후 실행
useEffect(() => {
function addMouseEvent() {
console.log(2)
}
window.addEventListener("click", addMouseEvent);
// 클린업 함수
return () => {
console.log('클린업 함수 실행!', 1)
window.addEventListener("click", addMouseEvent);
}
}, [counter])
종합해 보면, 왜 useEffect에 이벤트를 추가했을 때 클린업 함수에서 지워야 하는지 알 수 있다. 함수 컴포넌트의 useEffect는 그 콜백이 실행될 때마다, 이전의 클린업 함수가 존재한다면, 그 클린업 함수를 실행한 뒤에 콜백을 실행한다. 따라서 이벤트를 추가하기 전에 이전에 등록했던 이벤트 핸들러를 삭제하는 코드를 클린업 함수에 추가하는 것이다. 이로써 특정 이벤트의 핸들러가 무한히 추가되는 것을 방지할 수 있다.
이처럼 클린업 함수는 생명주기 메서드의 언마운트 개념과는 조금 차이가 있는 것을 볼 수 있다. 언마운트는 특정 컴포넌트가 DOM에서 사라진다는 것을 의미하는 클래스 컴포넌트의 용어다. 클린업 함수는 언마운트라기 보다는 함수 컴포넌트가 리렌더링됐을 때 의존성 변화가 있었을 당시 이전의 값을 기준으로 실행되는, 말 그대로 이전 상태를 청소해주는 개념으로 보는 것이 옳다.
의존성 배열은 보통 빈 배열을 두거나, 아예 아무런 값도 넘기지 않거나, 혹은 사용자가 직접 원하는 값을 넣을 수 있다. 빈 배열을 둔다면, 리액트가 이 useEffect는 비교할 의존성이 없다고 판단해 최초 렌더링 직후에 실행된 다음부터는 더 이상 실행되지 않는다. 아무런 값도 넘겨주지 않는다면, 의존성을 비교할 필요 없이 렌더링할 때마다 실행이 필요하다고 판단해 렌더링이 발생할 때마다 실행된다. 이는 보통 컴포넌트가 렌더링됐는지 확인하기 위한 방법으로 사용된다.
🖥️ useEffect(() => {
console.log("컴포넌트 렌더링됨")
})
위 코드를 구현하면 컴포넌트가 렌더링될 때마다 useEffect가 실행된다. 그렇다면, 의존성 배열이 없는 useEffect가 매 렌더링마다 실행된다면 그냥 useEffect 없이 써도 되는 것일까?
🖥️ // 1
function Component() {
console.log("렌더링됨")
}
// 2
function Component() {
useEffect(() => {
console.log("컴포넌트 렌더링됨")
})
}
두 코드는 명백한 차이점을 지니고 있다.
➡️ useEffect의 effect는 컴포넌트의 사이드 이펙트, 즉 부수 효과를 의미한다는 것을 명심하자. useEffect는 컴포넌트가 렌더링된 후에 어떠한 부수 효과를 일으키고 싶을 때 사용하는 훅이다.
리액트 코드를 읽다 보면 eslint-disable-line, react-hooks/exhaustive-deps 주석을 사용해 ESLint의 react-hooks/exhaustive-deps 룰에서 발생하는 경고를 무시하는 것을 볼 수 있다. 이 룰은 useEffect 인수 내부에서 사용하는 값 중 의존성 배열에 포함돼 있지 않은 값이 있을 때 경고를 발생시킨다.
🖥️ useEffect(() => {
console.log(props)
}
), []) //eslint-disable-line react-hooks/exhaustive-deps
대부분의 경우에 의도치 못한 버그를 만들 가능성이 큰 코드다. 이 코드를 사용하는 대부분의 예제가 빈 배열 []을 의존성으로, 즉 컴포넌트를 마운트하는 시점에만 무언가를 하고 싶다는 의도로 작성되곤 한다.
그러나 이는 클래스 컴포넌트의 생명주기 메서드인 coponentDidMount에 기반한 접근법으로, 가급적 사용을 자제해야 한다.
useEffect는 반드시 의존성 배열로 전달한 값의 변경에 의해 실행돼야 하는 훅이다. 그러나 의존성 배열을 넘기지 않은 채 콜백 함수 내부에서 특정 값을 사용한다는 것은, 이 부수 효과가 실제로 관찰해서 실행돼야 하는 값과는 별개로 작용한다는 것을 의미한다. 즉, 컴포넌트의 state, props와 같은 어떤 값의 변경과 useEffect의 부수 효과가 별개로 작동하게 된다는 것이다. useEffect에서 사용한 콜백 함수의 실행과 내부에서 사용한 값의 실제 변경 사이에 연결 고리가 끊어져 있는 것이다.
따라서 최초에 함수 컴포넌트가 마운트됐을 시점에만 콜백 함수 실행이 필요한지를 다시 되물어봐야 한다. 만약 정말 그렇다면 useEffect 내 부수 효과가 실행될 위치가 잘못됐을 가능성이 크다.
🖥️
function Component({ log }: { log: string}) {
useEffect(() => {
logging(log)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}
위 코드는 log가 최초로 props로 넘어와 컴포넌트가 최초로 렌더링 시점에만 실행된다. 코드를 작성한 의도는 해당 컴포넌트의 최초 렌더링시에만 logging을 실행하는 것이겠다.
그러나 당장은 문제가 없을지라도 위 코드는 버그의 위험성을 안고 있다. log가 아무리 변하더라도 useEffect의 부수 효과는 실행되지 않고, useEffect의 흐름과 컴포넌트 props.log의 흐름이 맞지 않게 된다.
따라서 앞에서 logging이라는 작업은 log를 props로 전달하는 부모 컴포넌트에서 실행되는 것이 옳을지도 모른다. 부모 컴포넌트에서 Component가 렌더링되는 시점을 결정하고, 이에 맞게 log 값을 넘겨준다면 useEffect의 해당 주석을 제거해도 위 예제 코드와 동일한 결과를 만들 수 있고, Component의 부수 효과 흐름을 거스르지 않을 수 있다.
빈 배열이 아닐 때도 마찬가지다. 만약 특정 값을 사용하지만 해당 값의 변경 시점을 피할 목적이라면, 메모이제이션을 적절히 활용해 해당 값의 변화를 막거나 적당한 실행 위치를 다시 고민해 보는 것이 좋다.
useEffect를 사용하는 많은 코드에서 useEffect의 첫 번째 인수로 익명 함수를 넘겨준다. 공식문서도 마찬가지다.
🖥️ useEffect(() => {
logging(user.id)
}, [user.id])
useEffect의 수가 적거나 복잡성이 낮다면 큰 문제는 없다. 그러나 useEffect의 코드가 복잡하고 많아질수록 useEffect 코드의 용도 파악이 어려워진다. 이때 첫 번째 인수를 기명 함수로 바꿔 목적 파악을 용이하게 하는 것이 좋다.
🖥️ useEffect(
function logActiveUser() {
logging(user.id)
},
[user.id],
)
어색해보일 수 있지만 useEffect의 목적을 명확히 하고 책임을 최소화한다는 점에서 유용하다.
useEffect는 의존성 배열을 바탕으로 렌더링 시 의존성이 변경될 때마다 부수 효과를 실행한다. 이 부수 효과의 크기가 커질수록 애플리케이션 성능에 악영향을 준다. useEffect가 컴포넌트의 렌더링 이후에 실행되기 때문에 렌더링 작업에 미치는 영향은 적을 수 있지만, 여전히 자바스크립트 실행 성능에 영향을 끼친다는 것은 변함없으므로, useEffect는 가능한 한 간결하고 가볍게 유지하는 것이 좋다.
만약 부득이하게 큰 useEffect를 만들어야 한다면, 적은 의존성 배열을 사용하는 여러 개의 useEffect로 분리하는 것이 좋다. 의존성 배열이 너무 거대하고 관리하기 어려운 수준에 이른다면 useEffect의 정확한 발생 시점을 알 수 없게 된다. 만약 의존성 배열에 여러 변수들이 들어가야 하는 상황이라면 최대한 useCallback과 useMemo 등으로 사전에 정제한 내용들만 useEffect에 담아두도록 한다.
useEffect의 크기가 작은 것과 같은 맥락에서, useEffect가 실행하는 콜백 또한 불필요하게 존재해서는 안 된다.
🖥️ function Component({ id }: {id: string}) {
const [info, setInfo] = useState<number |null>(null)
const controllerRef = useRef<AbortController | null>(null)
const fetchInformation = useCallback(async (fetchId: string) => {
controllerRef.current?.abort()
controllerRef.current = new AbortController()
const result = await fetchInfo(fetchId, { signal: controllerRef.signal })
setInfo(await result.json())
}, [])
useEffect(() => {
fetchInformation(id)
return () => controllerRef.current?.abort()
}, [id, fetchInformation])
return <div>{/* 렌더링 */}</div>
}
❓fetchInfo랑 fetchInformation이 왜 따로입니까?
이 컴포넌트는 props를 받아서 그 정보를 바탕으로 API 호출을 하는 useEffect를 가지고 있다. 그러나 useEffect 밖에서 함수를 선언하다보니 불필요한 코드가 많아지고 가독성이 떨어졌다.
🖥️ function Component({ id }: {id: string}) {
const [info, setInfo] = useState<number |null>(null)
useEffect(() => {
const controller = new AbortController()
;(async () => {
const result = await fetchInfo(id, { signal: controller.signal })
setInfo(await result.josn())
}) ()
return () => controller.abort()
}, [id])
return <div>{/* 렌더링 */}</div>
}
❓;(async () => ) 여기 뭡니까
useEffect 외부에 있던 관렴 함수를 내부로 가져왔더니 훨씬 간결해졌다. 불필요한 의존성 배열도 줄일 수 있었고, 무한루프에 빠지기 위해 넣었던 코드 useCallback도 삭제 가능했다. useEffect 내에서 사용할 부수 효과라면 내부에서 만들어서 정의해 사용하는 편이 훨씬 도움이 된다.
useEffect 내부에서 state를 결과에 따라 업데이트하는 로직이 있다고 가정해 보자. 만약 useEffect의 인수로 비동기 함수가 사용 가능하다면 비동기 함수의 응답 속도에 따라 결과가 이상하게 나타날 수 있다. 극단적 예제로, 이전 state 기반의 응답이 10초가 걸렸고, 이후 바뀐 state 기반의 응답이 1초 뒤에 왔다면, 이전 state 기반으로 결과가 나오는 불상사가 생길 수 있다. 이러한 문제를 useEffect의 경쟁 상태(race condition)라고 한다.
🖥️ useEffect(async () => {
// Effect callbacks are synchronous to porevent race conditions.
// Put the async function iside:
const response = await fetch('http://some.data.com/')
const result = await response.json()
setData(result)
}, [])
이러한 제약은 기술적 문제가 아니라, useEffect에서 비동기 함수 호출 시 발생할 수 있는 경쟁 상태 때문이다.
그렇다면 비동기 함수는 어떻게 실행할까? 한 가지 유념할 사실은, useEffect의 인수로 비동기 함수를 지정할 수 없는 것이지, 비동기 함수 실행 자체가 문제가 되는 것은 아니라는 사실이다. useEffect 내부에서 비동기 함수를 선언해 실행하거나, 즉시 실행 비동기 함수를 만들어 사용하는 것은 가능하다.
🖥️ useEffect(() => {
let shouldIgnore = false
async function fetchData() {
const response = await fetch('http://some.data.com')
const result = await response.json()
if (!shouldIgnore) {
setData(result)
}
}
fetchData()
return () => {
// shouldIgnore를 통해 useState의 두 번째 인수 실행을 막는 것뿐 아니라
// AbortController를 활용해 직전 요청 자체를 취소하는 것도 좋은 방법이 될 수 있다.
shouldIgnore = true;
}
}, [])
다만 비동기 함수가 내부에 존재하면, useEffect 내부에서 비동기 함수가 생성되고 실행되는 것을 반복하므로, 클린업 함수에서 이전 비동기 함수에 대한 처리를 추가하는 것이 좋다. fetch의 경우 abortController 등으로 이전 요청을 취소한다.
➡️ 즉, 비동기 useEffect는 state의 경쟁 상태를 야기할 수 있고,
클린업 함수의 실행 순서도 보장할 수 없기 때문에
개발자 편의를 위해 useEffect에서 비동기 함수를 인수로 받지 않는 것이다.
( 출처 : 모던 리액트 Deep Dive, 김용찬, 위키북스 )
https://hihiha2.tistory.com/169
https://velog.io/@jhsung23/React-useEffect-%EC%8B%A4%ED%96%89-%EC%88%9C%EC%84%9C