만다라트 협업 앱을 만들며 고민한 실시간 상태 관리 전략

붕붕·2025년 4월 2일
post-thumbnail

만다라트는 9x9 그리드 형태로 되어 있다. 각각의 박스를 셀이라고 칭하고, 3x3, 즉 하나의 주제에 대한 박스를 블록이라고 부른다. 우리는 여기서 Supabase의 Realtime 기능을 활용하여 실시간 협업 기능을 구현하려고 한다. 그렇다면 상태(state)는 어떻게 관리하는 것이 좋을까?

1. 각각의 셀을 컴포넌트로 만들기

각 셀을 컴포넌트로 만들고, 각자 상태를 가지는 구조이다.

function Cell({ initialValue }) {
  const [value, setValue] = useState(initialValue);

  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

이 경우 중앙 블록의 각각 주제들은 나머지 블록의 중심 주제가 되기 때문에
이 둘을 연결해줄 방법이 필요하다.

공통된 데이터를 공유하는 셀 컴포넌트는 동일한 외부 상태를 props로 받고, 변경도 그 쪽으로 전파되게 만든다.

구현 방법은 다음과 같다.

  1. 외부에서 중앙 블록의 상태를 정의한다.
const [centerBlock, setCenterBlock] = useState(Array(9).fill(""));
  1. 셀 컴포넌트 내부에서 공통 셀인지 판별하는 로직을 작성한다.
// value, onChange 부분 예시
const value = isShared ? sharedValue : localValue;
const handleChange = (e) => {
    const newVal = e.target.value;
    if (isShared) {
      onSharedChange(newVal);
    } else {
      setLocalValue(newVal);
    }
};
  1. 공통 셀인지 여부와 공통 셀일 경우 value와 onChange를 props로 내려준다.

하지만 공통 셀을 배열로 관리할 경우 결국 블록 단위로 묶여 있기 때문에 하나의 배열 내 state를 동시에 수정하면 병합 충돌 문제가 발생할 수 있다.
→ 따라서 개별 state로 관리한다면 공통 셀도 개별적으로 관리해야 한다.

장점

  1. 렌더링 최적화 가능 : 수정 중인 셀만 렌더링되기 때문에 전체 렌더링보다 훨씬 효율적이다.

  2. 병합 충돌 문제 해결 : 상태를 각각 나누어 관리하므로 배열/객체 구조에서 발생하는 충돌을 피할 수 있다.

  3. 유지보수 간결성 : 하나의 셀 컴포넌트를 재사용하기 때문에 관리가 쉽다.

단점

  1. 공통 셀 판별 필요 : 어떤 셀이 공유 셀인지 직접 지정해줘야 하므로 81개의 셀 정보를 직접 작성해줘야 하는 번거로움이 있다.

  2. 저장 기능 구현 난이도 : 전체 81개의 상태를 모아서 저장해야 하므로 다소 번거로울 수 있다.

2. 블록 단위로 관리

하나의 블록을 컴포넌트로 만들고 state를 객체나 배열로 만들어 관리하는 방식이다.

function Block({ blockId }) {
  const [cells, setCells] = useState(Array(9).fill(""));

  return (
    <>
      {cells.map((value, i) => (
        <input
          key={i}
          value={value}
          onChange={(e) => {
            const next = [...cells];
            next[i] = e.target.value;
            setCells(next);
          }}
        />
      ))}
    </>
  );
}

이 경우에도 마찬가지로 공통 셀은 중앙에서 상태를 관리하고 주변 블록의 중심 셀은 props로만 내려주는 형식으로 연결하면 된다.

장점

  1. 관리해야 할 state 수가 줄어든다 : 블록 단위로 묶이기 때문에 전체 상태 수가 줄고 구조가 간단해진다.

  2. 컴포넌트가 9개로 제한되므로 관리가 간편하다.

  3. 초기화 및 저장 시 유리하다 : 블록 단위로 한 번에 처리할 수 있다.

단점

  1. 병합 충돌 문제 : 동시에 하나의 블록을 수정할 경우 한쪽의 변경 사항이 날아갈 수 있다.

  2. 렌더링 비효율 : 셀 하나만 바뀌어도 블록 전체가 리렌더링되는 구조이다.

병합 충돌 문제

앞서 말했듯이, 하나의 블록을 동시에 수정할 경우 충돌이 발생할 수 있다.
이 문제를 해결하기 위해 CRDT나 OT 알고리즘을 구현할 수 있다.

하지만 직접 구현하기에는 난이도가 높기 때문에 조금 더 간단한 방식으로 해결할 수 있다.

방법

  1. isEditing state를 정의하여 입력 중인 셀에는 서버에서 온 값을 반영하지 않는다.

  2. 서버에서 수신된 값은 시간을 비교하여 항상 최신 값만 임시 저장해둔다.

  3. 입력이 끝난 후, 저장된 최신 값을 UI에 반영한다.

하지만 이렇게 구성하면 입력 중에는 상대방의 수정 내용을 실시간으로 확인할 수 없다.

→ “이럴 거면 realtime을 왜 쓰나?” 라는 의문도 생긴다.

이를 보완하기 위해, 상대방의 수정 값을 state에는 반영하지 않고 UI에만 표시하는 방식도 있다.
예를 들어, 회색 글씨로 실시간 값을 표시하되 실제 상태에는 내 입력이 끝난 뒤에 반영되도록 한다.

하지만 이 방식은 결국 추가적인 state가 필요해진다.
→ 상대방의 입력 상태를 저장할 pendingValue, editing, conflict 같은 state들이 늘어나게 된다.


그래서 오히려 각 셀을 개별 state로 관리하는 것이 더 나을 수 있다는 생각이 든다.

결국 CRDT를 구현해야 병합 충돌을 완벽히 해결할 수 있지만 그럴 경우 Supabase는 단순한 DB 역할만 하게 된다.
→ Supabase의 Realtime 기능은 의미가 없어진다.

현재로써 Supabase의 Realtime 기능을 가장 효율적으로 활용하기 위해서는
👉 각 셀을 개별 컴포넌트로 분리하고 독립적인 상태로 관리하는 방식이 최선이라고 판단된다.

profile
프론트엔드 개발자(가 되고 싶은 대학생)

0개의 댓글