Object.create()와 프로토타입
지난 글에서 프로토타입 체인의 기본 동작 원리를 살펴봤습니다. 이번에는 한 걸음 더 들어가서, new 없이 프로토타입 상속을 구현하는 Object.create()와, 프로토타입 체인이라는 구조 자체가 만들어내는 보안 취약점인 프로토타입 오염을 다뤄보겠습니다. 둘은 언뜻 관계없어 보이지만, 사실 같은 동전의 양면입니다 — 프로토타입 체인을 "의도적으로 설계"하면 Object.create()가 되고, "의도치 않게 오염"되면 취약점이 됩니다.
1. new 없이 상속하기: Object.create()
new Person()은 익숙하지만, 사실 프로토타입 상속을 만드는 방법은 하나가 아닙니다. Object.create()는 생성자 함수도, class도 거치지 않고 프로토타입 체인을 직접 연결합니다.
const animalPrototype = {
speak() {
console.log(`${this.name}이(가) 소리를 냅니다.`);
},
};
const dog = Object.create(animalPrototype);
dog.name = "초코";
dog.speak(); // "초코이(가) 소리를 냅니다."
여기엔 생성자 함수도, new도, this 바인딩을 위한 call도 없습니다. 그냥 "이 객체의 프로토타입은 저 객체다"라고 선언했을 뿐입니다. 이걸 **순수 프로토타입 패턴(prototypal pattern)**이라고 부릅니다. new가 감춰버리는 몇 가지 절차(암묵적 this 생성, 프로토타입 연결, 반환)를 거치지 않기 때문에 상속 구조가 훨씬 명시적으로 드러납니다.
new Person()과 무엇이 다른가
function Person(name) {
this.name = name;
}
const a = new Person("A");
const b = Object.create(Person.prototype);
b.name = "B";
두 방식 모두 __proto__가 Person.prototype을 가리킨다는 점은 동일합니다.
console.log(a.__proto__ === Person.prototype); // true
console.log(b.__proto__ === Person.prototype); // true
차이는 생성자 함수를 거치느냐입니다. new는 함수 호출과 동시에 프로퍼티 초기화 로직(this.name = name)까지 실행하지만, Object.create()는 프로토타입 연결만 담당하고 프로퍼티 채우기는 별도로 처리해야 합니다. 그래서 초기화 로직 없이 순수하게 "이 객체를 원형으로 삼는 객체를 만들고 싶다"는 의도가 명확할 때 더 적합합니다.
실무에서 언제 쓰게 될까
가장 흔한 사례는 의도적으로 프로토타입이 없는 객체를 만들 때입니다.
const dict = Object.create(null);
dict.toString = "누군가의 이름";
console.log(dict.toString); // "누군가의 이름" — 정상 동작
일반 객체 리터럴 {}은 사실 Object.prototype을 상속받기 때문에 toString, hasOwnProperty 같은 이름이 이미 예약되어 있는 셈입니다. 사용자 입력을 키로 받아 저장하는 순수한 맵(dictionary) 용도로 객체를 쓴다면, Object.create(null)로 프로토타입 체인 자체를 끊어버리는 것이 안전합니다. 상속받은 메서드와 이름이 충돌할 걱정이 없어지기 때문입니다.
2. 프로토타입 오염이란 무엇인가
프로토타입 체인은 "모든 객체가 공유하는 참조 구조"입니다. 그런데 이 구조가 가진 강력함은 곧 위험이기도 합니다. Object.prototype처럼 최상위 체인에 속성을 하나 추가하면, 그 순간부터 애플리케이션 전역의 모든 객체가 영향을 받습니다.
const obj = {};
obj.__proto__.isAdmin = true;
const anotherObj = {};
console.log(anotherObj.isAdmin); // true — 전혀 관계없는 객체인데도!
이게 프로토타입 오염(prototype pollution)의 핵심 원리입니다. 공격자가 직접 __proto__.isAdmin = true를 쓰는 경우는 드물지만, 사용자 입력을 재귀적으로 병합(merge)하거나 깊은 복사(deep clone)하는 유틸 함수에 이 입력을 그대로 흘려보내면 같은 결과가 발생합니다.
취약한 코드 패턴
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, userInput);
const freshObject = {};
console.log(freshObject.isAdmin); // true
이 merge 함수는 key가 __proto__인지 검증하지 않습니다. 그래서 source에 __proto__ 키가 들어오면, target[key]가 실제 데이터 프로퍼티가 아니라 프로토타입 체인 자체를 가리키게 되고, 결국 Object.prototype을 직접 조작하게 됩니다. JSON.parse로 받은 사용자 입력을 그대로 merge, extend, deep-clone 계열 함수에 넘기는 코드에서 실제로 자주 발견되는 패턴입니다.
왜 위험한가
전역 객체 하나가 오염되면 파급 범위가 애플리케이션 전체입니다.
- 인증/인가 로직에서 isAdmin, isVerified 같은 플래그를 체크하는 코드가 있다면, 이 값을 프로토타입 단에서 덮어써서 우회할 수 있습니다.
- 서버 사이드에서는 요청 하나의 오염이 프로세스가 살아있는 동안 다른 사용자의 요청에도 영향을 줄 수 있습니다 (Node.js는 요청마다 프로세스를 새로 띄우지 않으므로).
- 서비스 거부(DoS)로 이어지는 경우도 있습니다. 라이브러리 내부 로직이 의존하는 프로퍼티를 덮어써서 애플리케이션 전체가 예외를 던지게 만들 수 있습니다.
실제로 lodash의 merge, defaultsDeep 등에서 이런 유형의 취약점(CVE)이 보고된 바 있고, 이후 라이브러리들은 __proto__, constructor, prototype 같은 키를 명시적으로 차단하는 방어 코드를 추가했습니다.
3. 어떻게 막을 것인가
위험한 키 차단
가장 직접적인 방어는 병합/복사 로직에서 위험한 키를 걸러내는 것입니다.
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (DANGEROUS_KEYS.has(key)) continue;
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
for...in 대신 Object.keys()를 쓴 것도 눈여겨볼 부분입니다. Object.keys()는 객체 자신의 열거 가능한 키만 반환하기 때문에, 프로토타입 체인을 타고 올라가며 의도치 않은 키까지 순회하는 상황 자체를 줄여줍니다.
Object.create(null)로 오염 자체를 차단
사용자 입력을 담는 객체라면, 애초에 프로토타입 체인을 없애버리는 것도 방법입니다.
const safeDict = Object.create(null);
이 객체는 __proto__라는 이름의 프로퍼티 접근 자체가 Object.prototype으로 연결되지 않으므로, 애초에 오염 통로가 존재하지 않습니다.
Object.freeze(Object.prototype)
극단적이지만 강력한 방법으로, Object.prototype 자체를 동결시켜 어떤 코드도 여기에 속성을 추가하지 못하게 막는 방식도 있습니다. 다만 이건 애플리케이션 전역에 영향을 주는 조치라, 서드파티 라이브러리가 내부적으로 Object.prototype을 확장하는 방식에 의존하고 있지 않은지 충분히 검토한 뒤 적용해야 합니다.
Map 사용 고려
키-값 저장이 목적이라면, 애초에 일반 객체 대신 Map을 쓰는 것도 근본적인 해결책입니다. Map은 프로토타입 체인과 무관하게 키를 저장하기 때문에 __proto__라는 문자열도 그냥 평범한 키 하나로 취급됩니다.
const safeMap = new Map();
safeMap.set("__proto__", { isAdmin: true }); // 그냥 데이터일 뿐, 아무 일도 일어나지 않음
4. 정리
개념 핵심 실무 포인트
| Object.create() | 생성자 함수 없이 프로토타입 체인을 직접 연결 | Object.create(null)로 안전한 딕셔너리 객체 생성 가능 |
| 프로토타입 오염 | 전역으로 공유되는 Object.prototype을 사용자 입력으로 조작 | merge/clone류 함수에서 __proto__, constructor 키 검증 필수 |
| 방어 전략 | 키 화이트리스트, Object.create(null), Map 활용 | 신뢰할 수 없는 입력을 다루는 유틸 함수는 반드시 점검 |
프로토타입 체인은 자바스크립트를 유연하게 만들어주는 구조이지만, 그 유연함이 그대로 공격 표면이 되기도 합니다. Object.create()로 체인을 직접 설계해본 경험이 있으면, 반대로 그 체인이 어떻게 오염될 수 있는지도 훨씬 직관적으로 이해가 됩니다. 두 개념을 함께 정리해두면, "프로토타입 오염이 뭔가요?"라는 질문에도 원리부터 방어 전략까지 막힘없이 답할 수 있을 겁니다.
'Frontend' 카테고리의 다른 글
| [Frontend] 디바운싱과 쓰로틀링 (0) | 2026.07.15 |
|---|---|
| [Frontend] 프로토타입 알아보기 (0) | 2026.07.15 |
| [Frontend] Redux 비동기 관리 정리 (0) | 2026.07.14 |
| [Frontend] Production에서 만나는 Edge Case (0) | 2026.07.14 |
| [Frontend] useTransition과 Error Boundary가 만나면? (0) | 2026.07.14 |