- Published on
자바스크립트 믹스인 (Mixin)에 대해 알아보자
- Authors
회사에서 프로젝트를 진행하면서, '믹스인'을 사용해보자는 의견이 나왔다. 믹스인에 대해서는 잘 알지 못했기에, 이번 기회에 믹스인이 무엇이고, 프로젝트에서 사용할 때 어떤 점에서 장점이고 단점인지 알아보았다.
이 글에서는 자바스크립트로 믹스인이 무엇인지 알아보고, 믹스인을 사용할 때의 주의할 점과 단점을 알아보자.
[[Prototype]]
객체를 연결하는 단 하나의 자바스크립트는 단일 상속만을 허용하는 언어임을 알아두어야 한다. 이는 객체에 단 하나의 [[Prototype]]
만 있을 수 있음을 말한다.
여기서 [[Prototype]]
은 무엇일까? 자바스크립트는 프로토타입 기반 언어이다. 프로토타입은 한국어로 '원형'이라는 뜻이다. 함수를 생성하고 나면, 함수 객체와 함수의 프로토타입 객체가 동시에 생성된다.
아래 그림이 함수 객체와 함수 프로토타입 객체의 관계이다. funtion
네모 박스가 함수 객체이고, prototype
네모 박스가 그 함수의 프로토타입 객체이다. 서로가 .prototype
프로퍼티와, .constructor
로 가리키고 있다.
코드로 살펴보자. Person.prototype
으로 Person 프로토타입 객체를 가리키고, 거기에 다시 .constructor
를 하여 Person 함수 객체를 가리키게 된다.
function Person(name){
this.name = name;
};
Person === Person.prototype.constructor // true
함수 객체에 new
키워드를 사용하면, 인스턴스를 만들 수 있다. 인스턴스의 __proto__
프로퍼티는 자신을 만들어준 원형인 프로토타입 객체를 가리키고 있다. 코드를 통해 확인해보면, p1.__proto__
는 Person의 프로토타입 객체인 Person.prototype
과 동일함을 알 수 있다.
const p1 = new Person('yejin');
p1.__proto__ === Person.prototype // true
__proto__
프로퍼티가 바로 [[Prototype]]
이다. 자바스크립트에서 객체는 [[Prototype]]
으로 생성한 함수 객체의 프로토타입을 가리킨다. 그리고, 자바스크립트에서 객체는 [[Prototype]]
으로 단 하나의 프로토타입 객체만 가리킬 수 있다. 만약 상속을 한다면, 객체들의 관계는 아래 그림처럼 [[Prototype]]
으로 연결된다.
지금까지 보았듯이 자바스크립트는 단일 상속만 지원함을 알 수 있었다. 그렇다면, 믹스인은 무엇이며 왜 필요한지에 대해서 알아보자.
믹스인이란?
In object-oriented programming languages, a mixin (or mix-in) is a class that contains methods for use by other classes without having to be the parent class of those other classes. (wikipedia)
믹스인은 클래스를 상속하지 않고도, 믹스인을 사용하는 클래스가 사용할 수 있는 메서드를 담고 있는 클래스이다.
클래스를 상속받아서 메서드를 사용하면 되는데, 왜 믹스인을 사용하는 것일까?
믹스인은 왜 필요할까
먼저 동물을 주제로 예시를 살펴보자. 동물(Animal)을 상속 받는 포유동물(Mammal), 새(Bird), 물고기(Fish) 클래스가 있고, 그 클래스를 상속 받는 클래스들이 있다. 가장 마지막에 있는 클래스들 옆에 붙어있는 사각형은 각 동물들이 할 수 있는 행동을 나타낸다.
고양이(Cat)와 비둘기(Dove)는 걸을 수 있지만, 고양이는 비둘기와 달리 날 수 없다.같은 부모 클래스를 가진 클래스끼리 동일한 동작을 한다면, 부모 클래스에 '날다'라는 메서드를 구현하면 된다. 하지만 서로 다른 부모 클래스라면 어떻게 해야할까?
만약 클래스가 여러 개의 부모 클래스를 둘 수 있다면, 'Walker', 'Swimmer', 'Flyer'라는 클래스를 만들고, 고양이는 'Walker' 클래스를 상속받고 비둘기는 'Walker'와 'Flyer'를 상속받게 하면 된다.
하지만, 자바스크립트는 단일 상속만을 지원하므로, 하나의 클래스가 여러 개의 부모 클래스를 상속받을 수 없다. 바로 이때 믹스인
을 활용하여, 처리할 수 있다.
객체지향 프로그래밍에서 믹스인은 서로 다른 클래스에게 특정한 기능을 제공한다. 베이스 클래스와는 다르게 완전한 기능을 구현하지는 않는다. 대신에, 믹스인을 사용하는 클래스의 기능을 확장해준다.
자바스크립트에서 mixin을 구현하는 방법
자바스크립트는 믹스인이라는 기능을 제공하지 않는다. 대신, Object.assign
을 사용해서 믹스인을 구현할 수 있다. Object.assign
은 맨 앞에 타겟 객체가 오고, 그 뒤로는 소스 객체가 온다. 타겟 객체에 소스 객체의 모든 열거가능(enumerable)한 자체 속성을 복사한다.
Object.assign(target, ...sources)
예시
다양한 상황에서 믹스인을 사용한 예시를 살펴보자.
1. Mixin을 프로토타입에 병합
sayHi
라는 메서드를 가진 믹스인을 User
클래스에 병합하는 예시이다.
Object.assign
으로 sayHi
메서드를 User.prototype
에 복사한다. 그러면, 상속 없이 User 인스턴스에서 sayHi
메서드를 사용할 수 있다.
// 믹스인
const sayHiMixin = {
sayHi() {
console.log(`Hello ${this.name}`);
},
};
// 사용법:
class User {
constructor(name) {
this.name = name;
}
}
// 메서드 복사
Object.assign(User.prototype, sayHiMixin);
// 이제 User가 인사를 할 수 있습니다.
new User("Dude").sayHi(); // Hello Dude!
2. 클래스 상속과 동시에, 믹스인 병합
클래스가 부모 클래스를 상속받는 동시에, 믹스인을 병합할 수도 있다. 아래 예시에서 믹스인과 부모 클래스는 모두 sayHi
라는 메서드를 갖고 있다. User
클래스는 부모 클래스를 상속받고 믹스인을 병합한다. 이 때, User의 인스턴스가 sayHi()
와 introduce()
를 호출하게 되는데, 실행 결과가 어떻게 될까?
// 믹스인
const sayHiMixin = {
sayHi() {
console.log(`Hello ${this.name} from mixin`);
},
sayBye() {
console.log(`Hello ${this.name} from mixin`);
},
};
// 부모 클래스
class People {
constructor(name) {
this.name = name;
}
sayHi(){
console.log(`Hello ${this.name} from class`);
}
}
// 자식 클래스
class User extends People {
constructor(...args){
super(args);
}
introduce(){
super.sayHi();
}
}
// 믹스인 병합
Object.assign(User.prototype, sayHiMixin)
const user = new User('yejin');
user.sayHi();
user.introduce();
실행 결과는 다음과 같다.
user.sayHi(); // Hello yejin from mixin
user.introduce(); // Hello yejin from class
user.sayHi()
는 User.prototype
에 있는 믹스인의 sayHi 함수를 하였기에 'Hello yejin from mixin'을 출력한다.
user.introduce()
를 호출하면, super.sayHi()
가 호출된다. super
는 프로토타입 체인에서 sayHi
를 찾기 때문에, People
클래스의 sayHi 메서드를 호출하였다.
User.prototype
내부를 살펴보면, User.prototype
안에는 믹스인의 메서드인 sayBye
와 sayHi
가 있다. 그리고 [[Prototype]]
으로 가리키고 있는 프로토타입 객체로 가면, People.prototype
이 있고 People 클래스의 sayHi
가 있다.
주의할 점
Object.assign
으로 구현한 믹스인을 사용할 때의 주의할 점이 있다. 기존 클래스의 메서드와 동일한 이름으로 믹스인에 함수가 있다면, 믹스인의 함수가 우선순위가 더 높다.
클래스 메서드보다 믹스인의 메서드가 우선순위가 더 높다.
간단하게, 클래스가 하나의 믹스인을 상속받는 경우를 살펴보자. 믹스인과 클래스 모두 sayHi
라는 동일한 이름의 메서드를 갖고 있다. 믹스인을 병합한 클래스 인스턴스가 sayHi()
를 실행하면, 어떤 결과가 나올까?
const sayHiMixin = {
sayHi() {
console.log(`Hello ${this.name} from mixin`);
},
};
class User {
constructor(name) {
this.name = name;
}
sayHi(){
console.log(`Hello ${this.name} from class`);
}
}
Object.assign(User.prototype, sayHiMixin)
const user = new User('yejin');
user.sayHi(); // Hello yejin from mixin
바로 믹스인의 함수가 호출된다. 여기서 user.sayHi
는 왜 믹스인의 함수가 호출했을까?
mixin을 상속 받기 전의 User
클래스로 만든 user
인스턴스의 내부를 살펴보자. sayHi
메서드는 User.prototype
에 등록된 것을 알 수 있다.
- 믹스인을 상속받지 않은 User 클래스
class User {
constructor(name) {
this.name = name;
}
sayHi(){
console.log(`Hello ${this.name} from class`);
}
}
const user = new User('user name');
- 개발자 도구로 확인한 User
User.prototype에 믹스인을 병합하려면, 다음을 실행하게 된다.
Object.assign(User.prototype, sayHiMixin)
이 부분에서 User.prototype에 있는 sayHi 함수가 믹스인의 sayHi로 덮어 씌워진다. 그 이유는 Object.assign
이 동작하는 방식 때문이다. Object.assign(target, ...sources)
은 source의 프로퍼티와 target의 프로퍼티의 이름이 같을 때, source의 프로퍼티를 target으로 덮어 씌우게 된다.
Object.assign(User.prototype, sayHiMixin)
을 실행했을 때, 믹스인의 sayHi
메서드가 User.prototype.sayHi
를 덮어 씌웠기에, user.sayHi()
는 믹스인의 함수가 호출된 것이다.
여기서 문제는 어떠한 에러나 경고 없이 덮어씌워진다는 것이다. 믹스인을 사용하려면 프로그래머가 프로토타입과 Object.assign
을 잘 이해해야 하고, 잘 이해했다 하더라도 동일한 이름을 만들지 않도록 주의해야 한다.
여러 개의 믹스인을 사용하는 경우, 오른쪽에 있는 믹스인의 우선순위가 더 높다
여러 개의 믹스인을 사용하는 경우에는 어떨까? 마찬가지로, 동일한 이름이 있다면 source 객체의 프로퍼티가 target 객체의 프로퍼티를 덮어 씌운다.
아래 예시를 살펴보자. BaseClass
클래스에 총 3개의 믹스인을 합쳤다. 클래스와 믹스인 모두 sayName
이라는 동일한 메서드 이름을 갖고 있다.
const firstMixin = {
sayName(){
console.log('Hi, first mixin');
}
};
const secondMixin = {
sayName(){
console.log('Hi, second mixin');
}
}
const thirdMixin = {
sayName(){
console.log('Hi, third mixin');
}
}
class BaseClass {
sayName(){
console.log('Hi, class');
}
}
Object.assign(BaseClass.prototype, firstMixin, secondMixin, thirdMixin);
const item = new BaseClass();
item.sayName();
BaseClass의 인스턴스가 sayName을 호출하면 어떻게 될까? thirdMixin
의 sayName
함수가 호출된다.
Hi, third mixin
위 예시처럼 Object.assign(target, ...source)
에 여러 개의 source 객체가 있을 때, 오른쪽부터 왼쪽으로 복사한다. 이 때 동일한 이름의 프로퍼티가 있다면, 오른쪽에 프로퍼티가 우선한다. 3개의 믹스인을 병합할 때 가장 오른쪽에 있던 thirdMixin
이 다른 믹스인들의 sayName
을 덮어 썼기에, thirdMixin의 메서드가 호출된 것이다.
믹스인은 서로 다른 기능들을 구현하게 된다. 예를 들어, Walker
와 Flyer
는 걷기 메서드와 날기 메서드를 각각 구현할 것이다. 이렇듯 서로 다른 기능을 제공하는 믹스인을 구현할 때에도, 서로 메서드의 이름이 동일하지 않는지 확인해야한다.
단점
자바스크립트에서 믹스인을 사용할 때의 단점을 알아보자.
프로토타입 객체 자체가 수정된다.
첫 번째 단점은 Object.assign
이 프로토타입 객체 자체를 수정한다는 점이다. 프론트엔드에서는 불변성
(immutability)을 강조한다. 믹스인을 사용하면, 프로토타입 객체에 대한 그 불변성이 깨지게 된다.
프로토타입 객체에 대한 불변성이 깨지면, 왜 안 좋을까? 믹스인으로 합성하기 이전의 클래스를 사용하였고, 그 클래스의 메서드를 사용하고 싶은 경우에 문제가 발생한다.
아래 코드를 살펴보자. pureItem
인스턴스는 믹스인으로 합쳐지기 이전의 클래스의 sayName
메서드를 기대한다. 기대한대로, pureItem.sayName()
은 base class
라고 로그를 찍게 된다.
class BaseClass {
sayName(){
console.log('base class')
}
}
// (A)
// mixin으로 합치기 전에 생성된 인스턴스
const pureItem = new BaseClass();
pureItem.sayName() // base class
클래스와 같은 메서드 이름인 sayName
메서드를 갖고 있는 믹스인을 BaseClass.prototype
에 추가한다. 같은 메서드 이름이니깐, 덮어 씌워지게 된다. 그래서, mixedItem.sayName()
도 mixin
이라고 로그를 찍게 된다. 여기서 문제는 프로토타입이 수정된 이후에 pureItem에서 sayName을 호출하는 경우다. (C) 구간의 pureItem.sayName()
은 (A)에서와는 달리 믹스인의 함수가 호출된다.
const mixin = {
sayName(){
console.log('mixin');
}
}
// (B)
// mixin을 BaseClass.prototype에 복사함
Object.assign(BaseClass.prototype, mixin);
// mixin으로 합친 후에 생성된 인스턴스
const mixedItem = new BaseClass();
mixedItem.sayName() // mixin
// (C)
pureItem.sayName() // mixin
(C)구간에서 sayName
을 호출할 때, BaseClass.prototype
의 모습은 아래와 같다.
Object.assign
으로 BaseClass.prototype.sayName
이 믹스인의 함수로 대체되었기 때문에, 믹스인의 함수가 호출된 것이다.
pureItem
과 같이 믹스인으로 합치기 전의 클래스의 메서드를 사용하고 싶은 인스턴스가 있을 수 있다. 그러나, 코드 어디선가 믹스인을 사용하여 클래스의 프로토타입 자체를 변경하게 되면, 그 인스턴스는 예상과는 다르게 실행된다. 디버깅의 고통은 개발자의 몫이다.
사실 이렇게 자바스크립트에서 Object.assign
으로 믹스인을 구현하는 것은 믹스인의 원래 정의와 맞지 않다. 믹스인의 정의에 따르면, 믹스인을 클래스에 적용했을 때, 새로운 클래스를 만들어야 한다.
mixin application : The application of a mixin definition to a specific superclass, producing a new subclass.
믹스인 기능을 내장하고 있는 Dart 언어는 믹스인을 슈퍼 클래스에 적용하였을 때, 슈퍼 클래스와 서브 클래스 사이에 새로운 클래스를 만들어 낸다.
아래는 A
를 부모 클래스로 두고, M
을 믹스인으로 합친 것이다. 그러면 사진에서 보다시피, A
클래스와 B
클래스 사이에 A with M
이라는 새로운 클래스가 생성된다.
class B extends A with M {}
그러나, 자바스크립트에서는 Object.assign
으로 믹스인을 합쳤을 때, 새로운 클래스를 만들어내는 것이 아니라, 기존 클래스의 프로토타입 객체를 수정하게 된다.
IDE 지원을 받을 수 없다
두 번째 단점은 믹스인에 대한 IDE의 지원을 받을 수 없다는 점이다. 클래스의 상속과 믹스인을 모두 사용한 예시에서, 어떤 것이 IDE의 지원을 받는지 살펴보자.
const dateMixin = {
getDate(){
return new Date();
}
}
class Parent {
sayParentName(){
console.log('Hi, parent class');
}
}
class BaseClass extends Parent {
sayClassName(){
console.log('Hi, class');
}
}
Object.assign(BaseClass.prototype, dateMixin);
// class
item.sayClassName();
// parent class
item.sayParentName();
// mixin
item.getDate();
이 중에서 VSCode에서 자동 완성과, 구현부로 이동할 수 있는 메서드는 클래스의 메서드인 sayClassName
과 sayParentName
뿐이었다.
- 자동완성
- 구현부 확인 및 이동
- Command 키를 누르고, 마우스를 올리면 구현 내용을 확인할 수 있다.
- Coammdn 키를 누르고, 메서드를 클릭하면, 구현부로 이동한다.
반면, 믹스인의 메서드인 getDate
는 자동완성도 되지 않으며, 구현부로 이동하지 않는다.
개발하면서 IDE의 지원을 받을 수 없다는 것은 치명적이다. 해당 믹스인의 메서드가 어디에서 정의되었는지, 일일이 코드를 훑어봐야 한다. IDE 지원을 받을 수 없다는 점은 개발자 경험 측면에서 좋지 않다.
결론
자바스크립트 자체에서 믹스인을 지원하는 것이 아니기 때문에, Object.assign
으로 자체적으로 믹스인을 구현해서 사용할 때는 여러 가지를 주의해야 한다. 다른 방식을 사용하는 것이 더 안전해 보인다.
Reference
- https://ko.javascript.info/mixins
- https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/
- https://medium.com/flutter-community/dart-what-are-mixins-3a72344011f3
- https://jsdev.kr/t/javascript-prototype/2853
- https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object-oriented_programming
- https://giamir.com/js-prototype-chain-mechanism