본문 바로가기
FE/모던 리액트 Deep Dive

3. 리액트 훅 깊게 살펴보기 - useState

by Toddler_AD 2025. 9. 18.
  • 함수 컴포넌트가 상태를 사용하거나 클래스 컴포넌트의 생명주기 메서드를 대체하는 등의 다양한 작업을 하기 위해 훅(hook)이라는 것이 추가됐다. 훅을 활용하면 클래스 컴포넌트가 아니더라도 리액트의 다양한 기능을 활용할 수 있다. 리액트에서 현재 사용 가능한 훅이 무엇이고, 어떻게 쓰이는지, 그리고 훅을 사용할 때 주의할 점은 무엇인지 확인해보자.

3.1 리액트의 모든 훅 파헤치기

  • 리액트 함수 컴포넌트에서 가장 중요한 개념은 바로 훅이다. 훅은 클래스 컴포넌트에서만 가능했던 state, ref 등 리액트의 핵심적인 기능을 함수에서도 가능하게 만들었고, 무엇보다 클래스 컴포넌트보다 간결하게 작성할 수 있어 훅이 등장한 이래로 대부분의 리액트 컴포넌트는 함수 컴포넌트로 작성되고 있을 정도로 많은 사랑을 받고 있다. 그러나 앞서 2.3절 '클래스 컴포넌트와 함수 컴포넌트'에서 본 것처럼 함수 컴포넌트와 클래스 컴포넌트에는 많은 차이가 있기 때문에 리액트로 웹서비스를 만드는 개발자라면 훅이 어떻게 동작하는지 이해할 필요가 있다. 본격적으로 리액트의 훅에 대해 알아보자.

3.1.1. useState

  • 리액트에서 훅을 언급할 때 가장 먼저 떠올리는 것이 바로 useState다. useState는 함수 컴포넌트 내부에서 상태를 정의하고, 이 상태를 관리할 수 있게 해주는 훅이다.

useState 구현 살펴보기

  • 먼저 useState 훅의 기본적인 사용법을 살펴보자.
import { useState } from 'react'

const [state, setState] = useState(initialState)
  • useState의 인수로는 사용할 stats의 초기값을 넘겨준다. 아무런 값을 넘겨주지 않으면 초기값은 undefined다. useState 훅의 반환 값은 배열이며, 배열의 첫 번째 원소로 state 값 자체를 사용할 수 있고, 두 번째 원소인 setState 함수를 사용해 해당 state의 값을 변경할 수 있다. 이제 이 훅이 어떻게 작동하는지를 본격적으로 알아보자.
  • 만약 useState를 사용하지 않고 함수 내부에서 자체적으로 변수를 사용해 상태값을 관리한다고 가정해 보자.
function Component(){
  let state = 'hello'


  function handleButtonClick(){
    state = 'hi'
  }

  return(
    <>
      <h1>{state}</h1>
      <button onClick={handleButtonClick}>hi</button>
    </>
  )
}
  • 위 코드가 동작하지 않는 이유는 무엇일까? 앞서 리액트에서 렌더링이 어떻게 일어나는지 살펴본 내용을 다시 한번 떠올려보면 리액트에서 렌더링은 함수 컴포넌트의 return과 클래스 컴포넌트의 render 함수를 실행한 다음, 이 실행 결과를 이전의 리액트 트리와 비교해 리렌더링이 필요한 부분만 업데이트해 이뤄진다고 정리했다. 그리고 리렌더링을 일으키는 방법에 대해서도 살펴봤는데, 위 코드에서는 리렌더링을 발생시키기 위한 조건을 전혀 충족하지 못하고 있다. 그렇다면 다음과 같이 하면 어떨까?
function Component(){
  const [, triggerRender] = useState()
  let state = 'hello'


  function handleButtonClick(){
    state = 'hi'
  }

  return(
    <>
      <h1>{state}</h1>
      <button onClick={handleButtonClick}>hi</button>
    </>
  )
}
  • useState 반환값의 두 번째 원소를 실행해 리액트에서 렌더링이 일어나게끔 변경했다. 그럼에도 여전히 버튼 클릭 시 state의 변경된 값이 렌더링되고 있지 않다. state가 업데이트되고 있는데 왜 렌더링이 되지 않을까? 그 이유는 리액트의 렌더링은 함수 컴포넌트에서 반환한 결과물인 return의 값을 비교해 실행되기 때문이다. 즉, 매번 렌더링이 발생될 때마다 함수는 다시 새롭게 실행되고, 새롭게 실행되는 함수에서 state는 매번 hello로 초기화되므로 아무리 state를 변경해도 다시 hello로 초기화되는 것이다.
  • 지금까지는 이해할 수 있는 과정으로 보인다. 함수 컴포넌트는 매번 함수를 실행해 렌더링이 일어나고, 함수 내부의 값은 함수가 실행될 때마다 다시 초기화된다. 그렇다면, useState 훅의 결과값은 어떻게 함수가 실행되도 그 값을 유지하고 있을까?
  • 리액트의 내부 구현을 하나도 모른다고 가정하고 useState가 어떤 구조를 가지고 있을지 상상해보자
function useState(initialValue) {
  let internalState = initialValue

  function setState(newValue){
    internalState = newValue
  }

  return [internalState, setState]
}
  • 그러나 이는 우리가 원하는 대로 작동하지 않는다.
const [value, setValue] = useState(0)
setValue(1)
console.log(value) // 0
  • 이러한 결과가 발생하는 이유는 setValue로 값을 변경했음에도 이미 구조 분해 할당으로 state의 값, 즉 value를 이미 할당해 놓은 상태이기 때문에 훅 내부의 setState를 호출하더라도 변경된 새로운 값을 반환하지는 못한 것이다. 이를 해결하려면 먼저 state를 함수로 바꿔서 state의 값을 호출할 때마다 현재 state를 반환하게 하면 된다.
function useState(initialValue) {
  let internalState = initialValue

  function state(){
    return internalState
  }

  function setState(newValue){
    internalState = newValue
  }

  return [state, setState]
}

const [value, setValue] = useState(0)
setValue(1)
console.log(value) // 1
  • 물론 이것은 우리가 사용하는 useState 훅의 모습과는 많이 동떨어져 있다. 우리는 state를 함수가 아닌 상수처럼 사용하고 있기 때문이다. 
  • 이를 해결하기 위해 리액트는 클로저를 이용했다. 여기서 클로저는 어떤 함수(useState) 내부에 선언된 함수(setState)가 함수의 실행이 종료된 이후에도 (useState가 호출된 이후에도) 지역변수인 state를 계속 참조할 수 있다는 것을 의미한다.
  • 그렇다면 실제로 useState는 어떤 형태로 구현돼 있을까? 다음 코드는 실제 리액트의 useState 코드가 아니라 작동 방식을 대략적으로 흉내 낸 코드다.
const MyReact = (function() {
  const global = {}
  let index = 0

  function useState(initialState){
    if(!global.states){
      // 애플리케이션 전체의 state 배열을 초기화한다
      // 최초 접근이라면 빈 배열로 초기화한다.
      global.states = []
    }

    // state 정보를 조회해서 현재 상태값이 있는지 확인하고,
    // 없다면 초기값으로 설정한다.
    const currentState = global.states[index] || initialState

    // states의 값을 위해서 조회한 현재 값으로 업데이트한다.
    global.states[index] = currentState

    // 즉시 실행 함수로 setter를 만든다.
    const setState = (function() {
      // 현재 index를 클로저로 가둬놔서 이후에서 계속해서 동일한 
      // index에 접근할 수 있도록 한다.
      let currentIndex = index
      return function (value){
        global.states[currentIndex] = value
        // 컴포넌트를 랜더링한다. 실제로 컴포넌트를 랜더링하는 코드는 생략했다.
      }
    })()
    // useState를 쓸 때마다 index를 하나씩 추가한다. 이 index는 setState에서 사용된다.
    // 즉, 하나의 state마다 index가 할당돼 있어 그 index가 배열의 값(global.states)을 
    // 가리키고 필요할 때마다 그 값을 가져오게 한다.
    index = index + 1

    return [currentState, setState]
  }

  // 실제 useState를 사용하는 컴포넌트
  function Component() {
    const [value, setValue] = useState(0)
    // ....
  }
})();
  • 작동 자체만 구현했을 뿐, 실제 구현체와는 차이가 있다. 실제 리액트 코드에서는 useReducer를 이용해 구현돼 있다. useReducer와 useState는 크게 다르지 않은데, 이는 이후에 설명한다.
  • 여기서 함수의 실행이 끝났음에도 함수가 선언된 환경을 기억할 수 있는 방법은 바로 1장에서 소개한 클로저다. 매번 실행되는 함수 컴포넌트 환경에서 state 값을 유지하고 사용하기 위해서 리액트는 클로저를 활용하고 있다. 예제의 경우 MyReact라고 불리는 클로저 내부에 useState와 관련된 정보를 저장해 두고, 이를 필요할 때마다 꺼내놓는 형식으로 구성돼 있다.
  • 이렇듯 useState는 자바스크립트의 특징 중 하나인 클로저에 의존해 구현돼 있을 것이라는 사실을 짐작해 볼 수 있다. 클로저를 사용함으로써 외부에 해당 값을 노출시키지 않고 오직 리액트에서만 쓸 수 있었고, 함수 컴포넌트가 매번 실행되더라도 useState에서 이전의 값을 정확하게 꺼내 쓸 수 있게 됐다.

실제 리액트 내부에서 훅은 어떻게 구성돼 있을까요?

  • 이에 대한 정보를 알려면 리액트 깃허브 저장소를 참고해야 하는데, 훅에 대한 구현체를 타고 올라가다 보면 __SECRET_INTERNALS_DO_NOT_USE_OR_WILL_BE_FIRED라는 문구를 마주하게 된다.
  • 이렇게 변수명을 무섭게 지은 이유는 일반 사용자의 접근을 차단하고, 나아가 실제 프로덕션 코드에서 사용하지 못하게 하기 위함으로 보인다. 실제로 여기에 접근하는 것을 리액트 팀에서도 권장하지 않는다. 이 변수에는 ReactSharedInternals라 불리는 내부 객체가 저장돼 있는 것으로 추정된다.
  • __SECRET_INTERNALS_DO_NOT_USE_OR_WILL_BE_FIRED는 리액트의 버전 관리 대상에서도 제외돼 있고, 앞서 언급한 것 처럼 외부 사용자가 사용하거나 참고하는 것을 권장하지 않아 정확한 구현은 알기 어렵다. 대신 이번 장에서 훅에 대한 예제는 Preact의 구현을 기준으로 한다. Preact는 리액트의 경량화 버전으로, 대부분의 리액트 API를 지원하고 있으며 리액트보다 가볍다는 장점이 있으며 무엇보다 모든 코드를 명확하게 볼 수 있다.

게으른 초기화

  • 일반적으로 useState에서 기본값을 선언하기 위해 useState() 인수로 원시값을 넣는 경우가 대부분일 것이다. 그러나 이 useState의 인수로 특정한 값을 넘기는 함수를 인수로 넣어줄 수도 있다. useState에 변수 대신 함수를 넘기는 것을 게으른 초기화(lazy initialization)라고 한다. 이 게으른 초기화 무엇인지 살펴보자.
// 일반적인 useState 사용
// 바로 값을 집어 넣는다
const [count, setCount] = useState(
  Number.parseInt(window.localStorage.getItem(cacheKey)),
)

// 게으른 초기화
// 위 코드와의 차이점은 함수를 실행해 값을 반환한다는 것이다.
const [count, setCount] = useState(() =>
  Number.parseInt(window.localStorage.getItem(cacheKey)),
)
  • 리액트 공식 문서에서 이러한 게으른 초기화는 useState의 초기값이 복잡하거나 무거운 연산을 포함하고 있을 때 사용하라고 되어 있다. 이 게으른 초기화 함수는 오로지 state가 처음 만들어질 때만 사용된다. 만약 이후에 리렌더링이 발생된다면 이 함수의 실행은 무시된다. 다음 예제를 보자.
import { useState } from 'react'

export default function App(){
  const [state, setState] = useState(() =>{
    // App 컴포넌트가 처음 구동될 때만 실행되고, 이후 리렌더링 시에는 실행되지 않는다.
    console.log('복잡한 연산...')

    return 0
  }) 

  function handleClick(){
    setState((prev) => prev + 1)
  }

  return(
    <div>
      <h1>{state}</h1>
      <button onClick={handleClick}>+</button>
    </div>
  )
}
  • 리액트에서 렌더링이 실행될 때마다 함수 컴포넌트의 함수가 다시 실행된다는 점을 명심하자. 함수 컴포넌트의 useState의 값도 재실행된다. 물론 우리는 앞서 구현 예제를 통해 내부에는 클로저가 존재하며, 클로저를 통해 값을 가져오며 초기값은 최초에만 사용된다는 것을 알고 있다. 만약 useState 인수로 자바스크립트에 많은 비용을 요구하는 작업이 들어가 있다면 이는 계속해서 실행될 위험이 존재할 것이다. 그러나 우려와는 다르게 useState 내부에 함수를 넣으면 이는 최초 렌더링 이후에는 실행되지 않고 최초의 state 값을 넣을 때만 실행된다.
  • 만약 Number.parseInt(window.localStorage.getItem(cacheKey))와 같이 한 번 실행되는데 어느 정도 비용이 드는 값이 있다고 가정해 보자. useState의 인수로 이 값 자체를 사용한다면 초기값이 필요한 최초 렌더링과, 초기값이 있어 더 이상 필요 없는 리렌더링 시에도 동일하게 계속 해당 값에 접근해서 낭비가 발생한다. 따라서 이런 경우에는 함수 형태로 인수에 넘겨주는 편이 훨씬 경제적일 것이다. 초기값이 없다면 함수를 실행해 무거운 연산을 시도할 것이고, 이미 초기값이 존재한다면 함수 실행을 하지 않고 기존 값을 사용할 것이다.
  • 그렇다면 게으른 최적화는 언제 쓰는 것이 좋을까? 리액트에서는 무거운 연산이 요구될 때 사용하라고 한다. 즉, localStorage나 sessionStorage에 대한 접근, map, filter, find 같은 배열에 대한 접근, 혹은 초기값 계산을 위해 함수 호출이 필요할 때와 같이 무거운 연산을 포함해 실행 비용이 많이 드는 경우에 게으른 초기화를 사용하는 것이 좋다.