Zustand 소프트 삭제 — enumerable:false로 컴포넌트 크래시 없이 처리하기
JavaScript property descriptor의 enumerable 플래그를 활용해 삭제된 엔티티를 투명하게 처리하는 Zustand 패턴을 소개한다.
삭제된 항목을 참조하는 컴포넌트가 크래시를 일으킨다. 전형적인 문제다.
서버에서 "이 항목 삭제됨" 신호가 오면, 클라이언트 상태에서 즉시 제거하는 게 직관적이다. 그런데 아직 그 항목의 ID를 들고 있는 컴포넌트가 존재한다면? undefined 접근, 렌더 에러, 최악의 경우 화면 전체가 날아간다.
이 문제를 JavaScript property descriptor 하나로 해결하는 패턴이 있다.
핵심 아이디어: enumerable:false
JavaScript 객체의 프로퍼티에는 눈에 안 보이는 속성들이 있다. 그 중 enumerable이 false이면, 해당 프로퍼티는 반복에서 제외된다.
const obj = Object.defineProperties({}, {
'task-1': {
value: { id: 'task-1', title: 'Task 1' },
enumerable: true
},
'task-2': {
value: { id: 'task-2', title: 'Task 2' },
enumerable: false // 삭제된 항목
},
});
Object.keys(obj); // ['task-1'] — task-2는 안 보임
Object.values(obj); // [{ id: 'task-1', title: 'Task 1' }]
obj['task-2']; // { id: 'task-2', title: 'Task 2' } — 직접 접근은 됨이게 전부다. 삭제된 항목을 실제로 제거하는 게 아니라, 반복에서만 숨긴다. 목록을 렌더링하는 컴포넌트에는 보이지 않지만, ID로 직접 접근하는 컴포넌트는 여전히 데이터를 받는다.
[💡 잠깐! 이 용어는?]
Property Descriptor: JavaScript 객체 프로퍼티의 메타데이터. enumerable(반복 포함 여부), writable(값 변경 가능 여부), configurable(설명자 변경 가능 여부)를 제어한다.
서버 신호 처리
서버가 삭제 이벤트를 이렇게 보낸다고 가정하자:
{
"id": "task-1",
"entityType": "task",
"__isDeleted": true
}Zustand 레지스트리 내부의 parse 메서드에서 이 플래그를 감지한다:
import { create } from 'zustand';
type Entity = { id: string; [key: string]: unknown };
type Registry<T extends Entity> = Record<string, T>;
function defineEntity<T extends Entity>(
registry: Registry<T>,
entity: T & { __isDeleted?: boolean }
): Registry<T> {
const isDeleted = '__isDeleted' in entity;
const nextRegistry = { ...registry };
Object.defineProperty(nextRegistry, entity.id, {
value: entity,
enumerable: !isDeleted, // 삭제되면 숨김
writable: true,
configurable: true,
});
return nextRegistry;
}
interface TaskStore {
tasks: Registry<Task>;
upsertTask: (task: Task & { __isDeleted?: boolean }) => void;
}
export const useTaskStore = create<TaskStore>((set) => ({
tasks: {},
upsertTask: (task) =>
set((state) => ({
tasks: defineEntity(state.tasks, task),
})),
}));컴포넌트에서 사용
목록을 렌더링하는 컴포넌트는 아무것도 바뀌지 않는다:
function TaskList() {
const tasks = useTaskStore((state) => Object.values(state.tasks));
// Object.values는 enumerable:false 항목을 자동으로 제외
return (
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} taskId={task.id} />
))}
</ul>
);
}ID로 특정 항목에 접근하는 컴포넌트도 그냥 동작한다:
function TaskItem({ taskId }: { taskId: string }) {
const task = useTaskStore((state) => state.tasks[taskId]);
// 삭제된 항목이어도 undefined가 아닌 실제 값을 반환
if (!task) return null;
return <div>{task.title}</div>;
}삭제 로직이 레지스트리 안에 캡슐화되어 있다. 소비하는 컴포넌트는 삭제를 신경 쓸 필요가 없다.
이 패턴이 해결하는 것
| 문제 | 기존 방식 | enumerable:false 패턴 |
|---|---|---|
| 삭제 후 ID 참조 컴포넌트 | 크래시 | 정상 동작 |
| 목록 렌더링 | 삭제 항목 필터링 로직 필요 | 자동 제외 |
| 컴포넌트 코드 | 삭제 상태 인식 필요 | 인식 불필요 |
| 구현 복잡도 | 각 컴포넌트마다 처리 | 레지스트리 한 곳에서 처리 |
주의할 점
이 패턴에는 트레이드오프가 있다.
첫째, 메모리 누수 가능성이다. 삭제된 항목이 실제로 제거되지 않아서 시간이 지나면 쌓인다. 주기적인 정리 로직이 필요할 수 있다.
둘째, 직관성이 낮다. 팀 전체가 이 패턴을 알아야 한다. 모르는 개발자가 Object.keys()로 디버깅하면 "왜 이 항목이 없지?"라고 혼란스러워할 수 있다.
셋째, spread 연산자로 복사하면 enumerable:false 항목이 사라진다. 상태를 복사할 때 { ...state.tasks } 대신 Object.defineProperty를 통한 복사가 필요하다.
마무리
enumerable:false는 잘 알려지지 않은 JavaScript 기능이지만, 특정 상황에서는 매우 우아한 해결책이 된다. 삭제 로직을 단 한 곳에 캡슐화하고, 컴포넌트는 그 사실을 몰라도 된다. 실시간 데이터 동기화가 많은 앱이라면 고려해볼 만한 패턴이다.
참고: