홈으로

[JS] OLOO패턴에 대한 이해

2020년 03월 15일

프로토타입 기반 언어

자바스크립트를 한번쯤 공부해봤으면 알 수 있는 것처럼, 자바스크립트는 프로토타입 기반으로 동작하는 언어이다. 즉, 생성자 호출을 통해서 객체를 생성할 때 프로토타입 체인으로 연결되고 프로퍼티와 메서드를 프로토타입을 거슬러 올라가며 찾는 원리를 갖고 있다. 이러한 언어의 특성으로 볼 때 기존의 객체지향 언어의 기본 개념인 클래스와 인스턴스는 자바스크립트에 없다는 것을 알 수 있다. 그러나 예전부터 “프로토타입 기반 상속” 이라는 말 처럼 프로토타입을 기반으로 객체지향을 구현하려는 많은 시도들이 있어 왔고 ES6에선 그러한 노력을 보상하는 것처럼 클래스도 등장하였다. 하지만, 그럼에도 불구하고 객체지향의 상속 및 다형성 등의 핵심 개념들을 완벽히 구현하는 것은 불가능하다. 『You don’t know JS』 의 저자인 카일 심슨은 이러한 한계점으로 인해 OLOO(Objects Linked to Other Objects) 라는 새로운 패턴 을 제시하였고 여기서 이를 정리해보고자 한다.

ES6 이전의 클래스 구현

ES6에서 클래스가 나오기 전에, 프로토타입 기반으로 객체지향 원리를 구현하려는 노력을 알아야 왜 OLOO를 제시했으며 클래스가 나왔는지 알 수 있다. 먼저 코드를 보자.

function Person(name) {
  this.name = name;
}
function Student(name, level) {
  Person.call(this, name);
  this.level = level;
}

Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student;
Student.prototype.showLevel = function() { console.log(this.level) };
Person.prototype.showName = function() { console.log(this.name); }

const stu = new Student("이름",1);
stu.showName(); // "이름"
stu.showLevel(); // 1

여기서 Person 은 부모 클래스, Student 는 자식 클래스가 되도록 만든 것이며 프로토타입 링크를 연결시킴으로서 Student 로 생성자 호출을 하여도 Person.prototype 의 메서드에 접근할 수 있게 하였다. 자세하게 살펴보면 아래와 같다.

  • Object.create 는 새로운 객체를 생성하여 프로토타입 링크인 [[Prototype]] 을 인자로 전달된 객체에 연결시킨다. 즉, 본래의 프로토타입 프로퍼티를 버리고 새로운 객체를 생성하기 때문에 원래 있던 constructor 프로퍼티가 사라진다.
  • 이를 위해서 Student.prototype.constructor = Student 로 복구를 해준다.
  • 이후 Student.prototypePerson.prototype 의 메서드를 만들고 Student 를 생성자 호출로 하여 새로운 객체 stu 를 생성한다.
  • stu 는 프로토타입 체인을 통해서 Student.prototypeshowLevelPerson.prototypeshowName 을 호출할 수 있게 된다.

여기서 ES6의 Object.setPrototypeOf 메서드로 좀 더 개선할 수 있다.

// Student.prototype = Object.create(Person.prototype);
// Student.prototype.constructor = Student;
Object.setPrototypeOf(Student.prototype, Person.prototype);

constructor 를 버리지 않고 프로토타입 프로퍼티를 수정하는 메서드이기 때문에 안전하고 직관적이다.

ES6 이후의 클래스 구현

ES6의 클래스를 사용하면 보다 읽기 쉽고 편하게 구현할 수 있다.

class Person {
  constructor(name){
    this.name = name;
  }
  
  showName() {
    console.log(this.name);
  }
}

class Student extends Person {
  constructor(name, level){
    super(name);
    this.level = level;
  }
  
  showLevel() {
    console.log(this.level);
  }
}

const stu = new Student("이름", 1);
stu.showName(); // "이름"
stu.showLevel(); // 1

prototype 과 같은 프로퍼티라던가 Object.createObject.setPrototypeOf 와 같은 메서드를 하나도 사용하지 않고 기존의 객체지향 언어들과 비슷하게 프로토타입 기반의 상속을 구현하였다. 또한 super 키워드를 통해서 상위 객체의 메서드를 참조할 수 있도록 하였다. 상당히 편리해졌고 가독성도 훨씬 좋아졌다. 하지만, 그 내부에는 여전히 프로토타입을 기반으로 동작하고 있으며 실제의 클래스를 구현한 것이 아니라는 것을 반드시 기억해야 한다.

자바스크립트에서 프로토타입 기반 클래스 구현의 문제점

인스턴스는 클래스의 정의들을 복사하지 않는다.

기존의 객체지향 언어들에서 클래스를 사용해서 인스턴스를 생성하면 클래스의 메서드를 복사해서 자신만의 메서드를 가지고 있는다. 하지만 자바스크립트에선 프로토타입 기반이기 때문에 해당 클래스의 프로토타입 프로퍼티를 바꾸면 그로 생성된 인스턴스의 메서드도 바뀐다.

class Animal {
  show() { console.log("동물"); }
} 
const animal = new Animal();
animal.show(); // "동물"
Animal.prototype.show = function() { console.log("바뀐 동물"); };
animal.show(); // "바뀐 동물"

super는 정적 바인딩 된다.

this 키워드처럼 super 가 동적으로 바인딩 될 것 같지만, 정적으로 바인딩 된다. 이는, 객체의 프로토타입 링크가 다른 객체로 변경되도 선언당시의 상위 프로토타입 객체를 찾아간다는 말이다.

// You don't know JS의 코드
class P {
  foo() { console.log("P.foo"); }
}

class C extends P {
  foo() { super.foo(); }
}

const c1 = new C();
c1.foo(); // P.foo

const D = {
  foo: function() { console.log("D.foo"); }
};

const E = {
  foo: C.prototype.foo
};

Object.setPrototypeOf(E, D);
E.foo(); // P.foo

마지막에 E의 프로토타입 링크로 D를 연결시켰기 때문에 E의 foo() 를 호출하면 super.foo() 를 호출할 때 D의 foo() 를 호출 할 것 같지만, super 가 정적 바인딩되기 때문에 P의 foo() 를 호출하게 된다. 이는 자바스크립트의 동적인 특성과 상반되기 때문에 인지하고 잘 사용하여야 한다.

이렇게 부수적인 문제들이 발생할 수 있기 때문에 자바스크립트에서 클래스를 사용하는 것은 기존의 객체지향 언어의 클래스보다 불안정하다고 할 수 있다. 저자의 생각으로는 이를 안티패턴(Anti-pattern)으로 여기자고 주장하는데, 아직 이런 부류의 현상을 경험해본적이 없어서 납득이 가진 않는다. 그래도 충분히 인지하고 있어야 할 만한 개념은 확실하다.

OLOO(Objects Linked to Other Objects) 패턴

위에서 완벽하지 못한 클래스를 활용해보고, 그 문제점들에 대해 알아보았다. 또한 ES6 이전의 사용방식을 통해서 규모가 커짐에 따라 점점 코드가 읽기 어려워지고 난잡해진다는 사실도 파악하였다. 이제 OLOO패턴을 통해서 그 개선점을 알아볼 것이다. OLOO 패턴은 직역하자면 “다른 객체들에 연결된 객체들” 이라는 뜻으로 객체의 연결방식인 프로토타입 체인을 통해서 설계하는 새로운 방식 이다. 즉, 객체지향에서 상속이 부모로부터 메서드와 프로퍼티를 물려받는 개념이었다면 여기선 “위임” 이라는 용어를 사용한다. 위임은 자신에게 없는 메서드나 프로퍼티를 상위 프로토타입 객체에게 “넘기는” 방식 을 말한다. 좀 더 쉽게 보기 위해서 ES6의 클래스를 설명했던 예제를 OLOO로 바꿔보자.

const Person = {
  initName: function(name) { this.name = name; },
  showName: function() { console.log(this.name); }
};

const Student = {
  init: function(name, level) {
    this.initName(name);
    this.level = level;
  },
  showLevel: function() { console.log(this.level); }
};

Object.setPrototypeOf(Student, Person);

const stu = Object.create(Student);
stu.init("이름", 1);
stu.showName();
stu.showLevel();

stuStudentPerson 으로 프로토타입 체인을 연결시켰고 자신에게 없는 메서드의 경우 상위 프로토타입 객체에 해당 작업을 위임하였다. 이는 굉장히 단순한 연결방식이기 때문에 이해하기도 쉽고 상속, 다형성 등의 개념이 없기 때문에 굉장히 편리하다. “위임” 이라는 특징 때문에 메서드 및 프로퍼티 이름이 겹치면 안된다는 단점이 있지만 클래스 구현의 문제점에 비하면 그렇게 심각하지 않다고 생각한다.

마무리

저자의 주장은 클래스 패턴을 지양하고, OLOO 패턴을 지향하자는 의견이다. 물론 저자의 의견을 무작정 따르는 것은 좋지 않지만 그럼에도 불구하고 나 또한 실제로 로직을 구현할 때 OLOO 패턴을 사용하는 것을 고려해야 한다고 생각한다. 프로토타입의 구체적인 원리를 이해하고 클래스를 사용한다면 좀 낫겠지만 설사 그렇다 해도 발생할 수 있는 문제점이라던가 구현할 수 없는 것은 여전히 남아있다. 리액트가 컴포넌트를 클래스로 만들 수 있도록 해놓았지만 이 또한 대부분 훅(hooks)으로 바뀌고 있는 추세이기 때문에 사용하는 대상에서 클래스의 문제점을 야기시킬 만한 원인이 확실히 없을 때 사용하는 것이 바람직하다고 생각한다. 정리하자면, 클래스를 사용하던 OLOO를 사용하던 생각없이 쓰지 말고 동작원리를 이해하고 사용하자.

참고

Loading script...