이글은 "swyx"씨가 "Opinions & Insights"에 기고한 원문을 번역한 글입니다.

심층 분석: React Hook은 정말 어떻게 작동하는 걸까요?

저자 : 이 글은 더 많은 연관 내용을 포함한 대담으로 발전하였습니다. 그리고 여기서는 React 스케줄러 또는 state가 React 내부에 어떻게 저장되는지에 관해 자세히 설명하지는 않습니다.

Hook은 사용자 인터페이스에서 상태 변화(stateful behavior)와 이에 동반하는 연관 작용(side effects)을 간결하게 해주는 아주 간단한 방법입니다. 이 방법은 React에 처음 적용되었고 Vue, Svelte 같은 다른 프레임워크에도 광범위하게 채택되었으며 general functional JS에도 적용되어 있습니다. 하지만 이들의 기능적 설계는 Javascript의 Closure에 대한 충분한 이해를 필요합니다.

이 글에서는 React Hook의 작은 클론을 만들면서 Closure를 다시 살펴보도록 하겠습니다. 2가지 목적이 있는데요, 하나는 Closure의 효과적인 용도를 보여주는 것이고 두번째는 단지 29줄의 간결한 자바스크립트 코드로 어떻게 Hook 클론이 만들지는지 보여주고자 함입니다.

⚠️ Note: Hook을 이해하기 위해 이 과정을 꼭 거쳐야 할 필요는 없습니다. 하지만 이 연습을 통해 Javascript의 기본 원리를 좀 더 잘 이해할 수 있게됩니다. 그리고 그렇게 어렵지 않아요!^^

Closure는 무엇일까요?

Hook이 내세우는 많은 장점들 중 하나는 ClasseHOC(High Order Component)의 복잡성을 한꺼번에 피할 수 있다는 점입니다. 그런데, Hook을 사용하게되면 단순히 한 문제가 다른 문제로 바뀌었을 뿐이라고 생각하기도 합니다만, 문맥에 얽매이기 보다는 Closure에 집중해야 합니다. Mark Dalgleish가 기억하기 쉽게 요약했네요.:

Closure는 자바스크립트의 핵심 개념입니다. 그렇지만 초보 개발자들에게는 혼란스러운 녀석으로 악명이 높죠. Kyle Simpson는 본인의 저서 "You Don’t Know JS"에서 Closure를 다음과 같이 정의하고 있습니다.:

Closure는 함수가 자신의 정적 범위(lexical scope)를 벋어나 실행되고 있을 때에도 자신 정적 범위를 기억하고 접근할 수 있는 경우를 말합니다.

MDN에서 [함수들이 중첩되어 있을 때 Parser가 변수 이름을 결정하는 방법(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures)으로 정의하는 정적 범위의 개념과 Closure는 밀접하게 연관되어 있습니다. 실제 사례를 살펴보겠습니다.:

// 사례 0
function useState(initialValue) {
    var _val = initialValue // _val은 useState가 만든 지역변수
    
    function state() {
        // state는 내부함수이자 'Closure'입니다.
        return _val // state()은 부모함수가 선언한 _val변수를 사용합니다.
    }

    function setState(newVal) {
        // same
        _val = newVal // 노출한 _val을 사용하지 않고 설정함수를 이용해 값을 설정합니다.
    }
    
    return [state, setState] // 외부에서 사용할 수 았도록 함수들을 노출합니다.
}

var [foo, setFoo] = useState(0) // 배열 비구조화를 사용합니다.

console.log(foo()) // logs 0 - 처음 설정한 초기값입니다.
setFoo(1) // useState의 정적범위에 있는 _val에 설정합니다.
console.log(foo()) // logs 1 - 동일한 호출에도 불구하고 새롭게 바뀐값이 출력됩니다. 

아주 초보적인 useState() hook 클론을 만들어 봤습니다. 이 함수는 2개의 내부 함수( state와 setState)를 갖고 있습니다. state()는 부모에서 정의한 변수 _val을 반환합니다. 그리고 setState()는 받은 인자(i.e. newVal)를 _val 지역변수에 저장합니다.

state()는 일종의 geter 함수 형태로 만들어졌습니다. 바람직한 모습은 아니지만, 곧 수정할 것입니다. 중요한 점은 foo()와 setFoo()인데, 이들을 통해 우리는 내부변수 _val에 접근하고 조작(a.k.a "close over")할 수 있다는 점입니다.
이 함수들은 useState()의 정적범위에 접근할 수 있는 권한을 가지고 있으며, 이러한 참조 형태를 Closure라고 합니다. React와 다른 프레임워크들의 맥락에서 보면, 이것이 "상태"인 듯 하고, 정확히 그런 의미입니다.

Closure에 대해 더욱 자세히 알고싶다면, 그 주제에 대한 MDN, YDKJSDaily를 읽어볼 것을 추천합니다. 다만, 위의 예제 코드를 이해하셨다면 그것으로 충분합니다.

함수형 컴포넌트에서 사용

새로 만든 useState() 클론을 익숙한 환경에 적용해 보겠습니다. Counter 컴포넌트를 만들어 보겠습니다.

// 사례 1
function Counter() {
    const [count, setCount] = useState(0) // useState는 직전 사례와 같다.

    return {
        click: () => setCount(count() + 1),
        render: () => console.log('render:', { count: count() })
    }
}

const C = Counter()

C.render() // render: { count: 0 }
C.click()
C.render() // render: { count: 1 }

여기서는 DOM으로 렌더링(화면출력)하는 대신, 상태값(a.k.a count)을 Console로 출력하도록 하였습니다. 또한 이벤트 핸들러 대신에 스크립트를 실행할 수 있도록 API 형태로 노출하였습니다. 이렇게 해서 사용자 행위에 대해 Counter 컴포넌트의 렌더링과 반응을 시뮬레이션 할 수 있습니다.

Closure의 식상한 문제

React API 형태로 만들기위해, state는 함수 대신 변수가 되어야 합니다. 만약, _val을 함수로 감싸는 대신 단순하게 외부로 노출해버리면 bug를 만나게 됩니다.:

// 사례 0를 다시 봅시다. - this is BUGGY!
function useState(initialValue) {
    var _val = initialValue
    // state() 가 없습니다.
    function setState(newVal) {
        _val = newVal
    }
    return [_val, setState] // _val을 직접 노출해 버립니다.
}

var [foo, setFoo] = useState(0)

console.log(foo) // logs 0 함수호출없이 바로 가져옵니다. 
setFoo(1) // useState의 정적범위에 있는 _val에 저장합니다.
console.log(foo) // logs 0 - 이런!!

이것은 Closure의 식상한 문제점들 중의 한 형태입니다. useState() 호출 결과의 비구조화 할당으로 foo를 만들었는데, 이것은 초기 useState() 호출에서 생성된 _val값을 참조합니다. _val변수가 아닙니다. 따라서 다시는 변경되지 않습니다. 이것은 우리가 원하는 바가 아니죠. 우리는 일반적으로 함수 호출 대신 변수의 형태로 현재 상태를 반영하는 컴포넌트 state가 필요합니다! 이 두 가지 목표는 정반대인 것 같습니다.

Closure를 모듈안으로

Closure를 다른 Closure 내부로 옮겨서 위 useState 문제를 해결할 수 있습니다. ( Closures를 엄청 좋아한다죠 아마? ^^)

// 사례 2
const MyReact = (function() {
  let _val // 모듈 영역 내부에 상태를 유지시킵니다.

  return {
    render(Component) {
      const Comp = Component()
      Comp.render()
      return Comp
    },
    useState(initialValue) {
      _val = _val || initialValue // 매번 실행할 때마다 새로운 값이 저장됩니다.
        function setState(newVal) {
        _val = newVal
      }
      return [_val, setState]
    }
  }
})()

작은 React 클론을 만들기 위해 모듈 패턴을 사용하기로 하였습니다. React와 같이, 컴포넌트의 상태(위 샘플에서는 1개 컴포넌트며이고 추적되는 상태는 _val입니다.)를 추적할 수 있게 되었습니다. 이 설계를 통해 MyReact는 함수형 컴포넌트를 "렌더링"할 수 있으며, 항상 내부_val 값을 올바른 Closure에 할당할 수 있게 되었습니다.

// 사례 2 계속..
function Counter() {
    const [count, setCount] = MyReact.useState(0)

    return {
        click: () => setCount(count + 1),
        render: () => console.log('render:', { count })
    }
}

let App

App = MyReact.render(Counter) // render: { count: 0 }
App.click()
App = MyReact.render(Counter) // render: { count: 1 }

이제 React Hook과 좀 더 비슷해졌나요?

YDKJS에서 모듈 패턴과 Closure에 관한 더 많은 내용을 볼 수 있습니다.

useEffect을 복제해 봅시다.

지금까지 React의 기본적인 hook인 useState()를 다루었고, 다음 중요한 hook인 useEffect()를 살펴보겠습니다. setState()와 달리, useEffect는 비동기적으로 동작하기 때문에 Closure 문제들을 더 많이 겪게 된다는 것을 의미합니다.

지금까지 우리가 만들어 온 React의 작은 모델을 좀 더 확장해 보도록 하겠습니다.

// 사례 3
const MyReact = (function() {
  let _val, _deps // 정적영역에 상태(_val)와 의존성 요소들(_deps)을 담고 있습니다.

  return {
    render(Component) {
      const Comp = Component()
      Comp.render()
      return Comp
    },
    useEffect(callback, depArray) {
      const hasNoDeps = !depArray
      const hasChangedDeps = _deps ? !depArray.every((el, i) => el === _deps[i]) : true
      if (hasNoDeps || hasChangedDeps) {
        callback()
        _deps = depArray
      }
    },
    useState(initialValue) {
      _val = _val || initialValue
      function setState(newVal) {
        _val = newVal
      }
      return [_val, setState]
    }
  }
})()

// hook 사용
function Counter() {
  const [count, setCount] = MyReact.useState(0)

  MyReact.useEffect(() => {
    console.log('effect', count)
  }, [count])
  return {
    click: () => setCount(count + 1),
    noop: () => setCount(count),
    render: () => console.log('render', { count })
  }
}

let App

App = MyReact.render(Counter)
// effect 0
// render {count: 0}
App.click()
App = MyReact.render(Counter)
// effect 1
// render {count: 1}
App.noop()
App = MyReact.render(Counter)
// // no effect run
// render {count: 1}
App.click()
App = MyReact.render(Counter)
// effect 2
// render {count: 2}

useEffect는 의존성 변수들이 변할 때마다 재동작 해야하기 때문에 의존성 변수들의 추적을 위한 다른 변수인 "_deps"를 도입하였습니다.


###마술이 아니라 단지 배열(Array)일 뿐입니다.
useState()와 useEffect()로 동작하는 매우 훌륭한 클론을 가졌지만, 둘 다 한번씩만 사용할 수 있거나 버그가 있는 상황입니다. 어떤 상황에도 이용할 수 있도록 즉, 몇 번이고 state와 effects를 이용할 수 있도록 일반화할 필요가 있습니다. 다행스럽게도, Rudi Yardley가 밝혔듯이 React Hook은 마술을 부린게 아니라 단순히 배열을 사용했네요. 그래서 hook 배열을 사용하게된 것이고, 결코 곂치지 않기 때문에 우리의 hook 배열안에 _val과 _deps 둘 다 구겨넣을 수 있게 된 것입니다.

// 사례 4
const MyReact = (function() {
  // hook을 저장할 배열과 반복자(hook을 만들때마다 인덱스가 증가하고 랜더링 직후 초기화됩니다.)
  let hooks = [], currentHook = 0
  
  return {
    render(Component) { // Global 랜더링 함수가 필요시 개별 components들의 랜더링 함수를 수행합니다.
      const Comp = Component() // run effects
      Comp.render()
      currentHook = 0 // 다음 렌더링을 위해 초기화합니다.
      return Comp
    },
    useEffect(callback, depArray) {
      const hasNoDeps = !depArray
      const deps = hooks[currentHook] // 타입: 배열 또는 undefined
      const hasChangedDeps = deps ? !depArray.every((el, i) => el === deps[i]) : true
      if (hasNoDeps || hasChangedDeps) {
        callback()
        hooks[currentHook] = depArray
      }
      currentHook++ // 현재 hook을 처리했으면 요게 완료처리다.
    },
    useState(initialValue) {
      hooks[currentHook] = hooks[currentHook] || initialValue // 타입: 모든 자료형

      // 내부함수 setState의 closure를 만들자, setState가 나중에 실행될 때 setStateHookIndex는 hook 배열에서 제대로된 요소를 기억하고 있다.
      const setStateHookIndex = currentHook 
      const setState = newState => (hooks[setStateHookIndex] = newState)
      
      return [hooks[currentHook++], setState]
    }
  }
})()

여기서 setStateHookIndex의 사용을 주목해야 합니다. 아무일도 하지 않는 것처럼 보이지만 나중에 setState() 호출에서 올바른 currentHook 값을 가지도록 해줍니다.(setStateHookIndex는 이 setState의 Closure에 있기 때문에 기억하고 있는거죠.) 이렇게 하지 않으면, currentHook이 엉뚱한 값을 가지게 되어 setState 호출은 오류가 됩니다.

// 사례 4 계속 - hook 활용
function Counter() {
  const [count, setCount] = MyReact.useState(0)
  const [text, setText] = MyReact.useState('foo') // 2번째 상태!

  MyReact.useEffect(() => {
    console.log('effect', count, text)
  }, [count, text])
  
  return {
    click: () => setCount(count + 1),
    type: txt => setText(txt),
    noop: () => setCount(count),
    render: () => console.log('render', { count, text })
  }
}

let App
App = MyReact.render(Counter)
// effect 0 foo
// render {count: 0, text: 'foo'}
App.click()
App = MyReact.render(Counter)
// effect 1 foo
// render {count: 1, text: 'foo'}
App.type('bar')
App = MyReact.render(Counter)
// effect 1 bar
// render {count: 1, text: 'bar'}
App.noop()
App = MyReact.render(Counter)
// // no effect run
// render {count: 1, text: 'bar'}
App.click()
App = MyReact.render(Counter)
// effect 2 bar
// render {count: 2, text: 'bar'}

요약하자면, hook 배열과 hook이 호출될 때 마다 증가하고 해당 컴포넌트가 렌더링된 후 초기화되는 index를 가진다는 것입니다.

손쉽게 나만의 hook을 만들수도 있습니다.:

// 사례 4를 다시 살펴봅시다.
function Component() {
  const [text, setText] = useSplitURL('www.netlify.com')
  return {
    type: txt => setText(txt),
    render: () => console.log({ text })
  }
}

function useSplitURL(str) {
  const [text, setText] = MyReact.useState(str)
  const masked = text.split('.')
  return [masked, setText]
}

let App

App = MyReact.render(Component)
// { text: [ 'www', 'netlify', 'com' ] }
App.type('www.reactjs.org')
App = MyReact.render(Component)
// { text: [ 'www', 'reactjs', 'org' ] }}

이것은 hook이 절대 마술이 아니하는 것을 보여줍니다. - 나만의 hook은 단순히 프레임워크가 기본으로 제공하는 요소(hook APIs)들로 만들어졌을 뿐입니다. - React 또는 우리가 만들었던 작은 클론이던지 상관없이 말이죠.

Hook의 사용 규칙들을 이끌어내 봅시다.

Hook 첫번째 규칙을 손쉽게 이해할 수 있을겁니다.: Hook은 오직 탑레벨에서 호출합니다. 앞서서 우리는 React가 호출 순서를 currentHook 변수에 의존하도록 설계하였습니다. 우리의 구현 모델을 염두에 두고, React Hook의 전체규칙을 읽어본다면 어떻게 수행되는지 완전히 이해할 수 있을 것입니다.

또한 두 번째 규칙인 React 함수에서만 Hook 호출도 우리 구현체의 필연적 결과는 아니지만, 코드의 어떤 부분이 상태 저장 로직에 의존하는지 명시적으로 구분하는 것이 확실히 좋은 방법입니다.(긍정적인 부작용으로써는 첫 번째 규칙을 따르도록 도구를 더 쉽게 작성할 수 있습니다. 반복문과 조건문에서 일반 자바스크립트 함수들 같이 상태 관리을 포장하는 자충수를 두는일이 없어야겠습니다. 규칙 2를 따르면 규칙 1을 준수할 수 있습니다.)

결론

아마도 우리는 이 시점에서 최대한으로 여러 연습들을 할 수 있을 겁니다. useRef를 한줄로 구현하고 렌더링 함수가 사실 JSX를 사용하여 DOM에 마운트되게 한고 28줄의 React Hook 클론에서 생략한 중요한 세부 기능들을 만들 수 있겠죠. 하지만 여러분이 문맥상 Closure 사용 경험을 쌓았기를 바라며, React Hook이 어떻게 작동하는지 분명히 설명해주는 유용하고 추상적인 모델을 얻었기를 바랍니다.

이 에세이 초안을 검토하고 귀중한 피드백으로 개선점을 알려준 Dan Abramov와 Divya Sassidharan에게 감사드리고, 남은 실수는 모두 내 탓입니다.^^

Written by swyx
Published in Opinions & Insights on March 11, 2019

profile
만만세~

0개의 댓글