E-1: 클래스 & 프로토타입 — 객체를 찍어내는 틀, 그리고 다크 모드 약속 갚기
목차 21
안녕하세요, 홍순구 튜터입니다. 우리 인스타그램 클론, 여기까지 정말 멀리 왔어요. Category A에서 HTML로 뼈대를 세웠고, Category B에서 CSS로 옷을 입혔죠. 그리고 지난 시간(B-8)에는 흩어진 색값을 variables.css 한곳에 모으고, 그 토대 위에 다크 모드까지 얹으면서 CSS를 완주했어요.
그런데 지난 시간 마지막에 제가 약속을 하나 남겼어요. "구조만 깔아둔 그 다크 모드 토글 버튼도, JavaScript를 배우면 드디어 클릭 한 번으로 켜고 끌 수 있게 돼요"라고요. 오늘이 바로 그 약속을 갚는 날이에요. 그리고 그 약속을 갚을 도구가 오늘의 주인공, 클래스(class) 예요.
JavaScript 자체는 오랜만이죠? D-5에서 localStorage에 로그인 정보를 저장하고, DevTools로 저장소와 네트워크를 들여다보는 것까지 해냈어요. 지금까지는 함수 따로, 객체 따로 다뤘다면 — 오늘부터는 데이터와 동작을 함께 설계하는 법을 배워요. Category E, JavaScript 심화의 첫 모듈이에요.
오늘의 로드맵은 이래요. 먼저 객체를 찍어내는 틀인 class를 익히고(Step 1~2), D-3부터 키워 온 피드의 renderPost 함수를 PostCard 클래스로 옮겨요(Step 3). 그다음 클래스를 쓰다 보면 반드시 만나는 this 함정과 세 가지 해법을 배워요(Step 4~5).
이어서 상속과 프로토타입으로 클래스 밑의 속사정을 들여다본 뒤(Step 6~7), 드디어 다크 모드 토글을 완성해요(Step 8). 마지막엔 Symbol이라는 특별한 값도 맛봐요(Step 9).
지난 여정 (A ~ B-8, C ~ D-5) 오늘 (E-1)
┌────────────────────────────────┐ ┌─────────────────────────────────┐
│ HTML 뼈대 → CSS 옷 → 다크 모드 │ │ 객체를 찍어내는 틀: class │
│ JS: 함수·객체·DOM·fetch │ ──▶ │ 피드 카드가 PostCard 클래스로 │
│ 다크 모드: CSS 구조만 깔림 │ │ 토글 버튼이 드디어 움직임 │
└────────────────────────────────┘ └─────────────────────────────────┘
💡 오늘 수업의 핵심 — "비슷한 객체를 여럿 만들 땐 class라는 틀로 찍어낸다. 틀에서 나온 인스턴스는 데이터를 각자 갖고, 동작은 함께 쓴다. 그리고 메서드를 점(.) 없이 떼어 부르면 this를 잃는다 — bind나 화살표 필드로 붙들어 맨다." 🎯
🎯 학습 목표
- 객체 리터럴 복붙의 한계를 눈으로 확인하고, class(틀)와 new로 인스턴스를 찍어냅니다.
- constructor와 this로 인스턴스마다 다른 데이터를 담습니다.
- D-3의
renderPost함수를PostCard클래스로 옮기는 리팩토링을 경험합니다. - 메서드를 떼어냈을 때 this가 사라지는 이유를 이해하고, bind · 화살표 필드 · call/apply 세 해법으로 붙듭니다.
- extends와 super로 기존 클래스를 상속받아 광고 카드(
AdPostCard)를 만듭니다. - 클래스 밑에서 돌아가는 프로토타입 체인을 콘솔로 직접 확인합니다.
- B-8에서 구조만 깔아둔 다크 모드 토글을
ThemeToggle클래스로 완성합니다. - Symbol로 절대 겹치지 않는 키를 만들어 봅니다.
Step 1: "왜 클래스인가?" — 객체 리터럴 복붙의 한계
새 문법을 배우기 전에, 먼저 "왜 필요한가"부터 몸으로 느껴볼게요. C-4에서 배운 객체 리터럴, 기억하시죠? 중괄호 {}로 데이터와 메서드를 한 객체에 담는 방식이요. 그걸로 게시물을 두 개 만들어보면, 클래스가 왜 필요한지 바로 보여요.
js/class-demo.js라는 새 파일을 만들어요. 이 파일은 화면(DOM)을 전혀 안 건드리고 콘솔 출력만 해요. 그래서 C-6의 async-demo.js처럼, 브라우저 없이 터미널에서 node js/class-demo.js로도 돌려볼 수 있어요. 물론 Live Server를 켜고 브라우저 콘솔에서 확인해도 똑같아요.
// instagram-clone-frontend/js/class-demo.js
// C-4 에서 배운 객체 리터럴로 게시물을 만들어 봐요. 게시물마다 똑같은 메서드를 또 적게 돼요.
const literalPost1 = {
username: "jiwoo_log",
likes: 12,
printLikes() {
console.log(`${this.username}의 게시물 — 좋아요 ${this.likes}개`);
},
};
const literalPost2 = {
username: "minjae_dev",
likes: 47,
printLikes() { // literalPost1 과 완전히 똑같은 코드를 복붙했어요
console.log(`${this.username}의 게시물 — 좋아요 ${this.likes}개`);
},
};
literalPost1.printLikes(); // jiwoo_log의 게시물 — 좋아요 12개
literalPost2.printLikes(); // minjae_dev의 게시물 — 좋아요 47개
// 똑같이 생긴 함수인데, 객체마다 따로 만들어져서 "서로 다른" 함수예요.
console.log(literalPost1.printLikes === literalPost2.printLikes); // false
// 게시물이 100개면 똑같은 함수가 메모리에 100개... 객체를 찍어낼 "틀"이 필요한 이유예요.
돌아가긴 잘 돌아가요. 그런데 두 가지가 걸려요.
첫째, printLikes 메서드를 복붙했어요. 게시물이 100개면 똑같은 코드를 100번 적어야 하고, 출력 문구를 바꾸고 싶으면 100군데를 다 고쳐야 해요.
둘째, 마지막 줄을 보세요. literalPost1.printLikes === literalPost2.printLikes가 false예요. 코드 생김새는 글자 하나까지 똑같은데, 만들어질 때 메모리에 따로따로 만들어진 별개의 함수거든요. 객체 100개를 만들면 똑같은 함수가 메모리에 100개 쌓여요. 낭비죠.
이 문제를 푸는 게 바로 오늘 배울 클래스예요. 비유하자면 이래요. 객체 리터럴 복붙은 붕어빵을 주문이 들어올 때마다 손으로 하나하나 빚는 거예요. 반면 클래스는 붕어빵 틀이에요. 틀을 한 번 만들어 두면, 팥이 든 붕어빵도, 슈크림이 든 붕어빵도, 같은 틀에서 계속 찍어낼 수 있죠. 모양(동작)은 틀이 책임지고, 속재료(데이터)만 매번 달라지는 거예요.
"게시물 객체를 100개나 만들 일이 있나요?" 싶을 수 있는데 — 이미 만들고 있어요. 우리 피드는 D-3부터 json-server에서 게시물 데이터를 받아와 화면에 그리고 있고, D-4의 무한 스크롤은 스크롤할 때마다 게시물 객체를 계속 더 받아오죠. 게시물 하나하나가 전부 객체예요.
💡 객체 리터럴 복붙은 두 번이면 몰라도 백 번은 못 해요. 똑같은 메서드가 메모리에 객체 수만큼 쌓이고, 고칠 때도 전부 찾아다녀야 하니까요. 그래서 객체를 찍어내는 틀, 클래스가 필요해요.
Step 2: "붕어빵 틀과 붕어빵" — class로 객체 찍어내기
이제 진짜 틀을 만들어볼게요. 새 용어가 한꺼번에 나오니, 먼저 이름표부터 정리하고 코드로 갈게요. 외우려 하지 말고, 코드를 보면서 "아 이게 그거구나" 하고 연결만 하면 돼요.
| 용어 | 뜻 |
|---|---|
| class (클래스) | 객체를 찍어내는 틀 |
| new | 틀로 객체를 하나 찍어내는 키워드 |
| instance (인스턴스) | 틀로 찍혀 나온 객체 하나하나 |
| constructor (생성자) | 찍는 순간 딱 한 번 실행되는 준비 절차 |
| this | 지금 만들어지는, 또는 메서드를 부른 그 인스턴스 |
class-demo.js에 이어서 적어요.
// instagram-clone-frontend/js/class-demo.js
// class 가 틀, new 가 "틀로 하나 찍기", 찍혀 나온 객체가 인스턴스(instance)예요.
class Post {
// constructor(생성자): new 로 찍는 순간 한 번 실행돼요. this = 지금 만들어지는 인스턴스.
constructor(username, likes) {
this.username = username; // 인스턴스마다 따로 갖는 데이터(필드)
this.likes = likes;
}
// 메서드: 모든 인스턴스가 "함께 쓰는" 동작 (Step 7 에서 어디에 사는지 확인해요)
like() {
this.likes += 1;
}
printLikes() {
console.log(`${this.username}의 게시물 — 좋아요 ${this.likes}개`);
}
}
const post1 = new Post("jiwoo_log", 12);
const post2 = new Post("minjae_dev", 47);
post1.like();
post1.like();
post1.printLikes(); // jiwoo_log의 게시물 — 좋아요 14개
post2.printLikes(); // minjae_dev의 게시물 — 좋아요 47개 (post1 만 늘고 post2 는 그대로 — 인스턴스 독립)
흐름을 따라가 볼게요. new Post("jiwoo_log", 12)라고 적으면, JavaScript가 빈 객체를 하나 만들고 constructor를 실행해요. 이때 constructor 안의 this는 지금 만들어지고 있는 그 객체를 가리켜요.
그래서 this.username = username은 "이 새 객체에 username을 담아라"는 뜻이 되죠. 다 끝나면 완성된 객체 — 인스턴스 — 가 post1에 담겨요.
출력을 보면 인스턴스의 독립성이 보여요. post1.like()를 두 번 불렀더니 post1의 좋아요만 12에서 14가 됐고, post2는 47 그대로예요. 같은 틀에서 나왔지만, 각자의 데이터는 완전히 따로 살아요. 팥붕어빵을 한 입 베어 물어도 옆의 슈크림붕어빵은 멀쩡한 것처럼요.
┌────────── class Post (틀) ───────────────┐
│ constructor(username, likes) │
│ like() · printLikes() ← 동작은 틀에 한 벌│
└──────────────┬───────────────────────────┘
new Post(...) ← 틀로 찍기
┌────────────┴────────────┐
▼ ▼
post1 (인스턴스) post2 (인스턴스)
username: "jiwoo_log" username: "minjae_dev"
likes: 14 likes: 47
그림에서 눈여겨볼 점이 하나 있어요. 데이터(username, likes)는 인스턴스마다 따로 있는데, 동작(like, printLikes)은 틀에 한 벌만 있어요. Step 1에서 객체 리터럴은 메서드가 객체마다 복사돼 === 비교가 false였죠? 클래스에서는 두 인스턴스가 같은 메서드를 함께 써요. 이 메서드가 정확히 어디에 사는지는 Step 7에서 직접 확인할 거예요.
💡 class는 틀, new는 찍기, 인스턴스는 찍혀 나온 객체예요. constructor가 인스턴스마다 다른 데이터를 채우고, 메서드는 틀에 한 벌만 둬서 모든 인스턴스가 함께 써요.
Step 3: "renderPost의 이사" — 화면을 그리는 PostCard 클래스
콘솔 데모는 충분히 했으니, 이제 진짜 우리 앱에 클래스를 적용해볼게요. 대상은 D-3에서 만들고 D-4까지 키워 온 renderPost(post) 함수예요. 게시물 데이터를 받아 <article>을 만들고, 템플릿 마크업을 채워 돌려주던 그 함수요. 잘 돌아가던 함수지만, 데이터 받기 · article 만들기 · 마크업 채우기를 함수 하나가 전부 떠안고 있었죠.
오늘 이 함수를 PostCard라는 카드 클래스로 옮겨요. 이런 작업을 리팩토링(refactoring) 이라고 불러요. 동작은 그대로 두고 코드의 구조만 고치는 일이요. 그래서 오늘 작업이 끝나도 화면 모습은 한 픽셀도 안 변해요. 화면이 똑같다는 게 실패가 아니라, 그게 바로 성공의 증거예요.
Before (D-3 ~ D-4) After (오늘)
renderPost(post) 함수 하나가 class PostCard
├─ 데이터(post)도 받고 ├─ constructor(post) → 데이터 보관
├─ <article> 도 만들고 ├─ render() → <article> 생성
└─ 안쪽 마크업도 채움 └─ buildTemplate() → 안쪽 마크업
새 파일 js/post-card.js를 만들어요.
// instagram-clone-frontend/js/post-card.js
export class PostCard {
constructor(post) {
this.post = post; // 게시물 데이터(객체)를 인스턴스에 담아 둬요
}
// <article> 한 채를 만들어 돌려줘요 — 예전 renderPost(post) 와 화면 모습이 똑같아요.
render() {
const article = document.createElement("article");
article.dataset.postId = this.post.id; // 어느 게시물인지 기억해 둬요 (댓글 POST 에 필요)
article.innerHTML = this.buildTemplate();
return article;
}
// 카드 안쪽 마크업 — renderPost 시절의 템플릿을 그대로 옮겨 왔어요.
buildTemplate() {
const post = this.post;
return `
<header class="post-header">
<a class="post-user" href="profile.html">
<img class="post-avatar" src="${post.avatar}" alt="${post.username} 프로필 사진" width="32" height="32">
<strong class="post-author">${post.username}</strong>
</a>
...
`;
}
}
buildTemplate의 템플릿 전문은 생략했어요. D-3에서 함께 만든 그 마크업이 글자 하나 안 바뀌고 그대로 들어가요(전체는 post-card.js 파일에서 확인하세요). 대신 구조 변화에 집중해 주세요.
constructor(post)— 게시물 데이터를 받아this.post에 보관해요. 카드 하나 = 인스턴스 하나, 그 인스턴스가 자기 데이터를 품고 다녀요.render()—<article>을 만들어 돌려줘요. D-4에서 댓글 저장(POST)에 썼던dataset.postId도 그대로 챙겨요.buildTemplate()— 안쪽 마크업만 만들어요. 템플릿을 고치고 싶으면 이 메서드만 보면 돼요.
함수 하나에 뭉쳐 있던 일이 이름 붙은 메서드 셋으로 나뉘었어요. 코드를 처음 보는 사람도 메서드 이름만 읽고 "아, 데이터 보관 / article 생성 / 마크업 담당이구나" 하고 지도를 그릴 수 있죠.
이제 호출하는 쪽, feed.js도 바꿔요.
// instagram-clone-frontend/js/feed.js
import { PostCard, AdPostCard } from "./post-card.js";
async function loadPage() {
// ...
result.data.forEach((post, index) => {
// 매 페이지의 마지막(3번째, index 2) 게시물은 광고 카드로 그려요.
// 같은 데이터, 다른 모습 — 상속(AdPostCard extends PostCard) 데모예요.
const card = index === 2 ? new AdPostCard(post) : new PostCard(post);
feedMain.insertBefore(card.render(), sentinel); // 감시병 위에 차례로
});
// ...
}
예전엔 renderPost(post)를 불렀다면, 이제는 new PostCard(post)로 카드 인스턴스를 찍고 card.render()로 article을 받아요. 게시물 한 채 = 인스턴스 하나. Step 2의 붕어빵 틀이 그대로 우리 피드에 들어온 거예요.
그런데 코드에 AdPostCard라는 낯선 이름이 보이죠? 광고 게시물을 위한 카드인데, Step 6에서 상속을 배우면서 직접 만들어요. 지금은 new PostCard(post) 흐름만 따라와 주세요.
json-server와 Live Server를 켜고 feed.html을 열어보세요. 게시물도, 무한 스크롤도, 댓글도 전부 예전 그대로 동작해요. 화면은 그대로인데 코드의 구조가 좋아졌다 — 이게 리팩토링이에요.
🙋 학생 질문 — "함수로도 잘 돌아갔는데, 왜 굳이 클래스로 바꾸나요?"
지금 당장의 동작만 보면 차이가 없어요. 차이는 앞으로 코드가 자랄 때 나타나요.
첫째, 데이터와 동작이 한 객체 안에 모여요. renderPost 시절엔 post 데이터를 함수 인자로 계속 들고 다녀야 했는데, 이제는 카드 인스턴스가 자기 데이터를 품고 있어서 어떤 메서드든 this.post로 꺼내 쓰면 돼요.
둘째, 변형이 쉬워져요. 광고 카드, 추천 카드처럼 "기본 카드에서 살짝 다른" 카드가 필요해지면, 함수였다면 if 분기가 함수 안에 자꾸 쌓였을 거예요. 클래스는 상속으로 "기본은 물려받고 다른 부분만" 적을 수 있어요. 바로 다음다음 Step에서 AdPostCard로 직접 확인해요.
💡 리팩토링은 동작은 그대로, 구조만 고치는 일이에요. 화면이 한 픽셀도 안 변했다면 그게 성공이에요. renderPost가 하던 세 가지 일이 constructor / render / buildTemplate로 이름을 얻었어요.
Step 4: "this를 잃어버리는 함정" — 떼어낸 메서드는 주인을 모른다
클래스가 슬슬 손에 익을 때쯤, 모든 JavaScript 입문자가 한 번씩 넘어지는 돌부리가 있어요. 바로 this예요. 미리 넘어져 보고 일어나는 법까지 익혀두면, 실전에서 만났을 때 "아, 그거!" 하고 바로 잡을 수 있어요.
먼저 규칙부터요. this는 함수를 점(.)으로 부른 그 주인이 정해요. post1.printLikes()라고 부르면, 점 앞에 있는 post1이 this가 돼요. 그런데 중요한 건 — 메서드는 자기 주인이 누군지 적힌 명찰을 갖고 다니지 않아요. 부르는 순간, 점 앞의 객체가 그때그때 명찰이 되어줄 뿐이에요. 그럼 점 없이 부르면 어떻게 될까요? 명찰 없이 출근한 직원처럼, "당신 누구 소속이죠?"에 답을 못 해요.
class-demo.js에 이어서 직접 넘어져 볼게요.
// instagram-clone-frontend/js/class-demo.js
// 메서드를 변수에 "떼어서" 담으면, 함수만 복사되고 주인(this)은 따라오지 않아요.
const detached = post1.printLikes;
try {
detached(); // class 안의 코드는 자동으로 strict mode — this 가 undefined 라서 에러
} catch (error) {
console.log("떼어서 호출 → 에러:", error.message);
}
// setTimeout(post1.printLikes, 1000) 도 똑같이 터져요.
// setTimeout 은 받은 함수를 나중에 "그냥 함수로" 부르거든요 — 아래 흉내처럼요.
function callLikeSetTimeout(callback) {
try {
callback(); // 주인 없이 호출 → this 가 사라져요
} catch (error) {
console.log("setTimeout 흉내 → 에러:", error.message);
}
}
callLikeSetTimeout(post1.printLikes);
실행하면 콘솔에 이렇게 나와요.
떼어서 호출 → 에러: Cannot read properties of undefined (reading 'username')
setTimeout 흉내 → 에러: Cannot read properties of undefined (reading 'username')
에러 메시지를 읽어볼까요? "undefined의 'username'을 읽을 수 없다" — printLikes 안의 this.username에서 this가 undefined였다는 뜻이에요. detached에는 함수 본체만 복사됐고, 주인 정보는 안 따라왔거든요. 점 없이 detached()로 부르니 this를 정해줄 객체가 없는 거예요.
두 번째 사례가 더 무서워요. setTimeout(post1.printLikes, 1000)처럼 메서드를 다른 함수에 인자로 넘기는 순간에도 똑같이 떼어내기가 일어나요. C-6에서 만난 setTimeout은 받은 함수를 나중에 "그냥 함수로" 부르니까, 그 시점엔 점도 없고 주인도 없죠. 이벤트 핸들러에 메서드를 넘길 때도 똑같은 일이 벌어져요. Step 8의 다크 모드 토글에서 우리가 실제로 마주칠 상황이에요.
여기서 한 가지 다행인 점이 있어요. class 안의 코드는 자동으로 엄격 모드(strict mode) 로 동작해요. 그래서 주인을 잃은 this가 undefined로 남고, 건드리는 순간 에러가 나죠. 옛날식 함수였다면 this가 슬그머니 전역 객체를 가리켜서, 에러도 없이 엉뚱한 값이 나오는 조용한 버그가 됐을 거예요. 시끄럽게 터져주는 쪽이 찾기 훨씬 쉬워요.
💡 에러는 친구예요. "Cannot read properties of undefined"가 보이면 당황하지 말고 "아, this가 주인을 잃었구나"부터 의심하세요. class가 엄격 모드라서 조용히 망가지는 대신 크게 알려주는 거예요. 일어나는 법은 바로 다음 Step에서 배워요.
Step 5: "this를 붙드는 3가지 해법" — bind · 화살표 필드 · call/apply
넘어졌으니 일어나야죠. this를 붙드는 해법은 세 가지예요. 하나씩 코드로 확인하고, 마지막에 "언제 뭘 쓰나" 표로 정리할게요.
해법 ①은 bind예요. bind는 "묶다"라는 뜻 그대로, this를 영구 접착한 새 함수를 만들어 돌려줘요.
// instagram-clone-frontend/js/class-demo.js
// 해법 ① bind — this 를 영구 접착한 "새 함수"를 만들어 돌려줘요.
const boundPrint = post1.printLikes.bind(post1);
boundPrint(); // 떼어내 불러도 OK — this 가 post1 로 고정
setTimeout(post1.printLikes.bind(post1), 100); // setTimeout 도 안전 (이 파일 출력의 맨 끝에 나와요)
post1.printLikes.bind(post1)은 "printLikes의 this를 post1로 묶은 새 함수를 달라"는 뜻이에요. 그렇게 받은 boundPrint는 점 없이 불러도, setTimeout에 넘겨도, this가 post1에서 절대 안 떨어져요. Step 4에서 터지던 두 사례가 전부 해결되죠.
해법 ②는 클래스 필드 + 화살표 함수예요. 클래스 필드(class field)는 constructor 밖에서 이름 = 값 꼴로 적는 인스턴스 속성이고, 거기에 화살표 함수를 담는 거예요. C-2에서 화살표 함수를 배울 땐 "함수를 짧게 적는 문법" 정도로 익혔는데, 사실 진짜 차이는 this에 있어요. 화살표 함수는 자기 this가 없어요. 그래서 만들어질 때 자기를 감싸고 있던 this를 그대로 기억해요.
// 해법 ② 클래스 필드 + 화살표 함수 — 화살표 함수는 자기 this 가 없어서,
// 만들어질 때 감싸고 있던 this(= 인스턴스)를 그대로 기억해요.
class Story {
constructor(username) {
this.username = username;
this.views = 0;
}
// 필드에 화살표 함수를 담으면, 인스턴스마다 this 가 고정된 함수가 생겨요.
addView = () => {
this.views += 1;
console.log(`${this.username}의 스토리 — 조회수 ${this.views}`);
};
}
const story = new Story("soohyun_k");
const detachedAddView = story.addView;
detachedAddView(); // 떼어내도 OK — soohyun_k의 스토리 — 조회수 1
detachedAddView(); // 조회수 2
addView는 인스턴스가 만들어지는 순간에 태어나면서, 그때의 this(= story 인스턴스)를 기억해요. 그래서 detachedAddView처럼 떼어내도 끄떡없죠. bind처럼 쓸 때마다 묶어줄 필요 없이, 설계 단계에서부터 떼어 써도 안전한 메서드가 되는 거예요. Step 8의 다크 모드 토글에서 이 해법 ②를 실전으로 써요. 버튼 클릭 핸들러로 메서드를 넘겨야 하거든요.
해법 ③은 call과 apply예요. 앞의 둘이 "나중에 부를 함수"를 준비하는 도구였다면, 이 둘은 this를 이번 한 번만 지정해서 즉시 호출하는 도구예요.
// 해법 ③ call / apply — this 를 "이번 한 번만" 지정해서 즉시 호출해요.
function introduce(role, mark) {
console.log(`${mark} ${this.username} — ${role}`);
}
introduce.call(post1, "오늘의 인기 게시물", "[인기]"); // 인자를 낱개로 전달
introduce.apply(post2, ["오늘의 새 게시물", "[NEW]"]); // 인자를 배열 하나로 전달
// 둘 다 "this 를 지정해 즉시 호출"은 같고, 인자 전달 방식만 달라요.
// 다른 객체에게 메서드를 빌려줄 수도 있어요:
post1.printLikes.call({ username: "ad_official", likes: 999 }); // ad_official의 게시물 — 좋아요 999개
introduce는 어느 클래스에도 속하지 않은 일반 함수인데, call(post1, ...)로 부르면 그 한 번만 this가 post1이 돼요. call과 apply의 차이는 인자를 낱개로 주느냐(call), 배열 하나로 주느냐(apply)뿐이에요. 마지막 줄이 재밌죠? post1의 printLikes 메서드를 전혀 다른 객체에 빌려줘서 호출했어요. "메서드 빌려주기"라고 부르는 활용법이에요.
세 해법을 한 표에 정리할게요.
| 해법 | 무엇을 하나 | 언제 쓰나 |
|---|---|---|
bind(this) |
this를 영구 고정한 새 함수를 돌려줌 (호출은 나중에) | 이미 있는 메서드를 떼어서 어딘가에 넘길 때 |
| 클래스 필드 + 화살표 함수 | 만들어질 때의 this를 기억하는 함수가 인스턴스마다 생김 | 처음부터 "떼어 써도 안전하게" 메서드를 설계할 때 |
call / apply |
this를 이번 한 번만 지정해 즉시 호출 | 다른 객체에 메서드를 잠깐 빌려줄 때 |
🙋 학생 질문 — "bind를 하면 원래 함수가 바뀌어버리는 건가요?"
아니요, 원본은 그대로예요. post1.printLikes.bind(post1)은 원본 printLikes를 건드리지 않고, this가 고정된 복제본 함수를 새로 만들어 돌려줘요. 그래서 결과를 변수에 받아둬야 의미가 있어요(const boundPrint = ...). bind만 호출하고 결과를 안 받으면 아무 일도 안 일어난 것과 같아요.
참고로 한 번 bind로 묶인 함수는 다시 bind해도 처음 주인이 안 바뀌어요. 접착제가 한 번 굳으면 끝이에요.
💡 나중에 부를 함수라면 bind 또는 화살표 필드, 지금 한 번만 부를 거라면 call/apply예요. 그리고 새 클래스를 설계할 때 "이 메서드는 떼어내져서 넘겨질 운명"이 보이면, 처음부터 화살표 필드로 적는 게 제일 깔끔해요.
자, 이제 클래스의 기본기와 this까지 갖췄어요. 다음 Step에서는 PostCard를 상속받아 광고 카드 AdPostCard를 만들어요. Step 3에서 잠깐 스쳐 간 그 낯선 이름의 정체를 밝힐 시간이에요.
Step 6: "복붙 없이 광고 카드" — extends와 super로 물려받기
기획자가 새 요구를 들고 왔다고 상상해볼게요. "피드에 광고 게시물을 끼워 넣을 건데요, 일반 게시물이랑 똑같이 생겼는데 시간 표시 대신 '광고'라고 떠야 해요." 들어보면 PostCard와 95%는 같아요. 다른 건 라벨 하나뿐이죠.
가장 먼저 떠오르는 방법은 복붙이에요. PostCard를 통째로 복사해 AdPostCard를 만들고 라벨만 고치는 거죠. 그런데 Step 1에서 뭘 배웠죠? 복붙은 고칠 때 지옥이 된다고요. 템플릿을 수정하면 두 군데를 고쳐야 하고, 둘은 슬금슬금 어긋나기 시작해요.
클래스에는 이럴 때 쓰라고 준비된 도구가 있어요. 상속(inheritance) 이에요. 부모 클래스의 능력을 통째로 물려받고, 다른 부분만 덧붙이거나 고쳐 적는 방식이죠. 문법은 extends(확장하다) 한 단어예요. 붕어빵 비유로 이으면 — 기본 붕어빵 틀(PostCard)은 그대로 쓰고, 다 구운 다음 "광고" 도장 하나 찍어 내보내는 추가 공정을 더하는 거예요.
class PostCard (부모)
├─ constructor(post) ← 데이터 보관
├─ render() ← <article> 생성
└─ buildTemplate() ← 안쪽 마크업
▲
│ extends (물려받기)
class AdPostCard (자식)
├─ constructor → super(post) 부모에게 그대로 위임
├─ render() → super.render()로 본체를 받고 광고 라벨만 추가
└─ buildTemplate은 안 적음 → 부모 것을 그대로 씀
post-card.js의 PostCard 아래에 이어서 만들어요.
// instagram-clone-frontend/js/post-card.js
// 광고 게시물 카드 — PostCard 를 상속(extends)받아 "광고 표식"만 더해요.
export class AdPostCard extends PostCard {
constructor(post) {
super(post); // 부모(PostCard)의 constructor 를 먼저 불러요 — 데이터 보관은 부모가 해 줘요
}
// render() 오버라이드 — 카드 본체는 부모(super)가 만들고, 광고 표식만 얹어요.
render() {
const article = super.render(); // 1) 부모의 render() 로 카드 한 채를 만들고
article.classList.add("post-ad"); // 2) 광고 카드라는 클래스를 달고
const badge = document.createElement("span");
badge.className = "ad-badge";
badge.textContent = "광고";
article.querySelector(".post-time").replaceWith(badge); // 3) 시간 자리에 "광고" 라벨
return article;
}
}
짧죠? 95%가 같은 카드를 만드는데 코드는 열몇 줄이에요. 세 군데를 짚어볼게요.
첫째, extends PostCard. "PostCard의 모든 것을 물려받는 새 클래스"라는 선언이에요. 그래서 buildTemplate처럼 자식이 안 적은 메서드는 부모 것이 그대로 동작해요. 템플릿을 나중에 고치면 일반 카드와 광고 카드 모두에 한 번에 반영되죠. 복붙이었다면 불가능한 일이에요.
둘째, super(post). constructor 안의 super(...)는 부모의 constructor를 호출해요. 자식 constructor에서는 this를 건드리기 전에 반드시 super를 먼저 불러야 한다는 규칙이 있어요. 부모가 기본 준비(this.post = post)를 끝내야, 자식이 그 위에 덧붙일 수 있으니까요.
셋째, render()를 다시 정의했어요. 부모와 같은 이름의 메서드를 자식이 다시 적으면 자식 것이 우선하는데, 이걸 오버라이드(override, 덮어쓰기) 라고 불러요. 그런데 첫 줄을 보세요 — super.render()로 부모의 render를 불러서 카드 본체를 통째로 받아와요. 본체 제작은 부모에게 맡기고, 자식은 클래스 하나 달고 시간 표시를 "광고" 라벨로 바꾸는 일만 해요. "부모가 일하고, 자식은 다른 부분만" — 상속을 쓸 때 제일 예쁜 그림이에요.
이제 Step 3에서 스쳐 갔던 feed.js의 분기가 의미를 갖게 돼요.
// instagram-clone-frontend/js/feed.js — loadPage() 안
result.data.forEach((post, index) => {
// 매 페이지의 마지막(3번째, index 2) 게시물은 광고 카드로 그려요.
// 같은 데이터, 다른 모습 — 상속(AdPostCard extends PostCard) 데모예요.
const card = index === 2 ? new AdPostCard(post) : new PostCard(post);
feedMain.insertBefore(card.render(), sentinel); // 감시병 위에 차례로
});
쓰는 쪽에서는 어느 틀로 찍을지만 고르면 돼요. 둘 다 card.render()로 똑같이 부를 수 있죠 — 부모에게서 물려받은 같은 사용법이니까요. 라벨의 생김새는 components.css에 준비해 뒀어요.
/* instagram-clone-frontend/css/components.css */
/* 광고 카드의 "광고" 라벨 — 시간(.post-time) 자리에 들어가요 */
.ad-badge {
color: var(--color-text-soft);
font-size: 0.875rem;
}
.ad-badge::before {
content: "•";
margin-right: 0.5rem;
}
색은 B-8에서 만든 의미 토큰 --color-text-soft를 썼어요. 시간 표시와 같은 연한 색이라, 다크 모드에서도 알아서 어울리는 색으로 변해요. ::before의 • 덕분에 실제 인스타그램 광고처럼 "• 광고"로 보이고요.
json-server와 Live Server를 켜고 feed.html을 열어 확인해보세요. 각 페이지의 3번째 카드에서, 작성자 이름 옆에 시간 대신 "• 광고"가 떠요. 무한 스크롤로 다음 페이지를 불러와도 3번째마다 어김없이 광고 카드가 나오고요. render()에서 달아 둔 post-ad 클래스는 나중에 광고 카드만 테두리 색을 바꾸고 싶을 때 쓸 수 있는 CSS 연결 고리예요.
이 extends, 오늘이 마지막이 아니에요. 다음 시간에 아주 실용적인 곳에서 다시 만나게 되니, 사용법을 잘 기억해두세요.
🙋 학생 질문 — "AdPostCard의 constructor는 super(post)만 하는데, 그럼 아예 없어도 되지 않나요?"
좋은 눈썰미예요. 맞아요 — 자식 클래스가 constructor를 아예 안 적으면, JavaScript가 부모의 constructor를 자동으로 불러줘요. 그래서 지금의 AdPostCard는 constructor를 지워도 똑같이 동작해요.
그런데도 적어둔 건, "new로 찍는 순간 부모의 준비 절차가 먼저 돈다"는 흐름을 눈으로 보여주고 싶어서예요. 그리고 나중에 자식만의 데이터를 추가하고 싶어지면(예: 광고주 이름), 어차피 constructor를 적고 super(post) 다음 줄에 this.advertiser = ...를 더하게 돼요. 단, constructor를 적는 순간 super 호출은 의무라는 점만 기억하세요.
💡 95% 같은 클래스가 필요하면 복붙 대신 상속이에요.
extends로 물려받고, constructor에서는super(...)로 부모 준비를 먼저 시키고, 다르게 동작할 메서드만 오버라이드해요. 오버라이드 안에서도super.render()로 부모에게 본체를 맡기면, 자식 코드는 "다른 부분"만 남아요.
Step 7: "메서드는 어디에 살까?" — 프로토타입 체인
Step 2에서 약속을 하나 남겨뒀었죠. "메서드가 정확히 어디에 사는지는 Step 7에서 확인해요"라고요. 갚을 시간이에요. 그리고 이 확인은 Step 1의 충격적인 false와 짝을 이뤄요. 객체 리터럴 복붙에서는 printLikes === printLikes가 false였잖아요. 클래스로 찍은 인스턴스는 어떨까요?
class-demo.js에 이어서 콘솔로 직접 파볼게요.
// instagram-clone-frontend/js/class-demo.js
// Step 1 의 복붙 객체와 달리, 클래스의 메서드는 프로토타입에 "한 번만" 있어요.
console.log(post1.like === post2.like); // true — 두 인스턴스가 같은 함수를 공유해요
console.log(Object.getPrototypeOf(post1) === Post.prototype); // true — 인스턴스의 숨은 부모가 Post.prototype
// instanceof: "이 객체, 이 틀로 찍었나요?" — 체인을 거슬러 올라가며 확인해요.
console.log(post1 instanceof Post); // true
console.log(post1 instanceof Object); // true — 체인 끝에 Object.prototype 이 있어서
console.log(story instanceof Post); // false — Story 로 찍은 인스턴스니까
// 체인 거슬러 오르기: post1 → Post.prototype → Object.prototype → null(끝)
const firstStop = Object.getPrototypeOf(post1); // Post.prototype
const secondStop = Object.getPrototypeOf(firstStop); // Object.prototype
console.log(secondStop === Object.prototype); // true
console.log(Object.getPrototypeOf(secondStop)); // null — 체인의 끝
첫 줄부터 보세요. post1.like === post2.like가 true예요. Step 1에서는 false였던 비교가, 클래스에서는 true가 됐어요. 두 인스턴스가 메모리에 딱 하나 있는 같은 함수를 함께 쓰고 있다는 증거죠. 그 함수가 사는 곳이 바로 프로토타입(prototype) 이에요.
클래스를 만들면 JavaScript는 Post.prototype이라는 객체를 함께 만들고, 클래스 본문에 적은 메서드들(like, printLikes)을 전부 거기에 넣어둬요. 그리고 new로 찍힌 인스턴스마다 그 prototype을 가리키는 숨은 링크가 연결돼요. 두 번째 줄의 Object.getPrototypeOf(post1) === Post.prototype이 true인 게 그 링크의 증거예요.
그럼 post1.like()라고 부르면 무슨 일이 벌어질까요? JavaScript는 점(.) 앞의 객체에서 먼저 찾아봐요. post1 자신에게는 username과 likes밖에 없죠. 없으면? 숨은 링크를 타고 부모로 올라가서 찾아요. Post.prototype에서 like를 발견하면 그걸 실행해요. 어디서 본 구조 같지 않나요? D-1에서 DOM을 가계도로 봤을 때, 요소에서 부모로, 또 그 부모로 타고 올라갔잖아요. 프로퍼티 찾기도 똑같이 가계도를 거슬러 올라가요.
이렇게 이어진 링크의 행렬을 프로토타입 체인(prototype chain) 이라고 불러요.
post1 (인스턴스) ── username, likes 만 가짐
│ 숨은 링크 (Object.getPrototypeOf 로 확인)
▼
Post.prototype ── like(), printLikes() 가 여기 살아요
│
▼
Object.prototype ── toString() 같은 모든 객체의 기본 장비
│
▼
null ── 체인의 끝 (더 올라갈 곳 없음)
코드의 마지막 네 줄이 이 그림을 그대로 따라가요. post1에서 한 칸 오르면 Post.prototype, 또 한 칸 오르면 Object.prototype, 거기서 또 오르면 null — 끝이에요. Object.prototype은 모든 객체의 공통 조상이라, 우리가 한 번도 만든 적 없는 toString 같은 메서드를 모든 객체가 쓸 수 있는 거예요.
instanceof(~의 인스턴스인가)는 이 체인을 검사하는 연산자예요. post1 instanceof Post는 "post1의 체인 어딘가에 Post.prototype이 있나?"를 물어요. 있으니 true. post1 instanceof Object도 체인 끝에 Object.prototype이 있으니 true예요. 반면 story는 Story로 찍었으니 story instanceof Post는 false고요. Step 6의 상속도 사실 이 체인 위에서 동작해요. AdPostCard 인스턴스의 체인에는 AdPostCard.prototype 다음에 PostCard.prototype이 이어져 있어서, 자식에 없는 buildTemplate을 부모에게서 찾아내는 거예요.
💡 class는 2015년(ES6)에 들어온 문법이지만 새 발명품이 아니에요. 그 전부터 JavaScript를 떠받치던 프로토타입 구조 위에 얹힌 깔끔한 표기법이죠. 메서드는 prototype에 한 벌만 살고, 인스턴스는 숨은 링크로 그걸 찾아 써요. 오늘 콘솔로 체인을 직접 거슬러 올라가 봤으니, 여러분은 문법 아래의 진짜 구조까지 본 거예요.
Step 8: "다크 모드 약속 갚기" — ThemeToggle 클래스 완성
드디어 오늘의 클라이맥스예요. B-8 마지막에 남겨둔 약속 — "JavaScript를 배우면 클릭 한 번으로 다크 모드를 켜고 끌 수 있게 돼요" — 를 지금 갚아요.
먼저 B-8이 깔아둔 토대를 다시 볼게요. 그때 우리는 색을 전부 variables.css의 의미 토큰으로 모았고, 다크 모드용 재정의를 두 갈래로 준비했어요.
/* instagram-clone-frontend/css/variables.css */
/* ① 자동: 운영체제가 어두운 테마일 때 (단, 사용자가 라이트로 강제하지 않은 경우) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-text: #fafafa;
--color-bg: #000000;
/* ...나머지 의미 토큰도 어두운 값으로... */
}
}
/* ② 수동: 사용자가 직접 다크를 고르면 (운영체제 설정을 이긴다) */
[data-theme="dark"] {
--color-text: #fafafa;
--color-bg: #000000;
/* ...①과 같은 어두운 값 묶음... */
}
CSS 쪽 공사는 이미 끝나 있어요. <html>에 data-theme="dark"가 달리면 ②가 켜지면서 페이지 전체가 어두워져요. 그때 우리가 할 수 있던 건 DevTools의 Elements 탭에서 그 속성을 손으로 적어 넣는 것까지였죠. 오늘 JS가 할 일은 단 하나 — 그 속성을 클릭에 맞춰 갈아끼우고, 선택을 기억하는 것이에요.
부품은 셋이에요. 버튼(HTML), 동작을 책임질 클래스(JS), 그리고 둘을 잇는 연결부. 버튼은 사이드바의 "더 보기" 아래에 이미 마련해 뒀어요.
<!-- instagram-clone-frontend/feed.html -->
<!-- E-1: 다크 모드 토글 — js/theme-toggle.js 의 ThemeToggle 클래스가 동작을 맡아요 -->
<button type="button" class="theme-toggle">
<span class="ico" aria-hidden="true">🌙</span><span>다크 모드</span>
</button>
생김새는 components.css의 .theme-toggle 규칙이 사이드바 항목과 같은 차림으로 맞춰줘요. 이제 주인공, ThemeToggle 클래스예요. 새 파일 js/theme-toggle.js를 만들어요. 전체가 35줄이라 통째로 보여드릴게요.
// instagram-clone-frontend/js/theme-toggle.js
// E-1: 다크 모드 토글을 책임지는 클래스.
// <html> 의 data-theme 을 light/dark 로 갈아끼우면,
// variables.css 의 [data-theme="dark"] 가 의미 토큰을 어두운 값으로 바꿔요.
export class ThemeToggle {
constructor(button, storageKey) {
this.button = button; // 토글 버튼 요소
this.storageKey = storageKey; // localStorage 서랍 이름 (예: "theme")
}
// 클릭 핸들러를 "클래스 필드 + 화살표 함수"로 — addEventListener 에 떼어 넘겨도
// this 가 인스턴스에 고정돼요 (this 를 잃어버리는 함정의 해법 ②를 실전 적용).
handleClick = () => {
this.toggle();
};
// 페이지가 열릴 때 한 번 — 저장된 테마를 복원하고, 버튼에 클릭을 연결해요.
init() {
const saved = localStorage.getItem(this.storageKey); // "dark" | "light" | null
if (saved) {
document.documentElement.dataset.theme = saved; // <html data-theme="..."> 복원
}
this.button.addEventListener("click", this.handleClick);
}
// 현재 테마를 반대로 — <html> 의 data-theme 을 바꾸고 localStorage 에 기억해요.
// "light" 도 명시해서 저장해요 — 운영체제가 다크여도 사용자의 라이트 선택이 이겨요.
toggle() {
const current = document.documentElement.dataset.theme;
const next = current === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
localStorage.setItem(this.storageKey, next);
}
}
조각조각 뜯어볼게요.
constructor(button, storageKey) — 어떤 버튼에 붙을지, localStorage의 어느 서랍에 저장할지를 밖에서 받아요. 클래스 안에 하드코딩하지 않은 덕분에, 다른 페이지의 다른 버튼에도 같은 클래스를 재사용할 수 있어요.
handleClick = () => { ... } — Step 5에서 배운 해법 ②, 클래스 필드 + 화살표 함수의 실전 등판이에요. 잠시 후 init()에서 이 메서드를 addEventListener에 인자로 넘기는데, 그게 바로 Step 4에서 미리 넘어져 본 "메서드 떼어내기"예요. 일반 메서드를 그대로 넘겼다면 클릭 순간 this가 인스턴스가 아닌 엉뚱한 대상을 가리켜서 this.toggle을 못 찾았을 거예요. 화살표 필드라 this가 인스턴스에 고정돼 있으니 안심하고 떼어 넘길 수 있죠. "떼어내져서 넘겨질 운명인 메서드는 처음부터 화살표 필드로" — Step 5의 결론 그대로예요.
init() — 페이지가 열릴 때 한 번 부르는 준비 절차예요. 두 가지를 해요. 먼저 D-5에서 배운 localStorage.getItem으로 지난번 선택을 꺼내요. 저장된 게 있으면 document.documentElement(= <html> 요소)의 dataset.theme에 복원하고, null이면 아무것도 안 해요 — 그 경우엔 variables.css의 ①이 운영체제 설정을 따라가죠. 그다음 버튼에 클릭 리스너를 연결해요. dataset은 D-1에서 dataset.postId로 만난 바로 그 문법이에요.
toggle() — 핵심 동작이에요. 현재 data-theme을 읽고, dark면 light로, 아니면 dark로 뒤집은 다음, 그 값을 localStorage에 저장해요. 여기서 설계 포인트 하나 — "light"도 명시해서 저장한다는 점이에요. 운영체제가 다크 모드인 사람이 우리 사이트에서 라이트를 선택했다고 해볼게요. 저장된 "light"가 <html data-theme="light">로 복원되면, ①의 선택자 :root:not([data-theme="light"])에 걸려서 자동 다크가 무효가 돼요. 운영체제 설정보다 사용자의 선택이 이기는 구조 — B-8에서 "사용자 선택 우선"을 고민하며 미리 깔아둔 장치가 오늘 제 역할을 하는 거예요.
마지막 부품, 연결부예요. feed.js의 맨 끝에 더해요.
// instagram-clone-frontend/js/feed.js
import { ThemeToggle } from "./theme-toggle.js";
// ...(파일 맨 끝)...
// ===== 다크 모드 토글 — ThemeToggle 클래스(E-1)가 복원·클릭·저장을 책임져요 =====
const themeButton = document.querySelector(".theme-toggle");
if (themeButton) {
new ThemeToggle(themeButton, "theme").init();
}
버튼을 찾아 인스턴스를 찍고 init()을 부르면 끝이에요. if (themeButton) 가드는 D-5의 auth.js에서 if (loginForm)으로 했던 것과 같은 안전장치예요 — 버튼이 없는 화면에서 이 코드가 돌아도 조용히 건너뛰게요.
이제 확인해보세요. 피드를 열고 사이드바의 "다크 모드" 버튼을 클릭하면, 그 순간 배경·글자·테두리가 전부 어두운 값으로 변해요. B-8에서 색을 의미 토큰으로 모아 둔 덕분에, 갈아끼운 건 변수 몇 개뿐인데 페이지 전체가 변하죠. 한 번 더 누르면 라이트로 돌아오고요. 그리고 진짜 마법 — 새로고침해도 다크가 유지돼요. localStorage 덕분이에요. F12 → Application 탭을 열면 theme 서랍에 dark가 들어 있는 것도 확인할 수 있어요. D-5에서 익힌 확인 습관 그대로요.
⚠️ 한 가지 빈틈이 있어요. 복원(
init)은 feed.js가 도는 피드 페이지에서만 일어나요. 다크로 바꾼 뒤index.html이나profile.html로 가보세요 — 여전히 밝아요. 저장은 돼 있는데 읽어주는 코드가 그 페이지엔 없거든요. 이 빈틈은 과제에서 여러분이 직접 메워볼 거예요.
💡 B-8의 CSS 구조 위에 JS는 딱 한 가지만 했어요 —
<html>의data-theme을 갈아끼우고 localStorage에 기억하기. 그 한 가지를 ThemeToggle 클래스가 constructor(받기) · handleClick(화살표 필드) · init(복원+연결) · toggle(전환+저장)로 깔끔하게 나눠 맡았어요. 오늘 배운 클래스·this 해법·localStorage가 전부 모인 완성품이에요.
Step 9: "절대 겹치지 않는 이름표" — Symbol 맛보기
오늘 마지막은 디저트예요. 머리 무겁게 들어가지 말고, "이런 값도 있구나" 하고 눈으로만 구경하면 돼요.
Symbol(심볼) 은 문자열·숫자·불리언 같은 원시 타입의 막내 식구인데, 성격이 독특해요. Symbol()을 부를 때마다 세상에 하나뿐인 값이 새로 만들어져요. 괄호 안에 적는 글자는 그냥 설명(description)일 뿐이라, 설명이 같아도 서로 달라요. 동명이인 같은 거예요 — 이름표에 적힌 글자가 같다고 같은 사람이 아니듯이요.
// instagram-clone-frontend/js/class-demo.js
// Symbol(): 부를 때마다 세상에 하나뿐인 값을 만들어요. 설명이 같아도 서로 달라요.
console.log(Symbol("id") === Symbol("id")); // false
// 객체 키로 쓰면 "숨은 키"가 돼요 — 변수에 담은 심볼을 대괄호로 감싸 키로 써요.
const AD_ID = Symbol("id");
const adPost = {
username: "ad_official",
[AD_ID]: "ad-2026-0001",
};
console.log(adPost[AD_ID]); // ad-2026-0001 — 그 심볼을 가진 코드만 접근할 수 있어요
// 평범한 순회·직렬화에는 안 보여요 (라이브러리가 내부 데이터를 살짝 숨겨 둘 때 쓰는 방식).
for (const key in adPost) {
console.log("for...in 에 보이는 키:", key); // username 만 나와요
}
console.log(JSON.stringify(adPost)); // {"username":"ad_official"} — 심볼 키는 JSON 에도 안 실려요
첫 줄이 전부를 말해줘요. Symbol("id") === Symbol("id")가 false — 똑같이 생긴 호출인데 매번 다른 값이 나와요. 그래서 심볼을 객체의 키로 쓰면, 그 심볼을 변수로 들고 있는 코드만 그 값에 접근할 수 있는 숨은 키가 돼요. 키로 쓸 때는 [AD_ID]처럼 대괄호로 감싸요.
아래 두 출력이 재밌어요. for...in(객체의 키를 하나씩 도는 반복문)을 돌려도 username만 나오고, JSON.stringify로 직렬화해도 심볼 키는 통째로 빠져요. D-5에서 localStorage에 저장하려고 stringify를 썼던 걸 떠올리면 — 심볼에 담은 값은 저장에도 안 실린다는 뜻이죠. 그래서 라이브러리들이 "사용자 데이터와 절대 충돌하지 않을 내부 전용 데이터"를 객체에 끼워둘 때 이 방식을 써요. 누가 obj.id라는 키를 이미 쓰고 있어도, 심볼 키는 절대 안 겹치니까요.
외울 필요 없어요. 직접 Symbol을 만들 일은 한동안 드물 거예요. 다만 나중에 라이브러리 코드나 에러 메시지에서 Symbol(...)을 마주쳤을 때, "아, 절대 안 겹치는 숨은 키구나" 하고 알아볼 수 있으면 오늘 목표는 달성이에요.
💡 Symbol은 부를 때마다 유일한 원시 값이에요. 객체 키로 쓰면 for...in과 JSON.stringify에 안 보이는 숨은 키가 되고, 그래서 라이브러리가 내부 데이터를 충돌 없이 숨겨둘 때 써요. 지금은 "알아보는 눈"만 챙기면 충분해요.
마무리
오늘 우리는 JavaScript 심화의 문을 열었어요. 함수와 객체를 따로 다루던 단계를 지나, 데이터와 동작을 한 틀에 담아 설계하는 법을 배웠죠. 되짚어볼게요.
- class와 new — 객체 리터럴 복붙의 한계(메서드가 객체마다 복사,
===false)를 확인하고, 객체를 찍어내는 틀과 인스턴스를 익혔어요. - constructor와 this — 찍는 순간 한 번 도는 준비 절차로, 인스턴스마다 다른 데이터를 담았어요.
- PostCard 리팩토링 — D-3의
renderPost함수를 constructor / render / buildTemplate로 나뉜 카드 클래스로 옮겼어요. 화면은 한 픽셀도 안 변한 게 성공의 증거였죠. - this 함정과 3가지 해법 — 떼어낸 메서드는 주인을 잃어요. bind(영구 접착) · 클래스 필드 + 화살표 함수(설계 단계부터 안전) · call/apply(한 번만 지정)로 붙들었어요.
- extends와 super —
AdPostCard가PostCard를 물려받아, "부모가 일하고 자식은 다른 부분만" 적는 상속을 경험했어요. - 프로토타입 체인 — 메서드는 prototype에 한 벌만 살고(
post1.like === post2.liketrue), 점(.)으로 찾을 때 체인을 거슬러 올라간다는 진짜 구조를 콘솔로 확인했어요. - ThemeToggle — B-8의 CSS 구조 위에 클래스 하나를 얹어, 클릭으로 전환되고 새로고침에도 살아남는 다크 모드를 완성했어요.
- Symbol — 부를 때마다 유일한 값, 순회와 JSON에 안 보이는 숨은 키를 구경했어요.
그리고 오늘, 묵은 약속을 하나 갚았어요. B-8에서 구조만 깔아두고 "JavaScript를 배우면"이라고 미뤄뒀던 다크 모드 토글이 드디어 움직여요. HTML(버튼) · CSS(의미 토큰 재정의) · JS(ThemeToggle 클래스) — 세 과목이 각자의 역할로 맞물려 기능 하나가 완성되는 걸 처음부터 끝까지 본 거예요.
다음 시간 예고
다음 시간(E-2)의 주제는 에러 핸들링과 디버깅이에요. 그리고 오늘 배운 extends가 바로 다시 등판해요. 지금 우리 api.js는 요청이 실패하면 throw new Error(문자열)로 뭉뚱그려 던지고 있죠. 다음 시간엔 class ApiError extends Error로 나만의 에러 클래스를 만들어서, "뭐가, 왜" 실패했는지(상태 코드, 요청 주소 같은 정보)를 체계적으로 담아 던져요. 받아서 처리하는 try-catch 전략도 정리하고요.
D-5에서 배운 중단점의 고급판도 기다리고 있어요. "조건이 맞을 때만 멈추는" conditional breakpoint 같은 DevTools 디버깅 기술이에요. 에러를 만드는 쪽과 잡는 쪽, 양쪽 모두 한 단계 깊어지는 시간이 될 거예요.
먼 훗날 이야기 하나만 살짝 얹으면 — 오늘 배운 class로, 브라우저 표준만 써서 <post-card> 같은 나만의 HTML 태그를 만드는 시간(H-3)도 와요. 클래스는 앞으로 갈수록 더 자주 만나는 도구예요. 다음 시간에 만나요!
과제
오늘 배운 클래스·상속·this 해법을 직접 굴려볼 차례예요. json-server를 켠 채(npx json-server mock/db.json --port 3001) Live Server로 진행하세요.
[구현] 다른 페이지에도 다크 모드 기억시키기
Step 8의 ⚠️에서 봤듯이, 다크 모드 복원은 지금 피드 페이지에서만 동작해요. index.html과 profile.html에서도 저장된 테마가 복원되게 만들어보세요.
- 두 페이지에는 토글 버튼이 없어요. 버튼 없이 "복원만" 하려면
ThemeToggle의 어떤 부분이 필요한지 먼저 생각해보세요.init()안의 두 가지 일(복원 / 리스너 연결) 중 무엇이 필요한가요? - 방법은 여러 가지예요. 작은 스크립트에서
localStorage.getItem("theme")을 직접 읽어 복원만 해도 되고,ThemeToggle을 고쳐 버튼 없이도 쓸 수 있게 만들어도 돼요. 어느 쪽이든 동작하면 성공이에요. - 완성했다면 피드에서 다크로 바꾼 뒤 로그인 페이지·프로필 페이지를 오가며, 모든 페이지가 어둡게 유지되는지 확인하세요.
[구현] 댓글을 잠근 공지 카드 만들기
AdPostCard처럼 PostCard를 상속받아, 공지용 카드 NoticePostCard를 만들어보세요. 공지에는 댓글을 달 수 없게 하는 게 목표예요.
render()를 오버라이드해서,super.render()로 본체를 받은 뒤.comment-form을 찾아 제거(remove())하세요. D-1에서 배운 요소 삭제 그대로예요.- 공지임을 알리는 표시(예: "공지" 라벨이나 클래스)는 자유롭게 더해보세요.
AdPostCard의 코드가 좋은 참고서예요. feed.js의 분기에서 특정 게시물 하나를NoticePostCard로 찍어 화면에서 확인해보세요. 그 카드만 댓글 입력칸이 사라졌나요?
[탐구] bind 한 함수를 또 bind 하면?
Step 5의 질문 박스에서 "접착제가 한 번 굳으면 끝"이라고 했죠. 정말 그런지 콘솔에서 직접 실험해보세요.
- Step 5의
introduce함수를 재활용해서,introduce.bind(post1)로 한 번 묶은 함수를 다시.bind(post2)로 묶어보세요. 호출하면 this는 post1일까요, post2일까요? - 예상을 먼저 적어두고 실행해서 비교해보세요. 결과가 예상과 달랐다면, 왜 JavaScript가 그렇게 설계됐을지도 한 문장으로 추측해보세요.
생각해볼 주제
정답을 적는 문제가 아니에요. 오늘 배운 것의 "왜"를 곱씹어보는 질문들이에요. 스스로 답을 만들어본 뒤, 예시답안과 비교해보세요.
1. 화살표 필드도 결국 "인스턴스마다 함수"인데, 왜 써도 될까?
Step 1에서 우리는 객체마다 복사되는 함수 100개를 문제 삼았어요. 그런데 클래스 필드 + 화살표 함수도 인스턴스가 만들어질 때마다 함수가 새로 생겨요 — prototype에 한 벌만 두는 일반 메서드와 달리요.
그렇다면 모순 아닐까요? 그런데도 ThemeToggle.handleClick을 화살표 필드로 적은 이유는 뭘까요? "메모리 효율"과 "this 안전" 사이에서 무엇을 얻고 무엇을 내준 건지, 그리고 ThemeToggle처럼 인스턴스가 한두 개뿐인 경우와 PostCard처럼 수백 개 생기는 경우에 그 셈법이 어떻게 달라질지 생각해보세요.
2. 상속 말고 "조합"이라는 길
AdPostCard는 PostCard를 상속받았지만, 다른 설계도 가능해요. "광고 라벨 붙이기"를 별도 함수로 빼서, 일반 카드를 만든 뒤 그 함수로 라벨만 얹어 조립하는 방식이죠.
상속은 깔끔하지만, 부모-자식-손자로 단계가 깊어지면 어떤 어려움이 생길까요? 부모의 작은 수정이 모든 자손에게 미치는 영향, "광고이면서 동시에 공지인 카드"처럼 두 변형을 한꺼번에 원할 때의 난감함을 떠올리며, 상속이 어울리는 경우와 조합이 어울리는 경우를 나눠보세요.
3. 다크 모드 설정, 어디에 저장해야 할까?
우리는 테마 선택을 localStorage에 저장했어요. 그런데 localStorage는 그 브라우저, 그 기기에만 남아요. 노트북에서 다크로 바꿔도 폰으로 접속하면 다시 라이트죠.
로그인한 사용자가 어느 기기에서든 같은 테마를 보게 하려면, 이 설정은 어디에 저장돼야 할까요? 그렇게 옮겼을 때 잃는 것(로그인 전엔 테마를 모름, 서버 왕복 시간)과 얻는 것은 무엇인지, 혹은 둘을 함께 쓰는 절충안은 없을지 생각해보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니에요. 클래스 이름이나 라벨 문구는 자유롭게 바꿔도 좋아요. 중요한 건 부모의 일을 다시 복붙하지 않았는가, this 가 어디를 가리키는지 설명할 수 있는가, 그리고 브라우저에서 직접 확인했는가 예요.
과제 예시답안
🎯 [과제 1 예시답안] 다른 페이지에도 다크 모드 기억시키기
핵심 접근
ThemeToggle.init() 은 두 가지 일을 해요. ① 저장된 테마 복원, ② 버튼에 클릭 리스너 연결. index.html 과 profile.html 에는 토글 버튼이 없으니 ②는 할 수도 없고 할 필요도 없어요. 필요한 건 ①뿐이죠. 그래서 가장 단순한 모범은 각 페이지에 복원 코드 서너 줄만 넣는 거예요.
예시 구현
<!-- instagram-clone-frontend/index.html — profile.html 에도 동일하게 (과제용 예시) -->
<script>
const saved = localStorage.getItem("theme"); // "dark" | "light" | null
if (saved) {
document.documentElement.dataset.theme = saved;
}
</script>
ThemeToggle.init() 의 복원 부분과 똑같은 코드예요. if (saved) 가드가 핵심이에요 — 저장된 테마가 없으면(null) 아무것도 안 하고, 운영체제 설정을 그대로 따라가게 둬요. 이 스크립트를 <head> 안에 두면 화면이 그려지기 전에 테마가 정해져서, 라이트로 떴다가 다크로 바뀌는 깜빡임도 없어요.
ThemeToggle 클래스를 고치는 길도 있어요. "버튼이 없으면 복원만" 하도록 init() 에 가드를 하나 더하는 거죠.
// instagram-clone-frontend/js/theme-toggle.js — init() 변형 (과제용 예시, 이런 모양이면 충분해요)
init() {
const saved = localStorage.getItem(this.storageKey);
if (saved) {
document.documentElement.dataset.theme = saved;
}
if (this.button) { // 버튼이 없는 페이지에서는 복원만 하고 끝나요
this.button.addEventListener("click", this.handleClick);
}
}
이러면 각 페이지에서 new ThemeToggle(null, "theme").init(); 한 줄로 끝나요. 테마 관련 코드가 클래스 한 곳에 모이는 장점이 있고, 대신 버튼 없는 페이지에서도 모듈 스크립트를 불러와야 한다는 비용이 있어요. 둘 다 모범 사례 중 하나예요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 복원/리스너 분리 이해 | init() 의 두 가지 일 중 "복원"만 필요하다는 걸 짚었는가 | 상 |
| null 가드 | getItem 이 null 을 줄 수 있음을 알고 if (saved) 로 막았는가 |
상 |
| 세 페이지 확인 | 피드에서 다크로 바꾼 뒤 index/profile 로 이동해도 유지되는 걸 봤는가 | 중 |
| 깜빡임 고려 | 복원 스크립트를 <head> 쪽에 둬서 첫 화면 번쩍임을 막았는가 |
하 |
흔한 실수
dataset.theme = localStorage.getItem("theme")을 가드 없이 대입 — 저장값이 없으면null이 문자열"null"로 바뀌어<html data-theme="null">이 돼요. CSS 는"dark"만 찾으니 화면은 멀쩡해 보여도, 운영체제 자동 다크 규칙과 엮이면 디버깅이 어려워져요.if (saved)로 막아요.- 버튼 없는 페이지에서 기존 init() 을 그대로 호출 —
this.button이null이라addEventListener줄에서 에러가 나요. 콘솔의 "Cannot read properties of null" 이 바로 이 경우예요.
실무 개선 포인트 (심화)
- 복원 코드가 페이지마다 복붙되는 게 마음에 걸린다면,
theme-init.js같은 작은 공통 모듈로 빼서 모든 페이지가 import 하게 할 수 있어요. "같은 코드가 두 곳 이상" 이 보이면 모으는 습관이에요. - 실제 서비스는 이 복원 스크립트를 HTML 에 인라인으로 직접 심는 경우가 많아요. 외부 파일을 받아오는 시간조차 아껴서, 첫 페인트 전에 테마를 확정하려는 거예요.
🎯 [과제 2 예시답안] 댓글을 잠근 공지 카드 만들기
핵심 접근
AdPostCard 와 똑같은 패턴이에요. 카드 본체는 super.render() 가 만들고, 우리는 그 위에 "공지 라벨 달기 + 댓글 폼 떼기" 두 가지만 더해요. constructor 가 부모와 똑같다면 아예 생략해도 돼요 — 자식 클래스에 constructor 가 없으면 부모 것이 자동으로 쓰이거든요(AdPostCard 는 설명을 위해 적어둔 거예요).
예시 구현
// instagram-clone-frontend/js/post-card.js — 맨 아래에 추가 (과제용 예시, 이런 모양이면 충분해요)
export class NoticePostCard extends PostCard {
render() {
const article = super.render(); // 1) 카드 본체는 부모가 만들고
article.classList.add("post-notice"); // 2) 공지 카드 표시 클래스
const badge = document.createElement("span");
badge.className = "notice-badge";
badge.textContent = "공지";
article.querySelector(".post-time").replaceWith(badge); // 3) 시간 위치에 "공지" 라벨
article.querySelector(".comment-form").remove(); // 4) 댓글 입력칸을 통째로 제거
return article;
}
}
// instagram-clone-frontend/js/feed.js — loadPage 분기 (과제용 예시)
import { PostCard, AdPostCard, NoticePostCard } from "./post-card.js";
result.data.forEach((post, index) => {
const card =
post.id === 1 ? new NoticePostCard(post) // 첫 게시물을 공지로
: index === 2 ? new AdPostCard(post)
: new PostCard(post);
feedMain.insertBefore(card.render(), sentinel);
});
저장하고 새로고침하면 1번 게시물에 "공지" 라벨이 붙고 댓글 입력칸이 사라져요. 어떤 게시물을 공지로 삼을지(post.id === 1 대신 다른 조건)는 자유예요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| super.render() 재사용 | 카드 본체를 복붙하지 않고 부모에게 맡겼는가 | 상 |
| 댓글 차단 동작 | .comment-form 이 실제로 사라지고 댓글을 못 다는지 확인했는가 |
상 |
| 공지 표시 | 라벨이든 클래스든 공지임을 눈으로 구분할 수 있는가 | 중 |
| feed.js 분기 | import 추가 + 특정 게시물 하나만 NoticePostCard 로 갈아탔는가 | 중 |
흔한 실수
- buildTemplate() 전체를 복사해서 댓글 폼 줄만 지움 — 동작은 해요. 하지만 나중에 부모 템플릿을 고치면 공지 카드만 옛날 모습으로 남아요. 상속의 핵심은 "공통은 부모 한 곳에" 예요.
- remove() 대신 CSS 로 숨김(display: none) — 폼이 화면에서만 안 보일 뿐 DOM 에는 남아 있어요. 이벤트 위임으로 걸어둔 submit 처리가 여전히 살아 있어서, 키보드 조작 등으로 댓글이 제출될 수 있어요. 못 달게 하려면 제거가 확실해요.
- render() 안에서 super.render() 를 빼먹음 —
article이 없으니 그 아래querySelector가 전부 에러예요. 오버라이드할 때 "부모 것 먼저, 변형은 그 위에" 순서를 기억해요.
실무 개선 포인트 (심화)
- 화면에서 폼을 지우는 건 "안내"일 뿐 "차단"이 아니에요. 개발자 도구로 폼을 되살리거나 요청을 직접 보내면 우회돼요. 진짜 서비스라면 서버가 공지 게시물의 댓글 POST 를 거부해야 해요 — 프론트는 보여주기, 최종 결정은 서버.
- "댓글 N개 모두 보기" 링크까지 함께 지우면 공지 카드가 더 자연스러워요. 변형 항목이 늘어나기 시작하면 상속만으로 버틸지 고민이 필요한데, 이건 생각해볼 주제 2에서 이어가요.
🎯 [과제 3 예시답안] bind 한 함수를 또 bind 하면?
핵심 접근
예상을 먼저 적고 실험으로 확인하는 탐구예요. 직감으로는 "나중에 bind 한 게 덮어쓰겠지" 싶지만, 실제로는 첫 bind 가 이겨요. bind 는 원본을 바꾸는 게 아니라 this 를 봉인한 새 함수를 만들어 돌려주는데, 두 번째 bind 는 이미 봉인된 함수를 한 겹 더 감쌀 뿐 안쪽 봉인을 풀지 못하거든요.
예시 구현
// 과제용 실험 — js/class-demo.js 맨 아래나 콘솔에 붙여 넣어 확인해 보세요
function introduce(role, mark) {
console.log(`${mark} ${this.username} — ${role}`);
}
const post1 = { username: "soohyun_k" };
const post2 = { username: "ad_official" };
const boundOnce = introduce.bind(post1); // 첫 번째 봉인: this = post1
const boundTwice = boundOnce.bind(post2); // 두 번째 bind — 풀릴까?
boundOnce("오늘의 인기 게시물", "[인기]"); // [인기] soohyun_k — 오늘의 인기 게시물
boundTwice("오늘의 새 게시물", "[NEW]"); // [NEW] soohyun_k — 오늘의 새 게시물
두 번째 출력의 주인도 post2 가 아니라 soohyun_k(post1)예요. boundOnce.call(post2, ...) 처럼 call/apply 로 찔러봐도 결과는 같아요 — 한번 봉인된 this 는 어떤 방법으로도 다시 못 바꿔요.
왜 이렇게 설계됐을까요? bind 의 약속은 "이 함수의 this 는 영원히 이것" 인데, 누군가 다시 bind 해서 그 약속을 깰 수 있다면 함수를 안심하고 떼어 넘길 수 없기 때문 — 정도로 추측하면 충분해요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 실험으로 확인 | 코드를 실제로 돌려 post1 이 이기는 출력을 봤는가 | 상 |
| 원리 설명 | "bind 는 새 함수를 만들고, 봉인은 다시 못 푼다" 를 자기 말로 적었는가 | 상 |
| 예상 먼저 | 실험 전에 예상을 적었는가 (틀려도 좋아요 — 그게 탐구) | 중 |
| 설계 의도 추측 | 왜 그렇게 만들었을지 한 문장 추측이 있는가 | 하 |
흔한 실수
- "마지막 bind 가 덮어쓴다" 고 단정 — bind 를 변수 재할당처럼 생각한 거예요. bind 는 원본을 건드리지 않고 매번 새 함수를 돌려줘요. 두 번째 bind 의 재료는 원본이 아니라 "이미 봉인된 함수" 라서 늦은 거죠.
- bind 와 call 을 혼동 — call/apply 는 즉시 호출, bind 는 호출하지 않고 새 함수만 돌려줘요.
introduce.bind(post1)뒤에 출력이 안 보인다고 당황하지 않기.
실무 개선 포인트 (심화)
- 이 성질 덕분에 ThemeToggle 의 화살표 필드 핸들러를 안심하고
addEventListener에 넘길 수 있어요. 브라우저가 호출하는 쪽에서 this 를 뭘로 부르든, 우리 인스턴스로 고정된 채 그대로예요. - bind 는 this 만 봉인하는 게 아니라 앞쪽 인자도 미리 채울 수 있어요.
introduce.bind(post1, "공지 담당")처럼요. "인자를 절반만 채운 함수 만들기" 라는 기법인데, 나중에 함수형 코드를 읽을 때 자주 만나요.
생각해볼 주제 예시답안
1. 화살표 필드도 결국 "인스턴스마다 함수"인데, 왜 써도 될까?
[문제 상황 요약]
Step 1 에서 "객체마다 함수 복붙 100개" 를 문제 삼아 클래스로 넘어왔어요. 클래스 메서드는 프로토타입에 한 번만 있어서 모든 인스턴스가 공유하죠. 그런데 handleClick = () => {...} 같은 화살표 필드는 인스턴스를 만들 때마다 새 함수가 생겨요. Step 1 의 문제로 되돌아간 것 아닐까요?
[튜터의 가이드 및 해설]
두 방식이 각자 잘하는 게 달라요.
- Option A — 일반 메서드(프로토타입): 함수가 프로토타입에 1개뿐이라 인스턴스가 수백 개여도 메모리 비용이 늘지 않아요. 대신 떼어서 넘기면 this 를 잃어요.
- Option B — 화살표 필드: 만들어질 때의 this 를 기억해서,
addEventListener에 떼어 넘겨도 안전해요. 대신 인스턴스마다 함수가 하나씩 생겨요.
그래서 기준은 "이 함수를 떼어서 넘길 일이 있는가" 예요. ThemeToggle 의 handleClick 은 정확히 그런 함수죠 — addEventListener 에 떼어 넘기는 게 존재 이유예요. 그리고 ThemeToggle 인스턴스는 페이지에 한두 개뿐이라, 함수 한두 개의 비용은 사실상 0이에요. this 안전이 압도적으로 남는 장사예요.
PostCard 는 셈법이 달라요. 무한 스크롤로 게시물이 수백 개 쌓이면 인스턴스도 수백 개예요. render 와 buildTemplate 까지 화살표 필드였다면 같은 함수가 수백 벌 생겼겠죠. 두 메서드는 어디에 떼어 넘기지 않고 우리가 직접 card.render() 로 부르니, 일반 메서드로 공유하는 게 맞아요.
현업에서는 보통 둘을 섞어요. 이벤트 핸들러처럼 떼어 넘길 함수만 화살표 필드로, 나머지는 전부 일반 메서드로. 우리 코드의 ThemeToggle(핸들러만 화살표) 과 PostCard(전부 일반 메서드) 가 그 구분을 그대로 보여줘요.
🎯 면접관을 홀리는 핵심 멘트
"프로토타입 메서드는 공유로 메모리를 아끼고, 화살표 필드는 this 를 고정해요. 기준은 '이 함수를 떼어서 넘길 일이 있는가' 입니다. 이벤트 핸들러만 화살표 필드로 만들고 나머지는 일반 메서드로 두는데, 인스턴스가 수백 개인 클래스에서는 이 구분이 실제 메모리 차이로 나타나요."
2. 상속 말고 "조합"이라는 길
[문제 상황 요약]
AdPostCard 는 상속으로 깔끔하게 풀렸어요. 그런데 클래스를 새로 만드는 대신, "광고 라벨 붙이기" 를 별도 함수로 만들어 일반 카드에 발라주는 방법도 있었어요. 변형이 광고·공지·이벤트… 계속 늘어나면 어느 쪽이 버틸까요?
[튜터의 가이드 및 해설]
- Option A — 상속(extends): "광고 카드는 게시물 카드다(is-a)" 라는 관계가 명확하고 변형이 한 단계일 때 가장 읽기 좋아요.
super.render()한 줄로 부모의 일을 전부 물려받죠. 약점은 부모에 묶인다는 것 — 부모의 템플릿 구조가 바뀌면 모든 자식의querySelector가 영향을 받아요. 자식이 많아질수록 부모를 고치기가 무서워져요. - Option B — 조합(composition):
addAdBadge(article),lockComments(article)처럼 작은 변형을 함수로 만들어 두고, 필요한 것만 골라 적용해요. "광고이면서 댓글도 잠근 카드" 가 필요하면 두 함수를 차례로 부르면 끝이에요.
상속의 진짜 한계는 변형의 축이 둘 이상이 될 때 드러나요. 광고/일반 × 댓글잠금/허용이면 조합이 4가지인데, 상속으로 다 만들면 AdPostCard, NoticePostCard, AdNoticePostCard… 클래스 수가 곱으로 늘어요. 게다가 JavaScript 는 한 번에 하나만 extends 할 수 있어서, "광고 카드와 공지 카드를 동시에 상속" 같은 건 아예 불가능해요.
현업에서는 보통 이렇게 정리해요. 상속은 한 단계까지, is-a 가 자연스러울 때만. 변형의 종류가 늘어나거나 서로 섞이기 시작하면 작은 함수들의 조합으로 갈아타요. 오늘 우리의 AdPostCard 는 변형 하나짜리 1단 상속이라 딱 적합했던 거고, 그 경계를 넘으면 도구를 바꾸는 거예요.
🎯 면접관을 홀리는 핵심 멘트
"상속은 is-a 가 명확하고 변형이 한 축일 때까지만 써요. 광고이면서 공지인 카드처럼 변형이 섞이는 순간 클래스 수가 곱으로 늘고, JavaScript 는 다중 상속도 안 됩니다. 그때부터는 라벨 붙이기, 댓글 잠그기 같은 작은 기능을 함수로 만들어 조립하는 조합이 답이에요."
3. 다크 모드 설정, 어디에 저장해야 할까?
[문제 상황 요약]
지금 테마는 localStorage 에 저장돼요. 그 브라우저, 그 기기에서만 기억되죠. 폰에서 다크로 바꿔도 노트북은 모르고요. 여러 기기에서 같은 테마를 보려면 서버(사용자 프로필 DB)에 저장해야 하는데, 그러면 무엇을 잃고 무엇을 얻을까요?
[튜터의 가이드 및 해설]
- Option A — localStorage: 저장도 복원도 즉시고, 로그인 전에도 동작하고, 서버 왕복이 없어요. 단점은 기기·브라우저 한 대에 갇힌다는 것.
- Option B — 서버 DB: 어느 기기에서 로그인하든 같은 테마를 받아요. 대신 로그인 전에는 취향을 알 수 없고, 페이지를 열 때마다 서버 응답을 기다려야 해요. 응답이 오기 전까지 기본 테마로 그렸다가 갈아끼우면 화면이 번쩍하는 깜빡임(FOUC)이 생기죠.
현업의 절충은 "둘 다" 예요. 서버를 진실의 원천으로 두고, localStorage 는 캐시로 써요. 첫 화면은 localStorage 값으로 깜빡임 없이 즉시 그리고, 로그인 후 서버에서 받은 값이 다르면 그때 맞추면서 캐시도 갱신해요. 빠른 첫 화면(로컬)과 기기 동기화(서버)를 동시에 챙기는 구조예요.
여기서 떠올릴 만한 비교가 하나 있어요. D-5 에서 "비밀번호는 localStorage 에 절대 넣지 마라" 고 했죠. 테마는 정반대예요 — 유출돼도 잃을 게 없는 비민감 정보고, 즉시성이 더 중요해요. 같은 저장소라도 "무엇을 담느냐" 에 따라 답이 달라지는, localStorage 가 적합한 대표 사례예요.
🎯 면접관을 홀리는 핵심 멘트
"테마처럼 민감하지 않고 즉시성이 중요한 설정은 localStorage 가 1차 후보예요. 기기 간 동기화가 필요해지면 서버를 진실의 원천으로 올리고 localStorage 는 캐시로 내립니다. 첫 화면은 캐시로 깜빡임 없이 그리고, 서버 값이 도착하면 맞추는 구조예요."