유사한 아이템들을 트리구조로 구성해야할 때, 컨테이너 노드와 컴포넌트노드들의 인터페이스를 통일시켜준다. 이렇게하면, 컨테이너와 컴포넌트를 구별할 필요가 없어져서 컨테이너 안에 다른 컨테이너도 포함가능해진다. 또한 인터페이스에 정의된 기능을 실행시, 트리구조상의 하위 전체에 쉽게 실행할 수 있다.
이해가 쉬운 실제 예들이 아주 많다. 디렉토리 파일구조를 생각해보면, 디렉토리안에 다른 디렉토리가 올 수 있고, rename, delete등의 기능들을 인터페이스로 정의하면 된다는걸 알 수 있을것이다.
또 다른 예는 그래픽 툴들을 볼 수 있다. 그래픽 오브젝트들은 가장 직관적인 OOP에 해당하는데, 이들을 그룹으로 묶어 이동하거나 크기를 변경하는등의 일이 가능하다. 이를 composite 패턴으로 구현하는건 매우 직관적이다.

위의 이미지는 오픈소스 3D 모델링툴인 Blender의 Collection 윈도우 모습이다. 모델링으로 생성한 여러 오브젝트들을 Collection이란 트리구조로 관리할 수 있다. 이런 것들을 만들 때 composite pattern이 사용될 것이다.
하나만 예를 더 들자면, 회사의 조직도에 따른 직원 관리다. 직원과 직책들은 OOP를 표현하기에 직관적이라서 예시로 많이 나오는데, 여기서도 그렇다. 직원들의 컨테이너가 부서에 해당한다. composite 패턴으로 구현하면, 부서 전체에 포상을 하거나, 하위부서 전체를 이동하는등의 작업이 용이해진다.
design pattern자체의 목적도 명확하고 이해하기 어렵진 않다. 이를 UML로 표현하면 다음과 같다.

Kotlin
구현을 해보자. 앞에서 봤던 Blender의 Collection을 간단하게만 흉내내 보겠다.
package composite
data class Position(var x: Double = 0.0, var y: Double = 0.0, var z: Double = 0.0)
abstract class CollectionComponent(name: String, pos: Position) {
var componentName = name
var position: Position = pos
abstract fun getCollection(): CollectionComposite?
abstract fun move(delta: Position)
abstract fun scale(factor: Double)
}
class CollectionComposite(name: String, pos: Position): CollectionComponent(name, pos) {
private var leafs: MutableList<CollectionComponent> = mutableListOf()
override fun getCollection(): CollectionComposite? {
return this
}
override fun move(delta: Position) {
// iterate sub tree
for(component in leafs){
component.move(delta)
}
}
override fun scale(factor: Double) {
// iterate sub tree
for(component in leafs){
component.scale(factor)
}
}
fun addComponent(component: CollectionComponent){
leafs.add(component)
}
fun deleteComponent(component: CollectionComponent){
leafs.remove(component)
}
fun getChilds(): List<CollectionComponent>{
return leafs
}
}
class ObjectLeaf(name: String, pos: Position): CollectionComponent(name, pos) {
override fun getCollection(): CollectionComposite? {
return null
}
override fun move(delta: Position) {
position.x += delta.x
position.y += delta.y
position.z += delta.z
println("move $componentName : $position")
}
override fun scale(factor: Double) {
println("scale $componentName : $factor")
}
}
UML과 매칭시키기 위해 의도적으로 이름에 Component, Composite, Leaf를 넣었다. composite에만 있는 add, delete 메소드는 Component에서 abstract로 만들고 Leaf에 동작하지 않는 구현을 추가할수도 있으나, 여기서는 getCollection 메소드를 만들어서 null이 아니면 composite 객체로 캐스팅해서 사용할 수 있게했다.
package composite
fun main(args: Array<String>){
val collection1: CollectionComponent = CollectionComposite("Collection1", Position(1.0, 1.0, 1.0))
val cylinder: CollectionComponent = ObjectLeaf("Cylinder", Position(0.0, 0.0, 0.0))
val collection2: CollectionComponent = CollectionComposite("Collection2", Position(0.0, 0.0, 0.0))
val box: CollectionComponent = ObjectLeaf("Box", Position(0.0, 0.0, 0.0))
val camera: CollectionComponent = ObjectLeaf("Camera", Position(10.0, 10.0, 3.0))
val light: CollectionComponent = ObjectLeaf("Light", Position(15.0, 10.0, 13.0))
val composite2 = collection2.getCollection() as CollectionComposite
composite2.addComponent(camera)
composite2.addComponent(light)
composite2.addComponent(box)
for(component in composite2.getChilds()) println(component.componentName)
println("")
val composite1 = collection1.getCollection() as CollectionComposite
composite1.addComponent(cylinder)
composite1.addComponent(composite2)
for(component in composite1.getChilds()) println(component.componentName)
println("")
box.move(Position(2.0, 2.0, 2.0))
println("")
composite1.move(Position(1.0, 1.0, 1.0))
}
Camera
Light
Box
Cylinder
Collection2
move Box : Position(x=2.0, y=2.0, z=2.0)
move Cylinder : Position(x=1.0, y=1.0, z=1.0)
move Camera : Position(x=11.0, y=11.0, z=4.0)
move Light : Position(x=16.0, y=11.0, z=14.0)
move Box : Position(x=3.0, y=3.0, z=3.0)
Component단에서 인터페이스로 만들어놓은 move, scale 메소드는 composite에 사용될 경우, iteration하며 하위트리 모든 객체가 적용되는걸 볼 수 있다.
Composite에 있는 delete에 대한 고민을 좀 했었는데, 객체를 생성하는건 client에서 하고 add는 단지 레퍼런스를 추가하는 작업이기 때문에 여기에서의 delete는 레퍼런스만 제거하고 있다. 만약, 객체의 삭제를 고려한다면 move, scale처럼 sub tree에 전부 적용될 수 있도록 고민이 필요하다.
핵심은 트리형태로 구성을 할 수 있고 클라이언트에서 그 구성이나 아이템에 대해 몰라도 하나의 component에 대해 move() 나 scale()을 호출하면 sub-tree까지 전부 적용될 수 있다는 점이다.
C#
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Composite
{
public abstract class CollectionComponent
{
string _name;
Vector3 _position;
public string Name
{
get => _name;
set => _name = value;
}
public Vector3 Position
{
get => _position;
set => _position = value;
}
public CollectionComponent(string name, Vector3 pos)
{
Name = name;
Position = pos;
}
abstract public CollectionComposite GetComposite();
abstract public void Move(Vector3 delta);
abstract public void Scale(double factor);
}
public class CollectionComposite: CollectionComponent
{
private List<CollectionComponent> leafs = new List<CollectionComponent>();
public CollectionComposite(string name, Vector3 pos): base(name, pos)
{
}
public override CollectionComposite GetComposite()
{
return this;
}
public override void Move(Vector3 delta)
{
foreach(var leaf in leafs)
{
leaf.Move(delta);
}
}
public override void Scale(double factor)
{
foreach(var leaf in leafs)
{
leaf.Scale(factor);
}
}
public void AddComponent(CollectionComponent comp)
{
leafs.Add(comp);
}
public void deleteComponent(CollectionComponent comp)
{
leafs.Remove(comp);
}
public List<CollectionComponent> GetChilds()
{
return leafs;
}
}
public class ObjectLeaf: CollectionComponent
{
public ObjectLeaf(string name, Vector3 pos): base(name, pos)
{
}
public override CollectionComposite GetComposite()
{
return null;
}
public override void Move(Vector3 delta)
{
Position = Position + delta;
Debug.Log("Move " + Name + ", " + Position);
}
public override void Scale(double factor)
{
Debug.Log("Scale " + Name + ", " + factor);
}
}
}
C# 7.0부터 적용된 property의 간단한 사용법을 적용했다. C#에선 super 키워드가 없고 base 키워드를 쓴다. 서브클래스 생성자에서 base()생성자로 인자들을 전달하고 있다. Kotlin에서는 없어서 Postion이란 유사클래스를 만들어 썼는데, 유니티에서 기본으로 사용하는 Vector3가 있으므로 이를 사용했다. Kotlin이나 Python에서 익숙한 for문은 C#에서는 foreach에 해당한다. for문은 C/C++과 같은 옛날 포맷을 갖는다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Composite
{
public class Client : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
CollectionComponent collection1 = new CollectionComposite("Collection1", Vector3.zero);
CollectionComponent cylinder = new ObjectLeaf("Cylinder", new Vector3(0, 0, 0));
CollectionComponent collection2 = new CollectionComposite("Collection2", Vector3.zero);
CollectionComponent box = new ObjectLeaf("Box", new Vector3(0, 0, 0));
CollectionComponent camera = new ObjectLeaf("Camera", new Vector3(10, 10, 3));
CollectionComponent light = new ObjectLeaf("Light", new Vector3(15, 10, 13));
CollectionComposite composite = collection2.GetComposite();
composite.AddComponent(camera);
composite.AddComponent(light);
composite.AddComponent(box);
foreach(var item in composite.GetChilds())
{
Debug.Log(item.Name);
}
composite = collection1.GetComposite();
composite.AddComponent(cylinder);
composite.AddComponent(collection2);
foreach (var item in composite.GetChilds())
{
Debug.Log(item.Name);
}
box.Move(new Vector3(2, 2, 2));
composite.Scale(1.3);
}
}
}
클라이언트 코드는 Kotlin과 거의 같다.

유니티에서 로그를 필요한것만 깔끔하게 텍스트로 뽑아내는걸 잘 모르겠다.
Python
from dataclasses import dataclass
from typing import List
@dataclass
class Position:
x: float = 0
y: float = 0
z: float = 0
def __add__(self, pos):
return Position(self.x + pos.x, self.y + pos.y, self.z + pos.z)
def __iadd__(self, pos):
self.x += pos.x
self.y += pos.y
self.z += pos.z
return self
class Component:
def __init__(self, name: str, pos: Position):
self._name: str = name
self._position: Position = pos
@property
def name(self):
return self._name
@name.setter
def name(self, value: Position):
self._name = value
@property
def position(self):
return self._position
@position.setter
def position(self, value: Position):
self._position = value
def get_composite(self):
raise NotImplementedError
def move(self, delta: Position):
raise NotImplementedError
def scale(self, factor: float):
raise NotImplementedError
class Composite(Component):
def __init__(self, name: str, pos: Position):
super().__init__(name, pos)
self._leafs: List[Component] = []
def get_composite(self):
return self
def move(self, delta: Position):
for leaf in self._leafs:
leaf.move(delta)
def scale(self, factor: float):
for leaf in self._leafs:
leaf.scale(factor)
def add_component(self, comp: Component):
self._leafs.append(comp)
def remove_component(self, comp: Component):
self._leafs.remove(comp)
def get_childs(self) -> List[Component]:
return self._leafs
class Leaf(Component):
def __init__(self, name: str, pos: Position):
super().__init__(name, pos)
def get_composite(self):
return None
def move(self, delta: Position):
self.position += delta
print(f"{self.name} move {self.position}")
def scale(self, factor: float):
print(f"{self.name} scale : {factor}")
Vector를 흉내내는 간단한 dataclass로 Position을 만들었다. 연산자 오버로딩을 위해, 빌트인 함수인 __add__(self, other), __iadd__(self, other) 를 오버로딩했다. __iadd__는 ‘+=’ 연산자에 해당한다.
클래스 이름들은 앞에서와 달리 편의상 Component, Composite, Leaf로 만들었다. property는 python의 방법으로 처리되는걸 볼 수 있다. backing field는 private에 해당하는 ‘_’를 붙여서 외부에서 엑세스가 불가하게 만들었다. abstract method들은 abs 모듈을 사용하지 않고 간단하게 예외를 발생시키도록 했다.
from composite.Component import *
def main():
collection1 = Composite("Collection1", Position(1, 1, 1))
cylinder = Leaf("Cylinder", Position(0, 0, 0))
collection2 = Composite("Collection2", Position(0, 0, 0))
box = Leaf("Box", Position(0, 0, 0))
camera = Leaf("Camera", Position(10, 10, 3))
light = Leaf("Light", Position(15, 10, 13))
collection2.add_component(camera)
collection2.add_component(light)
collection2.add_component(box)
for item in collection2.get_childs():
print(item.name)
collection1.add_component(cylinder)
collection1.add_component(collection2)
for item in collection1.get_childs():
print(item.name)
box.move(Position(2, 2, 2))
collection1.scale(1.3)
if __name__ == "__main__":
main()
Camera
Light
Box
Cylinder
Collection2
Box move Position(x=2, y=2, z=2)
Cylinder scale : 1.3
Camera scale : 1.3
Light scale : 1.3
Box scale : 1.3
클라이언트 코드는 앞에서와 크게 다르지 않은데, dynamic type언어이기에 굳이 component 타입을 annotation으로 추가하지 않았다. 캐스팅없이 자연스럽게 사용가능하다.
Conclusion
여러가지 언어로 구현해보면서 좋은점은, 반복작업중에 패턴에대한 이해도가 높아진다는 점이다. composite pattern으로 구현해놓으니 클라이언트에서 move(), scale() 사용이 너무나도 편해진걸 느꼈다. 이 지점이 바로 composite pattern의 핵심이라고 보여진다.