지나가던 개발(zigae)

useState의 참조 동일성

2021년 11월 23일 • ☕️ 3 min read

react-logo


리액트 개발자는 메모이제이션과 레퍼런스 보존을 안정적으로 유지하기 위해, 보통 useMemo를 가장 먼저 떠올린다. 아래는 필자가 실제로 겪었던 사례를 들고자 한다.

Example

App의 라이프 사이클에서 한 번만 초기화되는 Resource가 있다고 가정한다. 이때 권장되는 패턴은 일반적으로 컴포넌트 외부에 인스턴스를 생성하는 것이다.

// ✅  정적 인스턴스는 한번만 생성 된다.
const resource = new Resource()

const Component = () => (
  <ResourceProvider resource={resource}>
    <App />
  </ResourceProvider>
)

Resource는 번들링 될 때 한 번만 생성되고 ResourceProvider를 통해 App에서 사용할 수 있게 되었다.

여기까진 그런대로 문제가 없는 것 같다. 하지만 이것은 일반적으로 redux store 처럼 서비스에 한 번만 필요한 경우에 적합하다.

그리고 이 경우(마이크로 프론트엔드) 앱이 여러 번 마운트 하게 되면 각각의 Resource가 필요하게 된다. 이때 같은 Resource를 공유하게 되면 지옥이 열리게 된다. 그렇다면 지옥을 피하기 위해 어떻게 해야 할까? 우리는 Resource를 컴포넌트로 옮기는 선택을 할 수 있다.

const Component = () => {
  // 🚨 주의: 모든 렌더링 과정에서 새로운 인스턴스가 생성된다.
  const resource = new Resource()
  return (
    <ResourceProvider resource={new Resource()}>
      <App />
    </ResourceProvider>
  )
}

하지만 필자는 이 방법이 썩 좋은 방법이라 생각 들지 않는다. 이제 매 렌더링마다 새로운 Resource를 생성한다. 컴포넌트를 한 번만 렌더링 하는 경우엔 문제없겠지만 개발자가 신뢰할 수 있는 동작은 아니다. 리렌더링을 관리하는 것은 꽤 번거롭기 때문에 의도와 상관없이 리렌더링이 발생할 수 있고, 이를 개발자가 인지하고 있기엔 인지부하를 줄 수 있다.

이 문제를 해결할 방법이 있을까? 많은 개발자들은 가장 먼저 useMemo를 떠올렸을 것이다. useMemo는 dependency array가 변경되는 경우에만 값을 재계산하고, Resource에는 dependency가 없기 때문에 괜찮은 방법으로 보인다.

const Component = () => {
  // 🚨 동작하지만 조금 더 고민 할 여지가 있다.
  const resource = React.useMemo(() => new Resource(), [])
  return (
    <ResourceProvider resource={resource}>
      <App />
    </ResourceProvider>
  )
}

한번 더 말하면, 이 방법은 동작하지만 React문서에서 useMemo에 대해 설명하는 문구를 살펴보자.

You may rely on useMemo as a performance optimization, not as a semantic guarantee. In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without useMemo — and then add it to optimize performance.

요점만 요약해보면 “성능 최적화에 useMemo를 사용할 수 있다. React는 이전에 메모이제이션 된 값을 ‘잊고’ 다음 렌더링 때 다시 계산하도록 할 수 있다.(예: 스크린 바깥 컴포넌트를 컴포넌트에 대한 메모리 확보) useMemo 없이도 작동하도록 코드를 작성한 다음 성능 최적화를 위해 useMemo를 추가한다.”

useMemo 없이도 동작하는 방식으로 코드를 작성 한 후에 useMemo를 추가하여 더 나은 코드를 만들자는 것이 아니다. 하지만 우리는 useMemo를 추가함으로써 코드를 좋지 못하게 만들고 있다. 필자는 여기서 성능 보다, 참조 동일성에 대한 안전성을 지키는 것을 원한다. 그렇다면 어떤 방법이 좋은 방법 일까?

State 안전성

그 방법은 state 안에 있다. state는 setter를 호출하는 경우에만 업데이트되는 것이 보장된다. 즉, 우리는 반환된 배열의 두 번째 값을 선언하지 않음으로써 setter를 호출하지 않을 수 있다. 이 방법은 lazy initializer와 함께 사용하면 Resource 생성자가 한 번만 호출 되도록 할 수 있다.

const Component = () => {
  // ✅ 참조 동일성에 대한 안전성을 보장한다.
  const [resource] = React.useState(() => new Resource())
  return (
    <ResourceProvider resource={resource}>
      <App />
    </ResourceProvider>
  )
}

이 방법을 사용하면 Resource가 컴포넌트 라이프사이클 당 한 번만 생성되도록 할 수 있다.

Refs도 있잖아?

useRef를 사용해도 같은 결과를 얻을 수 있다. 그리고 React rule에 위배되지도 않으며, 렌더링의 순수함도 유지할 수 있다.

const Component = () => {
  // ✅ 동작은 하지만, 으으음..
  const resource = React.useRef(null)
  if (!resource.current) {
    resource.current = new Resource()
  }
  return (
    <ResourceProvider resource={resource.current}>
      <App />
    </ResourceProvider>
  )
}

솔직히 필자는 왜 이 방법을 사용해야 하는지 모르겠다. 개인적으로 이 방법은 복잡해 보이고 resource.current의 타입은 null을 포함하기 때문에 TypeScript에서도 영 별로다. 이런 경우라면 그냥 useState를 사용하는 것이 낫겠다는 입장이다.