
객체지향의 사실과 오해책을 읽던 도중, 디자인 패턴의 한 예시로 Composite 패턴이 나왔었다.
해당 챕터의 내용은 사실 Composite 패턴이 핵심은 아니었다. 단지 자주 나오는 역할, 책임, 협력의 패턴들을 디자인 패턴이라고 말하는 것이 핵심이었다.
하지만, 이전에 친구가 Composite 패턴에 대해서 물어봐서 급하게 찾아본 적이 있었는데, 다소 생소한 개념이어서 넘어간 적이 있었다.
그래서 이번에 간단한 예시와 함께 이해해보려고 한다.
Composite 패턴 정의 by wiki
컴포지트 패턴(Composite pattern)이란 객체들의 관계를트리 구조로 구성하여부분-전체 계층을 표현하는 패턴이다.- 사용자가
단일 객체와복합 객체모두 동일하게 다루도록 한다.
글을 쓴 시점에선 실습을 해보아서 어렴풋이 알고는 있지만, 처음 봤을 때는 정말 어려웠다.
여기서 핵심은 "단일 객체와 복합 객체를 동일하게 다루도록"한다는 부분인 것 같다.
그렇다면 우선 단일 객체와 복합 객체의 의미는 무엇일까?
이것을 알기 전에 우선 Composite라는 단어를 이해해야된다.

네이버 사전을 통해 찾아본 뜻은 합성이다. 또한 수학에서 합성수라는 개념도 Composite Number라고 표현한다.
즉, Composite라는 의미는 합성을 의미한다.
그렇다면 Composite 패턴의 Composite역시 합성의 의미를 내포하고 있을까?
결론부터 말하면 맞다. Composite 패턴은 공용 인터페이스를 구현하는 객체들을 합성하여 하나의 트리 구조로 만드는 디자인 패턴이다.
말이 조금 어려운데, 우선 복합객체와 단일객체의 뜻에 대해 알아보자.

위키에서 긁어온 사진이다. 먼저 uml에 대해서 간단하게 살펴보자. uml에 대한 간략한 정보는 이 블로그를 참고하자.
Leaf와 Composite은 Component을 일반화하는 타입이다.
그리고 Leaf와 Composite은 operation을 공통적으로 수행한다.
Composite은 Operation이외에 다른 메서드들도 수행한다. 또한, Component를 자식으로 갖는다.
즉, Composite은 공통 인터페이스(Component)를 구현하는 다른 객체(Leaf)들과
자기 자신(Composite)을 자식으로 가질 수 있는 객체라는 것이다.
Composite Pattern에 Composite를 붙인 까닭은 공용 인터페이스의 구현체들이 합성된 하나의 객체로써 동작하기 때문으로 보인다.
이쯤에서 단일 객체와 복합 객체에 대해서 짚고 넘어가면 다음과 같다.
단일 객체, 복합 객체
단일 객체: 일반적으로 그림과 같이Leaf라고 불림.공용 인터페이스로 들어오는 호출에 대해 응답할 수 있는기본적인 행위를 구현함.
복합 객체: 일반적으로 그림과 같이Composite라고 불림.
외부로부터부분에 대한 세부사항은 감추고하나의 단위로 행동하는 역할이다.
한줄 요약하자면, Composite 패턴은 Leaf와 그 객체들을 포함한 Composite를 동일하게 다루기 위해, 객체들을 트리 구조로 조직하는 것이다.
그렇다면 어디에 쓰일 수 있을까?
파일 시스템을 통해 그 쓸모에 대해서 알아보자.
public abstract class FileComponent {
public void add(FileComponent component) {
throw new UnsupportedOperationException();
}
public abstract String showDetails();
}
FileComponent 추상 클래스를 통해 공용 인터페이스를 정의한다.
add 메서드는 기본적으로 UnsupportedOperationException을 던지며, 이는 Composite에서만 유효하다. Leaf 객체에서는 해당 메서드를 사용할 수 없다.
showDetails 메서드는 모든 하위 클래스에서 구현해야 할 추상 메서드로, 파일이나 디렉터리의 세부 정보를 반환한다.
public class File extends FileComponent {
private final String name;
public File(String name) {
this.name = name;
}
@Override
public String showDetails() {
return "File : " + name + "\n";
}
}
File 객체는 Leaf로써 FileComponent를 상속받아 구현한다.
File 객체는 showDetails를 통해 파일의 이름을 반환한다.
add 메서드를 호출할 경우, UnsupportedOperationException을 던진다. 이는 File이 자식을 가질 수 없음을 의미한다.
import java.util.ArrayList;
import java.util.List;
public class Directory extends FileComponent {
private final String name;
private final List<FileComponent> children;
public Directory(String name) {
this.name = name;
this.children = new ArrayList<>();
}
@Override
public void add(FileComponent component) {
children.add(component);
}
@Override
public String showDetails() {
return showDetails(0);
}
private String showDetails(int depth) {
StringBuilder details = new StringBuilder();
details.append(" ".repeat(depth)).append("Directory : ").append(this.name).append("\n");
for (FileComponent child : children) {
if (child instanceof Directory) {
details.append(((Directory) child).showDetails(depth + 1));
} else {
details.append(" ".repeat(depth + 1)).append(child.showDetails());
}
}
return details.toString();
}
}
Directory 객체는 Composite로써 FileComponent를 상속받아 구현한다.
Directory는 여러 자식 FileComponent 객체를 가질 수 있으며, add 메서드를 통해 자식 객체를 추가할 수 있다.
showDetails 메서드는 디렉터리와 그 자식들의 세부 정보를 반환한다. 재귀적으로 호출하여 트리 구조의 깊이에 따라 들여쓰기를 추가한다.
Directory의 showDetails의 호출에서 File의 showDetails를 호출함으로써 구현한 것을 주목하자.
public class CompositeTest {
@Test
public void 클라이언트_동작_통합_테스트() {
FileComponent rootDirectory = new Directory("루트 디렉토리");
FileComponent subDirectory = new Directory("서브 디렉토리");
FileComponent file1 = new File("파일 1");
FileComponent file2 = new File("파일 2");
rootDirectory.add(subDirectory);
subDirectory.add(file1);
rootDirectory.add(file2);
String expectedOutput = "Directory : 루트 디렉토리\n" +
" Directory : 서브 디렉토리\n" +
" File : 파일 1\n" +
" File : 파일 2\n";
assertEquals(expectedOutput, rootDirectory.showDetails(), "클라이언트가 단일 객체와 복합 객체를 동일하게 다룰 수 있어야 합니다.");
}
}
간단한 통합 테스트를 작성했다.
클라이언트 측에선 FileComponent라는 일반적인 타입을 통해 File혹은 Directory를 생성하고 메시지를 보낼 수 있다.
즉, 파일(단일 객체)와 디렉토리(복합 객체)가 추상클래스(공용 인터페이스)를 통해 동일하게 다뤄질 수 있다.


우리가 구현한 파일 시스템 예시코드와 Wiki의 UML을 비교해보자.
위 두 그림은 명칭과 메서드만 다를 뿐, 거의 유사한 구조를 갖고 있다.
Composite 패턴의 의의를 다음과 같이 생각할 수 있다.Composite패턴의 의의
전체와부분은 하나의 단위로 동일하게 추상화된다.
- 그렇게 추상화된
공통 인터페이스를 통해클라이언트는전체와부분을 동일하게 인식할 수 있게 된다.
(메시지 수신자가전체인지부분인지 알 필요가 없다.)
사실 이 포인트에서 많은 혼동을 느낄 수 있었다.
바로 "Composite객체에서만 사용되는 메서드는 어디에 구현해야되나?"에 관한 문제였다.
이전에 예시로 들었던 파일 시스템을 떠올려보자.
파일 시스템에서 Directory는 파일이나 디렉토리를 자신에게 더하는, add하는 동작을 지원해야 한다.
하지만, File에서는 add동작을 지원하지 않는다.
이런 상황에서 add가 정의되어야 하는 위치를 두 경우로 생각해볼 수 있다.
add가 정의될 수 있는 위치
- 공용인터페이스인 FileComponent 추상 클래스에 정의
- Composite객체인 Direcotry에만
앞서 다룬 예시에선 add를 FileComponent에 정의함과 동시에 기본 동작으로 UnsupportedOpperationException을 던지도록 구현했다.
이런 방법을 통해 File은 add수행시 예외를 던지고, Directory는 add를 Override함으로써 정상적인 add동작을 수행하도록 만들었다.
하지만 File에서 "굳이 사용되지 않는 메서드를 가지고 있어야 할까"에 대한 의문점이 들 수도 있다.
그렇다면 그 반대의 경우인 Directory에만 add를 정의하고 구현하는 경우에 대해 생각해보자.
결론부터 말하면, 이 경우엔 Composite 패턴이 갖고 있는 장점과 의의가 퇴색된다. 바로 코드로 알아보자.
public abstract class FileComponent {
public abstract String showDetails();
}
public class File extends FileComponent {
private final String name;
public File(String name) {
this.name = name;
}
@Override
public String showDetails() {
return "File : " + name + "\n";
}
}
public class Directory extends FileComponent {
private final String name;
private final List<FileComponent> children;
public Directory(String name) {
this.name = name;
this.children = new ArrayList<>();
}
public void add(FileComponent component) {
children.add(component);
}
@Override
public String showDetails() {
return showDetails(0);
}
private String showDetails(int depth) {
StringBuilder details = new StringBuilder();
details.append(" ".repeat(depth)).append("Directory : ").append(this.name).append("\n");
for (FileComponent child : children) {
if (child instanceof Directory) {
details.append(((Directory) child).showDetails(depth + 1));
} else {
details.append(" ".repeat(depth + 1)).append(child.showDetails());
}
}
return details.toString();
}
}
변경점이 크진 않다.
단지 add메서드의 정의 위치를 FileComponent에서 Directory로 옮겼을 뿐이다.


위와 같이 변경한 후, 기존의 Test코드를 확인하면 첫번째 사진의 에러를 확인할 수 있다.
FileComponent에선 add를 식별할 수 없으니까 Directory로 다운캐스팅을 하라는 것이다.
당연한 에러다. FileComponent내부엔 add가 존재하진 않기 때문이다.
따라서, 두번째 사진과 같이 add동작에 대해서 다운캐스팅을 진행해주면 된다.
어려운 문제도 아니다.
또한, 애초에 add메서드는 Directory에서만 수행할 수 있는 특수한 동작이기 때문에 틀린 부분도 없어보인다.
하지만 "Composite 객체에서만 이뤄지는 동작은 Composite 객체 내부에서만 정의"라는 해결책은 "Composite 패턴의 취지"에는 다소 벗어날 수 있다.
많은 블로그와 유튜브, 심지어 Wiki에서도 특수한 동작을 수행하는 메서드를 Composite객체에서 정의하도록 설명하고 있다.
ex) 방금 전의 예시처럼 add는 Directory에서만 선언되고 사용되도록
하지만, 그렇게 할 경우 클라이언트에선 공용 인터페이스가 아닌 구체적인 구현체에 의존하게 되어 "전체와 부분을 하나의 공용인터페이스로 다룰 수 있다. "라는 패턴의 장점이 사라지고 만다.
혼자서는 마땅한 답을 유추하진 못했고, 결국 Design Patterns Elements of Reusable Object-Oriented Software라는 책에서 해답을 얻을 수 있었다.
결론부터 얘기하면 "공용 인터페이스에 모든 메서드를 구현하는 방법"을 채택하고 있다.
해당 주장에 대한 근거는 책의 인용으로 대체한다.
The key to the Composite pattern is an abstract class that represents both primitives and their containers. (p.163)
: Composite 패턴의 핵심은 기본 요소와 컨테이너 모두를 나타내는 추상 클래스이다.
Maximizing the Component interface
One of the goals of the Composite pattern is to make clients unaware of the specific Leaf or Composite classes they're using. To attain this goal, the Component class should define as many common operations for Composite and Leaf classes as possible.
...
Sometimes a little creativity shows how an operation that would appear to make sense only for Composites can be implemented for all Components by moving it to the Component class.
...
Leaf classes can use the default implementation, but Composite classes will reimplement it to return their children. (p.167)
Component 인터페이스 최대화
Composite 패턴의 목표 중 하나는 클라이언트가 사용하는 특정 Leaf나 Composite 클래스를 인식하지 못하도록 하는 것이다. 이를 위해 Component 클래스는 가능한 한 많은 공통 작업을 Composite와 Leaf 클래스에 대해 정의해야 한다.
...
때로는 약간의 창의성을 발휘하여 Composite에만 의미 있는 작업이 모든 Component에서 구현될 수 있도록 Component 클래스로 옮길 수 있다.
...
Leaf 클래스는 기본 구현을 사용할 수 있지만, Composite 클래스는 이를 재구현하여 자식을 반환하게 할 수 있다.
Declaring the child management operations.
Although the Composite class implements the Add and Remove operations for managing children, an important issue in the Composite pattern is which classes declare these operations in the Composite class hierarchy.
Should we declare these operations in the Component and make them meaningful for Leaf classes, or should we declareand define them only in Composite and its subclasses?The decision involves a trade-off between safety and transparency:
• Defining the child management interface atthe root of the class hierarchy gives you transparency, because you can treat all components uniformly.
It costs you safety, however, because clients may try to do meaningless
things like add and remove objects from leaves.• Defining child management in the Composite class gives you safety,
because any attempt to add or remove objects from leaves will be caught
at compile-time in a statically typed language like C++. But you lose
transparency, because leaves and composites have different interfaces.We have emphasized transparency over safety in this pattern. If you opt for safety, then at times you may lose type information and have to convert a component into a composite.
자식 관리 작업 선언
Composite 클래스는 자식을 관리하기 위해
Add및Remove작업을 구현하지만, Composite 패턴에서 중요한 문제는 이러한 작업을 Composite 클래스 계층의 어느 클래스에 선언할 것인가 하는 것이다.
이러한 작업을 Component에 선언하여 Leaf 클래스에서도 의미 있게 만들 것인지, 아니면 Composite 및 그 하위 클래스에만 선언하고 정의할 것인지에 대한 고민이 필요하다.이 결정은
안전성과투명성사이의 균형을 포함한다:• 클래스 계층 구조의 루트에서 자식 관리 인터페이스를 정의하면 투명성을 얻을 수 있다. 모든 구성 요소를 동일하게 처리할 수 있기 때문이다. 그러나 안전성을 잃는다. 클라이언트가 Leaf에서 객체를 추가하거나 제거하려고 시도할 수 있기 때문이다.
• 자식 관리를 Composite 클래스에만 정의하면 안전성을 얻을 수 있다.
Leaf에서 객체를 추가하거나 제거하려는 시도를 컴파일 타임에 잡을 수 있기 때문이다. 하지만 투명성을 잃게 된다. Leaf와 Composite가 다른 인터페이스를 가지기 때문이다.우리는 이 패턴에서 투명성을 안전성보다 강조했다. 안전성을 선택하면 때로는 타입 정보를 잃게 되고, 구성 요소를 Composite로 변환해야 할 수도 있다.
정리
Composite 패턴은 최대한 많은 공통 작업을공용 인터페이스에 포함되도록 해야 한다.
add,remove와 같은 자식 관리 메서드는공용 인터페이스에서 정의되어야 하며, 이를 통해투명성을 제공한다.
비록, 안정성과 투명성 사이의 trade-off가 있지만, 이 패턴에서는투명성이 중요하다.
정의와 예시를 통해 Composite 패턴에 대해서 알아보았다.
그러면 Composite 패턴의 장점과 단점에 대해서 알아보자.
동일한 인터페이스 제공
단일 객체와 복합 객체에 대해 동일한 인터페이스를 제공하여 클라이언트 코드가 두 객체를 구분해야 하는 부담을 줄여준다.
이를 통해 클라이언트 코드를 더 직관적이고 간결하게 만든다.
계층 구조를 다루는데 용이
트리 구조를 갖고 있어 계층 구조를 다루는 데 특화되어 있다.
파일 시스템, GUI, 조직 구조 등 다양한 계층적 데이터를 효과적으로 처리할 수 있다.
유연하고 확장 가능한 구조
세부적인 구현체에 의존하지 않아서 변경사항 발생 시 클라이언트 코드의 변경을 최소화할 수 있다.
새로운 컴포넌트 타입을 추가하더라도 클라이언트에 영향을 미치지 않는다.
객체 조합의 투명성과 관리 용이성
복합 객체에 대한 연산이 단일 객체들로 전달되어 일관된 방식으로 처리된다.
-> Directory.showDetails()를 구현할 때 File.showDetails()를 호출하여 구현
단일 객체와 복합 객체를 동일한 방식으로 관리할 수 있어 객체 추가, 삭제, 수정 등의 작업을 단순화하며, 코드 유지보수성을 높인다.
제한된 구성 요소 타입
Composite 패턴은 복합 객체의 구성 요소 타입을 제한하는 데 어려움을 준다.
특정한 Composite에 특정 타입의 객체만 포함되기를 원할 때, 컴포지트 패턴은 타입 시스템을 통해 이러한 제약을 강제할 수 없다.
대신 런타임 체크를 사용해야 한다.
설계의 과도한 일반화
Composite 패턴은 설계를 지나치게 일반화시킬 수 있다.
이는 시스템의 유연성을 높이지만, 동시에 특정 상황에 맞는 세밀한 제어가 어려워질 수 있다.
장단점 요약
클라이언트가전체와부분을 구분할 필요가 없는 환경에서 사용하기 용이
ex)GUI,파일 시스템,메뉴와 세트메뉴등등
- 하지만,
Composite의 자식으로 올 수 있는타입을 제한하는 것이 한계가 있고,
과한 일반화로 세밀한 조정에 어려움이 생길 수 있음.
Composite 패턴의 경우 지금까지 개발을 하면서 쉽게 접하지 못한 패턴이었다.
여러 예제를 통해 확인할 수 있듯이, 부모-자식의 계층 구조가 존재하는 경우에 이러한 패턴을 유의미하게 적용할 수 있을 것이다. 즉, 범용적인 느낌은 아니다.
정리를 하면서 어려웠던 점은 아무래도 "add메서드의 정의 위치"였다. 이 내용 하나로 결국 GoF의 디자인 패턴 책까지 뜯어보며 결론을 도출할 수 있었다.
그 과정에서 디자인 패턴 - 객체지향 이론 간의 trade-off 관계에 대해서도 생각해볼 수 있어 흥미로운 주제였다.
좋은 글 잘 보고 갑니다! 추상화를 위해 런타임 에러를 발생시킨다는 건 익숙하지 않네요.. 디자인패턴은 너무 어려워요