본문 바로가기

React

React Hook과 Closure의 관계(feat. useState)

Nextjs에서 timer를 만들다가 고민에 빠진 내용에 대해서 기록하고 싶어서 글을 쓰고 있다.

React hook을 정말 많이 사용했고, 지금 timer만들때도 사용했지만, 정작 이 훅들이 어떻게 동작하는지에 대해서는 생각하지 못했었던거 같다. timer를 만들면서 useState에 클로저란 개념이 적용되어 있다는 것을 알게 되었고

그 내용을 천천히 적어보려한다.

Closures 란?

여러 사이트에서 Clsoure란 개념이 존재하지만 가장 명확하게 정리한 개념을 적어놨다.

“[Closure] makes it possible for a function to have “private” variables.” -W3Schools

클로저 덕분에 함수가 개인 변수를 가질 수 있게 되었습니다.

let foo = 1;

function add() {
  foo = foo + 1;
  return foo;
}

console.log(add());
console.log(add());
console.log(add());
console.log(add());
console.log(add());

foo라는 변수는 전역에 있어서, 어떤 중간 스크립트에서도 접근이 가능하고 변경할 수 있다.

let foo = 1;

function add() {
  foo = foo + 1;
  return foo;
}

console.log(add());
console.log(add());
console.log(add());
foo = 9999;
console.log(add());
console.log(add());

여기서 발생하는 문제는, foo를 global로 접근하여 임의로 수정이 가능하다는 점이다.

그렇다면, 전역을 없애고, 함수내부에서 변수를 선언하면 어떻게 될까?

function add() {
  let foo = 1;
  foo = foo + 1;
  return foo;
}

console.log(add());
console.log(add());
console.log(add());
console.log(add());
console.log(add());

그럼 또다른 문제가 발생한다.

add() 함수를 호출할 때 마다 foo는 1로 초기화가 이루어지고 매번같은 값이 출력된다는 것이다.

어떻게 하면 이문제를 해결할 수 있을까.

다음과 같이 진행하면 된다.

function getAdd() {
  let foo = 1;

  return function () {
    foo = foo + 1;
    return foo;
  };
}

const add = getAdd();

console.log(add());
console.log(add());
console.log(add());
console.log(add());
console.log(add());

이렇게 되면, getAdd 함수에서 add 함수를 반환하고, getAdd 함수에 선언되어있는 foo에 접근할 수 있다. 그리고 foo는 변경된 값을 유지한다.

여기서 모듈 패턴(즉각 호출하는 패턴)을 통해서도 클로저를 만들 수 있다.

const add = (function getAdd() {
  let foo = 1;

  return function () {
    foo = foo + 1;
    return foo;
  };
})();

console.log(add());
console.log(add());
console.log(add());
console.log(add());
console.log(add());

이런 방식이 클로저이다. foo라는 변수에는 접근할 수 없게 만들고,

오직 함수를 통해서만 값을 조작할 수 있는 구조이다.

바로 이런점을 활용해서 React Hook을 만들었다고 한다.

useState를 clone해보자

Using the State Hook – React

function useState(initVal) {
  let _val = initVal;
  const state = _val;
  const setState = (newVal) => {
    _val = newVal;
  };
  return [state, setState];
}

const [count, setCount] = useState(1);

console.log(count);
setCount(2);
console.log(count);

다음과 같이 코드를 짜면 무슨 문제가 생길까?

원래대로라면, useState(1)로 초기화를 했을 때, count는 1이 되고 setCount(2)를 정의하면 count는 2로 바뀌어야 한다.

하지만 결과는

const state = _val 를 하게 되면 새로운 메모리에 값을 저장하기 때문에 _val값이 변경되도 state는 변하지 않는다.

function useState(initVal) {
  let _val = initVal;
  const state = ()=>_val;
  const setState = (newVal) => {
    _val = newVal;
  };
  return [state, setState];
}

const [count, setCount] = useState(1);

console.log(count());
setCount(2);
console.log(count());

그렇다면, _val 상태에 따라 state도 변하게 하고싶으면 어떻게 해야 될까?

가장 간단한 방법은

const state = ()⇒_val

함수로 반환하게 하는것이다. 이렇게 되면 이 함수가 호출할 때마다 현재 저장된 _val의 값을 반환하기 때문에 최신값을 얻을 수 있습니다.

다시, 본론으로 돌아와서우리는 리액트에서 useState함수를 사용할 때 바로 가져와 사용하지 못했다.

바로 React 모듈안에 위치하기 때문인데, 아까 클로저 함수를 만들었던 방식대로 모듈 패턴을 사용해보자.

const React = (function () {
  function useState(initVal) {
    let _val = initVal;
    const state = () => _val;
    const setState = (newVal) => {
      _val = newVal;
    };
    return [state, setState];
  }
  return { useState };
})();

const [count, setCount] = React.useState(1);

console.log(count());
setCount(2);
console.log(count());

자 그러면 이제 훅을 실행할 컴포넌트를 만들어보자

function Component() {
  const [count, setCount] = React.useState(1)

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

컴포넌트에서는 렌더링과 클릭에 대한 값을 반환해주고 있다.

그러면 이제 컴포넌트를 어떻게 렌더링 해주어야할지 알려줘야한다.

const React = (function () {
  function useState(initVal) {
    let _val = initVal
    const state = () => _val
    const setState = (newVal) => {
      _val = newVal
    }

    return [state, setState]
  }

  function render(Component) {
    const C = Component()
    C.render() // 컴포넌트 렌더링

    return C
  }

  return { useState, render }
})()

function Component() {
  const [count, setCount] = React.useState(1)

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

var App = React.render(Component) // ƒ state() {}
App.click()
var App = React.render(Component) // ƒ state() {}

현재 함수가 찍히는 이유는 useState에서 state를 반환 함수로 보내주고 있기 때문이다.

그래서 이를 해결하기 위해 _val 변수를 밖으로 이동시킨다.

그러면 이제 initVal까지 처리가 가능해진다.

const React = (function () {
  let _val

  function useState(initVal) {
    const state = _val || initVal
    const setState = (newVal) => {
      _val = newVal
    }

    return [state, setState]
  }

  function render(Component) {
    const C = Component()
    C.render()

    return C
  }

  return { useState, render }
})()

function Component() {
  const [count, setCount] = React.useState(1)

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

var App = React.render(Component) // 1
App.click()
var App = React.render(Component) // 2

자 여기까지 보면 useState 선언, 렌더링 후 click을 통한 state 변경이 값이 잘 유지된 채로 진행되고 있다는 것을 알 수 있다.

hook을 여러개 사용하기

하지만 useState를 여러개(2개 이상) 사용한다면 정상적으로 동작하지 않게된다.

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (word) => setText(word),
  };
}

var App = React.render(Component);
App.click();
var App = React.render(Component);
App.type('pear');
var App = React.render(Component);

그 이유는, 하나의 변수(_val)만 있고 계속해서 단일 변수를 덮어쓰기 때문에 항상 같은 값으로 갱신되게 됩니다.

두개의 useState가 하나의 _val을 참조하고 있기 때문에 값이 변하게 된다면 같은 값을 가르키게 된다.

이를 방지하기 위해 상태의 수만큼 처리할 수 있도록배열과 인덱스를 활용하여 개선해보자

const React = (function () {
  let hooks = [];
  let idx = 0;
  function useState(initVal) {
    const state = hooks[idx] || initVal;
    const setState = (newVal) => {
      hooks[idx] = newVal;
    };
    idx++;
    return [state, setState];
  }

  function render(Component) {
    const C = Component();
    C.render();
    return C;
  }
  return { useState, render };
})();

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (word) => setText(word),
  };
}

var App = React.render(Component);
App.click();
var App = React.render(Component);
App.type('pear');
var App = React.render(Component);

여기서 pear를 추가한 순간 원래 text에 갱신이 되어야했지만 count에 변경이 되었다.

그 이유는 React.render를 하는 순간 Component가 useState 함수를 호출하게 되고 자동으로 idx가 추가가 된다.

그래서 이미 증가된 idx를 통해 갱신을 하다보니 값이 이상해진다.

그러면 idx가 증가되는걸 알아냈으니 이를 고쳐보자

const React = (function () {
  let hooks = [];
  let idx = 0;
  function useState(initVal) {
    const state = hooks[idx] || initVal;
    const _idx = idx; //freeze
    const setState = (newVal) => {
      hooks[_idx] = newVal;
    };
    idx++;
    return [state, setState];
  }

  function render(Component) {
    idx = 0;
    const C = Component();
    C.render();
    return C;
  }
  return { useState, render };
})();

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (word) => setText(word),
  };
}

var App = React.render(Component);
App.click();
var App = React.render(Component);
App.type('pear');
var App = React.render(Component);

바로 변동되는 idx를 useState의 고유한 값으로 고정시켜놓는 것이다.

이렇게 되면 idx가 component render 이후 idx가 증가했더라도 useState만의 고유한 idx가 있기 때문에 그에 맞는 상태를 변경할 수 있게 된다.

hook은 호출에 대한 순서보장이 필요하다

리액트 공식문서에도 명시되어 있듯이 hook은 반복문, 조건문 및 중첩된 함수에서 사용하면 안된다.

그 이유는 동일한 순서로 호출이 되어야 하기 때문이다.

function Component() {
  if (Math.random() > 0.5) {
    const [count, setCount] = React.useState(1) // ReferenceError: count is not defined
  }

  const [text, setText] = React.useState('apple')

  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (word) => setText(word),
  }
}

원래는 hook을 호출한 순간 각각의 상태에 대한 배열의 idx를 부여받고 그 곳에서 상태를 관리하지만,

만약 특정 조건에 따라서 hook이 생성된다면 idx가 변경될 여지가 있기 때문이다.

이처럼 리액트 훅은 클로저라는 개념을 사용하여 private한 값을 활용하여 구성되어있다.

정리

react hook은 정말 많이 써봤는데, 실제 원리를 알아본건 처음인거 같다. 이번에 정말 좋은 계기가 된거같다.

원리를 참 좋아했던 나였는데 이런것 조차 찾아보지 않았다니…. 그래도 재대로 알아봤으니 그걸로 됬다.

참고

React hook을 정말 많이 사용했고, 지금 timer만들때도 사용했지만, 정작 이 훅들이 어떻게 동작하는지에 대해서는 생각하지 못했었던거 같다. timer를 만들면서 useState에 클로저란 개념이 적용되어 있다는 것을 알게 되었고

그 내용을 천천히 적어보려한다.

 

참고

React Hook과 Closure의 관계 (feat. useState)

 

React Hook과 Closure의 관계 (feat. useState)

상상도 못한 정체! ㄴㅇㄱ

www.fronttigger.dev

https://www.youtube.com/watch?v=KJP1E-Y-xyo 

 

'React' 카테고리의 다른 글

ESLint에 대해서 알아가기  (1) 2023.07.12
프레임워크와 라이브러리  (0) 2023.06.01
Hoisting 이란?  (0) 2023.05.31
브라우저 workflow & virtual DOM - 01  (0) 2023.05.31
Virtual DOM 알아보기  (0) 2023.05.24