1주일 전 한 크루가 이런 질문을 했다. ‘피터! 이거 URLSearchParams를 콘솔에 찍으면 size밖에 안뜨는데, 왜 Object.fromEntries()를 하면 많은 속성이 뜨는거에요?’ 그렇게 나와 그 크루는 신기해하며 30분 동안 다양한 실험들을 해보았다.
그리고 실험한 내용을 정리하며 JavaScript의 코어한 지식들이 많이 얽혀있다는걸 깨닫고 이를 정리해보고자 글을 작성한다.
사건의 발단
JavaScript에는 URLSearchParams라는, 쿼리 스트링을 다룰 수 있는 객체를 제공한다. 아래와 같이 인스턴스를 만들어 쿼리 문자열을 다룰 수 있다.
const params = new URLSearchParams('name=peter&age=20');
console.log(params.get('name')); // 'peter'
params.set('age', 25));
console.log(params.get('age')); // 25
console.log(Object.fromEntries(params)); // {name: 'peter', age: '25'}
그런데 사건은 여기서 시작된다.
console.log(params); // URLSearchParams {size: 2}
콘솔에 params를 찍으면 size프로퍼티 하나만 가지고 있는 거다. get과 set 메서드는 어디갔지? 아무것도 없는데 Object.fromEntries()를 실행하니 어떻게 프로퍼티가 나타난거지?
탐구 시작
하나씩 살펴보자.
- 우리가 params를 통해 접근하는 get,set 메서드는 params인스턴스를 생성한 URLSearchParams객체의 프로토타입 메서드이다.
console.log(params.__proto__ === URLSearchParams.prototype) // true
__proto__는 자신의 부모 프로토타입을 참조할 때 사용하는 접근자 프로퍼티이다. 우리가 params.get(’age’)와 같이 메서드를 실행할 때, 중간에 __proto__를 생략해 실행하는 것과 같다. 이것이 바로 프로토타입의 묘미이다.
위와 같이 URLSearchParams의 프로토타입에는 params를 통해 접근한 get, set 메서드를 포함한 다양한 메서드가 있는 것을 확인할 수 있다.
정리하면 우리가 params를 통해 사용한 get, set 등의 메서드는 params의 메서드가 아니라 URLSearchParams객체의 메서드이기에 콘솔을 찍었을 때 열거되지 않은 것이다. 어찌보면 당연한 것이기도 하다. 마치 배열을 만들어 콘솔에 찍었을 때, 배열 메서드가 모두 나타나는게 아니라 요소를 담은 배열만 나타나는 것처럼.
- URLSearchParams는 이터러블 객체이다.
이터러블 객체란 이터러블 프로토콜을 준수하는, 순회 가능한 객체이다. JavaScript에서는 Symbol.iterator 메서드를 통해 이터러블을 구현한다. Symbol.iterator메서드를 구현한 이터러블 객체는 for...of문으로 순회를 하거나 전개 연산자를 사용할 수 있다.
위 URLSearchParams.prototype을 다시 살펴보면 Symbol.iterator가 선언되어있음을 확인할 수 있다. 그럼 이제 이터레이터를 이용해 직접 순회해보자.
const params = new URLSearchParams('name=peter&age=20');
const iter = params[Symbol.iterator]();
console.log(iter.next().value); // ['name', 'peter']
console.log(iter.next().value); // ['age', '20']
console.log(iter.next().value); // undefined
이렇게 직접 순회하지 않고 JavaScript에서 이터러블 객체를 for..of로 순회할 수도 있다.
const params = new URLSearchParams('name=peter&age=20');
for(const it of params){
console.log(it); // ['name', 'peter'], ['age', '20']
}
물론 전개 연산자도 사용할 수 있다.
const params = new URLSearchParams('name=peter&age=20');
console.log([...params])
// Array(2)
// 0: (2) ['name', 'peter']
// 1: (2) ['age', '20']
자, 이제 글 첫머리의 미스터리가 풀린다. Object.fromEntries(params)가 왜 되냐고? fromEntries는 [키, 값] 쌍을 뱉는 이터러블을 받아 객체로 만드는 메서드인데, 방금 봤듯이 URLSearchParams가 바로 그 이터러블이기 때문이다. [...params]로 펼쳐지는 것과 똑같은 원리로, fromEntries도 내부에서 params를 순회하며 ['name','peter'], ['age','25']를 하나씩 꺼내 객체로 조립하는 것이다. 콘솔엔 size만 보였지만, 실제 데이터는 이터러블을 통해 이렇게 멀집히 꺼낼 수 있었던 거다.
- 내부 값이 보이고 안보이고는 런타임 환경의 차이이다.
- 브라우저 - 개발자 도구
2. Node.js
3. Deno
맺으며
정리하면 원인은 결국 3번, 런타임 환경 차이였다. 브라우저의 개발자 모드의 콘솔 환경에서는 Node.js에서의 환경과 URLSearchParams의 정보를 보여주는 형식이 다른 것 뿐이었다.
근데 한 발만 더 들어가 보자. ‘런타임 환경 차이’라고 했지만, 정확히는 각 런타임이 객체를 콘솔에 그려주는 방식(콘솔 포매터)이 다른 거다. 브라우저 개발자 도구에는 Chrome DevTools 나름의 커스텀 포매터가 있고, Node.js에는 util.inspect라는 함수가 그 역할을 한다. 이 포매터들이 URLSearchParams를 받았을 때 ‘무엇을 보여줄지’ 각자 다르게 정해둔 것이다.
그럼 왜 하필 size만 보였을까? URLSearchParams가 실제로 담고 있는 키-값 데이터는 내부 슬롯이라는, 명세상 숨겨진 저장 공간에 들어있다. 이 내부 슬롯은 일반 프로퍼티가 아니라서 for...in으로도, Object.keys()로도 열거되지 않는다. 반면 size는 프로토타입에 정의된 평범한 getter 프로퍼티다. 그러니 콘솔 포매터 입장에서 ‘열거 가능한 것만 대충 보여주자’고 하면 실제 데이터는 다 숨고 size만 덩그러니 남는 것이다. 결국 데이터가 없어서가 아니라, 데이터가 열거 불가능한 곳에 숨어있어서 벌어진 일이었다.
1주일 전 한 크루의 질문 덕분에 평소였으면 무심코 지나갔을 것을 프로토타입, 이터러블 객체와 같이 다양한 관점에서 URLSearchParams객체에 대해 알아보았다. 덕분에 자바스크립트의 핵심 개념을 복습하고 이해할 수 있었다.
의문점은 항상 해소했을 때의 쾌감이 있다. 앞으로도 어떤 현상에 대해 항상 의문을 가지고, 하나씩 해소하는 개발자가 되어야겠다고 생각했다.
그리고 이번 연휴가 끝나면 그 크루에게 가서 신나게 설명해줘야지.