만다라트는 9x9 그리드 형태로 되어 있다. 각각의 박스를 셀이라고 칭하고, 3x3, 즉 하나의 주제에 대한 박스를 블록이라고 부른다. 우리는 여기서 Supabase의 Realtime 기능을 활용하여 실시간 협업 기능을 구현하려고 한다. 그렇다면 상태(state)는 어떻게 관리하는 것이 좋을까?
각 셀을 컴포넌트로 만들고, 각자 상태를 가지는 구조이다.
function Cell({ initialValue }) {
const [value, setValue] = useState(initialValue);
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
이 경우 중앙 블록의 각각 주제들은 나머지 블록의 중심 주제가 되기 때문에
이 둘을 연결해줄 방법이 필요하다.
공통된 데이터를 공유하는 셀 컴포넌트는 동일한 외부 상태를 props로 받고, 변경도 그 쪽으로 전파되게 만든다.
구현 방법은 다음과 같다.
const [centerBlock, setCenterBlock] = useState(Array(9).fill(""));
// value, onChange 부분 예시
const value = isShared ? sharedValue : localValue;
const handleChange = (e) => {
const newVal = e.target.value;
if (isShared) {
onSharedChange(newVal);
} else {
setLocalValue(newVal);
}
};
하지만 공통 셀을 배열로 관리할 경우 결국 블록 단위로 묶여 있기 때문에 하나의 배열 내 state를 동시에 수정하면 병합 충돌 문제가 발생할 수 있다.
→ 따라서 개별 state로 관리한다면 공통 셀도 개별적으로 관리해야 한다.
렌더링 최적화 가능 : 수정 중인 셀만 렌더링되기 때문에 전체 렌더링보다 훨씬 효율적이다.
병합 충돌 문제 해결 : 상태를 각각 나누어 관리하므로 배열/객체 구조에서 발생하는 충돌을 피할 수 있다.
유지보수 간결성 : 하나의 셀 컴포넌트를 재사용하기 때문에 관리가 쉽다.
공통 셀 판별 필요 : 어떤 셀이 공유 셀인지 직접 지정해줘야 하므로 81개의 셀 정보를 직접 작성해줘야 하는 번거로움이 있다.
저장 기능 구현 난이도 : 전체 81개의 상태를 모아서 저장해야 하므로 다소 번거로울 수 있다.
하나의 블록을 컴포넌트로 만들고 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로만 내려주는 형식으로 연결하면 된다.
관리해야 할 state 수가 줄어든다 : 블록 단위로 묶이기 때문에 전체 상태 수가 줄고 구조가 간단해진다.
컴포넌트가 9개로 제한되므로 관리가 간편하다.
초기화 및 저장 시 유리하다 : 블록 단위로 한 번에 처리할 수 있다.
병합 충돌 문제 : 동시에 하나의 블록을 수정할 경우 한쪽의 변경 사항이 날아갈 수 있다.
렌더링 비효율 : 셀 하나만 바뀌어도 블록 전체가 리렌더링되는 구조이다.
앞서 말했듯이, 하나의 블록을 동시에 수정할 경우 충돌이 발생할 수 있다.
이 문제를 해결하기 위해 CRDT나 OT 알고리즘을 구현할 수 있다.
하지만 직접 구현하기에는 난이도가 높기 때문에 조금 더 간단한 방식으로 해결할 수 있다.
방법
isEditing state를 정의하여 입력 중인 셀에는 서버에서 온 값을 반영하지 않는다.
서버에서 수신된 값은 시간을 비교하여 항상 최신 값만 임시 저장해둔다.
입력이 끝난 후, 저장된 최신 값을 UI에 반영한다.
하지만 이렇게 구성하면 입력 중에는 상대방의 수정 내용을 실시간으로 확인할 수 없다.
→ “이럴 거면 realtime을 왜 쓰나?” 라는 의문도 생긴다.
이를 보완하기 위해, 상대방의 수정 값을 state에는 반영하지 않고 UI에만 표시하는 방식도 있다.
예를 들어, 회색 글씨로 실시간 값을 표시하되 실제 상태에는 내 입력이 끝난 뒤에 반영되도록 한다.
하지만 이 방식은 결국 추가적인 state가 필요해진다.
→ 상대방의 입력 상태를 저장할 pendingValue, editing, conflict 같은 state들이 늘어나게 된다.
그래서 오히려 각 셀을 개별 state로 관리하는 것이 더 나을 수 있다는 생각이 든다.
결국 CRDT를 구현해야 병합 충돌을 완벽히 해결할 수 있지만 그럴 경우 Supabase는 단순한 DB 역할만 하게 된다.
→ Supabase의 Realtime 기능은 의미가 없어진다.
현재로써 Supabase의 Realtime 기능을 가장 효율적으로 활용하기 위해서는
👉 각 셀을 개별 컴포넌트로 분리하고 독립적인 상태로 관리하는 방식이 최선이라고 판단된다.