본문 바로가기
FE/리액트 인터뷰 가이드

3. 훅: 함수 컴포넌트에 state와 다른 기능 추가하기 - 2

by Toddler_AD 2025. 9. 23.

훅을 이용한 전역 state 관리

  • useContext 훅은 보통 전역 state 관리를 위해 useState 훅과 함께 사용된다. useContext 훅의 주된 이점은 프롭 드릴링 문제를 해결할 수 있다는 것이다.
  • useContext 훅의 일반적인 사용 사례에 관한 자세한 사항은 2장에서 이미 설명했기 때문에 이번 절에서는 주로 useContext 훅을 이용한 전역 state 관리와 관련된 특징 사용 사례와 관련된 질문에 중점을 두겠다.

컴포넌트 트리의 특정 부분에 대한 컨텍스트를 재정의하려면 어떻게 해야 하는가?

  • 가끔은 컴포넌트 트리의 특정 부분에 대해 다른 값을 가진 컨텍스트로 재정의해야 할 때가 있다. 해당 부분을 특정 값을 가진 다른 프로바이더로 감싸서 컨텍스트 값을 재정의 할 수 있다.
  • 다음은 모든 페이지에 파란 배경을 적용하되 연락처 페이지에서는 컨텍스트 프로바이더를 사용해 흰 배경을 적용하는 코드의 예다.
// /Chapter03/colorContextProvider.jsx

;<ColorContext.Provider value="blue">
  <About />
  <Services />
  <Clients />
  <ColorContext.Provider value="white">
    <Contact />
  </ColorContext.Provider>
</ColorContext.Provider>
  • 프로바이더를 사용해 컨텍스트를 재정의하는 횟수나 중첩 개수에는 제한이 없다.

일치하는 프로바이더가 없으면 컨텍스트 값은 어떻게 되는가?

  • useContext 훅을 호출하는 컴포넌트보다 상위에 일치하는 프로바이더가 없으면 create Context(defaultValue)에서 지정한 기본값이 반환된다.
  • 기본값을 지정하면 컴포넌트 트리에서 누락된 프로바이더에 대한 예상치 못한 오류를 방지할 수 있다.
  • 클래스 컴포넌트에서는 요구사항에 따라 componentDidMount, componentDidUpdate, componentWillUnmount 같은 다양한 생명주기 메서드에서 부수 효과를 처리한다. 반면 함수 컴포넌트에서는 effect 훅을 사용해 렌더링에 기반한 부수 효과를 한 곳에서 처리함으로써 단일 영역으로 간소화한다. 다음 절에서는 리액트 애플리케이션에서 부수 효과를 수행하는 것과 관련해 자주 묻는 질문을 다룰 것이다.

리액트 애플리케이션에서 부수 효과 실행하기

  • 부수 효과(effect)는 리액트 프로그래밍에서 일종의 탈출구 역할을 한다. 리액트에서는 데이터 불러오기, 구독, 타이머, 로깅, DOM 조작 등과 같은 부수 효과를 구현하는 데 사용 되는 몇 가지 effect 훅을 제공한다. 이러한 훅은 외부 시스템과 동기화할 때만 사용해야 된다. 이때 사용 가능한 훅에는 세 종류가 있다.
    • useEffect :  이 훅은 컴포넌트를 외부 시스템에 연결하는 데 자주 사용된다.
    • useLayoutEffect : 이 훅은 useEffect 훅과 동일하지만, 브라우저가 화면을 다시 그리기 전에 레이아웃을 측정하기 위해 호출된다.
    • userInsertionEffect : 이 훅은 리액트가 동적으로 css를 추가하는 등 DOM에 변경을 가하기 전에 발생한다.
  • 면접에서 물어볼 수 있는 질문에 답하기 위해 각 effect 훅과 그 특징을 자세히 알아보자.

useEffect 훅 내의 반응형 의존성이 로직에 어떤 영향을 미치는가?

  • useEffect 훅은 선택적인 의존성(dependency) 인자(반응형 값으로 구성된 배열)를 받는다. effect에 대한 의존성은 직접 선택할 수 없으며, 모든 종류의 버그를 피하기 위해서는 모든 반응형 값이 의존성으로 선언돼야 한다. 반응형 의존성을 전달할 경우 다음과 같이 다양한 경우가 있다.

의존성 배열 전달하기

  • 반응형 값을 의존성 배열에 전달되면 effect는 초기 렌더링 이후에 로직을 실행하며, 각각의 변경된 의존성에 대해 리렌더링된 후에도 실행될 것이다.
  • 다음은 의존성 배열과 useEffect 구문을 이해하기 위해 name과 status라는 반응형 의존성을 전달하는 예다.
// /Chapter03/useEffectDependencies.jsx

useEffect(() => {
  // Runs after first render and every re-render with dependency change
  // 초기 렌더링 이후와 의존성이 변경되어 리렌더링이 발생될 때마다 실행
}, [name, status])

빈 의존성 배열 전달하기

  • 만약 effect가 어떤 반응형 값도 사용하지 않는다면 이는 초기 렌더링 이후에만 실행된다. 이 경우 effect 훅은 다음과 같다.
// /Chapter03/useEffectEmptyDependencies.jsx

useEffect(() => {
  // Runs after initial render
  // 초기 렌더링 이후에만 실행 됨
}, [])

의존성 배열 전달하지 않기

  • 의존성 배열을 전달하지 않으면 해당 effect는 컴포넌트가 리렌더링 될 때마다 실행된다. effect 훅은 다음과 같이 구현된다.
// /Chapter03/useEffectSkipDependencies.jsx

useEffect(() => {
  // Runs after every re-render
  // 리렌더링 될 때마다 실행됨
})
  • 리액트는 Object.is 비교를 이용해 의존성 배열의 각 반응형 값을 이전 값과 비교해서 변경 사항을 확인한다.

useEffect 훅 내에서 설정 및 정리 함수는 얼마나 자주 호출되는가?

  • 대부분의 경우 effect에는 자신이 만든 변경 사항을 지우거나 되돌리기 위한 정리 함수가 있어야 한다. 리액트는 컴포넌트의 각 생명주기 단계에서 설정 및 정리 함수를 호출한다.
    • 마운트(Mounting) : 설정 함수 내부의 로직이 컴포넌트가 DOM 또는 뷰에 추가될 때마다 실행된다.
    • 리렌더링(Re-rendering) : 컴포넌트와 해당 의존성이 변경될 때마다 정리 함수(정의된 경우) 및 설정 함수가 순서대로 호출된다. 여기서 정리 함수는 이전 props 및 state와 함께 실행되며, 설정 코드는 이후 최신 props 및 state와 함께 실행된다.
    • 마운트 해제(Unmounting) : 컴포넌트가 DOM 또는 뷰에서 제거된 후에 최종적으로 정리 코드가 한 번 실행된다. 이 정리 함수는 메모리 누수와 성능 향상과 같은 원치 않는 동작을 방지하는 데 도움이 된다.
  • 리액트 애플리케이션에서 엄격(strict) 모드가 활성화된 경우 실제 설정 호출 전에 개발용 설정 및 정리 주기가 추가될 수 있다. 이것은 설정 로직이 정리 로직과 일치하게끔 해서 설정 코드와의 불일치를 방지하기 위한 것이다.

객체 또는 함수를 의존성에서 제거해야 하는 시점은 언제인가?

  • 만약 특정 effect가 랜더링 중에 생성된 객체나 함수에 의존하고 있다면, 그 effect가 불필요하게 자주 다시 실행될 수 있다. 이는 랜더링마다 생성된 객체나 함수가 다르기 때문이다.
  • 이 개념을 더 잘 이해하기 위해 예제를 살펴보자. 다음은 useEffect 훅 내에서 url 및 name 쿼리 파라미터 의존성을 기반으로 사용자 목록을 가져오는 예제다. 여기서 쿼리 객체는 랜더링 중에 생성되어 절대 URL 경로를 구성하는 데 사용된다.
// /Chapter03/useEffectFetchingData.jsx

const userUrl = 'https://jsonplaceholder.typicode.com/users'

export default function Users() {
  const [users, setUsers] = useState([])
  const [name, setName] = useState('John')
  const [message, setMessage] = useState('')

  const userQueryOptions = {
    url: userUrl,
    name,
  }

  useEffect(() => {
    const userUrl = buildUserURL(userQueryOptions) //buildUserURL is excluded from code snippet
    fetch(userUrl)
      .then((res) => res.json())
      .then((users) => setUsers(users))
  }, [userQueryOptions])

  return (
    <>
      Users: {message}
      <input value={message} onChange={(e) => setMessage(e.target.value)} />
      <input value={name} onChange={(e) => setName(e.target.value)} />
      {users &&
        users.map((user) => (
          <div>
            Name: {user.name}
            Email: {user.email}
          </div>
        ))}
    </>
  )
}
  • 이 코드에서는 message state의 변경으로 인해 매번 userQueryOptions 객체가 재생성됐다. 또한 이 message 데이터는 effect 내부의 반응형 요소와 관련이 없다.
  • 이 문제는 객체를 effect 내부로 이동하고 객체 의존성을 name 문자열로 대체해서 해결할 수 있다. 왜냐하면 name은 effect가 의존하는 유일한 반응형  값이기 때문이다.
// /Chapter03/useEffectCorrectDependencies.jsx

useEffect(() => {
  const userOptions = {
    url: userUrl,
    name,
  }

  const userUrl = buildUserURL(userOptions)
  fetch(userUrl)
    .then((res) => res.json())
    .then((users) => setUsers(users))
}, [name])
  • 마찬가지로 랜더링 단계에서 함수를 만들지 않고 effect 훅 내부로 옮겨 함수 의존성을 직접적인 반응형 의존성 값으로 대체할 수 있다.

useLayoutEffect 훅은 무엇이고 어떻게 동작하는가?

  • useLayoutEffect 훅은 화면을 다시 그리기 전에 호출되는 특수한 종류의 effect 훅으로, state를 업데이트 할 때 컴포넌트가 깜빡이는 경우에 사용된다. 웹 페이지의 팝오버 컴포넌트를 상상해보자. 컴포넌트는 먼저 뷰포트 내에서의 요소의 위치를 결정해야 하기 때문에 화면에 올바르게 랜더링되기 전에 레이아웃 정보를 제공해야 한다.
  • useLayoutEffect 훅의 주요 목적은 랜더링을 위한 레이아웃 정보를 제공하는 것이다. 이것은 간단하게 세 단계로 동작한다.
    1. 레이아웃 정보 없이 초기 콘텐츠를 렌더링한다.
    2. 브라우저가 화면을 다시 그리기 전에 레이아웃 크기를 계산한다.
    3. 올바른 레이아웃 정보를 사용해 컴포넌트를 리렌더링한다.

♣ 참고 : 컴포넌트는 두 번 렌더링되고 화면을 다시 그리기 전에 브라우저를 차단한다. 이는 애플리케이션 성능에 영향을 미친다. 따라서 useLayoutEffect 훅은 필요한 경우에만 사용하는 것이 좋다.

  • effect는 모든 렌더링 이후에 실행되기 때문에 리액트 애플리케이션의 성능을 최적화하는 주요 방법 중 하나는 불필요한 리렌더링을 피하는 것이다. 리액트에는 애플리케이션의 성능을 최적화하기 위한 몇 가지 내장 훅이 있다. 관련 내용은 다음 절에서 자세히 다루겠다.

애플리케이션 성능 최적화

  • 성능 최적화는 고객 경험에 엄청난 영향을 미친다. 리액트 애플리케이션은 기본적으로 매우 빠른 UI를 제공하지만 애플리케이션 크기가 커질수록 성능 문제가 발생할 수 있다. 이번 절에서는 성능 최적화 훅과 관련된 질문에 중점을 둔다. 이는 더 넓은 관점에서 여러분의 기술을 평가하려는 면접관들이 질문할 것이라고 예상할 수 있는 내용이다.

메모이제이션이란 무엇인가? 리액트에서 어떻게 구현할 수 있는가?

  • 메모이제이션(memoization)은 값비싼 함수 호출의 결과를 캐싱해서 웹 어플리케이션의 속도를 높이는 최적화 기법이다. 동일한 입력 인수가 다시 전달될 때 캐싱된 결과를 반환한다.
  • 리액트에서는 useMemo와 useCallback이라는 두 가지 훅을 통해 메모이제이션을 통한 최적화를 구현할 수 있다. 이러한 훅은 동일한 입력이 주어질 때 캐싱된 결과를 반환해서 불필요한 리렌더링을 건너뛰어 성능을 향상시킨다.

useMemo() 훅을 설명할 수 있는가?

  • useMemo() 훅은 리렌더링 사이에서 값비싼 계산의 결과를 캐싱하는 데 사용된다. 이 훅의 구문은 다음과 같다.
const cacheValue = useMemo(calculateValue, dependencies)
  • 이 훅은 두 개의 인수를 받아서 둘 중에서 비용이 더 많이 드는 계산의 값을 반환한다. 첫 번째 인수는 값비싼 계산을 수행하는 함수이고, 두 번째 인수는 계산에 사용되는 반응형 값으로 구성된 의존성 배열이다. 다시 말하면 의존성 값에 변경이 없을 때 캐싱된 결과(또는 마지막 랜더링에서 저장된 값)가 반환되며, 그렇지 않으면 계산이 다시 수행된다.
  • 예제를 통해서 이 개념을 이해해보자. 숫자의 팩토리얼 계산 함수를 useMemo 훅으로 둘러싸는 컴포넌트를 생각해보자. 또한 이 컴포넌트는 계산 함수와 독립적인 증가 작업도 수행 한다.
// /Chapter03/useMemoDemo.jsx

import { useState, useMemo } from 'react'

function factorial(number) {
  if (number <= 0) {
    return 'Number should be positive value.'
  } else if (number === 1) {
    return 1
  } else {
    return number * factorial(number - 1)
  }
}

export default function CounterFactorial() {
  const [count, setCount] = useState(0)
  const [number, setNumber] = useState(1)

  const factorial = useMemo(() => factorial(number), [number])

  return (
    <>
      <h2>Counter: {count}</h2>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <h2>Factorial: {factorial}</h2>
      <input
        type="number"
        value={number}
        onClick={() => setNumber(number + 1)}
      />
    </>
  )
}
  • 이 코드에서는 카운터 값을 증가시켜도 해당하는 반응형 숫자가 업데이트되지 않기 때문에 팩토리얼 함수와 관련된 리렌더링이 일어나지 않는다. 즉, 팩토리얼 함수는 입력 숫자에 변화가 있을 때만 호출되며 카운터 값이 증가할 때는 호출되지 않는다.

useMemo() 훅이 유용한 경우는 무엇인가?

  • 메모이제이션은 애플리케이션 성능을 최적화하는 데 도움이 되며, 어떤 개발자는 거의 모든 컴포넌트를 가능한 한 많이 메모이제이션하는 것도 문제가 없다고 생각하기도 한다. 그러나 이 기법은 함수 내 간단한 계산에는 불필요하다.
  • 메모이제이션이 유용한 몇 가지 일반적인 경우가 있다.
    • 렌더링 중에 정렬, 필터링, 형식 변경 등과 같은 비용이 많이 드는 계산이 있는 경우
    • useMemo 훅 내부에 래핑된 컴포넌트에 prop을 전달하고, prop에 변경이 없을 때 리렌더링을 건너뛰려는 경우. 즉, 순수 컴포넌트는 useMemo로 래핑할 수 있다.
    • 래핑된 컴포넌트에 전달되는 값이 다른 훅의 의존성으로 사용되는 경우
  • 리액트 개발자 도구(React Devtools)의 프로파일러(profiler) 섹션은 지연되는 컴포넌트를 식별하고 메모이제이션을 추가해야 할 컴포넌트를 파악하는 데 도움이 된다.

useMemo() 사용 시 흔히 저지르는 실수는 무엇인가? 이를 어떻게 고칠 수 있는가?

  • useMemo 훅의 사용법은 매우 직관적이며 이 훅은 렌더링 성능을 최적화하는 데 광범위하게 활용할 수 있다. 그러나 다음과 같은 몇 가지 일반적인 실수에 주의해야 한다.
  • useMemo 훅에서 객체를 반환하려고 할 때는 괄호로 둘러싸거나 명시적인 return 문을 사용해야 한다. 예를 들어, 아래의 useMemo 훅은 객체의 일부인 중괄호({) 때문에 undefined 값을 반환한다.
const findCity = useMemo(() => {
    country: 'USA',
    name: name,
}, [name])
  • 이 문제는 명시적인 return 문을 사용해 객체를 반환하는 식으로 수정할 수 있다.
// /chapter03/useMemoReturnObject.jsx

const findCity = useMemo(() => {
  return {
    country: 'USA',
    name: name,
  }
}, [name])
  • 만약 의존성을 명시하지 않으면 리렌더링마다 계산한다.
// /chapter03/useMemoWithoutDependencies.jsx

const filterCities = useMemo(() => filteredCities(city, country))
  • 이 경우, 계산에 사용된 반응형 값들이 의존성 배열에 포함돼야 하며, 이를 통해 불필요한 렌더링을 피할 수도 있다.
// /chapter03/useMemoWithDependencies.jsx

const filterCities = useMemo(
  () => filteredCities(city, country),
  [city, country],
)
  • useMemo를 루프 내에서 호출해서는 안된다. 대신에 이를 감싸거나 새로운 컴포넌트로 추출해야 한다.
// /chapter03/useMemoInsideLoop.jsx

{
  products.map((product) => {
    const revenue = useMemo(() => calculateRevenue(product), [product])

    return (
      <>
        <span>Product: {product.name}</span>
        <span>Revenue: {revenue}</span>
      </>
    )
  })
}
  • 이는 useMemo 계산을 자식 컴포넌트에서 추출함으로써 해결할 수 있다.
// /chapter03/useMemoInsideLoopFix.jsx

{
  products.map((product) => {
    return <Report product={product} />
  })
}
  • 앞에서 언급한 내용은 useMemo 훅을 사용할 때의 모범 사례로 여겨진다.

언제 useMemo() 훅 대신 useCallback 훅을 사용해야 하는가?

  • 상위 컴포넌트가 리렌더링될 때 기본적으로 리액트는 모든 하위 컴포넌트를 재귀적으로 리렌더링한다. 이것은 하위 컴포넌트가 계산이 많은 경우 애플리케이션의 성능에 영향을 미친다. 이때 Memo API 또는 useMemo 훅을 사용해 하위 컴포넌트를 최적화해야 한다.
  • 그러나 콜백 함수를 하위 컴포넌트로 전달하면 리액트는 항상 자식을 리렌더링한다. 왜냐하면 함수 정의나 화살표 함수는 렌더링을 할 때 마다 새로운 함수로 취급되기 때문이다. 이로 인해 메모이제이션의 목적이 상실된다. 이 경우 useCallback은 성능을 최적화 하는데 도움이 된다.
  • useCallback 훅은 useMemo 훅과 유사하지만 값을 캐싱하는 대신에 콜백 함수를 캐싱한다. 여전히 useMemo 훅을 사용할 수 있지만 계산 함수가 다른 함수를 반환해야 하는 등 추가로 중첩된 함수가 필요하다.
  • 이 개념을 TaxCalculation이 상위 컴포넌트이고 TaxPayer가 하위 컴포넌트인 예제로 설명하겠다. 하위 컴포넌트에서는 동일한 props가 전송되고 리렌더링이 느린 경우에 리렌더링을 건너뛰어야 한다.
  • 리렌더링을 건너 뛰려면 먼저 자식 컴포넌트(TaxPayer)를 memo 함수로 감싸야 한다.
// /chapter03/useMemoSkipRerender.jsx

import { memo } from 'react'

const TaxPayer = memo(function TaxPayer({ onSubmit }) {
  // ...
})
  • 부모 컴포넌트가 income props의 변경으로 리렌더링될 경우 이 변경으로 인해 자식 컴포넌트도 리렌더링된다. 이것은 자식 컴포넌트에 무거운 계산이 없고 income props의 변경이 미미한 경우 큰 문제가 되지 않는다.
  • 그러나 자식 컴포넌트에 콜백 함수를 props로 전달할 때마다 새로운 함수가 생성된다. 이런 특정한 경우는 성능상 영향과 관계없이 항상 피해야 한다.
  • 매번 새로운 props 때문에 리렌더링되지 않도록 useCallback 훅을 사용해 handleSubmit 콜백 함수에 적용해보자.
// /chapter03/useCallbackSkipRerender.jsx

function TaxCalculation({ year, income }) {
  const handleSubmit = useCallback(
    (taxPayerDetails) => {
      post('/tax/' + year, {
        taxPayerDetails,
        income,
      })
    },
    [year, income],
  )

  return (
    <div>
      <TaxPayer onSubmit={handleSubmit} />
    </div>
  )
}
  • 이 코드에서 콜백 함수는 의존성이 있는 반응형 값이 변경되지 않는 한 메모이제이션 될 것이다.
  • 클래스 컴포넌트에서 사용되는 Ref API와 유사하게, 특히 DOM 노드와 상호 작용하기 위해 함수 컴포넌트에서 생성된 몇 가지 훅이 있다. 다음 절에서는 훅을 사용해 DOM 노드에 접근하는 것과 관련된 중요한 개념에 대해 이야기해보자.