Reactive한 React 구현해보기

React를 이용해 스프레드 시트같은 기능을 어떻게 잘 만들 수 있을 지 궁금했다. 회사에서 만드는 UI 에디터가 스프레드시트같은 면이 있어서, 한 컴포넌트가 다른 컴포넌트의 변수를 참조할 수 있다. 스프레드시트에서 한 셀이 다른 셀의 값을 참조해 새로운 값을 연산하는 것과 비슷하다.

스프레드 시트의 가장 큰 특징이라고 하면, 각 셀의 값이 업데이트됨에 따라 자신을 사용하는(의존하는) 셀에서도 항상 최신의 값으로 업데이트 된다는 것이다. 이런 방식을 Reactive Programming의 한 방식이라고 한다.

React에서 반응성을 얻는 것과는 조금 다른데, React는 상태가 바뀌면 해당 컴포넌트 아래의 모든 컴포넌트를 다시 계산하는 방식으로 최신의 상태를 반영한다. 컴포넌트가 다시 계산되는 과정에서 최신의 상태를 기반으로 리렌더링 되도록 해 일종의 반응성을 얻게 되는 것이다.

나는 이 방식이 조금 느슨한 방식이라고 생각하는데, 특정 상태가 업데이트 될 때 상태와 관련된 컴포넌트, 값만 추적해 업데이트하는 것이 아니라 상태가 업데이트된 컴포넌트부터 하위 트리 전체를 다시 계산시켜버리는 것이다. 그래서 한 컴포넌트 내에 있으면 서로 관련없는 상태라도, 업데이트될 때 다시 계산된다. 다만 개별 상태값에 대한 반응성을 보장하기보다 UI의 반응성을 보장하면서 여러 업데이트가 배칭되어 한꺼번에 처리한다던가, 백그라운드 업데이트를 통해 취소될 수 있는 업데이트를 한다던가 하는 유연성이 보장될 수 있지 않았을까. 값은 또 다른 값이 참조할 수 있으므로 업데이트가 한꺼번에 처리하는 등에는 한계가 있을 것 같다. 물론 내 개인적인 추측이다.

스프레드 시트에서와 같은 반응형 프로그래밍을 React에서는 자연적으로 구현하기 힘든데, React 자체가 Runtime에 의존성을 parsing해서 추적하지 않으니 당연하다. 엇비슷하게나마 구현하려고 해도 순환참조를 막기 힘들다. 최근 자주 쓰이는 Signals 방식으로 상태가 관리되면 어느정도 될 수 있지 않을까..?

다만 React에서 반응형 프로그래밍을 구현할 수 없다는 것은 아니고, 다른 반응형 프로그래밍 구현체처럼 외부에서 구현하고 React와는 통합하는 방식으로 구성해볼 수 있을 것 같다. 이렇게 말만 써놓을 게 아니고 작은 어플리케이션 하나를 만들어보고 반응형 프로그래밍을 구동할 수 있도록 만들어보려고 한다.

미니 스프레드시트 만들기

mini-spreadsheet간이로 75개의 셀만 구현한 미니 스프레드시트

역시 직접 만들어보는게 좋을 것 같다. 간단하게 스프레드 시트를 껍데기만 먼저 만들어봤다. 내부 기능은 React 내부에서 처리되지 않고 외부에서 반응형으로 값을 처리하고 이를 React와 통합하려고 한다.

React에서 외부 API를 통합할 수 있는 도구로 useSyncExternalStore 를 써보려고 한다. 전역 상태관리나 URL, LocalStorage 같은 외부 API를 상태로써 통합될 수 있도록 사용할 수 있는 아주 고마운 훅이다. 변경이 일어났을 때 실행할 callback을 등록할 수만 있으면 어떤 API도 가능하다.

import { useCallback, useSyncExternalStore } from "react";
import { useSheet } from "./useSheet";

export function useCell(cellId: string) {
  const sheet = useSheet();

  const cellData = useSyncExternalStore(
    // sheet에 callback을 등록한다.
    useCallback((onStoreChange) => sheet.register(onStoreChange), [sheet]),
    // callback이 실행되면 값을 가져온다. 동등성 비교로 컴포넌트를 업데이트 한다.
    () => sheet.cells.get(cellId),
  );

  const updateCell = useCallback((input: string) => {
    sheet.updateCellInput(cellId, input);
  }, [sheet, cellId]);

  return [cellData, updateCell] as const;
}

sheetupdateCellInput은 일단 단순히 값을 업데이트하는 방식으로 구현했다. 업데이트한 후 등록된 callback을 실행해 기본적인 셀 편집이 되도록 했다.

class Sheet {
  // ...sheet
  private callbacks: Set<(() => void)>;
  private dispatch(): void {
    this.callbacks.forEach((callback) => callback());
  }

  updateCellInput(cellId: string, input: string): void {
    const cell = this.cells.get(cellId);

    if (cell == null) {
      throw new Error('cell not found');
    }

    this.cells.set(cellId, {
      ...cell,
      input,
      output: input,
    });

    this.dispatch();
  }
}

셀 평가하기

스프레드 시트는 ’=‘로 시작하는 셀의 내용은 연산으로 인식한다. ‘=1 + 2 + 3’같이 숫자 연산을 할 수도 있고 ‘=A1 + B1’처럼 셀의 계산을 할 수도 있다. 이걸 구현해보려고 하는데, 어쩌다보니 조그마한 인터프리터(interpreter)를 만들게 되었다. 컴파일러/인터프리터는 만들어본 적이 없었어서 다른 구현(Super Tiny Compiler1)을 참고해서 만들었다. 스프레드 시트에서 지원하는 모든 함수나 모든 연산을 지원할 수는 없고, 덧셈 뺄셈 정도만 구현했다. 곱셈과 나눗셈은 연산자 우선순위가 생겨 조금 더 복잡해져 제외했다.

구조를 간단하게 이야기하자면 interpreter는 tokenizer → parser → interpreter 형태로 실행되는데, token화 단계에서 cell을 인식해 parser에서 Cell이 어떤 값인지 가져와, interpreter에서 계산되도록 했다.

import interpret from "./interpreter";
import parse from "./parser";
import tokenize from "./tokenizer";

function evaluate(input: string, scope: Map<string, unknown>): unknown {
  const tokens = tokenize(input);
  const ast = parse(tokens, scope);
  const result = interpret(ast);

  return result;
}

셀의 반응성을 보장하기

셀에 의존하는 다른 셀에서 항상 최신의 셀을 보장할 수 있도록 작업해주자. 셀이 업데이트될 때 변경되는 내용이 다른 셀에도 전파하는 방식으로 구현하면 된다. 이걸 위해서는 자신을 의존하는 셀이 어떤 셀인지 알아야하는데, 셀이 업데이트될 때마다 의존하는 셀을 추출한다. 위의 token화 단계에서 cell token을 분류한 덕분에 쉽게 가능했다.

import { tokenize } from "./tokenizer";

function extractCellDependencies(input: string): string[] {
  return tokenize(input)
    .filter((token) => token.type === 'cell')
    .map((token) => token.value);
}

셀의 dependency를 추출하고, 의존하는 셀의 dependents에도 추가해준다. 셀의 변경이 다른 셀이도 전파되는 동작은 propagateCellChanges 를 구현해 해결했다.

updateCellInput(cellId: string, input: string): void {
  const cell = this.cells.get(cellId);

  if (cell == null) {
    throw new Error('cell not found');
  }

  // 추가되는 의존 셀에 dependents를 추가해주고, 없어지는 셀에는 제거해준다
  const newDependencies = extractCellDependencies(input);
  const oldDependencies = extractCellDependencies(cell.input);

  for (const dependencyCellId of new Set([...newDependencies, ...oldDependencies])) {
    if (!oldDependencies.includes(dependencyCellId)) {
      const dependencyCell = this.cells.get(dependencyCellId);

      if (dependencyCell == null) {
        throw new Error('dependency cell not found');
      }

      this.cells.set(dependencyCellId, {
        ...dependencyCell,
        dependents: [...dependencyCell.dependents, cellId],
      });
    } else if (!newDependencies.includes(dependencyCellId)) {
      const dependencyCell = this.cells.get(dependencyCellId);

      if (dependencyCell == null) {
        throw new Error('dependency cell not found');
      }

      this.cells.set(dependencyCellId, {
        ...dependencyCell,
        dependents: dependencyCell.dependents.filter((dependent) => dependent !== cellId),
      });
    }

    this.propagateCellChanges(cellId, input);
  }

propagateCellChanges는 재귀적으로 cell을 거쳐 셀의 변화를 전파하도록 했다. 여기까지 구현하면 셀이 변경될 때마다 변경사항이 전파되는 것을 확인할 수 있다. cell이 변경될 때마다 dispatch를 해줄 필요는 없어보여 sheet가 전파되어 업데이트 된 후, 한 번만 dispatch 되도록 했다.

순환 참조를 막기

sheet가 순수 TypeScript 세계이기 때문에 가능한 것이 하나 더 있는데, 바로 순환참조를 막는 것이다. 셀의 전파 중에 변경되는 cell을 기억하고있으면 순환참조를 감지할 수 있다.

만든 결과물은 GitHub에서도 확인할 수 있다.

https://github.com/0jaaack/mini-spreadsheet

Footnotes

  1. Super Tiny Compiler