본문 바로가기
JavaScript Deep Dive 스터디

[모던 자바스크립트 Deep Dive] - 19장 맛뵈기?

by 림졍 2026. 4. 29.
728x90
반응형

JavaScript Deep Dive 스터디 노트 (19장 맛뵈기?)

범위가 너무 많아요;;;;;

 

이번주 체감 거의 이정도급;;

 

솔직히 19장 범위가 너무 많아가지고... 읽으면서 넘기기엔 찜찜한 게 너무 많았다.
왜냐? 진짜 모르는 내용이 거의 절반이라... (삽질의 미X 연속;;;)
그러고 힘을 다 써버렸더니 20장이랑 21장은... 거의 내다버린 수준이 되어버렸다고;

(그랬더니 소올직히 제출을 늦게해버렸다.. 내가 늦다니...털썩...)

추가로 저번 스터디때 받은 피드백을 기반으로(?)
이번엔 그냥 한놈만 팬다 느낌으로 딥다이브를 해보고자한다.

아예 갈거면 제대로 패는 형식.... 아예 기초적인 것부터 쭈우우욱 패버릴 예정-!

 

난 한놈만 팬다.

 

그리고 여기서도 기억해야 할 부분은 사양(ECMAScript)과 엔진(V8)을 섞어 쓰지 않아야 하는거..

19장 맛뵈기....

멀티 패러다임 언어라는 게 뭐...뭐로;;;ㅇㅅaㅇ;

책에서 제일 처음부터 언급하는 자바스크립트에 대한 설명은 아래와 같다.

"자바스크립트는 명령형, 함수형, 프로토타입 기반 객체지향 프로그래밍을 지원하는 멀티 패러다임 프로그래밍 언어다."

 

음.. 생각보다 고-능한 멀티태스킹이 잘되는 언어인가라기엔

각각이 실제로 뭘 의미하는지 제대로 본 적이 없기 때문에..?

하나하나 깊게 파주기로 했다.

 

명령형 (Imperative)

명령형은 "어떻게(How) 할 것인지"를 단계별로 명령하는 스타일이다.
(CS50에서 C로 짰던 코드의 대부분이 명령형이었다고...)

// 1부터 5까지 합 구하기 — 어떻게 더할지를 직접 명령
let sum = 0;
for (let i = 1; i <= 5; i++) {
  sum += i; // "sum에 i를 더해라" → 상태를 직접 변경
}

 

변수에 값을 할당하고, 루프로 돌면서 상태를 바꾸는 것이 명령형의 특징이다.
컴퓨터한테 "이렇게 해, 저렇게 해" 하고 지시하는 것이기 때문에 직관적이다.

 

함수형 (Functional) — 함수를 값처럼 다루는 스타일

함수형은 "무엇을(What) 할 것인지"를 선언하는 스타일이다.

명령형이랑 같은 결과인데 쓰는 방식이 다르다.

// 명령형 — 어떻게 더하는지를 직접 명령
let sum = 0;
for (let i = 1; i <= 5; i++) { sum += i; }

// 함수형 — 뭘 할지만 선언, 어떻게는 reduce가 알아서
const sum = [1, 2, 3, 4, 5].reduce((acc, cur) => acc + cur, 0);

 

상태를 직접 바꾸지 않고, 함수를 값처럼 다루고, 부수 효과를 최소화하는 게 핵심이다.

reduce에 함수를 넘길 수 있는 이유가 JS에서 함수가 일급 객체이기 때문인데 — 이게 뭔 말인지 바로 파봤다.

 

+) 근데...일급 객체(First-class citizen)가 뭐에요? ㅇㅅㅇ;

 

"함수가 일급 객체"라는 게 나오는데, 일급 객체... 일급객체는 뭘까...?

"일급 객체" 라는 말은 사실 수학자 크리스토퍼 스트레이치(Christopher Strachey)가 1960년대에 만든 개념으로
"언어 안에서 어떤 것이 아무 제약 없이 다룰 수 있는 것",

쉽게 말해 "그 언어에서 제일 편하게 다룰 수 있는 것" 이라고 보면 된다.

 

예시로 숫자를 생각해보자. JS에서 숫자는:

  • 변수에 담을 수 있고 (const x = 5)
  • 함수 인수로 넘길 수 있고 (add(5, 3))
  • 함수 반환값이 될 수 있고 (return 5)
  • 배열에 넣을 수 있고 ([1, 2, 3])
  • 어디든 넣고 빼고 자유롭게 다룰 수 있다
    이렇게 아무 제약 없이 다룰 수 있는 것 = 일급 객체(일급 시민) 이다.

반대로 "이급 객체"특정 장소에서만 쓸 수 있거나, 일부 동작이 안 되는 것 이다.
C에서 함수는 변수에 담거나 인수로 넘기려면 복잡한 포인터 문법이 필요했다.
JS에서 함수처럼 자유롭게 못 다룬다는 뜻에서 "이급"에 가깝다고 볼 수 있다.

 

정리하면 아래와 같다.

일급 객체 = 그 언어 안에서 숫자처럼 아무 제약 없이 다룰 수 있는 것
           (변수에 담고, 인수로 넘기고, 반환값이 되고, 뭐든 다 됨)

 

∴ "함수가 일급 객체다" = "JS에서 함수를 숫자랑 똑같이 자유롭게 다룰 수 있다"와 동일한 뜻으로 보면 된다.

 

함수가 왜 일급 객체인데? 싶어서 이것도 뒤적거려봤는데

MDN에서는 이렇게 정의한다:

"A programming language is said to have First-class functions when functions in that language are treated like any other variable."
(프로그래밍 언어는 해당 언어의 함수들이 다른 변수처럼 다루어질 때 일급 함수를 가진다고 합니다.)
— MDN Glossary, First-class Function

 

간단히 말해... 함수를 숫자나 문자열처럼 "값"으로 다룰 수 있다는 것 이다.

C에서는 함수를 인수로 넘기려면 함수 포인터라는 복잡한 문법이 필요했다.

const fn = function() { return '안녕'; }; // ① 변수에 담을 수 있다
runTwice(fn);                              // ② 다른 함수의 인수로 넘길 수 있다
return fn;                                 // ③ 반환값이 될 수 있다

 

JS는 그냥 넘기면 된다. 이 자연스러움 자체가 일급 객체의 의미다.

void apply(int (*fn)(int, int), int x, int y) { // 그냥 fn이라고 못 씀
  printf("%d\n", fn(x, y));
}

 

 

 

고차 함수(Higher-Order Function)

 

MDN에서는 고차함수인 HOF를 이렇게 정의한다.

 Note: "A function that returns a function or takes other functions as arguments is called a higher-order function."
(함수를 반환하거나 다른 함수들을 전달인자로서 사용하는 함수를 고차 함수라고 합니다.) — MDN Glossary, First-class Function

// 함수를 인수로 받는 HOF
const doubled = [1, 2, 3].map(x => x * 2);
const evens   = [1, 2, 3, 4].filter(n => n % 2 === 0);

// 함수를 반환하는 HOF
function makeAdder(x) {
  return function(y) { return x + y; };
}
const add5 = makeAdder(5);
add5(3); // 8

 

즉 함수를 인수로 받거나, 함수를 반환하는 함수.

add5x = 5를 어떻게 기억하는지는 24장 클로저에서 파볼 예정이다.
지금은 "함수가 반환된 이후에도 바깥 변수를 기억할 수 있다" 정도만..... (미룬이 레스고)

+) 추가로 map, filter, reduce의 내부 메커니즘 또한 나중에... 27장에서 제대로 다룰 예정.

 

 

React는 왜 함수형 스타일로 전환되었죠??

 

React 초기(2013~)에는 클래스 컴포넌트가 기본이었는데,

2019년 Hooks가 나오면서 함수 컴포넌트로 완전히 전환됐다. 왜 굳이 바꿨을까?

 

 

클래스 컴포넌트의 문제 — 너무 헷갈리는 this...

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this); // ← 이게 왜 필요해?
  }

  handleClick() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return <button onClick={this.handleClick}>{this.state.count}</button>;
  }
}

 

bind(this)를 안 하면 onClick={this.handleClick}으로 넘길 때 this가 undefined가 된다.
함수를 값처럼 넘기는 순간 원래 객체의 this가 끊기기 때문이다.

 

게다가 로직을 재사용하기 어려웠다.

// "창 크기를 감지하는 로직"을 두 컴포넌트에서 쓰려면?
class ComponentA extends React.Component {
  componentDidMount()    { window.addEventListener('resize', this.handleResize); }
  componentWillUnmount() { window.removeEventListener('resize', this.handleResize); }
}
class ComponentB extends React.Component {
  // 똑같은 코드를 또 써야 함 — 복붙밖에 방법이 없음
  componentDidMount()    { window.addEventListener('resize', this.handleResize); }
  componentWillUnmount() { window.removeEventListener('resize', this.handleResize); }
}

 

위의 예시처럼 두 컴포넌트에서 사용하려면 같은 로직인데도 불구하고 클래스마다 복붙해야 한다.
상속으로 해결하려 하면 더 복잡해진다.

 

함수 컴포넌트 + Hook — 로직을 함수로 뽑아 어디서든 재사용하도록 만들긔

// 창 크기 감지 로직을 훅으로 분리 — 한 번만 작성
function useWindowSize() {
  const [size, setSize] = useState({ width: window.innerWidth });
  useEffect(() => {
    const handler = () => setSize({ width: window.innerWidth });
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);
  return size;
}

// 어디서든 한 줄로 재사용
function ComponentA() {
  const { width } = useWindowSize();
  return <div>너비: {width}</div>;
}
function ComponentB() {
  const { width } = useWindowSize();
  return <div>모바일: {width < 768 ? 'yes' : 'no'}</div>;
}

 

useWindowSize는 상태와 부수효과(이벤트 리스너)를 캡슐화한 커스텀 훅이다.
클래스 상속 없이 로직만 뽑아서 어디서든 쓸 수 있다.
이게 함수형이 클래스보다 로직 재사용에 유리한 이유다.

 

 

useState — 직접 변경하면 안 되는 이유

const [user, setUser] = useState({ name: '림졍', age: 20 });
user.name = '홍길동'; // ❌ 직접 변경 — React가 변경을 감지 못함
setUser({ ...user, name: '홍길동' }); // ✅ 새 객체를 만들어서 넘김

 

React는 이전 상태와 새 상태의 참조를 비교해서 리렌더링 여부를 결정한다.

user.name = '홍길동'은 같은 객체를 수정하는 거라 참조가 안 바뀜 → 리렌더링 안 됨.

setUser({ ...user })는 새 객체라 참조가 다름 → 리렌더링 됨.

 

 

setState에 함수를 넘기는 이유

// 값을 넘기면 — 배치 처리 시 문제
setCount(count + 1); // count가 0이면
setCount(count + 1); // 여기도 count가 0 → 결국 1만 증가
setCount(count + 1);

// 함수를 넘기면 — 이전 상태를 받아서 처리
setCount(prev => prev + 1); // prev=0 → 1
setCount(prev => prev + 1); // prev=1 → 2
setCount(prev => prev + 1); // prev=2 → 3

 

React가 상태 업데이트를 배치로 처리할 때, 값을 넘기면 오래된 count를 계속 참조하지만 함수를 넘기면

React가 최신 상태를 prev로 넘겨줘서 정확하다. HOF 패턴 덕분에 이게 가능한 것이다.

 

 

 

객체지향 (OOP) — 데이터와 동작을 하나로 묶는 것

 

C에서 관련된 데이터를 묶을 때는 struct를 썼는데,

C에서는 데이터(struct)와 동작(함수)이 언어 수준에서 연결되어 있지 않다.

// C — 데이터랑 함수가 완전히 분리
struct Recipe { char name[50]; };
void cook(struct Recipe r) { printf("%s 굽는 중\n", r.name); }

struct Recipe focaccia = {"포카치아"};
cook(focaccia); // 데이터랑 함수를 따로 넘겨야 함

 

OOP는 이 둘을 객체라는 하나의 단위로 묶는다.

// JS — 데이터(프로퍼티)와 동작(메서드)이 하나의 객체 안에
const focaccia = {
  name: '포카치아',
  cook() { console.log(`${this.name} 굽는 중`); }
};
focaccia.cook(); // 객체에게 직접 "요리해"라고 시킴

 

 

 

클래스 기반 OOP vs 프로토타입 기반 OOP — 근본적으로 다른 접근

Java랑 C++을 안 써봐서 솔직히 이 차이가 처음엔 잘 안 와닿았다.
그래서 MDN을 뒤적여봤는데... "Details of the Object Model" 문서에서 이렇게 정리해놨다

"JavaScript is a bit confusing for developers experienced in class-based languages (like Java or C++), as it is dynamic and does not have static types. While this confusion is often considered to be one of JavaScript's weaknesses, the prototypal inheritance model itself is, in fact, more powerful than the classic model."
(JS는 Java, C++ 같은 클래스 기반 언어에 익숙한 개발자에게 헷갈릴 수 있다. 동적이고 정적 타입이 없기 때문이다. 이 혼란이 JS의 약점으로 여겨지기도 하지만, 프로토타입 기반 상속 모델 자체는 사실 클래식 모델보다 더 강력하다.)
— MDN, Inheritance and the prototype chain

 

"더 강력하다"는 MDN이 직접 한 말이다.
그럼 클래스 기반이랑 프로토타입 기반이 구체적으로 뭐가 다른 건지 살펴보자.

 

 

클래스 기반 — "설계도와 제품이 완전히 다른 존재"

 

클래스 기반 언어(Java, C++)에서는 클래스인스턴스가 명확히 구분되는 별개의 개념이다.

  • 클래스 = 추상적인 설계도. 그 자체로는 아무 동작도 안 한다.
  • 인스턴스 = 설계도를 보고 찍어낸 제품. 실제로 동작하는 것.
붕어빵 틀 (클래스) — 추상적인 설계도
  ↓ 찍어냄
붕어빵 1호 ← 틀의 프로퍼티를 그대로 복사한 인스턴스
붕어빵 2호 ← 틀의 프로퍼티를 그대로 복사한 인스턴스
붕어빵 3호 ← 틀의 프로퍼티를 그대로 복사한 인스턴스
 
 

이미 구워진 붕어빵들을 다 슈크림으로 바꾸는 건 안 된다.
클래스 기반 언어는 클래스 구조가 컴파일 타임에 고정되기 때문에, 런타임에 인스턴스들의 구조를 동적으로 바꿀 수 없다.

또한 Java, C++ 같은 클래스 기반 언어는 접근 제한과 상속을 위한 키워드를 언어 수준에서 제공한다:

접근 제한 키워드:
public    → 진열대 붕어빵 (아무나 집어갈 수 있음)
private   → 내 서랍 속 붕어빵 (나만 꺼낼 수 있음)
protected → 가족(하위 클래스)끼리만 나눠 먹는 붕어빵

상속/구현 키워드:
extends    → "나는 붕어빵인데, 슈크림 버전이야" (클래스 상속)
implements → "나는 반드시 이 기능을 갖춰야 해" (인터페이스 구현 약속)
abstract   → "이 틀은 직접 못 구워, 더 구체적인 틀로 만들어야 해"
final      → "이 틀은 변형 금지"
 
 

JS에는 이런 키워드가 원래 없었다. ES2022에서야 # private 필드가 생겼다.

 

 

프로토타입 기반 — "그냥 객체끼리 연결"

 

MDN에서 JS 객체를 이렇게 설명한다:

"JavaScript objects are dynamic 'bags' of properties (referred to as own properties). JavaScript objects have a link to a prototype object."
(JS 객체는 프로퍼티의 동적인 '가방'이다. JS 객체는 프로토타입 객체로의 링크를 갖는다.)
— MDN, Inheritance and the prototype chain

 

클래스처럼 "설계도 → 복사본" 구조가 아니라, 객체가 다른 객체에 링크를 건다.

원본 레시피 (Recipe.prototype) — cook 능력을 가진 원본
     ↑ 링크
  focaccia — "cook이 필요하면 저 원본한테 물어봐"
     ↑ 링크
  scone   — "cook이 필요하면 저 원본한테 물어봐"
 
 

rex와 buddy는 복사본이 아니라 원본에 살아있는 링크를 건 존재다.

const focaccia = new Recipe('포카치아');
const scone    = new Recipe('스콘');

focaccia.cook === scone.cook; // true — 둘 다 같은 원본 cook을 가리키고 있음
 

그리고 클래스 기반과 결정적으로 다른 부분:

"It is possible to mutate any member of the prototype chain or even swap out the prototype at runtime, so concepts like static dispatching do not exist in JavaScript."
(프로토타입 체인의 어떤 멤버든 런타임에 변경하거나 프로토타입 자체를 교체할 수 있다. 그래서 JS에는 정적 디스패치 같은 개념이 존재하지 않는다.)
— MDN, Inheritance and the prototype chain

 

이미 구워진 붕어빵들이 원본에 링크를 걸고 있어서, 원본이 바뀌면 전부 즉시 반영된다.

const focaccia = new Recipe('포카치아');
// focaccia 생성 이후에 원본에 cook 추가
Recipe.prototype.cook = function() { return `${this.name} 굽는 중!`; };
focaccia.cook(); // '포카치아 굽는 중!' — 나중에 추가됐는데도 바로 쓸 수 있음
 
 

그럼 class는?

 

MDN "Using classes"에서는 이렇게 말한다:

"In JavaScript, classes are mainly an abstraction over the existing prototypical inheritance mechanism — all patterns are convertible to prototype-based inheritance."
(JS에서 클래스는 기존 프로토타입 기반 상속 메커니즘 위에 올라간 추상화다. 모든 패턴은 프로토타입 기반 상속으로 변환 가능하다.) — MDN, Using classes

"It is, for example, fairly trivial to build a classic model on top of a prototypal model — which is how classes are implemented."
(예를 들어, 프로토타입 모델 위에 클래식 모델을 구축하는 것은 꽤 사소한 일이다. 실제로 클래스가 구현되는 방식이 그것이다.)
— MDN, Inheritance and the prototype chain

 

클래스 기반은 "설계도 → 복사본", 프로토타입 기반은 "원본 → 살아있는 링크"

 

 

프로토타입 기반이 클래스 기반보다 "더 강력하다"는 게 무슨 말이여..

 

책에서는 이렇게 나와있다.

"클래스 기반 객체지향 프로그래밍 언어보다 효율적이며 더 강력한 객체지향 프로그래밍 능력을 지니고 있는
프로토타입 기반의 객체지향 프로그래밍 언어다."

 

처음에 이걸 읽고 "그냥 JS가 고능한건가" 싶었는데,
구체적으로 어떤 측면에서 더 강력한 건지 하나씩 파봤다.

 

1. 상속 관계가 런타임에 동적으로 바뀐다

 

클래스 기반은 붕어빵 틀이 확정된 이후에는 이미 구워진 붕어빵을 바꿀 수 없다.
코드를 수정하고 처음부터 다시 구워야(컴파일해야) 한다.

JS는 원본(프로토타입)이 살아있는 링크이기 때문에, 원본을 바꾸면 연결된 모든 게 즉시 반영된다.

function Dog(name) { this.name = name; }

const focaccia = new Recipe('포카치아'); // focaccia 생성

// focaccia를 만든 이후에 원본에 cook 추가
Recipe.prototype.cook = function() { return `${this.name} 굽는 중!`; };

focaccia.cook(); // '포카치아 굽는 중!' — 나중에 추가됐는데도 바로 쓸 수 있음
 

붕어빵 비유로 하면:
구워진 붕어빵들이 원본에 링크를 걸고 있으니까,
원본에 "이제부터 팥 추가야"라고 하면 링크 걸린 붕어빵들 전부 즉시 팥이 생기는 것이다.

이미 구워서 진열대에 올라간 붕어빵에 새 재료를 추가할 수 있다는 게 클래스 기반과 결정적으로 다른 부분이다.

 

 

2. "두 부모한테 같은 게 있으면 누구 걸 써?" 문제를 자연스럽게 피한다

 

클래스 기반 언어에서 다중 상속(두 클래스를 동시에 상속받는 것)을 허용하면
다이아몬드 문제가 생긴다.

할머니 레시피 (BaseRecipe)
       ↙              ↘
엄마 스타일 변형    이모 스타일 변형
  (MomStyle)    (AuntStyle)
       ↘              ↙
      나의 레시피 (MyRecipe)
 
 

할머니 레시피를 엄마랑 이모가 각자 다르게 변형했는데,
내가 둘 다 상속받으면 '반죽해' 할 때 엄마 버전이야 이모 버전이야? 라는 충돌이 생긴다.

그래서 Java는 클래스 다중 상속 자체를 금지했다.
C++은 허용하긴 하는데, 해결하기 위한 복잡한 문법이 필요하다.

JS의 프로토타입 체인은 한 줄짜리 연결고리라 이 문제가 원천적으로 없다.
대신 믹스인이라는 방식으로 여러 능력을 자유롭게 조합한다.

// 믹스인 — "이 기술 주세요" 하고 골라서 붙이는 것
const Bakeable = { bake()  { return `${this.name} 굽는 중!`; } };
const Boilable = { boil()  { return `${this.name} 끓이는 중!`; } };

class Recipe {
  constructor(name) { this.name = name; }
}

// 원하는 기술만 Recipe의 원본에 추가
Object.assign(Recipe.prototype, Bakeable, Boilable);

const focaccia = new Recipe('포카치아');
focaccia.bake(); // '포카치아 굽는 중!'
focaccia.boil(); // '포카치아 끓이는 중!'
 

 

Bakeable이랑 Boilable은 클래스가 아니라 그냥 기술 묶음(객체)다.
상속 계층 같은 거 없이 원하는 기능만 골라서 원본에 붙인다.
충돌이 나면 나중에 붙인 게 이긴다. 명확하고 단순하다.

 

 

3. Object.create — 클래스 없이 상속 관계를 자유롭게 설정

 

클래스 기반 언어에서 새 객체를 만들려면 반드시 클래스가 있어야 한다.
JS는 Object.create로 클래스 없이 프로토타입 관계를 설정할 수 있다.

const baseRecipe = {
  describe() { return `${this.name}은 요리다.`; }
};

// baseRecipe를 프로토타입으로 하는 새 객체 생성 — 클래스 없음
const focaccia = Object.create(baseRecipe);
focaccia.name = '포카치아';
focaccia.bake = function() { return `${this.name} 굽는 중!`; };

focaccia.describe(); // '포카치아은 요리다.' — baseRecipe에서 상속
focaccia.bake();     // '포카치아 굽는 중!' — focaccia 자신의 메서드
 
 

클래스라는 중간 단계 없이 객체가 객체를 직접 상속한다.
이게 프로토타입 기반 상속의 원래 모습이다.

클래스는 이 과정을 더 구조적으로 쓸 수 있게 감싼 문법이지,
프로토타입 기반 상속 자체보다 더 강력한 게 아니다.

 

 

4. 메모리 효율 — 메서드를 인스턴스마다 복사하지 않는다

function Recipe(name) { this.name = name; }
Recipe.prototype.cook = function() { return `${this.name} 굽는 중!`; };

const focaccia = new Recipe('포카치아');
const scone    = new Recipe('스콘');
const muffin   = new Recipe('머핀');

// 세 인스턴스가 하나의 cook 함수를 공유
focaccia.cook === scone.cook === muffin.cook; // true
 
 

메서드가 Recipe.prototype에 하나만 존재하고 모든 인스턴스가 참조한다.
인스턴스가 10000개여도 cook 함수는 메모리에 딱 하나다.

만약 생성자 함수 안에서 메서드를 정의하면:

function Recipe(name) {
  this.name = name;
  this.cook = function() { return `${this.name} 굽는 중!`; }; // 인스턴스마다 새 함수
}

const focaccia = new Recipe('포카치아');
const scone    = new Recipe('스콘');

focaccia.cook === scone.cook; // false — 다른 함수 객체
 
 

cook 함수가 인스턴스마다 생성된다. 인스턴스 10000개면 함수도 10000개.
이게 프로토타입에 메서드를 정의해야 하는 이유다.

 

 

5. 클래스 기반 언어는 OOP를 위한 키워드가 따로 있다 — JS는 없었다

 

클래스 기반 언어는 "이 데이터는 건드리지 마", "얘는 이 클래스에서만 써" 같은 규칙을
언어 키워드로 강제한다.
 
캡슐화를 위한 접근 제한자:

public    → 문이 활짝 열린 방 (누구나 들어올 수 있음)
private   → 자물쇠 걸린 내 방 (나만 들어올 수 있음)
protected → 가족 비밀번호가 있는 방 (나 + 자식 클래스만)

상속을 위한 키워드:

extends    → "나는 Animal인데, 더 구체적으로는 Dog야"
implements → "나는 반드시 이 기능들을 구현하겠다고 약속해"
abstract   → "이걸로 직접 만들면 안 돼, 더 구체적인 버전으로만"
final      → "이건 절대 변형 금지"
super      → "부모한테 물어볼게"
 

JS에는 원래 이런 키워드들이 없었다.
그래서 개발자들이 직접 비슷하게 흉내냈다.

// "건드리지 마세요" — _ 컨벤션 (그냥 약속일 뿐, 언어가 강제하지 않음)
const account = {
  _balance: 1000, // 앞에 _ 붙이면 "이건 내부용이야"라는 암묵적 약속
  getBalance() { return this._balance; },
};

account._balance = 9999; // 되긴 됨 ← 언어가 막아주지 않음
 
 

ES2022에 # private 필드가 생기면서 JS도 진짜 private이 가능해졌다.

class BankAccount {
  #balance; // # 붙이면 클래스 외부에서 접근 자체가 문법 오류

  constructor(initial) { this.#balance = initial; }
  getBalance()         { return this.#balance; }
}

const acc = new BankAccount(1000);
acc.#balance; // SyntaxError — 자물쇠 걸린 방에 못 들어옴
 
 
 

개념은 클래스 기반 언어의 private이랑 같다.
다만 클래스 기반 언어는 처음부터 언어에 내장되어 있었고,
JS는 30년 만에 뒤늦게 추가된 것이다.

그래서 프로토타입 기반이 "더 강력"하다는 게 어떤 의미냐

표로 정리하자면 아래와 같이 될 것이다.

비교 클래스 기반 프로토타입 기반 (JS)
객체 만드는 방식 반드시 틀(클래스)이 있어야 함 원본 객체만 있으면 됨, 클래스 불필요
상속 관계 변경 코드 수정 후 다시 컴파일 실행 중에 원본 수정하면 즉시 반영
여러 능력 조합 복잡한 인터페이스 구조 필요 Object.assign 한 줄로 믹스인
메서드 보관 위치 인스턴스마다 따로 또는 클래스에 묶임 원본에 하나, 전체 공유
접근 제한 public/private/protected 키워드 내장 원래 없었음 → ES2022에서 # 추가

"더 강력하다"는 건 "더 유연하고 동적으로 다룰 수 있다" 에 가깝다.
클래스 기반이 엄격한 구조와 타입 안전성을 주는 대신 유연함이 부족하다면,
프로토타입 기반은 실행 중에도 자유롭게 관계를 바꾸고 조합할 수 있다.

다만 "유연하다 = 더 좋다"는 아니다.
유연함이 커지면 실수할 여지도 커진다.
그래서 JS 위에 타입 시스템을 얹은 TypeScript가 나온 것이기도 하다.

 

 

 

 

객체지향 프로그래밍의 4가지 특징

OOP 관련 글들 찾아보면 캡슐화, 추상화, 상속, 다형성 네 단어 적어놓고 끝인 경우가 많았다.

JS에서 실제로 어떻게 동작하는 건지, 내부에서 뭔 일이 벌어지는 건지가 안 나와 있어

읽다가 의심 가는 부분은 ECMAScript 스펙까지 따라가며 다시금 정리해봤다.

 

1. 캡슐화 (Encapsulation) — "내부는 건드리지 마, 정해진 창구로만 와"

캡슐화는 두 가지가 섞여 있다:

  • 번들링(bundling): 관련된 상태(프로퍼티)와 동작(메서드)을 하나의 단위로 묶는 것
  • 정보 은닉(information hiding): 내부 구현을 숨기고 공개 인터페이스만 노출하는 것

그런데 "JS에서 어떻게 숨기냐"를 찾아보면 시기에 따라 방식이 달라서, ES6 이전 방식이랑 ES2022 이후 방식을 나눠서 보는 게 이해하기 편하다.

 

ES6 이전 — 클로저로 흉내내기

 

흉내내!

 

function createPerson(name, age) {
  // name, age는 외부에서 직접 접근 불가 — 클로저로 숨김
  let _name = name;
  let _age = age;

  return {
    getName() { return _name; },
    getAge()  { return _age; },
    setAge(val) {
      if (typeof val !== 'number' || val < 0) throw new Error('유효하지 않은 나이');
      _age = val;
    },
  };
}

const me = createPerson('림졍', 20);
console.log(me._age);    // undefined — 직접 접근 불가
console.log(me.getAge()); // 25 — 공개 인터페이스로만
me.setAge(-1);            // Error — 유효성 검사도 가능

 

_name, _age는 클로저 덕분에 createPerson 함수 스코프 안에 갇혀있다.
반환된 객체의 메서드들만 이 변수에 접근할 수 있다.

 

이 방식의 문제: 인스턴스마다 메서드가 새로 만들어진다. 프로토타입 공유가 안 된다.

 

 

ES2022 — 클래스 필드 # 문법으로 진짜 private이 생겼다

 

# 문법을 보고 "이게 클로저로 숨기는 거랑 어떻게 다른 거지?" 싶어서

스펙을 찾아봤더니 내부 구현 레벨에서 완전히 다른 방식이었다.

class Person {
  #name; // private 필드 선언 (ECMAScript 2022)
  #age;

  constructor(name, age) {
    this.#name = name;
    this.#age = age;
  }

  getName() { return this.#name; }
  getAge()  { return this.#age; }
  setAge(val) {
    if (typeof val !== 'number' || val < 0) throw new Error('유효하지 않은 나이');
    this.#age = val;
  }
}

const me = new Person('림졍', 20);
console.log(me.#age);    // SyntaxError — 클래스 외부에서 접근 불가 (진짜 private)
console.log(me.getAge()); // 20

 

# prefix가 붙으면 클래스 외부에서 접근 자체가 문법 오류다.
클로저 방식과 달리 프로토타입에 메서드가 올라가므로 메모리 효율도 좋다.

 

∴ 스펙에서 어떻게 구현되어 있냐

#age[[PrivateName]] 내부 슬롯에 저장된 고유한 식별자이고,

클래스 바디 외부에서는 해당 슬롯에 접근하는 방법 자체가 없다.

"Private names are defined and introduced using private field declarations in class bodies."
(Private name은 클래스 바디 안의 private 필드 선언을 통해 정의되고 도입된다.) — ECMAScript, PrivateFieldFind

 

 

접근자 프로퍼티를 통한 캡슐화

 

18장에서 다뤘던 getter/setter도 캡슐화의 한 형태다.

class Circle {
  #radius;

  constructor(radius) { this.#radius = radius; }

  get radius()      { return this.#radius; }
  set radius(value) {
    if (value < 0) throw new Error('반지름은 0 이상이어야 합니다');
    this.#radius = value;
  }
  get area()        { return Math.PI * this.#radius ** 2; } // 읽기 전용
}

const c = new Circle(5);
c.radius = -1;  // Error
c.area = 100;   // 무시 (setter 없음)

 

area는 getter만 있어서 외부에서 직접 쓸 수 없다.
값을 은닉하면서 유효성 검사, 파생 값 계산 등을 게이트처럼 처리하는 구조다.

 

 

2. 추상화 (Abstraction) — "필요한 것만 드러내는 것"

추상화는 복잡한 내부를 감추고 핵심적인 인터페이스만 노출하는 것이다.
캡슐화가 "어떻게 숨길 것인가"라면, 추상화는 "무엇을 드러낼 것인가"에 더 가깝다.

JS에는 공식적인 abstract 키워드가 없어서 패턴으로 비슷하게 구현한다.

 

패턴 1 — 추상 클래스 흉내내기

class Dish {
  constructor(name) {
    if (new.target === Dish) {
      throw new Error('Dish는 직접 인스턴스화할 수 없습니다');
    }
    this.name = name;
  }

  // 하위 클래스에서 반드시 구현해야 함
  cook() {
    throw new Error('cook()은 반드시 구현해야 합니다');
  }

  describe() {
    return `${this.name}: ${this.cook()}`;
  }
}

class Pasta extends Dish {
  cook() { return '파스타 삶는 중'; }
}

new Dish('요리');   // Error — 직접 인스턴스화 불가
new Pasta('까르보나라').describe(); // '까르보나라: 파스타 삶는 중'

 

new.targetDish 자신이면 에러를 던지는 방식이다.
하위 클래스에서 new Pasta()로 호출하면 new.targetPasta가 되므로 통과한다.

18장에서 다뤘던 new.target(= [[NewTarget]] 내부 슬롯)이 여기서도 쓰인다.

 

 

 

TypeScript가 있으면

abstract class Dish {
  abstract cook(): string;
  describe(): string { return `요리 중: ${this.cook()}`; }
}

 

JS의 패턴 방식이 런타임 에러로 잡는다면, TS는 abstract 키워드로 컴파일 단계에서 강제할 수 있다

(이런 타입 안전성 때문에 대부분의 프로젝트에서 TS를 쓰는 이유 중 하나이기도 하다.)

 

 

잠깐 — "다양한 상황에서 쓸 수 있게 만드는 게 추상화 맞아?"

 

공용 컴포넌트 작업을 하면서 "다양한 상황에서 쓸 수 있게 만드는 게 추상화 맞아?" 싶었는데,

찾아보니 반은 맞고 반은 더 정확한 단어가 있었다.

 

아래처럼 내 컴포넌트 결과에 대해 적어보고, 이 내용에 대해 한번 정리해보았다.

 

  • Button 컴포넌트 만들기 — 내부 스타일/로직을 숨기고 variant, size, onClick 같은 인터페이스만 노출 → 추상화 + 캡슐화
  • Modal이 게시글 삭제에도, 탈퇴 플로우에도 쓰이는 것 — 같은 인터페이스(children, onClose)로 다른 내용이 들어감 → 다형성
  • variant="primary" / variant="ghost"가 다른 스타일로 렌더되는 것 — 같은 컴포넌트, 다른 동작 → 다형성

 

function Button({ variant = 'primary', size = 'md', ...props }: ButtonProps) {
  // 내부 스타일 로직은 숨겨져 있음 (캡슐화)
  // 사용하는 쪽은 variant="ghost"만 넘기면 됨 (추상화)
  return <button className={cx(styles[variant], styles[size])} {...props} />;
}

 

 

 

즉, "다양한 상황에서 쓸 수 있게"는 추상화의 결과이지 추상화 그 자체는 아니다.
∴ 추상화가 제대로 됐을 때 → 재사용이 쉬워지는 것이다.

추상화: 복잡한 내부를 숨기고 인터페이스만 노출
  ↓ 결과로
캡슐화: 내부 구현이 은닉됨
  ↓ 결과로
재사용: 다양한 상황에서 쓸 수 있음
  ↓ 이걸 가능하게 하는 메커니즘이
다형성: 같은 인터페이스, 다른 내용

 

FSD 구조도 이 흐름인 OOP 개념이랑 연결된다.

FSD (Feature-Sliced Design) 계층:

shared/ui     ← 가장 추상적인 계층 (어디서든 쓸 수 있는 공용 컴포넌트)
  Button, Input, Modal, BottomSheet, Toast

features      ← 도메인 로직이 붙은 계층
  게시글 필터, 삭제 모달, 탈퇴 플로우

pages         ← 가장 구체적인 계층
  실제 화면

 

OOP에서 "추상 → 구체"로 계층을 나누는 것처럼
FSD도 "공용(추상) → 도메인(구체) → 페이지(최종)" 방향으로 계층이 나뉜다.

 

추가로 Storybook이 왜 필요한지도 추상화랑 연결되는데,

Button의 내부 구현이 잘 추상화되었다면

Storybook에서 variant, size, disabled 조합만 보여줘도

사용하는 쪽이 어떻게 쓸지 완전히 이해할 수 있어야 한다.
인터페이스가 명확하게 드러나 있는지를 Storybook이 검증해주는 셈이다.

 

 

3. 상속 (Inheritance)

상속은 OOP의 핵심인데, JS는 클래스 기반 언어들이랑 메커니즘이 다르다.

흔히 접하는 클래스 기반 언어의 경우(Java, C++ 등), 클래스가 인스턴스의 구조를 정의하, 인스턴스는 클래스의 복사본이지만

JS는 프로토타입 기반으로 객체가 다른 객체를 직접 참조(링크)해서 프로퍼티를 공유한다.

 

정리하자면 아래와 같다.

  • 클래스 기반 언어 (Java, C++): 클래스가 인스턴스의 구조를 정의하고, 인스턴스는 클래스의 복사본.
  • JS (프로토타입 기반): 객체가 다른 객체를 직접 참조(링크)해서 프로퍼티를 공유.

상속이 왜 필요한지 코드로 보면 바로 납득이 된다.

// 상속 없이 — getArea가 인스턴스마다 따로 만들어짐
function Circle(radius) {
  this.radius = radius;
  this.getArea = function() { return Math.PI * this.radius ** 2; };
}
const c1 = new Circle(1);
const c2 = new Circle(2);
c1.getArea === c2.getArea; // false — 함수가 2개 생김, 인스턴스 10000개면 함수도 10000개

// 프로토타입에 정의하면 — 하나만 만들고 전부 공유
function Circle(radius) { this.radius = radius; }
Circle.prototype.getArea = function() { return Math.PI * this.radius ** 2; };

const c1 = new Circle(1);
const c2 = new Circle(2);
c1.getArea === c2.getArea; // true — 하나를 공유함

 

JS의 상속은 복사가 아니라 참조다.

class Recipe { cook() { return '요리 중'; } }
const r = new Recipe();

r.__proto__ === Recipe.prototype; // true — 복사가 아니라 참조

 

인스턴스가 메서드를 "갖고 있는 게" 아니라 "갖고 있는 척 빌려 쓰는 것"이다.

 

 

extends는 내부적으로 무슨 일을 하나

class Dish {
  constructor(name) { this.name = name; }
  describe() { return `${this.name}은 요리다.`; }
}

class Pasta extends Dish {
  describe() { return `${this.name}은 파스타다.`; } // 오버라이딩
}

const p = new Pasta('까르보나라');
p.describe(); // '까르보나라은 파스타다.'

 

extends를 쓰면 스펙 레벨에서 두 가지 프로토타입 체인이 동시에 설정된다.

인스턴스 체인:
  p  →  Pasta.prototype  →  Dish.prototype  →  Object.prototype  →  null

생성자 함수 체인:
  Pasta  →  Dish  →  Function.prototype  →  Object.prototype  →  null

 

두 번째 체인이 왜 필요하냐: Pasta.staticMethod를 썼을 때 Dish의 정적 메서드도 상속받을 수 있게 하기 위해서다.

class Dish {
  static create(name) { return new this(name); }
}
class Pasta extends Dish {}

const p = Pasta.create('까르보나라'); // Pasta.create가 없어도 Dish.create 상속
p instanceof Pasta; // true — new this()에서 this가 Pasta이기 때문

 

 

 

4. 다형성 (Polymorphism) — "같은 인터페이스, 다른 동작"

다형성은 두 가지 상황으로 나뉜다.

  1. 덕 타이핑 — "뭔지 몰라도 cook()만 있으면 쓸 수 있다"
  2. 메서드 오버라이딩 — "같은 이름인데 객체마다 다르게 실행된다"

1) 덕 타이핑 — JS가 타입을 확인하는 방식

 

먼저, "타입 확인"이 뭐냐믄요...

 

카페에서 음료를 주문한다고 생각해보자.

바리스타 입장에서 손님이 누구인지 (학생인지, 회사원인지) 는 중요하지 않다. "주문을 말할 수 있는가" 만 확인하면 된다.

JS도 똑같다.

function prepareRecipe(dish) {
  return dish.cook(); // dish가 뭔지 확인 안 함 — cook()만 있으면 됨
}

 

dish가 Pasta인지 Soup인지 확인하지 않는다. cook()이라는 기능이 있는가 만 확인한다.

 

그럼 실제로 JS 엔진이 dish.cook()을 실행할 때 어떤 일이 벌어지나욤?

class Pasta { cook() { return '파스타 삶는 중'; } }
class Soup  { cook() { return '국물 끓이는 중'; } }

function prepareRecipe(dish) {
  return dish.cook();
}

prepareRecipe(new Pasta()); // '파스타 삶는 중'
prepareRecipe(new Soup());  // '국물 끓이는 중'

 

prepareRecipe(new Pasta())를 실행하면:

1. dish = new Pasta() 로 들어옴
2. dish.cook() 호출
3. JS 엔진: "dish 객체에 cook이 있나?" → 프로토타입 체인 탐색
4. Pasta.prototype에 cook 발견 → 실행
5. '파스타 삶는 중' 반환

 

prepareRecipe(new Soup())를 실행하면:

1. dish = new Soup() 로 들어옴
2. dish.cook() 호출
3. JS 엔진: "dish 객체에 cook이 있나?" → 프로토타입 체인 탐색
4. Soup.prototype에 cook 발견 → 실행
5. '국물 끓이는 중' 반환

 

prepareRecipe 함수는 완전히 똑같은 코드인데, 넘어온 객체에 따라 다른 결과가 나온다. 이게 다형성이다.

 

 

만약 cook()이 없으면?

class Bread {} // cook() 없음

prepareRecipe(new Bread()); // TypeError: dish.cook is not a function

 

JS 엔진이 프로토타입 체인을 다 뒤져도 cook을 못 찾으면 그때 에러가 난다. "타입이 뭔지"가 아니라 "필요한 기능이 있는지" 로 판단한다는 게 덕 타이핑의 핵심이다.

 

이름의 유래도 여기서 왔다.

"오리처럼 걷고, 오리처럼 꽥꽥거리면 — 그건 오리다" 즉, cook()이 있으면 — 요리 재료로 쓸 수 있다.

 

TS를 쓰면 cook()이 있는지를 실행 전에 미리 잡아줄 수 있다

interface Cookable {
  cook(): string; // "이 기능이 반드시 있어야 해"
}

function prepareRecipe(dish: Cookable) {
  return dish.cook();
}

prepareRecipe(new Bread()); // 컴파일 에러 — 실행도 전에 잡아줌

 

JS는 실행해봐야 에러가 나고, TS는 코드 작성 시점에 잡아준다.

그래서 TS를 쓰면 덕 타이핑의 유연함을 유지하면서 타입 안전성도 챙길 수 있다! ^-^)b

// 타입을 확인하지 않고, 필요한 메서드가 있으면 쓴다
function prepareRecipe(dish) {
  return dish.cook(); // dish가 뭐든 cook()만 있으면 됨
}

class Pasta  { cook() { return '파스타 삶는 중'; } }
class Soup   { cook() { return '국물 끓이는 중'; } }
class Salad  { cook() { return '채소 버무리는 중'; } } // 상속 관계 없어도 됨

prepareRecipe(new Pasta());  // '파스타 삶는 중'
prepareRecipe(new Soup());   // '국물 끓이는 중'
prepareRecipe(new Salad());  // '채소 버무리는 중'

 

타입 안전성은 떨어지지만 유연함은 높다. TS를 쓰면 인터페이스로 강제할 수 있다.

 

 

2) 메서드 오버라이딩 — 같은 이름, 다른 동작의 메커니즘

 

커피 테이스팅? 대회가 있다고 생각해보자.

심사위원은 모든 참가자에게 똑같이 "어떤맛인지 구별 해주세요" 라고 요청한다.

근데 각자 앞에 놓인 샘플이 다르니까 대답이 다를 수밖에 없다.

class Coffee {
  taste() { return '맛을 알 수 없음'; } // 기본값 — 구체적인 원두를 모를 때
}

class Ethiopia extends Coffee {
  taste() { return '블루베리, 자스민'; }
}

class Guatemala extends Coffee {
  taste() { return '다크초콜릿, 흑설탕'; }
}

class Kenya extends Coffee {
  taste() { return '블랙커런트, 토마토'; }
}

 

심사위원(호출하는 코드)은 taste()만 부른다. 샘플(각 객체)이 알아서 자기 맛을 답한다.

 

 

실제로 JS 엔진이 어떤 순서로 찾아가냐면요

const e = new Ethiopia();
e.taste(); // 어떤 taste가 실행되나?

 

JS 엔진이 e.taste()를 만나면 프로토타입 체인을 위로 올라가며 탐색한다

1단계: e 자신에 taste가 있나?
       → 없음

2단계: e.__proto__ (= Ethiopia.prototype)에 taste가 있나?
       → 있음! → 여기서 멈추고 실행
       → '블루베리, 자스민'

(Coffee.prototype의 taste는 탐색하지도 않음)
const g = new Guatemala();
g.taste();
1단계: g 자신에 taste가 있나?
       → 없음

2단계: g.__proto__ (= Guatemala.prototype)에 taste가 있나?
       → 있음! → 여기서 멈추고 실행
       → '다크초콜릿, 흑설탕'

 

체인을 타고 올라가다가 처음 발견한 것을 쓴다.

Ethiopia.prototype에 taste가 있으니까 Coffee.prototype까지 올라가지 않는다.

이게 프로퍼티 섀도잉 — 앞에 있는 게 뒤를 가리는 것이다.

 

 

그래서 이게 다형성이랑 어떻게 연결되죠?

function judging(coffee) {
  return coffee.taste();
}

judging(new Ethiopia());  // '블루베리, 자스민'
judging(new Guatemala()); // '다크초콜릿, 흑설탕'
judging(new Kenya());     // '블랙커런트, 토마토'

 

judging 함수는 완전히 똑같은 코드인데, 넘어온 객체에 따라 다른 결과가 나온다.

Ethiopia 차례: coffee = Ethiopia 인스턴스
  → Ethiopia.prototype.taste 발견 → '블루베리, 자스민'

Guatemala 차례: coffee = Guatemala 인스턴스
  → Guatemala.prototype.taste 발견 → '다크초콜릿, 흑설탕'

 

호출하는 코드 coffee.taste()는 완전히 동일하다.

근데 어떤 객체냐에 따라 프로토타입 체인에서 찾아내는 함수가 다르다. 이게 다형성의 메커니즘이다.

 

 

정리

  덕 타이핑 메서드 오버라이딩
핵심 질문 "이 기능이 있는가?" "같은 이름인데 누구 걸 쓸까?"
JS 엔진이 하는 일 프로토타입 체인에서 그 메서드를 탐색 체인에서 처음 발견한 것을 쓰고 멈춤
결과 있으면 실행, 없으면 TypeError 객체마다 다른 함수가 실행됨

둘 다 결국 프로토타입 체인 탐색이 기반이기 때문...에

"어디서 찾느냐"가 다형성을 만든다는 것을 잊지말긔.

 

 

 

 

class는 슈거코드인가? — 처음부터 다시

발표 때 "슈거코드 아니냐"는 말이 나왔고, 그때 제대로 답변도 못하고 어버버하고 넘겼던 기억이 있어

이번에 다시 제대로 파면서 비교를 해보기로 했다.

 

 

왜 class가 생겼을까욤

 

ES6 이전에 생성자 함수로 상속을 구현하려면 세 줄을 수동으로 챙겨야 했다

function BakedRecipe(name, temp) {
  Recipe.call(this, name);                          // ① 부모 생성자 수동 호출
  this.temp = temp;
}
BakedRecipe.prototype = Object.create(Recipe.prototype); // ② 프로토타입 체인 수동 연결
BakedRecipe.prototype.constructor = BakedRecipe;         // ③ constructor 수동 복원

 

각 줄이 왜 필요한지 하나씩 파보면:

// ① Recipe.call(this, name)
// BakedRecipe 생성자 안에서 Recipe 생성자를 수동으로 호출.
// 이게 없으면 this.name이 세팅 안 됨.

// ② BakedRecipe.prototype = Object.create(Recipe.prototype)
// BakedRecipe로 만든 인스턴스들의 프로토타입 체인에 Recipe.prototype을 끼워 넣는 것.
// 이게 없으면 focaccia.cook() 같은 Recipe 메서드를 못 씀.
//
// 체인: instance → BakedRecipe.prototype → Recipe.prototype → Object.prototype → null
//
// 참고: 왜 new Recipe()가 아니라 Object.create(Recipe.prototype)을 쓰냐?
// new Recipe()는 Recipe 생성자를 실행시켜버린다.
// Object.create는 Recipe.prototype을 부모로 갖는 빈 객체만 만든다.
// 생성자 실행 없이 상속 관계만 설정하고 싶을 때 쓰는 방법이다.

// ③ BakedRecipe.prototype.constructor = BakedRecipe
// ②에서 prototype을 갈아끼우면 constructor가 Recipe로 바뀌어버린다.
// (Object.create로 만든 빈 객체엔 constructor가 없어서 체인 타고 올라가면 Recipe가 나옴)
// 다시 BakedRecipe를 가리키도록 수동으로 복원하는 것.

 

세 줄 중 하나라도 빠뜨리면 버그가 생기는데 심지어 에러 메시지도 안 뜬다. (!!!!)

 

그래서 ES6에서 class가 나왔습니다요

class BakedRecipe extends Recipe { // extends가 위의 세 줄을 전부 해줌
  constructor(name, temp) {
    super(name);
    this.temp = temp;
  }
}

 

내부에서 만들어지는 프로토타입 체인은 생성자 함수 방식이랑 완전히 동일하다.

이게 "슈거코드 아니냐"는 말이 나온 이유다. 결과만 보면 맞는 말이다. (암암)

typeof Recipe // 'function' — class인데 함수라고 나옴

 

 

근데 다르게 설계된 것들이 있다

 

차이를 찾다가 스펙을 뒤졌더니 class에만 붙는 내부 슬롯들이 있었다.

 

차이 ① new 없이 호출

 

// 생성자 함수 — new 빼먹어도 에러 안 남
function Recipe(name) { this.name = name; }
Recipe('포카치아'); // 에러 없음 → window.name = '포카치아' 로 전역 오염 😱

// class — new 빼먹으면 바로 에러
class Recipe { constructor(name) { this.name = name; } }
Recipe('포카치아'); // TypeError

 

스펙을 찾아봤더니 class로 만든 함수에는 [[IsClassConstructor]]라는 내부 슬롯이 있어

new 없이 호출하면 즉시 TypeError를 던지도록 설계되어 있었다.

 

 

차이 ② 상속할 때 super() 전에 this가 없다

// 생성자 함수 상속 — this가 처음부터 있음
function Child(name) {
  console.log(this); // {} ← 이미 있음
  Parent.call(this, name);
}

// class 상속 — super() 전까지 this가 없음
class Child extends Parent {
  constructor(name) {
    console.log(this); // ReferenceError ← 아직 없음!
    super(name);
    console.log(this); // { name: ... } ← 이제 생김
  }
}

 

extends를 쓰면 [[ConstructorKind]]가 "derived"로 설정되는데,

파생 클래스는 this를 직접 만들지 않고 super()를 통해 부모가 만들어주기를 기다린다.

생성자 함수에는 이 개념 자체가 없다.

 

 

차이 ③ strict mode 자동 적용 / ④ TDZ

// class — 항상 strict mode
class Recipe {
  constructor(name) {
    naem = name; // ReferenceError ← 오타 바로 잡힘 (strict mode)
  }
}

// 선언 전에 쓰면 에러 (TDZ)
const r = new Recipe('포카치아'); // ReferenceError
class Recipe { constructor(name) { this.name = name; } }

 

 

그래서 결론은!!

슈거코드 맞아?  →  큰 틀은 맞다. 프로토타입 기반 모델 위에 올라간 문법이고,
                    typeof도 'function'이 나온다.

근데 완전히 같아?  →  아니다. 생성자 함수로는 재현 안 되는 것들이 있다.
                       의도적으로 더 엄격하게 설계한 부분들이 있기 때문이다.

 

"슈거코드"라고 하면 "완전히 같은 것을 예쁘게 쓴 것!(^ㅇ^)"이라는 뉘앙스가 있는데,

실제로는 실수할 구멍을 언어 레벨에서 막아놓은 버전에 가깝다.

발표 때 "슈거코드 맞다"고 한 건 완전히 틀린 말이 아니었다.

거기서 한 발 더 나가면 "근데 의도적으로 다르게 설계된 것들도 있어서 완전히 같진 않다"까지 가는 거고,

정확하게는 "프로토타입 기반 모델을 더 구조적이고 안전하게 표현한 문법" 이라고 보는 게 맞다.

 

 

그럼 생성자 함수는 이제 안 쓰는게 좋나요?

 

놀랍게도 아직 쓰는 경우가 있다.

// new 없이 호출했을 때 자동으로 new로 리다이렉트하고 싶을 때
function Circle(radius) {
  if (!new.target) return new Circle(radius); // new 없이 불러도 동작하게
  this.radius = radius;
}

Circle(5);     // 작동 (class였으면 TypeError)
new Circle(5); // 작동

 

class는 new 없이 호출하면 무조건 TypeError라 이런 패턴이 불가능하다.
레거시 코드나 특수한 상황에서는 생성자 함수가 필요할 수 있다.

하지만 새로 코드를 짤 때는 class를 쓰는 게 기본값이다.
실수할 구멍이 적고, 코드 의도가 더 명확하기 때문이다.

 

 

class도 일급 객체니까 값처럼 다룰 수 있다

 

class가 결국 함수 객체(값)이기 때문에 변수에 담고, 인수로 넘기고, 반환값으로 쓸 수 있다.

// 클래스 팩토리 — 클래스를 반환하는 함수
function createValidator(rules) {
  return class {
    constructor(data) { this.data = data; }
    validate() {
      return Object.entries(rules).every(([key, fn]) => fn(this.data[key]));
    }
  };
}

const UserValidator = createValidator({
  name: (v) => typeof v === 'string' && v.length > 0,
  age:  (v) => typeof v === 'number' && v >= 0,
});

new UserValidator({ name: '림졍', age: 20 }).validate(); // true
new UserValidator({ name: '',     age: 20 }).validate(); // false

 

"클래스를 반환하는 함수"가 말이 되는 이유가 class가 결국 함수 객체(값)이기 때문이다.

 

...

 

나머진 이어서 가져갈게요...

 

 

 

728x90
반응형