🔗 이 포스트에 대해 더 궁금하신가요? 다음 주소를 참고해주세요!
🗒️ 이 글의 수정 내역 (마지막 수정 일자: 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
를 생성할 수 있게 되었군요!
사실 아직은 큰 객체들을 설계 및 구현하는 과정이라, 디자인 패턴 공부에 가까운(?) 느낌이 드네요. 하지만 이러한 설계가 뒷받침 되어야, 안정적으로 개발을 할 수 있다고 믿어요. 그리고 백스토리를 알게 되기 때문에, 추후 더 잘 이해할 수 있을 거에요.
그렇기 때문에 분명 이렇게 천천히 하나하나 설계해나가는 것이, 이 글을 보는 분들께서도 왜 이렇게 코드를 짰는지에 대해 납득할 수 있다고 믿기에, 문서화로 남겨놓습니다.
디자인 패턴을 공부한다고 생각하고, 천천히 곱씹으면서 메타볼을 구현해나가보아요.
아마 다음에는 이 여러 개의 메타볼들을 어떻게 객체지향적으로 핸들링할지를 살펴볼 것 같아요.
좀만 더 참으면 재미있는 애니메이션을 만들 수 있겠군요. 화이팅! 🙇🏻♂️