9 분 소요

자바스크립트에서 상속이 필요한가?

자바스크립트는 C++와 같은 상속을 지원하지 않습니다. 하지만, 프로토타입을 이용하여 상속한 것처럼 만들 수는 있는데요, 억지로 상속한 것처럼 만들다 보니 쓸데없이 부모 개체의 속성을 복제하기 때문에 좀 비효율적입니다.

공통된 기능은 상속보다는 즉시 실행 함수를 이용한 모듈 개체를 통해 위임을 하도록 논리를 만드는게 좋습니다. Chain of Responsibility처럼 프로토타입 체인을 이용하는 거죠.

C++에서는 Base로부터 상속한 Derived 클래스로 개체를 인스턴스화 하면, 속성과 메서드를 물려받게 됩니다. 따라서 각 개체는 각각 baseProperty, derivedProperty를 갖게 되고, baseMethed(), derivedMethod()를 제공합니다. 메서드 정의는 Base 클래스와 Derived 클래스에서 제공하고요.

다음은 C++의 상속과 자바스크립트의 프로토타입 체인을 이용하는 거죠. 을 비교한 그림입니다. C++은 baseProperty가 각각 정의되는데, 자바스크립트는 Obj1Obj2가 동일한 basePropertybaseMethod()를 참조합니다. 그림을 보면 근본적인 구조부터 다른걸 알 수 있습니다.

image

우리가 상속을 하려는 경우는 다음과 같습니다.

항목 자바스크립트의 대처 방안
인터페이스를 만들어 단단한 코딩 계약을 만들고 싶습니다. 자바클래스는 너무도 유연합니다. 단단한 코딩 계약을 맺을 수 없으며, 대안으로 타입스크립트를 이용할 수 있습니다.
추상 클래스를 만들어 일부 기능은 부모 클래스에서 제공하고, 순가상 함수를 이용하여 자식 클래스에서는 일부 기능을 강제로 구현하게 하고 싶습니다. 부모 개체를 속성으로 저장하고 기능을 위임합니다. 자식 개체의 기능을 강제하는 것은 Strategy로 넘겨줍니다.(콜백 함수를 사용합니다.)
Template Method로 부모 클래스에서 자식 클래스에게 기능을 요청하고 싶습니다. 부모 개체가 필요로 하는 기능을 Strategy로 넘겨줍니다.(콜백 함수를 사용합니다.)
is-a관계를 만들고, 부모 개체에서 호출시 자식 개체들이 다형적으로 동작하게 하고 싶습니다. 변수는 아무 타입이나 받을 수 있으므로, 이미 is-a관계라고 생각하셔도 됩니다. 메서드명만 동일하면 호출되므로, 동일한 메서드명이면 다형적으로 동작한다고 생각해도 됩니다.
기존의 코드를 재활용하고 싶습니다. 자바스크립트는 모든 것이 개체입니다. 함수까지도요. 따라서 개체를 재활용하고 위임하면 됩니다.

즉, 상속이 필요한 항목이 아에 불필요하거나 위임으로 전환될 수 있습니다.

다음은 IDrawable 인터페이스를 만들고, 이를 상속한 Shape클래스를 만들어 기본적인 속성인 m_Left, m_Top, m_Width, m_Height속성을 구현하고, Shape을 상속한 RectangleEllipse에서 Draw시 다형적으로 그려주는 C++의 예입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 인터페이스
class IDrawable {
public:
    virtual void Draw() const = 0; // 순가상 함수입니다. 자식 클래스에서 구체화 해야 합니다.
};

// 추상 클래스
class Shape : 
    public IDrawable { // Shape은 IDrawable 인터페이스를 제공합니다.
    // 모든 도형은 왼쪽 상단 좌표와 크기를 가집니다.
    int m_Left;
    int m_Top;
    int m_Width;
    int m_Height;
public:
    Shape(int l, int t, int w, int h) : m_Left(l), m_Top(t), m_Width(w), m_Height(h) {}
    virtual ~Shape() {} // 다형 소멸 하도록 public virtual
};

// Shape을 구체화한 클래스 입니다. Draw()에서 사각형을 그립니다.
class Rectangle : public Shape {
public:
    Rectangle(int l, int t, int w, int h) : Shape(l, t, w, h) {}
    virtual void Draw() const {
        std::cout << "Rectangle::Draw()" << std::endl;
    }
};
// Shape을 구체화한 클래스 입니다. Draw()에서 타원을 그립니다.
class Ellipse : public Shape { 
public:
    Ellipse(int l, int t, int w, int h) : Shape(l, t, w, h) {}
    virtual void Draw() const {
        std::cout << "Ellipse::Draw()" << std::endl;
    }
};

Shape* shapes[2] = { // 도형들을 Shape* 로 관리합니다.
    new Rectangle(1, 2, 3, 4), 
    new Ellipse(10, 20, 30, 40)
};

// (O) Shape이 IDrawable을 상속했으므로 Draw() 할 수 있습니다.
for(int i = 0; i < 2; ++i) {
    shapes[i]->Draw(); // 다형적으로 그립니다.
}

for(int i = 0; i < 2; ++i) {
    delete shapes[i]; // 다형 소멸 합니다. Shape*로 Rectangle, Ellipse을 소멸합니다.
} 

자바스크립트에서는 다음과 같이 할 수 있습니다.

  1. 배열에 아무 타입이나 들어가니 굳이 Shape으로 추상화할 필요가 없습니다.
  2. 그냥 draw()를 호출하면 됩니다. 인터페이스로 만들 필요가 없습니다.

코딩 계약은 좀 느슨해 보입니다만, 잘 동작합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const Rectangle = (() => {  
    function Rectangle(l, t, w, h) { 
        this.left = l;
        this.top = t;
        this.width = w;
        this.height = h;   
    }
    Rectangle.prototype.draw = function() { // 메서드는 프로토타입에 선언합니다.
        console.log('Rectangle을 그립니다.', this.left, this.top, this.width, this.height);
    }; 
    
    return Rectangle; 
})();
const Ellipse = (() => {  
    function Ellipse(l, t, w, h) { 
        this.left = l;
        this.top = t;
        this.width = w;
        this.height = h;   
    }
    Ellipse.prototype.draw = function() { // 메서드는 프로토타입에 선언합니다.
        console.log('Ellipse을 그립니다.', this.left, this.top, this.width, this.height);
    }; 
    
    return Ellipse; 
})();

// #1. 배열에 아무 타입이나 들어가니 굳이 Shape으로 추상화할 필요가 없습니다.
let shapes = [new Rectangle(1, 2, 3, 4), new Ellipse(10, 20, 30, 40)];
shapes.forEach(
    // #2. 그냥 draw를 호출하면 됩니다. 인터페이스로 만들 필요가 없습니다.
    (shape) => shape.draw()
);

프로토타입을 이용한 상속

그럼에도 불구하고, 굳이 상속이 필요하다면, 다음과 같이 기반이 되는 Base프로토타입 개체의 메서드들과 baseObj의 속성들을 복제해서 사용할 수 있습니다.

image

  1. Derived 생성자 함수에서 Base.call(this, baseProperty);를 실행합니다.

    이 코드는 내부적으로 생성자 함수 BaseBase(baseProperty);와 같이 일반 함수처럼 호출(생성자 함수 참고)해 줍니다. 이렇게 되면, 개체를 생성하는 것이 아니라 this에 속성을 추가하는 함수가 되죠.

    또한 Base함수의 thisDerived 생성자 함수this(리턴하는 개체)로 바인딩 합니다.

    즉, Derived가 리턴하는 개체Base의 속성을 추가하는 효과가 있습니다.

  2. Object.setPrototypeOf()Base.prototypeDerived.prototype에 복제합니다. 다만 constructor()는 변경하지 않습니다. 이때 Derived.prototype.__proto__Base.prototype을 참조하여, Derived.prototype에 속성/메서드가 없다면, Base.prototype에서 찾게 합니다.

실행 결과를 보면, derived 객체는 baseProperty를 복제하여 사용하고 있고, Derived.prototypebaseMethod()를 복제하여 사용하고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const Base = (() => {  
    function Base(baseProperty) { // #1
        this.baseProperty = baseProperty;   
    }
    Base.prototype.baseMethod = function() { 
        console.log('baseMethod 입니다', this.baseProperty);
    }; 
    
    return Base; 
})();

const Derived = (() => {  
    function Derived(baseProperty, derivedProperty)  {
        Base.call(this, baseProperty); // #1. Base 생성자 함수를 호출합니다. 리턴하려는 this개체를 Base()함수 내의 this로 바인딩합니다.(Base 속성을 this에 추가합니다.)
        this.derivedProperty = derivedProperty;   
    }

    Object.setPrototypeOf(Derived.prototype, Base.prototype); // #2. Base.prototype의 속성들을 복제합니다.

    Derived.prototype.derivedMethod = function() { 
        console.log('derivedMethod 입니다', this.derivedProperty);
    }; 
    return Derived;
})();

const base = new Base('base');
base.baseMethod(); // baseMethod 입니다 base

const derived = new Derived('base from derived', 'derived'); 

console.log('Base.prototype을 복제했지만, constructor는 Derived입니다', derived.__proto__.constructor === Derived);
console.log('Derived.prototype.__proto__ === Base.prototype입니다', Derived.prototype.__proto__ === Base.prototype);
console.log('derived는 Derived로부터 생성되었습니다', derived instanceof Derived);
console.log('derived는 Base로부터 생성되었습니다', derived instanceof Base);

derived.baseMethod(); // baseMethod 입니다 base from derived
derived.derivedMethod(); // derivedMethod 입니다 derived

Derived에서 baseMethod를 재정의하면 오버라이딩 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const OverridingDerived = (() => {  
    function OverridingDerived(baseProperty, derivedProperty) {
        Base.call(this, baseProperty); 
        this.derivedProperty = derivedProperty;   
    }
    Object.setPrototypeOf(Derived.prototype, Base.prototype);
    OverridingDerived.prototype.baseMethod = function() { // baseMethod를 오버라이딩 합니다.
        console.log('Overriding 입니다', this.baseProperty);
    }; 
    return OverridingDerived;
})();
const overridingDerived = new OverridingDerived('base from derived', 'derived'); 
overridingDerived.baseMethod(); // Overriding 입니다 base from derived

클래스를 이용한 상속(ECMAScript6)

ECMAScript6 부터는 class문법이 도입되어 프로토타입을 이용한 상속보다 간결하게 상속을 구현할 수 있습니다. 실제로 C++같은 클래스를 제공하는 것은 아니며, 프로토타입을 이용한 상속의 좀더 쉬운 코딩법을 제공할 뿐입니다.

  1. constructor생성자 함수의 역할을 합니다. 생성자 함수처럼 속성들을 초기화합니다.
  2. 메서드는 알아서 프로토타입에 선언됩니다.
  3. extends는 상속을 표현합니다. 아마도 내부적으로 setPrototypeOf()를 호출할 겁니다.
  4. super()는 상위 생성자 함수를 호출합니다. 아마도, Base.call()을 호출할 겁니다.
  5. Derived용 메서드를 추가할 수 있습니다. 또한 Base의 메서드와 동일한 이름으로 재정의하면 오버라이딩 됩니다.
  6. 생성자 함수처럼 new로 생성합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Base { // 클래스는 관습적으로 Pascal 표기를 사용합니다.
    constructor(baseProperty) { // #1. 생성자 함수
        this.baseProperty = baseProperty;
    } 
    baseMethod() { // #2. 메서드는 알아서 프로토타입에 선언됩니다.
        console.log('baseMethod 입니다', this.baseProperty);
    } 
};
class Derived extends Base { // #3. 상속입니다. 아마도 내부적으로 setPrototypeOf()를 하겠죠.
    constructor(baseProperty, derivedProperty) { 
        super(baseProperty); // #4. 상위 생성자 함수를 호출합니다. 아마도, Base.call()을 호출하겠죠.
        this.derivedProperty = derivedProperty;
    }
    derivedMethod() { // #5. Derived에 메서드를 추가합니다.
        console.log('derivedMethod 입니다', this.derivedProperty);   
    }
};

const base = new Base('base'); // #6. 생성자 함수처럼 new 로 생성합니다.
base.baseMethod(); // baseMethod 입니다 base

const derived = new Derived('base from derived', 'derived'); 

console.log('Base.prototype을 복제했지만, constructor는 Derived입니다', derived.__proto__.constructor === Derived);
console.log('derived는 Derived로부터 생성되었습니다', derived instanceof Derived);
console.log('derived는 Base로부터 생성되었습니다', derived instanceof Base);

derived.baseMethod(); // baseMethod 입니다 base from derived
derived.derivedMethod(); // derivedMethod 입니다 derived

클래스 getter/setter/static(ECMAScript6)

getset을 이용하여 속성처럼 사용할 수 있는 gettersetter를 만들 수 있습니다.

또한 static을 사용하면 정적 함수를 만들 수 있습니다. 정적 함수는 클래스 명으로 호출합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class MyClass { 
    constructor(data) { 
        this.data = data;
    } 
    get xVal() { // getter
        return this.data.x;
    }
    set xVal(x) { // setter
        this.data.x = x;
    }

    get yVal() {
        return this.data.y;
    }
    set yVal(y) {
        this.data.y = y;
    }

    static staticMethod() {
        return '정적 함수 입니다';
    }
};
const data = { x: 1, y: 2 };
const myClass = new MyClass(data);
console.log('getter를 이용하여 속성처럼 데이터를 참조할 수 있습니다', myClass.xVal === 1 && myClass.yVal === 2);

myClass.xVal = 10;
myClass.yVal = 20;
console.log('setter를 이용하여 속성처럼 데이터를 수정할 수 있습니다', myClass.xVal === 10 && myClass.yVal === 20);

console.log(MyClass.staticMethod()); // 정적 함수 입니다

protected, private(ECMAScript10)

자바스크립트에서는 protected를 지원하지 않습니다. 외부에 노출하지 않는 것은 관습적으로 속성앞에 _를 붙여 사용할 뿐입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base {
    _protectedVal = 0; // _는 관습적일 뿐, 실제 접근 통제를 하지 못합니다.
    get val() {
        return this._protectedVal;
    } 
    set val(val) {
        this._protectedVal = val;
    }
};
class Derived extends Base {
    inc() {
        this._protectedVal += 1;
    }
};

const data = new Derived();
data._protectedVal = 10; // 접근이 가능합니다.
data.inc();
console.log('protected는 지원하지 않습니다.', data.val === 11);

하지만 private는 지원합니다. 속성명 앞에 #을 붙이면 접근이 통제되어 자기 자신에서만 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base {
    #privateVal = 0; // #은 자기 자신 외에는 접근할 수 없습니다.
    get val() {
        return this.#privateVal;
    } 
    set val(val) {
        this.#privateVal = val;
    }
};
class Derived extends Base {
    inc() {
        // this.#privateVal += 1; // (X) 자식 클래스에서도 접근할 수 없습니다.
        this.val += 1;
    }
};

const data = new Derived();
// data.#privateVal = 10; // (X) 외부에서 접근할 수 없습니다.
data.inc();
console.log('getter, setter로만 접근할 수 있습니다.', data.val === 1);

클래스 MixIn

코딩 패턴 - MixIn을 이용한 메서드 동적 추가에서 기존 개체에 다른 메서드를 추가하여 기능을 확장하는 패턴을 보여드렸는데요, 클래스에서도 이 기법을 활용하여 메서드를 추가할 수 있습니다.

다음 예는 DataBasicOperationMixIn 개체의 메서드들을 추가하는 예입니다. new Data()로 생성된 data1에 MixIn 하면 data1개체에만 반영되지만, Data.prototype에 반영하면 모든 개체에 반영되는 걸 알 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Data {
    constructor(a, b) { 
        this.a = a;
        this.b = b;
    } 
};

const BasicOperationMixIn = {
    plus: function() { // this를 사용하므로 화살표 함수를 사용하지 않습니다.
        return this.a + this.b;
    },
    minus: function() {
        return this.a - this.b;
    },
    multiply: function() {
        return this.a * this.b;
    },
    divide: function() {
        return this.a / this.b;
    } 
};

const data1 = new Data(1, 2);
// data1.plus(); // (X) plus 메서드가 없으므로 오류

Object.assign(data1, BasicOperationMixIn); // data1 개체에 사칙 연산 추가
console.log('개체에 MixIn', data1.plus() === 1 + 2);

const data2 = new Data(3, 4);
// data2.plus(); // (X) data1에만 사칙연산을 추가 했으므로 data2는 plus 메서드가 없으므로 오류
Object.assign(Data.prototype, BasicOperationMixIn); // 프로토타입 개체에 사칙 연산 추가
console.log('프로타입에 MixIn', data2.plus() === 3 + 4);

console.log('프로타입에 MixIn했으므로, 이후로 생성되는 모든 개체에 적용됨', (new Data(5, 6)).plus() === 5 + 6);  

댓글남기기