이번 학기에 CEOS 24기 프론트엔드 파트로 활동하게 되었다.
1주차 파트 스터디 세션에서는 React를 활용하지 않고 HTML/CSS와 Vanilla JavaScript만으로 메모 서비스를 구현하는 것이 과제였다. 이번 과제에서는 단순히 화면을 만드는 것보다 JavaScript로 DOM을 직접 생성하고, 데이터 상태와 화면을 일관되게 관리하는 과정에 집중했다.
주어진 Figma의 Main 화면을 기준으로 메모 조회, 검색, 태그 필터, 고정, 상세 조회 모달, 작성, 수정, 삭제 기능까지 구현했다. 메모 데이터는 localStorage에 저장하고, 반응형 레이아웃과 키보드 접근성도 함께 적용해 보았다.
배포 링크 : https://vanilla-memo-24th-six.vercel.app/
깃허브 PR : https://github.com/CEOS-Developers/vanilla-memo-24th/pull/9

개인적으로 이전에 프론트엔드를 접하며 React를 활용하는 것이 너무 익숙했기 때문에 막상 Vanilla로 구현하려니 오히려 더 막막하게 느껴졌던 것 같다. 특히 프로젝트 구조를 어떻게 짜야 할지에 대해 고민할 때부터 그랬던 것 같다.
기능이 적을 때는 하나의 JavaScript 파일에서도 관리할 수 있지만, 검색, 필터, 저장소, 모달, CRUD와 같은 기능들이 하나하나 추가되면서 각 코드의 책임을 구분할 필요가 생겼다. 우선 JavaScript는 아래와 같이 데이터, 저장소, 화면 생성, 애플리케이션 제어 역할로 분리했다.
js/
├─ data.js # 최초 실행 시 사용할 기본 메모 데이터
├─ memo-store.js # localStorage 저장과 데이터 유효성 검사
├─ memo-view.js # 메모 카드와 태그 버튼 등의 DOM 생성
└─ app.js # 상태 관리, 이벤트 연결, 검색, 모달, CRUD 흐름
CSS 역시 역할에 따라 분리했다.
css/
├─ reset.css # 브라우저 기본 스타일 초기화
├─ variables.css # Pretendard 폰트, 컬러와 타이포그래피 변수
├─ layout.css # 전체 페이지와 검색 영역
├─ memo.css # 메모 카드 스타일
├─ dialog.css # 상세, 작성, 확인 모달 스타일
└─ responsive.css # 화면 너비와 높이에 따른 반응형 스타일
app.js가 상태와 사용자 동작을 관리하고, memo-view.js는 전달받은 메모를 DOM 요소로 만든다. 브라우저 저장소에 접근하는 코드는 memo-store.js에 분리해 화면 렌더링 코드와 섞이지 않도록 했다. React에서 구현했다면 컴포넌트와 로직의 책임을 더 세분화했겠지만, 이번 과제에서는 데이터, 저장소, 화면 생성, 애플리케이션 제어를 기준으로 구조를 나눴다.

제시된 Figma와 요구사항들을 최대한 충족하는 것을 목표로 진행했다. 우선 프로젝트 초기 설정을 하며 Prettier 등에 대한 설정을 진행했고, 폰트, 색상 등의 디자인 시스템을 별도의 CSS 파일에 등록했다. 그 후 반복적으로 사용되는 UI 요소를 중심으로 정적 화면을 구현하고, 이후 검색, 필터, 고정, CRUD 기능을 순서대로 추가했다.
사진과 같이 메모가 구성되어 있어 메모 한 개는 아래와 같은 형태로 관리했다.

{
id: 'memo-1',
title: '메모 제목',
content: '메모 본문',
category: 'daily',
date: '2026-09-10',
isPinned: false,
}
애플리케이션에서 사용하는 주요 상태는 아래와 같은 형태이다.
let activeMemoId = null;
let editorMode = null;
const filterState = {
keyword: '',
category: '',
};
const memos = loadMemos();
memos에는 현재 메모 목록이 저장된다. filterState는 검색어와 선택된 태그를 관리하고, activeMemoId는 상세 조회나 수정, 삭제 대상이 되는 메모의 ID를 가리킨다. editorMode는 현재 에디터가 작성 모드인지 수정 모드인지 구분한다. 이는 두 가지 모드가 같은 기본 UI를 사용하기 때문에 존재한다.
React와 달리 Vanilla JS에서는 상태를 변경해도 화면이 자동으로 갱신되지 않는다. 따라서 사용자 동작으로 상태가 바뀌면 필요한 데이터를 저장하고, 직접 렌더링 함수를 호출하도록 구성했다.
이 과제에서 구현한 검색, 태그 변경, 고정, 새 메모 작성, 수정, 삭제는 각각의 기능이지만, 결과적으로 메모 목록을 다시 보여줘야 한다는 공통점이 있다.
각 기능에서 DOM을 따로 수정하면 화면을 갱신하는 기준이 달라질 수 있다고 생각했다. 그래서 상태를 변경한 뒤 renderMemos()를 호출하는 공통 흐름을 사용했다.
사용자 동작 -> 상태 변경 -> 필요한 경우 localStorage 저장 ->
renderMemos()실행 -> 현재 상태를 기준으로 DOM 다시 생성
조금 더 세부적으로 보면, 먼저 검색어와 태그를 기준으로 화면에 표시할 메모를 구한다.
function getVisibleMemos() {
const keyword = filterState.keyword.trim().toLowerCase();
return memos.filter((memo) => {
const matchesCategory =
!filterState.category || memo.category === filterState.category;
const searchableText = `${memo.title} ${memo.content}`.toLowerCase();
const matchesKeyword = !keyword || searchableText.includes(keyword);
return matchesCategory && matchesKeyword;
});
}
이때 검색 대상은 제목과 본문으로 한정했다. 사용자 입장에서 보았을 때, 태그는 별도의 필터로 제공하고 있기 때문에 태그 이름까지 검색 대상에 포함하지 않았다.
이렇게 필터링된 메모는 다시 고정 메모와 일반 메모로 나눴다.
function renderMemos() {
const visibleMemos = getVisibleMemos();
const pinnedMemos = visibleMemos.filter((memo) => memo.isPinned);
const unpinnedMemos = visibleMemos.filter((memo) => !memo.isPinned);
const hasNoMemos = memos.length === 0;
const hasNoSearchResults = !hasNoMemos && visibleMemos.length === 0;
renderMemoGrid(pinnedMemoGrid, pinnedMemos);
renderMemoGrid(unpinnedMemoGrid, unpinnedMemos);
pinnedMemoGrid.hidden = pinnedMemos.length === 0;
unpinnedMemoGrid.hidden = unpinnedMemos.length === 0;
memoEmptyState.hidden = !hasNoMemos;
searchEmptyState.hidden = !hasNoSearchResults;
memoApp.classList.toggle('memo-app--search-empty', hasNoSearchResults);
memoApp.classList.toggle('memo-app--memo-empty', hasNoMemos);
}
이 구현은 Figma에서 고정된 메모와 일반 메모가 서로 다른 행에 배치된 설계를 보고 두 목록을 분리했다. 또한, 이 과정에서 전체 메모가 없는 상태와, 메모는 있지만 검색 및 태그 조건에 맞는 결과가 없는 상태의 UI가 다르게 설계되어 있었기 때문에 이를 구분했다. 전체 메모가 없다면 새 메모 작성을 유도하는 화면을 보여주고, 검색 결과만 없다면 다른 검색어를 입력하도록 안내한다.


메모 목록은 HTML에 카드를 미리 작성해두는 방식이 아니라, 저장된 메모 데이터를 기준으로 JavaScript에서 필요한 DOM 요소를 직접 생성하는 방식으로 구현했다.
카드 생성 코드 중 데이터와 DOM을 연결하는 핵심 부분만 정리하면 다음과 같다.
function createMemoCard(memo) {
const listItem = document.createElement('li');
const memoCard = document.createElement('article');
const memoOpenButton = document.createElement('button');
const memoTitle = document.createElement('span');
const pinButton = document.createElement('button');
const memoContent = document.createElement('span');
const memoCategory = document.createElement('span');
const memoDate = document.createElement('time');
memoCard.className = `memo-card memo-card--${memo.category}`;
memoOpenButton.className = 'memo-card-open-button';
pinButton.className = 'pin-button';
memoTitle.textContent = memo.title;
memoContent.textContent = memo.content;
memoCategory.textContent = categoryLabels[memo.category];
memoDate.dateTime = memo.date;
memoDate.textContent = formatDate(memo.date);
memoOpenButton.dataset.memoId = memo.id;
pinButton.dataset.memoId = memo.id;
// 생성한 요소를 조합해 하나의 메모 카드를 만든다.
}
메모의 카테고리에 따라 카드의 클래스가 달라지기 때문에 Daily, Work, Others 메모에 각각 다른 색상을 적용할 수 있다. 상세 보기 버튼과 고정 버튼에는 data-memo-id를 저장해서 어떤 메모에서 발생한 동작인지 구분했다.
사용자가 작성한 제목과 본문은 innerHTML이 아니라 textContent로 삽입했다. 사용자가 HTML 형태의 문자열을 입력하더라도 실제 요소로 해석되지 않고 일반 텍스트로 표시되도록 하기 위해서였다.
여러 카드를 렌더링할 때는 DocumentFragment에 먼저 추가한 뒤 목록에 한 번에 반영했다.
export function renderMemoGrid(memoGrid, memoList) {
const memoFragment = document.createDocumentFragment();
memoList.forEach((memo) => {
memoFragment.append(createMemoCard(memo));
});
memoGrid.replaceChildren(memoFragment);
}
replaceChildren()으로 기존 목록을 현재 상태에 맞는 카드 목록으로 교체했다. 검색, 필터, 고정 상태가 변경되어도 같은 렌더링 함수를 사용할 수 있다.
메모 목록은 상태가 변경될 때마다 내부 카드가 새롭게 생성된다. 각 카드에 이벤트를 직접 등록하면 목록을 렌더링할 때마다 이벤트도 다시 연결해야 한다. 이를 피하기 위해 고정 메모 목록과 일반 메모 목록에 클릭 이벤트를 연결하여 실제 클릭된 요소를 구분하는 이벤트 위임 방식을 사용했다.
function handleMemoGridClick(event) {
const pinButton = event.target.closest('.pin-button');
if (pinButton) {
toggleMemoPin(pinButton.dataset.memoId);
return;
}
const memoOpenButton = event.target.closest('.memo-card-open-button');
if (memoOpenButton) {
openMemoDetail(memoOpenButton.dataset.memoId);
}
}
closest()를 이용해 고정 버튼과 상세 보기 버튼을 구분했다. 이벤트가 목록 부모에 연결되어 있기 때문에 카드 DOM이 교체되어도 이벤트를 다시 등록할 필요가 없다.
별 모양의 pin 버튼을 누르면 해당 메모의 isPinned 값을 변경하고 localStorage에 저장한 뒤 renderMemos()를 다시 실행한다. 그러면 메모의 고정 상태에 따라 고정 목록이나 일반 목록으로 이동한다.
상세 조회, 새 메모 작성, 수정, 삭제는 공통적으로 <dialog>를 이용해 구현했다. 작성과 수정은 Figma에서 동일한 구조로 설계되어 있어 하나의 에디터 UI를 공유하고, editorMode 값으로 두 동작을 구분했다. 작성, 수정, 삭제가 완료되면 데이터를 저장하고 공통 렌더링 함수를 호출하여 목록을 갱신했다.
메모를 작성하거나 수정, 삭제, 고정한 결과가 새로고침 후에도 유지되도록 localStorage를 사용했다.
브라우저에 저장된 값이 항상 정상적인 메모 데이터라는 보장은 없고, 저장된 JSON이 손상되거나 예상과 다른 형태의 값이 들어간 경우에는 렌더링 과정에서 오류가 발생할 수 있다. 따라서 저장값이 배열인지 확인하고, 배열 내부의 각 메모가 필요한 필드와 자료형을 가지고 있는지도 모두 검사하는 방식으로 구현했다.
function isStoredMemo(memo) {
return (
memo &&
typeof memo.id === 'string' &&
typeof memo.title === 'string' &&
typeof memo.content === 'string' &&
validCategories.includes(memo.category) &&
typeof memo.date === 'string' &&
typeof memo.isPinned === 'boolean'
);
}
데이터를 불러오는 과정은 try/catch로 처리했다.
export function loadMemos() {
try {
const storedMemos = localStorage.getItem(memoStorageKey);
if (!storedMemos) {
return createInitialMemos();
}
const parsedMemos = JSON.parse(storedMemos);
return Array.isArray(parsedMemos) && parsedMemos.every(isStoredMemo)
? parsedMemos
: createInitialMemos();
} catch {
return createInitialMemos();
}
}
만약 저장값이 없거나 JSON 파싱에 실패한 경우, 또는 저장된 데이터 구조가 올바르지 않은 경우에는 기본 메모 데이터를 반환했다. 저장 과정에서도 예외 처리를 진행해 브라우저 저장소를 사용할 수 없더라도 현재 화면의 동작까지 중단되지 않도록 했다.
제시된 Figma 상의 기본적인 디자인에 따르면 한 줄에 최대 4개의 카드가 배치된다. 처음에는 화면이 좁아지면 카드 크기도 함께 줄어들도록 구현했지만, 이 경우 카드 내부의 글과 여백까지 지나치게 좁아져 사용자 입장에서 좋지 않은 구현이라 판단했다.
최종적으로 카드 크기는 기존의 285px로 유지하고, flex-wrap을 이용해 사용 가능한 너비에 따라 카드가 다음 줄로 이동하도록 했다.
.memo-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.memo-grid > li {
display: flex;
flex: 0 0 285px;
}
.memo-card {
width: 285px;
height: 285px;
}
화면이 좁아지면 페이지의 좌우 여백과 헤더 버튼 크기를 먼저 조정하고, 더 이상 카드를 배치할 공간이 없으면 카드 크기를 줄이는 대신 열의 수를 자연스럽게 감소시켰다. 추가로 모바일 환경까지 고려해 헤더와 검색 영역의 배치를 변경하고, 화면 높이가 낮은 환경에서는 100dvh, clamp()와 높이 미디어쿼리를 이용해 에디터가 화면 아래에서 잘리지 않도록 했다.
다음 주차 과제를 미리 보니 이번 주에 구현한 메모 서비스를 React로 리팩토링하는 내용이다. 이번 과제에서는 UI부터 상태 관리와 DOM 조작까지 처음부터 Vanilla JavaScript로 직접 구현해야 했기 때문에 예상보다 시간이 오래 걸렸다. 구현 과정에서 겪었던 여러 문제 중 기억에 남는 두 가지를 정리해 보려고 한다.
처음에는 toISOString()을 이용해 오늘 날짜를 생성했다.
const date = new Date().toISOString().split('T')[0];
하지만 toISOString()은 사용자의 로컬 시간이 아닌 UTC를 기준으로 값을 반환한다. 예를 들어, 한국 시간으로 1월 1일 오전 1시에 메모를 작성하면 UTC에서는 아직 12월 31일이기 때문에 메모 작성일이 전날로 저장될 수 있었다.
이를 해결하기 위해 연도, 월, 일을 브라우저의 로컬 시간을 기준으로 각각 가져왔다.
function getTodayDate() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
이후에는 사용자의 로컬 날짜를 기준으로 메모 작성일이 정상적으로 입력되었다.
이 부분은 운영진님께서 피드백해주셔서 수정한 사항이다. 처음에는 메모 카드 전체를 article로 생성하고 tabIndex와 키보드 이벤트를 추가했다.
const memoCard = document.createElement('article');
memoCard.dataset.memoId = memo.id;
memoCard.tabIndex = 0;
Enter와 Space를 누르면 상세 화면이 열리도록 직접 keydown 이벤트도 구현했다. 키보드로 기능을 사용할 수는 있었지만, article은 기본적으로 클릭 가능한 요소가 아니기 때문에 보조기기가 이 영역을 상세 보기 버튼으로 명확하게 인식하기 어렵다는 피드백을 받았다.
이를 반영해 카드 안의 상세 보기 영역을 실제 button으로 변경하고, 고정 버튼과 분리했다.
const memoCard = document.createElement('article');
const memoOpenButton = document.createElement('button');
const pinButton = document.createElement('button');
memoOpenButton.type = 'button';
memoOpenButton.className = 'memo-card-open-button';
memoOpenButton.dataset.memoId = memo.id;
pinButton.type = 'button';
pinButton.className = 'pin-button';
pinButton.dataset.memoId = memo.id;
memoCard.append(memoOpenButton, pinButton);
button은 기본적으로 키보드 포커스를 받을 수 있고, Enter와 Space로 실행된다. 직접 키보드 동작을 구현했던 코드를 제거할 수 있었고, 보조기기에도 상세 보기 기능의 의미가 명확히 전달되었다. 이 과정을 통해 단순히 tabIndex를 추가해 키보드로 접근할 수 있게 만드는 것과 기능에 적합한 시맨틱 요소를 사용하는 것은 다르다는 점을 알게 되었다.
이번 1주차 과제를 진행하면서 Vanilla JavaScript에서는 상태와 DOM을 일관되게 연결하는 것이 중요하다는 점을 배웠다. 이외에도 UI와 각 기능을 구현하면서 새롭게 배운 내용이 많지만, 이 부분이 가장 핵심적이라 생각해서 이 글에는 해당 부분을 중심으로 정리해 보았다.
검색, 필터, 고정, CRUD 기능마다 DOM을 개별적으로 수정하는 대신 상태를 먼저 변경하고 renderMemos()를 실행하도록 구성했다. 이를 통해 여러 기능이 추가되어도 같은 기준으로 화면을 갱신할 수 있는 구조를 만들었다.
평소 파일의 책임을 초반에 적절히 나누는 것을 중요하게 생각하는데, 이번 과제에서는 JavaScript 파일을 데이터, 저장소, DOM 생성, 이벤트 처리로 분리하니 특정 기능이 어느 파일에 있는지 찾기 쉬워졌고 전체 코드의 흐름도 명확해졌다. 다음 React 과제에서는 각 컴포넌트와 로직의 책임을 더욱 명확하게 나눠보고 싶다.
또한, 화면이 Figma와 비슷하게 보이는 것만으로 구현이 끝나는 것은 아니었다. UTC와 로컬 시간의 차이, 손상된 저장 데이터, 시맨틱 요소처럼 화면에서 바로 드러나지 않는 부분도 실제 동작과 사용자 경험에 영향을 준다는 점을 알게 되었다.
React 없이 상태 변경과 DOM 갱신 과정을 직접 구현하면서 React가 상태에 따라 화면을 다시 렌더링하고 컴포넌트 단위로 UI를 관리하는 이유를 이전보다 구체적으로 이해할 수 있었다. 이후에는 삭제한 메모를 되돌리는 기능이나 정렬 기준을 추가해 사용자 경험을 더 개선해보고 싶다.
앞으로 진행할 CEOS 과제와 프로젝트에서도 단순히 결과만 기록하기보다, 어떤 문제를 발견했고 어떤 기준으로 코드를 작성했는지를 꾸준히 정리해보려고 한다.
너무 경력직이시네요