본 포스팅은 'React Hook' 에 대한 시리즈 게시글 중 4번째 게시글로, useEffect에 대해 중점적으로 다루고 있습니다!
useEffect() 를 배우면서 여러 console.log() 를 찍어 보며 작동 방식을 배웁니다. 이 때
React.StrictMode
가 켜져 있으면, 매 번 컴포넌트 (함수) 가 두 번씩 호출되는데, 이는 이해를 방해하므로 미리 꺼 두고 진행함을 알려드립니다!
React 를 쓰다 보면, 가장 많이 쓰는 hook 은 아마 useState()
가 아닐까 싶다. 그 다음으로 많이 쓰는 것은... 사람들마다 다를 수 있지만, 아무래도 useEffect()
가 아닐까? 😅
React 공식 문서에서는 hook 들을 설명하면서 '기본 hook (basic hooks)' 를 다음의 3가지라고 말하고 있다.
useState()
useEffect()
useContext()
useState()
같은 경우 우리가 흔히 자주 사용하는 것이고, 이전 포스팅 에서도 다루었었다.
useContext()
는 React 에서 제공하는 전역 상태관리 tool 로써, Context API 의 <Context.Consumer>
의 역할을 하는 hook 으로 소개되었다. 전역 상태 관리는 또 다른 주제이므로 다음 포스팅에서 다루어 보도록 하겠다!
그럼 마지막 남은 것이 바로 useEffect()
이다. 그만큼 React 에서 필수적인 hook 인 것이다. useEffect()
를 한 마디로 정의하면 다음과 같다.
함수형 컴포넌트에서 side effect 를 실행할 수 있도록 해 주는 hook
여기서 말하는 'side effect' 란 우선 컴포넌트가 화면 상에 rendering 된 이후 발생하는 모든 로직을 의미한다고 보면 된다.
즉, 이미 어떤 UI 컴포넌트가 화면 상에 그려진 이후에 실행할 것들을 실행시켜주는 hook 인 것이다. 그럼 문법부터 먼저 알아보자!
이름은 어려워 보이지만, 생각만큼 어렵지 않다. 아래의 코드를 보자.
// Test.js
import { useEffect } from 'react';
function Test() {
useEffect(() => {
console.log("안녕하세요! 👋")
})
return (
<div>Hello, world!</div>
)
}
export default Test
그리고 결과물을 확인하면 아래와 같다.
페이지를 들어가자마자 화면이 그려지고, 그리고 콘솔 상에
안녕하세요! 👋
가 출력이 됨을 을 수 있다.
앗 그럼 정확히 똑같은 동작을 하는 아래의 코드와 뭐가 다른지 궁금할 수 있다. 아래의 코드를 보자.
// Test.js
function Test() {
console.log("안녕하세요! 👋")
return (
<div>Hello, world!</div>
)
}
export default Test
아래와 같이 결과가 정확히 똑같다. 페이지를 들어가자마자 화면이 그려지고, 그리고 콘솔 상에 같은 내용이 표시된다.
하지만, 이 둘은 작동 방식이 분명히 다르다. 하나는 useEffect()
를 통해 호출이 되고, 하나는 컴포넌트 안에서 바로 호출이 된다. 이 둘의 차이점은 아래의 예시를 보면 명확하게 이해할 수 있다. 아래의 코드를 보자.
// Test.js
import { useRef } from 'react';
function Test() {
// input 을 잡을 ref 생성
const inputRef = useRef()
// input 의 값 출력
console.log(inputRef.current.value)
return (
<div>
<input ref={inputRef} />
</div>
)
}
export default Test
이번에는 console.log 가 아닌 지난 번에 다룬 ref
를 갖고 와 봤다. 위의 로직은 간단한다.
먼저, input
을 잡을 ref
를 하나 생성해준다.
그리고 바로
console.log(inputRef.current.value)
를 통해 (비어있지만) inputRef 의 값을 읽어오려는 시도를 한다. 하지만 벌써부터 오류가 발생할 조짐이 보인다. 왜냐하면 이 순간까지는 inputRef
라는 react 의 ref
object 만 만들었지, 그것이 브라우저 화면 상의 어떠한 <input />
element 와도 연결되지 않았기 때문이다.
그런데도 이렇게 값을 불러오려고 하면 정확하게 아래와 같은 에러가 발생한다.
10번째 줄에서 에러가 발생한다고 하니, 이동해서 확인해보면 역시나
console.log(inputRef.current.value)
부분을 가리키고 있음을 알 수 있다. 즉, 여기서 문제는 useRef()
를 통해서 inputRef()
를 만들 때 아무런 초기값도 넘기지 않아 현재 이 ref
object 의 current
값은 undefined
이다. 즉 아래와 같은 상태인 것이다.
inputRef = {
current: undefined
}
그러므로, 여기서 inputRef.current.value
를 읽으려는 시도에서 이미 inputRef.current
가 undefined
이므로 우리가 undefined.value
를 읽으려고 시도하고 있었고, 이는 당연히 오류를 낼 것이다.
이 문제에 대한 해결법은 여러 가지가 있을 수 있지만, useEffect()
를 통해 해결해 보자.
아래의 코드를 보자.
// Test.js
import { useRef, useEffect } from 'react';
function Test() {
// input 을 잡을 ref 생성
const inputRef = useRef()
useEffect(() => {
// input 의 값 출력
console.log(inputRef.current.value)
})
return (
<div>
<input ref={inputRef} />
</div>
)
}
export default Test
여기서 변경된 것은 딱 하나,
console.log(inputRef.current.value)
가 useEffect()
안으로 들어갔다. 다시 새로고침 해 보면, 이제는 같은 에러가 발생하지 않음을 알 수 있고, console 상에도 딱 하나의 빈 줄 (현재 <input />
에 아무 값도 입력돼있지 않으므로) 이 생기고 정상적으로 작동을 한다.
즉, 이렇다는 말은 JSX 내부 (return
문 안) 의
<input ref={inputRef} />
가 작동한 이후에 inputRef
에 실제 DOM 상의 <input />
이 연결이 되었고, 그 이후에
console.log(inputRef.current.value)
가 불려서 정상적으로
inputRef = {
current: <input>
}
의 ref object 가 .current.value
에 접근할 수가 있게 되었다는 것이다.
여기서 우리는 한 가지의 사실을 알 수가 있다.
useEffect()
는 컴포넌트가 브라우저상에 표시된 이후 (render 된 이후) 실행된다.
기본적으로 위의 사실을 알 수 있다. 즉, 아까의 console.log()
를 이용한 예시에서도, 두 경우에서 똑같이 작동을 "하는 것 처럼" 보였지만, 실제로는 작동 시점에서의 차이가 있었던 것 이다.
자 이제는 다음의 코드를 보자.
// Test.js
import { useRef, useEffect, useState } from 'react';
function Test() {
const [inputText, setInputText] = useState("")
// input 을 잡을 ref 생성
const inputRef = useRef()
useEffect(() => {
// input 의 값 출력
console.log(inputRef.current.value, "를 입력하였습니다. ")
})
return (
<div>
<input ref={inputRef} onChange={(e) => {
setInputText(e.target.value)
}} />
<p>{inputText}</p>
</div>
)
}
export default Test
코드를 보면, <input />
의 onChange 마다 inputText
라는 state 에 <input />
의 value 를 저장함으로써 하부에 <p>
element 에 input 의 내용이 표출되게 한다.
그런데 console 을 켜 보면, 아래와 같이 표시되며,
console.log(inputRef.current.value, "를 입력하였습니다. ")
이 출력되고 있음을 볼 수 있다.
여기서 우리는 또 다른 사실을 알 수 있다.
useEffect()
는 컴포넌트가 브라우저상에 re-render (update) 될 때도 실행된다.
자, 그러면 지금까지 정보로는 useEffect()
에 넘겨준 함수는 최초로 render 된 이후, 및 매 re-render 마다 (매 state 변경 마다) 실행된다는 것을 알 수 있다.
그런데 위의 경우처럼, 한 글자 한 글자 칠 때마다 console 상에 표시하는 것은 불필요할 것 같다. 그래서 이렇게 말고 "제출하기" 등과 같은 버튼을 눌렀을 때 딱 한 번만 그 시점에 <input />
에 있는 값을 console 에다가 옮겨 적어 주면 좋을 것 같다는 생각을 한다.
아래 코드를 보자.
// Test.js
import { useRef, useEffect, useState } from 'react';
function Test() {
const [inputText, setInputText] = useState("")
const [submitted, setSubmitted] = useState(false)
// input 을 잡을 ref 생성
const inputRef = useRef()
useEffect(() => {
// input 의 값 출력
console.log(inputRef.current.value, "를 입력하였습니다. ")
}, [submitted])
return (
<div>
<input ref={inputRef} onChange={(e) => {
setInputText(e.target.value)
}} />
<button onClick={() => { setSubmitted(true) }}>{submitted ? "제출 완료" : "제출하기"}</button>
<p>{inputText}</p>
<p>{submitted ? "제출 완료" : "제출 전"}</p>
</div>
)
}
export default Test
방금 전 코드에 비해 <button>
이 하나 추가되었고, 이 버튼이 눌렸는지 안 눌렸는지 알 수 있는 state
인 submitted
state 가 하나 더 추가되었다. (그리고 제출 된 상태인지 아닌지 알려주는 문구가 하나 추가되었으나, 현재 로직상 중요한 것은 아니다!)
그리고 가장 중요한, useEffect()
구조를 잘 보면, 두 번째 인자가 추가되었다. 두 번째 인자를 보면, 배열이고, 이 배열에는 submitted
라는 state 가 들어있음을 알 수 있다. 그리고 한번 실행을 해 보자.
이제는 매 입력마다 useEffect()
안의 console.log()
가 실행되지 않는다. 딱 한 번 실행이 되는데, 그 때가 바로 "제출하기" 버튼을 눌러서
setSubmitted(true)
코드가 실행되어 기존에
submitted === false
인 상태에서
submitted === true
인 상태로 변경이 된 때이다. 그리고 마침 useEffect()
안에 두 번째 인자로 들어간 배열 안의 값도 submitted
이다.
여기서 우리는 다음과 같은 사실을 알 수 있다.
useEffect()
에는 첫 번째 인자인 실행시킬 함수 말고, 두 번째 인자도 넘겨 줄 수 있으며, 이는 배열이다.
두 번째 인자를 넘겨주게 되면, 기존에 두 번째 인자를 넘겨주지 않았을 때
useEffect()
가 매 re-render 시에 무조건 첫 번째 인자로 넘겨준 함수를 실행하던 것과 달리, 이제는 두 번째 인자로 넘겨준 배열들에 포함된 값들 중 어느 하나라도 바뀌어야 첫번째 인자로 넘겨준 함수를 실행시킨다.
이 때, 이 두 번째 인자로 넘어간 배열 자체에 useEffect()
에 넘겨준 함수의 실행 여부가 '의존' 하므로, 이 배열의 "의존성 배열 (dependency array)" 라고 한다.
자 그러면 여기서 의존성 배열을 "넘겨주면" 매 re-render 시에 무조건 첫 번째 인자로 넘겨준 함수를 실행하지 않는다는 사실을 알 수 있다. 그리고 이 때 의존성 배열안의 값들 중 하나가 바뀌면 다시 첫 번째 인자로 넘겨준 함수를 실행한다는 것도 알았다.
그러면
두 번째 인자로 넘겨준 배열이 비어 있으면, 즉,
[]
을 넘겨주면 어떻게 될까?
정의 그대로이다.
이제 두 번째 인자로 배열이 넘어가서, 매 re-render 시에 실행되지는 않는다. 그러나, 이제는 배열이 비어 있어서, 그 어떠한 값의 변화도 이 첫번째 인자의 함수를 실행시킬 수가 없다. 즉, 어떠한 방법으로도 이 최초 render 때 실행 이후 이 useEffect()
에 넘어간 함수를 실행시킬 방법이 없다는 것이다.
정확히 그대로다. 즉, 우리는 다음과 같은 사실을 알 수 있다.
두 번째 인자로 넘겨준 배열이 비어 있으면, 최초 render 시에 딱 한 번 실행되고, 그 이후에는 실행되지 않는다. 즉, 딱 한 번, 최초 render 시에"만" 실행된다.
다음의 코드를 보자.
// Test.js
import WidthViewer from 'common/useEffect/WidthViewer';
import { useState } from 'react';
function Test() {
// 현재 창의 너비를 보여줄지 말지 결정하는 state
const [showWindowWidth, setShowWindowWidth] = useState(false)
return (
<div>
<button onClick={() => { setShowWindowWidth(!showWindowWidth) }}>
{showWindowWidth ? "닫기" : "창 가로 너비 보기"}
</button>
{showWindowWidth &&
<WidthViewer />
}
</div>
)
}
export default Test
// WidthViewer.js
import { useState, useEffect } from 'react';
function WidthViewer() {
// 현재 창의 너비를 저장할 state
const [windowWidth, setWindowWidth] = useState(window.innerWidth)
// 매 resize event 발생 시 마다 실행할 함수.
function handleResize() {
setWindowWidth(window.innerWidth)
console.log(window.innerWidth)
}
useEffect(() => {
// 컴포넌트 최초 render 직후 1회만 실행 (빈 의존성 배열)
window.addEventListener("resize", handleResize)
}, [])
return (
<div>
<p>창 가로 너비: {windowWidth}px</p>
</div>
)
}
export default WidthViewer
위의 코드는 아래 실행 결과와 같이 현재 열린 브라우저 창의 너비 (width) 를 화면에 표시해주는 기능을 한다.
비어 있는 의존성 배열이 주어졌으므로, 컴포넌트 실행 시 최초로 한 번 resize
에 대한 event listener 가 붙어서, 매 resize 마다 지정된 함수, 여기서는 handleResize
함수를 실행시킨다.
그리고 handleResize
함수는 windowWidth
라는 state 를 변경시키고, console 상에 width 를 출력한다.
언뜻 보면 별 문제가 없는 것 같지만, 여기서도 문제가 하나 발생한다. 아래와 같이 "닫기" 버튼 을 눌러 창 가로 너비를 보여주는 UI 를 화면상에서 제거하고, 다시 window 를 resize 해 보자.
이제 UI 를 닫아서, 더 이상 window resize 에 대한 event listener 가 필요 없어졌음에도 불구하고, console 상에는 계속 현재 window 의 가로 너비가 계속 업데이트 되고 있는 것으로 보아, window 의 innerWidth 가 필요한 UI 는 없어졌음에도 event listener 는 계속 작동 중이라는 것을 알 수 있다.
Event listener 의 경우 add 이후 명시적인 페이지 새로고침이나 아예 다른 페이지로 이동하지 않는 이상, 계속 남아 있다. 그래서 더이상 이 event listener 을 사용하는 컴포넌트가 없는 데에도 계속 event listener 을 남겨 두면, 성능 면에서 좋지 못하고, memory leak 가 발생할 수 있다.
그래서 이러한 경우에는 이 컴포넌트가 unmount 될 때 (화면에서 사라질 때) 명시적으로 remove event listener 을 다음과 같이 해 주어야 한다.
다음의 코드를 보자.
// WidthViewer.js
import { useState, useEffect } from 'react';
function WidthViewer() {
// 현재 창의 너비를 저장할 state
const [windowWidth, setWindowWidth] = useState(window.innerWidth)
// 매 resize event 발생 시 마다 실행할 함수.
function handleResize() {
setWindowWidth(window.innerWidth)
console.log(window.innerWidth)
}
useEffect(() => {
// 컴포넌트 최초 render 직후 1회만 실행 (빈 의존성 배열)
window.addEventListener("resize", handleResize)
// 컴포넌트 unmount 시 (화면에서 사라질 시) event listener 제거
return () => {
window.removeEventListener("resize", handleResize)
}
}, [])
return (
<div>
<p>창 가로 너비: {windowWidth}px</p>
</div>
)
}
export default WidthViewer
기존의 useEffect()
에 return
문을 추가해 주었고, 결국 "함수" 를 반환하도록 만들었는데, 이 함수는 event listener 을 제거 (remove) 하는 함수이다.
아래 실행 결과를 보자.
이제 컴포넌트가 닫히면 (unmmount 되면) 더 이상 listener 가 작동하지 않아, console 에도 아무것도 표시되지 않는다. 우리는 다음과 같은 사실을 알 수 있다.
useEffect()
에 첫 번째 인자로 주어지는 함수 내에서return
문 안에 또 다른 함수를 반환할 경우 이 함수는
1. 컴포넌트가 unmount 될 때
<또는>
2. 컴포넌트가 update 되기 직전
에 실행된다.
2번 (컴포넌트 update 직전)의 경우도 직접 해 보면 해당 시점에 실행된다는 것을 알 수 있다.
이렇게 return
문이 포함된 useEffect()
를 마지막으로 "정리" 작업을 한다고 해서, 이렇게 사용하는 useEffect()
를 useEffect() with cleanup
이라고 한다.
좋은 질문이다. 사실 useEffect()
도 개념적인 차원에서는 복잡한 내용도 아니고, 그만큼 처음에 배우고 나자마자, "이게 어디 쓰이지?" 라는 의문이 드는 것이 사실이다. 그래서 아래와 같은 경우에 많이들 쓴다.
어떤 state 의 변화에 따라서 다른 API 호출을 보내 data 를 fetch 할 수 있게 해 준다.
위의 예시와 같이 mouse 나 keyboard event 등 브라우저 상에서 일어나는 event 에 listener 을 add 하고 remove 할 때 사용한다.
특정 값에 변화가 일어났을 때, document.querySelector()
또는 document.getElementById()
등을 통해 직접 DOM 에 접근하여 화면을 다시 그릴 수 있다.
⚠️ React 는 언제나 state 를 통해 DOM 이 업데이트 되게끔 하는 것이 최선임을 알리고 있으며, 직접 DOM 을 접근하는 것은 불가피한 경우가 아니고서는 권장하지 않는다!
각각의 사용례는 추후 따로 포스팅을 하겠다!
결론적으로 useEffect()
는 다음과 같이 크게 3가지 사용법이 있고, 그리고 return
문을 사용하느냐 마느냐에 따라 또 종류가 나뉠 수 있다. 다음의 그림을 보자.
hook 들은 결국 React version 16.8 이전 클래스형 컴포넌트에서의 기능을 함수형 컴포넌트에서도 사용할 수 있도록 만들기 위해 만들어진 것이다.
다음 포스팅에서는 React 의 useEffect()
를 간단히 복습하고, React 컴포넌트의 생애 주기와 함께 이 생애 주기가 useEffect()
와 어떤 관련이 있는지 알아보자.