D-2: 이벤트 핸들링
안녕하세요, 홍순구 튜터입니다. 지난 시간(D-1)에 우리는 멈춰 있던 화면에 처음으로 손을 댔어요. querySelector로 요소를 찾고, textContent·classList로 바꾸고, createElement·append로 만들고, remove로 지웠죠.
좋아요 토글(toggleLike)과 댓글 추가/삭제(addComment·removeComment)라는 진짜 기능까지 만들었고요.
그런데 마지막에 좀 어색했어요. 좋아요를 누르려고 진짜 하트를 클릭한 게 아니라, 콘솔(F12)을 열고 toggleLike(0)을 손으로 쳤잖아요. 댓글의 "삭제" 버튼도 만들기만 했지, 눌러도 아무 반응이 없었고요. 진짜 인스타라면 하트를 클릭하는 순간 좋아요가 켜져야 하는데 말이죠.
오늘 그 마지막 한 칸을 채워요. "사용자가 하트를 클릭하면 toggleLike가 저절로 불리게" 연결하는 거예요. 이 연결을 이벤트(event) 라고 하고, 연결하는 도구가 addEventListener예요. 오늘이 지나면 콘솔을 두드릴 필요 없이, 화면을 직접 눌러서 인스타가 살아나는 걸 보게 돼요.
지난 시간 (D-1) 오늘 (D-2)
┌──────────────────────────┐ ┌──────────────────────────┐
│ 콘솔에서 toggleLike(0) 입력 │ ──▶ │ 하트를 클릭하면 저절로 실행 │
│ "삭제" 버튼은 만들기만 함 │ │ 삭제 버튼 클릭 → 진짜 지워짐 │
│ 사람이 직접 함수를 호출 │ │ 사용자 행동에 화면이 반응 │
└──────────────────────────┘ └──────────────────────────┘
💡 오늘 수업의 핵심 — "이벤트는 사용자 행동에 브라우저가 붙여주는 알림이다. addEventListener로 행동에 함수를 연결하고, 부모 한 곳에서 모든 클릭을 받는 이벤트 위임으로 게시물 100개를 한 번에 처리한다." 🎯
🎯 학습 목표
- 이벤트가 무엇인지, 왜 "초인종"에 비유하는지 이해합니다.
addEventListener("click", 함수)로 클릭에 동작을 연결합니다.- 핸들러가 받는
event객체와event.target으로 "무엇이 눌렸는지" 알아냅니다. event.preventDefault()로 폼 제출 시 새로고침 같은 기본 동작을 멈춥니다.- 클릭이 자식에서 부모로 타고 올라가는 버블링을 이해합니다.
- 부모 한 곳에 리스너 하나로 모든 자식 클릭을 처리하는 이벤트 위임을 익힙니다.
debounce·throttle로 너무 자주 터지는 이벤트의 호출 횟수를 줄입니다.
Step 1: "클릭하면 자동 실행" — addEventListener 기초
먼저 이벤트가 뭔지부터 잡고 갈게요. 이벤트는 어렵게 생각할 거 없어요. 사용자가 한 행동(클릭·입력·스크롤 등)에 브라우저가 붙여주는 알림이에요. 사용자가 버튼을 클릭하면 브라우저가 "방금 이 버튼이 클릭됐어!"라고 알려주고, 키보드를 누르면 "키가 눌렸어!"라고 알려줘요.
비유하자면 초인종이에요. 현관문에 초인종을 달아두면, 손님이 누를 때 집 안에서 "띵동" 소리가 나죠. 그런데 초인종이 울렸을 때 무엇을 할지는 미리 정해둬야 해요. "누가 누르면 → 문을 열어준다" 같은 약속이요. 이벤트도 똑같아요. "버튼이 클릭되면 → 이 함수를 실행한다"를 미리 연결해두는 거예요.
그 연결을 거는 도구가 addEventListener예요. 영어 그대로 "이벤트 듣는 사람(listener, 리스너)을 추가한다(add)"는 뜻이에요. 즉 "이 버튼에 클릭을 엿듣는 귀를 하나 붙여줘"라는 거죠. 문법은 이래요.
요소.addEventListener("click", 실행할함수)
└──┬──────────┘ └─┬─┘ └──┬───┘
이 요소에서 어떤 행동에 그때 실행할 함수
(click)
"click" 자리에 어떤 행동을 들을지 적고(클릭이면 "click", 입력이면 "input"), 그 뒤에 그 행동이 일어났을 때 실행할 함수를 넘겨요. 실제로 첫 게시물 하트 버튼 하나에 리스너를 붙이면 이렇게 돼요.
// (콘솔에서 직접 쳐보는 맛보기 — 파일 최종본 아님)
const firstLike = document.querySelector(".icon-btn-like"); // 첫 하트 버튼
firstLike.addEventListener("click", () => {
toggleLike(0); // D-1 에서 만든 좋아요 토글 함수
});
이 세 줄이면, 이제 첫 게시물 하트를 진짜 클릭할 때마다 toggleLike(0)이 저절로 불려요. 지난 시간엔 콘솔에 toggleLike(0)을 손으로 쳐야 했는데, 이제 하트를 클릭하기만 하면 빨개지고 숫자가 올라가요. 드디어 콘솔을 떠나 화면 위에서 직접 동작하는 거죠.
⚠️ 위 코드는 버튼 하나짜리 맛보기예요. 우리 피드엔 게시물이 10개라서, 이 방식대로면 버튼마다 일일이 리스너를 붙여야 해요. 게시물이 100개면 100번이고요. 그래서 더 똑똑한 방법을 Step 5~6에서 써요. 파일 최종본(
feed.js)엔 그 똑똑한 버전이 들어가 있어요. 지금은 "클릭 → 함수 실행" 연결의 감만 잡으면 돼요.
💡 이벤트는 "사용자 행동에 브라우저가 붙여주는 알림"이에요.
addEventListener("click", 함수)로 "이 요소가 클릭되면 이 함수를 실행해"라고 미리 연결해둬요. 초인종에 "누르면 문 열기"를 연결하는 것과 같아요.
Step 2: "어떤 버튼을 눌렀나" — 이벤트 객체 event.target
리스너에 연결한 함수는 사실 선물 하나를 자동으로 받아요. 바로 이벤트 객체(event) 예요. 브라우저가 "방금 클릭이 일어났어, 그 상세 정보를 여기 담아줄게" 하고 함수에 슬쩍 건네주는 보따리죠. 받으려면 함수의 괄호 안에 이름을 적어두기만 하면 돼요. 보통 event라고 적어요.
이 보따리 안에서 오늘 가장 많이 쓸 건 event.target이에요. 실제로 눌린 가장 안쪽 요소를 가리켜요. 콘솔에서 직접 확인해볼게요.
// (콘솔 실험용 — 무엇이 눌리는지 관찰)
const feed = document.querySelector("main");
feed.addEventListener("click", (event) => {
console.log("눌린 요소:", event.target);
});
main(피드 전체)에 리스너를 하나 붙이고, 그 안 아무 데나 클릭하면서 콘솔을 보세요. 하트 버튼을 누르면 그 버튼이, 글자를 누르면 그 <p>가 event.target에 찍혀요. "내가 방금 정확히 뭘 눌렀는지"를 브라우저가 알려주는 거예요.
그런데 여기 함정이 하나 있어요. 우리 하트 버튼은 안에 SVG 아이콘이 들어 있어요(D-1 코드에서 봤죠). 그래서 하트 모양을 클릭하면 event.target이 버튼이 아니라 그 안쪽 <svg>가 돼요. "버튼을 눌렀는데 왜 svg가 잡히지?" 하고 당황하기 쉬워요.
<button class="icon-btn-like"> ← 우리가 원하는 진짜 대상
└─ <svg class="ico"> ← 그런데 클릭은 여기서 일어남
└─ <use> ← 더 안쪽일 수도
하트를 클릭 → event.target 은 가장 안쪽 svg(또는 use)
→ 버튼을 찾으려면 거기서 "위로" 올라가야 해요
해결책은 closest예요. event.target.closest(".icon-btn-like")라고 하면, 눌린 요소에서 시작해 부모 쪽으로 올라가며 가장 가까운 .icon-btn-like 요소를 찾아줘요.
svg를 눌렀어도, 그 svg를 감싼 버튼을 자동으로 찾아주는 거죠. "이 사람 말고, 이 사람을 감싼 집안 어른을 찾아줘"라고 가계도를 거슬러 올라가는 셈이에요.
// (콘솔 실험용 — svg 를 눌러도 버튼을 찾아냄)
feed.addEventListener("click", (event) => {
const likeBtn = event.target.closest(".icon-btn-like");
console.log("진짜 버튼:", likeBtn); // svg 를 눌러도 버튼이 잡힘
});
closest는 다음 Step들에서 위임을 구현할 때 핵심 무기로 계속 써요. 지금은 "눌린 곳에서 위로 올라가 진짜 대상을 찾는 도구"라고만 기억하면 돼요.
💡 리스너 함수는
event객체를 받아요.event.target은 실제로 눌린 가장 안쪽 요소예요. 버튼 안에 아이콘이 있으면 아이콘이 잡히니까,event.target.closest(".클래스")로 위로 올라가 진짜 대상을 찾아요.
Step 3: "기본 동작을 멈춰라" — preventDefault()
이벤트 객체엔 또 하나 중요한 기능이 있어요. 바로 브라우저의 기본 동작을 멈추는 event.preventDefault()예요. 이게 왜 필요한지는 우리 댓글 폼을 보면 바로 와닿아요.
HTML의 어떤 요소들은 클릭하거나 제출하면 브라우저가 알아서 하는 동작이 정해져 있어요.
예를 들어 <form>을 제출하면 브라우저가 페이지를 통째로 새로고침해버려요(원래 폼은 서버로 데이터를 보내고 응답 페이지를 다시 받는 게 기본 동작이거든요). A-4에서 만든 로그인 폼을 떠올려보세요. 거기서도 [로그인] 버튼을 누르면 페이지가 새로고침됐죠. 그게 폼의 기본 동작이에요.
그런데 우리 댓글 폼은 새로고침되면 곤란해요. 댓글 한 줄 달자고 화면 전체가 깜빡 새로고침되면 사용자 경험이 엉망이 되니까요. 우리가 원하는 건 "새로고침 없이, 화면에 댓글 한 줄만 스윽 추가"예요. 이때 event.preventDefault()로 기본 동작(새로고침)을 막아요.
우리 댓글 폼은 feed.html에 이렇게 들어 있어요.
<!-- instagram-clone-frontend/feed.html -->
<form class="comment-form">
<textarea class="comment-input" rows="1" placeholder="댓글 달기..."
aria-label="댓글 입력"></textarea>
<button type="submit">게시</button>
</form>
type="submit" 버튼을 누르거나 입력칸에서 엔터를 치면, 이 폼에 "submit"(제출) 이벤트가 일어나요. 여기에 리스너를 붙여서 새로고침을 막고, 대신 D-1에서 만든 addComment로 댓글을 추가해요.
// instagram-clone-frontend/js/feed.js
const form = document.querySelector(".comment-form");
const input = form.querySelector(".comment-input");
form.addEventListener("submit", (event) => {
event.preventDefault(); // 폼의 기본 동작(페이지 새로고침)을 멈춰요
const text = input.value.trim();
if (!text) return; // 빈 댓글은 무시
addComment(0, text); // 첫 게시물에 댓글 추가
input.value = ""; // 입력칸 비우기
});
흐름을 따라가 볼게요. 폼이 제출되면 가장 먼저 event.preventDefault()로 새로고침을 막아요. 그다음 입력칸의 값(input.value)을 가져오는데, .trim()으로 앞뒤 공백을 떼요.
만약 빈 내용이면(if (!text)) 그냥 멈추고요(빈 댓글은 안 다니까요). 내용이 있으면 addComment(0, text)로 첫 게시물에 댓글을 추가하고, 마지막으로 input.value = ""로 입력칸을 비워서 다음 댓글을 칠 수 있게 해요.
이제 feed.html을 Live Server로 열고, 첫 게시물 댓글칸에 글자를 입력한 뒤 [게시]를 누르거나 엔터를 쳐보세요. 새로고침 없이 댓글이 한 줄 스윽 추가되고, 입력칸이 깔끔하게 비워져요. 폼이 진짜 댓글 기능처럼 동작하는 거예요.
💡
event.preventDefault()는 브라우저의 기본 동작을 멈춰요. 폼 제출의 기본 동작은 페이지 새로고침인데, 이걸 막고 대신 우리가 원하는 동작(댓글 추가)을 실행해요.<a>링크 클릭의 기본 동작(페이지 이동)도 같은 방법으로 막을 수 있어요.
지금은 추가한 댓글이 화면에만 생겨요. 새로고침하면 사라지죠. 다음 시간(D-3)엔 이 댓글을 fetch로 서버에 진짜 저장해서, 새로고침해도 남아 있게 만들어요. 오늘은 화면 조작까지가 목표예요.
Step 4: "버블링과 캡처" — 이벤트 흐름 원리
여기서 잠깐 이벤트가 어떻게 흘러가는지 원리를 짚고 갈게요. 이걸 알아야 다음 Step의 이벤트 위임이 왜 되는지 이해할 수 있거든요.
피드의 하트 버튼을 클릭했다고 해봐요. 하트는 main > article > button > svg 처럼 여러 겹으로 감싸여 있어요. 그럼 클릭 이벤트는 정확히 어디서 일어나는 걸까요? 정답은 "관련된 모든 요소를 거쳐 지나간다"예요. 두 단계로요.
┌─────────── 캡처(capturing) 단계: 위 → 아래로 내려감 ───────────┐
↓
window → document → <main> → <article> → <button>
│
[ <svg> = target ] ← 실제 클릭된 곳
│
↑
└─────────── 버블링(bubbling) 단계: 아래 → 위로 올라감 ───────────┘
<button> → <article> → <main> → document → window
먼저 캡처(capturing) 단계예요. 이벤트가 가장 바깥(window)에서 시작해 클릭된 요소까지 위에서 아래로 내려와요. 그다음 클릭된 요소(target)에 도착했다가, 이번엔 버블링(bubbling) 단계로 다시 아래에서 위로 올라가요.
버블(bubble)은 거품이라는 뜻인데, 물속 깊은 곳에서 생긴 거품이 보글보글 수면 위로 떠오르듯, 이벤트가 자식에서 부모로 떠오른다고 해서 붙은 이름이에요.
addEventListener는 기본적으로 버블링 단계에서 함수를 실행해요. 즉 자식에서 클릭이 일어나면, 그 클릭이 부모로 올라오면서 부모에 붙은 리스너도 같이 반응한다는 뜻이에요. 콘솔에서 확인해볼게요.
// (콘솔 실험용 — 클릭이 어디까지 올라오는지 관찰)
document.querySelector("main").addEventListener("click", () => {
console.log("main 까지 클릭이 올라왔어요!");
});
main에만 리스너를 붙였는데, 그 안의 하트 버튼이나 svg를 클릭해도 main 까지 클릭이 올라왔어요!가 찍혀요. 버튼에서 일어난 클릭이 버블링으로 main까지 떠올라서, main의 리스너가 반응한 거예요. 바로 이게 다음 Step 이벤트 위임의 원리예요. 부모 하나에만 귀를 달아둬도, 자식에서 일어난 클릭을 전부 받을 수 있는 거죠.
가끔은 이 올라가는 걸 막고 싶을 때도 있어요. 그럴 땐 event.stopPropagation()을 쓰면, 거기서 버블링이 멈춰서 더 위로 안 올라가요. 다만 이건 함부로 쓰면 안 돼요. 위로 올라가는 흐름을 끊으면, 부모에 달아둔 위임 리스너가 클릭을 못 받게 돼서 오히려 기능이 망가질 수 있거든요. 꼭 필요한 경우에만 신중하게 쓰세요.
💡 클릭은 바깥에서 안으로 내려갔다가(캡처), 다시 안에서 바깥으로 올라와요(버블링).
addEventListener는 보통 올라오는 버블링 단계에서 실행돼요. 그래서 부모 하나에 리스너를 달아도 자식의 클릭을 받을 수 있고, 이게 이벤트 위임의 토대예요.
Step 5: "100개 버튼을 100번 연결?" — 이벤트 위임 개론
Step 1에서 봤듯이, 하트 버튼 하나에 리스너를 다는 건 쉬워요. 문제는 우리 피드에 게시물이 10개라는 거예요. 게시물마다 하트가 있으니, 순진하게 만들면 이렇게 돼요.
// ❌ 나쁜 패턴 (실험용 — 파일엔 안 넣어요)
document.querySelectorAll(".icon-btn-like").forEach((btn, index) => {
btn.addEventListener("click", () => toggleLike(index));
});
버튼 전부를 찾아서 하나씩 리스너를 다는 코드예요. 동작은 해요. 하지만 두 가지 문제가 있어요.
첫째, 리스너가 너무 많아요. 게시물이 10개면 리스너 10개, 100개면 100개예요. 리스너 하나하나가 메모리를 조금씩 차지하니, 게시물이 많아질수록 부담이 커져요.
둘째, 더 심각한 문제인데 — 나중에 새로 생긴 버튼은 리스너가 없어요. 위 코드는 페이지가 처음 켜질 때 있던 버튼에만 리스너를 달아요. 그런데 우리는 D-1에서 댓글을 추가하면 "삭제" 버튼이 새로 만들어지는 걸 봤잖아요.
그 버튼들은 이 코드가 실행된 뒤에 생기니까, 리스너가 안 붙어요. 그래서 눌러도 아무 반응이 없죠. D-1에서 삭제 버튼이 먹통이었던 진짜 이유가 이거예요.
그럼 어떻게 할까요? Step 4에서 배운 버블링을 떠올리세요. 클릭은 자식에서 부모로 올라와요. 그러니 부모 한 곳에만 리스너를 달아두고, 올라온 클릭을 부모가 받아서 처리하면 돼요. 이걸 이벤트 위임(event delegation) 이라고 해요. 자식들이 할 일을 부모에게 위임(맡김)한다는 뜻이에요.
❌ 집집마다 사람 두기 ✅ 경비실 하나가 다 받기 (위임)
┌────────────────────┐ ┌────────────────────┐
│ 게시물1 ← 리스너 │ │ main ← 리스너 1개 │
│ 게시물2 ← 리스너 │ │ ├ 게시물1 │
│ 게시물3 ← 리스너 │ │ ├ 게시물2 │
│ ...100개 ← 100개 │ │ └ ...100개 (리스너X)│
└────────────────────┘ └────────────────────┘
새 게시물엔 리스너 없음 새로 생긴 버튼도 자동으로 잡힘
비유하자면 아파트 택배예요. 집집마다 사람이 대기하며 택배를 받는 대신, 경비실 한 곳에서 모든 택배를 받아 분류하는 거죠. 경비실 하나면 충분하고, 새로 이사 온 집의 택배도 경비실이 똑같이 받아줘요. 이벤트 위임도 마찬가지로, 부모 하나가 모든 자식의 클릭을 받고, 나중에 생긴 자식의 클릭도 그대로 받아내요.
💡 버튼마다 리스너를 다는 건 개수가 많아지고, 나중에 생긴 버튼은 놓쳐요. 대신 부모 한 곳에 리스너를 달고 버블링으로 올라온 클릭을 처리하는 게 이벤트 위임이에요. 경비실 하나가 모든 택배를 받듯이요.
Step 6: "부모에서 자식 클릭 낚아채기" — 이벤트 위임 실전 (closest)
이제 위임을 실제 코드로 만들어볼게요. 우리 feed.js는 main 한 곳에만 클릭 리스너를 달고, Step 2에서 배운 closest로 "방금 올라온 클릭이 좋아요 버튼인지, 삭제 버튼인지"를 가려내요.
// instagram-clone-frontend/js/feed.js
// ===== 이벤트 위임 — main 한 곳에서 모든 클릭을 받아요 =====
// 게시물이 10개든 100개든 리스너는 여기 하나뿐. 클릭이 자식에서 부모로 올라오는(버블링) 덕분이에요.
const feed = document.querySelector("main");
feed.addEventListener("click", (event) => {
// event.target = 실제로 눌린 가장 안쪽 요소. closest 로 위로 올라가며 진짜 대상을 찾아요.
// 1) 좋아요 하트 — 안쪽 svg 를 눌러도 closest 가 버튼까지 올라가요
const likeBtn = event.target.closest(".icon-btn-like");
if (likeBtn) {
const article = likeBtn.closest("article");
const index = [...document.querySelectorAll("article")].indexOf(article);
toggleLike(index);
return;
}
// 2) 댓글 삭제 버튼 — 페이지 로드 뒤 새로 생긴 버튼도 위임이라 그대로 잡혀요
const delBtn = event.target.closest(".comment-del");
if (delBtn) {
removeComment(delBtn.closest("li"));
}
});
한 줄씩 따라가 볼게요. main에 클릭 리스너를 하나 달았어요. 피드 안 어디를 클릭하든, 그 클릭이 버블링으로 main까지 올라와 이 함수가 실행돼요.
함수 안에서 먼저 event.target.closest(".icon-btn-like")로 "방금 클릭이 좋아요 버튼(또는 그 안 svg)에서 일어났나?"를 확인해요. 좋아요 버튼이 잡히면(if (likeBtn)), 그 버튼이 속한 게시물(article)을 또 closest("article")로 찾고, 그게 몇 번째 게시물인지 계산해요.
이때 [...document.querySelectorAll("article")]로 게시물 목록을 배열로 펼친 뒤(C-4에서 배운 스프레드죠), .indexOf(article)로 위치를 구해요. 그 번호를 toggleLike(index)에 넘기면, D-1에서 만든 토글이 정확히 그 게시물에서 동작해요. 처리가 끝났으니 return으로 함수를 빠져나가고요.
좋아요 버튼이 아니었다면, 다음으로 event.target.closest(".comment-del")로 "삭제 버튼이 눌렸나?"를 확인해요. 삭제 버튼이 잡히면, 그 버튼이 속한 댓글(<li>)을 찾아 removeComment로 지워요.
여기서 위임의 진짜 힘이 드러나요. D-1에서 댓글을 추가하면 "삭제" 버튼이 새로 만들어진다고 했죠? 그 버튼들은 페이지가 켜진 뒤에 생기지만, 우리는 버튼이 아니라 부모인 main에 리스너를 달았으니까 새로 생긴 삭제 버튼의 클릭도 그대로 main까지 올라와서 잡혀요.
버튼을 새로 만들 때마다 리스너를 다시 달 필요가 전혀 없는 거예요. D-1에서 먹통이던 삭제 버튼이, 오늘 위임 덕분에 드디어 진짜로 동작해요.
💡 부모(
main)에 리스너 하나만 달고,closest로 "좋아요냐 삭제냐"를 가려내요. 핵심은 나중에 새로 생긴 삭제 버튼도 위임이라 그대로 잡힌다는 거예요. 이게 동적으로 늘어나는 요소를 다루는 정석이에요.
Step 7: "너무 빠르게 많이 누르면" — 디바운스/스로틀
클릭은 한 번씩 똑똑 일어나지만, 어떤 이벤트는 순식간에 수십 번 터져요. 대표적으로 두 가지예요. "input"(입력) 이벤트는 글자를 한 자 칠 때마다 터지고, "scroll"(스크롤) 이벤트는 페이지를 조금만 움직여도 1초에 수십 번씩 터져요.
이게 왜 문제일까요? 만약 글자를 칠 때마다 무거운 작업(예: 서버에 검색 요청)을 한다면, "안녕하세요"를 치는 동안 요청이 5번이나 날아가요. 스크롤도 마찬가지로, 살짝 내리는 동안 함수가 수십 번 불려서 화면이 버벅거려요. 그래서 호출 횟수를 줄이는 두 가지 기법을 써요. 디바운스와 스로틀이에요. 이 도구는 util.js에 담았어요.
// instagram-clone-frontend/js/util.js
// 디바운스(debounce, 튕김 방지): 호출이 멈추고 delay(ms)가 지나면 그때 딱 한 번 실행.
export function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer); // 이전에 예약해둔 실행을 취소하고
timer = setTimeout(() => fn(...args), delay); // 새로 delay 뒤 실행을 예약
};
}
// 스로틀(throttle, 양 조절): delay(ms)마다 최대 한 번만 실행. 그 사이 호출은 그냥 무시.
export function throttle(fn, delay) {
let waiting = false;
return (...args) => {
if (waiting) return; // 쿨다운 중이면 무시
fn(...args); // 한 번 실행하고
waiting = true; // 쿨다운 시작
setTimeout(() => { waiting = false; }, delay); // delay 뒤 다시 허용
};
}
디바운스(debounce, 튕김 방지) 부터요. 호출이 들어올 때마다 이전 예약을 취소하고(clearTimeout) 새로 예약해요(setTimeout). 그래서 호출이 계속 이어지는 동안엔 실행이 자꾸 뒤로 밀리다가, 호출이 멈추고 정해진 시간(delay)이 지나야 그때 딱 한 번 실행돼요.
비유하면 엘리베이터 문이에요. 사람이 계속 타면 문이 안 닫히고 기다리다가, 아무도 안 들어오면 그제서야 닫히죠. "다 멈춘 뒤에 한 번"이 핵심이에요. 검색어 입력처럼 "다 치고 나서 처리하면 되는" 일에 딱이에요.
스로틀(throttle, 양 조절) 은 달라요. 한 번 실행하면 정해진 시간(delay) 동안 쿨다운에 들어가서, 그사이 들어온 호출은 그냥 무시해요. 그래서 아무리 자주 불려도 정해진 간격마다 최대 한 번만 실행돼요.
비유하면 회전목마예요. 사람이 아무리 줄을 서도, 회전목마는 정해진 간격마다 출발하죠. 스크롤처럼 "꾸준히 일정 간격으로만 반응하면 되는" 일에 어울려요.
입력/스크롤 이벤트: ▮▮▮▮▮▮▮▮▮▮▮▮▮▮ (쉴 새 없이 터짐)
디바운스(0.4초): ────────────────● (멈춘 뒤 마지막에 딱 한 번)
↑ 입력이 멈추고 0.4초 후
스로틀(0.3초): ●──────●──────●── (정해진 간격마다 한 번씩)
↑0.3초 ↑0.3초 ↑0.3초
우리 feed.js에선 둘을 이렇게 써요. 댓글칸 입력 글자 수는 디바운스로(다 치고 멈춘 뒤 한 번), 스크롤 위치는 스로틀로(0.3초마다 한 번) 콘솔에 찍어요.
// instagram-clone-frontend/js/feed.js
// ===== 입력 중 글자 수 — 디바운스 (입력이 멈춘 뒤 0.4초에 한 번만) =====
const showCount = debounce(() => {
console.log("현재 글자 수:", input.value.length);
}, 400);
input.addEventListener("input", showCount);
// ===== 스크롤 위치 — 스로틀 (0.3초에 한 번만) =====
const onScroll = throttle(() => {
console.log("스크롤 위치:", Math.round(window.scrollY));
}, 300);
window.addEventListener("scroll", onScroll);
debounce(함수, 400)은 "그 함수를, 입력이 멈춘 뒤 400밀리초(0.4초)에 한 번만 실행되게 감싼" 새 함수를 돌려줘요(C-3에서 배운 클로저가 여기 쓰였어요). 그걸 input 이벤트에 연결하면, 댓글칸에 글자를 와다다 쳐도 콘솔엔 "다 치고 멈춘 뒤 한 번"만 글자 수가 찍혀요. 스크롤도 마찬가지로, throttle(함수, 300)으로 감싸서 페이지를 쭉 내려도 0.3초에 한 번씩만 위치가 찍혀요. 콘솔을 열고 직접 입력하고 스크롤해보면, 이벤트가 솎아져서 찍히는 걸 눈으로 볼 수 있어요.
💡 입력·스크롤처럼 폭발하는 이벤트는 호출을 솎아내요. 디바운스는 "멈춘 뒤 한 번"(엘리베이터 문), 스로틀은 "정해진 간격마다 한 번"(회전목마)이에요. 검색어엔 디바운스, 스크롤엔 스로틀이 잘 어울려요.
Step 8: "종합" — 클릭으로 살아나는 인스타그램
오늘 배운 걸 한자리에 모아볼게요. 우리 feed.js는 이제 이렇게 동작해요. 전부 main 한 곳의 위임 리스너와 폼 리스너에서 출발해요.
- 하트 클릭 → 버블링으로
main까지 올라옴 →closest로 좋아요 버튼 확인 →toggleLike→ 하트 빨개지고 숫자 +1 - 댓글 작성 → 폼 제출 →
preventDefault로 새로고침 막음 →addComment→ 댓글 목록에 한 줄 추가 - 삭제 클릭 → 새로 생긴 버튼이어도 위임이라 잡힘 →
closest로 댓글<li>찾기 →removeComment→ 댓글 사라짐 - 입력/스크롤 → 디바운스·스로틀로 솎아내 콘솔에 찍힘
파일 맨 위를 보면, 첫 게시물에 댓글 두 줄을 미리 깔아두는 코드가 있어요. 이건 D-1에서 만든 addComment를 그대로 부르는 거예요. 새 코드가 아니라, 지난 시간에 만든 함수가 오늘 이벤트와 연결되는 거죠.
// instagram-clone-frontend/js/feed.js
import { toggleLike } from "./like.js";
import { addComment, removeComment } from "./comment.js";
import { debounce, throttle } from "./util.js";
// 첫 게시물에 댓글 두 줄을 미리 깔아둬요. addComment 가 '삭제' 버튼까지 함께 만들어줘요.
addComment(0, "minji_ 사진 너무 예뻐요!");
addComment(0, "yuna 다음 여행 같이 가요");
D-1에서 만든 toggleLike·addComment·removeComment를 import로 불러오고(C-5 모듈이죠), util.js의 debounce·throttle도 가져와요. 이 함수들이 이제 콘솔 호출이 아니라 진짜 클릭과 제출에 연결돼요.
이제 진짜 확인해볼 시간이에요. VS Code에서 feed.html을 Live Server로 열어보세요(type="module"을 쓰니 반드시 Live Server여야 해요. file://로 그냥 열면 모듈이 안 돌아가요).
직접 눌러보세요. 첫 게시물 하트를 클릭하면 빨개지면서 숫자가 1,240 → 1,241로 올라가요. 한 번 더 누르면 원래대로 돌아오고요. 댓글칸에 글자를 치고 [게시]를 누르면 댓글이 한 줄 추가돼요. 그 댓글 옆 "삭제"를 누르면 사라지고요. 콘솔(F12)을 열어두면 글자 수와 스크롤 위치도 솎아져서 찍혀요.
지난 시간엔 콘솔에 toggleLike(0)을 손으로 쳐야 했는데, 오늘은 손가락으로 화면을 직접 누르면 인스타가 반응해요. 이 "콘솔 호출 → 실제 클릭"이야말로 오늘의 가장 큰 전환점이에요. 멈춰 있던 정적인 클론이, 드디어 사용자 손에 살아 움직이는 진짜 앱이 된 거예요.
💡 모든 동작이
main한 곳의 위임 리스너와 폼 리스너에서 출발해요. 콘솔을 떠나 화면을 직접 누르는 순간, 우리가 D-1부터 쌓아온 함수들이 진짜 인터랙션으로 완성돼요. 꼭 Live Server로 열어 직접 눌러보세요.
마무리
오늘 우리는 멈춰 있던 화면에 "반응"을 달았어요. 콘솔 호출에 머물던 함수들이 진짜 클릭과 제출에 연결되면서, 인스타 클론이 처음으로 사용자 손에 살아났죠. 되짚어볼게요.
- 이벤트 / addEventListener — 사용자 행동에 브라우저가 붙여주는 알림.
addEventListener("click", 함수)로 행동에 함수를 연결. - event.target / closest — 실제 눌린 가장 안쪽 요소가
event.target. 버튼 안 아이콘이 잡히면closest로 위로 올라가 진짜 대상을 찾음. - preventDefault — 폼 제출 시 새로고침 같은 기본 동작을 멈춤. 그 자리에 우리 동작(댓글 추가)을 넣음.
- 버블링 / 캡처 — 클릭은 안에서 바깥으로 떠오름(버블링). 그래서 부모 하나가 자식들의 클릭을 받을 수 있음.
- 이벤트 위임 — 부모 한 곳에 리스너 하나. 게시물 100개여도, 나중에 새로 생긴 삭제 버튼이어도 그대로 잡힘.
- 디바운스 / 스로틀 — 폭발하는 입력·스크롤 이벤트를 솎아냄. "멈춘 뒤 한 번"(디바운스), "간격마다 한 번"(스로틀).
한 줄로 외워두세요. 행동에 함수를 연결하고(addEventListener) → 부모에서 한 번에 받고(이벤트 위임) → 너무 잦으면 솎아낸다(디바운스·스로틀). 이 흐름이 앞으로 만들 모든 인터랙션의 뼈대예요.
다음 시간 예고
그런데 한 가지 아쉬운 점이 있어요. 오늘 추가한 댓글, 토글한 좋아요는 화면에만 살아 있어요. 새로고침하면 싹 사라지죠. 게다가 지금 게시물 데이터는 feed.html 안에 글자로 고정돼 있어요. 진짜 인스타라면 게시물이 서버에서 계속 새로 내려와야 하는데 말이죠.
다음 시간(D-3)엔 이 벽을 넘어요. fetch 라는 도구로 서버에서 진짜 데이터를 불러와 화면에 뿌리는 거예요. 우리 컴퓨터에 작은 가짜 서버(json-server, Mock API)를 띄워서, 마치 진짜 서버처럼 게시물 목록을 주고받아요.
오늘 만든 댓글도 화면에만 추가하는 게 아니라 서버에 진짜 저장하게 되고요. 고정된 화면이 살아 있는 데이터로 채워지는 순간이에요. 다음 시간에 만나요!
과제
오늘 배운 이벤트(addEventListener·event.target·closest·preventDefault·이벤트 위임·디바운스/스로틀)를 직접 익혀볼 차례예요.
모든 과제는 feed.html을 Live Server로 열고, 직접 클릭·입력·스크롤하며 화면과 콘솔(F12)에서 결과를 확인하세요. 아직 서버 통신(fetch)은 쓰지 않아요 — 오늘 배운 이벤트 도구만으로 충분히 풀 수 있어요.
[구현] 저장(북마크) 버튼도 위임으로 토글하기
좋아요와 똑같은 방식으로, 저장 버튼(.icon-btn-save)에 is-active 토글을 붙여보세요.
feed.js의main위임 리스너 안에, 좋아요 분기 다음에 저장 버튼 분기를 하나 더 추가하세요.event.target.closest(".icon-btn-save")로 저장 버튼을 찾고, 잡혔으면 그 버튼의classList.toggle("is-active")로 켜고 끄세요.- 새 리스너를 따로 달지 말고, 기존
main위임 리스너 한 곳에 분기만 추가해야 한다는 점에 주목하세요. - 저장 버튼을 클릭할 때마다 상태가 켜졌다 꺼지는지 화면에서 확인하세요.
[구현] 댓글 입력 글자 수를 화면에 디바운스로 표시하기
콘솔이 아니라 진짜 화면에 글자 수가 뜨도록 만들어보세요.
- 댓글 폼 옆에 글자 수를 표시할 빈 요소를 하나 두세요(예:
<span class="char-count"></span>). - 입력 이벤트에 연결된 디바운스 함수가,
console.log대신 그 요소의textContent를 바꾸도록 고쳐보세요. - 댓글칸에 글자를 빠르게 쳐보며, 입력을 멈춘 뒤에야 글자 수가 갱신되는지 확인하세요.
- 디바운스 시간(0.4초)을 짧게/길게 바꿔보고 반응이 어떻게 달라지는지 실험해보세요.
[탐구] event.target과 event.currentTarget의 차이 추적하기
이벤트 객체엔 target 말고 currentTarget도 있어요. 위임 코드에서 둘이 어떻게 다른지 직접 확인해보세요.
main위임 리스너 안에서console.log(event.target, event.currentTarget)을 찍어보세요.- 하트 안 svg를 클릭했을 때 둘이 각각 무엇을 가리키는지 비교해보세요(하나는 실제 눌린 요소, 하나는 리스너가 달린 요소예요).
- 만약 삭제 버튼 분기에
event.stopPropagation()을 넣으면 위임이 어떻게 깨지는지도 실험해보고, 왜 위임에서stopPropagation을 함부로 쓰면 안 되는지 정리해보세요.
생각해볼 주제
정답을 적는 문제가 아니에요. 오늘 배운 것의 "왜"를 곱씹어보는 질문들이에요. 스스로 답을 만들어본 뒤, 예시답안과 비교해보세요.
1. 무한 스크롤로 새 게시물이 계속 들어온다면, 그 버튼들은 어떻게 동작할까?
진짜 인스타는 아래로 스크롤할수록 게시물이 계속 새로 나타나요. 그 게시물 하나하나에도 하트·댓글·삭제 버튼이 있죠. 그런데 그 버튼들은 페이지가 처음 켜진 뒤에 만들어진 것들이에요.
만약 우리가 Step 5의 "버튼마다 리스너 달기" 방식을 썼다면, 새로 들어온 게시물의 버튼은 어떻게 됐을까요? 반대로 오늘 만든 이벤트 위임 방식이라면 왜 별도 처리 없이도 그 버튼들이 동작할까요? "나중에 생긴 요소"라는 관점에서 두 방식의 차이를 곱씹어보세요.
2. 검색어 자동완성에는 디바운스, 무한 스크롤 로딩에는 스로틀 — 왜 그럴까?
검색창에 글자를 칠 때 자동완성 목록을 띄우는 기능과, 스크롤을 끝까지 내렸을 때 다음 게시물을 불러오는 기능이 있다고 해봐요. 둘 다 자주 터지는 이벤트지만, 어울리는 솎기 방식이 달라요.
검색어 자동완성엔 디바운스(멈춘 뒤 한 번)와 스로틀(간격마다 한 번) 중 어느 쪽이 자연스러울까요? 무한 스크롤 로딩 트리거엔 또 어느 쪽이 맞을까요? "사용자가 입력을 끝낸 시점"과 "일정 간격으로 꾸준히 확인해야 하는 상황"의 차이를 떠올리며, 각 기능에 왜 그 방식이 어울리는지 생각해보세요.
✅ 예시 답안정답 보기
여기 있는 답안은 "이것만 정답"이 아니라 모범 사례 중 하나예요. 같은 동작을 만드는 길은 여러 갈래라서, 여러분이 짠 코드가 모양이 조금 달라도 핵심 동작만 맞으면 충분히 잘 푼 거예요. 답안과 비교하면서 "왜 이렇게 했는지"를 곱씹는 게 진짜 공부예요.
과제 1: 저장(북마크) 버튼도 위임으로 토글하기
핵심 접근
좋아요 버튼이 이미 main 한 곳의 위임 리스너 안에서 처리되고 있어요. 저장 버튼도 똑같은 패턴이라, 새 리스너를 따로 달 필요가 전혀 없어요. 기존 위임 리스너 안에 event.target.closest(".icon-btn-save") 분기 하나만 더 넣으면 끝이에요.
잡혔으면 그 버튼의 classList.toggle("is-active")로 켜고 끄면 돼요.
예시 구현
// instagram-clone-frontend/js/feed.js
feed.addEventListener("click", (event) => {
// 1) 좋아요 하트 (기존)
const likeBtn = event.target.closest(".icon-btn-like");
if (likeBtn) {
const article = likeBtn.closest("article");
const index = [...document.querySelectorAll("article")].indexOf(article);
toggleLike(index);
return;
}
// 2) 저장(북마크) 버튼 — 좋아요와 똑같은 위임 패턴, 분기만 추가
const saveBtn = event.target.closest(".icon-btn-save");
if (saveBtn) {
saveBtn.classList.toggle("is-active"); // 켜졌다 꺼졌다 토글
return;
}
// 3) 댓글 삭제 버튼 (기존)
const delBtn = event.target.closest(".comment-del");
if (delBtn) {
removeComment(delBtn.closest("li"));
}
});
저장 버튼은 좋아요처럼 게시물 번호를 계산할 필요가 없어요. 그냥 눌린 그 버튼 자체를 켜고 끄면 되니까, saveBtn.classList.toggle("is-active") 한 줄이면 충분해요. 좋아요 분기처럼 return으로 빠져나가 두면, 저장 버튼을 처리한 뒤 아래 댓글 삭제 검사까지 헛돌지 않아요.
채점 포인트
| 포인트 | 무엇을 보는가 | 배점 가중 |
|---|---|---|
| 기존 위임 리스너에 분기 추가 | 새 addEventListener를 따로 달지 않고 기존 main 리스너 한 곳에 분기만 넣었는가 |
상 |
closest로 저장 버튼 찾기 |
event.target.closest(".icon-btn-save")로 안쪽 svg를 눌러도 버튼이 잡히게 했는가 |
상 |
classList.toggle 사용 |
is-active를 토글로 켜고 끄는가 (add만 하면 다시 못 끔) |
중 |
return으로 분기 종료 |
저장 처리 후 아래 분기를 헛돌지 않게 막았는가 | 하 |
| 화면 확인 | 클릭할 때마다 상태가 켜졌다 꺼지는지 직접 눌러봤는가 | 하 |
흔한 실수
- 저장 버튼만 따로
addEventListener를 새로 달았다 → 동작은 하지만 위임의 장점을 버린 거예요. 좋아요·삭제와 한 리스너에서 같이 처리하는 게 이 과제의 핵심이에요. event.target.classList.toggle(...)을 그대로 썼다 → 하트처럼 저장 버튼 안에도 svg가 있어서, svg를 누르면event.target은 svg예요. svg에is-active가 붙어버려 CSS가 안 먹어요. 반드시closest로 버튼까지 올라가야 해요.classList.add("is-active")만 썼다 → 한 번 켜지면 다시 꺼지지 않아요. 토글은toggle이어야 켜고 끄기가 둘 다 돼요.
실무 개선 포인트 (심화)
- 저장 상태를 어딘가 기억해두기: 지금은 새로고침하면 저장 상태가 싹 사라져요. 다음 시간(D-3)에 배울 서버 통신으로 "이 게시물을 저장했다"를 진짜 저장하면, 새로고침해도 남아 있게 만들 수 있어요. 오늘은 화면 토글까지가 목표예요.
- 저장/좋아요 분기를 데이터로 묶기: 버튼 종류가 늘어나면
if분기가 길어져요. 실무에서는 버튼마다 "어떤 동작인지"를 표시해두고, 위임 리스너에서 그 표시를 읽어 한 번에 처리하는 식으로 분기를 줄이기도 해요. 지금은if세 개로 충분하지만, 버튼이 열 개씩 늘어나면 이런 정리가 도움이 돼요.
과제 2: 댓글 입력 글자 수를 화면에 디바운스로 표시하기
핵심 접근
지금은 글자 수가 console.log로 콘솔에만 찍혀요. 이걸 진짜 화면에 보이게 하려면 두 가지만 하면 돼요.
먼저 글자 수를 담을 빈 요소(<span class="char-count">)를 댓글 폼 옆에 하나 두고, 디바운스 함수 안에서 console.log 대신 그 요소의 textContent를 바꾸면 돼요. 디바운스라서 글자를 와다다 쳐도, 입력이 멈춘 뒤에야 숫자가 갱신되는 게 핵심이에요.
예시 구현
먼저 feed.html의 댓글 폼 안에 빈 표시 요소를 하나 추가해요.
<!-- instagram-clone-frontend/feed.html -->
<form class="comment-form">
<textarea class="comment-input" rows="1" placeholder="댓글 달기..."
aria-label="댓글 입력"></textarea>
<button type="submit">게시</button>
<span class="char-count"></span> <!-- 글자 수가 여기 뜨게 -->
</form>
그다음 feed.js의 디바운스 함수를 textContent 갱신으로 고쳐요.
// instagram-clone-frontend/js/feed.js
const charCount = document.querySelector(".char-count");
// 입력이 멈춘 뒤 0.4초에 한 번만 글자 수를 화면에 표시해요
const showCount = debounce(() => {
charCount.textContent = `${input.value.length}자`; // 콘솔 대신 화면에
}, 400);
input.addEventListener("input", showCount);
debounce는 util.js에 있는 걸 그대로 써요. 바뀐 건 안쪽 함수가 console.log(...) 대신 charCount.textContent = ...를 하는 것뿐이에요. 댓글칸에 글자를 빠르게 치면 숫자가 바로바로 안 바뀌고, 손을 멈춘 0.4초 뒤에 "5자"처럼 한 번에 갱신돼요. 이게 디바운스의 "다 치고 멈춘 뒤 한 번"이에요.
채점 포인트
| 포인트 | 무엇을 보는가 | 배점 가중 |
|---|---|---|
| 표시 요소 추가 | <span class="char-count"> 같은 빈 요소를 HTML에 두었는가 |
중 |
textContent 갱신 |
console.log 대신 그 요소의 textContent를 바꿨는가 |
상 |
| 디바운스 유지 | debounce로 감싼 채 input 이벤트에 연결했는가 (감싸지 않으면 글자마다 갱신) |
상 |
| 멈춘 뒤 갱신 확인 | 빠르게 칠 땐 안 바뀌고 멈춘 뒤 갱신되는지 화면에서 봤는가 | 중 |
| delay 실험 | 0.4초를 짧게/길게 바꿔보며 반응 차이를 관찰했는가 | 하 |
흔한 실수
document.querySelector(".char-count")를 매번 디바운스 안에서 찾았다 → 동작은 하지만 입력할 때마다 요소를 새로 찾아요. 한 번 찾아 변수에 담아두고 재사용하는 게 깔끔해요.- 디바운스를 벗기고 input에 직접 연결했다 → 그러면 글자 한 자 칠 때마다 즉시 갱신돼서, 이 과제가 보려던 "멈춘 뒤 한 번"을 못 봐요.
debounce로 감싼 채 연결해야 해요. innerHTML로 글자 수를 넣었다 → 단순 숫자라 동작은 하지만, 화면에 글자만 넣을 땐textContent가 더 안전하고 적절해요(D-1에서 배운 "남의 입력은 textContent" 원칙).
실무 개선 포인트 (심화)
- 글자 수 제한과 색 경고: 댓글에 최대 글자 수(예: 150자)가 있다면, 남은 글자 수를 보여주거나 한도에 가까워지면 글자 수 색을 빨갛게 바꾸는 식으로 발전시킬 수 있어요. 지금 배운
textContent갱신에 조건(if)을 하나 더 얹으면 돼요. - 검색어에 디바운스 응용: 이 패턴은 그대로 검색창 자동완성에 쓰여요. "입력이 멈춘 뒤 한 번"이라는 디바운스의 성격이 검색에 딱 맞거든요. 자세한 이유는 아래 생각해볼 주제 2에서 다뤄요.
과제 3: event.target과 event.currentTarget의 차이 추적하기
핵심 접근
이건 코드를 많이 짜는 과제가 아니라 관찰하고 정리하는 탐구예요. 위임 리스너 안에서 event.target과 event.currentTarget을 나란히 찍어보면, 둘이 가리키는 게 다르다는 걸 눈으로 확인할 수 있어요.
target은 실제로 눌린 가장 안쪽 요소, currentTarget은 리스너가 달린 요소(여기선 main)예요. 그리고 stopPropagation을 위임에서 함부로 쓰면 왜 위험한지까지 정리하는 게 목표예요.
예시 구현
위임 리스너 맨 앞에 관찰용 한 줄을 넣고, 하트 안 svg를 클릭해보세요.
// instagram-clone-frontend/js/feed.js
feed.addEventListener("click", (event) => {
// 관찰용 — 하트 안 svg 를 눌렀을 때 둘이 뭘 가리키는지 비교
console.log("target:", event.target); // 실제 눌린 가장 안쪽 (svg 또는 use)
console.log("currentTarget:", event.currentTarget); // 리스너가 달린 요소 (항상 main)
// ... 기존 좋아요/저장/삭제 분기 ...
});
하트 모양(svg)을 클릭하면 콘솔에 이렇게 나와요.
하트의 svg 를 클릭했을 때:
target → <svg class="ico"> 또는 <use> (실제로 눌린 가장 안쪽)
currentTarget → <main> (리스너가 달린 곳, 항상 고정)
어디를 누르든 currentTarget 은 늘 main.
target 은 누른 위치마다 svg·use·p·button 등으로 바뀜.
target은 "내가 손가락으로 정확히 어디를 찍었나"라서 누를 때마다 달라져요. currentTarget은 "이 리스너가 어느 요소에 붙어 있나"라서 위임 리스너인 한 늘 main으로 고정이에요. 그래서 위임에서 진짜 대상(버튼)을 찾을 땐 event.target에서 closest로 위로 올라가는 거예요. currentTarget은 항상 main이라 버튼을 가려내는 데엔 쓸 수 없거든요.
이제 stopPropagation 실험이에요. 삭제 버튼에만 따로 리스너를 달고 거기서 버블링을 멈춰보면, 위임이 어떻게 깨지는지 보여요.
// (실험용 — 왜 위임이 깨지는지 관찰. 파일 최종본엔 넣지 마세요)
const someDelBtn = document.querySelector(".comment-del");
someDelBtn.addEventListener("click", (event) => {
event.stopPropagation(); // 여기서 버블링을 끊어버림
console.log("삭제 버튼에서 버블링을 멈췄어요");
});
이 리스너가 클릭을 먼저 받아 stopPropagation()을 부르면, 클릭이 main까지 못 올라가요. 그러면 main에 달아둔 위임 리스너의 삭제 분기가 영영 실행되지 않아서, 삭제 버튼이 다시 먹통이 돼요.
위임은 "클릭이 부모까지 올라온다"는 전제 위에 서 있는데, stopPropagation이 그 올라오는 길을 끊어버리기 때문이에요.
채점 포인트
| 포인트 | 무엇을 보는가 | 배점 가중 |
|---|---|---|
| target vs currentTarget 구분 | target=실제 눌린 안쪽 요소, currentTarget=리스너 달린 요소(main)로 정확히 구분했는가 | 상 |
| svg 클릭 관찰 | 하트 svg 클릭 시 target은 svg/use, currentTarget은 main임을 확인했는가 | 상 |
| stopPropagation의 위험 정리 | "버블링을 끊으면 부모 위임이 클릭을 못 받아 깨진다"를 설명했는가 | 상 |
| currentTarget이 고정인 이유 | 어디를 눌러도 currentTarget이 main으로 고정됨을 이해했는가 | 중 |
| 실험 코드 분리 | stopPropagation 실험을 최종본이 아닌 관찰용으로 다뤘는가 | 하 |
흔한 실수
- target과 currentTarget을 반대로 설명했다 → currentTarget이 "실제 눌린 곳"이라고 헷갈리기 쉬워요. currentTarget은 늘 리스너가 달린
main이고, 변하는 건 target이에요. - "stopPropagation을 쓰면 좋다"고 결론 냈다 → 위임 구조에서는 오히려 독이에요. 자식에서 버블링을 끊으면 부모 위임 리스너가 클릭을 통째로 놓쳐요. 꼭 필요한 특수한 경우가 아니면 위임과 함께 쓰지 않는 게 안전해요.
stopPropagation과preventDefault를 헷갈렸다 →preventDefault는 브라우저 기본 동작(새로고침·링크 이동)을 막는 거고,stopPropagation은 이벤트가 부모로 올라가는 걸 막는 거예요. 둘은 완전히 다른 일을 해요.
실무 개선 포인트 (심화)
- currentTarget을 활용하는 자리: 위임이 아니라 버튼 하나에 직접 리스너를 달았을 때는
event.currentTarget이 그 버튼 자신이라서 편하게 쓸 수 있어요. 즉 둘 중 뭘 쓸지는 "리스너를 부모에 달았나, 요소 자신에 달았나"에 따라 갈려요. - stopPropagation이 진짜 필요한 드문 경우: 모달(팝업) 안쪽을 클릭했을 때 "바깥 클릭으로 닫기" 리스너까지 같이 반응하면 곤란할 때가 있어요. 이럴 땐 안쪽 클릭의 버블링을 의도적으로 막기도 해요. 다만 이건 "왜 막는지 분명할 때만" 쓰는 예외예요. 평소 위임에서는 함부로 쓰지 않아요.
생각해볼 주제 1: 무한 스크롤로 새 게시물이 계속 들어온다면, 그 버튼들은 어떻게 동작할까?
[문제 상황 요약]
진짜 인스타는 아래로 스크롤할수록 게시물이 계속 새로 나타나요. 그 게시물 하나하나에도 하트·댓글·삭제 버튼이 달려 있죠. 그런데 이 버튼들은 페이지가 처음 켜진 뒤에 새로 만들어진 것들이에요. 우리가 "버튼마다 리스너 달기" 방식을 썼다면 이 새 버튼들은 어떻게 됐을까요? 반대로 오늘 배운 이벤트 위임이라면 왜 별도 처리 없이도 동작할까요?
[튜터의 가이드 및 해설]
이 질문의 핵심은 "나중에 생긴 요소" 라는 한 마디예요. 이벤트 리스너는 "지금 화면에 있는 요소"에만 달려요. 페이지가 켜진 뒤에 새로 만들어진 요소는, 그 시점엔 존재하지도 않았으니 리스너가 붙을 자리가 없어요.
두 방식을 비교해볼게요.
-
Option A — 버튼마다 리스너 달기: 페이지가 켜질 때
querySelectorAll로 버튼을 모두 찾아 하나씩 리스너를 달아요. 처음 10개 버튼은 잘 동작해요. 그런데 스크롤로 11번째 게시물이 들어오면, 그 버튼은 리스너를 다는 코드가 이미 끝난 뒤에 생겼으니 리스너가 없어요. 눌러도 무반응이에요. 새 게시물이 들어올 때마다 "리스너를 다시 달아주는" 코드를 추가로 짜야 하고, 그러다 같은 버튼에 리스너가 두 번 붙는 실수도 생겨요. -
Option B — 이벤트 위임: 부모(
main)에 리스너 하나만 달아둬요. 클릭은 자식에서 부모로 버블링해 올라오니까, 새 게시물의 버튼을 눌러도 그 클릭이main까지 똑같이 올라와요.main의 리스너는 "방금 올라온 게 어떤 버튼인지"만closest로 가려내면 되니까, 버튼이 나중에 생겼든 처음부터 있었든 전혀 신경 안 써도 돼요.
현업에서는 보통 동적으로 늘어나는 목록(무한 스크롤·실시간 댓글·채팅처럼 요소가 계속 추가되는 화면)에는 거의 항상 이벤트 위임을 써요. 새 요소마다 리스너를 다시 다는 건 코드도 늘고 실수도 늘거든요. 우리가 오늘 D-1의 먹통 삭제 버튼을 위임으로 살린 것도 정확히 같은 이유예요. 댓글 삭제 버튼은 addComment로 나중에 생기는데, 위임이라 그대로 잡혔잖아요. 무한 스크롤의 새 게시물 버튼도 똑같은 원리로 그냥 동작해요.
🎯 면접관을 홀리는 핵심 멘트
"리스너는 '지금 존재하는 요소'에만 붙기 때문에, 나중에 추가되는 요소는 개별 리스너 방식으로는 놓칩니다. 그래서 무한 스크롤처럼 요소가 계속 늘어나는 화면에서는 부모 한 곳에 리스너를 달고 버블링으로 올라온 클릭을
closest로 가려내는 이벤트 위임을 씁니다. 새 요소가 들어와도 별도 처리 없이 그대로 동작하고, 리스너 개수도 하나로 유지돼서 메모리에도 유리합니다."
생각해볼 주제 2: 검색어 자동완성에는 디바운스, 무한 스크롤 로딩에는 스로틀 — 왜 그럴까?
[문제 상황 요약]
검색창에 글자를 칠 때 자동완성 목록을 띄우는 기능과, 스크롤을 끝까지 내렸을 때 다음 게시물을 불러오는 기능이 있다고 해봐요. 둘 다 자주 터지는 이벤트지만, 어울리는 솎기 방식이 달라요. 자동완성엔 디바운스, 무한 스크롤엔 스로틀이 자연스러운데, 왜 그럴까요?
[튜터의 가이드 및 해설]
이 질문은 "끝난 시점에 한 번" 이냐 "진행하는 동안 꾸준히" 냐의 차이를 묻는 거예요. 디바운스와 스로틀은 둘 다 호출을 솎아내지만, 솎는 방식이 정반대예요.
-
Option A — 디바운스 (멈춘 뒤 한 번): 호출이 계속되는 동안엔 실행을 미루다가, 입력이 멈춘 뒤 정해진 시간이 지나야 딱 한 번 실행돼요. 검색어 자동완성에 딱 맞아요. 사용자가 "서울"을 칠 때 ㅅ·서·설·서울… 한 글자마다 검색하면 불필요한 요청이 쏟아져요. 우리가 알고 싶은 건 "다 친 단어"지, 치는 도중의 토막이 아니거든요. 그래서 "입력이 멈춘 시점", 즉 단어를 다 친 그 순간에 한 번만 검색하면 충분해요. 엘리베이터 문이 사람이 다 탈 때까지 기다렸다 닫히는 것과 같아요.
-
Option B — 스로틀 (간격마다 한 번): 한 번 실행하면 정해진 시간 동안 쉬고, 그사이 호출은 무시해요. 그래서 이벤트가 계속되는 동안 일정 간격으로 꾸준히 실행돼요. 무한 스크롤 로딩에 맞아요. 스크롤은 "언제 멈출지" 미리 알 수 없고, 내리는 도중에도 "지금 바닥 근처인가?"를 꾸준히 확인해야 다음 게시물을 제때 불러올 수 있어요. 만약 무한 스크롤에 디바운스를 쓰면 "스크롤을 멈춘 뒤에야" 확인하니까, 사용자가 계속 내리는 동안엔 아무것도 안 불러와서 화면 끝에 도달해도 멍하니 빈 화면을 보게 돼요.
정리하면 이렇게 갈려요.
검색어 자동완성: 사용자가 입력을 "끝낸 시점"이 중요 → 디바운스 (멈춘 뒤 한 번)
무한 스크롤: 내리는 "도중에 꾸준히 확인"이 중요 → 스로틀 (간격마다 한 번)
현업에서는 보통 이 기준으로 나눠요. "최종 결과 한 번만 필요"한 일(검색·자동완성·입력 검증)엔 디바운스, "진행 중에도 일정하게 반응"해야 하는 일(스크롤·창 크기 조절·드래그)엔 스로틀이에요. 같은 "자주 터지는 이벤트"라도, 내가 원하는 게 "끝난 결과"인지 "진행 중의 꾸준한 반응"인지를 먼저 따져보면 어느 쪽을 쓸지 자연스럽게 정해져요.
🎯 면접관을 홀리는 핵심 멘트
"디바운스와 스로틀의 선택 기준은 '입력이 끝난 시점에 한 번이면 되는가, 아니면 진행하는 동안 꾸준히 반응해야 하는가'입니다. 검색어 자동완성은 사용자가 단어를 다 친 시점의 결과만 필요하니 디바운스로 마지막 한 번만 처리합니다. 무한 스크롤은 내리는 도중에도 바닥 근처인지 계속 확인해야 하니 스로틀로 일정 간격마다 확인합니다. 둘 다 호출을 솎아내지만, '끝난 결과' 대 '꾸준한 확인'이라는 목적 차이가 선택을 가릅니다."