[React] 클로저와 useState Hooks

2020. 9. 20. 14:41Frontend

https://morioh.com/p/1c10ff1d8da1

 

 

Overview

 

React Hooks는 React Functional Component(함수형 컴포넌트)에서 상태관리 및 컴포넌트 생명주기 API(Lifecycle API) 등 클래스 컴포넌트에서만 지원했던 기능들을 사용할 수 있도록 도와줍니다. 함수형 컴포넌트는 클래스형 컴포넌트에 비해 선언하고 사용하기가 편리하며, 코드를 간결하게 사용할 수 있고, 메모리를 비교적 덜 사용하는 등의 여러 장점들이 있기 때문에 최근에 많이 사용되는 추세인데, 여기에 클래스 컴포넌트에서만 사용가능했던 기능들을 Hooks가 지원하면서 함수형 컴포넌트의 효용이 더 높아졌다고 평가되고 있습니다.

 

 

이번 포스팅에서는 함수형 컴포넌트에서 상태관리를 할 수 있도록 도와주는 useState 메소드에 대해서 알아보고, 이 메소드가 내부적으로는 실제로 어떻게 동작하는지 직접 구현해보도록 하겠습니다.

 

 

useState Method

 

가장 고전적인 예시인 카운터를 통해서 클래스형 컴포넌트의 상태관리와, 함수형 컴포넌트의 상태관리를 살펴보도록 하겠습니다.

 

 

1. 클래스형 컴포넌트

 

클래스형 컴포넌트에서는 클래스 안의 지역변수 state를 정의하고, 상태를 변경할 메소드 안에 setState 메소드를 추가하여 상태를 변경합니다. state변수에 직접 접근해서 변경하는 것이 아닌, setState메소드를 사용하는 이유는 react component에게 "상태 바뀌었으니까 이 상태 반영해서 새로 렌더링해줘" 라고 알려주기 위함입니다.

import React from "react";

class App extends React.Component {
  state = {
    counter: 0
  };

  increment = () => {
    this.setState({
      counter: this.state.counter + 1
    });
  };

  decrement = () => {
    this.setState({
      counter: this.state.counter - 1
    });
  };

  render() {
    return (
      <>
        <div>{this.state.counter}</div>
        <button onClick={this.increment}>+</button>
        <button onClick={this.decrement}>-</button>
      </>
    );
  }
}

export default App;

 

 

 

2. 함수형 컴포넌트

 

함수형 컴포넌트에서는 리액트에서 자체적으로 지원하는 'useState'라는 React hooks를 사용해서 상태를 관리할 수 있습니다.

useState함수는 초기값 initialValue를 받아서 [상태, 상태를 변경할 핸들러]의 배열을 반환하므로, 아래와 같이 destructuring한 형태의 배열로 받아서 사용할 수 있습니다.

 

아래 예시에서는 카운터라는 상태를 사용하고 초기값을 0으로 지정하며, setCounter를 통해 카운터를 변경할 수 있습니다.

import React, { useState } from "react";

export default function App() {
  const [counter, setCounter] = useState(0);

  return (
    <>
      <div>{counter}</div>
      <button onClick={() => setCounter(counter + 1)}>+</button>
      <button onClick={() => setCounter(counter - 1)}>-</button>
    </>
  );
}

 

 

How it works

 

render() 메소드를 통해 상태 변경을 감지해 필요한 부분만 업데이트 할 수 있는 클래스형 컴포넌트와는 다르게, 함수형 컴포넌트는 렌더가 필요할 때마다 함수를 다시 호출합니다. 함수형 컴포넌트라는 이름 자체에서 알 수 있듯이, props를 인자로 받아서 jsx문법에 맞는 리액트 컴포넌트를 리턴하는 것이 함수형 컴포넌트의 개념이기 때문에 렌더링 = 함수호출 이기 때문이죠.

 

따라서 함수형 컴포넌트에서 상태관리를 하기 위해서는 함수가 다시 호출되었을 때 이전의 상태를 기억하고 있어야 하며, React Hooks는 이를 클로저를 통해서 해결하려 합니다. 

 

 

 

https://lostechies.com/derekgreer/2012/02/17/javascript-closures-explained/

 

Closure

 

클로저는 JS가 가지고 있는 가장 중요한 특성이자 가장 기본적인 특성 중의 하나이지만, 그럼에도 불구하고 가장 헷갈리는 개념 중의 하나입니다. 클로저에 관한 가장 속시원한 정의는 (개인적으로 생각하기에) 다음과 같습니다.

 

" 함수가 속한 Lexical Scope를 기억하여 함수가 Lexical Scope 밖에서 실행될 때에도 이 스코프에 접근할 수 있게 해주는 기능"

 

이해를 돕기 위해 다음의 예시를 살펴보겠습니다.

function getAdd() {
  let num = 0;
  return function () {
    num += 1;
    return num;
  };
}

const add = getAdd();
console.log(add());

 

 

자바스크립트의 스코프 정의(Scope Definition)와 실행 컨텍스트 (Execution Context)는 함수 안에서 선언된 변수를 함수 바깥에서 접근할 수 없도록 제한합니다. 따라서 위 getAdd()함수에서 정의한 num이라는 변수를 함수 바깥에서 접근하면 (바깥에 num이라는 변수가 선언되어 있지 않는 한) 다음과 같은 에러를 뿜게 될 것입니다.

 

 

 

 

하지만 함수 안에서 선언된 변수를 함수 안에 선언된 또다른 함수 (위 예시에서는 getAdd안에 선언된 익명 함수) 에서 접근하는 것은 가능합니다. 따라서 위의 예시의 익명함수에서 부모 스코프의 num변수에 접근해서 변수를 조작하는 것이 가능하다는 것입니다. 

 

 

바로 이 부분이 클로저의 정의와 맞닿아 있는 부분입니다. 내부 함수에서 부모 함수의 스코프의 변수에 접근할 수 있는데, 이는 부모 함수가 이미 호출이 완료되어 리턴되었을 때도 가능하다는 것입니다. 즉 이미 실행컨텍스트 큐에서 부모 함수의 컨텍스트 정보는 모두 사라졌음에도, 자식 함수가 아직 남아 있다면, 그 자식 함수에서는 이미 실행이 종료된 부모 함수의 컨텍스트 정보(선언된 변수나 함수 등의 정보)를 참조할 수 있다는 것입니다. 

 

 

따라서 const add = getAdd();의 실행이 완료되는 시점에서 getAdd()는 실행이 완료되지만, add에 남아있는 익명함수는 여전히 부모 함수의 num정보를 가지고 있기 때문에 add안에 있는 익명 함수를 호출하였을 때, 해당 num을 업데이트하고 리턴해 줄 수 있는 것입니다. useState 메소드는 바로 이 클로저를 이용해서 함수의 상태를 기억합니다.

 

 

 

 simple useState method

const customUseState = (initialVal) => {

  let innerState = initialVal;
  const state = () => innerState;
  const setState = (newVal) => {
    innerState = newVal;
  };
  
  return [state, setState];
};

 

 

클로저 개념을 이용해서 구현한 useState함수입니다. 아직 정확하진 않지만, 일종의 개념모델로 생각하면 좋을 것 같습니다. useState함수를 실행하면, 파라미터로 넘어온 initialVal을 내부 상태에 할당하고, state를 호출하면 이 내부 state를 리턴하고, setState를 호출하면 새로운 파라미터로 넘어온 newVal을 내부 상태에 할당합니다. 

 

 

실제로 state과 setState를 사용하는 시점은 customUseState의 호출이 끝난 후이지만, 클로저가 innerState값을 기억하고 있기 때문에 그 이후에도 접근할 수 있는 것입니다. 

 

 

이제 이 메소드가 조금 더 정확한 동작을 하기 위해서는 state에 접근할 때 메소드가 아닌 변수로 접근할 수 있어야 합니다. (예를 들어 counter변수를 만들었으면 이 변수를 counter로 접근해야지  counter()로 접근하는 것은 불편하기도 할 뿐더러 낭비입니다.) 하지만 그렇게 하기 위해서 다음과 같이 접근하면 문제가 발생합니다.

 

 

const customUseState = (initialVal) => {

  let innerState = initialVal;
  const state = innerState;
  const setState = (newVal) => {
    innerState = newVal;
  };
  
  return [state, setState];
};

const [counter, setCounter] = customUseState(0);
console.log(counter);
setCounter(1);
console.log(counter);

 

 

 

state는 말 그대로 변수 값이기 때문에 customUseState의 호출이 끝나고 나면 그대로 리턴되어 더이상 변경할 수 없는 상태가 됩니다. 따라서 이후에 setState를 통해 counter값을 업데이트하더라도 해당 값을 참조할 수 없게 되는 것입니다. 리액트에서는 이 문제를 해결하기 위해 몇 가지 규칙을 만든 후, state 값을 useState메소드 외부에 배열 형식으로 저장하는 방법을 선택하게 됩니다.

 

 

Hooks는 마법이 아니라 그저 배열일 뿐.

 

리액트는 useState를 통해 생성한 상태를 접근하고 유지하기 위해서 useState메소드 바깥쪽에 state를 저장합니다. 이 state들은 선언된 컴포넌트를 유일하게 구분할 수 있는 키로 접근할 수 있으며 "배열" 형식으로 저장됩니다. 

 

 

따라서 useState함수 안에서 선언되는 상태들은 이 배열에 "순서대로" 저장되며, 상태가 업데이트 되었을 때, 이 상태들은 리액트 컴포넌트 바깥에 선언되어 있는 변수들이기 때문에 업데이트 한 후에도 이 변수들에 접근할 수 있게 됩니다. 

 

 

선언한 상태들이 컴포넌트를 키로 하는 배열에 '순서대로'저장되기 때문에 hooks 를 조건문이나, 일반 javascript함수 안에서 사용하게 된다면, 맨 처음 함수가 실행될때 저장되었던 순서와 맞지 않게 되어 엉뚱한 상태를 참조하게 될 수 있습니다. 이는 useState를 이용한 상태의 저장이 useState내부에 있는 것이 아니라 해당 컴포넌트 밖에 있기 때문입니다. 

 

 

어떤 경우에는 2개의 useState hook이 실행되고 어떤 경우에는 3개가 실행된다면, 혹은 어떤 조건들로 인해서 hook이 실행되는 순서가 바뀌어 버리게 된다면, 함수가 렌더링될 때 참조하는 배열의 순서와 맞지 않기 때문에 엉뚱한 상태를 참조할 수도 있게 됩니다. (자세한 내용은 아래의 reference를 참조해주세요)

 

 

마지막으로, 해당 컴포넌트의 바깥에 hook과 관련된 내용들이 저장되기 때문에 컴포넌트가 언마운트 될때 cleanup function등을 이용해서 해당 hook들을 메모리에서 제거해주어야 합니다. (이부분은 다음 useEffect 관련된 포스팅에서 더 자세히 다루도록 하겠습니다.)

아래는 위 내용을 바탕으로 간단하게 직접 구현한 useState메소드입니다. 

 

 

 

useState의 개념 모델 

import React from "react";

let state = [];
let setters = [];
let cursor = 0;
let firstrun = true;

const createSetter = (cursor) => {
  return (newValue) => {
    state[cursor] = newValue;
  };
};

const customUseState = (initialValue) => {
  if (firstrun) {
    state.push(initialValue);
    setters.push(createSetter(cursor));
    firstrun = false;
  }

  const resState = state[cursor];
  const resSetter = setters[cursor];
  cursor++;
  return [resState, resSetter];
};

export default function App() {
  cursor = 0;
  const [counter, setCounter] = customUseState(0);

  return (
    <>
      <div>{counter}</div>
      <button onClick={() => setCounter(counter + 1)}>+</button>
      <button onClick={() => setCounter(counter - 1)}>-</button>
    </>
  );
}

 

 

 

 

 

Reference

https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e

 

React hooks: not magic, just arrays

Untangling the rules around the proposal using diagrams

medium.com

https://ko.reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

 

Hook의 규칙 – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

 

 

 

 

반응형