Zustand 소프트 삭제 — enumerable:false로 컴포넌트 크래시 없이 처리하기
삭제된 항목을 참조하는 컴포넌트가 크래시를 일으킨다. 전형적인 문제다.
서버에서 "이 항목 삭제됨" 신호가 오면, 클라이언트 상태에서 즉시 제거하는 게 직관적이다. 그런데 아직 그 항목의 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 기능이지만, 특정 상황에서는 매우 우아한 해결책이 된다. 삭제 로직을 단 한 곳에 캡슐화하고, 컴포넌트는 그 사실을 몰라도 된다. 실시간 데이터 동기화가 많은 앱이라면 고려해볼 만한 패턴이다.
참고:
관심 있을 만한 포스트
React 상태 올리기 — 대부분의 경우 하지 않는 게 낫다
React에서 습관적으로 lift state up을 하는 안티패턴을 살펴보고, 상태를 쓰는 곳 가까이 두는 편이 왜 더 나은지 정리한다.
Coaction v1.0 — Web Worker로 멀티스레딩 상태 관리하기
JavaScript 단일 스레드 한계를 극복하는 상태 관리 라이브러리 Coaction의 동작 방식, Zustand와의 차이, Standard/Shared 두 가지 모드 사용법을 정리한다.
JavaScript 이미지 프리로딩 — 5가지 방법 비교
new Image, link preload, hidden div, Cache API, fetch — 각 프리로딩 방식의 장단점과 상황별 선택 기준을 정리한다.
JavaScript 물리 엔진 만들기 — 100줄로 구현하는 2D 물리 시뮬레이션
벡터 연산, 원 충돌 감지, 충격량 기반 응답까지 순수 JavaScript로 2D 물리 엔진을 직접 만든다.
Angular 1에서 React로 — Strangler 패턴으로 1년간 점진적 마이그레이션한 이야기
대규모 티켓 플랫폼을 Angular 1에서 React로 마이그레이션하면서 적용한 Strangler 패턴과 7가지 교훈을 정리한다.
React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가
React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.
Native JSON Modules — 번들러 없이 JSON을 import하는 시대
Import Attributes와 함께 표준이 된 native JSON module. 어떻게 동작하고, 기존 번들러 방식과 뭐가 다른지 정리했다.
Babel 7.29.0 — 10년 역사의 마지막 마이너, 그리고 8 RC1
2026년 1월 31일, Babel 7의 마지막 마이너 릴리스가 공개됐다. 이 버전이 갖는 역사적 의미와 Babel 8 RC1의 핵심 변화를 정리한다.