2025.01.28
한창 자바스크립트 복습을 하던 중 [object Object] 를 만났다. 개인적으로 이번 복습을 통해 자바스크립트를 구석구석 살펴보고 싶었기 때문에 정체를 알아보기로 했다. 이번 글에서는 [object Object] 가 무엇인지 알아보고, 이를 이해하기 위한 프로토타입의 동작 원리, 마지막으로 [object Object] 를 직접 바꾸고 활용하는 내용을 다뤘다.
const obj = { x: 1 };
console.log(obj); // { 'x' : 1 }
console.log(obj + ''); // [object Object]
위 코드에서 obj 를 출력했을 때는 객체의 내용을 그대로 보여준다. 하지만 문자열 ''
를 더한 경우 [object Object] 로 출력된다. 이는 객체가 암묵적으로 문자열 타입 변환이 된 결과다.
그렇다면 객체가 문자열로 변환될 때 내부적으로 어떤 일이 일어날까? 자바스크립트에서 객체가 문자열로 변환될 땐 기본적으로 객체의 toString 메서드가 호출된다. toString 메서드는 객체를 [object 타입]
형식으로 반환하기 때문에 [object Object] 라는 결과가 나온 것이다.
만약 객체를 그대로 문자열로 출력하고 싶다면, JSON.stringify
를 사용할 수 있다. 객체를 JSON 문자열로 객체 그대로 반환한다.
console.log(JSON.stringify(obj)); // { 'x' : 1 }
왜 [object Object] 가 나오는지는 알았다. 하지만 한 가지 의문이 생겼다.
toString 메서드는 어디서 온 것일까?
사실 toString 은 나에게 꽤 친숙한 메서드다. 아마 여러분들도 Java와 같은 객체지향 언어를 공부했다면 익숙할 것이다.
class Obj {}
Obj obj = new Obj();
System.out.println(obj.toString());
Java에서 toString 은 하위 클래스가 직접 구현하지 않아도 사용할 수 있는 메서드다. 이는 객체지향 프로그래밍 언어의 특징으로, 모든 클래스가 상위 클래스인 Object 로부터 toString 메서드를 상속받기 때문이다. 즉, 직접 정의하지 않고 사용하는 것이다.
자바스크립트에서도 동일하다. 자바스크립트의 객체는 프로토타입 객체를 통해 상위 객체의 속성과 메서드를 상속받는다. 따라서 객체를 생성할 때, 그 객체는 기본적으로 Object.prototype
에 정의된 toString 메서드를 상속받는다. 이를 통해 toString 을 직접 정의하지 않아도 사용할 수 있다.
자바스크립트가 지원하는 타입은 7개의 원시 타입과 object 타입까지 더해 총 8개다.
null, undefined 를 제외한 나머지 타입들은 래퍼객체를 갖고 있다. 중요한 것은, 해당 래퍼객체들이 모두 Object 의 인스턴스라는 것이다. 이는 곧, 원시 값들이 일시적으로 래퍼객체로 변환될 때, Object 의 프로퍼티에 접근할 수 있다는 말이 된다.
Boolean instanceof Object; //true
Number instanceof Object; //true
String instanceof Object; //true
Symbol instanceof Object; //true
BigInt instanceof Object; //true
undefined instanceof Object; //false
null instanceof Object; //false
그렇다면 구체적으로 어떤 과정을 통해 연결될까?
대부분의 객체는 [[Prototype]] 내부 슬롯을 가지며, 이는 프로토타입 객체의 참조를 가리킨다. 하위 객체는 프로토타입 객체에 접근하여 상위 객체의 메서드를 사용할 수 있다.
쉽게 말해, 프로토타입 객체는 상위 객체가 하위 객체에게 공유할 프로퍼티를 저장한 객체라고 보면 된다. 이때 상위 객체의 프로토타입 객체와 하위 객체를 연결하고 있는 것이 바로 prototype chain 이다.
프로토타입 체인의 끝은 Object.prototype
이다. 하위 객체에서 이를 접근하기 위해 __proto__
접근자 프로퍼티를 사용할 수 있다.
ES6 이후,
__proto__
대신,Object.getPrototypeOf
를 사용하는 것을 권장한다.
위에서 보았던 래퍼 객체들 뿐만 아니라, 빌트인 객체 또한 모두 Object 의 인스턴스이다. 프로토타입을 통해 Object 와 연결되어 있다.
Function.prototype.__proto__ === Object.prototype; // true
Array.prototype.__proto__ === Object.prototype; // true
RegExp.prototype.__proto__ === Object.prototype; // true
Promise.prototype.__proto__ === Object.prototype; // true
...
[[Prototype]] 이 참조하는 프로토타입 객체는, 해당 객체가 생성된 방식에 의해 결정된다.
Object.prototype
이다.생성자함수.prototype
이다.Object.create()
메서드로 생성된 경우, 프로토타입은 null
이다.따라서 {}
의 경우, 프로토타입은 Object.prototype
이다.
{}.__proto__ === Object.prototype; //true
좀 더 자세히 보면, 객체가 생성될 때 내부적으로 OrdinaryObjectCreate 메서드가 실행된다.
OrdinaryObjectCreate 메서드는 객체 생성 시 기본적으로 호출되는 연산이다. 객체의 [[Prototype]] 을 설정하고, 객체 내부 속성들을 초기화하는 작업을 수행한다.
객체 생성 방식마다 OrdinaryObjectCreate 의 동작과정이 모두 다르다.
알아야 할 것은, 객체 리터럴로 생성 시 OrdinaryObjectCreate(Object.prototype)
이 실행된다는 것이다. 따라서 객체 리터럴에 의해 생성되는 객체의 프로토타입은 Object.prototype
인 것이다.
프로로타입 객체는 프로토타입 체인으로 연결된다.
프로토타입 객체 내부에 toString 을 볼 수 있다.
toString 을 호출 시 프로토타입 체인으로 연결되는 과정을 자세히 보면,
Object.prototype
에 toString 이 정의되어 있다. 이것을 실행한다.오버라이딩이 가능한 것도 이러한 원리다.
const obj = {
toString() {
return 'my obj';
},
};
console.log(obj + ''); // "my obj"
지금까지 내용을 간단하게 정리하면,
결국, 실행되는 것은 Object.prototype.toString()
이다. 이 메서드에 대해 좀 더 자세히 알아보자.
Object.prototype.toString()
는 호출된 객체(this
값)가 어떤 클래스 타입인지 문자열 형식으로 알려주는 기능을 한다. [object Object] 에서 두 번째 값이 클래스 타입이다.
문서를 읽어보면 여러 클래스 타입에 대해 미리 정의된 것을 확인할 수 있다. 이러한 타입은 builtinTag
에 저장되어 있다. 해당 클래스 타입의 toString 을 호출할 때마다 위의 정의된 값들이 출력된다.
참고로, Object.prototype.toString()
을 직접 사용 시 this 값을 명시적으로 바인딩해줘야 한다. 함수가 정의된 위치를 기준으로 this는 전역객체가 되기 때문이다. 따라서 call
를 이용하자. this 를 인자값으로 참조할 수 있도록 말이다.
Object.prototype.toString(true); // '[object Object]'
Object.prototype.toString.call(true); // '[object Boolean]'
나머지 타입들도 마찬가지다.
Object.prototype.toString.call(''); // [object String]
Object.prototype.toString.call(1); // [object Number]
Object.prototype.toString.call(true); // [object Boolean]
Object.prototype.toString.call(Symbol('symbol')); // [object Symbol]
Object.prototype.toString.call(BigInt(1e10)); // [object BigInt]
Object.prototype.toString.call([]); // [object Array]
Object.prototype.toString.call({}); // [object Object]
Object.prototype.toString.call(null); // '[object Null]'
Object.prototype.toString.call(undefined); // '[object Undefined]'
문서의 16번 항목에 Symbol.toStringTag
이 언급되었다. 실제로 이 심볼 값을 변경하여 클래스 타입을 변경할 수 있다.
const myObj = {
[Symbol.toStringTag]: 'myObj',
};
Object.prototype.toString.call(myObj); // [object myObj]
이 동작은 프로토타입 객체 내에서 정의된 [Symbol.toStringTag]
를 오버라이드했기 때문에 가능하다.
실제로 이러한 활용 사례 중 하나는 Chromium
의 src/math.js
에서 찾아볼 수 있다
SetUpMath
함수를 보면 symbolToStringTag
에 "Math" 문자열을 넣은 것을 볼 수 있다. 이를 통해 Math
객체의 클래스 타입이 [object Math] 로 정의된다.
Object.prototype.toString.call(Math); // [object Math]
우연히 마주친 [object Object] 를 파고들면서 자바스크립트의 핵심 동작 원리를 깊이 이해할 수 있었다. 객체지향 프로그래밍에서 자주 사용되는 상속과 오버라이딩, this 바인딩, 그리고 프로토타입 체인의 개념을 실질적으로 살펴볼 기회가 되었다. 특히, Symbol.toStringTag
를 활용해 객체의 동작을 세밀하게 제어할 수 있다는 점이 흥미로웠다.
공부를 하면서 개인적으로 이를 어떻게 활용할 수 있을지 고민했는데, Chromium의 Math 라이브러리에서의 사용 사례를 통해 보았듯이, 라이브러리나 클래스를 설계할 때 Symbol.toStringTag
를 사용해 디버깅 편의성을 높이거나 객체의 식별성을 향상시킬 수 있겠다고 생각했다.