[2D 메타볼 애니메이션 구현] 1. 메타볼 객체 설계하기.

young_pallete·2023년 3월 22일
0
post-thumbnail
post-custom-banner

이 글을 코드로 간편하게 보고싶나요? 다음을 참고하시길 바라요!

  1. Issue - [♻️ Refactor] 객관적인 퍼포먼스 비교를 위해 메타볼 애니메이션 OOP로 재설계한다. #36
  2. PR commit - ✨ create Metaballs

🗒️ 이 글의 수정내역 (마지막 수정 일자 - 23.03.27)

  • DynamicMetaball에서 현재 당장 필요하지 않음에도 전달되던 props(moveStrategy)를 제거했어요. (commit - c699507)
  • setY의 세부 로직이 잘못되어, 이를 수정했어요. (commit - b74cc4a)

설계가 꼬였다.

사실 이 글을 쓰게 된 이유는, 제가 막구현을 했기 때문입니다. 😭

본격적으로 이력서를 작성함에 있어서, 퍼포먼스 비교가 필요했어요.
그리고 비교 결과, 최적화 이전과 이후의 스크립트 속도가 약 20배가 발생했어요.

그런데 이는 명백한 허점이 있었습니다.

  1. 잠깐! move 메서드가 다르잖아? - 최종 결과는 제가 쓰기 좋게 바꿔놓았고, move의 경우 화면 내에서 핑퐁이 이루어집니다.
  2. 잠깐! 객체 자체가 다르잖아? - 맞아요. 그때 과감히 포기한답시고, 처음부터 새롭게 만들었거든요 🥲

따라서 객관적으로 퍼포먼스 비교를 하고자 했는데... 객체를 재사용할 수 없었어요.
안 그래도 요새 시간도 없는데, 정말 좌절스러웠습니다.

그렇지만 역시 배운 것들은 또 써먹어야 까먹지 않죠!
이참에 다시 디자인 패턴을 복습한다는 생각으로, 위기를 기회로 만들어야겠다는 생각을 하게 됐어요. 또한, 제 코드도 더 단단해지겠죠 😎

실수는 반복되지 않아야 하니, 클린하게 설계부터 제대로 적용해보자는 생각이 들었습니다.


어떻게 설계할 것인가

일단 우리는 메타볼 객체를 만들어야겠죠?
저는 메타볼의 경우, 기본적으로 원형으로 되어 있다고 가정하고 구현했다는 점을 미리 말씀 드릴게요.

따라서 저는 <최적화 이전> 편에서는 다음과 같이 메타볼을 다룰 거에요.

  1. Canvas에서 원을 만듭니다.
  2. 이 원을 토대로 가중치들을 고려합니다.
  3. 가중치에 따라 메타볼이 융합되고, 확대됩니다.

그럼 간단하죠?
그냥 메타볼 하나 생성하면 그만입니다.

23.03.24 - 해당 코드는 해당 commit에서 수정되었어요.
기존에 따라하시던 분들께서는 주의해주세요. 처음 보셨다면 무시하고 진행하세요! 🙇🏻‍♂️

export class DynamicMetaball implements Metaball {
  public ctx: CanvasRenderingContext2D;

  public x: number;

  public y: number;

  public r: number;

  public v: IXYWeight;

  public vWeight: number;

  constructor({
    ctx,
    x,
    y,
    r,
    v,
    vWeight,
  }: IDynamicMetaballParams) {
    this.ctx = ctx;
    this.x = x;
    this.y = y;
    this.r = r;
    this.v = v;
    this.vWeight = vWeight ?? 1;
  }

  get vx() {
    return this.v.x;
  }

  setX(value: number) {
    this.x = value;
  }

  setY(value: number) {
    this.y = value;
  }

  setR(value: number) {
    this.x = value;
  }

  setVx(value: number) {
    this.v.x = value;
  }

  get vy() {
    return this.v.y;
  }

  setVy(value: number) {
    this.v.y = value;
  }

  move() {}
}

코드를 잠시 설명드리자면 다음과 같아요.

프로퍼티, 메서드설명
x, y좌표
v속력
ctxCanvas 컨텍스트입니다!
r메타볼을 원으로 가정했다고 했죠? 반지름입니다!

사실 이렇게만 작성해도, 움직일 수 있는 메타볼은 구현할 수 있어요.
그러나, 다음과 같은 문제가 기다리고 있었습니다.

잠깐! 뭔가 불안한데?

이것은 제가 최종적으로 구현한 결과물인데요!
저는 메타볼을 크게 3가지로 구현했어요.

  1. 고정된 메타볼
  2. 중심에서 벗어나지 않고 꿈틀대는 메타볼
  3. 벗어나서, 이후 일정 거리가 되면 터지는 메타볼

사실 엄밀히 말하자면 기존 정의된 메타볼은 아니지만, 확장한 결과물을 만들어낸 거죠!

그럼 여기서 1, 2, 3번은 과연 위의 메타볼 객체 하나에서 구현할 수 있는 걸까요?
음... 아무래도 힘들 것 같죠? 이유는 다음과 같습니다.

  1. 각자의 객체 역할이 다르다. 이를 처리하기 위해서는 또 별개의 분기처리들을 move마다 해야하며, 이는 객체 메서드 하나에 들어간 역할이 너무 많아져 단일 책임의 원칙에 위배될 것 같군요.
  2. 언제까지 분기처리만으로 얼렁뚱땅 넘길 수는 없겠죠? 예컨대 A라는 메서드가 어떤 곳에서는 필요한데, 어떤 곳에서는 사용되지 않고, 이것이 Side Effect를 발생시킨다면 매우 유지보수가 어렵겠어요. (순수하다고 하더라도, 결합도가 높은 코드는 보지 않아도 될 코드들까지 선별해야 하므로 어지럽죠 😵‍💫)

따라서 우리는 메타볼 객체를 제대로 설계해야 해요.
고민 끝에 저는 다음과 같이 설계를 했답니다.

  • Metaball (추상 클래스)
    • StaticMetaball (서브 클래스) - 좌표 상에서 움직이지 않는 메타볼
    • DynamicMetaball (서브 클래스) - 좌표 상에서 움직이는 메타볼

그리고 코드는 다음과 같이 짰어요.

23.03.24 - 해당 코드는 해당 commit에서 수정되었어요.
기존에 따라하시던 분들께서는 주의해주세요. 처음 보셨다면 무시하고 진행하세요! 🙇🏻‍♂️

export abstract class Metaball {
  abstract ctx: CanvasRenderingContext2D;

  abstract x: number;

  abstract y: number;

  abstract r: number;

  abstract setX(value: number): void;

  abstract setY(value: number): void;

  abstract setR(value: number): void;
}

export class StaticMetaball implements Metaball {
  public ctx: CanvasRenderingContext2D;

  public x: number;

  public y: number;

  public r: number;

  constructor({ctx, x, y, r}: IStaticMetaballParams) {
    this.ctx = ctx;
    this.x = x;
    this.y = y;
    this.r = r;
  }

  setX(value: number) {
    this.x = value;
  }

  setY(value: number) {
    this.x = value;
  }

  setR(value: number) {
    this.x = value;
  }
}

export class DynamicMetaball implements Metaball {
  public ctx: CanvasRenderingContext2D;

  public x: number;

  public y: number;

  public r: number;

  public v: IXYWeight;

  public vWeight: number;

  constructor({ctx, x, y, r, v, vWeight}: IDynamicMetaballParams) {
    this.ctx = ctx;
    this.x = x;
    this.y = y;
    this.r = r;
    this.v = v;
    this.vWeight = vWeight ?? 1;
  }

  get vx() {
    return this.v.x;
  }

  setX(value: number) {
    this.x = value;
  }

  setY(value: number) {
    this.x = value;
  }

  setR(value: number) {
    this.x = value;
  }

  setVx(value: number) {
    this.v.x = value;
  }

  get vy() {
    return this.v.y;
  }

  setVy(value: number) {
    this.v.y = value;
  }

  move() {}
}

잠깐! 왜 DynamicMetaballStaticMetaball을 상속받지 않았나요? 😔

물론 상속했다면 중복되는 코드를 줄이고, 그러면 저는 굉장히 좋았을 것 같아요!
그렇지만 다음과 같은 단점이 존재했습니다.

상속을 받을 수 있는 형태일까?

StaticMetaballmove를 하지 않는다고 해서, 더이상 변하지 말라는 보장이 존재할까요? 예컨대 고정된 채로 갑자기 줄어드는 translate라는 변형에 관한 메서드가 발생한다면 어떨까요? 상속을 하는 순간, DynamicMetaball 역시 위 메서드가 발생합니다.

이때, DynamicMetaball은 위의 translate와 다르게 설계되면, 리스코프 치환 원칙에 위배됩니다. 시간적 여유에 따라 반드시 SOLID할 필요는 없지만, 이왕이면 서로가 확장에 있어 독립적이고 자유롭도록 이를 지켜주고 싶었어요.

따라서 만약 굳이 상속을 해야만 했다면... 두 객체 위에 BaseMetaball이라는 것을 만들고, 이를 상속받게 했을 것 같아요.

그렇지만 여기서는 그렇게 하지 않았습니다. 추상 클래스면 충분하다고 생각했거든요.
결과적으로, 이렇게 간단하게! SOLID 원칙에 부합한 객체 생성을 했답니다. 🙆🏻🙆🏻‍♀️

마치며

객체를 잘못 설계하면 저같이 새롭게 짜야하는 순간이 오게 됩니다. (...)
그러한 순간이 오지 않도록, 충분히 재사용성과 확장성 좋은 코드를 짜야 한다고 생각해요.

물론 객체 설계에 1도 관심 없던 당시에 짠 코드였기에 어쩔 수 없지만, 설계의 중요성을 다시금 느낀 하루였네요.

그러면, 다음에는 캔버스에 관하여 글을 작성해볼게요. 🙇🏻‍♂️
비판은 제 모자란 지식을 채우기 위해 언제든지 환영입니다 👐 읽어주셔서 감사드려요!

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉
post-custom-banner

0개의 댓글