[2D 메타볼 애니메이션 구현] 3. 팩토리 메서드 패턴으로 메타볼 핸들링 객체 생성하기

young_pallete·2023년 3월 23일
0
post-thumbnail

🔗 이 포스트에 대해 더 궁금하신가요? 다음 주소를 참고해주세요!

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

  • 23.03.24 - 이후 시리즈 5번을 만드는 과정에서, 실제로 엮는데 불편했던 로직을 리팩토링하여 개선했어요. (commit - c699507)

메타볼 핸들링 객체 생성

제 캔버스에는 수많은 메타볼 도형들이 위치하고 있습니다.
그리고 이 핸들링 객체들은 각각의 특징을 담고 있어요.

  • Static한 메타볼이 있는가 하면,
  • Dynamic한 메타볼이 있어요.
    • 그런데 이 Dynamic한 메타볼 중에서는 일부는 주변을 벗어나지 않고
    • 일부는 주변에서 벗어나면서, 일정 범위를 벗어나면 터집니다.

그리고 이러한 옵션들은 매우 수많이 존재할 수 있겠죠?
따라서, 이러한 메타볼 객체들을 관리해야 합니다.

그런데 봅시다. Canvas의 역할은 무엇일까요?
'어떤 일련의 애니메이션을 동작시키는 역할'이라는 확실한 단일 책임을 갖고 있어요.

이때, '메타볼까지 책임지고 다 핸들링해줘!'라고 한다면 어떨까요?
이는 결합도를 높일 뿐 아니라, 캔버스에 대한 확장성을 떨어뜨릴 가능성, 그리고 메타볼의 일부가 변경되는 순간 Canvas의 로직이 실수로 변경될 위험에도 노출되겠죠.

따라서 이를 생성하기 위한 고민을 하게 되었는데요.
이번에는 팩토리 메서드 패턴을 통해 이를 핸들링하기로 결심했답니다.

Metaballs 클래스 구현

Metaballs의 경우 다음과 같은 구조로 구현했어요!

  • Metaballs(추상 클래스)
    • StaticMetaballs(서브 클래스)
    • DynamicMetaballs(서브 클래스)

사실상 Metaball과 같은 느낌의 구조죠?
정말 순수하게 일련의 Metaball들의 핸들링만을 책임지는 친구라서, 똑같이 가져가는 게 맞다고 생각했어요!

코드는 다음과 같아요.

23.03.24 - 해당 코드는 해당 commit에서 Metaballs를 완전히 추상 클래스로 해야겠다고 결정하였고, 따라서 push 메서드를 추상화했습니다. 처음 보시는 분들은 무시하셔도 돼요! 🙇🏻‍♂️

import {DynamicMetaball, StaticMetaball} from './Metaball';

abstract class Metaballs<MetaballType> {
  abstract balls: MetaballType[];

  abstract push(metaball: MetaballType): void;

  abstract moveAll(): void;
}

export class StaticMetaballs implements Metaballs<StaticMetaball> {
  balls: StaticMetaball[];

  constructor() {
    this.balls = [];
  }

  push(metaball: StaticMetaball) {
    this.balls.push(metaball);
  }

  moveAll() {
    /* eslint-disable-next-line no-console */
    console.log('moveAll!');
  }
}
export class DynamicMetaballs implements Metaballs<DynamicMetaball> {
  balls: DynamicMetaball[];

  constructor() {
    this.balls = [];
  }

  push(metaball: DynamicMetaball): void {
    this.balls.push(metaball);
  }

  moveAll() {
    /* eslint-disable-next-line no-console */
    console.log('moveAll!');
  }
}

그러면 우리는 생성 패턴을 이용해서 한 번 이 Metaballs를 생성해볼게요.

왜 생성 패턴을 쓰나요?

가령 다음과 같은 코드를 다양한 객체에서 쓴다고 가정합시다.

class A {
	// ...
  	bar() {
		const a = new Metaballs(options1);
    }
}

class B {
	// ...
  	foo() {
		const a = new Metaballs(options2);
    }
}

class ZZZ {
	// ...
  	baz() {
		const a = new Metaballs(options3);
    }
}

이때, Metaballs의 이름이 바뀌었다던지, Metaballs라는 클래스에서 수정할 게 발생한다면, 모든 클래스 내부를 수정해야 하죠. 즉 이는 개방-폐쇄의 원칙을 위배하게 됩니다.

하지만 이러한 생성의 책임을 하나의 클래스가 맡게 된다면 어떨까요?

class A {
	// ...
  	bar() {
		const a = new MetaballsFactory();
      
      	MetaballsFactory.create();
    }
}

class B {
	// ...
  	foo() {
		const a = new MetaballsFactory();
      
      	MetaballsFactory.create();
    }
}

class ZZZ {
	// ...
  	baz() {
		const a = new MetaballsFactory();
      
      	MetaballsFactory.create();
    }
}

내부의 생성 로직들 모두가 추상화가 되어 선언적이면서도 MetaballsFactory만 수정하면 되므로 더욱 SOLID 원칙에 부합해집니다.

애초부터 객체지향적으로 설계하지 않아 트라우마가 생겨 시작한 포스트니(...) 한 번 생성 패턴을 적용하기로 했어요! 🙆🏻‍♀️🙆🏻

그리고 저는 이들 중, 팩토리 메서드 패턴을 사용하였습니다.

왜 팩토리 메서드 패턴으로 했는가

일단 생성 패턴에 관하여 직관적으로 당장 떠오르는 건 다음과 같았어요.

  • 빌더 패턴
  • 추상 팩토리 패턴
  • 팩토리 메서드 패턴

사실 어떠한 패턴을 쓰던지 간에 별 문제가 발생하지 않습니다.
다만 팩토리 메서드 패턴을 쓴 이유는, 다음과 같은 이유에서 좀 더 가장 적합하다 생각했기 때문입니다.

  • Metaballs 클래스를 생성할 때, 다른 클래스를 추가적으로 결정할 일이 없습니다. 즉, 생성될 서브 클래스가 1개였죠! 따라서 추상 클래스를 일부 가져가되, 완전히 추상 팩토리 패턴처럼 만들 이유가 없었어요.
  • Metaballs는 그렇게 유연하게 가져갈 필요가 없는 클래스입니다. 따라서 빌더 패턴의 경우 오히려 쓸데없이 메모리를 낭비하여 불필요한 부하를 만드는 느낌이 들었어요.

그렇다면, 어떻게 Metaballs를 구현했는지 살펴보시죠!

구현하기

23.03.24 - 해당 코드는 해당 commit에서 생성 로직을 좀 더 추상화하고자 리팩토링되었습니다. 처음 보시는 분들은 무시하셔도 돼요! 🙇🏻‍♂️

import {DynamicMetaball, StaticMetaball} from './Metaball';

import {DynamicMetaballs, StaticMetaballs} from './Metaballs';

import {
  IPushMetaballPayload,
  TDynamicMetaballDataset,
  TStaticMetaballDataset,
} from './types';

export abstract class MetaballsFactory<T> {
  abstract createMetaballs(): T;

  abstract createMetaballByCount(
    metaballs: T,
    {
      options,
    }: IPushMetaballPayload<TStaticMetaballDataset | TDynamicMetaballDataset>,
  ): void;

  create(
    options: IPushMetaballPayload<
      TStaticMetaballDataset | TDynamicMetaballDataset
    >,
  ) {
    const metaballs = this.createMetaballs();

    this.createMetaballByCount(metaballs, options);

    return metaballs;
  }
}

export class StaticMetaballsFactory extends MetaballsFactory<StaticMetaballs> {
  constructor() {
    super();
  }

  createMetaballByCount(
    metaballs: StaticMetaballs,
    {options}: IPushMetaballPayload<TStaticMetaballDataset>,
  ) {
    if (options.data) {
      options.data.forEach(data => {
        metaballs.push(new StaticMetaball({ctx: options.ctx, ...data}));
      });
    }
  }

  createMetaballs(): StaticMetaballs {
    const metaballs = new StaticMetaballs();

    return metaballs;
  }

  create(
    options: IPushMetaballPayload<TStaticMetaballDataset>,
  ): StaticMetaballs {
    return super.create(options);
  }
}

export class DynamicMetaballsFactory extends MetaballsFactory<DynamicMetaballs> {
  constructor() {
    super();
  }

  createMetaballByCount(
    metaballs: DynamicMetaballs,
    {options}: IPushMetaballPayload<TDynamicMetaballDataset>,
  ) {
    if (options.data) {
      options.data.forEach(data => {
        metaballs.push(new DynamicMetaball({ctx: options.ctx, ...data}));
      });
    }
  }

  createMetaballs(): DynamicMetaballs {
    const metaballs = new DynamicMetaballs();

    return metaballs;
  }

  create(
    options: IPushMetaballPayload<TDynamicMetaballDataset>,
  ): DynamicMetaballs {
    return super.create(options);
  }
}

간단하죠?
즉 어떤 객체를 만들지를 서브 클래스의 create를 통해 결정할 수 있게 됩니다.
그리고 서브 클래스는 생성 과정을 추상화시키게 되는 거죠!

위의 코드를 보면, 실제로 생성을 하는 로직은 Metaball까지도 초기에 만들어줘야 했습니다.
만약 저 코드를 팩토리 없이 각 객체마다 추가했다면, 생성 로직과 다른 클래스들과의 결합도가 높아졌겠죠?

따라서 좀 더 유연하게, 다양한 Metaballs를 생성할 수 있게 되었군요!

🎉 마치며

사실 아직은 큰 객체들을 설계 및 구현하는 과정이라, 디자인 패턴 공부에 가까운(?) 느낌이 드네요. 하지만 이러한 설계가 뒷받침 되어야, 안정적으로 개발을 할 수 있다고 믿어요. 그리고 백스토리를 알게 되기 때문에, 추후 더 잘 이해할 수 있을 거에요.

그렇기 때문에 분명 이렇게 천천히 하나하나 설계해나가는 것이, 이 글을 보는 분들께서도 왜 이렇게 코드를 짰는지에 대해 납득할 수 있다고 믿기에, 문서화로 남겨놓습니다.

디자인 패턴을 공부한다고 생각하고, 천천히 곱씹으면서 메타볼을 구현해나가보아요.
아마 다음에는 이 여러 개의 메타볼들을 어떻게 객체지향적으로 핸들링할지를 살펴볼 것 같아요.
좀만 더 참으면 재미있는 애니메이션을 만들 수 있겠군요. 화이팅! 🙇🏻‍♂️

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

0개의 댓글