탭(Tab: 서류철의 색인 표처럼 여러 내용을 한 화면에 겹쳐 두고 선택한 항목만 보여주는 사용자 인터페이스) 컴포넌트(Component)는 한정된 화면 공간에 다수의 콘텐츠를 배치하고 사용자가 선택한 항목만 전환하여 표시하는 웹 UI(사용자 인터페이스, User Interface) 요소다. 버튼을 클릭하면 해당하는 영역의 화면 노출 상태가 변경되며, 이는 여러 페이지를 책갈피로 넘겨보는 동작과 유사하다. HTML로 메뉴와 본문 영역 간의 연결 구조를 작성하고, CSS(캐스케이딩 스타일 시트, Cascading Style Sheets)로 활성화 상태에 따른 시각적 배치를 제어한다. 이어지는 단계에서는 제이쿼리(jQuery)와 순수 자바스크립트(JavaScript)로 동작 로직을 구현한 뒤, 이벤트 위임(Event Delegation) 기법을 적용하여 코드를 개선하는 과정을 단계별로 다룬다.

1. 탭
탭메뉴만들기
완성화면
HTML
탭 메뉴의 구조를 정의하는 마크업(Markup)이다. 상단의 메뉴 목록인 .tab-menu와 하단의 본문 영역인 .tab-content 컨테이너로 영역을 분리하여 구성한다.
<div class="tab-wrapper">
<ul class="tab-menu">
<li><a href="#tabs-1" class="active">리미</a></li>
<li><a href="#tabs-2">감자</a></li>
<li><a href="#tabs-3">치킨</a></li>
</ul>
<div class="tab-content">
<div id="tabs-1" class="active">
<h2>리미</h2>
<p>I like 치킨 (치킨시켜) Everybody likes 치킨 (치킨시켜) Let`s eat 치킨 (치킨시켜) 양념 치킨 후라이드 치킨 치킨 치킨 (치킨시켜) (치킨시켜) x2 반은 양념 치킨 반은 후라이드 치킨 무는 많이 줘요 아줌마 부위는 날개는 세게 다리는 네게 목은 빼주세요 아줌마 고도비만 치킨 알이 꽉 찬 치킨 두 마리 같은 걸로 아줌마 구이는 날개는 미듐 가슴도 미듐 다린 웰던으로 아줌마</p>
</div>
<div id="tabs-2">
<h2>감자</h2>
<p>Edge 있게 물티슈, (아줌마) 콜란 서비스 (아줌마) 할인카드... 되죠? (아줌마) 어저께도 거기 (아줌마) 시켰는데 쿠폰 두개 (아줌마) 감사하죠 콜라는 다이어트 중이니까 다이어트 콜라 염통튀김도 몇 개 되요? 원 플러스 원 곱빼기도 되요? 옆 테이블 남는 거 싸가도 되요? 이거 다 타 매장쿠폰 계산 되죠? I like 치킨 (치킨시켜) Everybody likes 치킨 (치킨시켜) Let`s eat 치킨 (치킨시켜) 양념 치킨 후라이드 치킨 치킨 치킨 (치킨시켜) (치킨시켜) x2</p>
</div>
<div id="tabs-3">
<h2>I like 치킨 (나는 반반)</h2>
<p>후라이드 좋다던 첫사랑 그녀 근데 네 옷에 묻어있던 양념 1000원 더 비싸서 양념을 못 사준 내 탓 이 악물고서 나도 성공을 했다 붉은 빛깔 그녀의 입술 같아서 쿠킹호일도 다 핥아먹어 날 떠난 이윤 지독한 가난이라서 양념 밖에는 난 안 먹어 후라이드 좋아한다고 말해야 했지 어떤 날엔 화내야 했지 (오빠가 돈이 어디 있다고 양념을 사줘) 우린 데이트하면 후라이드 우리가 매일 먹고 또 먹던 후라이드 근데 오빠 나도 속물 인가봐 양념이라면 눈이 돌아갔지 양념이 오빠의 피 같아 지금은 후라이드 밖에 못 삼켜 난 양념치킨이 더 좋아 양념을 먹어봐 넌 건강해지고 양념을 먹어봐 넌 머리 좋아져 양념을 먹어봐 넌 웃을 수 있고 양념을 먹어봐 넌 시험 합격해</p>
</div>
</div>
</div>
<div class="tab-wrapper">
<ul class="tab-menu">
<li><a href="#tabs-21" class="active">리미</a></li>
<li><a href="#tabs-22">감자</a></li>
<li><a href="#tabs-23">치킨</a></li>
</ul>
<div class="tab-content">
<div id="tabs-21" class="active">
<h2>리미</h2>
<p>I like 치킨 (치킨시켜) Everybody likes 치킨 (치킨시켜) Let`s eat 치킨 (치킨시켜) 양념 치킨 후라이드 치킨 치킨 치킨 (치킨시켜) (치킨시켜) x2 반은 양념 치킨 반은 후라이드 치킨 무는 많이 줘요 아줌마 부위는 날개는 세게 다리는 네게 목은 빼주세요 아줌마 고도비만 치킨 알이 꽉 찬 치킨 두 마리 같은 걸로 아줌마 구이는 날개는 미듐 가슴도 미듐 다린 웰던으로 아줌마</p>
</div>
<div id="tabs-22">
<h2>감자</h2>
<p>Edge 있게 물티슈, (아줌마) 콜란 서비스 (아줌마) 할인카드... 되죠? (아줌마) 어저께도 거기 (아줌마) 시켰는데 쿠폰 두개 (아줌마) 감사하죠 콜라는 다이어트 중이니까 다이어트 콜라 염통튀김도 몇 개 되요? 원 플러스 원 곱빼기도 되요? 옆 테이블 남는 거 싸가도 되요? 이거 다 타 매장쿠폰 계산 되죠? I like 치킨 (치킨시켜) Everybody likes 치킨 (치킨시켜) Let`s eat 치킨 (치킨시켜) 양념 치킨 후라이드 치킨 치킨 치킨 (치킨시켜) (치킨시켜) x2</p>
</div>
<div id="tabs-23">
<h2>I like 치킨 (나는 반반)</h2>
<p>후라이드 좋다던 첫사랑 그녀 근데 네 옷에 묻어있던 양념 1000원 더 비싸서 양념을 못 사준 내 탓 이 악물고서 나도 성공을 했다 붉은 빛깔 그녀의 입술 같아서 쿠킹호일도 다 핥아먹어 날 떠난 이윤 지독한 가난이라서 양념 밖에는 난 안 먹어 후라이드 좋아한다고 말해야 했지 어떤 날엔 화내야 했지 (오빠가 돈이 어디 있다고 양념을 사줘) 우린 데이트하면 후라이드 우리가 매일 먹고 또 먹던 후라이드 근데 오빠 나도 속물 인가봐 양념이라면 눈이 돌아갔지 양념이 오빠의 피 같아 지금은 후라이드 밖에 못 삼켜 난 양념치킨이 더 좋아 양념을 먹어봐 넌 건강해지고 양념을 먹어봐 넌 머리 좋아져 양념을 먹어봐 넌 웃을 수 있고 양념을 먹어봐 넌 시험 합격해</p>
</div>
</div>
</div>
각 탭 메뉴 항목의 <a> 태그는 href 속성에 해시(#) 기호와 함께 대상 본문의 식별자 ID(아이디, Identifier)를 작성하여 서로를 연결한다. 예를 들어 href="#tabs-1" 속성은 id="tabs-1"인 본문 블록을 참조한다. 클래스(Class: HTML 요소의 유형이나 역할을 지정하기 위해 부여하는 식별 속성으로, 스타일에 이름표를 붙이는 역할을 한다) 중 active 클래스는 첫 화면에서 기본으로 노출할 메뉴 링크와 본문 콘텐츠에 미리 부여한다. 한 문서 안에 독립된 탭 컴포넌트가 둘 이상 존재할 때에는 개별 세트를 .tab-wrapper 요소로 묶어 범위를 분리하며, 본문 ID는 문서 전체에서 유일해야 하므로 두 번째 래퍼 내부에는 tabs-21과 같이 중복되지 않는 고유 식별자를 부여해야 한다. href의 대상 값과 본문 id가 불일치하면 스크립트 실행 시 대상 요소를 찾지 못해 화면 전환이 이루어지지 않는다.
CSS
탭 메뉴 버튼의 수평 정렬과 본문 영역의 중첩 레이아웃을 구성하고, 활성화 상태에 따른 화면 표시를 제어하는 스타일시트(Stylesheet)다.
* {
box-sizing: border-box;
-webkit-font-smoothing: antialised;
}
.tab-wrapper {
width: 960px;
margin: auto;
}
.tab-menu {
padding-left: 0;
}
.tab-menu li {
float: left;
list-style: none;
background: #ebebeb;
margin-right: 10px;
}
.tab-menu:after {
content: '';
display: block;
clear: both;
}
.tab-menu li a {
color: #999;
text-transform: uppercase;
font-weight: bold;
line-height: 27px;
text-decoration: none;
display: block;
padding: 0 20px;
}
.tab-menu li a.active {
background: #000;
color: #fff;
}
.tab-content {
position: relative;
min-height: 300px;
}
.tab-content > div {
position: absolute;
top: 0;
left: 0;
display: none;
background: #e7b36e27 url(http://qwerew.cafe24.com/example/javascript/chicken.png) no-repeat 100px top;
background-size: 40px;
padding: 1em;
}
.tab-content > div.active {
display: block;
}
.tab-menu li에 float: left를 선언하여 수직으로 나열되는 목록 항목을 가로로 정렬하며, 부모 요소의 가상 요소인 .tab-menu:after에 clear: both를 지정하여 자식 요소의 부유(Float)로 인해 부모 영역의 높이가 사라지는 문제를 해결한다. 본문 컨테이너인 .tab-content에는 position: relative를 지정하고, 그 하위의 모든 콘텐츠 블록(.tab-content > div)에 position: absolute와 top: 0; left: 0;을 부여하여 여러 내용이 동일한 좌표에 겹치도록 배치한다. 모든 탭 콘텐츠는 기본적으로 display: none으로 숨겨두며, active 클래스가 결합된 요소(.tab-content > div.active)만 display: block으로 전환되어 화면에 나타난다. 이때 부모 요소인 .tab-content에 최소 높이(min-height)를 지정하지 않으면 절대 위치로 배치된 자식 요소의 높이를 부모가 인식하지 못해 아래쪽의 다른 UI 요소와 겹치는 현상이 발생할 수 있다.
JQ
제이쿼리(jQuery) 라이브러리(Library: 자주 사용하는 코드를 모아둔 함수 집합)를 사용하여 여러 개의 탭 래퍼를 각각 격리하고 클릭 이벤트에 따라 콘텐츠를 전환하는 코드다.
const tabWrapper = $('.tab-wrapper');
//https://api.jquery.com/jquery.each/
tabWrapper.each(function () {
//tabWrapper이 여러개 있을경우 each 문을 작성해야 한다
let currentEl = $(this);
const targetLink = currentEl.find('.tab-menu a');
const tabContent = currentEl.find('.tab-content > div');
targetLink.click(function (e) {
e.preventDefault();
let tg = $(this);
let currentLink = tg.attr('href');
console.log(currentLink);
targetLink.removeClass('active');
tg.addClass('active');
tabContent.hide();
$(currentLink).show();
});
});
$('#tabs-1').css('display', 'block');
tabWrapper.each() 메서드를 사용하여 문서 내의 모든 .tab-wrapper를 순회하며, 각 래퍼 내부의 메뉴 링크(targetLink)와 본문 콘텐츠(tabContent)를 선택한다. 링크를 클릭하면 e.preventDefault()를 호출하여 <a> 태그의 기본 동작인 페이지 이동 및 해시 스크롤을 막는다. 선택자(Selector: 대상 요소를 지정하여 탐색하는 표현식)를 기반으로 tg.attr('href')를 통해 클릭된 요소의 대상 ID 문자열을 취득한 뒤, 모든 메뉴 링크의 active 클래스를 제거하고 클릭된 링크에만 클래스를 부여한다. 이어 전체 콘텐츠를 hide()로 숨긴 후 취득한 대상 요소만 show() 메서드로 화면에 표시한다. 주의할 점은 화살표 함수(() => {})를 사용하면 this가 상위 컨텍스트를 가리키므로 개별 DOM 요소를 바인딩해야 하는 each 콜백 내부에서는 일반 함수 표현식(function() {})을 사용하는 것이 바람직하다.
JS
외부 라이브러리 없이 웹 브라우저의 기본 DOM(문서 객체 모델, Document Object Model: HTML 문서를 계층적 트리 구조로 표현한 객체 모델) API를 활용하여 탭 전환을 제어하는 순수 자바스크립트 코드다.
// 모든 탭 래퍼 선택
const wraps = document.querySelectorAll('.tab-wrapper');
// 각 탭 래퍼 순회
for (let i = 0; i < wraps.length; i++) {
// 현재 래퍼의 메뉴 링크와 콘텐츠 선택
const links = wraps[i].querySelectorAll('ul.tab-menu a');
const tabs = wraps[i].querySelectorAll('.tab-content > div');
// 각 링크에 클릭 이벤트 추가
for (let j = 0; j < links.length; j++) {
links[j].addEventListener('click', function(e) {
e.preventDefault(); // 기본 링크 동작 방지
// 모든 링크와 탭에서 active 제거
for (let k = 0; k < links.length; k++) {
links[k].classList.remove('active');
tabs[k].classList.remove('active');
}
// 클릭한 링크에 active 추가
this.classList.add('active');
// href 속성으로 해당 탭 찾아서 active 추가
document.querySelector(this.getAttribute('href')).classList.add('active');
});
}
}
document.querySelectorAll('.tab-wrapper')로 모든 탭 컨테이너 노드를 가져온 뒤 3중 중첩 for 루프를 사용하여 동작을 처리한다. 첫 번째 루프는 각 탭 래퍼를 순회하고, 두 번째 루프는 래퍼 내부의 각 메뉴 링크에 addEventListener를 통해 클릭 이벤트 리스너(Event Listener: 특정 사건의 발생을 감시하다가 실행되는 함수로, 벨이 울릴 때 문을 열어주는 수문장 역할을 한다)를 직접 등록한다. 클릭이 발생하면 세 번째 루프로 모든 링크와 본문에서 active 클래스를 일괄 삭제한 뒤, 클릭된 요소(this)와 this.getAttribute('href')로 검색한 본문 요소에 active 클래스를 추가한다. 이 방식은 개별 링크마다 리스너를 생성하므로 메뉴 항목의 개수가 많아질수록 브라우저 메모리 사용량이 증가하는 구조적 한계가 있다.
탭 기능 리팩토링
리팩토링(Refactoring: 프로그램의 외부 동작 결과는 그대로 유지하면서 내부 코드의 구조와 가독성을 개선하는 작업)을 통해 복잡한 고전 루프를 함수형 순회 메서드로 정리한다. 배열 메서드인 forEach를 활용하여 가독성을 높인 코드다.
// 모든 탭 래퍼에 탭 기능 적용
const tabWrappers = document.querySelectorAll('.tab-wrapper');
tabWrappers.forEach(wrapper => {
const links = wrapper.querySelectorAll('.tab-menu a');
const contents = wrapper.querySelectorAll('.tab-content > div');
// 각 링크에 클릭 이벤트 추가
links.forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
// 모든 active 제거
links.forEach(l => l.classList.remove('active'));
contents.forEach(c => c.classList.remove('active'));
// 클릭한 링크와 해당 콘텐츠에 active 추가
link.classList.add('active');
const target = document.querySelector(link.getAttribute('href'));
target.classList.add('active');
});
});
});
tabWrappers.forEach와 links.forEach를 사용하여 각 탭 래퍼와 내부 링크 목록을 순회한다. 인덱스 변수를 수동으로 관리하던 이전 방식과 달리 각 요소를 인자로 직접 전달받아 처리하므로 코드가 간결해진다. 클릭 이벤트 내부에서도 forEach를 호출하여 active 클래스를 일괄 제거하고 선택된 대상에만 클래스를 부여한다.
이벤트 위임으로 더 간단하게
이벤트 위임(Event Delegation: 하위 요소 각각에 이벤트 리스너를 붙이지 않고 상위 부모 요소에 하나만 등록하여 이벤트를 총괄 관리하는 기법) 방식을 적용한 코드다. 회사의 대표 번호로 들어온 문의를 담당 부서로 연결하는 방식과 동일하다.
// 이벤트 위임 방식
document.querySelectorAll('.tab-wrapper').forEach(wrapper => {
wrapper.addEventListener('click', e => {
const link = e.target.closest('.tab-menu a');
if (!link) return;
e.preventDefault();
// active 토글
wrapper.querySelectorAll('.active').forEach(el => el.classList.remove('active'));
link.classList.add('active');
document.querySelector(link.getAttribute('href')).classList.add('active');
});
});
모든 링크에 리스너를 개별적으로 등록하지 않고 상위 요소인 .tab-wrapper에 단 하나의 클릭 리스너를 등록한다. 클릭이 발생하면 e.target.closest('.tab-menu a')를 통해 실제 클릭된 대상 요소 또는 그 상위 조상 중에서 탭 링크(.tab-menu a)를 탐색한다. 클릭된 요소가 탭 링크가 아닐 경우에는 if (!link) return; 구문으로 처리를 즉시 중단한다. 유효한 탭 링크가 클릭된 경우 wrapper.querySelectorAll('.active')를 통해 기존 활성 요소를 한 번에 찾아 클래스를 제거한 뒤, 클릭된 링크와 그에 대응하는 본문 요소를 활성화한다.
이벤트 위임의 장점
- 성능: 이벤트 리스너 수 감소
- 동적 요소: 나중에 추가된 요소도 작동
- 메모리: 메모리 사용량 감소
이벤트 위임은 하위 요소에서 발생한 이벤트가 상위 DOM 트리로 전파되는 이벤트 버블링(Event Bubbling: 거품이 수면 위로 올라오듯 자식 요소의 이벤트 신호가 부모 요소로 전달되는 현상) 원리를 기반으로 동작한다. 메뉴 항목마다 리스너를 등록하지 않고 부모 요소에 하나만 등록하므로 브라우저의 메모리 사용량을 줄일 수 있다. 또한 스크립트 실행 이후 새로운 탭 항목이 동적으로 생성되어 추가되더라도 상위 부모 요소가 이벤트를 수신하여 처리하므로 별도의 이벤트 리스너를 다시 바인딩할 필요가 없다.