본문 바로가기

간단한 할 일 목록 앱 만들기

할 일의 추가·완료 전환·삭제 상태를 설계하고 폼·리스트·key·조건부 렌더링을 결합해 Todo 앱을 완성합니다.

지금까지 우리는 리액트 컴포넌트의 심화 개념을 깊이 있게 학습했습니다.

조건부 렌더링, 리스트와 key 사용, 폼 다루기, useEffect 훅까지 익혔으니, 이제 이 지식을 활용해 작지만 기능적인 애플리케이션을 직접 만들어 볼 시간입니다.

이번 실습에서는 간단한 할 일 목록(Todo List) 애플리케이션을 만들어 봅니다.

이 앱은 다음과 같은 기능을 가집니다.

  • 새로운 할 일 추가
  • 할 일 목록 렌더링
  • 각 할 일 완료 상태 토글 (체크박스)
  • 할 일 삭제

이 실습을 통해 배운 개념이 실제 코드에서 어떻게 맞물리는지 체감하고, 이후 기능 확장 연습으로 자연스럽게 이어가면 됩니다.


실습 목표: Todo 상태 흐름 안정화

이번 실습은 완성 예제를 따라 입력과 목록 상태가 어떻게 바뀌는지 확인하는 방식으로 진행합니다.

특히 key 선택과 상태 업데이트 방식이 UI 일관성에 미치는 영향을 점검합니다.

useState 훅을 이용한 상태 관리: 할 일 목록 배열과 입력 필드의 값을 관리합니다.

폼 다루기: 새로운 할 일을 입력받는 <input> 필드와 제출 버튼을 제어 컴포넌트로 구현합니다.

리스트 렌더링과 key: 할 일 목록을 map() 함수와 고유한 key를 사용하여 효율적으로 렌더링합니다.

이벤트 처리: 할 일 추가, 완료 상태 토글, 삭제 버튼 클릭 이벤트를 처리합니다.

조건부 렌더링: 할 일의 완료 상태에 따라 텍스트에 취소선을 긋는 등의 시각적 변화를 줍니다.

(선택 사항) useEffect 활용: 초기 할 일 목록을 로드하거나, 할 일 목록이 변경될 때마다 로컬 스토리지에 저장하는 등의 부수 효과를 구현합니다.


준비 단계: 상태 모델 설계

준비 단계에서는 상태 경계를 먼저 정리합니다.

로컬 UI 상태처럼 단순한 값은 useState로 충분하지만, 저장/복원 규칙이 늘어나는 시점부터는 상태 흐름을 명시적으로 분리해 관리하는 편이 안전합니다.

Vite로 생성된 프로젝트가 있다고 가정합니다. 아래 원문 파일명은 .js지만 Vite에서는 JSX 파일을 .jsx로 저장합니다. localStorage 예제는 접근이 허용되고 저장된 JSON이 올바른 Todo 배열인 경우를 전제로 하며 복구·스키마 검증은 구현하지 않습니다.

src 폴더에 components 폴더를 만들고, 그 안에 TodoList.js 파일을 만들고 App.js에서 이를 렌더링하는 방식으로 진행하겠습니다.

src/App.js 수정 (기존 내용 대체)
src/App.js
import React from 'react';
import './App.css'; // 필요하다면 CSS 파일을 유지합니다.
import TodoList from './components/TodoList'; // TodoList 컴포넌트 불러오기

function App() {
  return (
    <div className="App">
      <h1>나의 멋진 Todo List</h1>
      <TodoList />
    </div>
  );
}

export default App;
src/components/TodoList.js 파일 생성 및 기본 구조 작성
src/components/TodoList.js
import React, { useState, useEffect } from 'react';

function TodoList() {
  // 1. 상태 정의: 할 일 목록 배열 (todoItems)과 새 할 일 입력 값 (newTodo)
  const [todoItems, setTodoItems] = useState(() => {
    // (선택 사항) 로컬 스토리지에서 초기 할 일 목록 불러오기
    const savedTodos = localStorage.getItem('todoItems');
    return savedTodos ? JSON.parse(savedTodos) : [];
  });
  const [newTodo, setNewTodo] = useState('');

  // 2. (선택 사항) todoItems가 변경될 때마다 로컬 스토리지에 저장
  useEffect(() => {
    localStorage.setItem('todoItems', JSON.stringify(todoItems));
  }, [todoItems]); // todoItems가 의존성 배열에 있으므로, 변경될 때마다 실행

  // 3. 새 할 일 입력 필드 변경 핸들러
  const handleInputChange = (e) => {
    setNewTodo(e.target.value);
  };

  // 4. 할 일 추가 핸들러
  const handleAddTodo = (e) => {
    e.preventDefault(); // 폼 제출 시 페이지 새로고침 방지
    if (newTodo.trim() === '') return; // 빈 값은 추가하지 않음

    const newId = todoItems.length > 0 ? Math.max(...todoItems.map(item => item.id)) + 1 : 1;
    const newTodoItem = {
      id: newId,
      text: newTodo,
      completed: false, // 초기에는 완료되지 않은 상태
    };

    setTodoItems([...todoItems, newTodoItem]); // 기존 목록에 새 할 일 추가
    setNewTodo(''); // 입력 필드 초기화
  };

  // 5. 할 일 완료 상태 토글 핸들러
  const handleToggleComplete = (id) => {
    setTodoItems(
      todoItems.map((item) =>
        item.id === id ? { ...item, completed: !item.completed } : item
      )
    );
  };

  // 6. 할 일 삭제 핸들러
  const handleDeleteTodo = (id) => {
    setTodoItems(todoItems.filter((item) => item.id !== id));
  };

  // 7. JSX 렌더링
  return (
    <div style={{ maxWidth: '500px', margin: '30px auto', padding: '20px', border: '1px solid #eee', borderRadius: '10px', boxShadow: '0 2px 10px rgba(0,0,0,0.1)' }}>
      {/* 할 일 추가 폼 */}
      <form onSubmit={handleAddTodo} style={{ display: 'flex', marginBottom: '20px' }}>
        <input
          type="text"
          value={newTodo}
          onChange={handleInputChange}
          placeholder="새 할 일을 입력하세요..."
          style={{ flexGrow: 1, padding: '10px', fontSize: '16px', border: '1px solid #ddd', borderRadius: '5px 0 0 5px' }}
        />
        <button type="submit" style={{ padding: '10px 15px', fontSize: '16px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '0 5px 5px 0', cursor: 'pointer' }}>
          추가
        </button>
      </form>

      {/* 할 일 목록 */}
      {todoItems.length === 0 ? (
        <p style={{ textAlign: 'center', color: '#666' }}>아직 할 일이 없습니다. 새 할 일을 추가해 보세요!</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {todoItems.map((item) => (
            <li
              key={item.id}
              style={{
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-between',
                padding: '10px',
                borderBottom: '1px dashed #eee',
                backgroundColor: item.completed ? '#e6ffe6' : 'white', // 조건부 스타일링
              }}
            >
              <div style={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
                <input
                  type="checkbox"
                  checked={item.completed}
                  onChange={() => handleToggleComplete(item.id)}
                  style={{ marginRight: '10px', transform: 'scale(1.2)' }}
                />
                <span
                  style={{
                    fontSize: '18px',
                    textDecoration: item.completed ? 'line-through' : 'none', // 조건부 렌더링 (취소선)
                    color: item.completed ? '#999' : '#333',
                  }}
                >
                  {item.text}
                </span>
              </div>
              <button
                onClick={() => handleDeleteTodo(item.id)}
                style={{
                  backgroundColor: '#dc3545',
                  color: 'white',
                  border: 'none',
                  padding: '5px 10px',
                  borderRadius: '5px',
                  cursor: 'pointer',
                  fontSize: '14px',
                }}
              >
                삭제
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default TodoList;

코드 설명 및 핵심 개념 복습

위 TodoList.js 코드는 우리가 배운 여러 개념을 통합하여 사용하고 있습니다.

useState를 이용한 상태 관리
  • todoItems: 현재 할 일 목록을 담는 배열 상태입니다. 각 할 일 객체는 id, text, completed 속성을 가집니다.
  • newTodo: 새로운 할 일을 입력받는 <input> 필드의 값을 관리하는 상태입니다.
폼 다루기 (제어 컴포넌트)
  • handleInputChange: <input> 필드의 onChange 이벤트에 연결되어 newTodo 상태를 실시간으로 업데이트합니다. value={newTodo}를 통해 input 필드가 newTodo 상태에 의해 제어됩니다.
  • handleAddTodo: <form>의 onSubmit 이벤트에 연결됩니다. e.preventDefault()로 페이지 새로고침을 막고, newTodo를 todoItems 배열에 추가한 후 newTodo를 초기화합니다. 새로운 할 일에는 id를 부여하여 나중에 key로 사용합니다.
리스트 렌더링과 key
  • todoItems.map((item) => ...): todoItems 배열을 순회하며 각 할 일 아이템을 <li> JSX 요소로 변환합니다.
  • key={item.id}: 각 <li> 요소에 할 일 객체의 고유 id를 key로 부여합니다. 이는 리액트가 목록을 효율적으로 업데이트하는 데 필수적입니다.
이벤트 처리
  • handleAddTodo, handleToggleComplete, handleDeleteTodo 함수들이 각각의 버튼 클릭이나 체크박스 변경 이벤트에 연결되어 있습니다.
  • handleToggleComplete, handleDeleteTodo는 id를 인자로 받아 특정 할 일에 대한 작업을 수행합니다. map() 함수 내부에서 onClick={() => handleDeleteTodo(item.id)}와 같이 화살표 함수로 감싸 인자를 전달하는 방식이 사용되었습니다.
조건부 렌더링
  • todoItems.length === 0 ? <p> ... </p> : <ul> ... </ul>: todoItems가 비어 있으면 할 일이 없습니다 메시지를, 그렇지 않으면 목록을 렌더링합니다. (삼항 연산자)
  • backgroundColor: item.completed ? '#e6ffe6' : 'white': 할 일이 완료되면 배경색을 변경합니다.
  • textDecoration: item.completed ? 'line-through' : 'none': 할 일이 완료되면 텍스트에 취소선을 적용합니다. (삼항 연산자)
  • checked={item.completed}: 체크박스의 checked 속성을 item.completed 상태와 연결하여 제어 컴포넌트로 만듭니다.
(선택 사항) useEffect 활용 (로컬 스토리지)
  • 첫 번째 useState의 초기값 설정 부분에서 localStorage.getItem('todoItems')를 통해 이전에 저장된 할 일 목록이 있다면 불러와 todoItems의 초기값으로 사용합니다.
  • 두 번째 useEffect 훅은 todoItems 상태가 변경될 때마다 ([todoItems] 의존성 배열 때문에) localStorage.setItem을 사용하여 현재의 todoItems 배열을 로컬 스토리지에 JSON 문자열 형태로 저장합니다. 이렇게 하면 페이지를 새로고침해도 할 일 목록이 유지됩니다.

검증 순서: 추가·토글·삭제

실습 검증은 추가 -> 토글 -> 삭제 -> 새로고침 순서로 고정합니다.

새로고침까지 확인하려면 삭제 후에도 남아 있는 항목을 관찰해야 합니다. 전부 삭제하여 빈 목록만 확인하면, 저장된 항목이 다음 로딩에서 복원되는지 구별하기 어렵습니다.

예시: 항목 하나를 남겨 저장 확인하기

이 사이트와 같은 저장소를 쓰는 다른 실습 데이터가 없는 별도 연습 프로젝트의 빈 todoItems 키를 시작 조건으로 합니다. 기존 데이터가 있다면 먼저 보관하고, 초기 목록이 빈 상태인지 확인하세요. 원래 코드의 저장 접근 허용·유효한 할 일 배열 조건도 그대로 적용합니다.

동작다음 목록과 확인할 표시
복습 추가ID 1, 복습, 미완료. 입력은 비어 있음
예제 추가ID 1 복습과 ID 2 예제, 둘 다 미완료
복습의 체크박스 선택ID 1 복습만 완료, 체크와 취소선 표시
예제 삭제ID 1 복습·완료가 남고 ID 2만 제거
저장 Effect가 끝난 뒤 새로고침ID 1 복습·완료가 다시 표시

ID는 목록의 key로 사용하여 같은 항목을 연결합니다. 목록의 추가, 완료 토글, 삭제는 서로 다른 입력에 대한 연산입니다. 새로고침에서는 이전 메모리 상태가 유지되는 것이 아니라, 저장된 JSON을 초기 상태로 다시 읽습니다.

위의 App.js와 TodoList.js 코드를 각각의 파일에 복사하여 붙여 넣으세요.

개발 서버가 실행 중이지 않다면, 프로젝트 폴더에서 npm run dev (또는 yarn dev) 명령어를 실행합니다.

브라우저를 열어 http://localhost:5173 (Vite 기본 포트)에 접속합니다.

새로운 할 일을 입력하고 추가 버튼을 클릭해보세요.

추가된 할 일 옆의 체크박스를 클릭하여 완료 상태를 토글해보고, 삭제 버튼을 클릭하여 할 일을 제거해보세요.

(선택 사항) 페이지를 새로고침했을 때 할 일 목록이 유지되는지 확인해 보세요.

개발자 도구(F12)의 Console 탭과 Components 탭을 활용하여 state와 props의 변화를 관찰해 보세요.

독립 연습: ID와 공백 입력을 추적하기

유효한 목록이 ID 4와 ID 7을 가진 두 항목이라고 가정합니다. ID 7을 삭제한 뒤 새 항목을 추가하면 어떤 ID를 부여하나요? 이후 공백 세 칸만 입력하고 추가했을 때 목록과 입력칸은 어떻게 되나요? 마지막으로 앞뒤 공백이 있는 " 복습 "을 입력하면 저장할 문자열 자체도 잘라내는지 원래 핸들러에서 확인하세요.

원래 코드의 조건으로 풀이

ID 7을 제거하면 현재 최댓값은 4이므로 새 ID는 5입니다. 이 규칙은 현재 목록 안의 중복을 피하며, 과거에 사용한 모든 ID를 영구히 기억하는 규칙은 아닙니다.

공백 세 칸은 trim 결과가 빈 문자열이므로 조기 반환합니다. 목록은 바뀌지 않고, 뒤에 있는 입력 초기화도 실행하지 않아 입력칸의 공백은 남습니다. " 복습 "은 trim 결과가 비어 있지 않아 추가되지만, text 속성에는 원래 입력 문자열을 저장하므로 앞뒤 공백을 포함합니다. 유효성 판단에 trim을 사용하는 일과 잘라낸 값을 저장하는 일을 구별합니다.

JSON 문자열이 파싱된다는 사실만으로 할 일 배열 형식까지 검증되는 것은 아닙니다. 예를 들어 null도 유효한 JSON이지만 이 예제가 요구하는 객체 배열은 아닙니다. 원래 실습은 유효한 저장 배열을 전제로 하므로, 손상된 데이터의 복구와 저장 권한 오류 처리는 별도 확장 과제입니다.

간단한 Todo 앱도 입력값, 목록, 완료 여부, 삭제 흐름을 분리해서 보면 React 상태 업데이트의 핵심 패턴을 익히기 좋습니다.


이 실습에서는 조건부 렌더링, 리스트 렌더링, 폼 다루기, useEffect가 함께 동작하는 Todo 흐름을 구현했습니다.

Todo 상태 전이 — 상태 추적

추가/토글/삭제에서 목록/초안/항목 참조/저장 값이 바뀐다. 실제 앱의 독립 동작과 검증 시나리오 순서를 구분하고 손상 JSON 복구는 구현된 것으로 쓰지 않는다.

추가·토글·삭제는 같은 목록에 적용하는 독립적인 변경입니다

TODO: THREE OPERATIONS

추가·토글·삭제는 같은 목록에 적용하는 독립적인 변경입니다

newTodo는 입력 초안, todoItems는 저장할 목록입니다. 각 동작은 새 배열을 만들고 React가 다음 화면을 표시합니다. 검증 순서와 사용자가 선택하는 동작을 구분합니다.

동일한 목록에서 고르는 독립 동작

동일한 목록에서 고르는 독립 동작추가→토글→삭제는 실습 검증 순서입니다. 실제 앱에서는 세 동작을 독립적으로 선택합니다. trim 거부·원 text·id·유효 저장값의 전제는 아래 원설명에 보존합니다.EffecttodoItems현재 목록추가새 항목토글대상만새 객체삭제대상 id 제외새 배열React 다음 UIJSON 저장새로고침 초기값
동일한 목록에서 고르는 독립 동작추가→토글→삭제는 실습 검증 순서입니다. 실제 앱에서는 세 동작을 독립적으로 선택합니다. trim 거부·원 text·id·유효 저장값의 전제는 아래 원설명에 보존합니다.EffecttodoItems현재 목록추가새 항목토글대상만새 객체삭제대상 id 제외새 배열React 다음 UIJSON 저장새로고침 초기값

추가→토글→삭제는 실습 검증 순서입니다. 실제 앱에서는 세 동작을 독립적으로 선택합니다. trim 거부·원 text·id·유효 저장값의 전제는 아래 원설명에 보존합니다.

원래 설명과 상세 기준

추가 · 새 항목 붙이기

전: [] / newTodo: "복습"
후: [{id:1, text:"복습",
      completed:false}]
입력 초안: ""

공백뿐인 입력은 trim으로 거부하지만 저장할 text는 원래 newTodo입니다. ID는 현재 최대값+1, 빈 목록이면 1입니다.

토글 · 대상만 새 객체

대상 id: 1
전: completed:false
후: completed:true
나머지 항목: 그대로

map으로 같은 id의 객체만 복사해 completed를 반전합니다. checked·취소선·배경이 같은 상태를 읽습니다.

삭제 · 대상만 제외

전: [id1, id2]
삭제 대상: id1
후: [id2]

filter로 대상 id를 제외합니다. id2의 key는 유지되며, 삭제가 새 입력을 만들거나 완료 토글을 요구하지는 않습니다.

다음 렌더와 저장

todoItems → map / key={item.id}
목록 변경 → [todoItems] Effect
           → localStorage JSON

초기 state는 저장값을 읽습니다. 유효한 Todo 배열과 저장소 접근이 가능한 환경을 전제로 하며 손상 JSON 복구는 본문에 없습니다.

실습 검증 · 사용자 동작과 구별

추가→토글→삭제→새로고침은 테스트 순서입니다. 실제 앱은 토글과 삭제를 각각 선택합니다. 모든 항목을 지운 뒤 새 항목의 ID가 다시 1이 될 수 있어도 현재 형제 목록 안에서는 고유합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명공백뿐인 입력은 trim으로 거부하지만 저장할 text는 원래 newTodo입니다. ID는 현재 최댓값+1, 빈 목록이면 1입니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
추가목록: id1 복습 false / 초안: 빈 문자열빈 목록과 복습 초안에서 id1 항목을 만든 뒤 초안을 비운다trim 거절 조건과 저장하는 원 text를 구분한다
토글id1: true / 다른 항목: 참조/값 유지id1만 새 객체로 completed:true가 된다map이 대상 객체만 바꾼다
삭제와 저장삭제: 대상 제외 / 저장: todoItems JSONid1 삭제 후 남은 목록을 다음 렌더에 반영하고 Effect로 저장한다순서는 검증 시나리오이며 실제 동작은 독립 선택이고 손상 JSON 복구는 없다

Todo 상태 전이 — 상태 추적

추가/토글/삭제에서 목록/초안/항목 참조/저장 값이 바뀐다. 실제 앱의 독립 동작과 검증 시나리오 순서를 구분하고 손상 JSON 복구는 구현된 것으로 쓰지 않는다.

추가·토글·삭제는 같은 목록에 적용하는 독립적인 변경입니다

TODO: THREE OPERATIONS

추가·토글·삭제는 같은 목록에 적용하는 독립적인 변경입니다

newTodo는 입력 초안, todoItems는 저장할 목록입니다. 각 동작은 새 배열을 만들고 React가 다음 화면을 표시합니다. 검증 순서와 사용자가 선택하는 동작을 구분합니다.

동일한 목록에서 고르는 독립 동작

동일한 목록에서 고르는 독립 동작추가→토글→삭제는 실습 검증 순서입니다. 실제 앱에서는 세 동작을 독립적으로 선택합니다. trim 거부·원 text·id·유효 저장값의 전제는 아래 원설명에 보존합니다.EffecttodoItems현재 목록추가새 항목토글대상만새 객체삭제대상 id 제외새 배열React 다음 UIJSON 저장새로고침 초기값
동일한 목록에서 고르는 독립 동작추가→토글→삭제는 실습 검증 순서입니다. 실제 앱에서는 세 동작을 독립적으로 선택합니다. trim 거부·원 text·id·유효 저장값의 전제는 아래 원설명에 보존합니다.EffecttodoItems현재 목록추가새 항목토글대상만새 객체삭제대상 id 제외새 배열React 다음 UIJSON 저장새로고침 초기값

추가→토글→삭제는 실습 검증 순서입니다. 실제 앱에서는 세 동작을 독립적으로 선택합니다. trim 거부·원 text·id·유효 저장값의 전제는 아래 원설명에 보존합니다.

원래 설명과 상세 기준

추가 · 새 항목 붙이기

전: [] / newTodo: "복습"
후: [{id:1, text:"복습",
      completed:false}]
입력 초안: ""

공백뿐인 입력은 trim으로 거부하지만 저장할 text는 원래 newTodo입니다. ID는 현재 최대값+1, 빈 목록이면 1입니다.

토글 · 대상만 새 객체

대상 id: 1
전: completed:false
후: completed:true
나머지 항목: 그대로

map으로 같은 id의 객체만 복사해 completed를 반전합니다. checked·취소선·배경이 같은 상태를 읽습니다.

삭제 · 대상만 제외

전: [id1, id2]
삭제 대상: id1
후: [id2]

filter로 대상 id를 제외합니다. id2의 key는 유지되며, 삭제가 새 입력을 만들거나 완료 토글을 요구하지는 않습니다.

다음 렌더와 저장

todoItems → map / key={item.id}
목록 변경 → [todoItems] Effect
           → localStorage JSON

초기 state는 저장값을 읽습니다. 유효한 Todo 배열과 저장소 접근이 가능한 환경을 전제로 하며 손상 JSON 복구는 본문에 없습니다.

실습 검증 · 사용자 동작과 구별

추가→토글→삭제→새로고침은 테스트 순서입니다. 실제 앱은 토글과 삭제를 각각 선택합니다. 모든 항목을 지운 뒤 새 항목의 ID가 다시 1이 될 수 있어도 현재 형제 목록 안에서는 고유합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명공백뿐인 입력은 trim으로 거부하지만 저장할 text는 원래 newTodo입니다. ID는 현재 최댓값+1, 빈 목록이면 1입니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
추가목록: id1 복습 false / 초안: 빈 문자열빈 목록과 복습 초안에서 id1 항목을 만든 뒤 초안을 비운다trim 거절 조건과 저장하는 원 text를 구분한다
토글id1: true / 다른 항목: 참조/값 유지id1만 새 객체로 completed:true가 된다map이 대상 객체만 바꾼다
삭제와 저장삭제: 대상 제외 / 저장: todoItems JSONid1 삭제 후 남은 목록을 다음 렌더에 반영하고 Effect로 저장한다순서는 검증 시나리오이며 실제 동작은 독립 선택이고 손상 JSON 복구는 없다