[React] 조건부 hook

장동균·2024년 3월 31일
1

https://portal.gitnation.org/contents/you-cant-use-hooks-conditionally-or-can-you
해당 영상을 정리한 글입니다.


리액트 19에는 use hook이 소개될 예정이다.

const MyComponent = () => {
  const data = use(promise) 
  
  return <p>{data}</p>
}

use hook의 가장 큰 특징은 조건부로 사용이 가능하다는 점이다.


const MyComponent = ({condition}) => {
  let data = null
  
  if (condition) {
    data = use(promise)
  }
  
  return <p>{data}</p>
}

hook에는 반드시 지켜야 하는 2가지 규칙이 존재한다.

  • Only Call Hooks from React Functions
  • Only Call Hooks at the Top Level

use hook의 조건부 사용은 hook의 2번째 규칙을 위반하게 된다. 그렇다면 어떻게 다른 hook과는 다르게 use hook은 조건부로 사용될 수 있는걸까?

사실 react의 core contributor인 Andrew Clark에 의하면, 조건부로 사용할 수 있는 훅의 시작이 use hook은 아니라는 것을 알 수 있다.

상태관리를 위해 사용하는 hook인 useContext 또한 조건부로 사용이 가능하다. (lint등으로 인해 컴파일 타임에서 에러가 날 수 있지만, 런타임에서 정상 동작한다.)

조건부로 사용할 수 있음에도 불구하고 useContext가 hook으로 분류되는 이유는, hook이 지켜야하는 규칙 첫번째를 useContext hook도 가지고 있기 때문이다.


Why can't we use hooks conditionally?

use hook이 어떻게 조건부로 사용될 수 있는지 확인하기 위해서는, 다른 hook이 왜 조건부로 사용할 수 없는 지부터 알아봐야 한다.

리액트의 Fiber tree는 리렌더를 위한 정보들(data, props, states)을 메모리에 저장한다.

리액트는 Fiber tree에 저장된 정보를 기반으로 리렌더 대상을 찾고 리렌더한다.

그렇다면 이 Fiber tree는 어떻게 생성되고 구성되는걸까?

Fiber tree의 시작은 JSX syntax이다. JSX 문법의 컴포넌트를 호출하게 되면 리액트는 해당 컴포넌트를 Fiber tree에 추가한다. (항상 새로운 Fiber로 만들어서 추가한다.)

예시이다.

1번에 의해 <App /> 라는 JSX 문법을 만나면서 A가 Fiber tree에 추가된다.

2번에 의해 <Greetings /> 라는 JSX 문법을 만나면서 B가 Fiber tree에 추가된다.

3번에 의해 button 태그의 JSX 문법을 만나면서 C가 Fiber tree에 추가된다.

4번에 의해 p 태그의 JSX 문법을 만나면서 D가 Fiber tree에 추가된다.

Fiber tree를 구성한 이후, 리액트는 tree를 순회하며 어떠한 컴포넌트를 DOM에 삽입할 지 결정한다. 이때 HTML Element가 아닌 <App />이나 <Greetings />와 같은 Pure React Component들은 제외된다.


Where is hooks data stored?

그렇다면 hook을 구성하는 data들은 Fiber tree 내부에 어떤식으로 저장되고 사용되는걸까?

hook을 구성하는 데이터들은 LinkedList 구조로 Fiber tree 내부에서 관리된다. 또한 호출 순서에 의해 LinkedList 내부에서의 순서가 결정된다.

결국 이 지점이 조건부로 hook을 호출할 수 없는 이유이다. LinkedList의 순서는 hook의 호출 순서에 의존한다. 만약 다음 렌더에 hook이 추가 혹은 제거되면 이 순서는 기존의 것과 달라진다. 다음의 예로 순서의 변경 방지가 왜 중요한지 확인해본다.

App 컴포넌트는 첫실행시 다음과 같은 Fiber tree를 구성하게 된다. showName의 값이 false이기 때문에 name에 대한 useState 훅은 실행되지 않는다.

이후 showName의 값을 바꾸는 버튼을 클릭했다고 가정한다.

이때는 showName의 값이 true가 되고 name에 대한 useState 훅이 실행된다. hook은 그 순서에 의존하기 때문에 리액트는 name의 기존 value가 29였다고 판단한다.

이후 age에 대한 useState 훅이 실행되었지만, 그 값이 LinkedList에 존재하지 않기 때문에 Invalid hook call 에러가 발생하게 된다.

LinkedList를 통해 관리되는 데이터들은 리렌더의 가장 중요한 요소이다. (현재 데이터와 이전 데이터가 다른 경우에 리액트는 리렌더를 결정한다.) 때문에 LinkedList의 순서를 동일하게 관리하는 것은 효율적이고 확실한 리렌더를 위한 가장 중요한 요소이다.


Why is useContext different?

useContext 훅이 사실은 조건부로 사용될 수 있는 이유 또한 LinkedList와 관련이 있다.

useContext에 의해 생성되는 객체는 다른 hook과는 달리 LinkedList에 의해 관리되지 않는다. (이 데이터는 Fiber tree 밖 별도의 영역에서 관리된다.) LinkedList에 의해 관리되지 않는 다는 것은, 조건부로 호출되어도 다른 hook에 영향을 끼치지 않는다는 의미이다.

사실 이러한 특징은 호출 구문에서도 확인해볼 수 있었다.

const [name, setName] = useState('Joey')

const AuthContext = createContext({ name: 'Joey' })
const { name } = useContext(AuthContext)

차이가 느껴지는가? useContext를 사용하는 경우에는 정확히 어떤 객체를 참조해야하는지 parameter를 통해 전달해야한다.

하지만 useContext를 사용하는 Fiber tree를 보다보면 다음과 같은 의문이 들 수 있다.

Fiber tree 외부에서 데이터를 관리하게되면, 그 데이터가 업데이트되는 경우는 어떻게 처리하는거지?


How does context work underneath?

버튼 클릭에 의해 새로운 Fiber tree가 구성되었다고 가정한다. 리액트는 Previous render Fiber tree와 New render Fiber tree를 비교해서 변화가 생긴 곳을 찾고, 이곳만 업데이트한다. New render Fiber tree를 구성하고 이를 렌더하는 과정을 확인해본다.

(WIP 마크가 있는 곳이 현재 확인이 진행되고 있는 Fiber라고 생각하면 된다.)

  1. 유저에 의해 버튼 클릭 이벤트가 발생한다.
  2. 리액트는 기존 Fiber tree를 복사한다.
  3. 새로운 Fiber tree에 변경되어야 하는 값을 반영한다. (Joey => Ross)
  4. App 컴포넌트를 재실행한다.
  5. App 컴포넌트의 재실행을 통해 AuthContext, Greetings, button에 대한 새로운 Fiber가 만들어진다. (JSX 문법을 만나면 항상 새로운 Fiber를 만든다.)

리액트가 컴포넌트를 리렌더하는 조건은 2가지이다.

  • rerender flag가 남은 경우
  • 이전 props와 현재 props가 다른 경우
  1. AuthContext Provider는 props가 변경되었기 때문에 rerender된다.

  2. AuthContext Provider의 rerender에 의해 AuthContext의 value는 Ross로 변경된다.

  1. Greetings Fiber는 props가 변경되었기 때문에 rerender된다. (자바스크립트에서 빈 객체의 얕은 비교는 false를 반환한다. {} === {} // false)
  1. Greetings Fiber의 리렌더에 의해 p 태그에 대한 Fiber도 새로운 user name으로 업데이트된다.

사실 실제 리액트에서 이렇게 두 개의 tree를 두고 비교하지는 않는다. 항상 하나의 트리만을 유지하는 형태로 구성된다. 다만 효율적인 비교와 이해를 위해 다음과 같이 표현되었다.


useContext의 사용 구문이 여러 뎁스를 거친 이후에 이뤄지는 경우는 어떨까?

AuthContext Provider => A => B => Greetings => p

총 5개의 Fiber가 리렌더된다. (새로운 Fiber가 생성되고 빈 객체 비교를 통해 리렌더된다.) 사실 A와 B는 리렌더될 이유가 없었다. context를 사용하지 않지만, context의 선언부(provider)와 호출부(consumer) 사이에 있다는 이유만으로 리렌더되어 버렸다.

이런 이유로 useContext를 사용하는 경우 최대한 하위 컴포넌트에서 선언하는 것을 권장한다.


불필요한 리렌더를 방지하는 또 다른 방법이 존재한다. 바로 children의 특징을 활용하는 것이다.

AuthProvider 컴포넌트를 분리하고 children을 렌더하는 형태로 변경한다. 이 경우 A, B, Greetings는 리렌더의 대상이 되지 않는다.

그 이유는 Fiber와 관련이 있다. 이전에 확인했듯이 JSX 문법을 만나는 순간이 Fiber를 생성하는 시점이다. 하지만 children은 단순한 prop일 뿐 JSX 문법이 아니다. 이로 인해 A, B, Greetings는 새로운 Fiber가 아닌 기존의 Fiber 그대로를 사용하게 된다. 기존 Fiber를 그대로 사용하게 되면서 동일한 참조주소의 props 객체를 사용하게 되고, 이로 인해 리렌더의 조건이 성립되지 않는다.

그렇다면 다시 새로운 질문이 등장할 수 있다.

Greetings가 리렌더되지 않는다면, update된 context value는 어떻게 리렌더될 수 있는건가요?

요거는 사실 context value를 참조하는 모든 컴포넌트는 context value가 update되는 순간 모두 리렌더되기 때문이라고 한다.


개인적 결론

이 발표의 가장 아쉬운 점은 그래서 use hook은 왜 조건부로 실행이 가능한지 정확하게 설명하지 않는 다는 점이다. 다만 발표의 내용을 미루어볼 때 useContext와 비슷하게 use 훅이 구현되어 있기 때문에 useContext로 설명을 대체한 듯 싶다.

이 발표에서 말하는 useContext의 업데이트 방식은 최근에 큰 화두로 떠오르고 있는 Signal의 개념과 유사해보인다. use hook 또한 리액트의 Signal 같은 느낌이 들 때가 있는데 이 부분은 조사가 필요하겠다.

profile
프론트 개발자가 되고 싶어요

1개의 댓글

comment-user-thumbnail
2024년 3월 31일

맵스에서 제 얼굴에 hook 날린건 어떻게 생각하시나요?

답글 달기