문법이 연결될 때 실력이 된다
자바스크립트 문법을 하나씩 배울 때는 이해한 것 같아도, 막상 직접 무언가를 만들려고 하면 손이 멈추기 쉽다. 변수는 알겠고, 조건문도 알겠고, 함수도 알겠는데, 이걸 어디서부터 어떻게 붙여야 할지 헷갈리기 때문이다.
그래서 작은 프로젝트가 중요하다. 작은 프로젝트는 흩어져 있던 문법 조각을 한 줄로 꿰어 준다. 초보자에게는 계산기, 카운터, 할 일 목록처럼 결과가 바로 눈에 보이는 프로젝트가 특히 좋다. 직접 만들면서 배우는 방식이 자바스크립트 기초를 실제 문제 해결 능력으로 바꾸는 데 효과적이라고 자주 추천되는 이유도 여기에 있다.12
이번 장에서는 할 일 목록 앱을 만들어 본다. 계산기도 좋은 예제지만, 할 일 목록은 입력값 처리, 조건문, 함수 분리, DOM 업데이트, 이벤트 연결, 그리고 저장 기능까지 한 화면에서 자연스럽게 경험할 수 있어서 기초를 정리하기에 아주 좋다.34
오늘 만들 앱: 할 일 목록
우리가 만들 앱은 화려하지 않다. 대신 꼭 필요한 것만 들어 있다. 할 일을 입력하고, 추가하고, 완료 상태를 바꾸고, 삭제할 수 있다. 그리고 새로고침을 해도 목록이 남아 있게 만들 것이다.
이 작은 앱 안에는 이미 꽤 많은 자바스크립트가 숨어 있다. 사용자가 입력창에 글을 쓰면 이벤트가 발생하고, 함수가 입력값을 검사한다. 조건문은 빈 입력을 막아 준다. 배열은 할 일 데이터를 저장하고, DOM 조작은 그 데이터를 화면에 다시 그려 준다. 마지막으로 localStorage는 브라우저가 그 내용을 기억하게 해 준다.34
먼저 흐름을 아주 단순하게 그려 보자.
사용자가 입력창에 할 일을 적는다.
폼을 제출하면 submit 이벤트가 발생한다.
자바스크립트 함수가 입력값이 비어 있는지 확인한다.
문제가 없으면 배열에 새 할 일을 추가한다.
renderTasks 함수가 화면을 다시 그린다.
완료나 삭제 버튼을 누르면 click 이벤트로 상태를 바꾼다.
이 흐름이 이해되면, 프로젝트는 생각보다 덜 무섭다. 결국 앱은 데이터와 화면이 서로 맞춰 움직이는 구조일 뿐이다.
한 파일로 먼저 완성해 보기
처음에는 파일을 여러 개로 나누는 것보다, 한 파일에서 전체 흐름을 보는 편이 이해하기 쉽다. 아래 코드를 그대로 입력해 실행해 보자.
<!DOCTYPE html>
<html lang='ko'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>할 일 목록</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f4f7fb;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 40px 20px;
}
.todo-app {
width: 100%;
max-width: 520px;
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
}
h1 {
margin-top: 0;
margin-bottom: 16px;
text-align: center;
}
form {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
input {
flex: 1;
padding: 12px;
border: 1px solid #d0d7e2;
border-radius: 10px;
font-size: 16px;
}
button {
border: none;
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
font-size: 14px;
}
form button {
background: #2563eb;
color: white;
}
#message {
color: #555;
margin: 8px 0;
}
#todo-count {
font-weight: bold;
margin-bottom: 16px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
margin-bottom: 10px;
}
.task-text {
flex: 1;
word-break: break-word;
}
.actions {
display: flex;
gap: 8px;
}
.toggle-btn {
background: #16a34a;
color: white;
}
.delete-btn {
background: #ef4444;
color: white;
}
.done .task-text {
text-decoration: line-through;
color: #888;
}
</style>
</head>
<body>
<div class='todo-app'>
<h1>오늘 할 일</h1>
<form id='todo-form'>
<input id='todo-input' type='text' placeholder='할 일을 입력하세요'>
<button type='submit'>추가</button>
</form>
<p id='message'>아직 할 일이 없어요.</p>
<p id='todo-count'>남은 할 일: 0개</p>
<ul id='todo-list'></ul>
</div>
<script>
const form = document.getElementById('todo-form');
const input = document.getElementById('todo-input');
const list = document.getElementById('todo-list');
const message = document.getElementById('message');
const count = document.getElementById('todo-count');
let tasks = loadTasks();
function loadTasks() {
const saved = localStorage.getItem('tasks');
return saved ? JSON.parse(saved) : [];
}
function saveTasks() {
localStorage.setItem('tasks', JSON.stringify(tasks));
}
function updateSummary() {
const remaining = tasks.filter(function(task) {
return !task.done;
}).length;
count.textContent = '남은 할 일: ' + remaining + '개';
if (tasks.length === 0) {
message.textContent = '아직 할 일이 없어요.';
} else {
message.textContent = '전체 ' + tasks.length + '개 중 ' + remaining + '개 남았어요.';
}
}
function renderTasks() {
list.innerHTML = '';
tasks.forEach(function(task) {
const li = document.createElement('li');
const span = document.createElement('span');
const actions = document.createElement('div');
const toggleButton = document.createElement('button');
const deleteButton = document.createElement('button');
if (task.done) {
li.classList.add('done');
}
span.className = 'task-text';
span.textContent = task.text;
actions.className = 'actions';
toggleButton.type = 'button';
toggleButton.className = 'toggle-btn';
toggleButton.dataset.id = task.id;
toggleButton.textContent = task.done ? '되돌리기' : '완료';
deleteButton.type = 'button';
deleteButton.className = 'delete-btn';
deleteButton.dataset.id = task.id;
deleteButton.textContent = '삭제';
actions.appendChild(toggleButton);
actions.appendChild(deleteButton);
li.appendChild(span);
li.appendChild(actions);
list.appendChild(li);
});
updateSummary();
}
function addTask(text) {
if (text === '') {
message.textContent = '빈칸 말고, 할 일을 하나 적어 주세요.';
return;
}
const newTask = {
id: Date.now(),
text: text,
done: false
};
tasks.push(newTask);
saveTasks();
renderTasks();
input.value = '';
input.focus();
}
function toggleTask(id) {
tasks = tasks.map(function(task) {
if (task.id === id) {
return {
id: task.id,
text: task.text,
done: !task.done
};
}
return task;
});
saveTasks();
renderTasks();
}
function deleteTask(id) {
tasks = tasks.filter(function(task) {
return task.id !== id;
});
saveTasks();
renderTasks();
}
form.addEventListener('submit', function(event) {
event.preventDefault();
const text = input.value.trim();
addTask(text);
});
list.addEventListener('click', function(event) {
const id = Number(event.target.dataset.id);
if (!id) {
return;
}
if (event.target.classList.contains('toggle-btn')) {
toggleTask(id);
}
if (event.target.classList.contains('delete-btn')) {
deleteTask(id);
}
});
renderTasks();
</script>
</body>
</html>브라우저에서 실행해 보면, 이제 정말로 작은 앱이 하나 살아 움직인다. 입력하면 추가되고, 완료 버튼을 누르면 줄이 그어지고, 삭제도 된다. 새로고침을 해도 사라지지 않는다. 꽤 그럴듯하다.
코드의 핵심 연결 고리
코드가 길어 보이면 겁이 날 수 있다. 하지만 실제로는 몇 가지 핵심 아이디어가 반복될 뿐이다.
변수는 현재 상태를 기억한다
tasks는 이 앱의 중심이다. 배열 안에 각 할 일이 객체 형태로 들어 있다. 예를 들면 이런 모양이다.
{
id: 1710000000000,
text: '자바스크립트 복습하기',
done: false
}이 객체 한 개가 할 일 한 개다. id는 구분용 번호이고, text는 내용, done은 완료 여부다. 결국 화면에 보이는 모든 것은 이 배열에 들어 있는 데이터를 바탕으로 만들어진다.
조건문은 앱을 덜 바보 같게 만든다
addTask 함수 안에는 이런 조건이 있다.
if (text === '') {
message.textContent = '빈칸 말고, 할 일을 하나 적어 주세요.';
return;
}이 조건문이 없으면 사용자가 아무 글자도 입력하지 않았는데 빈 할 일이 계속 추가된다. 조건문은 프로그램에 판단력을 넣는 도구다. 사람으로 치면, 눈치다. 아주 중요한 눈치다.
또 renderTasks에서는 task.done 값에 따라 완료 스타일을 줄지 말지 결정한다. 같은 데이터라도 상태에 따라 다른 화면을 보여 주는 것이다.
함수는 역할을 나눠서 코드를 덜 엉키게 한다
이 프로젝트에는 역할이 나뉜 함수들이 있다.
loadTasks는 저장된 데이터를 불러온다.saveTasks는 현재 데이터를 저장한다.renderTasks는 화면을 다시 그린다.addTask는 새 할 일을 추가한다.toggleTask는 완료 상태를 바꾼다.deleteTask는 항목을 지운다.
함수를 이렇게 나누면 코드가 읽기 쉬워진다. 나중에 문제가 생겨도 어디를 봐야 할지 빠르게 찾을 수 있다. 초보자일수록 한 함수에 모든 걸 몰아넣기 쉬운데, 그러면 금방 미로가 된다.
DOM은 데이터를 화면으로 번역한다
renderTasks 함수는 배열에 들어 있는 데이터를 실제 HTML 요소로 바꿔서 화면에 붙인다. createElement로 li, span, button을 만들고, appendChild로 연결한다. 이렇게 하면 자바스크립트가 HTML을 직접 조립하는 느낌을 배울 수 있다.
즉, tasks가 머릿속 계획표라면 renderTasks는 그 계획표를 벽에 붙이는 손이다. 데이터만 바뀌고 화면이 안 바뀌면 사용자는 아무 변화도 느끼지 못한다. 그래서 상태가 바뀔 때마다 renderTasks()를 다시 호출하는 것이다.
이벤트는 사용자의 행동을 잡아낸다
버튼 클릭, 폼 제출, 키 입력 같은 사용자 행동은 이벤트로 다룬다. 이 장의 앱에서는 addEventListener()로 이벤트를 연결했다. 이런 방식은 자바스크립트에서 가장 기본적이고 중요한 상호작용 방법이다.56
폼에는 submit 이벤트를 달았다.
form.addEventListener('submit', function(event) {
event.preventDefault();
const text = input.value.trim();
addTask(text);
});여기서 event.preventDefault()는 폼 제출 시 페이지가 새로고침되는 기본 동작을 막는다. 이 한 줄이 없으면 할 일을 추가할 때마다 화면이 번쩍 새로고침되면서 흐름이 끊긴다.
목록에는 click 이벤트를 달았다.
list.addEventListener('click', function(event) {
const id = Number(event.target.dataset.id);
if (!id) {
return;
}
if (event.target.classList.contains('toggle-btn')) {
toggleTask(id);
}
if (event.target.classList.contains('delete-btn')) {
deleteTask(id);
}
});이 코드는 목록 전체에 한 번만 이벤트를 걸고, 실제로 눌린 버튼이 완료 버튼인지 삭제 버튼인지 확인해서 다르게 처리한다. 버튼마다 일일이 이벤트를 다는 대신 부모 요소가 한꺼번에 받아 처리하는 방식이라 깔끔하다. 초보자 입장에서는 조금 낯설어 보여도, 구조를 잘 이해하면 아주 편리하다.
localStorage는 새로고침 뒤에도 기억한다
브라우저는 배열 자체를 그대로 저장하지 못한다. 그래서 JSON.stringify()로 문자열로 바꿔서 저장하고, 다시 꺼낼 때 JSON.parse()로 원래 모양에 가깝게 복원한다. 이런 저장 방식은 초보자용 할 일 목록 프로젝트에서 자주 쓰이는 확장 기능이다.34
function saveTasks() {
localStorage.setItem('tasks', JSON.stringify(tasks));
}function loadTasks() {
const saved = localStorage.getItem('tasks');
return saved ? JSON.parse(saved) : [];
}이제 11장에서 본 JSON이 여기서 다시 등장한다. 배운 내용이 서로 이어지는 순간이다.
직접 고쳐 보며 실력 굳히기
코드를 한 번 따라 쳤다고 해서 바로 내 것이 되지는 않는다. 진짜 실력은 고쳐 보면서 붙는다. 아래 세 가지를 직접 추가해 보면 좋다.
수정 기능을 넣어 보자. 삭제 버튼 옆에 수정 버튼을 만들고, 입력창에 기존 내용을 다시 넣어 고칠 수 있게 해 보자.
완료된 항목만 한꺼번에 지우는 버튼을 만들어 보자.
filter를 쓰면 꽤 깔끔하게 해결할 수 있다.우선순위를 추가해 보자. 예를 들어 높음, 보통, 낮음 같은 값을 넣고 색으로 구분해 보면 앱이 훨씬 진짜 같아진다.
이런 작은 확장은 단순히 기능을 늘리는 게 아니다. 스스로 설계하고, 어떤 변수가 필요할지 생각하고, 어느 함수에 책임을 줄지 판단하는 연습이 된다.
자주 막히는 지점
처음 만들 때 많은 사람이 비슷한 곳에서 막힌다. 아래 세 가지는 특히 자주 나온다.
renderTasks()를 빼먹는 실수다. 배열은 바뀌었는데 화면이 안 바뀌면, 데이터와 화면 연결이 끊긴 것이다.trim()을 안 써서 공백만 있는 입력이 들어가는 경우다. 보기에는 비어 있는데 문자열은 비어 있지 않아서 조건문이 통과해 버린다.dataset.id가 문자열이라 비교가 어긋나는 경우다. 그래서Number()로 숫자로 바꿔 주었다.
에러가 나면 겁먹지 말고 콘솔을 보자. 콘솔은 자바스크립트가 내는 힌트 쪽지다. 처음에는 무뚝뚝해 보여도, 익숙해지면 생각보다 친절하다.
작은 프로젝트 하나가 주는 변화
이 장에서 만든 것은 거대한 서비스가 아니다. 하지만 아주 중요한 출발점이다. 이제 여러분은 단순히 문법 문제를 푸는 사람이 아니라, 입력을 받고, 데이터를 저장하고, 화면을 바꾸고, 사용자 행동에 반응하는 작은 프로그램을 직접 만든 사람이다.
자바스크립트 기초는 여기서 끝이 아니라 여기서부터 살아난다. 변수는 이제 숫자 상자가 아니고, 앱의 상태를 담는 도구가 된다. 조건문은 단순한 참과 거짓이 아니라, 프로그램의 판단이 된다. 함수는 반복을 줄이는 장치가 아니라, 역할을 나누는 설계가 된다. DOM과 이벤트는 웹 페이지를 진짜 움직이게 만든다.
그리고 가장 중요한 사실 하나가 남는다. 작은 프로젝트를 스스로 끝까지 만들어 본 경험은, 다음 프로젝트를 시작할 용기를 준다. 그 용기가 쌓이면, 기초는 어느새 실전이 된다.
참고
1freeCodeCamp 초보자용 JavaScript 프로젝트 모음
2Skillcrush 쉬운 JavaScript 프로젝트 아이디어
3Djamware 초보자용 JavaScript 프로젝트 25선
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.