Learning
레슨 8 / 9·25분

실전: 메모 앱 만들기

프로젝트 구조

지금까지 배운 React의 핵심 개념(컴포넌트, State, Props, Hooks, Context)을 활용해 메모 앱을 만듭니다.

  • 메모 추가, 수정, 삭제 기능
  • 검색 필터링
  • Context로 상태 관리
  • localStorage 영속화
  • 컴포넌트 분리와 타입 안전성

타입과 Context 정의

tsx
// types.ts
type Memo = {
  id: string;
  title: string;
  content: string;
  createdAt: number;
  updatedAt: number;
};

// MemoContext.tsx
import { createContext, useContext, useReducer, useEffect } from 'react';

type Action =
  | { type: 'ADD'; payload: { title: string; content: string } }
  | { type: 'UPDATE'; payload: { id: string; title: string; content: string } }
  | { type: 'DELETE'; payload: string }
  | { type: 'LOAD'; payload: Memo[] };

function memoReducer(state: Memo[], action: Action): Memo[] {
  switch (action.type) {
    case 'ADD':
      return [...state, {
        id: crypto.randomUUID(),
        ...action.payload,
        createdAt: Date.now(),
        updatedAt: Date.now(),
      }];
    case 'UPDATE':
      return state.map(m =>
        m.id === action.payload.id
          ? { ...m, ...action.payload, updatedAt: Date.now() }
          : m
      );
    case 'DELETE':
      return state.filter(m => m.id !== action.payload);
    case 'LOAD':
      return action.payload;
    default:
      return state;
  }
}

Provider 구현

tsx
const MemoContext = createContext<{
  memos: Memo[];
  dispatch: React.Dispatch<Action>;
}>({ memos: [], dispatch: () => {} });

export function MemoProvider({ children }: { children: React.ReactNode }) {
  const [memos, dispatch] = useReducer(memoReducer, []);

  // 초기 로드
  useEffect(() => {
    const saved = localStorage.getItem('memos');
    if (saved) {
      dispatch({ type: 'LOAD', payload: JSON.parse(saved) });
    }
  }, []);

  // 변경 시 저장
  useEffect(() => {
    localStorage.setItem('memos', JSON.stringify(memos));
  }, [memos]);

  return (
    <MemoContext.Provider value={{ memos, dispatch }}>
      {children}
    </MemoContext.Provider>
  );
}

export const useMemos = () => useContext(MemoContext);

메모 목록 컴포넌트

tsx
function MemoList() {
  const { memos, dispatch } = useMemos();
  const [query, setQuery] = useState('');

  const filtered = useMemo(() =>
    memos
      .filter(m =>
        m.title.includes(query) || m.content.includes(query)
      )
      .sort((a, b) => b.updatedAt - a.updatedAt),
    [memos, query]
  );

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="메모 검색..."
      />
      <p>{filtered.length}개의 메모</p>

      {filtered.map(memo => (
        <div key={memo.id} className="memo-card">
          <h3>{memo.title}</h3>
          <p>{memo.content.slice(0, 100)}</p>
          <small>
            {new Date(memo.updatedAt).toLocaleString('ko-KR')}
          </small>
          <button onClick={() =>
            dispatch({ type: 'DELETE', payload: memo.id })
          }>
            삭제
          </button>
        </div>
      ))}
    </div>
  );
}

메모 작성 폼

tsx
function MemoForm({ editMemo }: { editMemo?: Memo }) {
  const { dispatch } = useMemos();
  const [title, setTitle] = useState(editMemo?.title || '');
  const [content, setContent] = useState(editMemo?.content || '');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim()) return;

    if (editMemo) {
      dispatch({
        type: 'UPDATE',
        payload: { id: editMemo.id, title, content },
      });
    } else {
      dispatch({
        type: 'ADD',
        payload: { title, content },
      });
    }
    setTitle('');
    setContent('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={e => setTitle(e.target.value)}
        placeholder="제목"
        required
      />
      <textarea
        value={content}
        onChange={e => setContent(e.target.value)}
        placeholder="내용을 입력하세요"
        rows={5}
      />
      <button type="submit">
        {editMemo ? '수정' : '추가'}
      </button>
    </form>
  );
}

// App.tsx
function App() {
  return (
    <MemoProvider>
      <div className="app">
        <h1>메모 앱</h1>
        <MemoForm />
        <MemoList />
      </div>
    </MemoProvider>
  );
}
💡

이 프로젝트에서 사용한 패턴(Context + useReducer + 커스텀 Hook)은 중소 규모 React 앱에서 가장 많이 사용되는 상태 관리 패턴입니다.