JS 클래스·프로토타입·모듈: jsc 로 직접 돌려 본 동작

JavaScript 강좌 4: 프로토타입 체인, class 와 함수의 차이, #private, this 를 잃는 TypeError, 순환 import, 객체당 40B 대 249B 실측.

시리즈: JavaScript 강좌 4/6 (중급). 함수와 배열·객체는 쓸 줄 알지만, 실무 코드에서 class, extends, #private, import 를 만나면 안에서 무슨 일이 일어나는지 모르는 분을 위한 글입니다. 다 읽으면 속성이 어디서 찾아지는지 예측하고, 클래스와 그냥 객체 중 하나를 고르고, this 를 잃어 죽는 버그를 피하고, 순환 import 에 당하지 않고 ES 모듈로 코드를 나눌 수 있습니다.

신입 분들이 React·Vue 코드를 처음 열었을 때 자주 묻는 것이 "Java 클래스랑 똑같은 거죠?" 입니다. 겉모양은 비슷하지만 속은 다릅니다. JavaScript 에는 설계도로서의 클래스가 없고, 객체가 다른 객체를 가리키는 고리만 있습니다. 이 차이를 모르면 콜백으로 넘긴 메서드에서 this 가 사라지거나, 공유 데이터가 몰래 바뀌는 버그를 만나게 됩니다. 이 글은 모든 조각을 실제 엔진에서 돌려 진짜 출력과 오류 메시지를 그대로 보여 줍니다.

실행 환경: macOS 26.6.2 에 들어 있는 JavaScriptCore(프레임워크 버전 21624.5.1.11.3)를 jsc 셸로 실행, Apple Silicon Mac (M1 Pro). Node.js, Deno, 브라우저는 실행하지 않았습니다. 동작이 다른 곳은 따로 적었습니다. 1 강에서 만든 별칭을 그대로 씁니다.

$ alias jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc

jsc 에서는 print() 로 출력합니다. 이 셸에는 console 이 없습니다.

프로토타입 체인: 속성은 실제로 어디서 찾아지나

모든 객체에는 [[Prototype]] 이라는 숨은 고리가 있고, 다른 객체(또는 null)를 가리킵니다. obj.name 을 읽었는데 obj 자신에게 name 이 없으면, 엔진은 그 고리를 따라가서 다시 찾습니다. 이름을 찾거나 null 에 닿을 때까지 반복합니다. 이것이 상속의 전부입니다. Object.create(p) 는 프로토타입이 p 인 새 객체를 만듭니다.

// proto.js — the prototype chain, step by step
const animal = { legs: 4, describe() { return `${this.name} has ${this.legs} legs`; } };
const cat = Object.create(animal);      // cat's prototype is animal
cat.name = "Tama";
print(cat.describe());                  // found on animal, this = cat
print(Object.hasOwn(cat, "legs"), "legs" in cat);
print(Object.getPrototypeOf(cat) === animal);
print(Object.getPrototypeOf(animal) === Object.prototype);
print(Object.getPrototypeOf(Object.prototype));
cat.legs = 3;                           // shadows, does not change animal
print(cat.legs, animal.legs);
const bird = Object.create(animal); bird.name = "Piyo";
animal.legs = 2;                        // every object without its own legs sees this
print(bird.describe());
for (const k in cat) print("for-in:", k);
print(Object.keys(cat));
$ jsc proto.js
Tama has 4 legs
false true
true
true
null
3 4
Piyo has 2 legs
for-in: name
for-in: legs
for-in: describe
name,legs

출력에서 읽어야 할 것은 세 가지입니다.

  • 자기 속성과 물려받은 속성. Object.hasOwn(cat, "legs") 는 false, "legs" in cat 은 true 였습니다. in 은 체인을 따라가고 hasOwn(ES2022)은 따라가지 않습니다.
  • 쓰기는 체인을 타지 않습니다. cat.legs = 3 은 cat 에 새 자기 속성을 만들었고(가리기, shadowing) animal.legs 는 4 그대로였습니다. 반대로 나중에 animal.legs 를 바꾸자 자기 legs 가 없는 bird 가 보는 값이 바뀌었습니다. 프로토타입에 공유된 변경 가능 데이터는 전형적인 버그 원인입니다.
  • for...in 은 물려받은 열거 가능 키까지 돕니다(describe 가 나왔습니다). Object.keys 는 아닙니다. 데이터에는 Object.keys/Object.entries 를 쓰세요.

describe 안의 this 는 animal 이 아니라 cat 이었습니다. this 는 메서드를 어디서 찾았든 호출할 때 점 앞에 있던 객체입니다.

체인 그림. new Circle(1) 로 만든 객체 c 는 데이터 속성 name 과 r 을 직접 가진다. 그 프로토타입은 Circle.prototype 이고 area 와 constructor 가 있다. 그 위는 Shape.prototype 으로 toString 과 자체 area 가 있다. 그 위가 hasOwnProperty 등을 가진 Object.prototype, 마지막이 null 이며 여기서 탐색이 멈추고 undefined 가 된다.
c.area() 와 c.toString() 을 찾는 과정. 엔진은 객체 자신을 먼저 보고, 이름을 찾거나 null 에 닿을 때까지 프로토타입 체인을 위로 올라간다.

class 는 함수다, 다만 눈에 보이는 차이 네 가지

ES2015 이전의 "클래스"는 생성자 함수와 .prototype 에 붙인 메서드였습니다. class 키워드도 같은 모양을 만들기 때문에 아래 두 방식은 둘 다 5 를 돌려줍니다.

function PointFn(x, y) { this.x = x; this.y = y; }
PointFn.prototype.len = function () { return Math.hypot(this.x, this.y); };
class Point { constructor(x, y) { this.x = x; this.y = y; } len() { return Math.hypot(this.x, this.y); } }
print(typeof Point, typeof PointFn);
print(new PointFn(3, 4).len(), new Point(3, 4).len());
for (const k in new PointFn(3, 4)) print("fn  for-in:", k);
for (const k in new Point(3, 4)) print("cls for-in:", k);
print(Object.getOwnPropertyDescriptor(Point.prototype, "len").enumerable,
      Object.getOwnPropertyDescriptor(PointFn.prototype, "len").enumerable);
const p = PointFn(1, 2);   // forgot new (sloppy mode)
print(p, typeof globalThis.x, globalThis.x);
try { Point(1, 2); } catch (e) { print(e); }
$ jsc classvsfn.js
function function
5 5
fn  for-in: x
fn  for-in: y
fn  for-in: len
cls for-in: x
cls for-in: y
false true
undefined number 1
TypeError: Cannot call a class constructor Point without |new|
  1. new 를 빼먹으면. 옛 함수 방식의 PointFn(1, 2) 는 undefined 를 돌려주고 전역 객체에 x, y 를 조용히 써 버렸습니다(globalThis.x 가 1). 클래스는 TypeError 로 거부합니다. 이것만으로도 class 를 쓸 이유가 됩니다.
  2. 메서드가 열거되지 않습니다. 함수 방식은 len 이 for...in 에 새어 나왔습니다.
  3. 클래스 본문은 strict 모드입니다. this 에 어떤 영향이 있는지는 아래 "this 를 잃은 메서드"에서 봅니다.
  4. Point.prototype 은 읽기 전용입니다. instanceof 절에서 확인합니다.

private #필드, getter, static 블록

ES2022 에서 진짜 비공개가 들어왔습니다. # 로 시작하는 이름은 클래스 본문 안에서만 존재합니다. ES2022 의 클래스 기능을 한 번에 다 쓴 작은 계좌 클래스입니다.

class Account {
  #balance = 0;                 // private field (ES2022)
  static #count = 0;            // private static
  static { print("static block ran"); }   // static initialization block (ES2022)
  constructor(owner) { this.owner = owner; Account.#count++; }
  deposit(n) { this.#check(n); this.#balance += n; return this; }
  #check(n) { if (!(n > 0)) throw new RangeError(`bad amount: ${n}`); }
  get balance() { return this.#balance; }        // read-only from outside
  static isAccount(o) { return #balance in o; }  // brand check (ES2022)
  static get count() { return Account.#count; }
}
const a = new Account("kim").deposit(100).deposit(50);
print(a.balance, Account.count);
a.balance = 999;                 // no setter: silently ignored in sloppy mode
print(a.balance);
print(Object.keys(a), JSON.stringify(a));
print(Account.isAccount(a), Account.isAccount({ balance: 1 }));
try { a.deposit(-5); } catch (e) { print(e); }
try { Account.prototype.deposit.call({}, 10); } catch (e) { print(e); }
$ jsc account.js
static block ran
150 1
150
owner {"owner":"kim"}
true false
RangeError: bad amount: -5
TypeError: Cannot access private method or acessor (evaluating 'this.#check')
  • a.balance = 999 는 아무 일도 하지 않고 아무 말도 없었습니다. getter 만 있고 setter 가 없는데, 대입한 곳이 strict 가 아닌 일반 스크립트였기 때문입니다. strict 코드에서는 같은 대입이 TypeError: Attempted to assign to readonly property. 로 터졌습니다(jsc -e '"use strict"; ...' 로 확인). 오래된 비모듈 스크립트에는 이런 버그가 숨어 있습니다.
  • private 필드는 Object.keys 에도 JSON.stringify 에도 안 나옵니다. 비공개 상태를 직렬화하려면 toJSON() 을 직접 쓰세요.
  • #balance in o 는 정말 이 클래스로 만든 객체인지 확인합니다. "balance" in o 같은 덕 타이핑으로는 구별이 안 됩니다.
  • 남의 객체에 메서드를 호출하면 첫 private 접근에서 실패합니다. JavaScriptCore 메시지는 실제로 "acessor" 라고 오타가 나 있습니다. 검색할 때는 출력된 그대로 찾으세요.

밖에서 private 이름에 접근하면 실행 중 오류조차 아닙니다. 파싱 단계의 SyntaxError 라서 파일의 첫 줄도 실행되지 않습니다.

$ cat outside.js
print("first line runs?");
class A { #x = 1 }
const a = new A();
print(a.#x);
$ jsc outside.js
Exception: SyntaxError: Cannot reference undeclared private names: "#x"
at outside.js:5
$ echo $?
3

"first line runs?" 는 끝내 출력되지 않았습니다(jsc 는 마지막 줄 다음인 5 행을 가리켰습니다. 파일 전체에 대한 오류라는 뜻입니다). 한편 a["#x"] 는 undefined 였습니다. 문자열 키 "#x" 는 전혀 다른 보통 속성입니다.

흔한 실수 1: 자기 자신을 부르는 setter

이렇게 씁니다: 입력을 trim 해서 같은 이름으로 저장하는 setter.

class User {
  set name(v) { this.name = v.trim(); }   // BUG: calls itself
}
try { new User().name = " lee "; } catch (e) { print(e); }

실제로 일어나는 일: setter 안의 this.name = ... 이 다시 setter 를 부르고, 끝없이 반복됩니다.

$ jsc setter_loop.js
RangeError: Maximum call stack size exceeded.

고치는 법: 값은 다른 곳, 가능하면 private 필드에 둡니다. #name; set name(v) { this.#name = v.trim(); } get name() { return this.#name; }

흔한 실수 2: this 를 잃어버린 메서드

이렇게 씁니다: 메서드를 콜백(setTimeout, 이벤트 리스너, array.map)으로 넘기거나 변수에 담습니다.

class Counter {
  count = 0;
  inc() { this.count++; return this.count; }
  incArrow = () => { this.count++; return this.count; };
}
const c = new Counter();
const f = c.inc;
try { f(); } catch (e) { print(e); }
setTimeout(c.inc, 0);           // same bug, later
const g = c.incArrow; print(g(), g());
const h = c.inc.bind(c); print(h());
print(Object.keys(c));

실제로 일어나는 일:

$ jsc lostthis.js
TypeError: undefined is not an object (evaluating 'this.count')
1 2
3
count,incArrow
Exception: TypeError: undefined is not an object (evaluating 'this.count')
inc@lostthis.js:3:15
$ echo $?
3

this 는 함수를 정의한 곳이 아니라 호출하는 방식으로 정해집니다. f() 는 점 앞에 객체가 없고, 클래스 본문은 strict 라서 this 가 전역 객체가 아니라 undefined 가 됩니다. setTimeout 쪽은 나중에, 아무도 잡지 않은 채 터져서 프로그램이 종료 코드 3 으로 끝났습니다. Chrome 이나 Node 는 Cannot read properties of undefined (reading 'count') 라고 다르게 표시합니다(이 글에서는 실행하지 않았으니 두 문구 모두로 검색하세요).

고치는 법: c.inc.bind(c), 감싸는 () => c.inc(), 또는 화살표 함수 클래스 필드. 마지막 줄을 보세요. incArrow 는 인스턴스마다 생기는 자기 속성(Object.keys 에 나옴)이고, inc 는 프로토타입에 하나만 있습니다. 여기에는 비용이 있어서 직접 재 봤습니다.

실측: 화살표 함수 필드는 객체 하나에 얼마나 드나

같은 작은 클래스를 네 가지로 쓰고, 인스턴스 10 만 개를 살려 둔 채 전체 GC 후 힙 크기를 읽었습니다. fullGC() 와 gcHeapSize() 는 jsc 전용 도우미 함수입니다. Node 에서는 --expose-gc 와 process.memoryUsage() 를 씁니다(여기서는 실행하지 않음).

// mem.js — heap cost per instance: prototype method vs per-instance function
// usage: jsc mem.js -- <variant>   (run each variant in a fresh process)
class ProtoMethod { constructor(i) { this.i = i; } get() { return this.i; } }
class ArrowField  { get = () => this.i; constructor(i) { this.i = i; } }
class BoundInCtor { constructor(i) { this.i = i; this.get = this.get.bind(this); } get() { return this.i; } }
class PrivateField { #i; constructor(i) { this.#i = i; } get() { return this.#i; } }
const variants = { ProtoMethod, ArrowField, BoundInCtor, PrivateField };
const N = 100_000;
const name = arguments[0];
const C = variants[name];
fullGC(); const before = gcHeapSize();
const t0 = performance.now();
const keep = new Array(N);
for (let i = 0; i < N; i++) keep[i] = new C(i);
const t1 = performance.now();
fullGC(); const after = gcHeapSize();
let sum = 0; for (const o of keep) sum += o.get();   // all instances still alive and usable
print(`${name.padEnd(12)} bytes/instance=${((after - before) / N).toFixed(1)} create=${(t1 - t0).toFixed(1)}ms sum=${sum}`);

각 방식을 새 프로세스에서 5 바퀴 돌렸습니다(첫 바퀴만 보이며, 바이트 수는 5 번 모두 같았습니다).

$ for v in ProtoMethod ArrowField BoundInCtor PrivateField; do jsc mem.js -- $v; done
ProtoMethod  bytes/instance=40.4 create=2.4ms sum=4999950000
ArrowField   bytes/instance=249.4 create=5.7ms sum=4999950000
BoundInCtor  bytes/instance=136.4 create=5.4ms sum=4999950000
PrivateField bytes/instance=72.4 create=2.2ms sum=4999950000
방식 인스턴스당 바이트 10 만 개 생성 (5 회 중앙값)
프로토타입 메서드 40.4 1.8 ms
private #i + 프로토타입 메서드 72.4 2.2 ms
생성자에서 bind() 136.4 5.4 ms
화살표 함수 클래스 필드 249.4 5.7 ms
막대그래프. 프로토타입 메서드는 인스턴스당 40.4 바이트였다. 같은 클래스를 private #필드로 쓰면 72.4 바이트. 생성자에서 bind 하면 136.4 바이트, 화살표 함수 클래스 필드는 249.4 바이트로 프로토타입 메서드의 약 6 배였다.
같은 클래스를 4 가지로 쓰고 10 만 개를 살려 둔 상태의 인스턴스 1 개당 힙 사용량(fullGC 후 gcHeapSize, 배열 슬롯 8 바이트 포함). 5 회 모두 같은 값, macOS 26.6.2 의 jsc, Apple M1 Pro.

keep 배열의 슬롯 8 바이트가 포함된 값입니다. 화살표 필드는 인스턴스마다 새 함수 객체(와 클로저)를 만들기 때문에 여기서는 프로토타입 메서드의 약 6 배 메모리를 쓰고, 생성도 약 3 배 느렸습니다. 화면 컴포넌트 50 개라면 무시해도 되지만, 데이터 레코드 100 만 개라면 대략 200MB 차이입니다. 콜백으로 넘기는 일이 많은 객체(이벤트 핸들러)에는 화살표 필드, 데이터 성격의 객체에는 프로토타입 메서드가 맞습니다. private 필드는 JavaScriptCore 에서 객체당 약 32 바이트 더 들었는데, 이유까지는 파고들지 않았고 V8 에서는 다를 수 있습니다.

extends, super, instanceof

class Shape {
  constructor(name) { this.name = name; }
  area() { return 0; }
  toString() { return `${this.name}(${this.area().toFixed(2)})`; }
}
class Circle extends Shape {
  constructor(r) { super("circle"); this.r = r; }
  area() { return Math.PI * this.r ** 2; }
}
class Square extends Shape {
  constructor(s) { super("square"); this.s = s; }
  area() { return this.s ** 2; }
  toString() { return "[" + super.toString() + "]"; }
}
const shapes = [new Circle(1), new Square(2)];
print(shapes.join(" "));
const c = shapes[0];
print(c instanceof Circle, c instanceof Shape, c instanceof Object, c instanceof Square);
print(Object.getPrototypeOf(Circle.prototype) === Shape.prototype, Object.getPrototypeOf(Circle) === Shape);
class Broken extends Shape { constructor() { this.x = 1; super("b"); } }
try { new Broken(); } catch (e) { print(e); }
class NoSuper extends Shape { constructor() { } }
try { new NoSuper(); } catch (e) { print(e); }
class Stack extends Array { peek() { return this[this.length - 1]; } }
const s = Stack.from([1, 2, 3]);
const m = s.map(x => x * 10);
print(s.peek(), m.peek(), m instanceof Stack, Array.isArray(m));
$ jsc inherit.js
circle(3.14) [square(4.00)]
true true true false
true true
ReferenceError: 'super()' must be called in derived constructor before accessing |this| or returning non-object.
ReferenceError: 'super()' must be called in derived constructor before accessing |this| or returning non-object.
3 30 true true

Shape.toString 이 this.area() 를 부르면 하위 클래스의 것이 돕니다. 찾기가 객체에서 시작하므로 가장 구체적인 메서드가 이깁니다. super() 전에 this 를 만지거나 super() 를 아예 안 부르면 같은 ReferenceError 가 납니다. Array 상속도 되고, Stack 에서 map 을 부르면 결과도 Stack 이었습니다.

instanceof 는 클래스 이름을 보지 않습니다. "Circle.prototype 이 이 객체의 체인 어딘가에 있나?" 를 물을 뿐입니다.

class Duck {}
const d = new Duck();
print(d instanceof Duck);
Duck.prototype = {};             // ignored: class prototype is non-writable
print(d instanceof Duck);
function Goose() {}
const g = new Goose();
Goose.prototype = {};            // allowed for plain functions
print(g instanceof Goose);
// another realm (like an iframe): jsc's createGlobalObject()
const other = createGlobalObject();
const arr = other.eval("[1, 2, 3]");
print(arr instanceof Array, Array.isArray(arr));
// duck typing via Symbol.hasInstance
class Iterable { static [Symbol.hasInstance](o) { return o != null && typeof o[Symbol.iterator] === "function"; } }
print("abc" instanceof Iterable, new Map() instanceof Iterable, {} instanceof Iterable);
try { print(d instanceof {}); } catch (e) { print(e); }
$ jsc instanceof.js
true
true
false
false true
true true false
TypeError: {} is not a function. (evaluating 'd instanceof {}')

다른 전역 객체("realm". 브라우저라면 iframe 이나 워커, createGlobalObject() 는 이를 흉내 내는 jsc 전용 도우미)에서 만든 배열은 Array.prototype 이 달라서 instanceof Array 가 아닙니다. 믿을 수 있는 검사는 Array.isArray 입니다.

모듈: jsc 가 돌릴 수 있는 것과 없는 것

모듈은 자기만의 스코프를 가진 파일이고, export/import(ES2015)로만 값을 나눕니다. jsc 는 확장자가 .mjs 이거나 -m 을 주면 모듈로 실행합니다.

// counter.mjs
export let count = 0;
export function inc() { count++; }
export default class Greeter { hi(n) { return `hi ${n}`; } }
print("counter.mjs evaluated");
// main.mjs
import Greeter, { count, inc } from "./counter.mjs";
import * as again from "./counter.mjs";       // same module, not evaluated twice
print(count); inc(); inc();
print(count, again.count);                     // live binding
try { count = 10; } catch (e) { print(e); }
print(new Greeter().hi("module"));
print(typeof this, typeof globalThis);
const cfg = await Promise.resolve({ debug: true });   // top-level await
print("tla:", cfg.debug);
const lazy = await import("./counter.mjs");
print(lazy.count, lazy === again);
print(import.meta.url.replace(/^.*\/mod\//, "~/demo/"));
$ jsc main.mjs
counter.mjs evaluated
0
2 2
TypeError: Attempted to assign to readonly property.
hi module
undefined object
tla: true
2 true
~/demo/main.mjs

모듈은 몇 번 import 하든 한 번만 실행됩니다. import 한 let 은 라이브 바인딩이라 내보낸 쪽이 바꾸면 0 에서 2 로 같이 바뀌지만, 가져온 쪽에서는 읽기 전용입니다. 최상위 this 는 undefined, 최상위 await(ES2022)와 import()(ES2020)도 동작했습니다. import.meta.url 의 절대 경로는 직접 잘라 냈습니다.

방식을 섞을 때 만나는 오류들입니다.

$ jsc classic.js          # line 1: import { inc } from "./counter.mjs";
Exception: SyntaxError: Unexpected token '{'. import call expects one or two arguments.
at classic.js:1
$ jsc -m classic.js       # same file, as a module: works
counter.mjs evaluated
1
$ jsc -e 'const fs = require("fs")'
Exception: ReferenceError: Can't find variable: require
global code@[Command Line]:1:19
$ jsc noext.mjs           # import { inc } from "./counter";
Exception: Error: Could not open file '~/demo/mod/counter'.
$ jsc badname.mjs         # import { incr } from "./counter.mjs";
Exception: SyntaxError: Importing binding name 'incr' is not found.
$ jsc sloppy.mjs          # x = 5;
Exception: ReferenceError: Can't find variable: x
module code@~/demo/mod/sloppy.mjs:1:2

모듈은 항상 strict 라서 선언 안 한 변수에 대입하면 전역이 생기는 대신 오류가 납니다. 브라우저도 확장자까지 쓴 파일 이름이 필요합니다. 번들러나 Node 의 CommonJS 해석기는 확장자를 추측해 주기 때문에, 많은 프로젝트에서 되는 "./counter" 가 여기서는 실패합니다.

CommonJS(require(), module.exports)는 Node 의 예전 모듈 방식입니다. jsc 에는 없고 Node 는 실행하지 않았으므로 다음은 설명만 합니다. CommonJS 는 동기로 불러오고 require 시점의 module.exports 를 받으며, ESM 바인딩은 라이브입니다. Node 는 .mjs/.cjs 확장자나 package.json 의 "type" 으로 둘을 구분합니다.

흔한 실수 3: 순환 import 와 TDZ

이렇게 씁니다: 두 모듈이 서로의 상수를 최상위에서 import 합니다.

// a.mjs
import { B_NAME } from "./b.mjs";
export const A_NAME = "A";
print("a.mjs sees", B_NAME);
// b.mjs
import { A_NAME } from "./a.mjs";
export const B_NAME = "B";
print("b.mjs sees", A_NAME);      // a.mjs has not run yet

실제로 일어나는 일: a.mjs 를 시작하면 먼저 b.mjs 가 실행되는데, 그 시점에 a.mjs 의 const 줄은 아직 실행 전입니다. 바인딩은 있지만 2 강에서 본 TDZ(일시적 사각지대)에 있습니다.

$ jsc a.mjs
Exception: ReferenceError: Cannot access 'A_NAME' before initialization.
module code@~/demo/mod/b.mjs:3:27

고치는 법: 순환을 끊거나(공유 상수는 세 번째 모듈로), 가져온 값을 두 모듈이 다 끝난 뒤 실행되는 함수 안에서만 읽습니다. b.mjs 가 function hello() { return "b.mjs sees " + A_NAME; } 를 내보내고 a.mjs 가 그것을 부르게 바꾸자 a.mjs sees B | b.mjs sees A 가 출력됐습니다.

판단표

상황 쓸 것 이유
동작이 있는 데이터, 인스턴스가 많음 프로토타입 메서드를 가진 class 메서드는 하나만, 실측 40B/개
콜백으로 넘기는 메서드(UI 핸들러) 화살표 필드 또는 한 번 bind this 유지, 대신 인스턴스마다 메모리
정말 숨길 상태, 소속 검사 #private, #x in obj 접근 불가, 직렬화 안 됨
값 묶음일 뿐 객체 리터럴 클래스 불필요, JSON 왕복이 깔끔
파일 간 코드 공유 ES 모듈(여기서는 .mjs 나 -m) 라이브 바인딩, strict, 브라우저에서도 동작
"배열인가?" Array.isArray instanceof 는 realm 을 넘으면 실패
두 모듈이 서로의 상수가 필요 공유용 세 번째 모듈 TDZ ReferenceError 회피

연습 문제

  1. 실행하기 전에 출력을 예측하고 jsc 로 확인하세요. const base = { greet() { return "hi " + this.n; } }; const o = Object.create(base); o.n = "A"; const fn = o.greet; print(o.greet()); print(typeof fn.call({ n: "B" }));
  2. 흔한 실수 1 의 setter 를 고쳐서 u.name = " lee " 이면 "lee" 가 저장되고, u.name 으로 읽히고, JSON.stringify(u) 가 {"name":"lee"} 를 출력하게 하세요.

정답

  1. hi A, 그다음 string. fn.call({ n: "B" }) 는 "hi B" 를 돌려줍니다. call 이 this 를 직접 정합니다.
  2. 풀이 예(jsc 로 확인):
class User {
  #name = "";
  set name(v) { this.#name = String(v).trim(); }
  get name() { return this.#name; }
  toJSON() { return { name: this.#name }; }
}
const u = new User(); u.name = "  lee ";
print(u.name, JSON.stringify(u));   // lee {"name":"lee"}

솔직한 한계 / 버전 메모

  • 엔진 하나. 모두 macOS 26.6.2 의 JavaScriptCore 에서만 돌렸습니다. 오류 메시지는 엔진마다 다르고(Chrome·Node 의 V8 은 문구가 다름), 여기서 보인 동작은 ECMAScript 명세가 정한 것입니다.
  • 버전: class, extends, super, Symbol.hasInstance, ES 모듈은 ES2015. 동적 import() 와 import.meta 는 ES2020. public·private 필드, private 메서드, static 블록, #x in obj, Object.hasOwn, 최상위 await 는 ES2022. 모두 이 jsc 에서 동작했고, 더 오래된 엔진은 시험하지 않았습니다.
  • jsc 전용: print, fullGC, gcHeapSize, createGlobalObject, -m 옵션은 jsc 셸에만 있습니다. 메모리 수치는 JavaScriptCore 의 힙 집계라서 V8 에서는 다를 수 있습니다.
  • 여기서 실행하지 않은 것: Node.js 와 CommonJS 로더, 브라우저, <script type="module">, 번들러, TypeScript. 그쪽의 확장자 규칙과 오류 메시지는 설명만 했습니다.

이전: 3 강: 객체, 배열과 메서드 · 다음: 5 강: 비동기, Promise, 이벤트 루프

시리즈: 1 타입과 동등 비교 · 2 함수, 스코프, 클로저 · 6 성능, 메모리, 숫자 · 관련 글: Java 클래스·레코드·인터페이스 · Dart 클래스·믹스인·확장 · 프로그래밍 학습 로드맵

자주 묻는 질문

JavaScript 의 class 는 결국 함수인가요?

대부분은 그렇습니다. typeof 클래스는 "function" 이고 메서드는 클래스명.prototype 에 붙습니다. 다만 차이도 확실히 있습니다. new 없이 호출하면 "Cannot call a class constructor Point without |new|" TypeError 가 나고, 메서드가 열거되지 않아 for...in 에 안 나오며, 클래스 본문은 항상 strict 모드이고, 클래스명.prototype 은 바꿔 끼울 수 없습니다.

"undefined is not an object (evaluating 'this.count')" 는 왜 나나요?

메서드를 객체에서 떼어 내(const f = obj.inc 나 setTimeout(obj.inc, 0)) 그냥 호출했기 때문입니다. this 는 호출 방식으로 정해지므로 strict 모드인 클래스 안에서 undefined 가 됐습니다. bind, 감싸는 화살표 함수, 화살표 함수 클래스 필드로 고칩니다. 마지막 방법은 인스턴스마다 메모리가 듭니다(실측 249B 대 40B).

jsc 셸에서 ES 모듈과 require() 를 쓸 수 있나요?

ES 모듈은 됩니다. .mjs 파일이나 jsc -m 으로 실행하면 import/export, 라이브 바인딩, 동적 import(), 최상위 await 가 모두 동작했습니다. require·module·exports 는 없어서 CommonJS 는 "ReferenceError: Can't find variable: require" 로 실패합니다. Node.js 는 이 글에서 실행하지 않았습니다.

  • #javascript
  • #중급
  • #클래스
  • #프로토타입
  • #모듈
  • #jsc
  • #javascriptcore

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑