본문 바로가기

CS/디자인패턴

[디자인패턴] 팩토리 패턴들

팩토리 관련 패턴은 대략 3개로 나눌 수 있다.

  1. 단순 팩토리 패턴: 객체 생성 로직을 캡슐화하여 클라이언트와의 결합도를 낮춘다.
  2. 팩토리 메서드 패턴: 객체 생성에 대한 인터페이스만 정의하고, 구체적인 생성은 서브 클래스에게 위임한다.
  3. 추상 팩토리 패턴: 함께 사용되거나 연관된 패밀리 객체 군을 생성하는 인터페이스를 정의한다. 각 객체를 생성하기 위한 인터페이스(메서드 각각)들을 정의할 때 팩토리 메서드나 프로토타입 패턴이 사용될 수 있다.

팩토리 메서드 패턴과 추상 팩토리 패턴의 경우 DIP를 중점적으로 생각하자...


단순 팩토리 패턴

단순 팩토리를 정말 간단하게 묘사한 그림

 객체를 생성하는 로직을 클라이언트로부터 숨겨 클라이언트와 객체 생성 책임 사이의 결합도를 낮추기 위한 패턴이다. 버튼 클래스의 서브 클래스들 중 하나를 선택하는 상황을 생각해 보자.

function main() {
    // 데이터를 읽어오는 로직이 있다고 가정.
    const btnType: string = 'red';
    let btn: Button1;
    switch(btnType) {
        case 'black':
            btn =  new BlackButton1();
            break;
        case 'white':
            btn = new WhiteButton1();
            break;
        case 'red':
            btn =  new RedButton1();
            break;
        default:
            return null;
    }
    if(btn) {
        btn.click();
    }
}

 클라이언트에 해당하는 main 함수에서는 외부로부터 입력받은 btnType 변수의 값을 이용하여 구체적인 객체를 선택한다. 만약 요구 사항이 추가되어 새로운 버튼을 추가한다면 switch 문의 case 관련 코드도 추가되어 main에 직접적인 변경을 가져온다. 이는 클라이언트의 main 함수가 구체적인 클래스들을 생성하는 책임과 결합되기 때문에 발생하는 문제로, Button 타입 클래스 생성의 책임을 별개로 분리하여 캡슐화하면 해결된다. 여기서 단순 팩토리를 사용할 수 있다.

// 팩토리 클래스
class SimpleButtonFactory {
    static createButton(btnType: string): Button1|null {
        switch(btnType) {
            case 'black':
                return new BlackButton1();
            case 'white':
                return new WhiteButton1();
            case 'red':
                return new RedButton1();
            default:
                return null;
        }
    }
}

function main2() {
    const btnType: string = 'red';
    // 구체적인 생성 로직은 팩토리 클래스에게 위임.
    // 팩토리 클래스만 변경되면 된다.
    const btn = SimpleButtonFactory.createButton(btnType);
    if(btn) {
        btn.click();
    }
}

  위 코드에서는 SimpleButtonFactory 클래스에게 Button 서브 클래스에 대한 인스턴스 생성 역할을  캡슐화하여 main 함수와 구체적인 로직의 결합을 끊었다.

 외부 입력에 대응해서 구체적인 객체를 생성해야 한다면, 코드 내 어디선가는 if / else 또는 switch / case 문을 이용하여 생성 로직을 작성해야 한다. 팩토리 패턴은 이런 생성 로직을 캡슐화하여 구체적인 로직과 외부 사이의 결합도를 낮춘다.

https://github.com/blaxsior/codesolveronline/blob/master/backend/src/scoring/ScoringProvider.ts

 과거에 개별 연구 과목에서 채점 시스템을 구현할 때, 사용자가 작성한 코드에 대응되는 컴파일러 및 인터프리터를 대응하기 위해 팩토리 패턴을 이용한 적이 있었다. 사용자가 프론트엔드에서 선택한 언어는 코드 등의 정보와 함께 백엔드로 전달된다. 전달된 언어의 종류를 ScoringProvider에 입력하여 구체적인 ScoringManager을 얻도록 구성했다.

팩토리 메서드 패턴

팩토리 패턴을 설명하는 그림

 객체 생성에 대한 인터페이스만 정의하고, 구체적인 생성은 서브 클래스에게 위임한다. 팩토리 메서드는 위 그림 상에서 FactoryMethod( )라고 명시된 메서드를 의미하며, 현재 패턴은 Factory에서 정의한 팩토리 메서드를 구체적 클래스인 ConcreteFactory에서 구현하는 방식으로 동작한다. 팩토리 메서드라고 이름 붙은 이유는 해당 메서드가 객체를 제조(manufacture)하는 방법을 알기 때문이다. 

 gof 책에서는 다양한 종류의 문서를 표현하는 응용 프로그램에 대한 프레임워크를 예시로 현재 패턴을 설명한다.

  • 문서(Document)와 응용프로그램(Application)은 추상화한다. ( 결합도를 낮추기 위한 설계로 예상 )
  • 특정 응용 프로그램에 종속적인 클래스 및 문서를 서브 클래스로 구현한다.
  • 응용프로그램은 자신과 연관된 문서를 생성 및 관리할 수 있다.

 여기서 발생하는 문제는 Application 클래스는 추상 클래스라 어떤 문서의 인스턴스를 생성해야 하는지 모른다는 점이다. 거의 모든 코드가 유지보수를 위해 Document 및 Application라는 추상적 수준에서 서술되지만, 실제로는 구체적 객체들끼리 동작하므로 Application의 서브클래스는 최소한 자신이 관리할 문서를 알아야 한다.

 팩토리 메서드 패턴은 Application이 가진 Document 생성 책임을 구체적인 서브클래스에게 넘겨 이 문제를 해결한다. Application 수준에서는 Document 객체를 생성하기 위한 인터페이스만 정의하고, 무엇을 생성할지는 Application의 서브클래스들이 정하게 만드는 것이다. 아마 다음 코드를 보면 이해가 될 것이다.

export abstract class Document { // 추상적 레벨의 문서
    protected metaData: string;
    constructor() {
        this.metaData = "";
    }
    get Metadata() {
        return this.metaData;
    }

    set Metadata(data: string) {
        this.metaData = data;
    }

    abstract open():void;
    abstract close():void;
    save() {
        console.log("save");
    }
    revert() {
        console.log("revert");
    }
}

export abstract class Application { // 추상적 레벨의 어플리케이션
    protected docs: Document[];
    constructor() {
        this.docs = [];
    }
    abstract createDocument(): Document;
    newDocument() {
        let doc = this.createDocument();
        doc.open();
    }

    openDocument() {
        // do something
    }

    get Documents(): readonly Document[] {
        return this.docs;
    }
}

 위 코드의 Document 및 Application은 추상 클래스로 정의되어, 추상적 수준에서 동작한다. Application에서 새로운 Document 객체를 생성하기 위해서는 createDocument 메서드를 호출해야 한다. 이때 앞서 언급했듯이 Application 자체는 추상적 레벨에서 동작하기 때문에 구체적으로 어떤 문서를 반환해야 할지 알 수 없다. 문서의 종류는 구체적인 응용프로그램에 종속되기 때문이다. 따라서 해당 메서드를 abstract로 선언하여 서브클래스에게 생성 책임을 넘긴다.

class TextDocument extends Document { // 구체적 레벨의 문서
    override open(): void {
        console.log("open text document");
    }
    override close(): void {
        console.log("close text document");
    }
}

export class TextApplication extends Application { // 구체적 레벨의 어플리케이션
    override createDocument(): Document {
        return new TextDocument;
    }
}

function logMetaOfDoc(app: Application) {
    for(const doc of app.Documents) {
        console.log(doc.Metadata);
    }
}

 구체적 수준의 애플리케이션인 TextApplication은 자신이 관리하는 구체적 문서인 TextDocument을 생성하는 책임을 createDocument 메서드를 구현함으로써 달성한다. 이렇게 코드를 작성하면 외부에서는 Application이라는 추상적 인터페이스 수준에서 객체에 접근할 수 있게 된다. 이 예시에서는 템플릿 메서드와 사용법이 유사하다.

 이런 부분에서 gof책에서는 팩토리 메서드를 정의하는 클래스를 Factory라고 칭하지 않는다. 팩토리 메서드 패턴을 적용한다고 반드시 팩토리(생성 책임만 존재)는 아니기 때문이다. 위 예시에서 Application 클래스는 구체적인 로직을 가지고 있으며, Document을 생성하는 것이 주된 기능도 아니다.

참여자

  1. Product: 팩토리 메서드가 생성할 객체의 인터페이스
  2. ConcreteProduct: Product 클래스에 정의된 인터페이스를 구현하는 구체적 클래스
  3. Creator: 팩토리 메서드를 선언한 추상적 레벨의 클래스
  4. ConcreteCreator: 팩토리 메서드를 오버라이딩하여 연관된 ConcreteProduct을 반환하는 클래스 

사용하는 경우

  1. 어떤 클래스가 자신이 생성해야 하는 객체의 클래스 타입을 예측할 수 없을 때 ( Application의 경우 )
  2. 객체 생성에 대한 책임을 서브 클래스가 맡았으면 할 때
  3. 객체 생성에 대한 책임을 보조 서브클래스 중 하나에게 맡기고, 누가 위임받았는지는 최소화하고 싶을 때
    (  개인적으로 무슨 소리인가 싶어서 회색으로. 누가 생성하는지 몰라도 되는 상황을 의미하는 것으로 예측 )

결과

  • 팩토리 메서드는 필요에 따라 서브클래스에서 재정의하여 객체 생성 로직을 변경할 수 있으므로 응용성이 높아진다.
  • 클래스를 책임에 따라 분리하는 일부 상황에서 병렬적인 클래스 계통을 연결한다. ( gof 참고 )

구현시 고려할만한 점

  • 팩토리 메서드에 파라미터를 설정하여 특정 객체를 생성하도록 만들 수도 있다. ( headfirst 피자 예시 )
  • Product 하나가 추가될 때마다 서브클래싱을 하는 대신 제네릭 문법을 이용할 수 있다.

 타입스크립트 환경에서는 제네릭 문법 부분을 다음과 같이 구현할 수 있다.

export class DefaultApplication<T extends {new(...args: any[]): Document}> extends Application {
    constructor(protected ctor: T) {
        super();
    }
    override createDocument(): Document {
        return new this.ctor();
    }
}
// 사용 예시
const myapp = new DefaultApplication(TextDocument);

  타입스크립트는 근본을 자바스크립트에 두고 있는 언어라, 컴파일 과정에서 제네릭 정보가 전부 날아간다. 따라서 컴파일 언어와는 달리  Document에 대한 constructor을 외부에서 받아 내부에 protected/private 변수로 저장하여 사용해야 한다.

 개인적으로 이 파트는 gof 책 예제가 상당히 좋은 것 같아 해당 코드를 타입스크립트 버전으로 바꿔 제시한다.

import { Door, DoorNeedingSpell } from "./door.js";
import { Maze } from "./maze.js";
import { EnchantedRoom, Room, RoomSide, RoomWithBomb } from "./room.js";
import { BombedWall, Wall } from "./wall.js";

// -> 팩토리 메서드 사용하자.
export abstract class MazeGame {
    // C++ 기준으로는 virtual function으로 설정되어 있으나
    // typescript에서는 abstract 선언하면 구현 자체가 불가능.
    // 기본 구현이라고 생각하자.
    makeMaze(): Maze {
        return new Maze();
    }

    /**
     * @abstract
     */
    makeRoom(n: number): Room {
        return new Room(n);
    }

    /**
     * @abstract
     */
    makeWall(): Wall {
        return new Wall();
    }

    makeDoor(r1: Room, r2: Room): Door {
        return new Door(r1, r2);
    }

    createMaze() {
        const maze = this.makeMaze(); // maze 생성

        const r1 = this.makeRoom(1); // 팩토리 메서드 사용
        const r2 = this.makeRoom(2); // 팩토리 메서드 사용
        const door = this.makeDoor(r1, r2); // 팩토리 메서드 사용

        maze.addRoom(r1);
        maze.addRoom(r2);

        r1.setSide(RoomSide.North, this.makeWall());
        r1.setSide(RoomSide.East, door);
        r1.setSide(RoomSide.South, this.makeWall());
        r1.setSide(RoomSide.West, this.makeWall());


        r2.setSide(RoomSide.North, this.makeWall());
        r2.setSide(RoomSide.East, this.makeWall());
        r2.setSide(RoomSide.South, this.makeWall());
        r2.setSide(RoomSide.West, door);

    }
}

 미로 찾기 게임을 만들기 위한 MazeGame 클래스는 createMaze 메서드를 통해 게임을 생성한다. make~ 의 이름을 가진 메서드들은 팩토리 메서드에 해당하며, MazeGame 클래스를 상속한 구체적 클래스에서 해당 메서드들을 오버라이딩하여 쉽게 부품들을 변경할 수 있다.

export class BombedMazeGame extends MazeGame {
    override makeWall(): Wall {
        return new BombedWall();
    }

    override makeRoom(n: number): Room {
        return new RoomWithBomb(n);
    }
}

export class EnchantedMazeGame extends MazeGame {
    override makeRoom(n: number): Room {
        return new EnchantedRoom(n);
    }

    override makeDoor(r1: Room, r2: Room): Door {
        return new DoorNeedingSpell(r1, r2);
    }
}

 BombedMazeGame 및 EnchantedMazeGame 클래스는 일부 팩토리 메서드만을 오버라이딩했다.


추상 팩토리 패턴

추상 팩토리 패턴을 설명하는 그림

 추상 팩토리는 연관 또는 종속 관계를 가진 패밀리 객체들(같이 사용되는 무리)에 대해 해당 객체를 생성하기 위한 인터페이스만 제공하고 구체적인 객체의 생성은 하위 클래스에게 위임하는 패턴으로, 서브 클래스를 구체적으로 지정하지 않고도 객체 모음을 생성할 수 있도록 한다.

 추상 팩토리 패턴은 일반적으로 팩토리 메서드 패턴을 이용하여 ( 하나의 팩토리에 여러 개의 팩토리 메서드를 사용 ) 구현되며, 이 측면에서 생각하면 각각의 createProduct 메서드를 팩토리 메서드로 볼 수 있다. 그렇다면 팩토리 메서드 패턴과 추상 팩토리 패턴의 차이점이 뭔가 하고 생각할 수 있다. 차이점들은 다음과 같다.

(https://www.youtube.com/watch?v=AqOwn4-NuNg)

  팩토리 메서드 추상 팩토리
다른 이름 Virtual-Constructor Kit, factory of factories
설명 객체 생성 목적의 인터페이스를 정의한다. 객체를 생성하는 책임은 서브 클래스에게 위임한다. 구체적인 서브클래스를 명시하지 않고 연관된 패밀리 객체들을 함께 제공한다.
연관되어 함께 사용하고 싶음 + 해당 객체들의 타입은 추상적으로 밖에 알림.
패밀리를 모아두는 것이 관점
달성 방식 상속 기반 컴포지션 기반
목적 일반적으로 단일 제품을 생성하는 목적.
다중 제품도 반환할 수는 있음.
연관된 제품 패밀리 군을 생성
추상화 방식 객체가 생성되는 방식을 추상화
( 추상화된 인터페이스 기반 )
객체를 생성 방식을 추상화하는 팩토리를 추상화.

 위에 언급된 표현인 Kit을 보면 알 수 있듯이, 추상 팩토리의 요점은 함께 사용되는 클래스 목록을 묶는 것이다. 환경에 따라 사용되는 키트는 달라진다. 따라서 "이런 클래스들을 사용해~"라는 의미를 담은 추상 팩토리 인터페이스를 정의한 후  "이런 클래스" 들을 각 환경에 맞게 채워 구체적인 팩토리를 만든다.

Night 모드

 다크 모드를 지원하는 UI 시스템을 생각해 보자. 다크 모드에서는 검정 배경과 하얀 글씨를 사용한다. 유채색의 컴포넌트들은 채도 또는 명도가 낮아질 수 있다. 반면 일반 모드에서는 하얀 배경과 검정 글씨를 사용한다. 이외의 색들은 일반적인 형태로 나타난다.

 위와 같은 상황에서 검정 배경, 낮은 명도의 컴포넌트 및 하얀 글씨는 다크모드 킷(kit)에 포함되어 함께 사용되는 편이 좋다. 반대로 어떤 경우에도 흰 바탕에 흰 글씨, 검은 바탕에 검은 글씨가 등장하는 상황은 발생하면 안 된다. 이런 제약을 위해서는 언급한 요소들을 하나의 킷 단위로 관리해야 한다.

 현재는 겨우 2개 테마밖에 존재하지 않으므로 if / else 문을 이용해서 각각의 컴포넌트를 일일이 바꿔 렌더링 하더라도 큰 문제는 없다. 그런데 만약 색맹을 위한 테마를 새로 추가하는 등 새로운 테마가 추가된다면? 각 컴포넌트들에 대해 조건문으로 추가하기에는 너무 넓은 범위에서 변화가 발생한다. 즉, 코드의 수정이 자유롭지 않은 상태가 된다.

 이런 문제를 피하기 위해서는 구체적으로 작성된 내용들을 추상적인 수준으로 끌어올려 변경을 최소화하고, 킷 단위로 쉽게 전체 컴포넌트를 변경할 수 있어야 한다. 여기서 추상 팩토리는 추상적 수준의 인터페이스에 해당하고, 각 모드에 대응되는 킷들은 구체적인 클래스들에 해당한다.

import { Button, DarkButton } from "./button.js";
import { Component, DarkComponent, WhiteComponent } from "./component.js";
import { DarkText, Text, WhiteText } from "./text.js";

export interface AbstractFactory {
    createButton(): Button;
    createComponent(): Component;
    createText(): Text;
}

export class DarkModeFactory implements AbstractFactory {
    createButton(): Button {
        return new DarkButton();
    }
    createComponent(): Component {
        return new DarkComponent();
    }
    createText(): Text {
        return new WhiteText();
    }
}

export class WhiteModeFactory implements AbstractFactory {
    createButton(): Button {
        return new WhiteButton();
    }
    createComponent(): Component {
        return new WhiteComponent();
    }
    createText(): Text {
        return new DarkText();
    }
}
// factory가 사용되는 코드
function batch(factory: AbstractFactory) {
    const button = factory.createButton();
    const component = factory.createComponent();
    const text = factory.createText();

    // UI 상에 적당히 배치
}

 위 설명한 예시를 간단하게 표현했다. AbstractFactory 인터페이스는 킷에 속한 객체를 생성하는 데 사용되는 createButton, createComponent 및 createText를 단순히 정의하기만 한다. 실제 객체 생성의 책임은 해당 인터페이스를 구현하는 DarkModeFactory, WhiteModeFactory에게 위임되며, 이 서브클래스들은 자신의 모드(다크, 기본)에 맞는 객체를 생성한다.

 추상 팩토리를 선언한 덕분에 외부 클라이언트(위의 경우 batch 함수)는 추상적 레벨에서 AbstractFactory 클래스와 동작하므로 새로운 팩토리 추가에 대해 열려 있는 구조, 즉 확장에 열려있는 구조가 된다.  이때 새로운 팩토리 추가에 열려있을 뿐, 팩토리 내 패밀리 객체 추가에 열려있는 것은 아니다. 객체를 생성하는 부분은 추상적인 부분(추상 팩토리)에 묶여 있으므로, 패밀리 객체 하나를 추가하는 행위는 연관된 모든 구체적 팩토리에 영향을 준다.

참여자

  1. Abstract Factory: 추상적 컴포넌트들을 생성하는 연산을 정의하는 인터페이스
  2. Concrete Factory: 구체적인 컴포넌트를 생성하는 연산을 구현하는 클래스
  3. Abstract Product: 컴포넌트에 대한 추상적 인터페이스
  4. Concrete Product: 구체적인 제품에 대한 객체
  5. Client: Abstract Product 인터페이스를 사용하는 외부 요소

결과

  • 구체적인 클래스를 분리한다. 팩토리는 생성 과정과 책임을 캡슐화한 것으로, 구체적인 구현은 사용자로부터 분리된다. 일반 프로그램은 추상 팩토리를 통해 인스턴스에 접근하며, 구체적인 클래스 명이 나타나지 않는다.
  • 코드들이 추상적인 개념 위주로 묶여 있으므로 팩토리 교체가 쉽고, 전체 제품군을 한번에 교체할 수 있다.
  • 팩토리가 제공하는 패밀리들은 함께 동작하도록 설계되어 있으므로 일관성이 쉽게 보장된다.
  • 팩토리에 새로운 객체를 추가하는 행위는 추상적 인터페이스를 변경, 서브클래스에 영향을 주므로 어렵다.

 파라미터로 만들고 싶은 객체에 대한 정보를 넘기도록 구현하면 단일 메서드만으로도 모든 패밀리 객체를 생성하도록 코드를 작성할 수 있다. 다만 이런 방식으로 획득한 객체는 타입을 유추하기 힘들기 때문에 차후 조건 검사나 동적 타입 변환을 요구한다. 타입 안전성과 확장성 사이의 선택 사항이라고 볼 수 있다.

 구체적인 팩토리들에게 복잡한 로직이 지정되어 있지 않다면 팩토리 자체를 싱글톤으로 구성할 수 있다.

코드

 팩토리 메서드에서 사용했던 Maze 예제를 사용한다.

// MazeGame은 팩토리를 파라미터로 받는다.
import { MazeFactory } from "./maze_factory.js";
import { RoomSide } from "./room.js";

export abstract class MazeGame {

    createMaze(factory: MazeFactory) {
        const maze = factory.makeMaze(); 

        const r1 = factory.makeRoom(1); 
        const r2 = factory.makeRoom(2); 
        const door = factory.makeDoor(r1, r2); 

        maze.addRoom(r1);
        maze.addRoom(r2);

        r1.setSide(RoomSide.North, factory.makeWall());
        r1.setSide(RoomSide.East, door);
        r1.setSide(RoomSide.South, factory.makeWall());
        r1.setSide(RoomSide.West, factory.makeWall());


        r2.setSide(RoomSide.North, factory.makeWall());
        r2.setSide(RoomSide.East, factory.makeWall());
        r2.setSide(RoomSide.South, factory.makeWall());
        r2.setSide(RoomSide.West, door);

    }
}

  팩토리메서드 패턴에서는 객체의 생성 책임이 MazeGame 클래스에 부여되어 있었다. 반대로 추상 팩토리 패턴을 이용하면 객체에 대한 생성 책임이 MazeFactory로 이동하므로 MazeGame은 구체적인 객체에 대한 사실을 몰라도 된다.

import { Door, DoorNeedingSpell } from "./door.js";
import { Maze } from "./maze.js";
import { EnchantedRoom, Room, RoomWithBomb } from "./room.js";
import { BombedWall, Wall } from "./wall.js";

export abstract class MazeFactory {
    // C++ 기준으로는 virtual function으로 설정되어 있으나
    // typescript에서는 abstract 선언하면 구현 자체가 불가능.
    // 기본 구현이라고 생각하자.
    makeMaze(): Maze {
        return new Maze();
    }

    /**
     * @abstract
     */
    makeRoom(n: number): Room {
        return new Room(n);
    }

    /**
     * @abstract
     */
    makeWall(): Wall {
        return new Wall();
    }

    makeDoor(r1: Room, r2: Room): Door {
        return new Door(r1, r2);
    }
}

export class BombedMazeFactory extends MazeFactory {
    override makeWall(): Wall {
        return new BombedWall();
    }

    override makeRoom(n: number): Room {
        return new RoomWithBomb(n);
    }
}

export class EnchantedMazeFactory extends MazeFactory {
    override makeRoom(n: number): Room {
        return new EnchantedRoom(n);
    }

    override makeDoor(r1: Room, r2: Room): Door {
        return new DoorNeedingSpell(r1, r2);
    }
}

 MazeFactory에서는 구체적인 객체 생성의 책임을 담당한다. 일반적으로 추상 팩토리는 인터페이스만 정의하지만, 현재 상황에서는 오버라이딩되지 않은 메서드들에 대해 기본값을 제공하기를 원하므로 각 메서드가 구현되어 있다.

  구체적 팩토리인 BombedMazeFactory 및 EnchantedMazeFactory에서는 인터페이스를 오버라이딩하여 필요한 객체를 생성할 수 있도록 구성한다.


 팩토리 패턴들은 클라이언트 코드와 객체 생성을 분리하여 둘 사이의 결합도를 낮추는데, 이를 통해 변경에는 닫혀 있고 (수평적) 확장에는 열려 있는 구조를 만들어준다. 하지만 패턴 자체가 처음 볼 때 목적을 이해하기 쉽지 않고, 코드 구조가 상당히 복잡하고 이해하기 어려워지는 단점이 있다. 디자인 패턴은 모든 상황에서 정답은 아니므로, 패턴을 무작정 도입하기보다는 정말 현재 이 패턴을 사용하는 것이 좋은지 판단하는 자세가 필요할 것 같다.