H-3: Web Components
목차 34
오늘 배울 것
지난 시간(H-2)에 우리는 막대 차트를 두 가지 방법으로 그렸어요. 하나는 fillRect로 직접 손코딩한 거였고, 다른 하나는 Chart.js를 가져다 쓴 거였죠. Chart.js를 쓸 때 우리가 한 일은 사실 간단했어요. <script src="...chart.js"> 한 줄로 남이 만들어 둔 준비된 조각을 빌려와서, new Chart(...)로 불러 쓴 게 전부였어요.
오늘은 그 방향을 거꾸로 뒤집어요. 라이브러리를 빌려 쓰는 게 아니라, 라이브러리 없이 내 전용 태그를 직접 만들어요. <div>나 <button> 같은 브라우저 기본 태그 옆에, <like-button>이나 <post-card> 같은 나만의 HTML 태그를 새로 만드는 거예요. 한 번 만들어 두면 페이지 어디서든 그 태그 한 줄로 불러 쓸 수 있어요.
오늘 손에 넣을 것을 정리하면 이래요.
- 커스텀 엘리먼트(Custom Elements) —
<like-button>같은 내 전용 태그를 정의해요. - 라이프사이클 콜백 — 태그가 화면에 붙고·바뀌고·떨어지는 순간마다 자동으로 코드가 돌아요.
- 속성으로 데이터 주입 —
<like-button count="1240">처럼 HTML 속성으로 값을 넘겨요. - Shadow DOM(그림자 방) — 내 컴포넌트의 스타일을 바깥과 완전히 격리해요.
- template과 slot — 마크업 틀을 재사용하고, 바깥 콘텐츠를 끼워 넣어요.
- 인스타 카드
<post-card>— 위의 모든 걸 합쳐 게시물 카드 한 채를 통째로 태그로 만들어요.
지난 시간 (H-2) 이번 시간 (H-3)
<script src="chart.js"> class LikeButton extends HTMLElement
남이 만든 조각 내가 만든 조각
링크로 빌려 씀 태그로 직접 만듦
│ │
▼ ▼
new Chart(...) <like-button></like-button>
🎯 오늘의 목표: 라이브러리 없이
<like-button>·<post-card>같은 내 전용 HTML 태그를 직접 정의하고, Shadow DOM·template·slot으로 한 덩어리로 캡슐화한다.
오늘도 인스타 클론 피드에 바로 꽂기보단, web-components.html이라는 작은 실험 페이지에서 태그를 하나씩 만들어 가며 진행할게요. 그리고 이번 시간은 우리 과목의 마지막 모듈이에요. 마지막에 짧게 지난 여정을 돌아보는 시간도 가질게요.
Step 1: 왜 Web Components인가 — 빌린 조각에서 내 조각으로
같은 마크업을 자꾸 반복해서 쓰고 있었어요
우리 인스타 피드를 떠올려 보세요. 게시물 카드 하나에는 프로필 사진, 작성자 이름, 사진, 좋아요 버튼, 캡션이 들어가요. 그런데 게시물이 10개면, 이 똑같은 묶음을 10번 그려야 해요. 지난 모듈들에서 우리는 이걸 renderPost(post) 같은 함수로 묶어 처리했죠.
함수로 묶는 것도 좋은 방법이에요. 그런데 한 가지 아쉬운 점이 있어요. 그 카드의 생김새(CSS), 동작(JS), 구조(HTML)가 서로 다른 파일에 흩어져 있다는 거예요. 좋아요 버튼 하나를 고치려면 like.js도 보고, components.css도 보고, 마크업도 봐야 해요.
Web Components는 이 셋을 한 덩어리로 묶어서, 새 HTML 태그 하나로 만들게 해줘요. 생김새와 동작과 구조를 <like-button>이라는 태그 안에 통째로 가둬 두는 거예요. 그러면 쓰는 쪽은 그냥 이렇게만 적으면 돼요.
<like-button count="1240"></like-button>
라이브러리를 빌리는 것과 무엇이 다를까
지난 시간 Chart.js를 떠올려 보세요. 그것도 일종의 "준비된 조각"이었어요. 차이는 누가 만들었느냐예요.
Chart.js (지난 시간) Web Components (오늘)
남이 만든 조각 내가 만든 조각
링크(CDN)로 빌려 씀 브라우저 표준 기능으로 직접 만듦
new Chart(...) 로 사용 <like-button> 태그로 사용
→ 빠르고 편하지만 → 손은 더 가지만
내 맘대로 못 고침 내 도메인에 딱 맞게 만듦
여기서 중요한 건, Web Components를 만드는 데 아무 라이브러리도 설치하지 않는다는 점이에요. 우리가 쓸 customElements, attachShadow, <template>, <slot>은 전부 브라우저에 이미 들어 있는 표준 기능이에요. 추가로 받아올 게 하나도 없어요.
💡 Web Components는 2026년 기준 크롬·파이어폭스·사파리·엣지 모든 최신 브라우저에서 그냥 동작해요(전 세계 지원율 약 99%). 별도 설치 없이 표준만으로 컴포넌트를 만들 수 있다는 뜻이에요.
🙋 학생 질문 — "튜터님, '컴포넌트'가 정확히 뭔가요?"
여기서 컴포넌트는 어렵게 생각할 것 없어요. 화면을 이루는 재사용 가능한 한 조각을 말해요. 좋아요 버튼 한 개, 게시물 카드 한 채, 프로필 칩 하나 — 이렇게 "한 번 만들어 두고 여기저기서 다시 쓰는 UI 블록"이 컴포넌트예요.
지금까지 우리는 이런 조각을 함수나 CSS 클래스로 만들어 왔어요. 오늘은 한 걸음 더 나아가, 그 조각을 아예 새 HTML 태그로 만드는 거예요. 그래서 이름이 "Web Components", 즉 웹의 부품들이에요.
이제 가장 작은 조각부터 직접 만들어 볼게요. 좋아요 버튼이에요.
Step 2: 첫 커스텀 태그 만들기
HTMLElement를 상속받으면 내 클래스가 태그가 돼요
새 태그를 만드는 출발점은 E-1에서 배운 class 문법이에요. 기억하시죠? class extends로 부모의 기능을 물려받는 거요. 여기서는 HTMLElement라는 브라우저 내장 클래스를 상속받아요. HTMLElement는 모든 HTML 태그의 부모예요. 이걸 물려받으면 내 클래스가 "진짜 태그처럼 행동할 자격"을 얻어요.
// instagram-clone-frontend/js/like-button.js
// HTMLElement 를 상속(extends)받으면, 내 클래스가 '진짜 HTML 태그' 가 돼요. (E-1 의 class 문법!)
class LikeButton extends HTMLElement {
// 태그가 화면(DOM)에 '붙는 순간' 딱 한 번 불려요 — 첫 그림은 여기서 그려요.
connectedCallback() {
this.innerHTML = `
<button type="button" class="like-btn">
<span class="heart">♡</span>
<span class="count">0</span>
</button>
`;
}
}
// 정의한 클래스를 '태그 이름' 과 연결해요. 이름엔 반드시 하이픈(-)이 들어가야 해요.
// 그래야 브라우저 기본 태그(div·p…)와 안 겹친다고 약속돼 있어요.
customElements.define("like-button", LikeButton);
딱 두 부분이에요. 위쪽은 설계도(클래스), 맨 아래 customElements.define(...)은 그 설계도를 브라우저에 등록하는 한 줄이에요. 등록하고 나면, 이제 HTML에서 <like-button>을 진짜 태그처럼 쓸 수 있어요.
class LikeButton extends HTMLElement ← 설계도(클래스)를 그리고
│
▼
customElements.define("like-button", LikeButton) ← 브라우저에 등록하면
│
▼
<like-button></like-button> ← 이제 HTML 에서 태그로 사용!
태그 이름엔 하이픈이 꼭 있어야 해요
customElements.define의 첫 번째 값이 태그 이름인데, 여기엔 규칙이 하나 있어요. 반드시 하이픈(-)이 들어가야 해요. likebutton은 안 되고 like-button은 돼요.
왜냐하면 브라우저는 앞으로도 <dialog>, <details> 같은 새 기본 태그를 계속 추가할 거예요. 만약 우리가 만든 태그가 한 단어면, 언젠가 브라우저가 같은 이름의 기본 태그를 내놓았을 때 충돌해요. 그래서 "하이픈이 든 이름은 개발자 몫"이라고 영역을 갈라 둔 거예요.
이 태그를 화면에 띄워 보려고, 작은 실험 페이지를 하나 만들어요.
<!-- instagram-clone-frontend/web-components.html -->
<body class="demo-page">
<main class="demo-main">
<h1 class="demo-title">Web Components — 내 전용 태그</h1>
<section class="demo-block">
<h2><like-button> — 내가 만든 좋아요 버튼</h2>
<like-button></like-button>
</section>
</main>
<script type="module" src="js/like-button.js"></script>
</body>
이 페이지를 열면 좋아요 버튼이 보여요. 하지만 아직은 눌러도 아무 일이 없어요. 그냥 "♡ 0"이 그려진 정적인 모습이죠. 여기에 생명을 불어넣을 차례예요.
Step 3: 라이프사이클 콜백 — 붙을 때와 떨어질 때
태그에는 '일생'이 있어요
방금 우리는 connectedCallback이라는 메서드를 썼어요. 이름이 좀 길지만 뜻은 단순해요. 이 태그가 화면(DOM)에 붙는 순간 브라우저가 자동으로 불러주는 함수예요. 우리가 직접 부르지 않아도, 태그가 화면에 나타나면 브라우저가 알아서 실행해 줘요.
이렇게 "특정 순간에 자동으로 불리는 함수"를 라이프사이클 콜백(lifecycle callback), 우리말로 생애주기 콜백이라고 해요. 태그에도 사람처럼 일생이 있어서, 태어날 때·바뀔 때·사라질 때마다 다른 콜백이 불려요.
<like-button> 가 화면에...
붙을 때 → connectedCallback() (첫 그림 + 이벤트 등록)
바뀔 때 → attributeChangedCallback() (다음 단계에서 배워요)
떨어질 때 → disconnectedCallback() (걸어둔 이벤트 정리)
붙을 때 이벤트를 걸고, 떨어질 때 떼어내요
이제 좋아요 버튼이 눌리면 하트가 켜지게 만들어요. 클릭을 들으려면 connectedCallback에서 이벤트를 걸어요(D-2에서 배운 addEventListener예요). 그리고 태그가 화면에서 사라질 때는 그 이벤트를 떼어내요.
// instagram-clone-frontend/js/like-button.js
connectedCallback() {
this.liked = false; // 좋아요 켜짐/꺼짐 상태
this.count = 1240; // 좋아요 수 (다음 단계에서 속성으로 바깥에서 받아와요)
this.render();
// 클릭하면 toggle 을 부르도록 연결해요. 정리할 때 똑같은 함수를 떼어내야 해서,
// 변수에 담아 둬요. (이름 없는 함수면 나중에 못 떼어내요.)
this.onClick = () => this.toggle();
this.addEventListener("click", this.onClick);
}
// 태그가 화면에서 '떨어질 때' — 걸어둔 이벤트를 떼어내 메모리 누수를 막아요.
disconnectedCallback() {
this.removeEventListener("click", this.onClick);
}
여기서 disconnectedCallback이 왜 필요한지 짚고 갈게요. 만약 태그를 화면에서 지웠는데 걸어둔 이벤트가 그대로 남아 있으면, 브라우저는 "이 버튼은 아직 누군가 듣고 있네"라고 착각해서 메모리에서 못 지워요. 이게 쌓이면 메모리 누수(memory leak)가 돼요. 그래서 붙을 때 건 이벤트는 떨어질 때 떼어내는 게 습관이에요.
이제 실제로 좋아요를 켜고 끄는 toggle과, 현재 상태대로 그리는 render를 봐요.
// instagram-clone-frontend/js/like-button.js
// 좋아요를 켜고 꺼요 — 상태를 뒤집고, 숫자를 1 올리거나 내린 뒤 다시 그려요.
toggle() {
this.liked = !this.liked;
this.count = this.liked ? this.count + 1 : this.count - 1;
this.render();
}
// 지금 상태대로 버튼 속을 그려요.
render() {
this.innerHTML = `
<button type="button" class="like-btn" aria-pressed="${this.liked}">
<span class="heart">${this.liked ? "♥" : "♡"}</span>
<span class="count">${this.count.toLocaleString()}</span>
</button>
`;
}
이제 버튼을 누르면 ♡가 ♥로 바뀌고 숫자가 1 올라가요. 다시 누르면 원래대로 돌아오죠. aria-pressed는 G-1에서 배운 접근성 속성이에요. 색(빨간 하트)은 눈으로만 보이니, 눌림 상태를 스크린 리더에게도 알려주는 거예요.
💡 클릭 이벤트를
this(태그 자기 자신)에 걸어 둔 게 포인트예요.render()로 안쪽 버튼을 통째로 다시 그려도, 이벤트는 바깥 태그에 붙어 있어서 안 떨어져요. 안쪽 클릭이 바깥으로 올라오는 성질(D-2의 이벤트 버블링)을 활용한 거예요.
Step 4: 속성으로 데이터 주입하기
좋아요 수를 코드에 박지 말고, 바깥에서 받아와요
방금 코드엔 한 가지 어색한 점이 있어요. this.count = 1240처럼 좋아요 수가 JS 코드 안에 박혀 있죠. 그러면 게시물마다 좋아요 수가 다 다른데, 매번 코드를 고쳐야 해요. 진짜 HTML 태그라면 이래야 자연스러워요.
<like-button count="1240"></like-button>
<like-button count="87"></like-button>
<img src="...">나 <a href="...">처럼, 속성(attribute)으로 데이터를 넘기는 거예요. 이걸 가능하게 하는 게 observedAttributes와 attributeChangedCallback 한 쌍이에요.
// instagram-clone-frontend/js/like-button.js
// 어떤 속성을 '지켜볼지' 미리 알려줘요 — 여기 적은 속성이 바뀔 때만 감지해요.
static get observedAttributes() {
return ["count", "liked"];
}
// observedAttributes 에 적은 속성이 바뀔 때마다 자동으로 불려요 — 바뀐 값으로 다시 그려요.
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) this.render();
}
observedAttributes는 "이 속성들만 지켜봐 줘"라고 브라우저에게 미리 알려주는 명단이에요. 여기 적은 count나 liked가 바뀌면, 브라우저가 attributeChangedCallback을 자동으로 불러줘요. 그 안에서 다시 render()만 하면, 화면이 새 값으로 갱신돼요.
속성을 프로퍼티처럼 읽고 쓰게 해줘요
속성은 HTML 문자열이라, JS에서 다루기 편하게 통로를 만들어 둬요. E-1에서 배운 get/set(게터·세터)이에요.
// instagram-clone-frontend/js/like-button.js
// count 속성을 평범한 프로퍼티처럼 읽고 쓰게 해줘요 — el.count 로요.
get count() {
return Number(this.getAttribute("count")) || 0;
}
set count(value) {
this.setAttribute("count", value); // 속성을 바꾸면 attributeChangedCallback 이 다시 그려줘요
}
// liked 는 '있다/없다' 로 표현해요 — <like-button liked> 처럼 값이 없는 속성.
get liked() {
return this.hasAttribute("liked");
}
// 좋아요를 켜고 꺼요 — liked 속성을 달거나 떼고, 숫자를 1 올리거나 내려요.
toggle() {
if (this.liked) {
this.removeAttribute("liked");
this.count = this.count - 1;
} else {
this.setAttribute("liked", "");
this.count = this.count + 1;
}
}
이제 상태가 전부 속성에 담겨요. toggle은 liked 속성을 달거나 떼고 숫자를 바꿀 뿐인데, 속성이 바뀌면 attributeChangedCallback이 알아서 다시 그려줘요. 우리가 render()를 직접 부르지 않아도요.
진짜 재밌는 건 이거예요. 브라우저 콘솔에서 속성만 바꿔도 화면이 따라와요. 한번 직접 해보세요.
// 브라우저 콘솔에서:
document.querySelector("like-button").setAttribute("count", "9999");
// → 화면의 숫자가 바로 9,999 로 바뀌어요!
💡 이게 "선언형"의 맛이에요. 우리가 "숫자를 다시 그려라"라고 명령하는 게 아니라, "속성이 곧 화면이다"라고 선언해 두면, 속성만 바꿔도 화면이 알아서 따라와요. 데이터와 화면을 묶어 둔 거죠.
Step 5: Shadow DOM — 스타일을 가두는 그림자 방
바깥 CSS가 내 컴포넌트를 망가뜨리는 문제
지금까지 만든 <like-button>엔 숨은 위험이 하나 있어요. 버튼 안에 class="like-btn", class="heart" 같은 클래스를 썼죠. 그런데 만약 이 페이지의 다른 CSS 파일에 .heart { color: blue } 같은 규칙이 있으면 어떻게 될까요? 내 좋아요 하트가 엉뚱하게 파래져요. 내가 만든 컴포넌트인데, 바깥 CSS가 침범해서 망가뜨리는 거예요.
반대 방향도 문제예요. 내가 컴포넌트 안에 쓴 스타일이 바깥으로 새어 나가서, 페이지의 다른 요소를 건드릴 수도 있어요. 컴포넌트가 늘어날수록 이런 충돌은 점점 골치 아파져요.
Shadow DOM(섀도우 돔), 우리말로 그림자 방이 이 문제를 깔끔하게 풀어요. 컴포넌트마다 바깥과 격리된 작은 방을 하나 주는 거예요. 그 방 안의 스타일은 바깥으로 안 새고, 바깥 스타일도 그 방 안으로 못 들어와요.
일반 DOM (light) Shadow DOM (그림자 방)
바깥 CSS 가 들어옴 바깥 CSS 가 못 들어옴
내 스타일이 새어 나감 내 스타일도 안 샘
│ │
▼ ▼
충돌 위험 있음 완전히 격리됨
attachShadow로 방을 열고, 그 안에 그려요
그림자 방은 attachShadow로 열어요. 보통 생성자(constructor)에서 한 번 열어 두고, 그 다음부터는 this.innerHTML 대신 this.shadowRoot.innerHTML에 그려요.
// instagram-clone-frontend/js/like-button.js
constructor() {
super(); // HTMLElement 의 생성자를 먼저 불러요 — 규칙이에요
// 이 태그만의 '그림자 방' 을 하나 열어요. open 이면 바깥에서 el.shadowRoot 로 들여다볼 수 있어요.
this.attachShadow({ mode: "open" });
}
super()는 E-1에서 배운 그 super예요. 부모(HTMLElement)의 생성자를 먼저 불러야 한다는 규칙이라, 한 줄 적어줘요. mode: "open"은 바깥에서 el.shadowRoot로 이 방을 들여다볼 수 있게 한다는 뜻이에요.
이제 render가 그림자 방 안에 스타일과 마크업을 함께 그려요. 이 <style>은 방 안에만 적용돼요.
// instagram-clone-frontend/js/like-button.js
// 그림자 방 안에 스타일과 마크업을 함께 그려요. 이 <style> 은 바깥으로 절대 안 새요.
render() {
this.shadowRoot.innerHTML = `
<style>
button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 0;
background: none;
padding: 0;
font: inherit;
cursor: pointer;
color: #262626;
}
.heart { font-size: 1.4rem; line-height: 1; }
/* :host 는 <like-button> 태그 자기 자신을 가리켜요 — liked 속성이 있을 때만 빨갛게. */
:host([liked]) .heart { color: #ed4956; }
</style>
<button type="button" aria-pressed="${this.liked}">
<span class="heart">${this.liked ? "♥" : "♡"}</span>
<span class="count">${this.count.toLocaleString()}</span>
</button>
`;
}
여기서 새 친구 :host가 나왔어요. 그림자 방 안에서 :host는 태그 자기 자신(<like-button> 그 자체)을 가리켜요. :host([liked])는 "내 태그에 liked 속성이 달려 있을 때"라는 뜻이라, 좋아요가 켜졌을 때만 하트를 빨갛게 칠해요.
이제 이 <style> 안에 button이라고만 적었는데도, 이 규칙은 그림자 방 밖의 다른 버튼엔 전혀 영향을 안 줘요. 방 안에 갇혀 있으니까요. 클래스 이름이 바깥과 겹칠까 봐 길고 복잡하게 짓던 고민이 사라지는 거예요.
⚠️ 그림자 방을 열면 클릭 이벤트도 방 안에서 들어야 해요. 그래서
connectedCallback의addEventListener를this대신this.shadowRoot에 걸어요. 방 안 버튼의 클릭이 방의 뿌리(shadowRoot)까지 올라오거든요.
이게 우리 like-button.js의 최종 모습이에요. Step 2의 정적인 버튼이, 라이프사이클·속성·그림자 방을 거치며 어엿한 컴포넌트로 자랐어요.
Step 6: template과 slot — 틀 재사용과 콘텐츠 투영
매번 innerHTML 문자열로 그리는 게 아쉬워요
지금까지 우리는 shadowRoot.innerHTML = \...``처럼 문자열로 마크업을 그렸어요. 짧을 땐 괜찮은데, 카드처럼 구조가 커지면 JS 안에 긴 HTML 문자열이 들어가서 읽기 불편해져요.
<template>(템플릿) 태그가 이걸 도와줘요. 마크업 틀을 HTML에 미리 한 번만 적어 두고, 컴포넌트는 그 틀을 복제(clone)해서 쓰는 거예요. <template> 안의 내용은 화면에 바로 그려지지 않아요. "복제해서 쓸 틀"로만 대기하고 있죠.
새 컴포넌트로 연습해 볼게요. 프로필 사진과 이름을 묶은 작은 유저 칩이에요.
<!-- instagram-clone-frontend/web-components.html -->
<template id="user-chip-tpl">
<style>
:host { display: inline-flex; align-items: center; gap: 0.5rem; }
.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }
/* ::slotted 는 바깥에서 끼워 넣은 콘텐츠를 가리켜요 */
::slotted(*) { font-weight: 600; color: #262626; text-decoration: none; }
</style>
<img class="avatar" alt="프로필 사진" width="32" height="32">
<slot>익명</slot>
</template>
slot은 바깥 콘텐츠가 들어올 빈자리예요
위 틀 안에 <slot>이라는 처음 보는 태그가 있어요. 이게 핵심이에요. <slot>은 바깥에서 끼워 넣은 콘텐츠가 들어올 빈 구멍이에요. 컴포넌트를 이렇게 쓰면:
<user-chip avatar="https://picsum.photos/seed/avatar1/64/64">jaehoon</user-chip>
태그 사이에 적은 jaehoon이 그림자 방 안의 <slot> 자리로 쏙 들어가서 그려져요. 이걸 콘텐츠 투영(projection)이라고 해요. 바깥 콘텐츠를 컴포넌트 안의 정해진 자리로 비춰 넣는 거죠.
<user-chip>jaehoon</user-chip> ← 바깥에서 끼운 콘텐츠
│
▼ (slot 구멍으로 쏙)
그림자 방: <img> <slot>▣</slot>
└ 여기에 "jaehoon" 이 들어가 그려져요
컴포넌트 쪽 코드는 짧아요. 틀을 복제해서 방에 넣고, 사진 주소만 속성에서 채워주면 끝이에요.
// instagram-clone-frontend/js/user-chip.js
class UserChip extends HTMLElement {
static get observedAttributes() {
return ["avatar"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
// HTML 에 적어둔 <template> 을 찾아, 그 내용을 복제(clone)해서 그림자 방에 넣어요.
// 매번 innerHTML 문자열로 새로 만드는 대신, 미리 만든 틀을 복제하니 깔끔해요.
const tpl = document.getElementById("user-chip-tpl");
this.shadowRoot.appendChild(tpl.content.cloneNode(true));
}
connectedCallback() {
this.update();
}
attributeChangedCallback() {
this.update();
}
// 틀은 그대로 두고, 그림자 방 속 <img> 의 src 만 avatar 속성값으로 채워요.
update() {
const img = this.shadowRoot.querySelector(".avatar");
if (img) img.src = this.getAttribute("avatar") || "";
}
}
customElements.define("user-chip", UserChip);
tpl.content.cloneNode(true)가 틀을 통째로 복제하는 한 줄이에요. true는 "안쪽 내용까지 깊게 복제하라"는 뜻이에요. 이렇게 만든 유저 칩은 어디서든 <user-chip> 한 줄로 불러 쓸 수 있어요. 끼워 넣는 이름만 바꾸면 사람마다 다른 칩이 되고요.
💡
::slotted(*)는 그림자 방 안에서 투영된 바깥 콘텐츠를 골라 꾸미는 선택자예요. 슬롯으로 들어온jaehoon이나<a>minji</a>에 굵은 글씨를 입혀요. 단, 슬롯 콘텐츠는 어디까지나 바깥 소유라, 꾸밀 수 있는 범위엔 한계가 있어요.
Step 7: 종합 — 인스타 카드 post-card 만들기
E-1의 'PostCard 클래스'가 진짜 태그가 돼요
오늘의 마지막 조각이자, 모든 걸 합치는 시간이에요. 사실 우리는 E-1에서 이미 PostCard라는 클래스를 만들어 본 적이 있어요. 게시물 데이터를 받아 <article> 한 채를 만들어 돌려주는 클래스였죠.
// instagram-clone-frontend/js/post-card.js (E-1 에서 만든 '클래스' — 비교용)
export class PostCard {
constructor(post, options = {}) {
this.post = post; // 게시물 데이터(객체)를 인스턴스에 담아 둬요
this.priority = options.priority ?? false;
}
// <article> 한 채를 만들어 돌려줘요
render() {
const article = document.createElement("article");
article.dataset.postId = this.post.id;
article.innerHTML = this.buildTemplate();
return article;
}
// ... (전체 코드는 코드베이스 post-card.js 참고)
}
이건 평범한 클래스라, 쓰려면 JS에서 new PostCard(post).render()를 호출하고 그 결과를 화면에 붙여야 했어요. 오늘은 이걸 진짜 HTML 태그 <post-card>로 다시 만들어요. 그러면 HTML에 이렇게 적기만 하면 카드가 생겨요.
<post-card
username="jaehoon"
avatar="https://picsum.photos/seed/avatar1/64/64"
image="https://picsum.photos/seed/insta1/600/600"
likes="1240">
오늘 날씨 최고 ☀️
</post-card>
데이터는 속성으로(Step 4), 캡션은 슬롯으로(Step 6), 생김새는 그림자 방으로(Step 5) — 오늘 배운 게 한자리에 모여요.
작은 조각이 큰 조각 안으로 들어가요
카드 틀은 <template>에 적어 둬요. 그런데 이 틀 안에 우리가 만든 또 하나의 조각이 들어가요. 바로 Step 2~5에서 만든 <like-button>이에요.
<!-- instagram-clone-frontend/web-components.html -->
<template id="post-card-tpl">
<style>
:host {
display: block;
max-width: 32rem;
margin: 1rem auto;
border: 1px solid #dbdbdb;
border-radius: 8px;
background: #fff;
overflow: hidden;
font-family: system-ui, sans-serif;
}
.head { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.8rem; }
.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }
.username { font-weight: 600; color: #262626; }
.photo { width: 100%; display: block; }
.body { padding: 0.6rem 0.8rem; }
.caption { margin: 0.4rem 0 0; color: #262626; }
</style>
<div class="head">
<img class="avatar" alt="프로필 사진" width="32" height="32">
<strong class="username"></strong>
</div>
<img class="photo" alt="게시물 사진">
<div class="body">
<like-button class="like-slot"></like-button>
<p class="caption"><slot>내용 없음</slot></p>
</div>
</template>
<like-button class="like-slot"> 한 줄 보이시죠? 내가 만든 조각을, 또 다른 내 조각 안에 그냥 태그로 끼워 넣은 거예요. 이게 컴포넌트의 진짜 힘이에요. 한 번 만들어 둔 좋아요 버튼을, 카드 안에서 레고 블록처럼 다시 써요.
<post-card>
├─ <img> 게시물 사진
├─ <like-button> ← Step 2~5 에서 만든 내 조각이 그대로 끼워짐!
└─ <slot> 캡션 (바깥에서 끼운 "오늘 날씨 최고 ☀️")
컴포넌트 코드는 틀을 복제하고, 빈칸을 속성값으로 채워요. 좋아요 자리엔 카드가 받은 likes 값을 <like-button>에 그대로 넘겨줘요.
// instagram-clone-frontend/js/post-card-element.js
class PostCardElement extends HTMLElement {
static get observedAttributes() {
return ["username", "avatar", "image", "likes"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
// 카드 틀을 <template> 에서 복제해 와요 (Step 6 에서 배운 방식).
const tpl = document.getElementById("post-card-tpl");
this.shadowRoot.appendChild(tpl.content.cloneNode(true));
}
connectedCallback() {
this.update();
}
attributeChangedCallback() {
this.update();
}
// 틀의 빈칸을 속성값으로 채워요. 좋아요 자리엔 우리가 만든 <like-button> 이 이미 끼워져 있어요.
update() {
const root = this.shadowRoot;
root.querySelector(".avatar").src = this.getAttribute("avatar") || "";
root.querySelector(".username").textContent = this.getAttribute("username") || "";
root.querySelector(".photo").src = this.getAttribute("image") || "";
// 작은 조각이 큰 조각 안으로! Step 2~5 에서 만든 <like-button> 에 좋아요 수만 넘겨줘요.
root.querySelector(".like-slot").setAttribute("count", this.getAttribute("likes") || "0");
}
}
customElements.define("post-card", PostCardElement);
이제 web-components.html을 열면, 진짜 인스타 게시물처럼 생긴 카드가 보여요. 그 안의 좋아요 버튼을 누르면 ♡가 ♥로 통통 켜지고요. E-1에서 new PostCard(...)로 호출하던 그 카드가, 이젠 <post-card> 태그 한 줄로 살아나는 거예요.
💡 이 카드를 피드에 10개 깔고 싶으면
<post-card>를 10번 적거나, JS로 데이터를 돌며 만들면 돼요. 데이터·생김새·동작이 태그 하나에 다 들어 있으니, 쓰는 쪽은 속성만 채우면 끝이에요.
Step 8: Web Components, 언제 쓸까 — 한계와 선택
강점은 분명해요
오늘 만든 걸 돌아보면 Web Components의 강점이 보여요. 첫째, 라이브러리가 0개예요. 브라우저 표준만 썼으니 설치할 것도, 버전 올릴 것도 없어요. 둘째, 어디서나 동작해요. <like-button>은 그냥 HTML 태그라, 어떤 페이지에 갖다 놔도 똑같이 작동해요. 셋째, 캡슐화돼요. 그림자 방 덕분에 스타일 충돌 걱정 없이 조각을 옮겨 다닐 수 있어요.
그래서 Web Components가 특히 빛나는 자리가 있어요. 여러 프로젝트에서 공통으로 쓰는 디자인 시스템(버튼·칩·뱃지 같은 공용 부품 모음)이나, 한 번 만들어 오래 재사용하는 위젯이에요.
한계도 솔직히 알아둬요
물론 만능은 아니에요. 오늘 우리가 toggle마다 render()가 다시 불리게 직접 엮은 걸 떠올려 보세요. 데이터가 바뀌면 화면을 어떻게 갱신할지를 우리가 일일이 손으로 연결해야 했어요. 컴포넌트가 수십 개로 늘고 서로 복잡하게 얽히면, 이 연결을 손으로 관리하는 게 점점 버거워져요.
Web Components 무엇이 더 편할까
작은 위젯·공용 부품 ← 라이브러리 없이 표준으로 충분
디자인 시스템 (버튼·칩 모음) ← 어디서나 동작, 캡슐화 강력
vs
화면이 수십 개인 큰 앱 ← 상태·갱신을 더 편하게 다루는
데이터가 복잡하게 얽힘 전용 도구가 손이 덜 감
큰 화면 전체를 통째로 다루는 일에는, 상태와 화면 갱신을 더 편하게 묶어주는 전용 도구들이 따로 있어요. 그건 우리 과목 다음 과정에서 본격적으로 만나요. 재밌는 건, 그런 도구들도 결국 오늘 배운 컴포넌트라는 개념 위에 서 있다는 거예요. 오늘 "조각을 만들고 합친다"는 감각을 잡아두면, 다음 도구가 훨씬 쉽게 들어와요.
💡 정답은 "둘 중 하나만"이 아니에요. 작은 재사용 부품엔 Web Components가, 복잡한 큰 앱엔 전용 도구가 어울려요. 지난 시간 "직접 그리기 vs Chart.js"에서 느낀 것과 똑같은 선택의 문제예요. 도구를 아는 사람이 상황에 맞게 고르는 거죠.
마무리
오늘 우리는 라이브러리 없이 내 전용 태그를 직접 만들었어요. 가장 작은 <like-button>으로 시작해서, 라이프사이클·속성·그림자 방·슬롯을 하나씩 익히고, 마지막엔 그걸 다 합쳐 <post-card> 한 채를 통째로 태그로 만들었죠. 작은 조각이 큰 조각 안으로 레고처럼 끼워지는 것도 봤고요.
- 커스텀 엘리먼트 —
class extends HTMLElement+customElements.define으로 새 태그를 정의했어요. - 라이프사이클 — 붙을 때(
connectedCallback)·바뀔 때(attributeChangedCallback)·떨어질 때(disconnectedCallback) 자동으로 코드가 돌았어요. - 속성 주입 —
observedAttributes로 데이터를 바깥에서 받아왔어요. - Shadow DOM — 그림자 방으로 스타일을 완전히 격리했어요.
- template·slot — 틀을 재사용하고, 바깥 콘텐츠를 슬롯으로 투영했어요.
그리고 이번 시간은 우리 과목의 마지막 모듈이었어요. 잠깐 지나온 길을 돌아볼게요. 우리는 HTML로 웹의 뼈대를 세웠고(A), CSS로 거기에 옷을 입혔어요(B). JavaScript로 화면에 생명을 불어넣었고(C), DOM과 fetch로 서버와 대화하는 법을 배웠죠(D). 언어의 깊이를 파고들었고(E), 빠른 페이지를 만드는 법(F)과 모두를 위한 접근성(G)을 익혔어요. 그리고 오늘 브라우저의 숨은 힘까지 꺼내 썼어요(H).
처음엔 <h1> 하나 띄우는 것도 낯설었을 거예요. 그런데 지금은 좋아요가 켜지고, 무한 스크롤이 되고, 사진에 필터가 걸리고, 내 전용 태그까지 만드는 인스타 클론 한 채를 손에 쥐고 있어요. 정말 먼 길을 왔어요.
다음 과정인 프론트엔드 프레임워크 과정에서는, 오늘 만난 "컴포넌트"라는 개념을 훨씬 큰 규모로 다루는 도구를 배워요. 오늘 직접 손으로 조각을 만들고 합쳐본 경험이, 그 도구를 이해하는 가장 든든한 밑바탕이 될 거예요. 그동안 정말 고생 많으셨어요. 여러분은 이제 웹 한 채를 처음부터 끝까지 지을 줄 아는 사람이에요.
과제
[구현] 팔로우 버튼을 커스텀 태그로 만들기
좋아요 버튼을 본떠, <follow-button> 커스텀 엘리먼트를 만들어 보세요. 누르면 "팔로우 ↔ 팔로잉"이 토글되는 버튼이에요.
class extends HTMLElement+customElements.define("follow-button", ...)로 정의해요.following이라는 속성으로 상태를 표현해요(observedAttributes에 등록).attributeChangedCallback에서 다시 그리게 하고, 클릭하면following속성을 토글해요.- Shadow DOM(
attachShadow)을 열고,:host([following])일 때 버튼 색을 다르게 줘보세요(팔로우=파란 버튼, 팔로잉=회색 테두리). - 힌트: 구조는 오늘의
like-button.js와 거의 같아요. 좋아요 켜짐/꺼짐 대신 팔로우/팔로잉으로 바꾸면 돼요.
[구현] 스토리 링을 template과 slot으로 만들기
<user-chip>을 확장해, 아바타 둘레에 인스타 스토리 테두리(그라데이션 링)를 두른 <story-ring>을 만들어 보세요.
<template id="story-ring-tpl">에 틀과<style>을 적어 두고, 컴포넌트가 복제해 쓰게 해요.- 아바타는
avatar속성으로 받고, 유저 이름은<slot>으로 투영받아요. - 링은 아바타를 감싼 요소에
padding을 주고background: linear-gradient(...)로 만들면 돼요(B-7에서 배운 그라데이션). - 힌트: 틀을 복제하고 속성만 채우는
user-chip.js의 흐름을 그대로 따라가요.
[탐구] Shadow DOM의 open과 closed는 뭐가 다를까
오늘 우리는 attachShadow({ mode: "open" })을 썼어요. 그런데 mode에는 "closed"도 있어요. 둘이 무엇이 다른지 조사해 보세요.
open일 때와closed일 때, 바깥에서el.shadowRoot로 그림자 방에 접근할 수 있는지 각각 확인해 보세요.- 키워드: "shadowRoot 접근", "캡슐화 강도", "closed 가 실무에서 잘 안 쓰이는 이유".
- 정답을 외우기보다, "방을 더 꽁꽁 잠그면 뭐가 좋고 뭐가 불편할까"를 기준으로 정리해 보세요.
생각해볼 주제
1. 커스텀 태그 이름엔 왜 하이픈이 꼭 있어야 할까
오늘 customElements.define을 쓸 때, 태그 이름에 반드시 하이픈(-)을 넣어야 한다고 했어요. <likebutton>은 안 되고 <like-button>은 되죠. 브라우저는 앞으로도 새 기본 태그를 계속 추가할 텐데, 이 규칙이 그 미래와 어떤 관계가 있을지 생각해 보세요. 만약 하이픈 규칙이 없었다면, 몇 년 뒤 어떤 문제가 생길 수 있을까요?
2. 캡슐화는 좋은데, 바깥 테마는 어떻게 적용하지
Shadow DOM은 바깥 CSS를 막아줘서 스타일 충돌을 없애줘요. 그런데 이게 양날의 검이에요. B-8에서 만든 다크 모드를 떠올려 보세요. 페이지 전체 색을 바꾸고 싶은데, 내 컴포넌트가 그림자 방으로 꽁꽁 닫혀 있으면 바깥 테마 색이 안 들어가요. "충돌은 막되, 의도한 테마는 통과시키는" 통로가 필요할 텐데, B-8에서 배운 CSS 변수(--로 시작하는)가 여기서 어떤 역할을 할 수 있을지 생각해 보세요.
3. 직접 만든 조각 vs 준비된 도구, 언제 무엇을 고를까
지난 시간엔 차트를 "직접 그리기 vs Chart.js"로 비교했고, 오늘은 컴포넌트를 "직접 만들기 vs 다음 과정의 전용 도구"로 비교했어요. 둘은 닮은 고민이에요. 여러분이 작은 사이드 프로젝트에 좋아요 버튼 하나가 필요하다면 어느 쪽을, 화면이 수십 개인 큰 서비스를 만든다면 어느 쪽을 고를까요? 그 판단의 기준을 한번 정리해 보세요.
✅ 예시 답안정답 보기
Web Components 모듈의 과제와 생각해볼 주제 예시답안이에요. 정답이 하나는 아니에요. 아래는 "이렇게 풀면 깔끔해요"의 한 갈래이니, 본인 코드와 비교하며 읽어보세요.
과제 예시답안
🎯 [과제 1 예시답안] 팔로우 버튼을 커스텀 태그로 만들기
핵심 접근
오늘 만든 like-button.js와 구조가 거의 같아요. "좋아요 켜짐/꺼짐"을 "팔로우/팔로잉"으로 바꾸기만 하면 돼요. 상태는 following 속성 하나로 표현하고, 클릭하면 그 속성을 토글해요. :host([following])로 두 상태의 버튼 색을 다르게 줘요.
답안
// follow-button.js
class FollowButton extends HTMLElement {
static get observedAttributes() {
return ["following"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.onClick = () => this.toggle();
this.shadowRoot.addEventListener("click", this.onClick);
}
disconnectedCallback() {
this.shadowRoot.removeEventListener("click", this.onClick);
}
attributeChangedCallback() {
this.render();
}
get following() {
return this.hasAttribute("following");
}
toggle() {
if (this.following) {
this.removeAttribute("following");
} else {
this.setAttribute("following", "");
}
}
render() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 0.4rem 1.2rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
background: #0095f6;
color: #fff;
border: 1px solid #0095f6;
}
/* 팔로잉 상태면 흰 배경 + 회색 테두리 */
:host([following]) button {
background: #fff;
color: #262626;
border-color: #dbdbdb;
}
</style>
<button type="button" aria-pressed="${this.following}">
${this.following ? "팔로잉" : "팔로우"}
</button>
`;
}
}
customElements.define("follow-button", FollowButton);
쓸 때는 이렇게요.
<follow-button></follow-button>
<follow-button following></follow-button>
<script type="module" src="js/follow-button.js"></script>
채점 포인트
| 항목 | 확인 |
|---|---|
HTMLElement 상속 + customElements.define 등록(하이픈 든 이름) |
✅ |
following 속성을 observedAttributes에 등록 |
✅ |
attributeChangedCallback에서 재렌더 |
✅ |
클릭 시 following 속성 토글(setAttribute/removeAttribute) |
✅ |
attachShadow + :host([following])로 두 상태 색 분리 |
✅ |
disconnectedCallback에서 이벤트 정리 |
✅ |
흔한 실수
- 태그 이름에 하이픈이 없음 —
followbutton은 등록이 거부돼요. 반드시follow-button. observedAttributes에following을 안 넣음 — 속성을 바꿔도attributeChangedCallback이 안 불려서 화면이 안 바뀌어요.- 상태를 JS 변수(
this.isFollowing)에만 두고 속성을 안 씀 — 동작은 하지만,<follow-button following>처럼 HTML로 초기 상태를 못 줘요. 속성을 상태의 근원으로 삼는 게 핵심이에요.
실무 개선 포인트 (심화)
- 버튼을 누르면 보통 서버에 "팔로우 했다"고 알려야 해요. 컴포넌트가 직접 fetch를 부르기보단,
this.dispatchEvent(new CustomEvent("follow-change", { detail: { following: this.following } }))로 커스텀 이벤트를 쏘고, 바깥에서 그 이벤트를 듣고 통신하게 하면 컴포넌트가 더 독립적이에요(오늘 범위를 넘는 한 걸음).
🎯 [과제 2 예시답안] 스토리 링을 template과 slot으로 만들기
핵심 접근
user-chip.js의 흐름을 그대로 따라가요. <template>에 틀과 <style>을 적어 두고, 컴포넌트는 복제해서 써요. 차이는 아바타를 .ring이라는 요소로 한 번 감싸고, 거기에 padding + 그라데이션 배경을 줘서 인스타 스토리 테두리를 흉내 내는 거예요.
답안
<!-- web-components.html -->
<template id="story-ring-tpl">
<style>
:host { display: inline-flex; flex-direction: column; align-items: center; gap: 0.3rem; }
.ring {
padding: 3px;
border-radius: 50%;
background: linear-gradient(45deg, #f09433, #dc2743, #bc1888);
}
.avatar {
display: block; width: 56px; height: 56px;
border-radius: 50%; border: 2px solid #fff; object-fit: cover;
}
::slotted(*) { font-size: 0.8rem; color: #262626; }
</style>
<span class="ring">
<img class="avatar" alt="프로필 사진" width="56" height="56">
</span>
<slot>익명</slot>
</template>
// story-ring.js
class StoryRing extends HTMLElement {
static get observedAttributes() {
return ["avatar"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
const tpl = document.getElementById("story-ring-tpl");
this.shadowRoot.appendChild(tpl.content.cloneNode(true));
}
connectedCallback() {
this.update();
}
attributeChangedCallback() {
this.update();
}
update() {
const img = this.shadowRoot.querySelector(".avatar");
if (img) img.src = this.getAttribute("avatar") || "";
}
}
customElements.define("story-ring", StoryRing);
쓸 때는 이렇게요.
<story-ring avatar="https://picsum.photos/seed/avatar1/64/64">jaehoon</story-ring>
채점 포인트
| 항목 | 확인 |
|---|---|
<template id>에 틀 + <style> 작성, 화면에 직접 안 그려짐 이해 |
✅ |
생성자에서 cloneNode(true)로 틀 복제 후 그림자 방에 추가 |
✅ |
avatar 속성 → 그림자 방 <img>의 src 반영 |
✅ |
<slot>으로 유저 이름 투영(::slotted로 꾸밈) |
✅ |
.ring에 padding + linear-gradient로 스토리 테두리 |
✅ |
흔한 실수
cloneNode(true)의true를 빼먹음 — 틀의 겉껍데기만 복제되고 안쪽 내용(img·slot)이 안 따라와서 빈 칩이 떠요.<template>을 컴포넌트 사용처보다 뒤에 둠 —getElementById가 못 찾아 에러가 나요. 모듈 스크립트는 문서를 다 읽은 뒤 실행되니 보통 괜찮지만, 틀은 사용처 가까이 본문에 두는 게 안전해요.- 링 색을 아바타
<img>에 직접 줌 — 이미지가 그라데이션을 덮어 안 보여요. 아바타를 감싼 바깥 요소(.ring)에 줘야 테두리처럼 보여요.
실무 개선 포인트 (심화)
- 인스타는 "안 본 스토리"만 컬러 링이고, 본 스토리는 회색 링이에요.
seen이라는 속성을 추가하고:host([seen]) .ring { background: #dbdbdb }로 두 상태를 나누면 실제 인스타에 한 걸음 가까워져요(오늘 배운:host([속성])패턴 재사용).
🎯 [과제 3 예시답안] Shadow DOM의 open과 closed는 뭐가 다를까
핵심 접근
mode는 "그림자 방을 바깥에서 들여다볼 수 있게 둘지"를 정해요. open이면 열쇠 구멍이 있고, closed면 아예 잠가버려요. 직접 콘솔에서 확인해보면 차이가 한눈에 보여요.
답안
open과 closed의 핵심 차이는 바깥에서 el.shadowRoot로 그림자 방에 접근할 수 있는가예요.
// open: 바깥에서 그림자 방을 들여다볼 수 있어요
const a = document.querySelector("like-button");
console.log(a.shadowRoot); // → #shadow-root (안의 내용이 보임)
// closed: 바깥에서 막혀 있어요
// constructor 에서 this.attachShadow({ mode: "closed" }) 로 만든 경우
const b = document.querySelector("some-closed-element");
console.log(b.shadowRoot); // → null (접근 거부)
open이면 el.shadowRoot로 방 안을 들여다보고 조작할 수 있어요. closed면 el.shadowRoot가 null이라, 컴포넌트 자신만 (생성자에서 attachShadow가 돌려준 값을 변수에 따로 보관해서) 방에 접근할 수 있어요.
그럼 closed가 더 안전하니 항상 써야 할까요? 실무에선 보통 open을 써요. 이유는 이래요.
- 캡슐화 강도의 차이는 생각보다 작아요.
closed라도 마음먹으면 우회할 방법이 있어서, "진짜 보안"을 주진 못해요. 약속 수준의 가림막에 가까워요. - 디버깅과 테스트가 불편해져요. 개발 도구나 테스트 코드가 방 안을 못 들여다봐서, 문제를 추적하기 어려워져요.
- 다른 코드와의 협업이 막혀요. 접근성 도구나 외부 스크립트가 컴포넌트 내부를 읽어야 할 때
closed면 길이 막혀요.
그래서 "꽁꽁 잠근다고 무조건 좋은 게 아니다"가 결론이에요. 적당히 열어두는 open이 실무의 기본값이에요.
채점 포인트
| 항목 | 확인 |
|---|---|
open은 el.shadowRoot 접근 가능, closed는 null 임을 확인 |
✅ |
closed가 "완벽한 보안"이 아니라는 점 이해 |
✅ |
실무에서 open을 기본으로 쓰는 이유(디버깅·협업) 1개 이상 |
✅ |
흔한 실수
- "closed가 더 안전하니 무조건 좋다"고 결론 내리기 — 캡슐화 강도 차이는 작고, 잃는 게 더 많아요.
- open/closed를 "스타일 격리 강도" 차이로 오해하기 — 둘 다 스타일 격리는 똑같이 돼요. 차이는 외부에서의 JS 접근뿐이에요.
실무 개선 포인트 (심화)
- 라이브러리로 배포하는 컴포넌트라면, 사용자가 내부를 함부로 못 건드리게
closed를 고려할 수도 있어요. 하지만 그조차도 대부분open+ "내부를 건드리지 말라"는 문서 안내로 해결해요. 잠그는 비용이 얻는 것보다 큰 경우가 많거든요.
생각해볼 주제 예시답안
1. 커스텀 태그 이름엔 왜 하이픈이 꼭 있어야 할까
[문제 상황 요약]
customElements.define으로 새 태그를 만들 때, 이름에 반드시 하이픈(-)을 넣어야 해요. <likebutton>은 거부되고 <like-button>은 허용돼요. 이 규칙이 브라우저의 미래와 어떤 관계가 있는지가 질문이에요.
[튜터의 가이드 및 해설]
핵심은 이름 충돌을 미래까지 막기 위한 영역 분리예요. 브라우저 표준은 지금도 자라고 있어서, 언젠가 <carousel>이나 <rating> 같은 새 기본 태그가 추가될 수 있어요. 만약 개발자가 한 단어짜리 <rating>을 이미 만들어 쓰고 있었다면, 브라우저가 같은 이름의 기본 태그를 내놓는 순간 둘이 정면 충돌해요. 기존 페이지가 망가지는 거죠.
그래서 표준은 영역을 깔끔하게 갈라뒀어요. 하이픈 없는 한 단어 이름은 브라우저 몫, 하이픈 든 이름은 개발자 몫. 브라우저는 앞으로도 절대 하이픈 든 기본 태그를 만들지 않겠다고 약속했어요. 그러니 내가 <like-button>을 만들어 두면, 몇 년이 지나도 브라우저 기본 태그와 부딪힐 일이 없어요.
만약 이 규칙이 없었다면, 새 브라우저 버전이 나올 때마다 "내 태그 이름이 새 기본 태그와 겹치진 않나" 조마조마해야 했을 거예요. 작은 규칙 하나가 미래의 큰 혼란을 막아준 셈이에요.
🎯 면접관을 홀리는 핵심 멘트
"커스텀 엘리먼트의 하이픈 규칙은 브라우저와 개발자의 이름 공간을 영구히 분리하기 위한 약속이에요. 브라우저는 하이픈 없는 한 단어 태그만 추가하고, 하이픈 든 이름은 개발자에게 영원히 양보하죠. 덕분에 표준이 아무리 자라도 내 커스텀 태그가 미래의 기본 태그와 충돌하지 않아요."
2. 캡슐화는 좋은데, 바깥 테마는 어떻게 적용하지
[문제 상황 요약]
Shadow DOM은 바깥 CSS를 막아 충돌을 없애줘요. 그런데 B-8에서 만든 다크 모드처럼 페이지 전체 색을 바꾸고 싶을 때, 컴포넌트가 그림자 방으로 닫혀 있으면 바깥 테마가 안 들어가요. 충돌은 막되 의도한 테마는 통과시키는 방법이 질문이에요.
[튜터의 가이드 및 해설]
이건 캡슐화의 트레이드오프예요. "바깥을 다 막는" 강점이, "필요한 것까지 막는" 약점이 되는 순간이죠. 다행히 표준은 이 문제에 통로를 하나 뚫어 뒀어요. 바로 B-8에서 배운 CSS 변수(--로 시작하는 커스텀 프로퍼티)예요.
CSS 변수는 일반 스타일과 달리 그림자 방의 벽을 통과해요. 바깥에서 :root { --like-color: #ed4956 }처럼 변수를 정해두면, 그림자 방 안의 .heart { color: var(--like-color) }가 그 값을 받아써요. 평범한 color: red는 못 뚫고 들어오지만, 변수라는 정해진 통로는 열려 있는 거예요.
그래서 컴포넌트를 만들 때 색·간격처럼 바깥에서 바꾸고 싶을 만한 값은 var(--이름, 기본값) 형태로 열어둬요. 이러면 다크 모드일 때 바깥에서 변수 값만 바꿔주면, 닫힌 컴포넌트도 테마를 따라가요. "막을 건 막고, 열 건 연다"는 균형을 변수가 잡아주는 거예요.
🎯 면접관을 홀리는 핵심 멘트
"Shadow DOM은 일반 CSS는 막지만 CSS 커스텀 프로퍼티는 통과시켜요. 그래서 컴포넌트의 테마 가능한 값을
var(--token, 기본값)으로 열어두면, 캡슐화는 유지하면서도 바깥에서 다크 모드 같은 테마를 주입할 수 있어요. 캡슐화와 테마 적용이라는 두 요구를 변수가 동시에 만족시키죠."
3. 직접 만든 조각 vs 준비된 도구, 언제 무엇을 고를까
[문제 상황 요약]
지난 시간엔 차트를 "직접 그리기 vs Chart.js"로, 이번엔 컴포넌트를 "직접 만들기 vs 다음 과정의 전용 도구"로 비교했어요. 작은 프로젝트와 큰 서비스에서 각각 무엇을 고를지, 그 기준이 질문이에요.
[튜터의 가이드 및 해설]
두 비교는 사실 같은 질문이에요. "통제력 vs 편의성" 사이에서 어디에 설지를 고르는 거죠. 직접 만들면 내 도메인에 딱 맞게 통제할 수 있지만 손이 많이 가요. 준비된 도구를 쓰면 빠르고 편하지만 그 도구의 방식을 따라야 해요.
기준은 보통 규모와 재사용성이에요. 좋아요 버튼 하나, 작은 위젯 하나라면 Web Components로 충분해요. 설치할 것도 없고, 어디에 갖다 놔도 동작하고, 가볍거든요. 반대로 화면이 수십 개고 데이터가 복잡하게 얽힌 큰 서비스라면, 상태와 화면 갱신을 손으로 일일이 엮는 게 금세 버거워져요. 그땐 그걸 대신 관리해주는 전용 도구가 손이 훨씬 덜 가요.
그리고 중요한 건, 이 둘이 적이 아니라는 거예요. 큰 서비스 안에서도 "여러 곳에서 공통으로 쓰는 작은 부품"은 Web Components로 만들어 두고, 전체 뼈대는 전용 도구로 짜는 식으로 섞어 써요. 결국 좋은 개발자는 "무조건 직접" 도, "무조건 도구" 도 아니에요. 상황을 읽고 적절한 쪽을 고르는 사람이에요. 직접 만들어 본 경험이 있어야 도구도 잘 고르고요.
🎯 면접관을 홀리는 핵심 멘트
"직접 만들기와 준비된 도구는 통제력과 편의성의 트레이드오프예요. 작고 재사용되는 부품엔 라이브러리 없는 Web Components가, 화면이 많고 상태가 복잡한 큰 앱엔 상태 관리를 대신해주는 전용 도구가 어울려요. 둘은 배타적이지 않아서, 큰 앱 안의 공용 부품을 Web Components로 만들어 섞어 쓰기도 하죠. 직접 만들어 본 사람이 도구의 가치도 정확히 판단해요."