개발 기술/사소하지만 놓치기 쉬운 개발 지식

[JS] 내장 프로토타입에 커스텀 함수를 등록하면 안되는 이유

by GicoMomg 2024. 12. 20.

1. 들어가며…

여러 프로젝트 레포지토리를 살펴보다 보면, 드물게 보이는 코드 중 하나는 프로토타입을 이용해 전역 커스텀 함수를 선언하는 경우이다.
예를 들어, 다음과 같이 Array.prototype에 사용자 정의 메서드를 추가하는 코드를 볼 수 있다:

Array.prototype.myMethod = function() {
  console.log('This is my custom method');
};

이러한 방식으로 프로토타입에 커스텀 함수를 등록하는 데에는 몇 가지 장점이 있다.
첫째, 편리성인데 프로토타입에 메서드를 추가하면 모든 배열 인스턴스에서 해당 메서드를 사용할 수 있어, 개별적으로 함수를 정의할 필요가 없다.
둘째, 코드의 재사용성이 향상된다. 동일한 기능을 여러 곳에서 반복하지 않고, 프로토타입을 통해 한 번에 정의할 수 있다.


그러나 프로토타입에 접근해 커스텀 함수를 선언하면, 두가지 문제가 있다.
우선 첫 번째는 해당 함수를 다른 개발자가 인지하기 어렵고,
두 번째는 최신 자바스크립트 문법에서 같은 이름의 함수가 추가할 시 예상치 못한 동작을 유발할 수 있다는 점이다.

  • 그러나 개념만 알고 있지, 앞서 언급한 두 가지 문제를 직접 경험하거나 사례를 듣지 못했다.
  • 그래서 관련 정보를 찾아보던 중, "프로토타입에 접근해 커스텀 함수를 선언할 때 성능에 영향을 줄 수 있다!"는 사실을 알게 되었다!
  • 추가적으로, 프로토타입 커스텀으로 인해 성능에 영향을 줬던 SmooshGate 사건, 그리고 이 방식이 몽키패칭(Monkey Patching) 이라는 개념과 관련이 있다는 것도 알게 되었다. (이걸, 이제 알았네!!)
  • 그래서 이번 시간에는 프로토타입, 프로토타입 체인, 몽키패칭의 개념과 SmooshGate 사건, 프로토타입을 수정할 때 성능상 어떤 차이가 있는지 등을 알아보았다.



목차
1. 들어가며
2. 내장 프로토타입으로 커스텀 함수를 등록하면 안되는 이유
1) 프로토타입과 프로토타입 체인 이해하기
2) 내장 프로토타입 수정의 위험성
3) 원숭이 패칭(Monkey Patching)과 SmooshGate 사건
4) 프로토타입 변형시 발생하는 문제를 피하는 방법
3. 마치며





2. 내장 프로토타입에 커스텀 함수를 등록하면 안되는 이유

1) 프로토타입과 프로토타입 체인 이해하기

  • JavaScript는 프로토타입 기반의 객체 지향 언어로, 객체 간의 상속을 프로토타입 체인을 통해 구현한다.
  • 이 개념을 이해하기 위해선 프로토타입 객체프로토타입 체인의 알아야 한다.

(1) 프로토타입 객체와 체인

프로토타입 객체:

  • 모든 JavaScript 객체는 [[Prototype]]이라는 내부 슬롯을 가지고 있다.
  • 이 내부 슬롯은 다른 객체를 참조하며, 이 참조된 객체를 프로토타입이라고 부른다.
  • 예를 들어, 특정 객체가 어떤 속성이나 메서드를 가지고 있지 않을 경우, JavaScript는 이 객체의 프로토타입을 통해 해당 속성이나 메서드를 검색하게 된다.
  • 이러한 방식으로 객체는 자신의 프로토타입을 통해 상속받은 속성과 메서드에 접근할 수 있다.

프로토타입 체인:

  • 프로토타입 체인은 객체가 속성이나 메서드를 탐색하는 과정을 지칭한다.
  • 객체가 특정 속성을 찾으려고 할 때, 먼저 자신의 프로퍼티에 해당 속성이 있는지 확인한다.
  • 만약 해당 속성이 존재하지 않으면, JavaScript는 객체의 프로토타입을 참조하여 동일한 속성을 검색한다.
  • 이 과정은 속성을 찾을 때까지 또는 최종적으로 null에 도달할 때까지 반복된다.
  • 이러한 탐색 과정을 프로토타입 체인이라고 부르며, 이 과정을 통해 객체는 상속받은 속성과 메서드에 접근할 수 있다.
function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  console.log(`Hello, my name is ${this.name}`);
};

const alice = new Person('Alice');
alice.greet(); // "Hello, my name is Alice"
  • 위 예제에서 alice 객체는 greet 메서드를 자신의 프로퍼티로 가지지 않지만, Person.prototype을 통해 상속받아 사용할 수 있다.

예시만 봤을 때는 프로토타입에 커스텀 함수를 정의하면, 재사용성도 높고 사용하기도 편한 거 같은데..?
그럼 내장 프로토타입에도 커스텀 함수를 지정하면 좋을 거 같은데, 어떤 이유 때문에 이 방식을 피해야할까?



2) 내장 프로토타입 수정의 위험성

(1) 성능 저하

  • JavaScript 엔진(예: V8, SpiderMonkey, JavaScriptCore)은 프로토타입 체인을 효율적으로 처리하기 위해 다양한 최적화 기법을 사용한다.
  • 예를 들어, 특정 속성의 위치를 미리 알고 있으면 해당 속성에 빠르게 접근할 수 있어 성능이 향상되는 것과 같다.
  • 그러나 프로토타입을 동적으로 변경하면 이러한 최적화가 무효화된다.
  • 프로토타입이 변경되면 엔진은 다시 객체의 구조를 분석하며, 기존의 최적화된 코드를 무효화하고 재최적화를 수행해야한다.
    (아래 이미지: javascriptinfo.io의 설명 일부)


그럼 정말, 내장 프로토타입에 커스텀 함수를 등록하면 성능이 저하될까?
배열의 반복문 로직을 내장 프로토타입에 커스텀 함수로 등록해서 시간 차이를 계산해보았다.

  • 아래는 테스트에 사용했던 코드 중 일부이다.
  • 성능 비교를 위해 실행 시간은 performance.now()를 이용해 측정했다.
// 1. 프로토타입 변경 없이 객체 생성
for (let i = 0; i < iterations; i++) {
  const obj = { a: 1 };
}
let end = performance.now();

// 2. __proto__를 사용하여 프로토타입 변경
for (let i = 0; i < iterations; i++) {
  const obj = { a: 1 };
  obj.__proto__ = { b: 2 };
}

// 3. Object.setPrototypeOf를 사용하여 프로토타입 변경
for (let i = 0; i < iterations; i++) {
  const obj = { a: 1 };
  Object.setPrototypeOf(obj, { b: 2 });
}

  • 측정 결과를 보면, 프로토타입 없이 실행한 케이스는 실행 시간은 0ms이었다.
  • 그에 비해 프로토타입 수정 방식의 실행 시간이 0.699ms이었는데…
  • 프로토타입 없이 실행한 케이스 대비 시간이 약 600% 증가해 성능이 6배로 저하되었음을 알 수 있었다;; (테스트했던 코드)


(2) 호환성 문제

  • 내장 프로토타입을 수정하거나 확장하면, 다른 라이브러리나 서드파티 코드와의 충돌이 발생할 수 있다.
  • 또한 코드의 예측 가능성이 떨어지고, 디버깅이 어려워져 장기적으로 코드의 유지보수성을 저하시킨다.
  • 특히, 원숭이 패칭(Monkey Patching)은 이 문제를 심화시키는데 대표적인 사례로는 SmooshGate 사건이 있다.
    (아래 이미지: MDN docs의 상속과 프로토타입 설명 일부)


잠깐! 내장 프로토타입에 커스텀 함수를 정의하는 걸 피해야한다는 건 알겠다.
그런데, 원숭이 패칭은 무엇이고 SmooshGate 사건이 뭐길래 대표적인 사례 중 하나인걸까? 🤔
원숭이 패칭의 개념과 SmooshGate 사건에 대해 간략히 알아보자!



3) 원숭이 패칭(Monkey Patching)과 SmooshGate 사건

(1) 원숭이 패칭이란?

  • 원숭이 패칭은 기존에 존재하는 객체나 함수의 동작을 런타임 단계에서 수정하는 걸 의미한다.
  • 마치 원숭이가 기존의 코드를 몰래 바꿔 놓는 것처럼, 기존 코드의 흐름을 변경하기에 이런 이름이 붙었다.
  • 특히, 내장된 프로토타입을 확장하는 방식이 자주 사용되는데, 이는 전역적으로 모든 객체의 동작에 영향을 미칠 수 있다.

(2) 원숭이 패칭의 예

  • 다음은 Array.prototype에 새로운 메서드를 추가하는 예제이다:
// Array.prototype에 새로운 메소드 'sum' 추가
Array.prototype.sum = function() {
  return this.reduce((acc, cur) => acc + cur, 0);
};

// 사용 예시
const numbers = [1, 2, 3, 4, 5];
const result = numbers.sum();
console.log(result); // 15
  • 위와 같이 Array.prototypesum를 추가하면, 모든 배열 인스턴스에서 sum를 사용할 수 있게 된다.
  • 코드만 보았을 때는 유용해 보이지만, 여러 가지 문제를 내포하고 있다….!

(3) 원숭이 패칭의 문제점

  • JavaScript에 새로운 메서드나 기능이 내장 객체에 추가될 수 있는데, 만약 개발자가 기존에 추가한 메서드와 이름이 충돌하게 되면,
    예상치 못한 동작이 발생할 수 있다.
  • 또한, 다른 라이브러리나 서드파티 코드가 동일한 메서드 이름을 사용할 경우, 충돌이 발생하여 예기치 않은 동작을 초래할 수 있다.
  • 원본 기능이 변경되면, 버그를 추적하고 수정하는 과정이 복잡해진다. 특히, 여러 라이브러리가 동일한 메서드를 수정하려고 할 때,
    어떤 변경이 문제가 되는지 파악하기 어렵다.
  • 프로토타입 체인을 변경하면, JavaScript 엔진의 최적화가 무효화되어 성능에 부정적인 영향을 미칠 수 있다.

(4) SmooshGate 사건: 원숭이 패칭의 실질적 문제

  • SmooshGate 사건은 원숭이 패칭이 웹 개발에 영향을 준 사례 중 하나이다.
  • 사건의 시작은 Array.prototype.flatten 이었다.
  • 원래 Array.prototype.flatten은 지정된 depth(기본값: 1)까지 배열을 재귀적으로 평면화했다.
const array = [1, [2, [3]]];
array.flatten();
// → [1, 2, [3]]

  • Firefox Nightly에서 Array.prototype.flatten을 도입하면서, MooTools는 자체 비표준 버전의 Array.prototype.flatten을 정의하고 있었다.
  • 그런데, 브라우저가 네이티브 flatten을 제공하게 되면서 MooTools가 이를 재정의하면서 기존 웹사이트의 동작에 문제가 발생했다.
  • MooTools는 모든 맞춤 배열 메서드를 Elements.prototype에 복사하는 방식을 사용했다:
for (var key in Array.prototype) {
  Elements.prototype[key] = Array.prototype[key];
}

이 과정에서 MooTools는 Array.prototype.flatten을 열거 가능한 속성으로 만들었다.
그런데, 브라우저가 네이티브 flatten(지금의 flat 함수)을 제공하는 경우 MooTools의 flatten이 덮어쓰어지면서,
Elements.prototype.flatten이 복사되지 않게 되었다.

  • 그 결과, Array.prototype.flatten의 도입으로, MooTools를 사용하는 웹사이트들이 정상적으로 동작하지 않게 되었다.
  • 그래서 이 문제를 해결하기 위해 flatten의 이름을 flat으로 변경하였고, 이는 공식적으로 TC39 회의에서 승인되어 문제를 해결했다.
  • 자세한 이야기를 보고 싶다면 SmooshGate FQA 문서를 참고하자.



4) 프로토타입 변형시 발생하는 문제를 피하는 방법

  • 앞서 우리는 내장 프로토타입을 변형했을 때 발생하는 문제와 대표적인 사례를 살펴보았다.
  • 그럼 만약 내장 프로토타입 대신 사용할 수 있는 방법은 없을까? 3가지 방법을 알아보았다 🙂

(1) 유틸리티 함수 만들기

  • 내장된 프로토타입을 확장하지 않고, 필요한 기능은 별도의 유틸리티 함수나 클래스를 통해 구현한다.
  • 그럼 호환성 문제를 피하고, 코드의 예측 가능성을 높여준다.
// 수정 전: 원숭이 패칭

Array.prototype.myMethod = function() {...};

const arr = [1, 2, 3];
arr.myMethod()
// 수정 후: 유틸리티 함수 사용

function myMethod(array) {...}

const arr = [1, 2, 3];
myMethod(arr)

(2) 네임스페이스 활용하기

  • 글로벌 네임스페이스를 오염시키지 않도록, 특정 네임스페이스 내에서 기능을 구현한다.
// 수정 전: 원숭이 패칭

Array.prototype.myMethod = function() {...};

const arr = [1, 2, 3];
arr.myMethod()
// 수정 후: 네임스페이스 사용
const MyLibrary = {
  myMethod: function(array) {...}
};

const arr = [1, 2, 3];
MyLibrary.myMethod(arr); 

(3) 클래스로 선언하기

  • 클래스를 사용해 모듈로 분리한다.
// 수정 전: 원숭이 패칭

Array.prototype.myMethod = function() {...};

const arr = [1, 2, 3];
arr.myMethod()
// 수정 후: 클래스 사용

class AdvancedArray {
  constructor(arr) {
    this.arr = arr;
  }

  myMethod() {...}
}

const arr = [1, 2, 3];
const advancedArray = new AdvancedArray(arr);
advancedArray.myMethod();



3. 마치며…

이번 시간에는 SmooshGate 사건과 원숭이 패칭(Monkey Patching)을 통해 내장 프로토타입 수정시 발생하는 문제를 살펴보았다. 문제는 총 3가지로 요약할 수 있었다:

  1. 성능 저하: JavaScript 엔진의 최적화가 무효화되면서 객체의 속성 접근 속도가 저하되고, 재최적화를 반복적으로 수행해야 하는 부담이 발생한다.
  2. 호환성 문제: 내장 프로토타입 수정으로 인해 다른 라이브러리나 서드파티 코드와의 충돌이 발생하며, 코드 예측 가능성이 떨어진다.
  3. 유지보수의 어려움: 코드의 일관성이 손상되고 디버깅이 복잡해지며, 장기적으로 시스템의 안정성과 유지보수성을 저하시킨다.

이에 대한 대안으로는 유틸리티 함수 만들기, 네임스페이스 활용하기, 클래스로 선언하기와 같은 방법이 있었다.
물론 프로토타입을 직접 수정하면 단기적으로는 편리해 보일 수 있지만, 성능, 호환성, 유지보수 측면에서 심각한 문제를 초래할 수 있기에 사용에 주의하는 걸 추천한다 🙂



반응형

댓글