본문 바로가기
project/reactify

Auto Batching, 전역 상태 관리

by qjatjs123123 2025. 2. 18.



안녕하세요!

'Reactify 개발기 1부 - 컴포넌트 구현'에 이어, 이번에는 상태 관리에 대해

 

그 과정에서 고민했던 아이디어와 직면한 문제, 그리고 이를 해결하기 위한 방안들을 공유해보려고 합니다.

 

 


 

상태관리

컴포넌트 내에서 state관리는 React에서 사용되는 데이터 단방향 흐름을 채택했습니다.

 

단방향 데이터 바인딩이란 데이터가 한 방향 (부모 → 자식)으로 데이터가 전달됩니다. 자식은 부모의 데이터를 props을 통해 읽기만 할 수 있고, 수정은 부모가 제공한 콜백함수를 통해서만 수정할 수 있습니다.

 

저는 이러한 부분을 클래스의 private, 생성자를 통해 해결했습니다.

private필드를 사용해서 부모 뿐만 아니라 다른 컴포넌트의 state변수에 접근하지 못하도록 하였고,

생성자를 통해서 자식에게 props를 넘겨주었습니다.

 

코드를 통해 살펴봅시다.

 

export abstract class View<T> {
  #state: { [key: string]: any }; 
  protected viewStore;
  key = 0;
  _element: HTMLElement | null = null;
  queue: any[];
  isBatching:boolean = false;

  constructor(public props: T) {
    this.#state = {};  
    this.key = 0;
    this.viewStore = ViewStore.getInstance();
    this.queue = [];
    
  }

  setState(key: string, value: any) {
    if (Object.is(this.state[key], value)) return;

    this.queue.push([key, value]);
    if (!this.isBatching) {
      this.isBatching = true;

      Promise.resolve().then(() => {
        this.flush();
      })
    }
    
  getState(key: string): any {
    return this.#state[key];
  }

    ...

 

View(컴포넌트)라는 추상 클래스를 선언하여 공통적인 속성과 메서드를 정의한 클래스입니다.

여기서 state를 private Field로 선언하여 외부에서는 접근 못하도록 정의하였고,

오로지 setState, getState함수를 통해서만 접근을 할 수 있는 것을 볼 수 있습니다.

 

 

this.arrowPrevBtnView = new ArrowBtnView({content: "&#8592;", className: "arrow-prev", func: () => this.prevCallback.bind(this)});
this.arrowNextBntView = new ArrowBtnView({content: "&#8594;", className: "arrow-next", func: () => this.nextCallback.bind(this)})

또한, 생성자 함수에 props를 전달하여 데이터의 단방향 흐름을 구현할 수 있습니다.

 

 

 

여기서,

React처럼 state가 변하면 재랜더링 되도록 구현하였는데요,

여기서 React에서 사용되는 최적화 기법 2가지를 적용하였습니다.

  • 기존 state와 새로운 state가 동일한 경우, 렌더링을 건너뛴다
  • setState가 연속으로 호출되더라도, 한 번만 렌더링을 수행하는 배치처리

 


 

 

최적화 기법 1. 기존 state와 새로운 state가 동일한 경우, 렌더링을 건너뛴다

React는 데이터 상태 변화에 따라 렌더링을 합니다.

여기서 기존 state와 새로운 state가 변경이 없다면 굳이 렌더링을 할 필요가 없습니다.

저는 React 철학을 참고하여 데이터를 Immutable하게 관리하고 비교하였습니다.

 

왜냐하면 모든 객체를 순회하여 변경 여부를 확인하는 것은 성능에 큰 부담을 줄 수 있기 때문에, 참조값만 비교하여 변경 여부를 판단하는 방식이 더 효율적이기 때문입니다.

 

 

if (Object.is(this.state[key], value)) return;

 

Object.is를 사용해 기존 state와 새로운 state의 참조값을 비교하고, 변경이 없을 경우 연산을 생략하여 성능을 최적화하였습니다.

 

 

 


 

 

최적화 기법 2.  setState 함수 배치처리

만약 setState가 실행할 때마다 렌더링이 발생된다면 성능에 악영향을 끼칠 수 있습니다.

React에서는 이러한 문제를 배치처리를 통해 해결하였는데요

저도 setState 배치처리를 적용해보았습니다.

  setState(key: string, value: unknown) {
    if (Object.is(this.state[key], value)) return;

    this.queue.push([key, value]);
    if (!this.isBatching) {
      this.isBatching = true;

      Promise.resolve().then(() => {
        this.flush();
      })
    }
  }

  private flush() {
    while (this.queue.length > 0) {
      const [key, value] = this.queue.shift();

      this.state[key] = value;
    }
    this.isBatching = false;
    this._element?.replaceWith(this.render()!);
  }

 

 

 

동작하는 과정은 다음과 같습니다.

 

1. setState 함수가 호출되면  Promise.resolve()로 비동기 작업을 예약합니다.

2. flush 함수는 이벤트 루프의 동작에 따라 현재 실행 중인 동기 코드가 모두 끝날 때까지 마이크로태스크 큐에서 기다립니다.
3. 그 동안 여러 번의 setState가 호출되어 값이 변경될 때마다 변경된 상태는 큐에 저장됩니다.
4. 동기 코드가 모두 실행된 후, 콜스택이 비어있다면, 이벤트 루프는 대기 중인 flush함수를 실행시킵니다.

5. flush 함수가 큐에 쌓인 상태들을 한 번에 변경하고, 그 후 렌더링을 진행하게 됩니다.

 

이 과정을 통해 상태 변경이 모아서 처리되므로, 불필요한 렌더링을 줄여 최적화된 방식으로 동작합니다.

 

 

 

(Auto Batching)

 

이벤트 핸들러 내부에 비동기 함수가 포함된 경우, React 17 이하 버전에서는 배치 처리를 수동으로 해주어야 하는 문제가 있었습니다.

 

이를 해결하기 위해 Promise를 활용하여 콜 스택이 비워지는 타이밍에 자동으로 배치 처리가 이뤄지도록 구성하였고, 결과적으로 비동기 함수의 상태 변경도 자연스럽게 배치 처리될 수 있도록 만들었습니다.

 

배치 타이밍 = 콜 스택이 비워지는 시점, 즉 마이크로태스크 큐가 실행되는 시점 

 

 

 


 

 

 

전역 상태 관리

React에서 props drilling 문제를 해결하기 위해 Recoil, Redux와 같은 다양한 상태 관리 라이브러리를 사용하셨을 겁니다.

 

저는 이러한 전역 상태 관리를 옵저버 패턴(Observer Pattern)으로 해결하였습니다.

핵심적인 아이디어는 여러 View(컴포넌트)가 state를 구독하고, state가 변경되면 구독한 모든 View(컴포넌트)가 렌더링되는 방식입니다.

 

코드를 통해 살펴봅시다.

import { View } from "./View";

export class StateStore {
  #state!: Record<string, unknown>;  
  #subscribers!: Record<string, Set<View<any>>>;
  static #instance : StateStore | null = null;

  constructor() {
    if (StateStore.#instance) {
      return StateStore.#instance;
    }

    this.#state = {};
    this.#subscribers = {};

    StateStore.#instance = this;
  }

  static getInstance() {
    if (!StateStore.#instance) {
      StateStore.#instance = new StateStore();
    }
    return StateStore.#instance;
  }

  enroll<T>(key: string, state: T): void {
    this.#state[key] = state;
  }

  subscribe(key: string, component: View<any>) {
    if (!this.#subscribers[key]) {
      this.#subscribers[key] = new Set();
    }
    this.#subscribers[key].add(component);
  }

  #notify(key : string) { 
    if (this.#subscribers[key]) {
      this.#subscribers[key].forEach((component) => {
        component._element?.replaceWith(component.render()!);
      });
    }
  }

  setState(key: string, newValue: unknown) {
    const currentValue = this.#state[key];

    if (JSON.stringify(currentValue) === JSON.stringify(newValue)) return;
    this.#state[key] = newValue;
    this.#notify(key);
  }

  getState(key : string) {
    return this.#state[key];
  }
}

 

이 기능은 여러 인스턴스를 만들 수 없으므로 싱글턴 패턴으로 구현해야 합니다.

먼저 enroll() 을 통해 상태와 초기값을 키-값 형식으로 저장하고, 이후 해당 키값을 사용하여 구독할 수 있습니다.
만약 키값이 변경되면, 구독한 모든 View(컴포넌트)에게 notify() 를 실행하여 렌더링을 하는 로직입니다.

 

 

하지만 이 방법은 메모리 측면에서 개선이 필요합니다.

 

React에서는 컴포넌트 언마운트 개념이 있지만, 기본적으로 경로 변경 시 기존 컴포넌트가 메모리에서 완전히 해제되지 않기 때문에, 여러 페이지를 방문하면 불필요한 이전 컴포넌트들이 여전히 메모리를 차지하게 됩니다.

 

이로 인해 메모리 누수 문제가 발생할 수 있습니다.

 

따라서 언마운트 컴포넌트 자체를 null로 설정하면 해당 컴포넌트와 관련된 참조들도 null로 바뀌게 되어, 불필요한 메모리 사용을 줄이고 메모리 문제를 개선할 수 있을 것입니다.

 

 

 

 

감사합니다.

피드백, 질문 환영입니다.