TIL 23. 11. 06

오늘 한 일

  • React 개인 과제 리팩토링

내배캠 React 개인과제 리팩토링

개인 과제인 React 사용해 todolist 만들기 리팩토링을 해보았다. 단순 기능 구현에만 그치지 않고 중복을 줄이고 단일 책임을 갖는 컴포넌트를 만들기, 읽기 좋은 코드 작성을 목표로 하였다.

React 폴더 구조

📦nbc-todolis
 ┣ 📂public
 ┃ ┣ 📜index.html
 ┣ 📂src
 ┃ ┣ 📂components
 ┃ ┃ ┣ 📜AddTodo.jsx
 ┃ ┃ ┣ 📜AddTodo.modules.css
 ┃ ┃ ┣ 📜TodoItem.jsx
 ┃ ┃ ┣ 📜TodoItem.modules.css
 ┃ ┃ ┣ 📜TodoList.jsx
 ┃ ┃ ┗ 📜TodoList.modules.css
 ┃ ┣ 📂static
 ┃ ┃ ┗ 📂fonts
 ┃ ┃ ┃ ┣ 📜NanumMyeongjo-Bold.woff2
 ┃ ┃ ┃ ┗ 📜NanumMyeongjo-Regular.woff2
 ┃ ┣ 📜App.jsx
 ┃ ┣ 📜App.css
 ┃ ┣ 📜index.js
 ┃ ┣ 📜reset.css
 ┃ ┗ 📜variables.css
 ┣ 📜.gitignore
 ┣ 📜package.json
 ┣ 📜README.md
 ┗ 📜yarn.lock
  • public: 정적 파일
  • src/components: 재사용 가능한 컴포넌트들
  • src/static: 이미지, 폰트

컴포넌트 분리

App 컴포넌트는 진입점으로써 애플리케이션의 주요 구조를 정의한다. 이번 과제의 경우는 헤더, 사이드바 등 전체에 적용되는 레이아웃이 없으므로 TodoList 컴포넌트만 포함한다. 루트에서 공유되어야 할 상태가 없으므로 조촐한 모습.

// App.jsx

import React from 'react';
import TodoList from './components/TodoList';

export default function App() {
  return <TodoList />;
}

➡ 글을 작성하며 수정했다. 애플리케이션의 전체 구조를 정의한다는 측면에서 App 컴포넌트가 상태 관리와 이벤트를 처리하도록 변경했다.

// App.jsx

import React, { useState } from 'react';
import TodoList from './components/TodoList';
import AddTodo from './components/AddTodo';

export default function App() {
  const [todos, setToDos] = useState([]);

  const handleAdd = (inserted) => setToDos([...todos, inserted]);
  const handleUpdate = (updated) => {
    updated.isDone = !updated.isDone;
    setToDos(todos.map((todo) => (todo.id === updated.id ? updated : todo)));
  };
  const handleDelete = (deleted) => {
    const result = window.confirm('정말로 삭제하시겠습니까?');
    if (!result) return;

    setToDos(todos.filter((todo) => todo.id !== deleted.id));
  };

  return (
    <main className="container">
      <h1>"나의 할 일 목록"</h1>
      <AddTodo onAddTodo={handleAdd} />
      <TodoList
        todos={todos}
        handleUpdate={handleUpdate}
        handleDelete={handleDelete}
      />
      ;
    </main>
  );
}

차이가 있다면 TodoList 컴포넌트에서 리렌더링 되었던 것이 App 컴포넌트에서 리렌더링된다는 것과 기존에는 레이아웃을 위해 TodoList가 AddTodo를 포함하고 있었는데 TodoList와 동일한 레이어에 배치되었다.

또한 TodoList 컴포넌트가 문서 전체 레이아웃인 main 태그를 담당하고 있었는데 App 컴포넌트에서 레이아웃을 담당함으로 가벼운(?) 블록이 되었다. todolist UI만을 담당하는 컴포넌트로 바뀌었다.

// TodoList.jsx
// Before
import React, { useState } from 'react';
import AddTodo from './AddTodo';
import TodoItem from './TodoItem';
import './TodoList.modules.css';

export default function TodoList() {
  const [todos, setToDos] = useState([]);
  const { doingTodos, doneTodos } = filterTodos(todos);

  const handleAdd = (inserted) => setToDos([...todos, inserted]);
  const handleUpdate = (updated) => {
    updated.isDone = !updated.isDone;
    setToDos(todos.map((todo) => (todo.id === updated.id ? updated : todo)));
  };
  const handleDelete = (deleted) => {
    const result = window.confirm('정말로 삭제하시겠습니까?');
    if (!result) return;

    setToDos(todos.filter((todo) => todo.id !== deleted.id));
  };

  return (
    <main className="container">
      <h1>"나의 할 일 목록"</h1>
      <section>
        <AddTodo onAddTodo={handleAdd} />
      </section>
      <section className="todo">
        <div className="todo-box">
          <h3>🔥 진행중</h3>
          <ul className="todo-list">
            {doingTodos.map((todo) => (
              <TodoItem
                key={todo.id}
                todo={todo}
                onDelete={handleDelete}
                onUpdate={handleUpdate}
              />
            ))}
          </ul>
        </div>
        <div className="todo-box">
          <h3>✅ 완료</h3>
          <ul className="todo-list">
            {doneTodos.map((todo) => (
              <TodoItem
                key={todo.id}
                todo={todo}
                onDelete={handleDelete}
                onUpdate={handleUpdate}
              />
            ))}
          </ul>
        </div>
      </section>
    </main>
  );
}

const filterTodos = (todos) => {
  const doingTodos = todos.filter((todo) => todo.isDone === false);
  const doneTodos = todos.filter((todo) => todo.isDone === true);
  return { doingTodos, doneTodos };
};


// After
import React from 'react';
import TodoItem from './TodoItem';
import './TodoList.modules.css';

export default function TodoList({ todos, handleUpdate, handleDelete }) {
  const { doingTodos, doneTodos } = filterTodos(todos);

  return (
    <section className="todo">
      (생략 - Before와 동일)
    </section>
  );
}

const filterTodos = (todos) => {
  const doingTodos = todos.filter((todo) => todo.isDone === false);
  const doneTodos = todos.filter((todo) => todo.isDone === true);
  return { doingTodos, doneTodos };
}

TodoItem 컴포넌트는 전달받은 todo 객체로 todo 목록을 보여주는 UI 컴포넌트이다. 단순히 UI만 담당하는 컴포넌트이므로 상태를 관리하지 않고 부모 컴포넌트에서 상태를 변경하는 함수를 전달받는다.

// TodoItem.jsx

import React from 'react';
import './TodoItem.modules.css';

export default function TodoItem({ todo, onDelete, onUpdate }) {
  const { id, title, body, isDone } = todo;

  const handleDelete = () => onDelete(todo);
  const handleUpdate = () => onUpdate(todo);

  return (
    <li className="todo-item" key={id}>
      <p className="todo-title" title={title}>
        {title}
      </p>
      <p className="todo-body">{body}</p>
      <div>
        <button onClick={handleDelete} className="todo-btn">
          삭제
        </button>
        <button onClick={handleUpdate} className="todo-btn">
          {isDone ? '취소' : '완료'}
        </button>
      </div>
    </li>
  );
}

AddTodo 컴포넌트는 새로운 할 일 항목을 추가한다. 사용자 입력을 받아야 하므로 상태를 관리해야 한다. UI 컴포넌트와 기능 컴포넌트를 분리해야 하나 싶었지만 재사용성이 낮다고 생각해 따로 분리하지는 않았다.

// AddTodo.jsx

import React, { useState } from 'react';
import './AddTodo.modules.css';

export default function AddTodo({ onAddTodo }) {
  const [todo, setTodo] = useState({ title: '', body: '' });

  const handleChange = (e) =>
    setTodo({ ...todo, [e.target.name]: e.target.value });

  const handleSubmit = (e) => {
    e.preventDefault();

    if (todo.title.trim().length === 0 || todo.body.trim().length === 0) {
      alert('');
      return;
    }

    onAddTodo({
      id: Date.now(),
      title: todo.title,
      body: todo.body,
      isDone: false,
    });
    setTodo({ title: '', body: '' });
  };

  return (
    <form onSubmit={handleSubmit} className="todo-form">
      <h2>할 일을 추가하세요.</h2>
      <label htmlFor="title">제목</label>
      <input
        id="title"
        type="text"
        name="title"
        placeholder="제목을 입력하세요."
        value={todo.title}
        onChange={handleChange}
        required
      />
      <label htmlFor="body">내용</label>
      <input
        id="body"
        type="text"
        name="body"
        placeholder="할 일을 입력하세요."
        value={todo.body}
        onChange={handleChange}
        required
      />
      <button type="submit" className="form-btn">
        만들기
      </button>
    </form>
  );
}

정리하자면

  • App 컴포넌트
    • 최상위 컴포넌트로 애플리케이션 전체 상태와 이벤트 관리
    • AddTodo와 TodoList 컴포넌트에 필요한 상태와 함수를 전달
  • TodoList 컴포넌트
    • todo 목록을 보여줄 전체 UI 생성
    • App 컴포넌트로부터 받은 todo 배열에서 필터링한 배열을 순회하며 이벤트 처리 함수를 TodoItem 컴포넌트에 전달
  • TodoItem 컴포넌트
    • 할 일 목록을 보여주고 진행/완료 업데이트와 삭제 기능 제공
    • 전달받은 할 일 목록 데이터를 화면에 표시
  • AddTodo 컴포넌트
    • 새로운 할 일 항목을 추가
    • 사용자 입력 상태 관리와 이벤트 처리

웹 폰트 경량화

프로젝트를 vercel을 사용해 배포했을 때 Lighthouse로 성능을 측정했다. Performance에서 95점이 나왔는데 ‘Largest Contentful Paintful’ 즉, 사용자가 화면에 렌더링된 콘텐츠를 보는데 걸리는 시간이 오래 걸렸다.

Font issue

그 이유는 프로젝트에 적용한 웹 폰트 때문이었다. 웹 폰트 다운로드가 마칠 때까지 폰트 적용이 되지 않아 렌더링이 오래 걸렸던 것.

주목해야 할 부분은 CSSOM(CSS Object Model)을 생성하는 과정이다. 이 과정에서 외부 웹 폰트 링크로 정의된 부분을 만나고 해당 폰트 파일을 다운로드하기 시작한다. 하지만 그리기(paint) 단계에서 웹 폰트 파일처럼 외부 링크로 연결된 파일의 다운로드가 완료되지 않았으면 브라우저는 해당 자원을 사용하는 콘텐츠의 렌더링을 차단한다. - 웹 폰트 사용과 최적화의 최근 동향

Google Font에서 웹 폰트를 선택해 CDN 링크를 사용하고 있었다. CDN 링크를 사용하면 간단하게 웹 폰트를 사용할 수 있지만 로딩 속도가 느린 단점이 있다.

폰트 파일의 용량 줄이기

폰트 파일의 용량을 최적화해 완화할 수 있다. WOFF(Web Open Font Format) 형식과 WOFF 2.0 형식은 압축된 폰트 형식이다. WOFF2 형식을 사용하도록 해서 폰트의 용량을 줄일 수 있다. (WOFF2 형식이 WOFF 형식보다 30~50% 더 압축된 형식)

Google Fonts에서 폰트를 다운받아서 ttf를 woff2 확장자로 변환해서 적용했다. font-display 는 font face가 표시되는 방법을 결정한다. swap은 글꼴이 다운로드되기 전까지, 시스템 글꼴을 사용하다가 다운로드가 완료되면 웹 글꼴을 적용한다.

@font-face {
  font-family: 'Nanum Myeongjo';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(./static/fonts/NanumMyeongjo-Regular.woff2) format('woff2');
}

@font-face {
  font-family: 'Nanum Myeongjo';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(./static/fonts/NanumMyeongjo-Bold.woff2) format('woff2');
}

▶ 그 결과

해결

느낀점

리액트 개발하며 드는고민은 “어떻게 컴포넌트를 분리해야 할까?” 라는 것이다. 재사용성과 단일 책임을 기준으로, 화면 담당 컴포넌트와 기능[로직] 담당 컴포넌트 등.. 고려해야 할 사항이 많다. 그래서 투두리스트 기능을 구현하는 것만큼 리팩토링도 비슷한 시간이 걸린 것 같다. 여러 차례 수정해보며 가장 옳다고 생각하는 방식으로 구현했지만 다른 방법도 괜찮지 않았나 라는 의구심이 든다.

내일부터는 리액트 숙련주차이다. 지금까지는 독학한 밑거름으로 충분히 따라갈 수 있었다면 이제는 긴장해야 하지 않을까. 더욱 기초를 탄탄히 하고 성장이 멈추지 않도록 부단히 애써야겠다.

Reference

카테고리:

업데이트:

댓글남기기