Zustand 소프트 삭제 — enumerable:false로 컴포넌트 크래시 없이 처리하기

6 min read
Zustand상태 관리JavaScriptReact패턴
Zustand 소프트 삭제 — enumerable:false로 컴포넌트 크래시 없이 처리하기

삭제된 항목을 참조하는 컴포넌트가 크래시를 일으킨다. 전형적인 문제다.

서버에서 "이 항목 삭제됨" 신호가 오면, 클라이언트 상태에서 즉시 제거하는 게 직관적이다. 그런데 아직 그 항목의 ID를 들고 있는 컴포넌트가 존재한다면? undefined 접근, 렌더 에러, 최악의 경우 화면 전체가 날아간다.

이 문제를 JavaScript property descriptor 하나로 해결하는 패턴이 있다.

핵심 아이디어: enumerable:false

JavaScript 객체의 프로퍼티에는 눈에 안 보이는 속성들이 있다. 그 중 enumerablefalse이면, 해당 프로퍼티는 반복에서 제외된다.

enumerable 동작 예시
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(설명자 변경 가능 여부)를 제어한다.

서버 신호 처리

서버가 삭제 이벤트를 이렇게 보낸다고 가정하자:

WebSocket 삭제 신호
{
  "id": "task-1",
  "entityType": "task",
  "__isDeleted": true
}

Zustand 레지스트리 내부의 parse 메서드에서 이 플래그를 감지한다:

store/entityRegistry.ts
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),
    })),
}));

컴포넌트에서 사용

목록을 렌더링하는 컴포넌트는 아무것도 바뀌지 않는다:

components/TaskList.tsx
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로 특정 항목에 접근하는 컴포넌트도 그냥 동작한다:

components/TaskItem.tsx
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을 하는 안티패턴을 살펴보고, 상태를 쓰는 곳 가까이 두는 편이 왜 더 나은지 정리한다.

React상태 관리

Coaction v1.0 — Web Worker로 멀티스레딩 상태 관리하기

JavaScript 단일 스레드 한계를 극복하는 상태 관리 라이브러리 Coaction의 동작 방식, Zustand와의 차이, Standard/Shared 두 가지 모드 사용법을 정리한다.

CoactionWeb Worker

JavaScript 이미지 프리로딩 — 5가지 방법 비교

new Image, link preload, hidden div, Cache API, fetch — 각 프리로딩 방식의 장단점과 상황별 선택 기준을 정리한다.

JavaScript성능

JavaScript 물리 엔진 만들기 — 100줄로 구현하는 2D 물리 시뮬레이션

벡터 연산, 원 충돌 감지, 충격량 기반 응답까지 순수 JavaScript로 2D 물리 엔진을 직접 만든다.

Physics EngineCanvas

Angular 1에서 React로 — Strangler 패턴으로 1년간 점진적 마이그레이션한 이야기

대규모 티켓 플랫폼을 Angular 1에서 React로 마이그레이션하면서 적용한 Strangler 패턴과 7가지 교훈을 정리한다.

ReactAngular

React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가

React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.

ReactReact Compiler

Native JSON Modules — 번들러 없이 JSON을 import하는 시대

Import Attributes와 함께 표준이 된 native JSON module. 어떻게 동작하고, 기존 번들러 방식과 뭐가 다른지 정리했다.

JavaScriptESM

Babel 7.29.0 — 10년 역사의 마지막 마이너, 그리고 8 RC1

2026년 1월 31일, Babel 7의 마지막 마이너 릴리스가 공개됐다. 이 버전이 갖는 역사적 의미와 Babel 8 RC1의 핵심 변화를 정리한다.

BabelJavaScript