본문 바로가기

Programming

[번역] 팩토리함수 vs 생성자함수 vs 클래스함수 (JavaScript Factory Functions vs Constructor Functions vs Classes by Eric Elliott)

 

이 글은 아래 블로그 포스팅을 한글로 번역한 글입니다. 오역이 있을 수 있으니 양해 부탁드리고 댓글로 알려주시면 감사하겠습니다!!

 

https://medium.com/javascript-scene/javascript-factory-functions-vs-constructor-functions-vs-classes-2f22ceddf33e

 

JavaScript Factory Functions vs Constructor Functions vs Classes

Prior to ES6, there was a lot of confusion about the differences between a factory function and a constructor function in JavaScript. Since…

medium.com

 

ES6가 도입되기 이전에는 팩토리함수와 생성자 함수의 차이에 대한 많은 혼란이 있었다.

 

ES6에 class가 도입되면서, 많은 사람들은 생성자함수로 인해 발생했던 많은 문제들이 해결되었다고 생각하는 것 같다.

 

하지만 실제로는 그렇지 않다. 당신이 여전히 알아야할 주요한 차이가 있다.

 

우선, 각각의 예제를 살펴보자:

 

// class
class ClassCar {
  drive () {
    console.log('Vroom!');
  }
}

const car1 = new ClassCar();
console.log(car1.drive());


// constructor
function ConstructorCar () {}

ConstructorCar.prototype.drive = function () {
  console.log('Vroom!');
};

const car2 = new ConstructorCar();
console.log(car2.drive());


// factory
const proto = {
  drive () {
    console.log('Vroom!');
  }
};

const factoryCar = () => Object.create(proto);

const car3 = factoryCar();
console.log(car3.drive());

 

세가지 방법 모두 공통된 프로토타입에 메소드를 저장하고 생성자함수의 클로저를 통해 private한 데이터를 선택적으로 제공한다. 즉, 이 세 방법은 거의 동일한 특징을 가지고 있고 대부분의 경우 서로 바꿔쓸 수 있다.

 

자바스크립트에서, 모든 함수는 새로운 객체를 반환할 수 있다. 그 함수가 생성자함수나 클래스가 아니라면, 그것은 팩토리함수이다.

 

ES6의 class를 *desugar한 것이 생성자함수이기 때문에 (ES6 classes *desugar to constructor functions) , 생성자함수의 모든 것은 class에도 동일하게 적용된다

 

* syntactic sugar : 
문법적인 기능은 그대로 유지하되, 코드를 작성하는 사람 입장에서 혹은 그 코드를 다시 읽는 사람의 입장에서 편의성이 높은 프로그래밍 문법을 말한다.

즉, 위의 경우에서 ES6부터 도입된 class는 생성자 함수와 실제로 동작하는 것은 똑같지만 개발자의 편의를 위해 겉보기에만 다르게 보여지는 것이다.

syntactic sugar의 예로는 삼항연산자나 i = i + 1을  i++ 로 표현하는 것 등이 있다.

반대로 desugar는 위의 예처럼 간략하게 표현한 코드를 다시 확장하여 표현하는 것을 말한다.

 

class Foo {};

console.log(typeof Foo); // 클래스의 데이터타입은 함수

 

 

팩토리함수와 생성자 함수는 어떤 차이가 있을까?

 

생성자의 호출자는 반드시 `new`키워드를 사용해야한다. 그러나 팩토리함수는 new 키워드가 필요없다. 차이는 이뿐이지만 이로인해서 몇가지의 side-effect가 발생한다. 

 

그렇다면 new키워드가 하는 일은 무엇일까?

 

참고: 여기서 칭하는 'instance'란 새롭게 생성된 인스턴스를 뜻하고, 'Constructor'란 인스턴스를 생성하는 생성자함수 또는 클래스를 뜻한다.

 

  1. 새로운 인스턴스 객체를 만들고 이 인스턴스의 this값으로 생성자를 바인딩한다.
  2. 인스턴스의 던더프로토 (instance.__proto__)를 생성자 프로토타입(Constructor.prototype)에 바인딩한다.
  3. 2번에 대한 side-effect로, 인스턴스의 던더프로토(instance.__proto__.constructor)가 'Constructor'에 바인드된다. 
  4. 암묵적으로 'instance'를 참조하는 this를 반환한다.

생성자함수와 클래스의 장점

  • 대부분의 책들은 클래스나 생성자함수에 대한 내용이다.
  • this는 새로운 객체를 참조한다.
  • 몇몇 사람들은 my Foo = new Foo() 와 같은 방식으로 쓰인 코드를 읽기를 더 선호한다.
  • 성능이 아주 약간 최적화되어있다는 장점이 있지만, 당신의 코드를 분석, 분해해서 이점이 있다는 것을 입증할 일이 없는 한 이것이 드러날 일은 없습니다.

생성자함수와 클래스의 단점

1. new 키워드가 필요하다.

ES6이전에, new 키워드를 빼먹는 것은 꽤 흔한 버그였다. 이를 막기 위해, 많은 사람들은 new키워드를 미리 지정하는 다음과 같은 코드를 추가했다.

 

function Foo() {
	if (!(this instanceof Foo)) { return new Foo(); }
}

 

ES6+(ES2015)에서 개발자가 new 키워드를 빼먹고 생성자함수를 호출하려하면, 에러가 발생한다. 팩토리함수로 클래스를 감싸는 방법이 아니라면 new키워드없이 호출하는 것은 불가능하다.

 

2. 인스턴스의 세부사항이 호출 API로 유출된다(new 키워드 요구를 요구하기 때문에)

 

모든 호출자는 생성자함수 구현과 밀접하게 연관되어있다.

 

만약 추가적인 유연성이 필요한 경우 팩토리함수로의 리팩토링은 파괴적인 변경(breaking change)가 될 것이다.

* breaking point: 다른 컴포넌트에도 영향을 끼칠 수 있는 변화

 

클래스에서 팩토리함수로의 리팩토링은 리팩토링에 대한 중요한 책인 『Refactoring: Improving the Design of Existing Code』에 표시될 정도로 일반적이다.

 

3. 생성자함수는 개방(open)/폐쇄(close)의 원칙에 어긋난다.

 

* 개방/폐쇄의 원칙(OCP, Open-Closed Principle) : 소프트웨어 개체(클래스, 함수, 모듈,,,)는 확장에 있어서는 열려있어야 하고 수정에 있어서는 닫혀있어야 한다는 원칙이다.

 

생성자함수는 new키워드가 요구되기 때문에, 개방/폐쇄의 원칙을 위반하게 된다.

 

나는 클래스에서 팩토리함수로의 리팩토링은 모든 생성자에 대해 표준적인 확장이 되어야할 정도로 일반적인 것이라고 생각한다.

 

그러나 생성자함수나 클래스를 작성하고 사용자가 그 생성자함수를 사용하기 시작한 이후에야 팩토리함수의 유연성이 필요하다는 것을 알아채서는 안된다.

 

( 예를 들어 object poll의 구현을 변경하고자하거나, 실행 컨텍스트를 인스턴스화하고자하거나, 다른 프로토타입을 사용하기 위해 더 많은 상속 유연성이 필요하거나...)

 

불행히도, 자바스크립트에서, 생성자함수나 클래스에서 팩토리함수로 바꾸는 것은 파괴적인 변경(breaking change)이다.

 

즉, 사용자의 호출구문을 모두 리팩토링하지 않는 한 팩토리함수로 쉽게 변경할 수 없다.

 

// 기존 구현(클래스):

// class Car {
//   drive () {
//     console.log('Vroom!');
//   }
// }

// const AutoMaker = { Car };

// 리팩토링(팩토리함수):
const AutoMaker = {
  Car (bundle) {
    return Object.create(this.bundle[bundle]);
  },

  bundle: {
    premium: {
      drive () {
        console.log('Vrooom!');
      },
      getOptions: function () {
        return ['leather', 'wood', 'pearl'];
      }
    }
  }
};

// 리팩토링된 이후 요구되는 호출자:
const newCar = AutoMaker.Car('premium');
newCar.drive(); // 'Vrooom!'

// 그러나 기존에 있던 많은 호출자들은 다음과 같은 형태이다.
const oldCar = new AutoMaker.Car();

// 물론 이 경우 아래와 같은 에러가 발생한다. 
// TypeError: Cannot read property 'undefined' of undefined at new AutoMaker.Car

 

 

위 예제에서, 처음에는 클래스로 작성하기 시작했지만 이후에 더 다양한 차종에 대한 수용 가능성을 추가하고자했다.

 

팩토리함수는 다양한 차종을 수용하기 위한 대안적인 프로토타입이 된다.

 

나는 이 기술을 이용해 다양한 미디어 플레이어 다양한 인터페이스를 저장하고 해당 플레이어가 제어해야 하는 미디어의 유형에 따라 정확한 프로토타입을 지정하는 데 사용하였다.

 

 

 

4. 생성자함수를 사용하면 'instanceof'를 통해 정확한 결과를 얻을 수 없다.

 

생성자함수에서 팩토리함수로의 리팩토링 시 발생하는 파괴적인 변경(breaking change) 중 하나가 바로 'instanceof'이다.

 

때때로 사람들은 안전한 코드 작성을 위해 데이터의 타입을 확인하는 방법으로 'instanceof'를 사용한다.

 

이것은 꽤 큰 문제가 될 수 있다. 나는 'instanceof'키워드를 사용을 지양하는 것을 추천한다.

`instanceof` 는 거짓말을 한다.

// instaceof는 객체의 데이터타입을 체크하는 것이 아니라,
// 해당 객체의 프로토타입을 체크한다.

// 그말은, 실행콘텍스트를 거쳐가는 과정에서 그 값이 바뀔 수 있다는 것이다.
// 만약 프로토타입이 유동적으로 변화하는 경우,
// 다음과 같은 혼란스러운 상황이 발생할 수 있다.

function foo() {}
const bar = { a: 'a'};

// foo의 프로토타입 속성을 bar로 지정
foo.prototype = bar; 

// bar는 foo함수의 인스턴스인가? 아니다.
console.log(bar instanceof foo); // false

// 그럼 bar는 foo의 인스턴스가 아니라면,,
// baz는 foo의 인스턴스가 절대로 아니다!
const baz = Object.create(bar);

// 틀렸다..
console.log(baz instanceof foo); // true. oops.

 

instanceof는 강력한 유형의 언어에서처럼 당신이 원하는대로 객체의 생성자 타입을 확인해주지 않을 것이다.

 

단지 이것은 객체의 __proto__객체와 Constructor.prototype속성을 비교하여 일치 여부를 체크할 뿐이다.

 

이것은 iframe과 같이 서로 다른 메모리를 갖는 경우나 Constructor.prototype이 바뀌는 경우에는 제대로 작동하지 않는다.

 

이것은 클래스나 생성자함수로 시작하는 경우에도 실패할 수 있다. 

 

즉, instanceof는 생성자에서 팩토리함수로 바꾸는 것이 파괴적인 변경(breaking change)이 되는 또다른 이유이다. 

클래스의 장점

  • 편리하고 독립적인 자체 문법이 존재한다.
  • JavaScript에서 클래스를 모방할 수 있는 표준적인 방법이다. ES6이전에는 몇몇의 유명한 라이브러리들에서 경쟁적인 구현이 있었다.
  • 클래스 기반의 언어에 익숙한 사람들에게 더욱 친숙하다.

클래스의 단점

모든 생성자함수의 단점에 추가로,

  • extends키워드로 인해 사용자들로 하여금 문제가 있는 클래스 계층을 생성하도록 유도한다. 

클래스 계층은 객체 지향적 구조에서 잘 알려진 수많은 문제들(the fragile base class problem, the gorilla banana problem, the duplication by necessity problem, 등등.)

 

클래스를 사용하면  extends로 상속하게되고, 공은 던지게 되고, 의자는 앉게 된다.

 

더 많은 정보가 필요하다면, “The Two Pillars of JavaScript: Prototypal OO” 그리고 “Inside the Dev Team Death Spiral”을 읽어보라.

 

물론 생성자함수와 팩토리함수 모두 문제가 있는 클래스 계층을 만들 수 있다. 그러나 extends 키워드로 인해 클래스는 개발자로하여금 '여유'가 생겨 잘못된 길로 쉽게 가게 된다는 점에 주목해야 한다.

 

즉, 이것은 "has-a"나 "can-do"와 같이 유연한 관계가 아니라 "is-a"와 같이 융통성 없고 (때로는 틀린) 관계로 생각하게 만든다.

"여유"는 특정한 작업을 수행할 기회를 제공하는 특징이다. 예를들어 레버를 당기게하고, 버튼을 누르게하는등등

팩토리함수의 장점

팩토리함수는 생성자함수나 클래스에 비해 유연하다. 그리고 extends 키워드와 깊은 상속 계층으로 인해 잘못된 길로 빠지게 하지 않는다.

 

팩토리함수에는 모듈을 포함하여 당신이 클래스의 상속보다도 선호할만한 많은 안전한 재사용 매커니즘이 있다.

1. 임의적인 프로토타입을 이용해 임의적인 객체를 반환한다.

동일한 API를 구현하는 다양한 타입의 객체를 쉽게 만들 수 있다. 예를 들어, 서로 다른 API를 사용하는 hood내에서 다양한 유형의 비디오를 인스턴스화할 수 있는 미디어 플레이어, 또는 DOM이벤트와 웹 소켓 이벤트를 둘 다 전송할 수 있는 이벤트 라이브러리가 있다. 

 

 또한 팩토리함수는 object pool의 이점으로 실행콘텍스트를 인스턴스화할 수 있고 더 유연한 프로토타입적인 상속 모델을 만들 수 있게 한다.. 

2. 리팩토링에 대한 걱정이 없다.

팩토리함수로 구현한 상태에서 생성자함수로 리팩토링할 일은 전혀 없을 것이다.

3. 'new'키워드가 없다.

new를 사용함으로써 마주하는 애매함이 없다.

4. 표준적으로 동작하는 'this'

'this'의 동작이 기존의 방식과 동일하기 때문에 'this'를 통해 부모 객체에 접근할 수 있게 된다. 예를 들어, player.create()에서 'this'는 다른 실행 구문과 동일하게 player를 가리킨다. call()메소드와 apply()메소드 또한 우리가 원하는대로 this값을 할당해준다.

5. `instanceof`구문으로 원하는 결과를 얻을 수 있다.

6. 몇몇의 사람들은 `myFoo = createFoo()`와 같은 방식으로 쓰인 코드를 읽기를 선호한다.

 

팩토리함수의 단점

  • 인스턴스와 팩토리함수의 프로토타입사이에 연결관계를 만들지 않는다. 하지만 이 특징으로 인해 instanceof 구문을 통해 원하는 결과를 얻게 될 것이므로 실제로는 이점으로 작용한다. 
  • 'this'는 팩토리 함수 내부의 새로운 객체를 가리키지 않는다. 이 또한 이점으로 작용한다.

결론

 클래스는 편리한 문법을 가지고 있다고 생각한다. 그러나 사용자들로 하여금 클래스 상속을 구현하는 데 있어서 실수하게 되는 것을 막지는 못한다.

 

이것이 더욱 위험한 이유는 미래에, 팩토리함수로 업그레이드를 해야할 때 모든 호출자는 new키워드로 인해 생성자함수와 긴밀하게 연관되어있기 때문에 클래스에서 팩토리함수로의 리팩토링은 breaking change이다.

 

아마도 호출하는 부분만 리팩토링한다면 될 것이라고 생각하겠지만, 거대한 팀 내에서, 또는 당신이 공용 API의 한 부분을 맡아 작업을 하고있다면, 호출자를 리팩토링할 수 없을 수도 있기 때문에 당신의 의도와는 다르게 코드를 망치게될 수 있다. 

 

팩토리함수의 좋은 점은 더욱 강력하고 유연한 것 뿐만아니라 전체 팀과 전체 API 사용자가 간단하고, 유연하고, 안전한 패턴을 사용하도록 권장하는 가장 쉬운 방법이다.