[Web Sculpting] 상속 vs 합성

JINBOK LEE·2023년 7월 31일
0

Web Sculpting

목록 보기
2/4
post-thumbnail

0. 들어가기에 앞서

현재 Javascript와 HTML canvas Element를 이용해 2D 애니메이션을 구현하는 과정에 대해 공부하고 있다. 과정을 진행하면서, 그 동안 한번도 사용해보지 않았던 '클래스'를 사용하여 애니메이션에 필요한 여러 객체들을 만들고 있다.

공부에 참고하는 인터넷 강의에서는, 2D 애니메이션을 구현할 Canvas와 CavasOption을 사용하며, 이후 모든 애니메이션 클래스들이 CanvasOption을 상속하여 구현되는 방식을 취하고 있다.

Canvas Class / Canvas Option Class

class CanvasOption {
 constructor() {
   this.canvas = document.querySelector("canvas");
   this.ctx = this.canvas.getContext("2d");
   this.dpr = window.devicePixelRatio;
   this.canvasWidth = window.innerWidth;
   this.canvasHeight = window.innerHeight;
   this.bgColor = "#000000";
 }
}

class Canvas extends CanvasOption {
 constructor() {
   super();
 }

 init() {
   this.canvas.width = this.canvasWidth * this.dpr;
   this.canvas.height = this.canvasHeight * this.dpr;
   this.ctx.scale(this.dpr, this.dpr);

   this.canvas.style.width = this.canvasWidth + "px";
   this.canvas.style.height = this.canvasHeight + "px";
 }
}

Spark Class (Inherits from the CanvasOption Class)

class Spark extends CanvasOption {
 constructor() {
   super()
   this.x = x;
   this.y = y;
 }

 draw() {
   this.ctx.beginPath();
   this.ctx.arc(this.x, this.y, 1, 0, Math.PI * 2);
   this.ctx.fillStyle = "gold";
   this.ctx.fill();
   this.ctx.closePath();
 }

위 코드들이 그 예시이며, 이 외에도 아래 Spark Class와 같은 애니메이션 요소들의 클래스들이 다양하게 존재하는 상태이다.

다른 애니메이션 클래스들도 항상 CanvasOption을 extends 하면서 생성을 하고 있는데, 클래스를 다뤄본 경험이 없는 상태에서, 아래의 생각들을 하면서 만일 애니메이션에 필요한 클래스들이 엄청나게 많다면? 그것들이 모두 CanvasOption 클래스를 상속하는게 문제가 없는 걸까? 라는 의구심을 가지게 되었다.

❓ CanvasOption 의 과도한 상속이 아닐까

그렇지만 CavnasOption은 모든 애니메이션들에 공통적으로 쓰여야 하는 값이다

❓ CanvasOption 의 상속으로 인해 너무 높은 결합도를 가지는 것이 아닐까

개별 클래스들을 완전히 분리하기 위해서는 공통적으로 쓰이는 CanvasOption에 대한 코드>를 매번 똑같이 작성해야 한다. 이는 너무 비효율적인 방식이고, 이후 유지관리도 어려울 것이다

❓ CanvasOption 을 상속하고, 'ctx' 만 사용할텐데, 그 외에 dpr, canvasWidth, canvasHeight 등의 데이터들로 인해 불필요한 연산을 하진 않을까

필요 이상으로 많은 속성이나 메소드를 상속받는 것은 메모리 사용량 증가와 불필요한 연산을 초래할 수 있다. 사용하지 않는 데이터들은 Javascript의 V8 엔진이 '가비지 콜렉션' 이라는 기능으로 메모리를 관리한다고 하지만, 이러한 연산이 '굳이' 필요하게 만드는 것 또한 낭비가 아닐까 라는 생각이 든다.

따라서, 보다 더 나은 접근 방식은 없을지에 대한 고민을 하며 찾아본 결과, 상속과 합성의 방식에 대한 장단점을 알게 되었고, 상속은 생각보다 신중하게 사용해야 하는 관계라는 것을 알게 되었다.


1. 상속이란 무엇인가?

상속은 한 클래스가 다른 클래스의 특성을 물려받는 것을 의미한다. (IS - A 관계)
예를 들어, '동물' 클래스의 특성을 물려받은 '개' 클래스를 만들 수 있다.

기존에 작성한 코드처럼 Spark 클래스가 CanvasOption 클래스를 물려받은 것이다.

기본 예제

class Animal {
 eat() {
   console.log('Eating...');
 }
}

class Dog extends Animal {
 bark() {
   console.log('Woof!');
 }
}

const dog = new Dog();
dog.eat(); // "Eating..."
dog.bark(); // "Woof!"

적용 코드

class Spark extends CanvasOption {
 constructor(x, y) {
   super()
   this.x = x;
   this.y = y;
 }

 draw() {
   // this.ctx는 CanvasOption에서 상속받은 속성이다.
   this.ctx.beginPath();
   this.ctx.arc(this.x, this.y, 1, 0, Math.PI * 2);
   this.ctx.fillStyle = "gold";
   this.ctx.fill();
   this.ctx.closePath();
 }
}

1-1 상속의 장점

  • 코드 재사용 : 부모 클래스에서 정의된 메소드와 속성을 자식 클래스가 자동으로 상속받아 사용할 수 있어 코드를 재사용할 수 있다.

  • 쉬운 확장 : 부모 클래스의 메소드를 오버라이딩하거나 새로운 메소드를 추가함으로써 쉽게 기능을 확장할 수 있다.

1-2 상속의 단점

  • 과도한 상속 : 상속 계층이 깊어질수록 코드의 복잡도가 높아진다. 과도한 상속은 코드를 이해하고 유지하기 어렵게 만들 수 있다.

  • 불필요한 속성 : 부모 클래스의 모든 속성과 메소드를 상속받기 때문에, 필요하지 않은 속성이나 메소드를 상속받을 수 있고, 이는 메모리 낭비를 초래할 수 있다.


2. 합성이란 무엇인가?

합성은 한 객체가 다른 객체를 포함하는 것을 의미한다. (HAS-A 관계)
예를 들어, '자동차' 객체가 '바퀴' 객체를 포함할 수 있다.

기본 예제

class Wheel {
 rotate() {
   console.log('Rotating...');
 }
}

class Car {
 constructor() {
   // Wheel 인스턴스를 만든다.
   this.wheel = new Wheel();
 }

 move() {
   this.wheel.rotate();
   console.log('Car is moving...');
 }
}

const car = new Car();
car.move(); // "Rotating..." "Car is moving..."

적용 코드

class Spark {
 constructor(x, y) {
   this.x = x;
   this.y = y;
   // CanvasOption 인스턴스를 만든다.
   this.canvasOptions = new CanvasOption();
 }

 draw() {
    // ctx는 CanvasOption 인스턴스에서 가져온 속성이다.
    const ctx = this.canvasOptions.ctx;
    ctx.beginPath();
    ctx.arc(this.x, this.y, 1, 0, Math.PI * 2);
    ctx.fillStyle = "gold";
    ctx.fill();
    ctx.closePath();
  }
}

2-1 합성의 장점

  • 유연성 : 클래스 간의 관계를 상속이 아닌 포함으로 정의함으로써 더 높은 수준의 유연성을 얻을 수 있다. 특정 클래스의 일부 기능만 필요한 경우, 해당 기능을 가진 객체를 만들어서 사용할 수 있다.

  • 느슨한 결합 : 클래스 간의 의존성이 줄어들어 코드가 더 유연해지고 변경이 용이해진다. 코드 변경시 다른 부분에 미치는 영향이 적어져 유지 보수가 쉬워진다.

2-2 합성의 단점

  • 이해하기 어려움 : 합성을 사용하면 코드가 좀 더 복잡해지고 이해하기 어려워질 수 있다.

  • 객체 생성 비용 : 합성을 사용하면 각 기능마다 새로운 객체를 생성해야 하는데, 이는 메모리를 더 많이 사용하고 객체 생성에 대한 비용이 증가할 수 있다.


3. 합성이 상속보다 더 많은 메모리를 사용할 수 있다?

상속으로 인해 불필요한 속성을 상속 받아 메모리의 낭비를 초래할 수 있다고 생각하면서, 합성은 이러한 단점을 보완할 수 있다고 생각했었다.

그러나 합성은, 상속에 비해 더 유연한 관계를 만들기 위해 인스턴스를 만드는 과정에서 더 많은 메모리의 사용과 비용의 증가가 발생할 수 있다고 한다.

프로젝트의 특성이나, 프로젝트의 요구 사항 등에 따라 달라질 수 있기 때문에, 무엇이 더 효율적인지는 쉽게 결론 내리지 못할 것 같다.

나는 이러한 사실을 알고나서, 그럼 새로운 인스턴스를 만드는 것 대신, 전달 인자로 전달하는 방식이라면? 이라는 생각을 해보았고, 아래와 같이 코드를 구성해 보았다.

class CanvasOption {
// ... 위와 동일 ...
}

class Canvas extends CanvasOption {
  constructor() {
    super();
    this.sparks = [];
    this.createSpark();
  }

  init() {
   // ... 위와 동일 ...
  }

  createSpark(x, y, color) {
    const sparkNum = 330;
    const x = 100;
    const y = 100;
    for (let i = 0; i < sparkNum; i++) {
      this.sparks.push(new Spark(x,y));
    }
  }

  render() {
    let deltaTime;
    let previousTime = performance.now();

    const frame = (currentTime) => {
      requestAnimationFrame(frame);
      deltaTime = currentTime - previousTime;
      previousTime = currentTime;

      this.ctx.fillStyle = this.bgColor + "30";
      this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);

      this.sparks.forEach((spark, index) => {
        // spark의 draw 매서드에 this.ctx를 전달인자로 전달한다
        spark.draw(this.ctx);
      });
    };

    requestAnimationFrame(frame);
  }
}


class Spark {
  constructor(x,y) {
    this.x = x;
    this.y = y;
  }
 
  // 전달인자로 받은 ctx를 사용한다  
  draw(ctx) {
    ctx.beginPath();
    ctx.arc(this.x, this.y, 1, 0, Math.PI * 2);
    ctx.fillStyle = "gold";
    ctx.fill();
    ctx.closePath();
  }
}

이러한 방식은 합성을 사용하여 Spark 객체가 필요한 CanvasOption의 일부(ctx)만을 사용할 수 있도록 한다. 이렇게 하면 Spark가 CanvasOption을 상속받는 것보다 메모리를 더 효율적으로 사용할 수 있다.

그렇지만.. 이런 방식은 코드를 더 복잡하게 만들고, 기존의 상속의 방식만큼 클래스간 결합도가 높아진다는 생각이 들었다. 이럴 바에는 오히려 가독성도 좋고, 유지보수도 쉬운 상속의 방식을 채택하는게 더 낫지 않을까?

따라서, 현재 프로젝트는 간단히 애니메이션을 구현해보는 토이 프로젝트이고, 고정된 CanvasOption을 사용하기 때문에 현재로서는 상속의 방식이 가장 적합하다고 결론을 내렸다.

만약 여러 CanvasOption과 그에 따른 여러 애니메이션 클래스들이 존재한다면, 그때는 무엇이 더 효율적일지 고민을 해볼만 하다고 생각이 든다.


4. 상속과 합성은 어떻게 선택해야 하는가?

위에서도 잠시 이야기 했듯, 개발자 개인의 선호도나 프로젝트의 성격 등에 따라 무엇이 더 효율적인지 나뉘는 gray zone이라고 생각한다. 그러나 기본적인 가이드라인은 아래와 같이 정리할 수 있겠다.

"is-a" vs "has-a" 관계

만약 하나의 클래스가 다른 클래스와 "is-a" 관계(예: "Dog" is an "Animal")에 있다면, 상속을 사용하는 것이 더 적합할 수 있다. 반대로, 만약 클래스가 "has-a" 관계(예: "Car" has a "Wheel")에 있다면, 합성이 더 적절한 선택일 수 있다.

코드 재사용

상속은 코드의 재사용을 쉽게 해준다는 장점이 있다. 하지만 많은 양의 상속은 복잡성을 증가시킬 수 있으며, 클래스 간에 강력한 종속성을 만들 수 있다. 합성은 더 많은 유연성을 제공하며 클래스 간의 종속성을 줄여주고. 이는 유지보수 및 코드 이해를 용이하게 한다.

결합도와 응집도

합성은 더욱 낮은 결합도와 높은 응집도를 가진다. 클래스들이 서로 느슨하게 연결되어 있고, 각 클래스는 자신의 기능에 집중할 수 있기 때문인데, 이는 유지보수를 용이하게 하며, 변경이 필요할 때 이를 더 쉽게 수행할 수 있게 한다.

변경의 유연성

만약 어플리케이션의 특정 부분이 빈번하게 변경되는 경향이 있다면, 합성이 더 적합할 수 있다. 상속은 클래스 간의 강력한 종속성을 만들기 때문에, 변경이 필요할 때마다 상속받은 모든 클래스에 영향을 미칠 수 있다.


5. 마치며

클래스와 인스턴스를 다뤄 보면서, 상속과 합성에 대해 공부해 본 시간이었다.
상속에 대해서는 아주 얄팍하게 알고 있었으나 합성에 대해서는 새롭게 배운 부분들이 많다.

그 동안의 프로젝트에서 나도 모르게 사용하고 있었던 (강의만 보고 따라하던 모습을 반성하며) 클래스들이 있었던 것 같은데, 이런 내용을 미리 공부했더라면 더 깊은 고민을 해볼 기회가 되지 않았을까 생각이 든다.

또한, 그 동안 React를 주로 사용하면서 단순히 반복되는 내용들에 대해 컴포넌트화 하고 재사용 하는 정도의 수준이었는데 이보다 더 low-level에서 코드를 재사용 할 수 있는 방법이 있다고 느꼈다.

앞으로 2D를 넘어 3D 애니메이션/인터렉션을 구현하게 될텐데, 얼마나 더 많은 새로운 내용들이 나를 기다리고 있을지 기대반 걱정반 이다. :)


참고

상속과 프로토타입 / MDN
자바스크립트의 메모리 관리 / MDN

➡️ 구현 결과 보러가기

profile
깔끔한 비즈니스 로직 설계를 위해 공부하는 FE 개발자

0개의 댓글