본문 바로가기

FRONTEND

React Hooks Introduction & useState

https://unsplash.com/ko/%EC%82%AC%EC%A7%84/a-multicolored-tile-wall-with-a-pattern-of-small-squares-jR4Zf-riEjI

 

 

소개

훅(Hooks)의 종류는 다양합니다. React에서 제공하는 Hooks API 목록 입니다. 

 

Basic Hooks 

  • useState
  • useEffect
  • useContext

 

Additional Hooks

  • useReducer
  • useCallback
  • useMemo
  • useRef
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue
  • useDeferredValue
  • useTransition
  • useId

공식문서에 따르면 React Hooks의 정의는 다음과 같습니다.

Hooks are functions that let you “hook into” React State and lifecycle features from function components.

여기서 3가지 키워드에 집중하여 살펴보겠습니다. 훅(Hooks)은 함수이며, React의 상태와 생명주기함수형 컴포넌트에서 사용할 수 있도록 엮어주는(연동해주는) 기능을 합니다.

 

함수형 컴포넌트(function components)

React에는 클래스형, 함수형 컴포넌트가 존재합니다. 초기에는 클래스형 컴포넌트만이 사용 됐지만 React 14 버전 이후 함수형 컴포넌트가 도입되었습니다.

 

클래스형 컴포넌트는 여러가지 단점이 존재합니다.

첫번째로, 컴포넌트에서 ‘상태’ 관련 로직을 재사용 하기 어렵습니다.

두번째, 동작을 예측하기 어렵습니다. 클래스 내부에서는 this 라는 키워드를 사용하는데 this는 변경 가능하며 조작이 가능하기에 props와 state는 불변값이어야 하지만 변하는 상황이 발생합니다.

세번째, 클래스 문법 자체에 대한 이해하기가 어렵습니다. 이에 반해, 함수형 컴포넌트는 코드에 대한 작성이 쉽습니다. 예를 들어, 클래스의 인스턴스를 생성하거나 이벤트 핸들러를 컨스트럭터와 바인딩하는 작업을 할 필요가 없습니다. 또한 예측할 수 있는 코드를 작성할 수 있습니다.

 

상태와 생명주기(React State and lifecycle features)

이러한 상황에서 클래스형 컴포넌트를 계속해서 사용해야 했던 이유는 React의 핵심이라 할 수 있는 '상태'와 '생명주기'를 통한 상태 관리를 함수형 컴포넌트에서는 할 수 없었기 때문입니다. 함수형 컴포넌트는 결국 JavaScript의 함수입니다. 함수형 컴포넌트가 실행 될 때마다, 함수형 컴포넌트 내 모든 코드가 초기화 되고 재 실행되기 때문에 상태(변수)를 기억할 수 없습니다. 하지만 훅이 16.8 버전 이후부터 등장하면서 바로 훅(Hooks)은 함수형 컴포넌트의 장점을 살리면서 상태, 생명 주기를 통한 상태관리를 하기 위한 것이었습니다.

 

이제 앞서 정의를 통해 설명한 것과 같이, 훅(Hooks)은 함수형 컴포넌트의 장점을 살리면서 상태와 생명주기를 사용할 수 있도록 하는 함수이다 라는 내용을 이해하시는데 도움이 되실 것입니다. 그렇다면 훅(Hooks)은 어떻게 함수 내부에서 모든 변수가 초기화 되는 문제점을 해결 했을까요?

 

클로저(Closure)

바로 JavaScript의 클로저라는 개념입니다.

클로저는 복합적인 개념입니다! (클래스 컴포넌트의 this를 피해 왔지만 closure가 기다리고 있다니..)

간단하게 클로저에 대해 정의 해보면 다음과 같습니다.

클로저란?
외부 함수보다 중첩 함수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이때 중첩함수를 클로저라 부른다.

 

기본적으로 변수와 함수의 생명주기를 생각해보면 다음과 같습니다. 함수가 실행이 되면 해당 함수의 생명주기는 종료됩니다. 이에 따라 함수 내부에 선언된 변수(지역변수)의 생명주기 또한 함수의 생명주기가 종료됨과 동시에 종료가 되고 해당 변수를 참조할 수 없습니다. 하지만 클로저를 형성한다면 생명주기가 종료되어야 하는 지역변수는 사라지지 않고 계속해서 참조할 수 있는 상태가 됩니다. 아래에서 부가적으로 설명하겠습니다.

먼저, 클로저의 간단한 예제 코드를 살펴보겠습니다.

const x = 1; //전역변수
 
function outer() {
  const x = 10; //지역변수
  const inner = function() {
    console.log(x);
  };
  
  return inner;
}
 
const innerFunc = outer();
innerFunc();
 
//출처: 모던 자바스크립트 Deep Dive

 

위 코드에서 13번째줄 innerFucn 함수를 실행하면 어떤 값이 콘솔에 찍힐까요?

콘솔창에는 10이 찍히게 됩니다. 당연히 10이라고 생각할 수 있지만, JavaScript 실행 컨텍스트에 따르면, 당연한것이 아닐 수 있습니다.

 

실행 컨텍스트는 변수와 생명주기에 관련된 것인데, 함수가 실행되고 나면 함수의 생명주기가 종료되고 함수 내부에 선언된 변수의 생명 주기도 같이 종료됩니다. 생명주기가 종료된다는 의미는 메모리상에서 사라진다는 말(Garbage Collector의 수거 대상)이므로 다른 곳에서 해당 변수를 참조할 수 없습니다. 예제 코드를 살펴보면 12번째 줄에서 outer 함수가 실행되면 내부 로직이 실행되고 outer 함수의 생명주기가 종료됩니다. 구체적으로 말하면, 함수 내부에 선언된 변수 x(4번 째 줄) 의 생명주기가 끝나 이 변수 역시 메모리상에서 사라집니다. 그 이후에 13번 줄의 함수가 실행되면 inner 함수의 6번째 줄 코드는 4번째 줄의 x를 참조할 수 없고 1번 째 줄의 x를 참조 해야합니다. 하지만 4번째 줄의 x는 메모리상에서 사라지지 않고 참조할 수 있는 상태가 됩니다.(엄밀히 말하면, 생명주기가 종료되었음에도 어디선가 해당 변수를 참조하고 있다면 GC의 수거 대상이 되지 않습니다!) 이때 4번째 줄의 변수 x와 중첩함수 inner가 클로저를 형성한다고 할 수 있습니다.

 

다시 정리하자면, 외부 함수와 내부 함수가 있는 경우, 일반적인 경우라면 외부 함수의 생명 주기가 종료되면, 외부 함수 내부에 선언된 변수, 중첩 함수의 생명주기도 같이 종료되어야 하지만, 중첩함수가 외부함수보다 오래 살아남는 경우, 외부 함수 내부에 선언된 변수를 중첩함수가 참조할 수 있고 이 변수와 중첩 함수는 클로저를 형성한다고 할 수 있습니다.

 

useState를 직접 만들어보자

useState 를 직접 구현해보며 클로저 개념이 어떻게 적용되어 있는지 알아보겠습니다. 먼저, useState에 대해 살펴보겠습니다. useState Hook은 React에 내장된 기본적인 훅(Hooks)으로 상태값(state)과 해당 상태 값을 업데이트 하는 setter 함수로 구성되어 있습니다. 기본적인 사용 방법은 useState 훅 함수의 첫 번째 인자로 초기값을 넣고 실행하여, state의 초기 값을 설정합니다. setter 함수는 하나의 인자를 받는데 인자로 state를 업데이트 하고 싶은 값을 넣어 setter 함수를 실행시키면 해당 state가 그 인자에 들어간 값으로 업데이트 됩니다. setter 함수는 인자로 모든 형태의 값(문자열, 배열, 객체, 숫자, undefined, null 등)과 함수를 받을 수 있습니다. 그리고 useState를 사용할 때는 statesetter 함수를 배열에 담아 반환하므로 사용할 때는 배열 구조 분해 할당 문법을 활용하여 사용하면 됩니다. 다음과 같은 형태로 사용합니다.

  • useState 형태
const [state, setState] = useState(initialValue);

useState를 직접 만들어 봅시다!

const ReactHooks = (() => {
  const useState = (initialValue) => {
    let state = initialValue;
    
    const setterFunction = (newValue) => {
      state = newValue;
    };
   
   return [state, setterFunction];
  };
  
  return {
    useState
  };
})();

 

해당 코드를 살펴보면 눈에 띄는 점은 ReactHooks 함수를 즉시실행함수로 만들었다는 점입니다. 즉시실행함수는 딱 한번만 실행되기 때문에 변수 state가 재차 초기화되는 것을 방지해 줍니다.

 

우선 ReactHooks 함수 내에 useState 함수를 선언하고 12번째 줄에서 useState 함수를 반환합니다. useState 함수는 인자로 initialValue를 받고 내부에 state, setterFunction을 선언합니다. state는 인자로 받은 initialValue를 할당 받습니다. setterFunction 함수는 newValue를 인자로 받고 state를 인자로 받은 newValue로 재할당 합니다. 그리고 9번 째 줄에서 state, setterFunction 함수를 배열에 담아 반환합니다.

해당 부분을 실행해보도록 하겠습니다.

//Hooks 선언 부분
const ReactHooks = (() => {
  const useState = (initialValue) => {
    let state = initialValue;
    
    const setterFunction = (newValue) => {
      state = newValue;
    };
   
   return [state, setterFunction];
  };
  
  return {
    useState
  };
})();
 
//실행 부분
const { useState } = ReactHooks;
 
const Component = () => {
  const [counter, setCounter] = useState(1);
  
  console.log(counter);
  
  if (counter !== 2) {
    setCounter(2); //setter 함수를 통해서 값을 업데이트
  }
};
 
Component(); //1 반환

Component(); //1 반환 원하는 기대값은 2이지만 계속해서 1이 반환...

처음 Component를 실행하면 콘솔에는 의도한 대로 1이 찍힙니다. 하지만 Component를 2번, 3번 실행시켜보면, 계속해서 1이 찍히는 것을 볼 수 있습니다. Component를 실행할 때마다 counter의 값이 1씩 증가하는 것을 기대하며 코드를 작성했지만 이와는 다른 결과를 보여줍니다.

그 원인은 Component를 실행할 때마다 변수 state의 값이 initialValue로 초기화 되기 때문인데요, 변수 stateuseState 함수 내부에 위치하기 때문에 Component를 실행할 때마다 useState 함수가 실행되어 초기화 되기 때문입니다. 이 모습이 앞서 살펴본 일반적인 변수와 생명주기 동작 방식입니다. 클로저를 도입하여 상태 state를 초기화 시키지 않고 바로 이전 값으로 유지시켜보도록 하겠습니다.

 

보통 클로저를 구현하는 방법은 외부 함수의 외부로 중첩 함수를 반환(중첩 함수의 생명주기가 외부 함수의 생명주기보다 길어지도록 하기위함)하여 외부 함수의 변수(이곳에서는 변수 state)와 중첩 함수간의 참조 관계를 형성하는 것을 통해 클로저를 구현할 수 있습니다.

const ReactHooks = (() => {
  let state; // 변수 state가 실행될 때마다 초기화 되지 않도록 useState 함수의 상위 스코프에 선언
  
  const useState = (initialValue) => {
    if (state === undefined) {
      state = initialValue;
    }
    
    const setterFunction = (newValue) => {
    	state = newValue
    };
   
   return [state, setterFunction];
  };
  
  return {
    useState,
  };
})();
 
//실행 부분
const { useState } = ReactHooks;
 
const Component = () => {
  const [counter, setCounter] = useState(1);
  
  console.log(counter);
  
  if (counter !== 2) {
    setCounter(2);
  }
};
 
Component(); //1
Component(); //2

이렇게 하면 우리가 원하는대로 이전 상태 값을 기억하며 동작하는 것을 볼 수 있습니다.

하지만 여기서 한 가지 더 고려할 사항이 있습니다. 실제 우리는 하나의 상태만을 사용하지 않는 다는 것입니다. 대부분의 경우, 어플리케이션은 무수히 많은 상태값을 갖게 됩니다. 따라서 관리해야할 상태 값들은 배열의 형태로 관리하는 것이 바람직 한 형태가 될 것 입니다. 이를 반영하여 코드를 작성해보겠습니다.

const ReactHooks = (() => {
  let state = []; //여러개의 상태를 관리하기 위해 state를 배열의 형태로 만듦
  
  let index = 0; //useState 함수 외부에 index를 선언하여 사용하면 클로저가 형성되기 때문에
  		//useState 함수를 실행해도 index 값이 0으로 초기화 되지 않음.
  
  const useState = (initialValue) => {
    const localIndex = index; //이전의 인덱스를 지역 스코프에서 사용할 인덱스에 저장
    index++;
    
    if (state[localIndex] === undefined) {
      state[localIndex] = initialValue;
    }
    
    const setterFunction = (newValue) => {
      state[localIndex] = newValue;
    };
   
   return [state[localIndex], setterFunction];
  };
  
  const resetIndex = () => {
    index = 0;
  };
  
  return {
    useState,
    resetIndex,
  };
})();
 
//실행 부분
const { useState, resetIndex } = ReactHooks;
 
const Component = () => {
  const [counter, setCounter] = useState(1);
  
  console.log(counter);
  
  if (counter !== 2) {
    setCounter(2);
  }
};
 
Component();
resetIndex();
Component();

resetIndex 함수를 사용하여 변수 index를 초기화하는 이유는 하나의 상태는 하나의 인덱스를 통해 관리하는 React Hooks의 특징 때문입니다. 위의 예제코드에서 counter 상태는 인덱스 0(state[0])에서 그 값이 계속해서 관리되어야합니다. 하지만 변수 index를 초기화 시키지 않으면 9번째 줄에서 계속해서 index의 값이 증가하므로 Component를 실행할 때마다 state 배열에 동일한 상태 값이 추가되게 됩니다. 따라서 같은 상태를 업데이트 하는 경우에는 동일한 인덱스를 사용해야 하므로 컴포넌트를 렌더링 하는 과정마다 인덱스를 초기화 해주어야 하는 것입니다. 실제로 React 소스코드를 살펴보면 컴포넌트 렌더링 사이 마다 인덱스를 초기화 하는 작업이 진행됩니다.

이런 이유로 React Hooks 사용 관련 규칙 중 훅(hooks)을 반복문, 조건문 등의 문법 내부에서 사용하는 것을 지양하도록 하는데 이는 조건문과 반복문 내에서 훅(hooks)을 사용할 경우, 의도치 않게 상태를 담아두는 배열의 인덱스에 영향을 주어 적절하지 못한 상태를 참조하게 되는 이유가 있기 때문입니다.

 

참고

https://ko.reactjs.org/docs/hooks-intro.html

https://overreacted.io/ko/how-are-function-components-different-from-classes/

https://www.youtube.com/watch?v=1VVfMVQabx0&t=153s

https://goidle.github.io/react/in-depth-react-hooks_1/

'FRONTEND' 카테고리의 다른 글

useMemo  (1) 2024.12.15
useCallback  (0) 2024.12.14
useState - setState에 대해서  (0) 2024.12.13
useEffect  (0) 2024.12.11
[NextJS] 공식문서 읽어보기 Learn - SEARCH ENGINE OPTIMIZATION - Crawling and Indexing  (0) 2023.05.26