Typescript로 다시 쓰는 GoF - Prototype

아홉번째태양·2023년 8월 14일
0

Prototype이란?

Prototype 패턴은 이름에서 나타나는 것처럼 어떤 인스턴스의 원형을 정하는 패턴이다. 클래스를 인스턴스화하고 내부 구조를 만드는 과정이 복잡할 때, 이를 매번 새로 인스턴스화하고 만드는 것은 비효율적이다. 이럴 때, 프로토타입 패턴에 따라 설계된 객체라면 손쉽게 인스턴스를 복사, 혹은 클로닝하여 같은 데이터를 유지한 새로운 인스턴스를 만들어낼 수 있다.



자바스크립트의 Prototype과의 차이

이때, 자바스크립트의 Prototype과 헤깔릴 수 있는데, 둘의 이름이 같고 어느정도 기능적으로 유사한 부분도 있지만 엄연히 다른 개념이다.

목적

우선 두 개념의 목적이 다르다.

디자인 패턴으로서의 프로토타입은 이미 존재하는 객체를 효율적으로 복사하기 위한 방법론의 일종이다. 반면, 자바스크립트의 프로토타입은 상위 객체의 프로퍼티를 하위 객체가 상속받기 위해 사용하는 자바스크립트의 고유 기능이다.


사용방법

두 개념을 실제 코드에서 사용하는 방법도 다르다.

디자인 패턴의 프로토타입은 정말 개념으로서만 존재하기 때문에 이를 코드단에서 직접 그 구조를 설계해야한다. 반면, 자바스크립트의 프로토타입은 개념이라기보다는 자바스크립트의 한 기능에 가깝기 때문에 자바스크립트 객체에 자동적으로 부여되는 속성이다.

자동적으로 부여되기 때문에 디자인 패턴으로서의 프로토타입이 구현되는 것이라고 오해할 수도 있다. 하지만 앞서 말했듯이 프로토타입 패턴의 목적은 어디까지나 하나의 인스턴스를 그 인스턴스가 가지고 있는 데이터 그대로 복사하는데 있을 뿐, 자바스크립트의 프로토타입처럼 인스턴스와 관계없이 상위클래스의 프로퍼티를 가져와 사용하는 편의(?)는 고려하지 않는다.



언제 쓸까?

Prototype 패턴은 결국 코드와 동작의 중복을 줄이기 위한 패턴의 일종이다.

중복된 속성의 인스턴스

만약 굉장히 많은 수의 인스턴스를 가지고 있는 인스턴스가 있고, 새로운 인스턴스를 만들 때 대부분이 인스턴스의 속성들이 중복된다면 이걸 전부 새롭게 선언하는 것은 비효율적이다.

예를들어, 게임의 몬스터들처럼 외형이나 공격패턴은 공유하면서 능력치가 달라지는 경우 프로토타입 패턴을 이용해 인스턴스를 복제해 능력치 부분만 별개로 다시 덮어씌울 수 있다.


초기화에 많은 시간이 걸리는 인스턴스

어떤 인스턴스는 초기화를 위해 런타임 중 많은 작업 시간을 필요로할 수가 있다.

예를들어, 데이터베이스에서 많은 양의 데이터를 가져와야한다거나, 아니면 사용자의 입력으로 만들어지는 그림 같은 데이터라면 이런 인스턴스는 다시 만들기가 까다롭다. 따라서, 이런 인스턴스들은 새로 초기화하기보다 이미 만들어져있는 인스턴스를 그대로 복사하는 것이 효율적이다.


유사한 기능의 클래스가 너무 많은 경우

유사한 기능을 구현하는 클래스가 많아지면 해당 기능을 구현하는 코드와 객체를 관리하기 위한 코드 등이 반복되면서 유지보수하기가 까다로워진다.

예를들어, 경고 수준에 따라 입력받은 문자열을 지정된 문자로 감싸서 출력하는 클래스들이 있을 떄, 경고 수준을 구분하기 위해 각각의 클래스들은 구분되어야한다. 하지만, 유사한 기능을 하는 클래스가 계속 늘어난다면, 이 기능을 수정하려할 때 수정해야하는 코드가 매우 많아진다.

이부분이 가장 잘 와닿지 않는 이유이기도한데, 이를 예제로서 구현해보자.



Prototype 구현

Prototype 패턴에는 세 가지 객체가 등장한다.

  1. Prototype 원형
    인스턴스를 복사하여 새로운 인스턴스를 만들기 위한 메소드를 정의한다.

  2. ConcretePrototype 구체적인 원형
    인스턴스 복사 메소드를 어떻게 구현할지를 결정한다.

  3. Client 이용자
    프로토타입들을 저장하고 필요한 때에 복사해서 전달해주는 역할을 한다.

Prototype

Prototype은 복사가될 인스턴스들이 공통적으로 지니는 기능과, 복사하기 위한 메소드를 정의한다.

interface Product {
    use(s: string): void;
    createClone(): Product;
}

이때, Cloneable이라는 빌트인 클래스가 있어서 인스턴스 클로닝 메소드를 직접 구현할 필요가 없는 자바와 다르게 자바스크립트에서는 인스턴스를 그대로 복사해주는 메소드는 없다.

따라서, 어차피 인스턴스를 복사해주는 공통메소드를 담기위해 추상 클래스로 프로토타입을 구현하거나, 아니면 이후 ConcretePrototype들에서 복제에 대한 구체적인 행동을 덧붙이기 위해 Cloneable의 역할을 하는 클래스를 만드는 방법도 유효하다.

abstract class Product {
    abstract use(s: string): void;
    createClone(): Product {
        return Object.assign(Object.create(Object.getPrototypeOf(this)), this);
    }
}

여기서 주의할점은, 자바스크립트에서 어떤 클래스의 인스턴스를 온전히 복제하려면 프로토타입까지 복제가 되어야함에 유의한다.

혹은

class Cloneable {
    clone(): this {
        return Object.assign(Object.create(Object.getPrototypeOf(this)), this)
    }
}

abstract class Product extends Cloneable {
    abstract use(s: string): void;
    abstract createClone(): Product;
}

단, 이후 아래 코드에서는 조금 더 객체지향적인 코드를 위해 마지막 방식을 토대로 코드를 이어나갈 것이기 때문에 다른 프로토타입 형태를 쓴다면 참고하길 바란다.


ConcretePrototype

프로토타입에서 정의된 메소드들을 구체화한다.

입력한 문자로 문자열을 감싸주는 MessageBox와 밑줄을 그어주는 UnderlinePen 두 가지를 예시로서 구현해보자.

class MessageBox extends Product {
    constructor(private decoChar: string) {
        super();
        this.decoChar = decoChar;
    }

    use(s: string) {
        const decoLenth = s.length + 2;
        const line = this.decoChar.repeat(decoLenth);
        
        console.log(line);
        console.log(this.decoChar + s + this.decoChar);
        console.log(line);
    }

    createClone(): Product {
        return this.clone();
    }
}

class UnderlinePen extends Product {
    constructor(private ulChar: string) {
        super();
        this.ulChar = ulChar;
    }

    use(s: string) {
        console.log(s);

        const line = this.ulChar.repeat(s.length);
        console.log(line);
    }

    createClone(): Product {
        return this.clone();
    }
}

Cloneable 클래스를 이용해서 clone이라는 메소드를 먼저 구현했기 때문에, 개별적인 클래스별로 복제한 이후에 다른 작업을 추가할 경우 createClone에만 추가하면 된다.

또한, 자바스크립트 객체의 프로토타입까지 완벽하게 복제가 되는지도 한번 테스트해보자.

const proto = {
    hello: () => console.log('world')
};
Object.assign(MessageBox.prototype, proto);

const message = new MessageBox('*');
console.log(Object.getPrototypeOf(message));

const clone = message.createClone();
console.log(Object.getPrototypeOf(clone));
Product { hello: [Function: hello] }
Product { hello: [Function: hello] }

Client

마지막으로 만들어낼 프로토타입을 찍어낼 Manager라는 클래스를 만든다.

class Manager {
    private showcase = new Map<string, Product>();

    register(name: string, prototype: Product) {
        this.showcase.set(name, prototype);
    }

    create(name: string): Product {
        const product = this.showcase.get(name);
        if (!product) {
            throw new Error(`Product ${name} is not registered.`);
        }
        return product.createClone();
    }
}

Map을 사용해서 프로토타입의 식별자와 프로토타입을 맵핑하고, 새 인스턴스에 대한 요청이 들어오면 보관하고 있는 프로토타입을 복제해서 반환한다.


사용 예시

const manager = new Manager();

const upen = new UnderlinePen('-');
const mBox = new MessageBox('*');
const sBox = new MessageBox('/');

manager.register('upen', upen);
manager.register('mBox', mBox);
manager.register('sBox', sBox);

const upenClone = manager.create('upen');
const mBoxClone = manager.create('mBox');
const sBoxClone = manager.create('sBox');

upenClone.use('hello world');
mBoxClone.use('hello');
sBoxClone.use('world');
hello world
-----------
*******
*hello*
*******
///////
/world/
///////

Prototype을 안 썼을 경우 비교

마치기 전에 앞서 언급했듯이, Prototype 패턴을 쓰지 않고 위 예시를 구현했을 경우를 코드로 비교해보자.

class MessageBox {
    constructor(private decoChar: string) {
        this.decoChar = decoChar;
    }
    use(s: string) {
        const decoLenth = s.length + 2;
        const line = this.decoChar.repeat(decoLenth);

        console.log(line);
        console.log(this.decoChar + s + this.decoChar);
        console.log(line);
    }
}

class UnderlinePen {
    constructor(private ulChar: string) {
        this.ulChar = ulChar;
    }

    use(s: string) {
        console.log(s);

        const line = this.ulChar.repeat(s.length);
        console.log(line);
    }
}

const decorateString = (type: string) => {
    switch (type) {
        case 'upen':
            return new UnderlinePen('-');
        case 'mBox':
            return new MessageBox('*');
        case 'sBox':
            return new MessageBox('/');
        default:
            return (s: string) => s;
    }
}

유사한 기능을 구현하는 클래스가 늘어날수록 decorateString이라는 함수의 내부구조가 더 복잡하며 관리하기가 힘들어질 것이다.

또, 해당 인스턴스가 필요할때마다 새로 만들어내야하기 때문에 혹시 초기화에 많은 리소스가 들어가는 경우 전체적인 서비스 퍼포먼스에도 영향을 끼칠 수 있다.




참고자료

Java언어로 배우는 디자인 패턴 입문 - 쉽게 배우는 Gof의 23가지 디자인패턴 (영진닷컴)

1개의 댓글

comment-user-thumbnail
2023년 8월 14일

정보 감사합니다.

답글 달기