Composite
Composite 패턴이란?
컴포지트 패턴Composite Pattern은 객체를 트리 구조로 구성하여 부분-전체 계층을 표현하는 데 사용되는 구조적 디자인 패턴입니다. 이 패턴을 통해 클라이언트는 개별 객체와 복합 객체(여러 객체로 구성된 복합 구조)를 동일하게 다룰 수 있습니다. 이로 인해 코드의 복잡성을 줄이고, 객체 구조를 유연하게 관리할 수 있습니다.
Composite 패턴 구조
- Client → Component
- 클라이언트는
Component
인터페이스를 통해Operation()
을 호출합니다. - 클라이언트는
Leaf
와Composite
간의 차이를 알 필요 없이, 동일한 방식으로 접근합니다.
- 클라이언트는
- Composite → Component (Children 관리)
Composite
객체는 자신의 하위 요소(자식 노드)들을 관리합니다.children
속성을 통해 자식 리스트를 유지하며, 필요한 경우 자식들에게 작업을 위임합니다.
- Composite → Component (Operation 재귀 호출)
Composite
의Operation()
이 호출되면, 자신이 가진 자식 노드 각각의Operation()
을 호출하여 작업을 위임합니다.- 자식 노드가 또 다른
Composite
일 경우, 재귀적으로 호출이 이루어집니다.
- Leaf → Component
Leaf
객체는 더 이상 하위 요소를 가지지 않는 개별 객체입니다.Leaf
는 자신의Operation()
을 구현하여 요청을 처리합니다.
Composite 패턴 구성 요소
컴포지트 패턴은 다음과 같은 구성 요소로 이루어집니다:
컴포넌트Component
부분과 전체 객체에 공통된 인터페이스를 정의하는 추상 클래스나 인터페이스입니다. 이 추상 클래스나 인터페이스는 트리 구조를 관통하는 역할을 하며, 트리 구조가 어떻게 확장되든 상관없이 클라이언트가 트리의 각 요소를 동일한 방식으로 처리할 수 있도록 합니다.
리프Leaf
트리의 말단 노드로, 더 이상 하위 객체를 포함하지 않는 개별적인 객체입니다. 리프 객체는 트리 구조의 가장 작은 단위로, 공통 인터페이스를 구현하여 클라이언트가 동일하게 다룰 수 있도록 합니다.
컴포지트Composite
트리의 가지를 구성하는 복합 객체로, 하위에 다른 리프나 컴포지트를 포함할 수 있습니다. 컴포지트 객체도 공통 인터페이스를 구현하여, 자신이 포함하는 하위 객체들에 대한 처리를 위임할 수 있습니다.
Composite 패턴 적용
Composite 패턴의 필요성
컴포지트 패턴은 다음과 같은 상황에서 필요합니다:
부분-전체 계층 구조 표현
객체들이 부분-전체 관계를 가질 때, 이들을 트리 구조로 표현하여 관리해야 할 경우에 유용합니다.
단일 객체와 복합 객체의 동일한 처리
클라이언트가 단일 객체와 복합 객체를 동일한 인터페이스로 처리해야 하는 경우, 컴포지트 패턴을 통해 이러한 요구를 충족할 수 있습니다.
잘못된 계층 구조 처리 방식
컴포지트 패턴을 적용하지 않으면, 트리 구조의 계층을 처리할 때 코드가 복잡해지고, 단일 객체와 복합 객체를 별도로 처리해야 하는 번거로움이 발생할 수 있습니다. 도서관 관리 시스템에서 섹션과 개별 책을 관리하는 예시를 생각해보겠습니다.
// 개별 책을 처리하는 클래스
public class Book
{
public string Title { get; private set; }
public Book(string title)
{
Title = title;
}
public void Display() => Console.WriteLine(Title);
}
// 섹션을 처리하는 클래스
public class Section
{
private List<Book> _books = new List<Book>();
private string _name;
public Section(string name)
{
_name = name;
}
public void AddBook(Book book) => _books.Add(book);
public void Display()
{
Console.WriteLine($"Section: {_name}");
foreach (var book in _books)
{
book.Display();
}
}
}
단일 객체와 복합 객체의 관리
클라이언트가 개별 책과 섹션을 각각 별도로 관리해야 합니다. 이로 인해 코드가 복잡해지고, 새로운 구조를 추가할 때마다 수정이 필요합니다.
유연성 부족
새로운 계층을 추가하거나 복잡한 트리 구조를 관리하려면, 기존 코드의 수정이 필요하며, 관리의 어려움이 발생할 수 있습니다.
Composite 패턴 적용 예시
컴포지트 패턴을 사용하면, 단일 객체와 복합 객체를 동일한 인터페이스로 관리할 수 있습니다.
// 컴포넌트 인터페이스
public interface ILibraryComponent
{
void Display();
}
// 리프 클래스: 개별 책
public class Book : ILibraryComponent
{
public string Title { get; private set; }
public Book(string title)
{
Title = title;
}
public void Display() = Console.WriteLine(Title);
}
// 컴포지트 클래스: 섹션
public class Section : ILibraryComponent
{
private List<ILibraryComponent> _components = new List<ILibraryComponent>();
private string _name;
public Section(string name)
{
_name = name;
}
public void AddComponent(ILibraryComponent component)
{
_components.Add(component);
}
public void Display()
{
Console.WriteLine($"Section: {_name}");
foreach (var component in _components)
{
component.Display();
}
}
}
// 클라이언트 코드
public class Program
{
public static void Main(string[] args)
{
// 개별 책 생성
ILibraryComponent book1 = new Book("Design Patterns");
ILibraryComponent book2 = new Book("Refactoring");
ILibraryComponent book3 = new Book("Clean Code");
// 섹션 생성 및 책 추가
Section section1 = new Section("Software Engineering");
section1.AddComponent(book1);
section1.AddComponent(book2);
Section section2 = new Section("Programming");
section2.AddComponent(book3);
// 섹션을 상위 섹션에 추가
Section mainSection = new Section("Library");
mainSection.AddComponent(section1);
mainSection.AddComponent(section2);
// 전체 트리 구조 출력
mainSection.Display();
}
}
ILibraryComponent 인터페이스
컴포지트와 리프 객체가 구현해야 하는 공통 인터페이스로, Display()
메서드를 정의합니다.
Book 클래스
리프 클래스 역할을 하며, 개별 책을 나타냅니다.
Section 클래스
컴포지트 클래스 역할을 하며, 여러 개의 ILibraryComponent 객체(책 또는 섹션)를 포함할 수 있습니다.
클라이언트 코드
클라이언트는 ILibraryComponent 인터페이스를 통해 개별 책과 섹션을 동일하게 관리할 수 있습니다.
Composite 패턴 장단점
장점
트리 구조 관리의 용이성
컴포지트 패턴을 사용하면 트리 구조의 계층을 일관되게 관리할 수 있습니다.
확장성
단일 객체와 복합 객체를 동일하게 처리할 수 있으므로, 새로운 구조나 계층을 쉽게 추가할 수 있습니다.
단순화된 클라이언트 코드
클라이언트는 컴포넌트 인터페이스만 사용하면 되므로, 단일 객체와 복합 객체의 처리 로직이 단순해집니다.
단점
복잡성 증가
트리 구조를 구축하고 관리하기 위한 추가적인 클래스를 생성해야 하므로, 시스템의 복잡성이 증가할 수 있습니다.
객체의 지나친 일반화
모든 객체를 동일하게 처리하려는 경향 때문에, 객체의 특수한 행동이나 상태를 다루기 어려울 수 있습니다.
단점 해결 방안
컴포넌트 계층의 적절한 설계
트리 구조를 지나치게 복잡하게 설계하지 않고, 필요한 만큼만 계층을 도입하여 관리합니다.
특수한 행동의 확장
기본 컴포넌트 인터페이스 외에도 특수한 행동이 필요한 경우, 인터페이스나 추상 클래스를 확장하여 추가적인 기능을 제공합니다.
객체지향 원칙과의 관계
Composite와 캡슐화
컴포지트 패턴은 객체의 내부 구조를 클라이언트로부터 캡슐화하여, 클라이언트는 객체의 구체적인 구조나 계층을 알 필요 없이 동일한 방식으로 객체와 상호작용할 수 있습니다.
Composite와 다형성
컴포지트 패턴은 다형성을 활용하여, 동일한 인터페이스를 통해 다양한 객체들을 처리할 수 있도록 합니다.
Composite와 단일 책임 원칙
컴포지트 패턴은 각 객체가 자신의 책임을 관리하도록 하여, 단일 책임 원칙을 잘 준수할 수 있습니다.
Composite와 개방_폐쇄 원칙
컴포지트 패턴은 새로운 컴포넌트나 계층을 추가할 때 기존 코드를 수정할 필요 없이 확장할 수 있도록 합니다.
결론
컴포지트 패턴은 객체를 트리 구조로 구성하여 복잡한 계층 구조를 관리할 수 있도록 해주는 유용한 패턴입니다. 이 패턴을 사용하면 단일 객체와 복합 객체를 동일하게 처리할 수 있으며, 시스템의 확장성과 유연성을 크게 향상시킬 수 있습니다. 다만, 트리 구조의 복잡성이 증가할 수 있으므로, 필요에 따라 신중하게 설계해야 합니다.
심화학습
재귀적 구조의 이해
컴포지트 패턴은 재귀적으로 설계된 구조로, 트리의 각 노드가 또 다른 트리 구조를 포함할 수 있습니다. 이 재귀적인 구조를 이해하면, 컴포지트 패턴을 사용하는 데 큰 도움이 됩니다. 재귀적 구조는 특히 복잡한 계층 구조를 가진 시스템에서 유용합니다. 도서 관리 시스템에서 재귀적 구조를 사용하여 섹션과 하위 섹션을 관리하는 방법을 살펴보겠습니다.
// Component 인터페이스
public abstract class LibraryItem
{
public abstract void Display(int indent);
}
// Leaf 클래스: 책
public class Book : LibraryItem
{
private string _title;
public Book(string title)
{
_title = title;
}
public override void Display(int indent)
=> Console.WriteLine(new String('-', indent) + " " + _title);
}
// Composite 클래스: 섹션
public class Section : LibraryItem
{
private List<LibraryItem> _items = new List<LibraryItem>();
private string _name;
public Section(string name)
{
_name = name;
}
public void Add(LibraryItem item) => _items.Add(item);
public void Remove(LibraryItem item) => _items.Remove(item);
public override void Display(int indent)
{
Console.WriteLine(new String('-', indent) + "+ " + _name);
foreach (LibraryItem item in _items)
{
item.Display(indent + 2);
}
}
}
// 클라이언트 코드
public class Program
{
public static void Main(string[] args)
{
// 메인 섹션
Section mainSection = new Section("Main Section");
// 서브 섹션 1
Section fictionSection = new Section("Fiction");
fictionSection.Add(new Book("To Kill a Mockingbird"));
fictionSection.Add(new Book("1984"));
// 서브 섹션 2
Section scienceSection = new Section("Science");
scienceSection.Add(new Book("A Brief History of Time"));
scienceSection.Add(new Book("The Selfish Gene"));
// 메인 섹션에 서브 섹션 추가
mainSection.Add(fictionSection);
mainSection.Add(scienceSection);
// 도서관 전체 섹션 구조를 출력
mainSection.Display(1);
}
}
안전성과 투명성의 트레이드오프
컴포지트 패턴에서 리프와 컴포지트가 동일한 인터페이스를 구현함으로써, 클라이언트가 이들을 동일하게 처리할 수 있습니다. 하지만, 이 과정에서 안전성과 투명성 사이의 트레이드오프가 발생할 수 있습니다.
투명성
클라이언트가 리프와 컴포지트 객체를 구분하지 않고 동일하게 다룰 수 있는 장점이 있습니다. 하지만, 리프 객체에 하위 요소를 추가하는 메서드(예: Add
, Remove
)가 포함되어 있다면, 클라이언트가 실수로 리프 객체에 하위 요소를 추가하려고 시도할 수 있습니다.
안전성
리프 객체에 하위 요소를 관리하는 메서드를 포함시키지 않으면, 이런 실수를 방지할 수 있습니다. 하지만, 이 경우 클라이언트는 리프와 컴포지트를 구분해야 하며, 패턴의 일관성이 떨어질 수 있습니다.
// 잘못된 예: 리프(Book)에서도 Add 메서드를 제공
public abstract class LibraryItem
{
public abstract void Display(int indent);
public virtual void Add(LibraryItem item)
=> throw new InvalidOperationException("Cannot add item to a leaf");
public virtual void Remove(LibraryItem item)
=> throw new InvalidOperationException("Cannot remove item from a leaf");
}
public class Book : LibraryItem
{
private string _title;
public Book(string title)
{
_title = title;
}
public override void Display(int indent)
=> Console.WriteLine(new String('-', indent) + " " + _title);
}
// 이 경우 Book 객체에서 Add 메서드를 호출하려고 하면 예외가 발생
위 코드에서는 투명성을 유지하기 위해 리프와 컴포지트 모두 동일한 인터페이스를 사용하지만, Book
객체에서 Add
메서드를 호출할 수 없으므로 예외를 발생시킵니다. 이를 방지하기 위해, 리프에서는 이러한 메서드를 제공하지 않도록 수정할 수 있습니다.
지연된 연산 활용
컴포지트 패턴에서 일부 연산을 지연Lazy Evaluation시키는 방법을 사용할 수 있습니다. 예를 들어, Display
메서드에서 트리 구조의 일부 노드만을 표시하고 나머지는 해당 트리를 확장할 때 표시하도록 할 수 있습니다. 이 방식은 트리 구조가 매우 크거나, 연산 비용이 클 때 유용합니다.
// Leaf 클래스: 책
public class Book : LibraryItem
{
private string _title;
public Book(string title)
{
_title = title;
}
public override void Display(int indent)
=> Console.WriteLine(new string('-', indent) + " " + _title);
}
// Composite 클래스: 섹션
public class LazySection : LibraryItem
{
private List<LibraryItem> _items = new List<LibraryItem>();
private string _name;
private bool _isLoaded = false;
public LazySection(string name)
{
_name = name;
}
private void LoadItems(int indent)
{
// indent 수준에 맞게 부분적으로 로드
if (!_isLoaded)
{
Console.WriteLine(new string('-', indent) + $" Loading items for {_name}...");
// 실제로 데이터베이스나 파일에서 데이터를 로드하는 시뮬레이션
_items.Add(new Book("Loaded Book 1"));
_items.Add(new Book("Loaded Book 2"));
_isLoaded = true;
}
}
public void Add(LibraryItem item) => _items.Add(item);
public void Remove(LibraryItem item) => _items.Remove(item);
public override void Display(int indent)
{
Console.WriteLine(new string('-', indent) + "+ " + _name);
// 현재 indent 수준에 맞게 필요한 만큼만 로드
LoadItems(indent + 2);
foreach (LibraryItem item in _items)
{
item.Display(indent + 2);
}
}
}
// 클라이언트 코드
public class Program
{
public static void Main(string[] args)
{
LazySection mainSection = new LazySection("Main Section");
LazySection subSection1 = new LazySection("Sub Section 1");
subSection1.Add(new Book("Initial Book 1"));
LazySection subSection2 = new LazySection("Sub Section 2");
subSection2.Add(new Book("Initial Book 2"));
mainSection.Add(subSection1);
mainSection.Add(subSection2);
// 첫 번째 호출 시 일부만 로드됨
mainSection.Display(1);
// 추가 호출 시 확장된 부분만 로드됨
subSection1.Display(1);
subSection2.Display(1);
}
}
참조 무결성 관리
트리 구조에서 각 노드는 다른 노드(부모나 자식)를 참조합니다. 이 참조 무결성을 관리하는 것이 중요합니다. 특히 트리 구조가 동적으로 변경될 때(예: 노드가 추가되거나 제거될 때) 참조의 무결성을 유지하는 것이 중요합니다.
public class Section : LibraryItem
{
private List<LibraryItem> _items = new List<LibraryItem>();
private string _name;
private Section _parentSection;
public Section(string name)
{
_name = name;
}
public void Add(LibraryItem item)
{
if (item is Section section)
{
section.SetParent(this);
}
_items.Add(item);
}
public void Remove(LibraryItem item)
{
_items.Remove(item);
}
public Section GetParent() => _parentSection;
private void SetParent(Section parent)
{
_parentSection = parent;
}
public override void Display(int indent)
{
Console.WriteLine(new String('-', indent) + "+ " + _name);
foreach (LibraryItem item in _items)
{
item.Display(indent + 2);
}
}
}
위 코드에서는 Section
클래스가 부모 섹션을 참조하게 하여, 트리 구조에서 참조 무결성을 유지합니다.
캐싱을 통한 성능 최적화
트리 구조에서 특정 연산 결과를 캐싱하여 성능을 최적화할 수 있습니다. 특히 트리 구조가 크거나, 동일한 연산이 자주 반복될 경우 캐싱이 유용합니다. 도서 관리 시스템에서 섹션에 포함된 책의 개수를 계산하는 연산을 캐싱할 수 있습니다. 한 번 계산된 결과를 캐싱하고, 섹션에 책이 추가되거나 제거될 때만 캐시를 업데이트하도록 하면, 불필요한 재계산을 줄일 수 있습니다.
public class CachedSection : Section
{
private int _cachedItemCount = -1;
public CachedSection(string name) : base(name) { }
public int GetItemCount()
{
if (_cachedItemCount == -1)
{
_cachedItemCount = CalculateItemCount();
}
return _cachedItemCount;
}
private int CalculateItemCount()
{
int count = 0;
foreach (var item in _items)
{
if (item is Section section)
{
count += section.GetItemCount();
}
else
{
count++;
}
}
return count;
}
}
composite와 Factory Method
컴포지트 패턴을 사용할 때, 복잡한 트리 구조를 단순화하기 위해 팩토리 메서드 패턴과 결합하는 것도 좋은 방법입니다. 팩토리 메서드를 통해 트리 구조를 생성하는 과정을 캡슐화하고, 클라이언트 코드가 직접 트리 구조를 구성하는 복잡성을 피할 수 있습니다. 도서 관리 시스템에서 팩토리 메서드를 사용해 전체 섹션과 하위 섹션, 그리고 책들을 자동으로 구성하는 메서드를 제공할 수 있습니다. 이를 통해 클라이언트는 단순히 팩토리 메서드를 호출하여 복잡한 트리 구조를 생성할 수 있습니다.
public class LibraryFactory
{
public static Section CreateLargeLibrary()
{
Section mainSection = new Section("Main Library");
Section fictionSection = new Section("Fiction");
fictionSection.Add(new Book("To Kill a Mockingbird"));
fictionSection.Add(new Book("1984"));
Section scienceSection = new Section("Science");
scienceSection.Add(new Book("A Brief History of Time"));
scienceSection.Add(new Book("The Selfish Gene"));
mainSection.Add(fictionSection);
mainSection.Add(scienceSection);
return mainSection;
}
}
// 클라이언트 코드
public class Program
{
public static void Main(string[] args)
{
Section library = LibraryFactory.CreateLargeLibrary();
library.Display(1);
}
}
트리 구조의 순회 전략
컴포지트 패턴을 사용할 때, 트리 구조를 순회하는 전략을 명확히 정의하는 것이 중요합니다. 일반적으로 전위 순회pre-order, 중위 순회in-order, 후위 순회post-order 등의 방법이 사용됩니다.