원하는 때에 원하는 코드 실행! - useEffect()

프론트 깎는 개발자·2023년 1월 2일
1

react hooks

목록 보기
4/5
post-thumbnail

본 포스팅은 'React Hook' 에 대한 시리즈 게시글 중 4번째 게시글로, useEffect에 대해 중점적으로 다루고 있습니다!

useEffect() 를 배우면서 여러 console.log() 를 찍어 보며 작동 방식을 배웁니다. 이 때 React.StrictMode 가 켜져 있으면, 매 번 컴포넌트 (함수) 가 두 번씩 호출되는데, 이는 이해를 방해하므로 미리 꺼 두고 진행함을 알려드립니다!

Intro

React 를 쓰다 보면, 가장 많이 쓰는 hook 은 아마 useState() 가 아닐까 싶다. 그 다음으로 많이 쓰는 것은... 사람들마다 다를 수 있지만, 아무래도 useEffect() 가 아닐까? 😅

React 공식 문서에서는 hook 들을 설명하면서 '기본 hook (basic hooks)' 를 다음의 3가지라고 말하고 있다.

  1. useState()
  2. useEffect()
  3. 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.currentundefined 이므로 우리가 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() 를 이용한 예시에서도, 두 경우에서 똑같이 작동을 "하는 것 처럼" 보였지만, 실제로는 작동 시점에서의 차이가 있었던 것 이다.

useEffect 의 작동 시점

자 이제는 다음의 코드를 보자.

// 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> 이 하나 추가되었고, 이 버튼이 눌렸는지 안 눌렸는지 알 수 있는 statesubmitted 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 시에"만" 실행된다.

마지막: cleanup 작업

다음의 코드를 보자.

// 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() 도 개념적인 차원에서는 복잡한 내용도 아니고, 그만큼 처음에 배우고 나자마자, "이게 어디 쓰이지?" 라는 의문이 드는 것이 사실이다. 그래서 아래와 같은 경우에 많이들 쓴다.

1. Data Fetching

어떤 state 의 변화에 따라서 다른 API 호출을 보내 data 를 fetch 할 수 있게 해 준다.

2. Event Listening

위의 예시와 같이 mouse 나 keyboard event 등 브라우저 상에서 일어나는 event 에 listener 을 add 하고 remove 할 때 사용한다.

3. DOM 을 직접 조작

특정 값에 변화가 일어났을 때, document.querySelector() 또는 document.getElementById() 등을 통해 직접 DOM 에 접근하여 화면을 다시 그릴 수 있다.

⚠️ React 는 언제나 state 를 통해 DOM 이 업데이트 되게끔 하는 것이 최선임을 알리고 있으며, 직접 DOM 을 접근하는 것은 불가피한 경우가 아니고서는 권장하지 않는다!

각각의 사용례는 추후 따로 포스팅을 하겠다!

결론 및 요약

결론적으로 useEffect()는 다음과 같이 크게 3가지 사용법이 있고, 그리고 return 문을 사용하느냐 마느냐에 따라 또 종류가 나뉠 수 있다. 다음의 그림을 보자.

hook 들은 결국 React version 16.8 이전 클래스형 컴포넌트에서의 기능을 함수형 컴포넌트에서도 사용할 수 있도록 만들기 위해 만들어진 것이다.

다음 포스팅에서는 React 의 useEffect() 를 간단히 복습하고, React 컴포넌트의 생애 주기와 함께 이 생애 주기가 useEffect() 와 어떤 관련이 있는지 알아보자.

profile
Comfort Zone 에서 벗어나자!

0개의 댓글