기록의 습관화
article thumbnail

useState에 대하여

저번 포스트에서 reRendering에 대해서 다뤘었다.

 

https://ww8007-learn.tistory.com/21

 

왜 내 컴포넌트는 reRendering이 될까?

이번 포스트에서는 React Component의 reRendering에 대해서 다뤄보려고 합니다. 리렌더링은 안 좋은 거다. useCallback이나 useMemo를 사용해서 최적화를 해야 한다 등 여러 가지 말들이 정말 많은 주제이죠.

ww8007-learn.tistory.com

 

reRendering에 대한 내용을 작성하면서 디버깅을 진행하던 도중 문득

나는 React에 대해서 제대로 알고 있는 것이 맞을까? 라는 의문이 들어서

공식 문서를 기반으로 hook 들과 동작 원리에 대해서 이해하고 이를 직접 구현해보려고 한다.

어느 순서로 살펴볼지 방향을 지금 정하는 것보다 useState와 시작하여 그와 연관된 hook들 그리고

관련된 JS 내용도 함께 정리하면서 나아가려고 한다.

 

 

일단은 useState 공식 문서부터 읽어보도록 하자

 


공식문서의 useState

useState – React

 

useState – React

The library for web and native user interfaces

react.dev

State: A Component's Memory – React

 

State: A Component's Memory – React

The library for web and native user interfaces

react.dev

 

공식문서에서는 다음과 같이 useState를 소개한다.

 

 

구성 요소에 상태 변수를 추가할 수 있는 React Hook이다.

 

결국 useState의 경우 우리가 React를 사용하면서 사용하는 상태를 만들 수 있게 하는 일종의 상태관리 도구 이다.

타입까지 살펴본다면 정확하게 useState를 어떻게 사용하는지 알 수 있다.

 

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];

 

 

초기 상태를 넘기게 되면

 

  1. 변화를 상태를 tracking 할 수 있게 해주는 get
  2. 상태를 변화시킬 수 있게 하는 set을 주게 된다.

 

그럼 이제 대략 동작을 하도록 구성을 해야 하는지 알았으니 구현부터 해보자

 


useState의 구현

일단 우리는 기본적으로 타입을 정의함으로써 우리가 구현하고 싶은 구현체의 뼈대를 정할 수 있다.

 

type SetState<T> = (newState: T | ((prevState: T) => T)) => void;
type ReturnType<T> = [T, SetState<T>];

export const useState = <T>(initialValue: T): ReturnType<T>

 

  • 다음과 같이 작성하게 되면
  1. 최초 상태를 넘기는 것
  2. Return에서 구조분해 할당을 통해 get, set을 빼올 수 있는 것

이라는 목적을 부합하게 된다.

그럼 내부 구현체도 작성을 해보자

 

import { currentInstance, render } from "./render";

type SetState<T> = (newState: T | ((prevState: T) => T)) => void;
type ReturnType<T> = [T, SetState<T>];

export const useState = <T>(initialValue: T): ReturnType<T> => {
    let state = initialValue;

    const setState: SetState<T> = (newState: T | ((prevState: T) => T)) => {
        state =
            typeof newState === "function"
                ? (newState as (prevState: T) => T)(state)
                : newState;

        if (currentInstance === null) {
            throw new Error("useState는 컴포넌트 함수 내에서만 호출할 수 있습니다.");
        }
        const nextVNode = currentInstance.component(currentInstance.props);
        render(nextVNode, document.getElementById("root")!);
    };

    return [state, setState];
};

 

하지만 예상과는 달리 동작하지 않는다.

 

 

동작하지 않는다

 

이 코드에는 한 가지 문제점이 있다.

그것은 바로 state가 함수 내부에서 관리되고 있기 때문에 setState를 통해 상태를 지정한다 하더라도 그 값이 저장되지 않고 계속해서 같은 값을 보여주게 되는 것이다.

문제를 해결하기 위해서는 JS의 클로저를 이용할 수 있다.

구현을 바꾸기 전에 일단 간단하게 클로저에 대한 개념을 알아보자

 


JS의 클로저

일단 클로저의 경우 난해하기로 유명한 JS의 개념 중 하나이다.

함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성이고

고유 개념 자체는 아니기 때문에 ECMAScript 사양에는 등장하지 않는다.

MDN에서는 다음과 같이 클로저를 설명한다.

클로저 - JavaScript | MDN

 

클로저 - JavaScript | MDN

클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합입니다. 즉, 클로저는 내부 함수에서 외부 함수의 범위에 대한 접근을 제공합니다. JavaScript에서 클로저는 함수 생

developer.mozilla.org

 

 

여기서 핵심은 함수가 선언된 Lexical(어휘적) 환경과의 조합이다.

일단 간단한 예시를 보면서 알아보자.

 

const increase = (() => {
    // 카운트 상태 변수
    let num = 0;

    // 클로저
    return () => {
        // 카운트 상태 변수를 1만큼 증가시킨다.
        return ++num;
    };
})();

console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3

 

아마 이 함수가 클로저의 특징을 가장 잘 보여준 예시라고 볼 수 있다.

일단 return 하는 클로저가 가장 중요한 부분인데

  1. 즉시 실행 함수가 호출되고
  2. 즉시 실행 함수가 반환한 함수가 increase 변수에 할당 되게 된다.
  • 고로 이제 increase 변수에 할당된 함수는 자신이 저장한 위치에 의해 결정된
  • 상위 스코프를 참조하는 클로저가 되게 된다.

 

만약 return에서 num을 참조하지 않았더라면 GC에 의해서 자동으로 해제가 될 테지만

자유변수인 num을 이제 언제 어디서든 호출, 참조, 변경 가능 하게 되는 것이다.

그리고 num의 경우 외부에서 접근이 불가능하기 때문에 → 은닉화된 private 변수

전역 변수로 선언했을 때와 같이 의도되지 않은 변경을 걱정하지 않아도 되어

안전하게 코딩이 가능하다.

 

그럼 이 예제도 클로저 일까?

 

const x = 1;

function outerFunc() {
    const x = 10;
    function innerFunc() {
        console.log(x); // 10
    }

    innerFunc();
}

outerFunc();

함수와 그 함수가 선언된 렉시컬 스코프의 조합 자체는 맞지만

이 경우 innerFunc는 outerFunc의 렉시컬 스코프 자체에 접근 자체는 가능하다

 

  • 하지만 일반적인 클로저의 용도나 이점을 활용하고 있지는 않는다.

 

클로저의 보통 사용 용도는 다음과 같다.

  1. 내부 함수를 반환
  2. 그 함수가 외부 함수의 환경에 대한 기억을 함

 

[[Environment]]에 상위 스코프에 대한 참조를 저장한다.

 

  • 현재 실행 중인 컨텍스트의 렉시컬 환경을 가리킴
  • 함수가 정의가 평가되어 함수 객체를 생성하는 시점
  1. 함수가 정의된 환경 → 상위 함수(또는 전역 코드)가 평가
  2. 실행되고 있는 시점
  • 전역 코드 평가 시점에 실행 중인 실행 컨텍스트의 렉시컬 환경인 저역
  • 렉시컬 환경의 참조가 저장됨

 

그럼 이에 대한 console의 결과는 어떻게 출력이 될까?

 

const x = 1;

function foo() {
    const x = 10;

    // 상위 스코프 : 함수 정의 환경(위치)에 따라 결정됨
    // 함수 호출 위치와 상위 스코프 : 아무런 관계 없음
    bar();
}

// 함수 bar는 자신의 상위 스코프, 즉 : 렉시컬 환경을
// [[Environment]]에 저장하여 기억하게 됨
function bar() {
    console.log(x);
}

foo();
bar();

 

  • 결국 함수는 평가시점에 상위 스코프가 결정되기 때문에
  • 함수를 어디서 호출했는지 보다 어디서 선언했는지가 중요하다.

 


반복문에서의 클로저

JS의 면접 질문이나 클로저의 예시에서 다음과 같은 예시를 본 사람들이 꽤 있을 것이다.

 

function count() {
    for (var i = 0; i < 10; i++) {
        setTimeout(function () {
            console.log(i);
        }, 100);
    }
    return count;
}

count();
  • 이에 대한 실행 결과가 어떻게 될까?

 

단순하게 생각한다면 0 ~ 10이 출력되어야 할 것 같지만

실제는 다르다.

계속 같은 값을 출력한다

 

 

이유는 왜 그럴까?

 

 

바로 setTimeout이 테스크 큐에 등록되기 때문이다.

i가 10으로 for문이 끝난 시점에 평가가 되고 테스크큐에 있는 setTimeout에서 콜백이 실행될 때는

10을 참조하기 때문이다.

 

해결법은?

 

 

1. let을 이용하여 블록레벨 스코프를 가지게 변환

function count() {
    for (let i = 0; i < 10; i++) {
        setTimeout(function () {
            console.log(i);
        }, 100);
    }
}

count();

// TO ES5
function count() {
  var _loop = function _loop(i) {
    setTimeout(function () {
      console.log(i);
    }, 100);
  };
  for (var i = 0; i < 10; i++) {
    _loop(i);
  }
}
count();

 

 

2. forEach와 같은 이터러블을 이용해서 해결

function count() {
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(function(i) {
        setTimeout(function () {
            console.log(i);
        }, 100);
    });
}

count();

3. IIFE(즉시 실행 함수 표현식)을 이용하여 해결

function count() {
	for (var i = 0; i < 10; i++) {
		(function (i) {
			setTimeout(function () {
				console.log(i);
			}, 100);
		})(i);
	}
	return count;
}

count();

 


다시 본론으로

 

useState 상태를 가지도록 수정하기

이제 본 주제로 돌아와서 useState에 클로저의 개념을 도입해 볼 차례이다.

여기서 두 개의 선택지가 존재한다.

  1. 상태를 관리하는 state만 함수 외부로 이동하고 클로저로 동작하게 수정
  2. 팩토리 패턴을 비슷하게 사용하여 createStateFactory를 만들고 이를 활용

 

일단 두 개를 만들어보면서 어떤 차이점을 가지는지 알아보자

 

 

1. 상태를 함수 바깥으로 이동하고 클로저로 이용

import { currentInstance, render } from "./render";

type SetState<T> = (newState: T | ((prevState: T) => T)) => void;
type ReturnType<T> = [T, SetState<T>];

let state: unknown = undefined; // 외부로 이동하고 클로저로 이용

export const useState = <T>(initialValue: T): ReturnType<T> => {
    if (state === undefined) {
        state = initialValue;
    }

    const setState: SetState<T> = (newState: T | ((prevState: T) => T)) => {
        state =
            typeof newState === "function"
                ? (newState as (prevState: T) => T)(state as T)
                : newState;

        if (currentInstance === null) {
            throw new Error("useState는 컴포넌트 함수 내에서만 호출할 수 있습니다.");
        }
        const nextVNode = currentInstance.component(currentInstance.props);
        render(nextVNode, document.getElementById("root")!);
    };

    return [state as T, setState];
};

 

2. 팩토리 패턴을 이용하여 구현

import { currentInstance, render } from "./render";

type SetState<T> = (newState: T | ((prevState: T) => T)) => void;
type ReturnType<T> = [T, SetState<T>];

// 상태 관리 인터페이스 정의
interface StateFactory {
    <T>(initialValue: T): ReturnType<T>;
}

// 팩토리 함수
export function createStateFactory(): StateFactory {
    let state: unknown = undefined;

    return <T>(initialValue: T): ReturnType<T> => {
        if (state === undefined) {
            state = initialValue;
        }

        const setState: SetState<T> = (newState: T | ((prevState: T) => T)) => {
            state =
                typeof newState === "function"
                    ? (newState as (prevState: T) => T)(state as T)
                    : newState;

            if (currentInstance === null) {
                throw new Error("useState는 컴포넌트 함수 내에서만 호출할 수 있습니다.");
            }
            const nextVNode = currentInstance.component(currentInstance.props);
            render(nextVNode, document.getElementById("root")!);
        };

        return [state as T, setState];
    };
}

// 팩토리를 통한 인스턴스 생성
export const useStateWithFactory = createStateFactory();
export const useStateWithFactory2 = createStateFactory();

 

아직도 부족한 점들이 있다.

 

  1. 동일한 파일 내에서 useState를 여러 번 사용할 경우 하나의 변수만을 가지고 생성을 하고 있기 때문에 상태가 공유되어 다른 상태가 변화되면 영향을 끼친다.
  2. 1번과는 다르게 팩토리 패턴을 통해 여러 인스턴스를 생성하고 독립적인 상태를 유지할 수 있다.
  3. 하지만 결국 이렇게 useState가 필요할 때마다 생성해서 내보내줘야 하기 때문에 편의성은 떨어진다.

 

모양새는 갖췄다


각각의 상태를 가지도록 수정하기

그럼 간단하게 배열에 상태를 저장하고 현재의 인덱스를 저장하여

useState의 선언마다 배열에 상태를 추가하고

변경 시에는 자신의 변경값만 수정하도록 수정을 해보자

import { currentInstance, render } from "./render";

type SetState<T> = (newState: T | ((prevState: T) => T)) => void;
type ReturnType<T> = [T, SetState<T>];

let states: unknown[] = [];
export let currentIndex = 0;

export const useState = <T>(initialValue: T): ReturnType<T> => {
    if (states[currentIndex] === undefined) {
        states[currentIndex] = initialValue;
    }

    const setStateIndex = currentIndex;

    const setState: SetState<T> = (newState: T | ((prevState: T) => T)) => {
        states[setStateIndex] =
            typeof newState === "function"
                ? (newState as (prevState: T) => T)(states[setStateIndex] as T)
                : newState;

        if (currentInstance === null) {
            throw new Error("useState는 컴포넌트 함수 내에서만 호출할 수 있습니다.");
        }
        const tempIndex = currentIndex; // 현재 값 저장
        currentIndex = 0;
        const nextVNode = currentInstance.component(currentInstance.props);
        render(nextVNode, document.getElementById("root")!);
        currentIndex = tempIndex; // 원래 값으로 복원
    };

    currentIndex += 1; // 다음 호출을 위해 증가
    return [states[setStateIndex] as T, setState]; // 이전 상태값 반환
};

이렇게 바꾸면 useState를 여러 번 선언한다 하더라도 각각의 독립적인 상태를 가지게 되는 것을 확인할 수 있다.

 

이제 잘 동작 한다

 


다시 공식 문서로

그럼 이제 공식문서의 내용을 기반으로 직접 만든 useState에서 어떤 점들이 부족한지 그리고 useState에 대해서 주의해야 할 점들을 알아보자.

 

1. 이 set함수는 다음 렌더링에 대한 상태 변수만 업데이트합니다. set함수를 호출한 후 상태 변수를 읽으면 호출하기 전에 화면에 있었던 이전 값을 계속 얻을 수 있습니다.

 

  • 이 말은 다음 예제와 함께 보면 더 이해가 쉽다.
const upDate3 = () => {
    console.log(count);
    setCount(count + 1);
    console.log(count);
    setCount(count + 1);
    console.log(count);
    setCount(count + 1);
};

이에 대한 count는 결국 몇이 될까?

정답은 1이다. 클로저를 이용해서 상태를 관리하기 때문에 upDate3 내부에서의 count는 캡처된 상태를 참조하게 된다. 함수 내에서 여러 번 setCount를 호출한다 하더라도 count의 값은 변하지 않는다.

 

만약 바꾸고 싶으면요?

 

const upDate3 = () => {
    console.log(count);
    setCount((prev) => prev + 1);
    console.log(count);
    setCount((prev) => prev + 1);
    console.log(count);
    setCount((prev) => prev + 1);
};

이와 같이 이전 상태값을 빼와서 직접 바꿔주면 된다.

 

2. state제공한 새 값이 비교 결과에 따라 현재 값과 동일하면 [Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)React는 구성 요소와 해당 하위 항목을 다시 렌더링 하는 것을 건너뜁니다. 이것은 최적화입니다. 경우에 따라 React는 하위 요소를 건너뛰기 전에 구성 요소를 호출해야 할 수도 있지만 코드에 영향을 주지는 않습니다.

 

  • 현재 useState의 구현에서 빠져있는 부분이다. 결국 렌더링도 비용 중에 하나이기 때문에 값이 동일하다면 업데이트를 할 필요는 없다. 하지만 Object.is는 얕은 비교 이므로 객체나 배열의 깊은 부분 까지는 같은지 검사하지 못한다.

 

3.React는 상태 업데이트를 일괄 처리합니다. 모든 이벤트 핸들러가 실행되고 해당 기능을 호출한 후에 화면을 업데이트합니다 set. 이렇게 하면 단일 이벤트 중에 여러 번 다시 렌더링 되는 것을 방지할 수 있습니다. 예를 들어 DOM에 액세스 하기 위해 React가 화면을 더 일찍 업데이트하도록 강제해야 하는 드문 경우에는 [flushSync.](https://react.dev/reference/react-dom/flushSync)

 

  • 아까 1번의 예시와 비슷한 부분이다. setCount의 실행마다 렌더링이 일어난다면 비효율적일 것이다.
    React에서는 이렇게 BatchUpdate를 통해 렌더링에 대해 최적화를 제공한다.

 

4. 렌더링 중 set함수 호출은 현재 렌더링 구성 요소 내에서만 허용됩니다. React는 출력을 삭제하고 즉시 새 상태로 다시 렌더링을 시도합니다. 이 패턴은 거의 필요하지 않지만 이전 렌더링의 정보를 저장하는 데 사용할 수 있습니다.아래 예를 참조하세요.

 

  • 이 부분은 예시를 참고하는 것이 좀 더 도움이 된다.
    최악의 경우 렌더링된 값을 기반으로 다른 상태를 업데이트를 할 수 있게 해 준다는 것인데 직접 사용할 일은 없어 보이므로 필요한 사람만 참고하여도 될 듯하다.

 

5. Strict 모드에서 React는 우발적인 불순물을 찾는 데 도움을 주기 위해 업데이트 기능을 두 번 호출합니다. 이는 개발 전용 동작이며 프로덕션에는 영향을 주지 않습니다. 업데이터 기능이 순수하다면(되어야 하는 대로) 동작에 영향을 주지 않습니다. 호출 중 하나의 결과는 무시됩니다.

 

  • 지금은 개선이 되었는지 모르겠지만 이전에는 업데이트 기능을 두 번 호출하여 실제 동작과 다른 결과를 뱉게 되어 Strict 모드를 끄고 개발을 진행해야 했던 경험이 있다.

 


마무리

이렇게 해서 useState의 기본동작과 직접 useState 구현해 보고, JS의 Closure 개념과 각각의 주의사항까지 알아보았다.

직접 useState를 구현해 보면서 closure에 대해서 어느 정도 알고 있다고 생각했는데 어느정도 헷갈렸던 부분들이 많았고 이번 기회에 스스로도 정리가 많이 되었던 거 같다.

아직 최적화의 여지는 너무나도 많이 남아있긴 하지만 이 부분은 다른 hook 들과 렌더링에 대해서 다루면서 그때 진행해보려고 한다. 😃

profile

기록의 습관화

@ww8007

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!