JavaScript · 8장 프로토타입
내장 프로토타입과 프로토타입 오염
앞 글에서 모든 배열이 Array.prototype을, 모든 객체가 Object.prototype을 함께 쓴다고 봤어요. "함께 쓴다"는 건 편리하지만 위험하기도 해요. 그곳을 한 번 고치면 앱 안의 모든 객체가 바뀌기 때문이에요. 실무에서 이 성질이 문제가 되는 세 장면을 짧게 정리해요.
01. 내장 프로토타입에 메서드를 더하지 않아요
Array.prototype에 메서드를 더하면 모든 배열에서 바로 쓸 수 있어요. 편해 보이지만, 실무에서는 하지 않는 게 규칙이에요.
Array.prototype.last = function () { return this[this.length - 1]; };
console.log([1, 2, 3].last()); // 3 — 동작은 해요
const keys = [];
for (const k in [7, 8]) keys.push(k);
console.log(keys); // ['0', '1', 'last'] — 엉뚱한 곳에 섞여 나와요
- 앞으로 나올 표준과 부딪혀요. 예전에 한 라이브러리가
Array.prototype.flatten을 직접 만들어 둔 탓에, 표준에 같은 기능을 넣을 때 웹사이트가 깨질까 봐 이름을flat으로 바꿔야 했어요. 직접 만든last도 언젠가 표준의 같은 이름과 동작이 다를 수 있어요. - 다른 코드와 부딪혀요. 두 라이브러리가 같은 이름을 다르게 만들면, 나중에 불러온 쪽이 이겨서 다른 쪽이 조용히 망가져요.
- 예상 밖의 곳에 나타나요. 위처럼
for...in에 섞여 나오고, 코드를 읽는 사람은last가 어디서 왔는지 찾기 어려워요.
필요한 기능은 평범한 함수(last(arr))로 만들거나 이미 있는 표준(arr.at(-1))을 써요. 내장 프로토타입을 고쳐도 되는 유일한 경우는 표준에 이미 있는 기능을 오래된 브라우저에 채워 넣는 폴리필이고, 그것도 직접 짜기보다 검증된 도구에 맡겨요(폴리필).
02. 사전으로 쓸 객체는 Object.create(null)
보통 객체 {}는 Object.prototype을 물려받아서 toString, constructor, hasOwnProperty 같은 이름이 이미 있는 것처럼 읽혀요. 사용자가 입력한 단어 · 태그 · 아이디처럼 어떤 문자열이든 키가 될 수 있는 곳에 {}를 쓰면, 이 이름들과 부딪혀요.
const words = ['js', 'constructor', 'js', 'toString'];
const counts = {};
for (const w of words) counts[w] = (counts[w] || 0) + 1;
console.log(counts.js); // 2
console.log(counts.constructor); // 1이 아니라 이상한 글자 — 물려받은 함수에 1을 더했어요
const safe = Object.create(null); // 아무것도 물려받지 않는 객체
for (const w of words) safe[w] = (safe[w] || 0) + 1;
console.log(safe.constructor, safe.toString); // 1 1
console.log('hasOwnProperty' in safe); // false — 물려받은 이름이 없어요
Object.create(null)로 만든 객체는 프로토타입이 null이라 물려받은 이름이 하나도 없어요. 대신 obj.hasOwnProperty()도 없으니 확인은 Object.hasOwn(obj, key)로 해요. 키가 계속 늘고 주는 사전이라면 처음부터 Map(객체와 Map)을 쓰는 게 가장 깔끔해요.
03. 프로토타입 오염: 모든 객체가 바뀌는 보안 문제
설정을 저장하거나 요청 본문을 기본값과 합칠 때, 받은 JSON을 객체에 깊게 합치는 함수를 흔히 써요. 그런데 받은 JSON에 "__proto__"라는 키가 들어 있으면, 합치는 코드의 target['__proto__']는 새 칸이 아니라 Object.prototype 자체를 가리켜요. 그 안에 값을 써 넣는 순간 앱의 모든 객체가 그 값을 물려받아요. 이것을 프로토타입 오염이라고 해요.
function deepMerge(target, source) { // 흔히 보이는 깊은 합치기
for (const key in source) {
const value = source[key];
if (value && typeof value === 'object') target[key] = deepMerge(target[key] ?? {}, value);
else target[key] = value;
}
return target;
}
// 사용자가 보낸 요청 본문이라고 해요
const body = JSON.parse('{"theme": "dark", "__proto__": {"isAdmin": true}}');
console.log(Object.keys(body)); // ['theme', '__proto__'] — 여기까지는 그냥 키예요
const settings = deepMerge({}, body);
const guest = {}; // 아무 관계 없는 새 객체
console.log(guest.isAdmin); // true — 모든 객체가 관리자가 됐어요
실제로 여러 유명 라이브러리의 합치기 · 경로 설정 함수에서 이 문제가 보안 취약점으로 보고됐어요. "if (user.isAdmin)" 같은 검사가 모두 통과되거나, 서버가 멈출 수 있어요. 막는 방법은 여러 겹으로 둬요.
- 위험한 키를 건너뛰어요. 합치기 · 복사 함수에서
__proto__·constructor·prototype키를 무시해요(아래 과제). - 받는 그릇을 바꿔요. 받은 데이터를 담는 객체를
Object.create(null)이나Map으로 만들면 오염될 프로토타입이 없어요. - 모양을 검사해요. 받은 JSON은 기대한 키와 종류만 꺼내 써요. 서버에서는 스키마 검사 도구로 걸러요.
- 검증된 함수를 써요. 직접 만든 깊은 합치기보다, 이 문제를 막아 둔 최신 라이브러리나
structuredClone· 펼치기(얕은 합치기)를 써요.
위의 deepMerge를 프로토타입 오염이 일어나지 않게 고쳐 보세요. 정상 키는 그대로 합쳐야 해요. dark undefined가 나와야 해요.
function deepMerge(target, source) {
for (const key in source) {
const value = source[key];
if (value && typeof value === 'object') target[key] = deepMerge(target[key] ?? {}, value);
else target[key] = value;
}
return target;
}
const body = JSON.parse('{"theme": "dark", "__proto__": {"isAdmin": true}}');
const settings = deepMerge({}, body);
console.log(settings.theme, ({}).isAdmin);
해답 보기
자기 키만 도는 Object.keys로 바꾸고, 프로토타입에 닿을 수 있는 세 이름을 건너뛰어요. 받은 데이터를 담을 새 객체를 Object.create(null)로 만들면 한 겹 더 안전해요.
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED.has(key)) continue;
const value = source[key];
if (value && typeof value === 'object') target[key] = deepMerge(target[key] ?? {}, value);
else target[key] = value;
}
return target;
}
// dark undefined
04. 실전 포인트
- 언제 쓰나요? 사용자 입력이 키가 되는 사전은
Map이나Object.create(null), 받은 JSON을 합칠 땐 위험한 키를 거르는 함수나 검증된 라이브러리를 써요. 자기 속성 확인은 늘Object.hasOwn이에요. - 대신 쓰는 것: 내장 프로토타입에 더하고 싶은 기능은 평범한 함수나 유틸 모듈로 만들어요. 표준 기능을 오래된 브라우저에 채우는 일은 폴리필 도구에 맡겨요.
- 흔한 실수:
Array.prototype·String.prototype에 메서드를 더하는 것, 사용자 입력을{}의 키로 쓰는 것, 받은 JSON을for...in으로 깊게 합치는 것,obj.hasOwnProperty(key)를 그대로 믿는 것(이름 자체가 덮어써질 수 있어요)이에요.
정리
- 내장 프로토타입은 모든 객체가 함께 써요. 고치면 앱 전체가 바뀌니 더하지 않아요.
{}는toString·constructor같은 이름을 물려받아요. 임의의 키를 담는 사전은Object.create(null)이나Map을 써요.- 받은 JSON의
__proto__키를 합치면Object.prototype이 오염돼 모든 객체가 바뀌어요. - 위험한 키 걸러내기 · 프로토타입 없는 그릇 · 모양 검사로 여러 겹 막아요.
Array.prototype.last를 직접 만들면 안 되는 이유 두 가지는?
앞으로 표준에 같은 이름이 생기면 동작이 달라 부딪힐 수 있고, 다른 라이브러리와 이름이 겹치거나 for...in 같은 곳에 섞여 나오기 때문이에요. 평범한 함수나 arr.at(-1)을 써요.
단어 개수를 세는 객체를 {}로 만들면 "constructor"라는 단어에서 무슨 일이 생길까요?
counts.constructor가 처음부터 물려받은 Object 함수를 가리켜서, 1이 아니라 이상한 값이 돼요. Object.create(null)이나 Map으로 세요.
프로토타입 오염이 일어나면 왜 관계없는 객체까지 영향을 받을까요?
값이 개별 객체가 아니라 모든 객체가 물려받는 Object.prototype에 써지기 때문이에요. 자기 칸에 없는 속성은 체인을 따라 거기서 읽히니, 새로 만든 {}까지 그 값을 읽어요.