본문 바로가기

CS/디자인패턴

[디자인패턴] Composite 패턴

Composite 패턴을 설명하는 그림

설명

 클라이언트 입장에서 개별적인 객체와 복합적인 객체를 동일하게 처리할 수 있도록 한다. Composite는 복합 객체로, 내부에 Component 추상 클래스를 상속하는 다른 객체들을 배열 등의 형태로 관리할 수 있다. 클라이언트는 복합 객체와 단일 객체의 구분 없이 동일하게 취급하며, 추상적인 Component라는 단위로 접근하게 된다.

구성 요소

  • Component: 모든 객체에 대한 인터페이스(추상 클래스)로, 객체들이 공통적으로 가지는 동작인 op 및 복합 객체에게 필요한 Add, Remove 등의 연산을 모두 정의하고 있다.
     리프 노드들 입장에서는 Add, Remove 등 연산이 필요하지 않지만, 리스코프 교체 원리를 따라 단일 객체와 복합 개체 모두가 Component라는 인터페이스 아래에서 동작해야 하므로  단순히 구현하지 않은 채로 놔둔다.
  • Leaf: 말단 객체로, 자식을 가지지 않는 가장 기본적인 객체이다.
  • Composite: 복합 객체로, 내부적으로 단일 객체 및 복합 객체들을 Component [ ] 형태로 보관한다. 여기서부터 Add, Remove 등 연산을 반드시 구현해야 한다.
  •  Client: 현재 패턴으로 구성된 단일 및 복합 객체를 Component라는 일관된 인터페이스를 통해 사용한다. 

  Leaf 노드 입장에서 Add, Remove와 같은 복합 객체를 위한 메서드는 사용되지 않기 때문에 차라리 관련된 메서드를 구체적인 Composite으로 옮기고, Component에는 공통된 Op들만 남기는 것이 낫다는 생각이 들 수 있다. 이 경우 아래 그림과 같은 구조를 가지게 된다.

형식 안정성을 고려한 설계

 이러한 측면에서 볼 때 Composite 패턴은 균일성과 형식 안정성 중 어디에 더 초점을 두는지에 따라 다른 설계를 가질 수 있다. 균일성을 추구하는 경우 Component 인터페이스에 모든 인터페이스를 몰아넣을 것이고, 안정성을 추구하는 경우 Component에서 Composite로 복합 객체를 조정하는 코드를 옮길 것이다. 각 방식은 저마다의 장단점이 있다.

  • 균일성에 초점을 두는 설계: Component에 모든 인터페이스가 모여 있기 때문에 Composite와 Leaf를 구분하지 않고 동일한 코드로 처리할 수 있다. 즉, Composite을 Component라는 인터페이스로 받더라도 이게 복합 객체인지 아닌지 구분할 필요가 없다는 것이 큰 장점이다. 반면 단일 객체들에서 Add, Remove 메서드를 접근할 수 있으므로 안정성이 떨어지는 편이다.
  • 형식 안정성에 초점을 두는 설계: 복합 객체와 관련된 작업을 Composite에서만 수행할 수 있으므로 Leaf를 통해 Add, Remove와 같은 메서드를 접근할 수 없게 되어 안정성이 확보된다. 다만 Composite 패턴을 도입하기 이전처럼 복합 객체적인 동작을 다루기 위해 타입 체크가 요구되므로, 일관성이 다시 떨어진다.

 위에 표현한 내용만 봐도 알 수 있듯이, Composite 패턴은 형식 안정성보다는 균일성에 초점을 둔 설계이다. 구현에 따라 단일 객체에서 복합 객체 전용 메서드를 접근할 수 있는 위험성이 존재하지만, 대신 일관된 방식으로 처리가 가능하다. 높은 범용성에 의한 위험과 일관된 접근을 통한 장점을 교환한 구조라고 볼 수 있다.

구현 고려사항

 구조에 따라 구현할 때 고려할만한 사항들은 다음과 같다.

  1. 자식이 부모를 참조할 수 있도록 포인터를 둘 수 있다. 이 경우 구조의 관리가 단순해지고, 책임 연쇄 패턴을 구현할 때 도움이 된다고 한다. 이때 어떤 객체 타입인지와 관계없이 부모를 가질 수 있으므로 부모에 대한 정의는 Component 인터페이스 수준에 명시되며, Composite에서 자식을 추가하거나 삭제할 때만 부모를 설정하도록 구현한다.
  2. 공통적인 구성요소의 경우 여러 객체에서 공유하는 편이 메모리 측면에서 좋을 것이다. 다만 (1)의 정의에서는 자식이 하나의 부모만 가질 수 있기 때문에 공유가 어려워진다. 구성요소를 자식으로 가지더라도 구성요소 입장에서는 제한된 하나만 부모로 표현될 것이기 때문이다. 이러한 문제를 해결하기 위해 자식뿐만 아니라 부모 자체도 여러 개를 가질 수 있도록 구성하기도 한다. 이러한 객체가 굳이 구분될 필요가 없다면 FlyWeight 패턴을 도입할 수 있다고 한다.
  3. Composite 패턴은 균일성에 초점을 맞춰 단일 객체와 복합 객체에 공통된 동작 말고도 복합 객체만을 위한 동작들도 Component의 인터페이스에 정의한다. 단일 객체와 복합 객체는 이러한 인터페이스들을 오버라이딩하여 사용하는데, 단일 객체 입장에서는 복합 객체의 동작들이 의미 없으므로 상속에 위배되는 경향이 있다.
  4. 일단 균일성(투명성)에 집중하는 구조를 선택했다면 자식을 어떻게 관리할 수 있을지 고민해야 한다. 원론적으로 리프 노드에서 Add, Remove를 수행하려고 시도할 때 실패하도록 만드는 것이 좋다.

이외의 고려 사항들은 gof 책을 참고.

예시 코드

import {stdout} from 'process';

export abstract class Figure {
    parent?: Figure;
    abstract draw(): void;
    abstract move(): void;
    add(f: Figure) {
        throw new Error("must implemented before use");
    }
    remove(f: Figure) {
        throw new Error("must implemented before use");
        // nothing
    };
    getChild(num: number): Figure|undefined {
        throw new Error("must implemented before use");
    }
    get composite(): Figure|null {
        return null; // composite 객체에서는 다시 구현!
        // gof 책에서 설명한 패턴 중 하나.
        // 단, 이 방식은 비효율적. 연산이 가능한지 비교하는 것과 동일.
        // if문 돌리는 것과 다르지 않음.
    }
}

export class Circle extends Figure {
    draw() {
        console.log("●: circle!");
    }
    move() {
        console.log("->●: circle move!");
    }
}

export class Triangle extends Figure {
    draw() {
        console.log("▲: triangle!");
    }
    move() {
        console.log("->▲: triangle move!");
    }
}

export class Line extends Figure {
    draw() {
        console.log("↕:Line!");
    }
    move() {
        console.log("↗:Line move!");
    }
}

export class Group extends Figure {
    private name: number;
    private figures: Figure[];
    constructor(name: number) {
        super();
        this.name = name;
        this.figures = [];
    }
    draw(): void {
        console.log("Group!");
        this.figures.forEach(it => {
            stdout.write(`  ${this.name} `);
            it.draw();
        });
    }
    move(): void {
        console.log("Group move!");
        this.figures.forEach(it => {
            stdout.write(`  ${this.name} `);
            it.move();
        });
    }
    override add(f: Figure) {
        f.parent = this;
        this.figures.push(f);
    }

    override remove(f: Figure) {
        const didx = this.figures.indexOf(f);
        if(didx >= 0) { // 존재하면
            this.figures.splice(didx,1); // 자식에서 제거
            delete f.parent;
        }
    }

    override getChild(num: number) {
        return this.figures[num];
    }

    override get composite() {
        return this;
    }
}

  abstract class인 Figure은 Composite 패턴의 Component 역할을 수행한다. add, remove, getChild와 같이 리프 노드에서 사용할 수 없는 메서드의 경우 기본 구현을 통해 Error을 반환하도록 작성했으며, 복합 객체인 Group에서는 해당 메서드들을 구현하고 있는 모습을 볼 수 있다.

// 실행 코드
import { Circle, Group, Line, Triangle } from './composite-pattern.js';

export function main() {
    const base2 = new Group(1);
    base2.add(new Triangle());
    base2.add(new Triangle());
    base2.add(new Triangle());


    const base3 = new Group(2);
    base3.add(new Circle());
    base3.add(new Triangle());
    base3.add(new Line());

    const base = new Group(3);
    base.add(new Circle());
    base.add(new Circle());
    base.add(base2);
    base.add(new Circle());
    base.add(new Triangle());
    base.add(base3);
    base.add(new Line());
    base.add(new Line());
    base.getChild(2).add(new Line()); // 이런 동작이 가능해진다.
    base.getChild(5).composite?.add(new Line()); // Composite 객체인 것을 몰라도 동작 가능
    
    console.log(base2.parent);
    console.log(base3.parent);

    base.move();
    base.draw();

    base.remove(base2);
    console.log(base2.parent);
}

출력된 결과

 메인 함수 내에서는 Composite, Leaf 등을 선언하고 부모 자식 관계를 지정한 후 base 복합객체를 마치 단일객체인 것처럼 사용하고 있다. Component 타입으로 인식되는 base의 자식에 접근, 리프 노드를 추가하는 모습도 볼 수 있다.

'CS > 디자인패턴' 카테고리의 다른 글

[디자인패턴] Facade 패턴  (0) 2023.04.30
[디자인패턴] SOLID 원칙  (0) 2023.04.27
[디자인패턴] Bridge 패턴  (0) 2023.04.25
[디자인패턴] Singleton 패턴  (0) 2023.04.20
[디자인패턴] Adapter 패턴  (1) 2023.04.14