지난주까지 공통 세션에서는 자기소개와 커피챗 등 서로를 알아가기 위한 아이스브레이킹 세션이 진행되었다. 이번 주부터는 기획, 디자인, 프론트엔드, 백엔드가 한 팀을 이루어 기존 서비스를 분석하고 개선된 화면 하나를 구현하는 Mash Up Day가 시작된다.
프론트엔드에서는 기존 화면의 구조와 동작, API 연결 방식을 역으로 분석하고, 개선된 화면을 바로 구현할 수 있도록 프로젝트 환경과 목 데이터, 명세 초안을 준비한다. 이후 기획과 디자인에서 구체화한 개선안을 백엔드와 연결해 실제로 동작하는 화면으로 완성할 예정이다.
기존 서비스를 단순히 따라 만드는 것이 아니라 현재 구조와 사용자 흐름을 분석한 뒤 발견한 개선점을 실제 결과물에 반영한다는 점에서 지금까지 경험해보지 못한 형태의 프로젝트가 될 것 같아 기대된다. 앞으로는 파트 스터디 회고와 함께 Mash Up Day에서 서비스를 분석하고 구현한 과정도 프론트엔드 관점에서 정리해보려고 한다.
이번 프론트엔드 2주차 과제는 1주차에 Vanilla JavaScript로 구현했던 메모 서비스를 React로 리팩토링하는 것이었다.
1주차에는 상태가 변경될 때마다 필요한 DOM을 직접 다시 구성하고, 상태와 화면을 연결하는 흐름도 직접 관리해야 했다. 이번 과제에서는 같은 기능과 디자인을 React로 옮기면서 단순히 문법만 바꾸는 데 그치지 않고, 상태를 기준으로 UI를 구성하고 컴포넌트의 책임을 나누는 방식을 고민해보고자 했다.
프로젝트는 Vite, React, TypeScript를 기반으로 구성했고, 스타일링에는 Tailwind CSS를 사용했다. 1주차와 동일하게 메모 조회, 검색, 태그 필터, 고정, 상세 조회, 새 메모 작성, 수정, 삭제 기능을 구현했으며, 브라우저를 새로고침한 이후에도 작성하거나 변경한 메모가 유지되도록 localStorage를 연결했다.
배포 링크 : https://react-memo-24th.vercel.app/
GitHub PR : https://github.com/CEOS-Developers/react-memo-24th/pull/1

1주차 전체 피드백을 다시 살펴보던 중 favicon에 대한 피드백을 확인했다. 직접 아이콘을 디자인하기에는 어려움이 있어 GPT의 도움을 받아 메모와 고정 기능을 표현한 favicon을 제작해 적용해 보았다. ^_^
React를 사용한 개발은 UMC 9기 Nuvibe 프로젝트에서 경험해 보았기 때문에 상대적으로 익숙했다. 하지만 이미 Vanilla JavaScript로 완성한 서비스를 같은 요구사항으로 다시 구현해 보는 경험은 새로웠다. 두 구현을 바로 비교할 수 있었기 때문에 이전에는 자연스럽게 사용했던 컴포넌트와 상태 관리의 역할을 더 구체적으로 생각해볼 수 있었다.
1주차에는 JavaScript 파일을 데이터, 저장소, DOM 생성, 애플리케이션 제어 역할로 분리했다. 이번 React 과제에서는 이 구조를 그대로 옮기기보다 화면의 역할, 상태의 소유권, 재사용 가능성을 기준으로 구조를 다시 설계했다.
src/
├── components/
│ ├── common/
│ │ ├── ActionModal.tsx
│ │ ├── IconButton.tsx
│ │ └── Modal.tsx
│ └── memo/
│ ├── MemoCard.tsx
│ ├── MemoCategorySelect.tsx
│ ├── MemoDetailModal.tsx
│ ├── MemoEditor.tsx
│ ├── MemoList.tsx
│ ├── MemoSearchBar.tsx
│ ├── MemoTagFilter.tsx
│ └── MemoToolbar.tsx
├── data/
├── hooks/
├── pages/
├── styles/
├── types/
└── utils/
1주차 때보다 단위를 작게 나누긴 했지만, 컴포넌트를 무조건 작게 나누는 것을 목표로 하지는 않았다. 독립적인 역할이나 상태를 가지고 있는지, 다른 화면에서도 사용할 수 있는지, 분리했을 때 부모 컴포넌트의 흐름이 더 명확해지는지를 기준으로 판단했다.
예를 들어 MemoCard는 메모 한 개를 표현하고, MemoList는 전달받은 메모 배열을 순회해 목록을 구성한다. 검색창과 태그 필터는 각각 입력과 선택이라는 별도의 상호작용을 갖고 있어 MemoSearchBar와 MemoTagFilter로 나눴다.
반면 새 메모 작성과 수정 화면은 입력값의 초기값과 저장 동작만 다르고 대부분의 UI가 같았다. 그래서 두 화면을 따로 만들지 않고 하나의 MemoEditor 컴포넌트에서 처리했다.
const isEditing = memo !== undefined;
const [title, setTitle] = useState(memo?.title ?? '');
const [content, setContent] = useState(memo?.content ?? '');
const [category, setCategory] = useState<MemoCategory | ''>(
memo?.category ?? '',
);
수정할 메모가 전달되면 기존 값을 초기값으로 사용하고, 전달되지 않으면 빈 작성 화면으로 시작한다. 공통 UI는 재사용하면서 실제로 메모를 추가하거나 변경하는 책임은 상위 컴포넌트에 남겨두었다.
이처럼 컴포넌트 분리는 단순히 파일의 길이를 줄이는 작업이 아니라, 함께 변경되는 코드와 서로 다른 이유로 변경되는 코드를 구분하는 과정이라는 점을 다시 생각해볼 수 있었다.
1주차에서는 검색, 필터, 고정, 작성, 수정, 삭제로 메모 상태가 변경될 때마다 renderMemos()를 직접 호출했다.
사용자 동작 -> 상태 변경 -> localStorage 저장 ->
renderMemos()-> 현재 상태를 기준으로 DOM 다시 생성
여러 기능에서 같은 렌더링 함수를 사용하도록 구성했지만, 상태를 변경한 뒤 다시 그리는 시점까지 직접 관리해야 했다.
React에서는 메모 목록과 검색 조건을 상태로 관리하고, 현재 상태를 바탕으로 화면에 표시할 데이터를 계산했다.
const visibleMemos = filterMemos(memos, keyword, category);
const pinnedMemos = visibleMemos.filter((memo) => memo.isPinned);
const unpinnedMemos = visibleMemos.filter((memo) => !memo.isPinned);
검색 결과와 고정 메모 목록은 원본 메모와 검색 조건으로 다시 계산할 수 있는 값이기 때문에 별도의 상태로 저장하지 않고, 렌더링 과정에서 구하도록 했다.

예를 들어 메모의 고정 상태를 변경할 때는 별 아이콘의 색상을 직접 바꾸거나 카드를 다른 목록으로 옮기지 않는다. 메모 배열의 isPinned 값만 변경하면, 해당 상태를 기준으로 고정 목록과 일반 목록이 다시 계산된다.
function handleTogglePin(memoId: Memo['id']) {
setMemos((previousMemos) =>
previousMemos.map((memo) =>
memo.id === memoId
? { ...memo, isPinned: !memo.isPinned }
: memo,
),
);
}
Vanilla에서는 '상태가 변경되었으니 어떤 DOM을 수정해야 하는가?'를 생각했다면, React에서는 '현재 상태에서는 어떤 화면이 보여야 하는가'를 표현했다. React의 선언적인 렌더링이 어떤 부분을 대신 처리해주는지도 이전보다 구체적으로 이해할 수 있었다.
React로 전환하면서 컴포넌트 분리만큼 고민했던 부분은 상태를 어디에서 관리할 것인지였다.
메모 목록, 검색어, 선택된 태그, 상세 조회 대상과 수정 대상은 여러 컴포넌트의 동작에 영향을 준다. 그래서 화면 전체의 흐름을 담당하는 MemoPage에서 관리했다.
const [memos, setMemos] = useStoredMemos();
const [keyword, setKeyword] = useState('');
const [category, setCategory] = useState<MemoCategory | ''>('');
const [selectedMemoId, setSelectedMemoId] =
useState<Memo['id'] | null>(null);
const [editingMemoId, setEditingMemoId] =
useState<Memo['id'] | null>(null);
상세 조회 중인 메모 객체를 상태에 그대로 저장하지 않고 메모의 ID만 저장한 것도 하나의 선택이었다.
const selectedMemo = memos.find(
(memo) => memo.id === selectedMemoId,
);
선택된 메모 객체를 별도로 저장하면 메모를 수정했을 때 원본 배열과 상세 화면의 데이터를 함께 갱신해야 할 수 있다. ID만 저장하고 현재 메모 배열에서 다시 찾도록 하면 상세 화면에서도 항상 최신 데이터를 사용할 수 있다.
반면 태그 선택 메뉴가 열려 있는지와 같은 상태는 다른 컴포넌트에서 알 필요가 없다. 이런 상태는 MemoTagFilter와 MemoCategorySelect 내부에서 관리했다.
이번 과제에서는 전역 상태 관리 라이브러리를 사용하지 않는 것이 요구 조건에 있었다. 현재 규모에서는 여러 컴포넌트가 함께 사용하는 상태만 공통 부모에 두고, 특정 컴포넌트에서만 필요한 상태는 내부에서 관리하는 것으로 충분하다고 판단했다.
상태를 무조건 상위 컴포넌트에 모으기보다 실제로 어느 범위에서 사용하는지를 기준으로 위치를 정하는 것이 중요하다는 점을 다시 생각해볼 수 있었다.
메모를 작성하거나 수정할 때마다 컴포넌트에서 직접 localStorage에 접근하면 상태를 변경하는 코드와 저장소 코드가 섞이게 된다. 이를 분리하기 위해 메모 상태와 브라우저 저장소의 동기화를 useStoredMemos라는 커스텀 훅으로 만들었다.
export function useStoredMemos() {
const [memos, setMemos] = useState<Memo[]>(loadMemos);
useEffect(() => {
saveMemos(memos);
}, [memos]);
return [memos, setMemos] as const;
}
초기 렌더링에서는 loadMemos()를 통해 저장된 메모를 불러오고, 이후 메모 상태가 변경되면 useEffect에서 새로운 값을 저장한다.
MemoPage에서는 구체적인 저장 방식을 신경 쓰지 않고 일반적인 상태와 동일하게 사용할 수 있다.
const [memos, setMemos] = useStoredMemos();
저장된 JSON이 손상되거나 예상하지 못한 형태의 데이터가 들어오는 경우를 검증하는 과정은 1주차 회고에서 다뤘다. 이번에도 해당 로직을 memoStorage.ts에 분리하고, 저장값이 배열인지, 각 메모가 필요한 필드와 올바른 자료형을 가지고 있는지 검사했다. 문제가 있다면 초기 데이터를 반환하도록 처리했다.
1주차에도 저장소 로직을 별도 파일로 분리했지만, 이번에는 커스텀 훅을 통해 상태와 부수 효과까지 하나의 기능 단위로 묶을 수 있었다. 이전에는 커스텀 훅을 반복되는 로직을 묶는 용도로만 생각했는데, 이번에는 컴포넌트가 저장 방식을 알지 않아도 되도록 책임을 분리하는 데 활용할 수 있었다.

메모 작성 중 뒤로가기나 작성 취소 버튼을 누르면, 작성한 내용을 정말 버릴 것인지 확인하는 창이 나타난다.
처음에는 작성 화면과 그 위에 나타나는 확인창을 모두 HTML의 <dialog> 요소로 구현했다.
변경 전
MemoEditor <dialog>
└── ActionModal <dialog>
그런데 확인창을 Escape 키로 닫으면 작성 화면은 그대로 보이지만, 입력창을 선택하거나 버튼을 누를 수 없는 문제가 발생했다. 확인창은 사라졌지만 바깥의 작성 화면이 비활성화된 것처럼 남아 있었다.
문제를 확인해보니 작성 화면의 <dialog> 안에서 또 다른 <dialog>를 여는 구조에서 포커스가 정상적으로 복구되지 않고 있었다. 브라우저는 모달이 열려 있는 동안 그 바깥 영역을 조작하지 못하도록 제한하는데, 두 모달이 겹치면서 안쪽 확인창을 닫은 뒤에도 작성 화면이 다시 활성화되지 않았다.
작성 화면과 확인창은 모두 모달처럼 보이지만 역할은 달랐다. 작성 화면은 페이지 위에 독립적으로 열리는 화면이고, 확인창은 작성 화면 위에서 사용자의 선택을 한 번 더 확인하기 위한 작은 안내창이다.
따라서 작성 화면에는 기존처럼 <dialog>를 사용하고, 그 위에 표시되는 확인창은 일반 요소를 이용한 오버레이로 변경했다.
변경 후
MemoEditor <dialog>
└── ActionModal <div role="dialog">
ActionModal에는 role="dialog"와 aria-modal="true"를 지정해 보조 기술에서도 대화상자로 인식할 수 있도록 했다. 구조만 변경하는 데 그치지 않고, 확인창이 열리면 내부 버튼으로 포커스를 이동시켰다. Tab과 Shift+Tab을 눌렀을 때는 포커스가 확인창 내부에서만 순환하도록 했으며, Escape 키로 닫으면 확인창을 열었던 버튼으로 포커스가 돌아가도록 처리했다.
수정한 뒤에는 확인창을 Escape 키로 닫아도 작성 화면의 입력창과 버튼을 정상적으로 다시 사용할 수 있었다.
이번 문제를 해결하면서 화면이 모달처럼 보인다고 해서 모든 영역을 같은 <dialog> 요소로 구현할 필요는 없다는 점을 알게 되었다. 겹쳐서 나타나는 UI를 구현할 때는 각 화면의 역할뿐만 아니라, 사용자의 포커스가 어디로 이동하고 닫힌 뒤에는 어디로 돌아가야 하는지도 함께 고려해야 했다.
1주차와 2주차에 같은 서비스를 Vanilla JavaScript와 React로 각각 구현하면서 두 방식의 차이를 직접 비교해볼 수 있었다. Vanilla JavaScript에서는 상태와 DOM을 연결하는 흐름을 직접 관리했다면, React에서는 현재 상태를 기준으로 화면을 선언하고 UI를 컴포넌트 단위로 구성할 수 있었다.
이번 과제를 통해 React를 사용하는 것과 React에 맞게 구조를 설계하는 것은 다르다는 점을 느꼈다. React를 사용하더라도 상태를 어디에서 관리하고, 어떤 기준으로 컴포넌트를 분리할지는 개발자가 직접 결정해야 했다. 앞으로도 기능을 구현하는 데 그치지 않고 각 코드의 역할과 변경 이유를 고민하며, 내가 선택한 구조를 스스로 설명할 수 있는 개발을 해나가고 싶다.