티스토리 뷰

 

해당 글은 이어서 GoF의 구조 패턴(Structural Pattern)을 이어서 다루겠습니다.

 

데코레이터 (Decorator)

해당 패턴은 기존 인터페이스를 수정하지 않으면서 추가적인 기능을 확장하기 위해 주로 사용됩니다. 얼핏 들어보면 기존에 배웠던 어댑터 패턴과 목적성이 비슷해 보일 수 있습니다, 이는 여러분들이 기존 패턴들을 잘 숙지하고 이해했다는 뜻이기도 합니다. 당연히 기존 인터페이스를 변경하지 않고 기능을 추가한다는 점에서 어댑터 패턴과 유사해 보일 수 있습니다. 하지만 데코레이터 패턴은 호환성보다는 확장성에 초점을 맞춥니다.

 

현실 세계의 패턴 예시

어댑터 패턴이 마치 전혀 다른 언어를 사용하는 사람끼리 대화하기 위해 통역사를 필요로 하는 상황이라면, 데코레이터 패턴은 이미 같은 언어를 사용하는 사람들이 대화에 필요한 그림이나 자료를 추가하여 풍성하게 만드는 과정과 유사합니다. 즉, 데코레이터 패턴은 객체의 기본 기능은 그대로 유지하면서 추가적인 기능을 마치 장식처럼 붙여나가는 방식으로 동작합니다. 예를 들어, 커피 주문 시스템에서 기본 커피 객체에 우유, 시럽, 휘핑크림 등을 추가하여 카라멜 마끼아또, 카페모카와 같은 다양한 음료를 만드는 것을 생각해볼 수 있습니다.(맛있겠...씁) 이처럼 데코레이터 패턴을 사용하면 기존 코드를 변경하지 않고도 객체의 기능을 유연하게 확장할 수 있습니다.

 

해당 패턴을 구현하는 간단한 예시 코드를 보도록 하겠습니다.

 

abstract class Pizza {
    abstract perform(): void;
}

abstract class PizzaDecorator<T extends Pizza> implements Pizza {
    constructor(public parent: T) { }

    perform(): void {
        this.parent.perform();
    }
}

// ...

class TomatoPizza extends Pizza {
    perform(): void {
        console.log("토마토 피자");
    }
}

class HamDecoratorWithTomatoPizza extends PizzaDecorator<TomatoPizza> {
    perform(): void {
        console.log("토마토 피자의 햄 토핑");
        super.perform();
    }
}

const pizza = new HamDecoratorWithTomatoPizza(new TomatoPizza());
pizza.perform();

// 출력:
// 토마토 피자의 햄 토핑
// 토마토 피자

 

해당 코드에서의 핵심은 PizzaDecorator가 Pizza를 상속하는 것이 아니라 구현한다는 점입니다(implements 키워드 사용). 즉, PizzaDecorator는 Pizza의 하위 타입이 아니라, Pizza와 동일한 메서드를 제공하면서 내부적으로 기능을 확장합니다, 만약에 PizzaDecorator가 Pizza를 구현이 아닌 상속한다면 PizzaDecorator은 피자의 실존적 개념이 될 것입니다. 즉 피자 그 자체가 되는 것이죠.


결과적으로, TomatoPizza는 Pizza 추상 클래스를 상속받아 구체적인 피자 타입을 정의하고, TomatoPizzaHamDecorator는 Pizza 인터페이스를 구현한 데코레이터로서 TomatoPizza 객체를 감싸고 추가적인 기능을 제공하는 방식으로 동작합니다. 이러한 방식은 SOLID 원칙 중 인터페이스 분리 원칙에도 부합하며, 코드의 재사용성과 유지보수성을 향상시킬 수 있다는 것을 알 수 있습니다.

 

해당 코드를 그림으로 표현하면 다음과 같습니다.

 

 

퍼사드 (Facade)

해당 패턴은 클라이언트 코드가 복잡한 서브시스템(동작을 구성하는 작은 단위)에 직접 접근하고 의존해야 하는 상황을 최소화하기 위해 사용됩니다. 서브시스템을 구성하는 여러 서브 클래스들의 복잡한 관계와 로직을 클라이언트에게 그대로 노출하는 대신, 퍼사드 패턴은 단순화된 인터페이스를 제공하여 이러한 문제들을 해결합니다.

 

덕분에 클라이언트는 서브시스템의 내부 구현이나 의존 관계에 대한 깊은 이해 없이도 퍼사드가 제공하는 인터페이스를 통해 필요한 기능을 편리하게 사용할 수 있습니다.


또한 퍼사드 패턴은 단순히 인터페이스를 단순화하여 복잡성을 해결하는데 그치지 않고, 클라이언트 코드 전반의 일관성을 높여주는 데에도 활용될 수 있습니다.

 

현실 세계의 패턴 예시

커피 전문점을 예시로 들어보겠습니다, 커피 전문점에서 사용하는 에스프레소 머신을 생각해 보세요. 이 머신에는 원두 분쇄, 물 온도 조절, 추출 압력 조절, 우유 스티밍 등 다양한 기능을 가진 복잡한 시스템이 존재합니다.


하지만 바리스타는 이 모든 복잡한 과정을 직접 다루지 않죠. 단순히 버튼 하나만 누르면 "에스프레소 추출", "카푸치노 만들기" 와 같은 원하는 작업을 수행할 수 있습니다.


이처럼 복잡한 커피 머신 시스템을 단순화된 인터페이스로 제공하는 것이 바로 퍼사드 패턴의 핵심 개념입니다. (앞서 설명한 것처럼 일관성 있는 동작을 보장할 수 있겠죠.)

 

해당 패턴을 구현하는 간단한 예시 코드를 보도록 하겠습니다.

 

abstract class Coffee { }
abstract class CoffeeMachine<T extends Coffee> {
    abstract createCoffee(): T;
    abstract grindBeans(target: T): void;
    abstract regulateWaterTemp(target: T, level: number): void;
    abstract adjustBrewPressure(target: T, level: number): void;
    abstract steamMilk(target: T): void;
}

class SuperCoffee extends Coffee { }
class SuperCoffeeMachine extends CoffeeMachine<SuperCoffee> {
    createCoffee(): SuperCoffee {
        return new SuperCoffee();
    }

    grindBeans(target: SuperCoffee): void {
        console.log("원두 분쇄");
    }
    
    regulateWaterTemp(target: SuperCoffee, level: number): void {
        console.log(`${level}°C 물 온도 조절`);
    }

    adjustBrewPressure(target: SuperCoffee, level: number): void {
        console.log(`${level}으로 추출 압력 조절`);
    }

    steamMilk(target: SuperCoffee): void {
        console.log("우유 스티밍");
    }
}

class CoffeeMachineCase<T extends CoffeeMachine<Coffee>> {
    constructor(public parent: T) {}

    extract(level1: number, level2: number) {
        const obj = this.parent.createCoffee();
        this.parent.regulateWaterTemp(obj, level1);
        this.parent.adjustBrewPressure(obj, level2);
        this.parent.steamMilk(obj);
        return obj;
    }
}

const controller = new CoffeeMachineCase(new SuperCoffeeMachine);
controller.extract(60, 3);

// 출력:
// 60°C 물 온도 조절
// 3으로 추출 압력 조절
// 우유 스티밍

 

물론 해당 코드 예시는 조금 간단하지만, 실제로 더 복잡한 상황에 맞닥뜨리게 되면 이 패턴의 진정한 가치를 느끼게 될 것입니다.

 

플라이웨이트 (Flyweight)

해당 패턴은 수많은 중복 객체로 인해 발생하는 높은 메모리 사용량 문제를 해결하고 최적화하기 위해 주로 사용됩니다. 해당 패턴의 주요 핵심은 바로 "자원 공유"입니다. 객체들이 공통으로 사용할 수 있는 데이터를 별도의 공유 객체로 분리하여 메모리 사용량을 최소화하는 것입니다.


즉, 객체 생성을 줄이고 이전에 사용되었던 객체를 서로 공유하여 사용하고 재활용하기 위한 패턴이라고 생각하면 되겠습니다.

 

실제로 비슷한 개념으로 JDK에서는 메모리 절약을 위해 정수(Integer)를 캐싱합니다 -128에서 127 사이의 정수 값을 미리 생성해 저장하고, 해당 범위의 값을 요청하면 새로운 객체를 생성하는 대신 캐시에서 가져오는 방식을 사용하여 성능을 향상시킵니다.

이는 문자열 리터럴의 경우도 마찬가지입니다. String Pool이라는 곳에 저장되고, 동일한 문자열을 사용하면 새로운 객체를 생성하는 대신 풀에 있는 객체를 공유하여 메모리를 절약합니다.

또한 이와 비슷하게 서버 성능 향상을 위해서 커넥션 풀(Connection Pool)을 이용하여 DB 연결 객체를 재활용하는 경우가 있습니다.

 

현실 세계의 패턴 예시

"똑같은 망치를 여러 개 만들 필요가 있을까?"

 

문제 상황 1:

여러 작업자가 각자 망치를 사용하여 못을 박아야 합니다. 모든 작업자는 동일한 종류의 망치를 사용하며, 망치의 무게, 재질, 크기는 모두 같습니다. 각 작업자가 망치를 하나씩 가지고 있다면, 똑같은 망치를 여러 개 만들어야 합니다, 또한 작업자들이 일하는 시간은 단 1분이며 사용대도 모두 다릅니다.

 

이로 인해 작업자들이 1만 명이라면 동일한 망치를 1만 개 만들어야 하고 작업자들이 망치를 사용하지 않을 때 이를 유지하기 위해서 창고에 망치들을 모두 저장해두어야 합니다.

 

 

"렌탈(대여) 서비스의 필요성"

 

문제 상황 2:

여러 관광객 또는 스키를 즐겨야 하는 사용자들은 일일히 스키를 타기 위해 필요한 복장 또는 여러 장비들을 모두 구매해야 합니다, 이러한 사용자들은 평균적으로 1시간 정도 스키를 즐기며 그 이후 한 달에서 1년이라는 기간 동안 스키를 타지 않습니다, 즐기는 시간대도 모두 다릅니다.

 

음... 말만 들어도 이러한 상황들이 공간적 낭비와 자원 낭비(또는 높은 비용 부담)이라는 생각이 나지 않나요, 우리 현실 세계에서도 한정된 자원으로 인한 높은 비용을 분산시키고 낮은 비용(도서관의 대여, PC방)으로 여러 질 높은 서비스들을 누릴 수 있는 것 처럼, 프로그래밍 세계에서도 마찬가지입니다.

 

객체 분류: 고유 vs 공유

일단 플라이웨이트 패턴을 적용하기 위해서는 객체를 두 가지 유형으로 분류하는 것이 중요합니다.

 

  • 고유 상태(Intrinsic State): 객체의 고유한 특징을 나타내는 정보입니다. 예를 들어, 글자 객체의 경우 'A', 'B', 'C' 와 같은 글자 자체를 의미합니다.
  • 공유 상태(Extrinsic State): 객체 간에 공유 가능한 정보입니다. 글꼴, 크기, 색상 정보가 이에 해당합니다.

 

해당 패턴을 구현하는 간단한 예시 코드를 보도록 하겠습니다.

 

class Nail {
    constructor(public id: number) {}

    get debugLabel() {
        return `nail-${this.id}`;
    }
}

// 해당 객체 하나하나가 차지하는 크기가 크다고 가정해 보세요.
class Hammer {
    constructor(
        public id: number,
        public weight: number,
        public length: number,
        public thickness: number,
    ) {}

    drive(nail: Nail, position: {x: number, y: number}): void {
        console.log(`${position.x}, ${position.y}에 ${this.debugLabel}으로 ${nail.debugLabel}을 박음.`);
    }

    get debugLabel() {
        return `hammer-${this.id}`;
    }
}

// 저번에 언급했던 싱글톤(Singleton) 패턴을 사용하여 정적으로 관리.
class HammerFactory {
    private static _instance: HammerFactory;
    private static _idCount: number = 0;
    private static _itemStack: {[key: string]: Hammer} = {};

    private constructor() {}

    static get instance(): HammerFactory {
        return this._instance ?? (this._instance = new HammerFactory());
    }

    // 주어진 망치의 특성에 따라 망치를 새로 만들지, 기존에 만들어진 망치를 반환할 지가 달라집니다.
    requestWith(
        weight: number,
        length: number,
        thinkness: number
    ): Hammer {
        let itemStack = HammerFactory._itemStack;
        let key = `${weight}-${length}-${thinkness}`;
        let hammer = itemStack[key];
        if (hammer) {
            return hammer;
        }

        return itemStack[key] = new Hammer(HammerFactory._idCount++, weight, length, thinkness);
    }
}

class HammerUser { // 추상화 생략
    constructor(
        public id: number,
        public name: string,
        public hammer: Hammer
    ) {
        console.log(`${this.debugLabel} 생성 됨.`)
    }

    driveAt(x: number, y: number) {
        this.hammer.drive(new Nail(this.id), {x: x, y: y});
    }

    get debugLabel() {
        return `user-${this.id}`;
    }
}

const user1 = new HammerUser(1, "작업자 1", HammerFactory.instance.requestWith(10, 100, 50));
const user2 = new HammerUser(2, "작업자 2", HammerFactory.instance.requestWith(10, 100, 50));
const user3 = new HammerUser(3, "작업자 3", HammerFactory.instance.requestWith(20, 300, 50));

user1.driveAt(10, 15);
user2.driveAt(20, 25);
user3.driveAt(30, 35);

// 출력:
// user-1 생성 됨.
// user-2 생성 됨.
// user-3 생성 됨.
// 10, 15에 hammer-0으로 nail-1을 박음.
// 20, 25에 hammer-0으로 nail-2을 박음.
// 30, 35에 hammer-1으로 nail-3을 박음.

 

해당 코드를 보면 별도의 클래스(HammerFactory)가 Hammer 클래스의 생성을 모두 관리하는 것을 볼 수 있습니다, 따라서 클라이언트 측에서는 Hammer 생성에 직접적으로 관여하지 않고 HammerFactory에게 전부 위임하면 됩니다. (별도의 망치 전용 공장이 망치의 생산과 저장 그리고 분배를 모두 책임지는 상황이라고 생각하면 됩니다.)

 

이로 인해 매번 불필요한 동일한 객체가 증복 생성되는 경우를 막을 수 있습니다.

 

프록시 (Proxy)

해당 패턴은 이름 그대로 특정 객체를 대신하여 요청을 처리하고, 해당 객체에 대한 접근을 제어해주는 대리자 역할을 하는 클래스를 별도로 생성하여 관리하는 것을 말합니다.

 

현실 세계의 패턴 예시

해당 패턴은 마치 연예인 매니저처럼 생각하면 매우 쉽게 이해할 수 있습니다. 연예인 대신 스케줄을 관리하고, 팬들을 만나거나 인터뷰하는 것을 중개하는 역할이죠. 연예인에게 직접 연락하는 게 아니라 매니저를 통해서만 가능하게 하는 것입니다.

"어? 그럼 여러 연예인들을 한 곳에 모아서 관리하는 기획사 같은 건 퍼사드 패턴인가요?"

맞습니다, 기획사는 여러 연예인들을 하나로 묶어서 팬들이나 대중들에게 보여주는 역할을 합니다. 하지만 프록시 패턴은 한 연예인에 집중해서 그 연예인의 이미지를 관리하고, 필요한 경우에만 팬들과 소통하게 하는 역할을 한다는 점에서 큰 차이점이 존재합니다.

예를 들어, 팬들이 연예인에게 갑자기 몰려들면, 매니저는 연예인을 보호하기 위해서 팬들과 직접 만나는 것을 막고 사인회를 열거나, 팬들에게 감사 인사를 전하는 영상을 촬영해서 보여줄 수 있습니다. 이렇게 하면 연예인의 사생활도 보호하고 팬들과의 소통도 원활하게 이어갈 수 있죠. 반면 기획사는 여러 연예인들을 모아서 콘서트를 개최하거나, 새로운 예능 프로그램에 출연시키는 등 전체적인 그림을 그리는 역할을 합니다.

 

해당 패턴을 구현하는 간단한 예시 코드를 보도록 하겠습니다.

 

class Celebrity { // 추상화 생략
    constructor(public name: string) {}

    meetFans(): void {
        console.log(`${this.name}이(가) 팬들을 만나서 인사합니다!`);
    }

    interview(media: string): void {
        console.log(`${this.name}이(가) ${media}와 인터뷰를 진행합니다.`);
    }
}

type CelebrityListener<T extends Celebrity> = (celebrity: T) => void;

class CelebrityManager<T extends Celebrity> {
    private _meetListeners: CelebrityListener<T>[] = [];
    private _isResting = true;
    private get isResting(): boolean {
        return this._isResting;
    }
    private set isResting(value: boolean) {
        if (this._isResting != value) {
            this._isResting = value;
            this.notifyMeetListeners();
        }
    }

    constructor(private target: T) {
        setTimeout(() => {
            this.isResting = true;
            this.notifyMeetListeners();
        }, 500);
    }

    requestMeetFans(callback: CelebrityListener<T>): void {
        if (this.isResting) {
            this._meetListeners.push(callback);
            console.log(`${this.target.name}님은 현재 휴식 중입니다, 곧 팬 사인회 일정을 공지하겠습니다!`);
        } else {
            callback((this.target.meetFans(), this.target));
        }
    }
    
    requestInterview(media: string, callback: CelebrityListener<T>): void {
        setTimeout(() => {
            callback((this.target.interview(media), this.target));
        }, 1000);
        console.log(`${this.target.name}님에 대한 인터뷰 요청 감사합니다!, 인터뷰 내용 조율 후 연락드리겠습니다.`);
    }

    notifyMeetListeners() {
        this._meetListeners.forEach(l => l((this.target.meetFans(), this.target)));
        this._meetListeners = [];
    }
}

const manager = new CelebrityManager(new Celebrity("땅콩"));
manager.requestMeetFans(celebrity => {
    console.log(`${celebrity.name}과 만남.`);
});
manager.requestInterview("이세계 채널", celebrity => {
    console.log(`${celebrity.name}과 인터뷰함.`);
});

// 출력:
// 죄송합니다, 당콩님은 현재 휴식 중입니다, 곧 팬 사인회 일정을 공지하겠습니다!
// 땅콩님에 대한 인터뷰 요청 감사합니다!, 인터뷰 내용 조율 후 연락드리겠습니다.
// 땅콩이(가) 팬들을 만나서 인사합니다!
// 땅콩과 만남.
// 땅콩이(가) 이세계 채널와 인터뷰를 진행합니다.
// 땅콩과 인터뷰함.

 

참고로 해당 패턴은 이러한 위 코드 구조 말고도 매우 많은 방법들이 있으므로 외워두기보단 기초적인 개념을 잘 숙지해놓는 것이 좋습니다.

 

또한 자세한 내용들은 따로 구글링하여 숙지해놓으시길 추천드립니다.

 

Happy coding in the another world!

 

https://mttankkeo.tistory.com/23