모던 리액트 딥다이브 Week3 - Chapter3

지코·2025년 9월 16일

FE STUDY

목록 보기
4/7
post-thumbnail
본 포스팅 시리즈는 📚'모던 리액트 Deep Dive'를 주간 별로 1장씩 공부하며
* 새롭게 알게 된 것들
* 평소 알고 있다고 생각했지만 이번에 제대로 알게 된 것들
* 궁금한 부분에 대해 딥다이브한 것들
등을 기재하기 위해 시작되었다.

📖 3장. 리액트 훅 깊게 살펴보기


🤔 useState를 사용하지 않고 자체 변수를 사용해 상태값을 관리하기 어려운 이유는? (p. 190-194)

function Component() {
  let state = 'hello'
  
  function handleButtonClick() {
    state = 'hi'
  }
  
  return (
    <>
      <h1>{state}</h1>
      <button onClick={handleButtonClick}>hi</button>
    </>
  )
}

useState 를 사용하지 않고 함수 내부에서 자체적으로 변수를 사용해 상태값을 관리하는 위 예제 코드를 살펴보자.

위 코드가 개발자가 의도한 대로 동작하지 않는 이유는 무엇일까?
리액트에서 렌더링은 함수 컴포넌트의 return과 클래스 컴포넌트의 render 함수를 실행한 결과를 이전의 리액트 트리와 비교해 리렌더링이 필요한 부분만 업데이트해 이루어진다.
이때 일반 변수는 리액트의 상태 관리 메커니즘에 포함되지 않기 때문에 값이 바뀌어도 리렌더링되지 않으며, 리렌더링되더라도 함수형 컴포넌트는 렌더링될 때마다 함수가 처음부터 다시 실행되므로 state 변수의 값은 계속 'hello'로 초기화된다.

🧐 그렇다면 useState 훅은 함수가 실행되어도 어떻게 그 값을 유지하고 있을까?

이를 위해 리액트는 클로저(closer)를 이용했다.

♾️ 이 맥락에서 클로저는,
어떤 함수(useState) 내부에 선언된 함수(setState)가 함수의 실행이 종료된 이후에도(useState 가 호출된 이후에도) 지역변수인 state를 계속 참조할 수 있다는 것을 의미한다.

매번 실행되는 함수 컴포넌트 환경에서 state의 값을 유지하고 사용하기 위해서 리액트는 클로저를 활용하고 있다. 실제 리액트에서 useStateuseReducer 를 이용해 구현돼 있다.

게으른 초기화 (p. 194-196)

와 나는 '게으른 초기화'라는 문장을 처음 들어봤으며, useState 에 초깃값을 함수로 넣을 생각은 전혀 못 해봤다 ... (띵 ~ )

⚡️ 게으른 초기화(lazy Initialization)
: useState 에 변수 대신 함수를 넘기는 것.

리액트에서 렌더링이 실행될 때마다 함수 컴포넌트의 함수가 재실행되며, useState의 값도 재실행된다. 하지만 리액트는 useState 내부에 존재하는 클로저를 통해 값을 가져오며, 초깃값은 최초에만 사용된다.

// 일반적인 useState 사용
// 바로 값을 집어넣는다.
const [count, setCount] = useState(
  Number.parseInt(window, localStorage.getItem(cacheKey))
)

// 게으른 초기화
// 위 코드와의 차이점은 함수를 실행해 값을 반환한다는 것이다.
const [count, setCount] = useState(() => 
  Number.parseInt(window.localStorage.getItem(cacheKey))
)

만약 useState 인수로 자바스크립트에 많은 비용을 요구하는 작업이 들어가 있다면, 이는 계속해서 실행될 위험이 존재할 것이다. 하지만 useState 의 초깃값으로 함수를 넣으면, 최초 렌더링 이후에는 실행되지 않고 최초의 state 값을 넣을 때만 실행된다.

🧐 게으른 최적화는 언제 사용하는 것이 좋을까?

리액트에서는 무거운 연산이 요구될 때 사용하라고 한다.

  • localStorage, sessionStorage에 대한 접근.
  • map, filter, find 같은 배열에 대한 접근.
  • 함수 호출이 필요할 때와 같이 무거운 연산을 포함해 실행 비용이 많이 드는 경우.

useEffect 클린업 함수의 동작 흐름 (p. 199-201)

보통 useEffect 내에서 반환되는 함수를 클린업 함수라고 부른다.

import { useState, useEffect } from ‘react' 

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> 
    </> 
  ) 
}

위 컴포넌트를 실행해보면 다음과 같은 결과를 얻을 수 있다.

클린업 함수 실행! 0
1

클린업 함수 실행! 1
2

클린업 함수 실행! 2
3

...

로그를 살펴보면 클린업 함수는 이전 state 값을 참조해 실행된다❗️는 것을 알 수 있다. 새로운 값을 기반으로 렌더링 뒤에 실행되지만, 이 변경된 값을 읽는 것이 아니라 함수가 정의됐을 당시에 선언됐던 이전 값을 보고 실행된다는 것이다.

🔍 이렇게 실행되는 useEffect의 흐름은 무엇일까?

  1. 함수 컴포넌트는 state 값이 바뀔 때마다 다시 호출(렌더링)된다.
    setCounter로 상태를 바꾸면 리액트가 컴포넌트를 다시 렌더링하고, 그때 useEffect도 새로 설정된다.
  2. useEffect의 클린업 함수는 새 effect가 실행되기 직전에 실행된다.
    즉 새로운 렌더링에서 새로운 effect가 적용되기 전에, 이전 effect의 클린업 함수가 호출되는 것이다.
  3. 클린업 함수는 이전 렌더링 시점의 변수와 클로저를 기억한다.
    자바스크립트의 클로저 개념 때문에, 클린업 함수 안에서 참조하는 counter 값은 그 클린업 함수가 정의된 시점(이전 렌더링)의 값이다.

의존성 배열이 없는 useEffectuseEffect를 사용하지 않는 것의 차이 (p. 202)

useEffect 의 의존성 배열로 빈 배열을 사용할 경우
➡️ 리액트가 이 useEffect는 비교할 의존성이 없다고 판단해, 컴포넌트가 마운트(mount)될 때만 실행되고 그 이후에는 실행되지 않는다.


useEffect 의 의존성 배열에 아무 것도 넘기지 않을 경우
리액트가 이 useEffect 는 의존성을 비교할 필요가 없다고 판단해, 렌더링이 발생할 때마다 실행된다.

// 1
function Component() {
  console.log("컴포넌트 렌더링됨")
}

// 2
function Component() {
  useEffect(() => {
    console.log("컴포넌트 렌더링됨")
  })
}

useEffect와 함수 본문에서의 코드 실행은 실행 횟수는 같아 보이지만, 실행 시점 / 환경 / 성능 영향에서 큰 차이가 있다.

  • 함수 본문
    • 함수 내부에서의 직접 실행은 컴포넌트의 렌더링 중에 실행되며, 서버 사이드 렌더링의 경우에는 서버에서도 실행된다.
    • 서버에서도 실행되므로 window나 document 접근 시 에러 위험이 있다.
    • 무거운 연산이 있으면 렌더링 속도를 저하시킬 수 있다.
  • 의존성 배열이 없는 useEffect 사용
    • 컴포넌트의 렌더링이 완료된 후 클라이언트 사이드에서 실행된다.
    • window 객체의 접근에 의존하는 코드를 사용할 수 있다.
    • 부수 효과 작업을 안전하게 처리할 수 있다.

즉 DOM 조작, 이벤트 등록, API 호출 등 렌더링 외적인 작업은 useEffect 로 분리해 실행하는 것이 리액트의 핵심 철학이다.

🤔 useMemoReact.memo의 차이 (p. 210)

useMemo 로 컴포넌트도 감쌀 수 있다. 물론 React.memo 를 쓰는 것이 더 현명하다.

🌐 의도 명확성

React.memo(SomeComponent) 는 “이 컴포넌트를 메모이제이션하겠다”는 뜻이 명확하다.

반면 useMemo(() => <SomeComponent />) 는 “렌더링 결과를 메모이제이션”하는 꼼수 같아, 코드를 읽는 개발자가 헷갈릴 수 있다.

🌐 Props 비교 자동화

React.memo 는 props를 얕게 비교해, 바뀌지 않으면 리렌더링을 막는다.

하지만 useMemo<SomeComponent /> 를 감싸면 props 비교를 의존성 배열 관리로 직접 해줘야 하기 때문에 실수 위험이 증가한다.

🌐 성능 최적화에 맞춤

React.memo 는 내부적으로 컴포넌트 렌더링 최적화 로직을 갖고 있어서 이 목적에 특화되어 있다.
하지만 useMemo 는 단순히 계산 결과를 캐싱하는 훅이라, 컴포넌트 렌더링 최적화엔 맞지 않는다.

useCallback에 기명 함수를 넘겨주는 이유 (p. 214)

const toggle1 = useCallback(() => {
  setStatus1(!status1)
}, [status1])

위 예제 코드와 같이 useCallback 에 기명 함수를 사용하는 것이 권장되는 이유는 크롬 메모리 탭에서 디버깅을 용이하게 하기 위함이다. 익명 함수로 넘겨줄 경우, 말 그대로 이름이 없어 해당 함수를 추적하기 어렵기 때문이다.

useRef의 유용한 활용 예제와 코드 흐름 (p. 218)

function usePrevious(value) {
  const ref = useRef()
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref.current
}

function SomeComponent() {
  const [counter, setCounter] = useState(0)
  const previousCounter = usePrevious(counter)
  
  function handleClick() {
    setCounter((prev) => prev + 1)
  }
  
  // 0 (undefined)
  // 1 0
  // 2 1
  // ...
  return (
    <button onClick={handleClick}>
      {counter} {previousCounter}
    </button>
  )
}

위 예제는 useRef 를 활용해 useState의 이전 값을 저장하는 usePrevious 훅을 구현한 것이다.

🔎 코드의 흐름은 어떻게 되는가

  1. SomeComponent가 렌더링됨과 동시에 counter 변수가 0으로 초기화되고, usePrevious(0) 이 실행된다.
  2. useEffect는 렌더링 후에 실행되기 때문에, ref.current는 초깃값인 undefined를 가지게 된다.
  3. 화면에 counter = 0, previousCounter = undefined가 렌더링된다.
  4. 렌더링이 끝났기 때문에, useEffect 가 실행되고 ref.current = 0이 된다.
  5. 위 과정은 버튼을 클릭할 때마다 각각 1씩 증가한 값으로 반복된다.

useReducer 파악하기 ⭐️ (p.225-227)

useReducer 훅은 useState 와 비슷한 형태를 띠지만, 좀 더 복잡한 상태값을 미리 정의해 놓은 시나리오에 따라 관리할 수 있다.

  1. 반환값은 useState와 동일하게 길이가 2인 배열이다.
  • state: 현재 useReducer가 가지고 있는 값.
  • dispatcher: state를 업데이트하는 함수. dispatcher는 setState와 다르게 state를 변경할 수 있는 액션인 action을 넘겨준다.
  1. useState의 인수와 달리 2~3개의 인수를 필요로 한다.
  • reducer: useReducer의 기본 action을 정의하는 함수.
  • initialState: useReducer의 초깃값
  • init: 초깃값을 지연해서 생성시키고 싶을 때 사용하는 함수. 필수값은 아니다.
// useReducer가 사용할 state
type State = {
  counter: number
}

// state의 변화를 발생시킬 action의 타입과 넘겨줄 값(payload)을 정의
type Action = {type: 'up' | 'down' | 'reset'; payload?: State }

// 무거운 연산이 포함된 게으른 초기화 함수
function init(count: State): State {
  // State를 받아서 초깃값을 어떻게 정의할지 연산하면 된다.
  return count
}

// 초깃값
const initialState: State = { count: 0 }

// state와 action을 기반으로 state가 어떻게 변경될지 정의
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'up':
      return { count: state.count + 1 }
    case 'down':
      return { count: state.count - 1 > 0 ? state.count : 0 }
    case 'reset':
      return init(action.payload || { count: 0 })
    default:
      throw new Error(`Unexpected action type ${action.type}`)
  }
}

export default function App() {
  const [state, dispatcher] = useReducer(reducer, initialState, init)
  
  function handleUpButtonClick() {
    dispatcher({ type: 'up' })
  }
  
  function handleDownButtonClick() {
    dispatcher({ type: 'down' })
  }
  
  function handleResetButtonClick() {
    dispatcher({ type: 'reset', payload: { count: 1 }})
  }
  
  return (
    <div className="App">
      <h1>{state.count}</h1>
      <button onClick={handleUpButtonClick}>+</button>
      <button onClick={handleDownButtonClick}>-</button>
      <button onClick={handleResetButtonClick}>reset</button>
    </div>
  )
}

책에 나와있는 useReducer 예제 코드이다.

useReducer 의 목적은 간단하다.
복잡한 형태의 state를 사전에 정의된 dispatcher로만 수정할 수 있게 만들어 줌으로써 state 값에 대한 접근은 컴포넌트에서만 가능하게 하고, 이를 업데이트하는 방법에 대한 상세 정의는 컴포넌트 밖에다 둔 다음, state의 업데이트를 미리 정의해 둔 dispatcher로만 제한하는 것이다.
state 값을 변경하는 시나리오를 제한적으로 두고 이에 대한 변경을 빠르게 확인할 수 있게끔 하는 것useReducer 의 목적이다❗️

따라서 단순히 number나 boolean처럼 간단한 값을 관리하는 것은 useState로 충분하지만, state 하나가 가져야 할 값이 복잡하고 이를 수정하는 경우의 수가 많아진다면, 성격이 비슷한 여러 개의 state를 묶어 useReducer로 관리하는 편이 더 효율적일 수도 있다.

forwardRef 파악하기 ⭐️ (p. 229-231)

useRef 훅에서 반환한 객체인 ref는 리액트 컴포넌트의 props인 ref에 넣어 HTMLElement에 접근하는 용도로 사용한다.

이러한 ref를 상위 컴포넌트에서 하위 컴포넌트로 전달하고 싶거나, 상위 컴포넌트에서 접근하고 싶은 ref가 있지만 이를 직접 props로 넣어 사용할 수 없을 때 forwardRef 훅을 사용할 수 있다.

일반적으로 리액트에서는 컴포넌트의 props인 ref 속성을 통해 ref 객체를 전달할 수 없게 되어 있다. 전달하게 될 경우 undefined를 반환하며 경고문이 뜰 것이다.
다른 이름의 props를 설정해 ref 객체를 전달할 수도 있지만, 코드 상의 일관성을 제공하기 위해 forwardRef 훅이 탄생하게 되었다.

const ChildComponent = forwardRef((props, ref) => {
  useEffect(() => {
    // {current: undefined}
    // {current: HTMLInputElement}
    console.log(ref)
  }, [ref])
  
  return <div>안녕!</div>
})

function ParentComponent() {
  const inputRef = useRef()
  
  return (
    <>
      <input ref={inputRef} />
      <ChildComponent ref={inputRef} />
    </>
  )
}

forwardRef 를 사용하는 예제를 살펴보자.

먼저 ref 객체를 받고자 하는 컴포넌트를 forwardRef 로 감싸고, 두 번째 인수로 ref를 전달 받는다. 그리고 부모 컴포넌트에서는 동일하게 props의 ref 속성을 통해 ref 객체를 넘겨줄 수 있다.

결국 forwardRef 를 사용하면 ref를 props로 전달할 수 있고, 전달 받은 컴포넌트에서도 ref라는 이름을 그대로 사용할 수 있다.

useEffectuseLayoutEffect의 차이점과 실행 흐름 (p. 233-234)

useLayoutEffect 훅은 형태나 사용 예제 면에서 useEffect 훅과 차이가 없다.

큰 차이점은 useLayoutEffect 훅은 모든 DOM의 변경 후에 useLayoutEffect의 콜백 함수 실행이 동기적으로 발생한다는 점이다. 여기서 DOM 변경은 브라우저에 변경사항이 반영되는 것이 아닌, 렌더링을 의미한다.

useEffectuseLayoutEffect 를 한 컴포넌트 내에서 함께 사용하고, 해당 컴포넌트의 state가 변경되어 리렌더링이 발생했을 때 두 훅의 콜백 함수가 어떻게 실행되는지 흐름을 살펴보자.

  1. 리액트가 DOM을 업데이트한다.
  2. useLayoutEffect 를 실행한다.
  3. 브라우저에 변경사항을 반영한다.
  4. useEffect 를 실행한다.

항상 useLayoutEffectuseEffect 보다 먼저 실행된다. 이는 useLayoutEffect 가 브라우저에 변경사항이 반영되기 전에 실행되는 반면, useEffect 는 그 이후에 실행되기 때문이다.

주의할 점으로는, 리액트 컴포넌트가 useLayoutEffect 가 완료될 때까지 기다려야 하기 때문에 웹 애플리케이션이 일시 중지되는 것과 같은 일이 발생하게 되며, 이는 성능 문제로까지 이어질 수 있다는 것이다. 따라서 반드시 필요할 때만 사용하는 것이 좋다.

🤔 AbortController 에 대하여 (p.239-240)

import { useEffect, useState } from 'react'

// HTTP 요청을 하는 사용자 정의 훅
function useFetch<T>(
  url: string,
  { method, body }: { method: string; body?: XMLHttpRequestBodyInit }
) {
  // 응답 결과
  const [result, setResult] = useState<T | undefined>()
  // 요청 중 여부
  const [isLoading, setIsLoading] = useState<boolean>(false)
  // 2xx 3xx로 정상 응답인지 여부
  const [ok, setOk] = useState<boolean | undefined>()
  // HTTP Status
  const [status, setStatus] = useSTate<number | undefined>()
  
  useEffect(() => {
    const abortController = new AbortController()
    
    ;(async () => {
      setIsLoading(true)
    
      const response = await fetch(url, {
        method,
        body,
        signal: abortController.signal
      })
      
      setOk(response.ok)
      setStatus(response.status)
      
      if (response.ok) {
        const apiResult = await response.json()
        setResult(apiResult)
      }
      
      setIsLoading(false)
    })()
    
    return () => {
      abortController.abort()
    }
  }, [url, method, body])
    
  
  return [ok, result, isLoading, status]
}

위 코드는 AbortController 를 활용해 HTTP 요청을 하는 사용자 정의 훅을 구현한, 책에 나와있는 예제 코드이다. 여기서 AbortController 에 대해 더 자세하게 알아보려고 한다.

⚙️ AbortController 란?

AbortController 는 자바스크립트에서 제공하는 비동기 작업 취소용 컨트롤러이다. 주로 fetch API와 함께 사용되며, 요청을 중간에 중단할 수 있다.

〰️ 전반적인 흐름

  • controller = new AbortController()
    → 컨트롤러 생성.
  • controller.signal
    → 요청과 컨트롤러를 연결해주는 신호.
    → fetch와 같은 비동기 함수에 signal을 넘겨주면, 후에 요청이 중단됐을 때 signal을 통해 그 사실을 전달할 수 있다.
    → fetch 뿐 아니라 다른 비동기 로직에서도 취소 알림을 받고 싶을 때도 활용할 수 있다.
  • controller.abort()
    → 신호를 통해 요청 중단.
    → 위 예제에서는 클린업 함수에 포함되어 있다.

리액트에서는 컴포넌트 언마운트 시 불필요한 네트워크 요청을 중단하는 용도로 많이 쓴다. 예를 들어 검색창 자동완성 기능에서 사용자가 입력할 때마다 API 요청을 보내면, 이전 요청이 완료되기도 전에 새 요청이 발생한다. 이럴 때 이전 요청을 취소해주면 불필요한 리소스 낭비를 막을 수 있다.
위 코드를 예로 들면, url, method, body가 바뀌면 useEffect가 다시 실행되면서 이전 요청은 클린업 함수에서 취소된다.

이외에도 언마운트된 컴포넌트에서 setState가 호출되는 문제를 예방해 메모리 누수를 방지하고, 검색, 무한스크롤, 실시간 데이터 요청에서 불필요한 요청을 줄여 리소스를 최적화하는 데에도 AbortController 를 주로 사용한다.

주의할 점은 AbortController 는 대부분의 최신 브라우저에서 지원되지만, IE와 일부 오래된 환경에서는 동작하지 않는다. 필요하다면 polyfill을 고려해야 한다.

사용자 정의 훅과 고차 컴포넌트 중 무엇을 써야 할까? (p.249-251)

사용자 정의 훅과 고차 컴포넌트 모두 리액트 코드에서 어떠한 로직을 공통화해서 별도로 관리할 수 있다는 특징이 있다.

  1. 사용자 정의 훅이 필요한 경우
  • 단순히 useEffect, useState와 같이 리액트에서 제공하는 훅으로만 공통 로직을 격리할 수 있을 때.
  • 컴포넌트 전반에 걸쳐 동일한 로직으로 값을 제공하거나, 특정한 훅의 작동을 취하게 하고 싶을 때.
  1. 고차 컴포넌트를 사용해야 하는 경우
  • 함수 컴포넌트의 반환값, 즉 렌더링의 결과물에도 영향을 미치는 공통 로직을 처리해야 할 때.

Reference

📚 모던 리액트 Deep Dive

profile
꾸준함이 무기

0개의 댓글