지금까지 연재에서는 VS Code 설치, 터미널, Codex, HTML/CSS, JavaScript, 오류 읽기, Git 체크포인트까지 하나씩 나눠서 익혀봤습니다. 여기까지 오면 이런 생각이 들기 시작합니다. "이제 조각조각은 알겠는데, 그래서 실제로 하나를 끝까지 만들 수는 있는 걸까?"

이번 글은 바로 그 질문에 답하는 실습입니다. 처음부터 거창한 서비스를 만드는 건 아닙니다. 대신 작고 분명한 기능을 가진 웹 프로젝트 하나를 끝까지 완성해보겠습니다. 초보자에게는 이 경험이 꽤 중요합니다. 기능 하나를 배우는 것과, 작은 프로젝트를 시작해서 마무리까지 해보는 건 생각보다 느낌이 다르기 때문입니다.

이번 편에서는 할 일 체크리스트 앱을 만들어보겠습니다. 기능은 일부러 단순하게 가져가겠습니다. 할 일 추가, 완료 체크, 삭제, 새로고침 후 유지까지만 넣고, 로그인이나 서버 같은 요소는 넣지 않겠습니다. 처음 완주하는 프로젝트일수록 욕심을 줄이는 쪽이 훨씬 좋습니다.

이번 프로젝트 범위
이번에 넣는 것 이번에는 일부러 빼는 것 왜 이렇게 자르나
할 일 추가 로그인 첫 프로젝트에서는 입력과 상태 변경 흐름만 잡아도 충분합니다
완료 체크 서버 연동 브라우저 안에서 끝나는 앱 구조를 먼저 익히는 편이 좋습니다
삭제 카테고리, 마감일, 정렬 기능 기능이 많아질수록 첫 완주 경험이 흐려지기 쉽습니다
브라우저 저장 유지 복잡한 UI 꾸미기 저장 흐름을 처음 경험하기에 localStorage가 가장 단순합니다

이번 글의 핵심 한 줄
첫 프로젝트에서는 "멋진 결과물"보다 입력 -> 상태 변경 -> 저장 -> 다시 렌더링 흐름을 끝까지 한 번 돌려보는 경험이 훨씬 중요합니다.


왜 이번에는 작은 프로젝트를 끝까지 만들어봐야 하는가

초보자가 가장 많이 겪는 정체 구간 중 하나는, 배운 건 조금씩 있는데 "완성해본 것"은 없는 상태입니다. 버튼 클릭도 해봤고, 입력값 처리도 해봤고, Git도 조금 만져봤는데 막상 프로젝트 하나를 끝까지 굴려본 적은 없는 거죠. 이 상태에서는 머릿속에 지식이 있어도 손이 잘 안 따라옵니다.

그래서 어느 시점부터는 작은 프로젝트를 하나 끝까지 가져가보는 경험이 필요합니다. 꼭 대단한 결과물일 필요는 없습니다. 오히려 첫 프로젝트는 심심할 정도로 단순한 편이 좋습니다. 그래야 "어디까지가 구조이고, 어디까지가 동작이고, 어디서 저장되고, 어디서 꼬일 수 있는지"를 눈으로 따라가기가 쉽습니다.

  • 기능 범위를 스스로 줄이는 연습이 됩니다.
  • 파일을 나눠서 관리하는 이유가 조금 더 분명해집니다.
  • 오류가 날 때 어느 파일부터 봐야 하는지도 감이 잡힙니다.
  • Git 체크포인트를 언제 남기면 좋은지도 실제로 체감하게 됩니다.

핵심 포인트
첫 프로젝트에서는 "대단한 결과물"보다 끝까지 완성해본 흐름이 더 중요합니다.


이번 프로젝트에서 만들 기능과 파일 구조

이번 프로젝트는 아주 단순한 할 일 앱입니다. 넣을 기능은 네 가지뿐입니다.

  • 할 일을 입력해서 추가한다.
  • 체크해서 완료 상태를 표시한다.
  • 필요 없는 항목은 삭제한다.
  • 새로고침해도 목록이 유지된다.

프로젝트 폴더 구조도 단순하게 가겠습니다.

mini-todo/
  index.html
  style.css
  script.js

이 정도 구조가 좋은 이유는 역할이 분명하기 때문입니다. HTML은 화면 뼈대, CSS는 모양, JavaScript는 동작과 저장을 맡습니다. 프로젝트가 작을수록 이 구분을 몸에 익히기 좋습니다.


HTML로 입력창과 목록 뼈대 만들기

먼저 화면 뼈대부터 만들어보겠습니다. 입력창, 추가 버튼, 안내 문구, 목록이 보이면 지금 단계에서는 충분합니다. 핵심은 멋진 화면이 아니라, JavaScript가 나중에 찾아서 쓸 수 있도록 구조를 분명하게 잡아두는 것입니다.

index.html은 아래처럼 시작하면 됩니다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0"
  >
  <title>오늘 할 일 앱</title>
  <link rel="stylesheet" href="style.css">
  <script defer src="script.js"></script>
</head>
<body>
  <main class="app">
    <section class="panel">
      <p class="eyebrow">Mini Project</p>
      <h1>오늘 할 일</h1>
      <p class="description">
        작은 기능부터 끝까지 완성해보는 첫 프로젝트입니다.
      </p>

      <form id="todoForm" class="todo-form">
        <label for="todoInput">할 일 입력</label>
        <div class="input-row">
          <input
            id="todoInput"
            type="text"
            placeholder="예: JavaScript 복습하기"
          >
          <button type="submit">추가</button>
        </div>
      </form>

      <p id="message" class="message">
        할 일을 입력하고 추가 버튼을 눌러보세요.
      </p>

      <ul id="todoList" class="todo-list"></ul>
    </section>
  </main>
</body>
</html>

여기서 눈에 익혀둘 부분은 많지 않습니다. 이번에는 아래 네 가지만 먼저 보면 충분합니다.

  • todoForm : 제출 이벤트를 잡을 폼입니다.
  • todoInput : 사용자가 실제로 할 일을 입력하는 칸입니다.
  • message : 안내 문구를 바꿔서 보여줄 자리입니다.
  • todoList : JavaScript가 목록 항목을 채워 넣을 영역입니다.

핵심 포인트
HTML 단계에서는 예쁜 화면보다 JavaScript가 나중에 쓸 자리들을 분명하게 만드는 것이 더 중요합니다.


CSS로 보기 편한 화면 만들기

뼈대가 준비됐으면 이제 화면을 조금 정리해보겠습니다. 이번 CSS도 목적은 화려함이 아닙니다. 입력 영역과 목록 영역이 구분되고, 모바일에서도 너무 답답하지 않게 보이면 충분합니다.

style.css는 아래 정도로 시작하면 무난합니다.

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
  background: #f3f4f6;
  color: #111827;
}

.app {
  width: min(760px, calc(100% - 32px));
  margin: 48px auto;
}

.panel {
  background: #ffffff;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #6b7280;
}

h1 {
  margin: 0 0 12px;
  font-size: 32px;
  line-height: 1.2;
}

.description {
  margin: 0 0 24px;
  line-height: 1.7;
  color: #374151;
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}

.input-row {
  display: flex;
  gap: 12px;
}

input {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 14px;
  font: inherit;
}

button {
  border: none;
  border-radius: 14px;
  padding: 14px 18px;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  background: #111827;
  color: #ffffff;
}

.message {
  margin: 16px 0 0;
  line-height: 1.6;
  color: #6b7280;
}

.todo-list {
  list-style: none;
  margin: 24px 0 0;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  align-items: center;
  padding: 16px 18px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #f9fafb;
}

.todo-item + .todo-item {
  margin-top: 12px;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

.todo-left span {
  line-height: 1.6;
  word-break: break-word;
}

.todo-item.done span {
  text-decoration: line-through;
  color: #9ca3af;
}

.delete-button {
  background: #ffffff;
  color: #111827;
  border: 1px solid #d1d5db;
}

@media (max-width: 640px) {
  .app {
    margin: 32px auto;
  }

  .panel {
    padding: 24px;
  }

  .input-row {
    flex-direction: column;
  }

  .todo-item {
    flex-direction: column;
    align-items: stretch;
  }
}

이번 CSS에서 중요한 건 속성을 전부 외우는 게 아닙니다. 대신 아래처럼 연결해서 보는 연습이 더 중요합니다.

  • 입력창과 버튼을 나란히 두는 건 input-row입니다.
  • 할 일 한 줄 한 줄의 카드 느낌은 todo-item이 만듭니다.
  • 완료된 항목이 흐려지는 건 todo-item done 상태입니다.
  • 모바일에서 세로 배치로 바뀌는 건 맨 아래의 반응형 구간입니다.

핵심 포인트
CSS도 "예쁜 코드"보다 어느 화면 요소를 위한 규칙인지 연결해서 보는 것이 훨씬 중요합니다.


JavaScript로 추가, 완료, 삭제, 저장 붙이기

이제부터가 이번 프로젝트의 핵심입니다. JavaScript에서는 크게 네 가지를 하게 됩니다.

  • 입력한 할 일을 배열에 넣기
  • 목록을 화면에 다시 그리기
  • 완료 체크와 삭제를 처리하기
  • 브라우저 저장 공간에 남겨서 새로고침 뒤에도 유지하기

처음부터 한 줄씩 해석하려고 하지 않아도 괜찮습니다. 먼저 "입력 -> 배열 변경 -> 저장 -> 다시 그리기" 흐름만 잡아두면 생각보다 훨씬 덜 어렵습니다.

이번 JavaScript에서 먼저 봐야 할 것
이름 역할 왜 중요한가
todos 현재 할 일 목록 배열 앱의 실제 상태가 여기 들어 있습니다
saveTodos 현재 배열을 localStorage에 저장 새로고침 뒤에도 유지되게 만드는 핵심입니다
renderTodos 현재 배열 기준으로 화면을 다시 그림 화면에 보이는 목록이 여기서 결정됩니다

script.js는 아래처럼 작성해보겠습니다.

const STORAGE_KEY = "vibe-todos";

const todoForm = document.querySelector("#todoForm");
const todoInput = document.querySelector("#todoInput");
const todoList = document.querySelector("#todoList");
const message = document.querySelector("#message");

let todos = loadTodos();

function loadTodos() {
  try {
    const savedTodos = localStorage.getItem(STORAGE_KEY);

    return savedTodos ? JSON.parse(savedTodos) : [];
  } catch (error) {
    return [];
  }
}

function saveTodos() {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(todos)
  );
}

function showMessage(text) {
  message.textContent = text;
}

function createTodoItem(todo) {
  const item = document.createElement("li");
  const left = document.createElement("div");
  const checkbox = document.createElement("input");
  const text = document.createElement("span");
  const deleteButton = document.createElement("button");

  item.className = "todo-item";
  left.className = "todo-left";

  if (todo.done) {
    item.classList.add("done");
  }

  checkbox.type = "checkbox";
  checkbox.checked = todo.done;

  checkbox.addEventListener("change", function () {
    todo.done = checkbox.checked;
    saveTodos();
    renderTodos("할 일 상태를 업데이트했습니다.");
  });

  text.textContent = todo.text;

  deleteButton.type = "button";
  deleteButton.className = "delete-button";
  deleteButton.textContent = "삭제";

  deleteButton.addEventListener("click", function () {
    todos = todos.filter(function (currentTodo) {
      return currentTodo.id !== todo.id;
    });

    saveTodos();
    renderTodos("항목을 삭제했습니다.");
  });

  left.append(checkbox, text);
  item.append(left, deleteButton);

  return item;
}

function renderTodos(messageText) {
  todoList.innerHTML = "";

  if (todos.length === 0) {
    showMessage(messageText || "아직 등록된 할 일이 없습니다.");
    return;
  }

  todos.forEach(function (todo) {
    todoList.appendChild(
      createTodoItem(todo)
    );
  });

  showMessage(
    messageText ||
      "체크해서 완료 처리하거나, 필요 없으면 삭제해보세요."
  );
}

todoForm.addEventListener("submit", function (event) {
  event.preventDefault();

  const text = todoInput.value.trim();

  if (!text) {
    showMessage("할 일을 먼저 입력해보세요.");
    todoInput.focus();
    return;
  }

  todos.unshift({
    id: Date.now(),
    text: text,
    done: false
  });

  todoInput.value = "";
  saveTodos();
  renderTodos("할 일을 추가했습니다.");
  todoInput.focus();
});

renderTodos();

이번 코드에서 핵심은 세 줄기로 보면 됩니다.

  • loadTodos : 시작할 때 저장된 목록을 읽는다
  • saveTodos : 상태가 바뀔 때마다 저장한다
  • renderTodos : 저장된 현재 상태를 화면에 다시 그린다

그리고 실제 동작은 전부 같은 흐름을 반복합니다.

입력하거나 체크하거나 삭제한다
-> todos 배열이 바뀐다
-> saveTodos()로 저장한다
-> renderTodos()로 다시 그린다

이 흐름이 보이면 JavaScript가 훨씬 덜 복잡해집니다. 겉으로는 버튼도 있고, 체크박스도 있고, 삭제도 있지만 결국은 상태를 바꾸고 저장하고 다시 그리는 구조로 계속 반복되기 때문입니다.

핵심 포인트
이번 프로젝트에서 JavaScript를 이해하는 핵심은 문법보다 배열 상태를 바꾸고, 저장하고, 다시 그린다는 흐름을 잡는 데 있습니다.


직접 눌러보며 테스트하기

코드가 일단 돌아간다고 바로 끝내면 아쉽습니다. 프로젝트를 완성했다는 말은, 최소한 기본 동작을 내가 직접 확인했다는 뜻까지 포함하는 편이 좋습니다. 그래서 마지막에는 짧게라도 테스트를 해보는 게 좋습니다.

이번 앱이라면 아래 정도만 확인해도 충분합니다.

  • 빈 입력 상태에서 추가를 눌렀을 때 항목이 생기지 않는가
  • 할 일을 두세 개 넣었을 때 목록이 정상적으로 쌓이는가
  • 체크한 항목이 완료 상태로 보이는가
  • 삭제 버튼이 의도한 항목만 지우는가
  • 새로고침한 뒤에도 목록과 완료 상태가 유지되는가
  • 모바일 폭에서도 버튼과 입력창이 너무 답답하지 않은가

이런 확인이 중요한 이유는, AI가 코드를 잘 만들어줬는지보다 내가 지금 이 기능을 어떤 기준으로 검증하는지를 익히는 데 더 도움이 되기 때문입니다.

핵심 포인트
첫 프로젝트에서는 테스트도 어렵게 생각할 필요 없습니다. "입력된다, 바뀐다, 지워진다, 새로고침 뒤에도 남는다" 이 네 가지만 직접 눌러봐도 충분합니다.


Git 체크포인트까지 남겨보기

여기서 한 걸음 더 가면 이 프로젝트는 그냥 "만들어본 것"이 아니라 "되돌릴 수 있는 것"이 됩니다. 지난 글에서 Git을 체크포인트 도구처럼 보자고 했던 이유도 여기서 연결됩니다. 잘 돌아가는 기본 버전을 먼저 남겨두면, 이후 수정이 훨씬 편해집니다.

아직 이 프로젝트 폴더에서 Git을 시작하지 않았다면, 먼저 저장소를 만들고 상태를 확인합니다.

git init
git status

그다음 현재 상태가 안정적으로 돌아간다면, 커밋으로 남겨둘 수 있습니다.

git add .
git commit -m "할 일 앱 기본 기능 완성"

여기서 메시지도 꽤 중요합니다. "수정", "업데이트"처럼 넓게 적어두면 나중에 다시 볼 때 아쉬운 경우가 많습니다. 반대로 지금처럼 무엇이 완성된 상태인지를 메시지에 남기면, 이후에 기능을 더 붙였을 때도 기준점이 분명해집니다.

핵심 포인트
첫 프로젝트에서는 Git도 어렵게 볼 필요 없습니다. "지금 잘 되는 상태 하나를 이름 붙여 남긴다"는 감각이면 충분합니다.


Codex에게는 이렇게 나눠서 요청하면 된다

이번 정도 크기의 프로젝트는 Codex와 같이 실습하기에 꽤 좋습니다. 다만 여전히 중요한 건 한 번에 다 시키지 않는 것입니다. 특히 첫 프로젝트에서는 "전부 만들어줘"보다, 작은 수정과 작은 보완을 따로 맡기는 편이 훨씬 낫습니다.

전체 방향을 먼저 설명받고 싶다면 이렇게 물어볼 수 있습니다.

지금 아주 단순한 할 일 앱을 만들고 있어.
기능은 추가, 완료 체크, 삭제, 새로고침 후 유지까지만 필요해.
HTML, CSS, JavaScript를 모두 바꿀 수는 있지만
구조를 너무 복잡하게 만들지 말아줘.
먼저 어떤 파일을 왜 만들지부터 설명해줘.

JavaScript 쪽만 더 좁게 요청하고 싶다면 이렇게 말할 수 있습니다.

@script.js
localStorage를 이용해서
할 일 추가, 완료 체크, 삭제, 새로고침 후 유지까지만 되게 해줘.
프레임워크 없이 순수 JavaScript로 하고,
추가, 저장, 렌더링 흐름이 어떻게 이어지는지도 같이 설명해줘.

화면 쪽만 정리하고 싶다면 이렇게 자를 수 있습니다.

@style.css
이 할 일 앱이 너무 밋밋하지 않게만 정리해줘.
화려할 필요는 없고,
입력 영역과 목록 영역이 읽기 쉽게 보이면 충분해.
모바일에서도 입력창과 버튼이 너무 답답하지 않게 해줘.

이런 식으로 나누면 AI가 범위를 크게 흔들 가능성이 줄어듭니다. 첫 프로젝트에서는 "무엇이 어떻게 바뀌는지 따라갈 수 있는가"가 더 중요하기 때문에, 요청도 그 기준으로 자르는 편이 좋습니다.

핵심 포인트
Codex에게 첫 프로젝트를 맡길 때는 "다 해줘"보다 기능 범위를 먼저 줄이고, 파일 역할부터 설명하게 하는 것이 훨씬 좋습니다.


마무리

이번 편에서 중요한 건 할 일 앱 하나를 만들었다는 결과만이 아닙니다. 그보다 더 중요한 건, 작은 프로젝트를 끝까지 가져가는 기본 흐름을 한 번 직접 밟아봤다는 점입니다. 기능 범위를 줄이고, HTML과 CSS와 JavaScript 역할을 나누고, 입력과 상태 변경과 저장을 연결하고, 테스트와 Git 체크포인트까지 한 번 돌려본 경험이 남았기 때문입니다.

사실 초보자에게는 이 경험이 꽤 큽니다. 여기까지 해보면 이제부터는 AI가 준 결과를 그냥 붙여 넣는 단계에서 조금 벗어나게 됩니다. 어떤 기능을 만들고 싶은지 더 또렷하게 말할 수 있고, 무엇이 바뀌었는지도 조금씩 읽히기 시작하기 때문입니다.

여기까지 직접 해보면 왜 다들 첫 프로젝트를 하나 끝내보라고 하는지 감이 옵니다. 기능 수보다, 한 흐름을 끝까지 가져가 본 경험이 남기 때문입니다. 할 일 앱은 작지만, 여기서 익힌 입력 -> 상태 변경 -> 저장 -> 렌더링 흐름은 이후 작은 웹앱 거의 전부에 다시 나옵니다.