본문 바로가기

3.구현/HTML5&Javascript

[Javascript] Javascript의 prototype에 대한 고찰

들어가기

Javascript는 웹환경에서 핵심 기술로 이미 잘 알려진 프로그래밍 언어이다. javascript는 일급 함수을 가진 가벼운 인터프리터이다. javascript는 prototype 기반이고, 다중 패러다임, 싱글 쓰레드, 동적 언어이며 객체지향, 명령형, 선언형 스타일을 지원한다.

대부분은 javascirpt을 스크립트 언어로 이벤트 기반으로 객체지향 언어처럼 사용하고 있다. 여기서 prototype 기반이라는 의미를 다시 생각볼려고 한다. 물론 다양한 패러다임이 있기 때문에 다른 형태로 비슷하게 작성할 수 있다.

이글의 목적은 javascript에서 prototype에 대해 생각해보는 기회를 가져볼려고 한다.

작성자: ospace114@empal.com, http://ospace.tistory.com/

prototype-based?

ECMAScript 표준에 따르면 객체지향 프로그래밍 언어라고 한다. 다른 곳에 설명에서는 prototype 기반으로 되어 있다. 넓게 보면 prototype도 객체지형 언어에 포함되기 때문에 OOP(Object-oriented programming)라고 말할 수 있다. 재사용성을 위해 기본적인 캡슐화나 상속 비슷한 개념이 포함되어 활용하고 있기 때문이다.

prototype 기반은 복제와 확장으로 객체를 확장하는 구조이다. 그렇기에 클래스가 없고 복제 과정과 함수를 정의 통해서 기능을 구현하고 확장한다. 객체의 동적인 특성으로 인해 쉽게 수정과 확장이 가능하다.

일반적인 OOP는 클래스라는 정적인 틀이 있고 이를 통해 객체를 생성하지만, prototype 프로그래밍에서는 객체를 복제하여 새로운 객체를 생성하는 형태이다. 객체를 생성하고 필요한 동작과 속성을 추가하고, 다시 이를 기반으로한 새로운 객체를 생성해서 동작과 속성을 추가해 확장을 지속해나간다.

확장 방법을 수평적 확장 방법과 수직적 확장 방법으로 나눌 수 있다. 수평적 확장 방법은 객체를 복제하고 기능을 추가하는 방식이고, 수직적 확장 방법은 객체를 생성하는데 기존 객체를 prototype으로 만든다. 이는 새로 생성된 객체가 기능이 없으면 prototype에 있는 객체에서 기능을 찾는다. 상속과 비슷한 개념이다. 체인룰 처럼 계속해서 prototype 객체의 prototype을 찾아간다.

지금의 javascript은 어쩌면 더이상 prototype 언어라고는 말할 수 없을지도 모른다. 현대 프로그래밍 언어가 대부분 멀티 패러다임을 포함하고 있기 때문에 javascript도 그런 방향으로 진행하고 있다.

객체(Object) 생성

OOP 관점에서 객체는 클래스가 인스탄화한 결과이다. Javascript에서는 OOP에서 클래스 개념은 없기 때문에 인스탄스라고는 할 수 없고, Javascript에서는 인스탄스보다 객체 생성이라고 표현한다. 그냥 일반적인 객체로서 데이터와 함수를 가지며, 보통 프로퍼티와 메소드라고도 한다.

객체를 생성하는 방법은 여러가지가 있다.

let o1 = {}; // (1)
let o2 = Object.create(null); // (2)
function Foo() { /*중략*/ }
let o3 = new Foo(); // (3)

(1)은 javascript 구분에 의해 바로 객체를 생성하는 표현법이다. (2)은 create() 함수에 의해서 객체를 생성한다. (3)은 함수를 통해서 객체를 생성한다. 함수가 생성자 역할과 비슷하다. 이외에도 객체를 만들 있는 다양한 방법이 있다.

그리고 다른 객체 생성 방법으로 복제가 있다. 복제에는 얕은 복제(shallow copy)와 깊은 복제(deep clone)가 있다. 완벽한 복제를 위해서는 깊은 복제가 필요한데 성능상 이슈가 있다. Javascript에서는 얕은 복제만 지원하고 깊은 복제는 직접 작업해야한다.

let foo = { name: 'foo' };
let foo_clone1 = Object.assign({}, foo1);
let foo_clone2 = { ... }

깊은 복사 예이다.

let foo = {name: 'foo', items:[1,2,3]};
let foo_clone1 = JSON.parse(JSON.stringify(foo));

깊은 복사에서 한가지 주의할 부분은 순환 참조가 발생할 경우이다. 그렇기 때문에 직접 구현하거나 다른 라이브러리를 사용하는게 안전하다. 좋은 소식은 공식적인 API로 객체를 복제할 있는 함수인 structuredClone()를 제공하고 있다. 브라우저 버전에 따라 지원하는지 확인이 필요하다. 앞에 깊은 복사에서 한가지 주의할 부분은 메소드는 복제가 안된다.

Prototype-based

디자인 패턴에서 프로토타입 패턴이 있다. 이 패턴은 한 객체가 프로토타입이 되어서 해당 객체를 복제해서 사용하는 방식이다. 즉, 실행하는 과정에서 객체에서 다른 객체를 생성한다. 패턴에서는 새로운 객체로 만드는 것으로 끝나지만, 프로토타입 언어에서는 원본과 복사본 간에 관계가 생긴다. 이런 관계가 단방향일 수 있고, 양방향일 수도 있다. 물론 이런 부분도 언어에 따라 달라질 수 있다.

Javascript에서 보면 “proto” 속성으로 이런 관계를 표현한다. 복사본에 어떤 속성이나 메소드가 없다면 “proto”에 있는 객체에서 찾는다. 즉, 위임에 의해서 부모 객체로 처리를 넘긴다고 볼 수 있다. 또한 부모도 없다면, 부모의 ”proto”을 찾아서 다시 그 위의 부모에서 넘기면서 null일 때까지 계속 처리한다. “proto” 체인 연쇄로 처리를 한다. 즉 OOP에서 상속이 부모를 확장해서 자식을 만드는 개념과 유사하다.

Javascript에서 Object 자체가 객체이다. 기본적으로 모든 객체는 Object 객체가 최상위에 있고 “proto” 체인 마지막에는 Object 객체가 있다라고 할 수 있다. 물론 “proto” 없이 객체를 생성할 수 있다. 물론 실제 Javascript에서 “proto” 관계는 좀더 복잡하다.

이 방식에서 부모 객체를 수정하면 자식 객체에 모두 반영 되지만, 서로 속성이나 메소드가 겹치게 되는 경우 예측할 수 없는 문제가 생길 수 있다. 또한 체인이 길어지면 처리하는데 비효율적이게 되는 문제도 있다.

Javascript에서 prototype 고찰

항상 새로운 독립된 객체를 만들어서 사용하는 경우 별로 신경쓸 부분은 없다. 고려해야할 부분은 객체를 생성해서 생성한 객체를 재사용해서 확장하는 부분이다. OOP에서 상속을 통해서 이런 부분을 해결하려고 했다.

객체 생성에서 좀더 자세히 살펴보자. 여기서 객체는 단순 데이터가 아닌 메소드까지 포함된 객체를 다룬다. 객체에 메소드를 추가하는 방법은 두가지로 나눌 수 있다. 직접 객체에 추가하거나 new로 생성할 때에 추가할 수 있다. 먼저 직접 객체에 추가하는 방법이다.

let foo = { name: 'foo' };
foo.hi = function() { alert(`Hello ${this.name}!`); }
foo.hi();

객체에 함수를 추가하는 방법은 다양하게 있지만, 결국 위의 방법으로 정리할 수 있다.

다음으로 new를 이용한 객체 생성 방법이다.

function Foo() {
  this.name = 'foo';
}

Foo.prototype.hi = function() { alert(`Hello ${this.name}!`); };

let foo = new Foo();
foo.hi();

Foo()가 생성자 역할을 한다. 물론 여기서도 다양한 방법으로 생성할 수도 있지만 위의 예가 가장 전형적인 형태라고 할 수 있다.

보통 전자 방식보다 후자 방식을 많이 사용한다. 어떻게 보면 후자 방식이 OOP의 클래스 구조에 더 가깝다. 그리고 prototype 속성을 사용해 추후 객체 생성할 때에 “proto”으로 재사용함으로써 구조적으로도 더 좋아 보인다. 이 방식도 나쁘지는 않지만 좀더 프로토타입 언어 같지는 않아서 전자 방식에 좀더 깊이 들어가려고 한다.

다른 방법으로 class가 있지만 이는 new을 사용한 방법을 구문설탕(syntax sugar)으로 표현한 방식이기 때문에 여기서는 다루지 않는다.

이 방식을 new를 사용한 방식에 좀더 가깝게 만들면 다음처럼 만들 수 있다.

let foo_proto = {
  hi:function() { alert(`Hello ${this.name}!`); }
};
let foo = {name: 'foo'};
Object.setPrototypeOf(foo, foo_proto);
foo.hi();

이를 좀더 간편하게 만드는 함수가 Object.create()이다.

let foo_proto = {
  hi:function() { alert(`Hello ${this.name}!`); }
};
let foo = Object.create(foo_proto, {name: {value: 'foo'}});
foo.hi();

Object.create()에서 두번째 인자가 속성을 정의하는 부분인데, 일반 객체와는 다르게 복잡한 형식을 사용해야 한다. 물론 더 풍부한 기능을 제공한다.

이를 좀더 단순하게 처리하기 위해 create2()라고 정의했다.

function create2(proto, props) {
  let obj = Object.create(proto);
  return Object.assign(obj, props);
}

create2()를 앞에 예제에 적용해보자.

let foo_proto = {
  hi:function() { alert(`Hello ${this.name}!`); }
};
let foo = create2(foo_proto, {name: 'foo'});
foo.hi();

좀더 직관적으로 바뀌었다. 물론 기존 Object.create()을 사용하면 가시성과 접근 제어를 할 수 있다. 이 부분은 범위을 벗어나기 때문이 다루지 않는다. foo_proto에 개체 메소드를 저장하고 추후 “proto”에 포함되며서 new 방식의 prototype과 비슷해졌다.

이제 객체를 확장하는 부분을 살표보자. 위의 예를 잘 살펴보면 foo_proto도 객체이다. 이부분이 “proto” 속성으로 사용된다. “proto” 체인에 상위인 부모객체에 해당된다. 한가지 재미 있는 부분은 foo_proto에 속성을 추가하면 foo 객체에서도 해당 객체를 접근할 수 있다.


let foo_proto = {
  id: 0,
  hi:function() { alert(`Hello ${this.name}!`); }
};
let foo = create2(foo_proto, {name: 'foo'});
alert("id: " + foo.id);

결국 메소드용 객체를 만들었지만 일반 객체를 사용할 수 있고, 부모 객체처럼 동작한다. 자식 객체에 없는 id와 hi()를 부모 객체에서 가져다가 사용한다.

부모 객체를 복제해서 사용한다는 의미가 된다. 그리고 “proto”에는 프로토타입에 해당하는 객체인 부모 객체가 연결되어 있게 된다. 복제는 프로토타입에 해당하는 부모 객체를 가지고 자식객체 들을 생성한다고 말할 수 있다. 앞에 create2()를 이를 기반으로 발전시켜보자.

function clone(parent, props) {
  let obj = Object.create(parent);
  return props ? Object.assign(obj, props) : obj;
}

이를 활용한 간단한 예를 보자.

let p = {name: 'parent'}
p.hi = function() { alert(`Hello ${this.name}!`); }

let c1 = clone(p, {name: 'child1'});
let c2 = clone(p, {name: 'child2'});

p.hi();
c1.hi();
c2.hi();

부모 객체에서 연속적으로 자식 객체를 생성하고 다시 자식에서 자손으로 확장할 수 있게 되었다.

결론

지금까지 javascript의 프로토타입 언어의 특징을 살려서 구현하는 방법을 살펴보았다. 성능 측면에서 new을 사용한 방법과 비교할 경우 성능이 떨어진다. 대부분 성능이 중요한 분야일 경우는 위의 방법은 적당하지 않다. 위 방법은 기존 OOP 패러다임과 다르게 확장하는 개념이기에 기존 구조와 맞지 않을 수도 있다.

지금까지 new를 사용한 방식을 많이 쓰는 이유를 보면 프로토타입 패러다임에 익숙하지 않기 때문일거라 생각한다. 이전에 절차적 프로그래밍에서 OOP로 넘어오는데도 시간이 많이 걸렸다. 지금은 함수형 언어를 많이 사용하고 있고 멀티 패러다임으로 이야기를 하지만, 거의 대부분이 OOP을 중심을 두고 다뤄지고 있다. 이런 흐름에 Javascript도 프로토타임 언어 특징을 강화하기 보다는 OOP을 도입하고 있는 이유인지도 모르겠다. Javascript가 OOP로 재탄생하기 위해서는 근본을 바꿔야하는데 쉽지않아보인다. 지금까지 Javascript에서 프로토타임 언어을 활용할 수 있는 방안에 대해서 고찰해보았다. 부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐거운 코딩생활 하세요. ospace.

참고

[1] Javascirpt, https://developer.mozilla.org/en-US/docs/Web/JavaScript

[2] Javascript, https://en.wikipedia.org/wiki/JavaScript

[3] ECMAScript 2024 Language Specification, https://tc39.es/ecma262/

[4] Prototype-based programming, https://en.wikipedia.org/wiki/Prototype-based_programming

[5] https://ko.wikipedia.org/wiki/프로토타입_기반_프로그래밍

반응형