Skip to main content

이벤트

Views 5약 8분생성형 AI 활용
Summary

웹페이지가 대답하기 시작하는 순간

지난 장에서 우리는 JavaScript로 HTML 요소를 선택하고, 글자를 바꾸고, 클래스를 붙였다 떼고, 새로운 요소를 만들었습니다. 그때까지의 웹페이지는 우리가 코드를 실행해야만 변했습니다. 마치 무대 위 소품을 스태프가 직접 옮기는 것과 비슷했지요.

이번 장에서 배울 이벤트는 그 무대에 관객을 들여보내는 일입니다. 사용자가 버튼을 클릭하고, 입력창에 글자를 쓰고, 폼을 제출하고, 화면 바깥을 누르는 행동이 모두 JavaScript를 깨우는 신호가 됩니다. 이벤트를 이해하면 웹페이지는 더 이상 정적인 문서가 아닙니다. 사용자의 행동에 맞춰 반응하는 인터페이스가 됩니다.

버튼을 누르면 메뉴가 열리고, 검색어를 입력하면 목록이 줄어들고, 제출 버튼을 누르면 새 할 일이 추가됩니다. 이런 장면은 모두 같은 흐름으로 만들어집니다.

사용자가 행동한다 → 브라우저가 이벤트를 발생시킨다 → JavaScript가 정해 둔 함수를 실행한다

이 장의 핵심은 이 단순한 흐름을 몸에 익히는 것입니다.

이벤트

이벤트란 무엇인가?

이벤트는 브라우저 안에서 일어나는 사건입니다. 사용자가 마우스로 클릭하는 것도 사건이고, 키보드를 누르는 것도 사건입니다. 입력창의 값이 바뀌는 일, 폼이 제출되는 일, 페이지가 다 불러와지는 일도 이벤트입니다.

우리는 브라우저에게 이렇게 말할 수 있습니다.

이 버튼에서 클릭이 일어나면,
이 함수를 실행해 줘.

JavaScript 코드로 쓰면 다음과 같습니다.

const button = document.querySelector('.save-button');

button.addEventListener('click', function () {
  console.log('버튼을 클릭했습니다!');
});

addEventListener는 이벤트를 듣기 위해 사용하는 메서드입니다. 첫 번째 인수인 'click'은 듣고 싶은 이벤트의 종류이고, 두 번째 인수인 함수는 이벤트가 발생했을 때 실행할 코드입니다. 이런 함수를 이벤트 핸들러 또는 이벤트 리스너 함수라고 부릅니다.

함수를 전달할 때 특히 조심해야 할 점이 있습니다. 함수 이름 뒤에 괄호를 붙이지 않는 경우가 많다는 것입니다.

function sayHello() {
  console.log('안녕하세요!');
}

button.addEventListener('click', sayHello);

위 코드는 버튼을 클릭했을 때 sayHello를 실행합니다. 반대로 다음처럼 쓰면 클릭을 기다리지 않고, 해당 줄을 읽는 순간 함수가 바로 실행됩니다.

button.addEventListener('click', sayHello());

이벤트에서는 대부분 "지금 실행할 결과"가 아니라 "나중에 실행할 함수 자체"를 전달해야 합니다. 그래서 괄호를 붙일지 말지를 구분하는 습관이 중요합니다.

클릭 이벤트

가장 먼저 익힐 이벤트는 click입니다. 사용자가 버튼, 링크, 카드, 아이콘 같은 요소를 클릭했을 때 발생합니다.

<button class='like-button'>좋아요</button>
<p class='like-count'>0</p>
const likeButton = document.querySelector('.like-button');
const likeCount = document.querySelector('.like-count');

let count = 0;

likeButton.addEventListener('click', function () {
  count = count + 1;
  likeCount.textContent = count;
});

이 짧은 코드에는 인터랙티브 웹페이지의 기본 구조가 들어 있습니다. 현재 상태를 나타내는 count가 있고, 사용자가 클릭하면 상태가 바뀌며, 바뀐 상태가 화면에 다시 표시됩니다.

상태를 준비한다 → 이벤트가 발생한다 → 상태를 변경한다 → 화면을 갱신한다

메뉴 버튼을 누르면 메뉴가 열리고 닫히는 예제도 같은 흐름입니다.

<button class='menu-button'>메뉴</button>
<nav class='menu'>
  <a href='#'>홈</a>
  <a href='#'>소개</a>
  <a href='#'>문의</a>
</nav>
.menu {
  display: none;
}

.menu.open {
  display: block;
}
const menuButton = document.querySelector('.menu-button');
const menu = document.querySelector('.menu');

menuButton.addEventListener('click', function () {
  menu.classList.toggle('open');
});

JavaScript가 직접 display 값을 바꾸는 대신 CSS에 표시 방식을 맡기고, JavaScript는 클래스를 붙였다 떼는 역할만 합니다. 이런 역할 분리는 코드가 길어질수록 큰 도움이 됩니다.

입력 이벤트

입력창에서는 사용자가 글자를 입력할 때마다 반응해야 할 때가 많습니다. 이때 자주 사용하는 이벤트가 input입니다.

<input class='nickname-input' type='text' placeholder='닉네임을 입력하세요'>
<p class='preview'>아직 입력하지 않았습니다.</p>
const nicknameInput = document.querySelector('.nickname-input');
const preview = document.querySelector('.preview');

nicknameInput.addEventListener('input', function () {
  const nickname = nicknameInput.value.trim();

  if (nickname === '') {
    preview.textContent = '아직 입력하지 않았습니다.';
    return;
  }

  preview.textContent = `${nickname}님, 반갑습니다!`;
});

input 요소의 현재 값은 .value로 읽습니다. textContent가 요소 안의 글자를 뜻한다면, value는 입력창에 들어 있는 값을 뜻합니다. trim()은 앞뒤 공백을 제거합니다. 사용자가 스페이스바만 여러 번 누른 경우를 빈 입력으로 처리할 때 자주 사용합니다.

비슷한 이벤트로 change도 있습니다. input은 값이 바뀌는 즉시 발생하지만, change는 값이 어느 정도 확정되었을 때 발생합니다. 텍스트 입력창에서는 포커스가 빠져나갈 때 발생하는 경우가 많고, 체크박스나 선택 상자에서는 선택이 바뀌는 순간 발생합니다.

<select class='theme-select'>
  <option value='light'>밝은 테마</option>
  <option value='dark'>어두운 테마</option>
</select>
const themeSelect = document.querySelector('.theme-select');

themeSelect.addEventListener('change', function () {
  document.body.className = themeSelect.value;
});

실시간 검색처럼 즉시 반응해야 한다면 input이 어울리고, 사용자의 선택이 확정된 뒤 처리해도 된다면 change가 자연스럽습니다.

폼 제출 이벤트

로그인, 회원가입, 댓글 작성, 검색창, 할 일 추가처럼 사용자가 무엇인가를 보내는 장면에는 대개 form이 있습니다. 폼에서는 버튼의 클릭 이벤트보다 submit 이벤트를 다루는 편이 좋습니다. 사용자는 버튼을 클릭할 수도 있고, 입력 후 Enter 키를 누를 수도 있기 때문입니다.

<form class='todo-form'>
  <input class='todo-input' type='text' placeholder='할 일을 입력하세요'>
  <button type='submit'>추가</button>
</form>

<ul class='todo-list'></ul>
const todoForm = document.querySelector('.todo-form');
const todoInput = document.querySelector('.todo-input');
const todoList = document.querySelector('.todo-list');

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

  const text = todoInput.value.trim();

  if (text === '') {
    return;
  }

  const li = document.createElement('li');
  li.textContent = text;
  todoList.append(li);

  todoInput.value = '';
});

event.preventDefault()는 브라우저의 기본 동작을 막습니다. 폼은 원래 제출되면 페이지를 새로고침하거나 다른 주소로 이동하려고 합니다. 아직 서버로 보내는 기능을 만들지 않았는데 페이지가 새로고침되면, 방금 추가한 할 일도 사라질 수 있습니다.

폼을 다룰 때는 보통 다음 순서로 생각하면 됩니다.

  • submit 이벤트를 듣습니다.

  • preventDefault로 기본 제출 동작을 막습니다.

  • 입력값을 읽고 검증합니다.

  • 상태나 화면에 반영합니다.

이벤트 객체

이벤트 리스너 함수는 이벤트에 대한 정보를 담은 객체를 받을 수 있습니다. 보통 매개변수 이름으로 event를 사용합니다.

button.addEventListener('click', function (event) {
  console.log(event);
});

이벤트 객체에는 많은 정보가 들어 있지만, 처음에는 다음 네 가지를 자주 만납니다.

  • event.target: 이벤트가 실제로 시작된 요소

  • event.currentTarget: 이벤트 리스너가 등록된 요소

  • event.preventDefault(): 브라우저의 기본 동작 막기

  • event.stopPropagation(): 이벤트가 부모 요소로 퍼지는 것 막기

다음 예제에서는 버튼에 적어 둔 data-color 값을 읽어 박스 색을 바꿉니다.

<div class='color-buttons'>
  <button data-color='tomato'>토마토</button>
  <button data-color='royalblue'>파랑</button>
  <button data-color='seagreen'>초록</button>
</div>

<div class='box'>색이 바뀝니다</div>
const colorButtons = document.querySelector('.color-buttons');
const box = document.querySelector('.box');

colorButtons.addEventListener('click', function (event) {
  const clickedButton = event.target.closest('button');

  if (clickedButton === null) {
    return;
  }

  const color = clickedButton.dataset.color;
  box.style.backgroundColor = color;
});

dataset은 data-로 시작하는 HTML 속성을 JavaScript에서 읽을 때 사용합니다. data-color='tomato'는 JavaScript에서 dataset.color로 읽을 수 있습니다.

여기서 event.target을 바로 쓰지 않고 closest('button')을 사용한 점도 중요합니다. 버튼 안에 아이콘이나 <span>이 들어가면 실제 클릭된 요소가 버튼의 자식일 수 있습니다. closest는 현재 요소에서 시작해 부모 방향으로 올라가며 조건에 맞는 가장 가까운 요소를 찾아 줍니다.

이벤트 버블링

이벤트 버블링은 이벤트가 안쪽 요소에서 바깥쪽 요소로 올라가는 현상입니다.

<div class='card'>
  <button class='card-button'>자세히 보기</button>
</div>
const card = document.querySelector('.card');
const cardButton = document.querySelector('.card-button');

card.addEventListener('click', function () {
  console.log('카드를 클릭했습니다.');
});

cardButton.addEventListener('click', function () {
  console.log('버튼을 클릭했습니다.');
});

버튼을 클릭하면 버튼의 클릭 이벤트가 먼저 발생하고, 그 이벤트가 부모 요소인 카드로 올라갑니다. 그래서 카드에 등록한 클릭 핸들러도 실행될 수 있습니다. 물속의 공기 방울이 위로 올라가는 모습과 비슷해서 버블링이라고 부릅니다.

어떤 상황에서는 이벤트가 더 올라가지 못하게 막아야 할 때도 있습니다.

cardButton.addEventListener('click', function (event) {
  event.stopPropagation();
  console.log('버튼을 클릭했습니다.');
});

다만 stopPropagation을 습관처럼 넣으면 안 됩니다. 이벤트 흐름을 끊어 버리면 다른 코드가 이벤트를 감지하지 못할 수 있습니다. 정말 필요한 경우에만 사용하세요.

이벤트 위임

할 일 목록처럼 비슷한 요소가 여러 개 반복되는 UI에서는 각 버튼에 이벤트를 하나씩 붙이는 것보다 부모 요소에 이벤트를 한 번만 붙이는 편이 좋습니다. 이를 이벤트 위임이라고 합니다.

<ul class='todo-list'>
  <li>우유 사기 <button class='delete-button'>삭제</button></li>
  <li>책 읽기 <button class='delete-button'>삭제</button></li>
  <li>운동하기 <button class='delete-button'>삭제</button></li>
</ul>
const todoList = document.querySelector('.todo-list');

todoList.addEventListener('click', function (event) {
  const deleteButton = event.target.closest('.delete-button');

  if (deleteButton === null) {
    return;
  }

  const item = deleteButton.closest('li');

  if (item !== null) {
    item.remove();
  }
});

부모인 .todo-list에 클릭 이벤트를 등록했지만, 실제로 삭제 버튼을 클릭했는지는 closest('.delete-button')으로 확인합니다. 이 방식은 나중에 JavaScript로 새 할 일을 추가해도 그대로 동작합니다. 새 버튼에 이벤트를 다시 붙일 필요가 없습니다.

동적으로 추가되는 할 일 목록을 안전한 방식으로 완성해 보겠습니다. 사용자가 입력한 값을 화면에 넣을 때는 innerHTML보다 textContent를 사용하는 편이 안전합니다.

const todoForm = document.querySelector('.todo-form');
const todoInput = document.querySelector('.todo-input');
const todoList = document.querySelector('.todo-list');

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

  const text = todoInput.value.trim();

  if (text === '') {
    return;
  }

  const item = document.createElement('li');
  const span = document.createElement('span');
  const deleteButton = document.createElement('button');

  span.textContent = text;
  deleteButton.textContent = '삭제';
  deleteButton.type = 'button';
  deleteButton.classList.add('delete-button');

  item.append(span, deleteButton);
  todoList.append(item);

  todoInput.value = '';
});

todoList.addEventListener('click', function (event) {
  const deleteButton = event.target.closest('.delete-button');

  if (deleteButton === null) {
    return;
  }

  const item = deleteButton.closest('li');

  if (item !== null) {
    item.remove();
  }
});

이 예제에는 폼 제출, 입력값 검증, 요소 생성, 이벤트 위임이 모두 들어 있습니다. 특히 사용자가 입력한 문장을 textContent로 넣었기 때문에 HTML 태그처럼 해석되지 않습니다.

실전: 탭 메뉴 만들기

탭 메뉴는 버튼을 누르면 해당 내용만 보여 주는 인터페이스입니다. 상품 상세 페이지에서 "설명", "리뷰", "문의"를 나누거나 관리자 화면에서 여러 설정 영역을 전환할 때 자주 사용합니다.

<div class='tabs'>
  <div class='tab-buttons'>
    <button class='tab-button active' data-tab='intro'>소개</button>
    <button class='tab-button' data-tab='review'>후기</button>
    <button class='tab-button' data-tab='qna'>문의</button>
  </div>

  <section class='tab-panel active' data-panel='intro'>
    <h2>소개</h2>
    <p>처음 만나는 JavaScript 강의입니다.</p>
  </section>

  <section class='tab-panel' data-panel='review'>
    <h2>후기</h2>
    <p>버튼을 누르면 화면이 바뀌는 순간이 정말 재미있어요.</p>
  </section>

  <section class='tab-panel' data-panel='qna'>
    <h2>문의</h2>
    <p>궁금한 점을 남겨 주세요.</p>
  </section>
</div>
.tab-panel {
  display: none;
}

.tab-panel.active {
  display: block;
}

.tab-button.active {
  font-weight: bold;
  border-bottom: 2px solid black;
}
const tabButtonArea = document.querySelector('.tab-buttons');
const tabButtons = document.querySelectorAll('.tab-button');
const tabPanels = document.querySelectorAll('.tab-panel');

tabButtonArea.addEventListener('click', function (event) {
  const clickedButton = event.target.closest('.tab-button');

  if (clickedButton === null) {
    return;
  }

  const tabName = clickedButton.dataset.tab;

  tabButtons.forEach(function (button) {
    button.classList.remove('active');
  });

  tabPanels.forEach(function (panel) {
    panel.classList.remove('active');
  });

  clickedButton.classList.add('active');

  const targetPanel = document.querySelector(`[data-panel='${tabName}']`);

  if (targetPanel !== null) {
    targetPanel.classList.add('active');
  }
});

탭 메뉴의 핵심은 "모두 비활성화한 다음, 선택한 것만 활성화한다"입니다. 버튼이 세 개든 다섯 개든 이 흐름은 같습니다.

실전: 모달 창 만들기

모달 창은 현재 화면 위에 떠서 사용자의 주의를 끄는 UI입니다. 로그인 창, 확인 메시지, 이미지 확대 보기, 설정 창 등에 자주 사용됩니다. 모달을 만들 때는 보통 열기, 닫기, 배경 클릭 닫기, Escape 키 닫기를 처리합니다.

<button class='open-modal-button'>안내 보기</button>

<div class='modal-backdrop hidden'>
  <div class='modal'>
    <button class='close-modal-button' type='button'>닫기</button>
    <h2>안내</h2>
    <p>이 창은 JavaScript 이벤트로 열고 닫을 수 있습니다.</p>
  </div>
</div>
.modal-backdrop {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.5);
}

.modal {
  width: 300px;
  padding: 24px;
  border-radius: 12px;
  background: white;
}

.hidden {
  display: none;
}
const openModalButton = document.querySelector('.open-modal-button');
const closeModalButton = document.querySelector('.close-modal-button');
const modalBackdrop = document.querySelector('.modal-backdrop');

function openModal() {
  modalBackdrop.classList.remove('hidden');
}

function closeModal() {
  modalBackdrop.classList.add('hidden');
}

openModalButton.addEventListener('click', openModal);
closeModalButton.addEventListener('click', closeModal);

modalBackdrop.addEventListener('click', function (event) {
  if (event.target === modalBackdrop) {
    closeModal();
  }
});

document.addEventListener('keydown', function (event) {
  if (event.key === 'Escape') {
    closeModal();
  }
});

열기와 닫기 동작을 함수로 분리했기 때문에 여러 이벤트가 같은 함수를 재사용할 수 있습니다. 닫기 버튼을 눌러도, 배경을 눌러도, Escape 키를 눌러도 결국 closeModal이 실행됩니다.

작은 프로젝트: 검색 가능한 목록

마지막으로 입력 이벤트와 배열, DOM 조작을 함께 써 보겠습니다. 상품 목록에서 검색어에 맞는 항목만 보여 주는 예제입니다.

<input class='search-input' type='text' placeholder='상품 검색'>
<ul class='product-list'></ul>
const products = [
  { name: '무선 키보드', price: 45000 },
  { name: '게이밍 마우스', price: 39000 },
  { name: '노트북 거치대', price: 28000 },
  { name: 'USB 허브', price: 22000 }
];

const searchInput = document.querySelector('.search-input');
const productList = document.querySelector('.product-list');

function renderProducts(items) {
  productList.innerHTML = '';

  items.forEach(function (product) {
    const item = document.createElement('li');
    item.textContent = `${product.name} - ${product.price.toLocaleString()}원`;
    productList.append(item);
  });
}

renderProducts(products);

searchInput.addEventListener('input', function () {
  const keyword = searchInput.value.trim();

  const filteredProducts = products.filter(function (product) {
    return product.name.includes(keyword);
  });

  renderProducts(filteredProducts);
});

이 코드는 이전 장들의 내용이 서로 어떻게 이어지는지 잘 보여 줍니다. 배열 안에 객체가 있고, filter로 조건에 맞는 데이터만 고릅니다. DOM 요소를 만들고 textContent로 안전하게 내용을 넣습니다. 그리고 이 모든 과정은 사용자가 검색어를 입력하는 input 이벤트가 발생할 때마다 실행됩니다.

이벤트를 다룰 때 자주 하는 실수

이벤트는 재미있지만, 처음에는 작은 실수 때문에 코드가 예상과 다르게 움직일 수 있습니다. 특히 다음 상황을 자주 만납니다.

  • 이벤트 리스너에 함수를 전달해야 하는데 함수이름()처럼 바로 호출해 버립니다.

  • 입력값을 읽을 때 value가 아니라 textContent를 찾습니다.

  • 폼 제출에서 preventDefault를 빼먹어 페이지가 새로고침됩니다.

  • 동적으로 추가한 요소에 이벤트가 붙지 않는 이유를 모르고 같은 코드를 여러 번 반복합니다.

  • 이벤트 버블링을 고려하지 않아 부모와 자식의 클릭 동작이 함께 실행됩니다.

  • 사용자가 입력한 값을 innerHTML에 바로 넣어 의도하지 않은 HTML이 해석될 위험을 만듭니다.

디버깅할 때는 console.log(event.target)을 자주 사용해 보세요. 내가 클릭했다고 생각한 요소와 브라우저가 실제로 인식한 요소가 다를 때가 많습니다. 그 차이를 보는 순간 이벤트가 훨씬 덜 무섭게 느껴집니다.

마무리

이벤트를 배우면 웹페이지를 보는 눈이 달라집니다. 버튼은 더 이상 그냥 버튼이 아닙니다. 클릭이라는 사건을 기다리는 장치입니다. 입력창은 단순한 빈칸이 아닙니다. 사용자의 생각이 글자로 들어오는 통로입니다. 폼은 데이터를 보내는 문이고, 모달은 사용자의 선택을 잠시 붙잡는 작은 방입니다.

이번 장에서는 addEventListener로 이벤트 리스너를 등록하고, 클릭·입력·제출 이벤트를 처리했습니다. 이벤트 객체로 실제 발생 위치를 확인했고, preventDefault로 기본 동작을 제어했습니다. 또한 이벤트 버블링을 이해하고, 이벤트 위임으로 동적으로 추가된 요소까지 효율적으로 다루었습니다. 탭 메뉴와 모달 창, 검색 가능한 목록을 만들며 클래스 변경, 상태 표시, 사용자 행동 처리를 하나로 연결했습니다.

다음 장에서는 브라우저가 제공하는 더 넓은 기능을 만나게 됩니다. 데이터를 잠시 저장하는 localStorage, 일정 시간이 지난 뒤 코드를 실행하는 타이머, 주소 정보를 다루는 URL, 그리고 외부 서버에서 데이터를 가져오는 Fetch API까지 살펴볼 것입니다. 이벤트가 사용자의 행동을 붙잡는 기술이었다면, 브라우저 API는 웹페이지가 브라우저의 능력을 빌려 더 많은 일을 하게 만드는 도구입니다.

책 목록

처음 만나는 JavaScript: 웹페이지를 살아 움직이게 만드는 언어

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기