Development Artist

Let's Dive Deep into the Composite Pattern 본문

Research/Spring

Let's Dive Deep into the Composite Pattern

JMcunst 2025. 4. 2. 21:39
728x90
반응형

들어가며

패턴에 대한 공부를 하면서 가장 먼저 생각하는 것은

 

“그 패턴이 없을 때 코드를 먼저 짜보라. 어떤 부분을 해소하기 위함인가”

 

컴포지트(Composite) 패턴도 마찬가지다. 객체를 트리 구조로 표현하고, 개별 객체와 복합 객체를 동일하게 다루기 위한 이 패턴은, 없으면 코드가 얼마나 지저분해질 수 있는지를 몸소 느껴야 진가를 알 수 있다. 이 글에서는 컴포지트 패턴이 없는 세상이 얼마나 고통스러운지를 실제 사례와 함께 살펴보고, 이를 통해 왜 이 패턴이 중요한지 풀어보고자 한다.

컴포지트 패턴이 없는 세상

1. 클라이언트 코드가 복잡해짐 (타입 분기 지옥)

컴포지트 패턴이 없으면 가장 먼저 눈에 띄는 문제는 클라이언트 코드가 쓸데없이 복잡해진다는 것이다.

if (obj instanceof Leaf) {
    ((Leaf) obj).doSomething();
} else if (obj instanceof Folder) {
    for (Component child : ((Folder) obj).getChildren()) {
        child.doSomething();
    }
}

이런 코드는 새로운 타입이 추가될 때마다 instanceof 분기가 늘어난다. 유지보수는 점점 지옥을 향하고, OCP(Open-Closed Principle)는 깨진다. 클라이언트는 타입을 알지 못한 채 작업하고 싶어도, 현실은 타입 검사와 다운캐스팅의 반복이다.

2. 비즈니스 로직 중복

패턴 없이 설계하면 유사한 역할을 하는 객체들이 동일한 로직을 중복 구현하게 된다. 대표적인 예가 가격 계산 로직이다.

class Product {
    int getPrice() { return 1000; }
}

class ProductBundle {
    List<Product> products;
    int getTotalPrice() {
        int sum = 0;
        for (Product p : products) {
            sum += p.getPrice();
        }
        return sum;
    }
}

Product와 ProductBundle은 모두 '가격'을 다루지만, 구조가 다르기 때문에 중복된 로직을 별도로 작성해야 한다. 컴포지트 패턴을 적용하면 두 객체 모두 Component.getPrice()를 구현하고, 클라이언트는 이를 그대로 호출하면 끝난다.

3. 확장성 부족

계층 구조를 필요로 하는 시스템에서는 컴포지트 패턴의 부재가 더욱 치명적이다. 예를 들어 UI 위젯 시스템에서 Button, Label, Panel, ContainerPanel 같은 구성 요소를 만들어야 한다고 가정해보자.

 

컴포지트 없이 구현하면 다음과 같이 각 레벨마다 따로 코드를 작성해야 한다:

class Button {
    public void render() {
        System.out.println("Rendering Button");
    }
}

class Label {
    public void render() {
        System.out.println("Rendering Label");
    }
}

class Panel {
    private Button button;
    private Label label;

    public Panel(Button button, Label label) {
        this.button = button;
        this.label = label;
    }

    public void render() {
        System.out.println("Rendering Panel Start");
        button.render();
        label.render();
        System.out.println("Rendering Panel End");
    }
}

class ContainerPanel {
    private List<Panel> panels;

    public ContainerPanel(List<Panel> panels) {
        this.panels = panels;
    }

    public void render() {
        System.out.println("Rendering ContainerPanel Start");
        for (Panel panel : panels) {
            panel.render();
        }
        System.out.println("Rendering ContainerPanel End");
    }
}

이런 구조는 트리 구조로 깊이 중첩된 UI를 표현할 수 없고, 재귀적 렌더링도 불가능하다. 반면 아래 처럼 컴포지트 패턴을 적용하면, PanelUIComponent 리스트만 가지고 있으면 끝이다. 얼마나 자유로운가?

interface UIComponent {
    void render();
}

class Button implements UIComponent {
    public void render() {
        System.out.println("Rendering Button");
    }
}

class Label implements UIComponent {
    public void render() {
        System.out.println("Rendering Label");
    }
}

class Panel implements UIComponent {
    private List<UIComponent> children = new ArrayList<>();

    public void add(UIComponent component) {
        children.add(component);
    }

    public void render() {
        System.out.println("Rendering Panel Start");
        for (UIComponent child : children) {
            child.render();
        }
        System.out.println("Rendering Panel End");
    }
}

4. 테스트 코드가 복잡해짐

테스트에서도 고통은 이어진다. 추상화 없이 객체를 직접 분기해서 다뤄야 하기 때문에, 테스트 코드도 분기 투성이가 된다.

  • Mocking이 어렵고
  • Fake 객체 생성이 귀찮고
  • 새로운 기능 추가 시 테스트 코드도 전면 수정해야 한다

컴포지트 패턴이 있다면 공통 인터페이스만 테스트하면 된다. 단일 객체든 복합 객체든 같은 방식으로 다룰 수 있다.

5. 재사용성과 다형성 약화

패턴 없이 객체를 구성하면, 데이터 구조조차도 구분해서 다뤄야 한다.

List<Product> products = ...;
List<ProductBundle> bundles = ...;

ProductProductBundle을 함께 처리해야 할 경우? 또 새로운 구조가 추가되면? 이 구조는 끝없이 분기되고, 반복된다. 반면 컴포지트 패턴을 쓰면 List<Component>로 끝이다. 재사용성, 유연성, 다형성 3박자를 한 번에 챙긴다.


사용 팁

컴포지트 패턴은 단순히 “트리 구조를 표현하는 패턴”이라고만 알고 있으면 아쉽다.

실제로 쓰다 보면, 의외로 복잡한 상황에서 코드의 질서를 잡아주는 아주 강력한 도구가 된다.

하지만 제대로 활용하려면 몇 가지 중요한 팁들을 기억해 두는 게 좋다.

1. 안전한 설계: 역할에 따라 인터페이스를 분리한다

가장 흔한 실수는 Component 인터페이스에 add()remove()를 무조건 넣는 것이다.

그러면 Leaf 타입에도 자식 추가 기능이 생겨버려서, 클라이언트가 실수할 여지가 생긴다.

 

아래처럼 Composite 인터페이스를 따로 분리하면 그런 문제를 컴파일 타임에서 막을 수 있다.

interface Component {
    void operation();
}

interface Composite extends Component {
    void add(Component component);
    void remove(Component component);
}

class Leaf implements Component {
    @Override
    public void operation() {
        System.out.println("Leaf operation");
    }
}

class CompositeImpl implements Composite {
    private List<Component> children = new ArrayList<>();

    @Override
    public void operation() {
        for (Component child : children) {
            child.operation();
        }
    }

    @Override
    public void add(Component component) {
        children.add(component);
    }

    @Override
    public void remove(Component component) {
        children.remove(component);
    }
}

Leaf 클래스는 Component만 구현하고, 자식 추가/삭제는 Composite을 구현한 클래스에서만 가능하도록 한다.

이렇게 하면 실수할 여지를 줄일 수 있고, 설계 자체도 명확해진다.

2. 순환 참조는 반드시 막는다

컴포지트 패턴은 트리 구조를 전제로 설계된 것이다.

그런데 클라이언트가 실수로 조상 노드를 자식으로 다시 추가해버리면, 구조가 꼬이고 무한 루프나 StackOverflow 같은 문제가 생길 수 있다.

이럴 때는 자식 추가 시에 조상인지 아닌지를 체크해서 막아야 한다.

class CompositeImpl implements Composite {
    private List<Component> children = new ArrayList<>();
    private Composite parent;

    @Override
    public void operation() {
        for (Component child : children) {
            child.operation();
        }
    }

    @Override
    public void add(Component component) {
        if (component instanceof CompositeImpl) {
            CompositeImpl composite = (CompositeImpl) component;
            if (isAncestor(composite, this)) {
                throw new IllegalArgumentException("순환 참조가 발생했습니다.");
            }
            composite.setParent(this);
        }
        children.add(component);
    }

    @Override
    public void remove(Component component) {
        children.remove(component);
        if (component instanceof CompositeImpl) {
            ((CompositeImpl) component).setParent(null);
        }
    }

    private boolean isAncestor(Composite potentialAncestor, Composite current) {
        Composite parent = current.getParent();
        while (parent != null) {
            if (parent == potentialAncestor) {
                return true;
            }
            parent = parent.getParent();
        }
        return false;
    }

    public Composite getParent() {
        return parent;
    }

    public void setParent(Composite parent) {
        this.parent = parent;
    }
}

이 코드는 자식 요소를 추가할 때 순환 참조를 검사하여 무한 루프나 스택 오버플로우와 같은 문제를 방지한다.

3. 성능도 고려하여 최적화 하자

트리 구조가 깊어지면 재귀 호출로 인해 성능 저하가 발생할 수 있다. 이를 개선하기 위해 반복문을 사용한 순회나, 부모 노드에서 자식 노드로의 참조뿐만 아니라 자식 노드에서 부모 노드로의 참조를 추가하여 상향식 처리가 가능하도록 설계할 수 있다.

public void operationIterative() {
    Stack<Component> stack = new Stack<>();
    stack.push(this);

    while (!stack.isEmpty()) {
        Component current = stack.pop();
        current.operation();

        if (current instanceof CompositeImpl) {
            List<Component> children = ((CompositeImpl) current).getChildren();
            for (int i = children.size() - 1; i >= 0; i--) {
                stack.push(children.get(i)); // DFS 방식
            }
        }
    }
}

이 방식은 재귀 호출 대신 명시적인 스택을 써서 반복적으로 트리를 순회한다.

트리 구조가 아주 깊어도 JVM의 콜 스택을 타지 않기 때문에 안전하다.

class CompositeImpl implements Composite {
    private List<Component> children = new ArrayList<>();
    private CompositeImpl parent;

    public void setParent(CompositeImpl parent) {
        this.parent = parent;
    }

    public CompositeImpl getParent() {
        return parent;
    }

    public void notifyParent() {
        if (parent != null) {
            parent.handleChildUpdate(this);
        }
    }

    public void handleChildUpdate(Component child) {
        System.out.println("자식이 변경되었음을 감지함: " + child);
        // 업데이트 처리 후, 다시 위로 전달 가능
        if (parent != null) {
            parent.handleChildUpdate(this);
        }
    }
}

이렇게 하면 어떤 Leaf나 하위 노드에서 이벤트나 상태 변화가 발생했을 때

상위 노드로 연쇄적으로 이벤트를 전달하거나 처리할 수 있는 구조를 만들 수 있다.

4. 예외 처리는 명확하게 한다

클라이언트가 Leafadd()remove()를 호출했을 때 아무 반응이 없으면 오히려 혼란스럽다.

이럴 땐 명확하게 예외를 던져서, 잘못된 사용이라는 걸 확실하게 알려주는 게 좋다.

class Leaf implements Component {
    @Override
    public void operation() {
        System.out.println("Leaf operation");
    }

    public void add(Component component) {
        throw new UnsupportedOperationException("Leaf는 자식을 가질 수 없습니다.");
    }

    public void remove(Component component) {
        throw new UnsupportedOperationException("Leaf는 자식을 가질 수 없습니다.");
    }
}

이렇게 하면 클라이언트가 Leaf 객체에서 addremove 메서드를 호출하려고 할 때 예외가 발생하여 잘못된 사용을 방지할 수 있다.


결론

컴포지트 패턴은 구조가 단순해서 “이걸 꼭 써야 하나?” 싶을 수도 있다.

하지만 복잡한 트리 구조를 다루기 시작하면 얘기가 달라진다.

  • 자식과 부모의 경계가 명확하지 않거나
  • 타입 분기로 지저분한 코드가 많아지거나
  • 복합 객체와 단일 객체를 동일하게 다뤄야 할 때

 

이럴 때 컴포지트 패턴을 잘 써두면 구조가 단단해지고, 유지보수는 훨씬 편해진다.

728x90
반응형