udemy 강좌(Java Design Patterns & SOLID Design Principles)를 정리한 내용이다.
컴포지트는 무엇인가?
이제 컴포지트 디자인 패턴을 구현하는 방법과 왜 구조적 디자인 패턴이 필요한지 알아볼 것이다.
우선 왜 컴포지트가 필요하고 언제 사용할 수 있을까?
- 객체의 부분-전체 관계 혹은 계층구조가 있을 때 이 구조에서 단일 형태로 처리하고 싶을 때
- 단지 객체지향 프로그래밍의 합성 개념은 아니고 좀 더 강화된 내용이다.
- 트리 구조를 다루는 복합 패턴을 생각하면 된다.
컴포지트 구현
- 컴포넌트에 대한 추상 클래스/인터페이스를 생성하면서 시작
- 컴포넌트는 리프(leaf)와 컴포지트 양쪽에 적용될 수 있는 모든 메소드를 정의
- 누가 자식의 동작을 관리할 지 정해야 한다. (컴포넌트 혹은 컴포지트)
- 그리고 나서 컴포지트를 구현하다. 컴포지트에서 실행되는 동작은 모든 자식에 전파된다.
- 리프(leaf) 노드에서는 자식을 추가/삭제와 같이 적용될 수 없는 동작에 대해서 처리해야 한다.
- 결국에 컴포지트 패턴 구현으로 노드가 리프인지와 상관없이 알고리즘을 사용할 수 있게 된다.
예제: UML
Java 구현
@AllArgsConstructor
@Getter
public abstract class File {
private String name;
public abstract void ls();
public abstract void addFile(File file);
public abstract File[] getFiles();
public abstract boolean removeFile(File file);
}
여기에 File 이라는 추상 클래스가 있고 컴포지트 디자인 패턴에서 컴포넌트가 될 것이다. 이것은 리프 노드와 컴포지트 노드가 이 File 클래스를 확장한다는 것을 나타낸다. 이 File 클래스에서 이름을 나타내는 name이라는 하나의 필드를 가지고 있다. 그리고 파일명을 받는 생성자가 있고 getter 메서드와 setter 메서드가 있다.
그리고 추상 오퍼레이션을 정의했다.
파일을 삭제하고 추가하는 등 컴포지트에만 적용가능한 자식을 관리하는 오퍼레이션을 추가했다.
public class BinaryFile extends File {
private long size;
public BinaryFile(String name, long size) {
super(name);
this.size = size;
}
@Override
public void ls() {
System.out.println(getName() + "\t" + size);
}
@Override
public void addFile(File file) {
throw new UnsupportedOperationException("Leaf node doesn't support and operation");
}
@Override
public File[] getFiles() {
throw new UnsupportedOperationException("Leaf node doesn't support and operation");
}
@Override
public boolean removeFile(File file) {
throw new UnsupportedOperationException("Leaf node doesn't support and operation");
}
}
이 BinaryFile은 File을 확장한다.
public class Directory extends File {
private List<File> children = new ArrayList<>();
public Directory(String name) {
super(name);
}
@Override
public void ls() {
System.out.println(getName());
children.forEach(File::ls);
}
@Override
public void addFile(File file) {
children.add(file);
}
@Override
public File[] getFiles() {
return children.toArray(new File[children.size()]);
}
@Override
public boolean removeFile(File file) {
return children.remove(file);
}
}
Directory 클래스는 컴포지트이다.
public class Client {
public static void main(String[] args) {
File root1 = createTreeOne();
root1.ls();
System.out.println("*************************");
File root2 = createTreeTwo();
root2.ls();;
}
private static File createTreeOne() {
File file1 = new BinaryFile("File1", 1000);
Directory dir1 = new Directory("dir1");
dir1.addFile(file1);
File file2 = new BinaryFile("file2", 2000);
File file3 = new BinaryFile("file3", 150);
Directory dir2 = new Directory("dir2");
dir2.addFile(file2);
dir2.addFile(file3);
dir2.addFile(dir1);
return dir2;
}
private static File createTreeTwo() {
return new BinaryFile("Another file", 200);
}
}
이것은 클라이언트 코드이다.
구현 고려사항
- 부모 노드로 접근할 수 있는 메소드를 제공할 수 있다. 이 방법은 단순히 전체 트리를 순회하도록 하는 것이다.
- 컴포지트 대신에 기반 컴포넌트에서 자식을 유지하도록 컬렉션 필드를 정의할 수 있지만 그 필드는 리프 클래스에서 사용하지는 않는다.
- 리프 객체는 계층구조에서 반복적으로 사용될 수 있다면 리프노드를 공유함으로써 메모리를 절약하고 초기화 비용을 줄일 수 있다. 적은 양의 노드에 캐쉬를 사용하는 것이 더 비용이 들 수 있기 때문에 노드의 개수가 주요 결정 요소가 된다.
디자인 고려사항
- 자식을 관리하는 오퍼레이션이 어디서 정의될 지 결정해야 한다. 컴포넌트에서 정의되면 투명성을 제공하지만 리프노드는 해당 메소드를 구현해야만 한다. 컴포지트에서 정의하면 더 안전하지만 클라이언트는 컴포지트를 인지해야 할 필요가 있다.
- 디자인의 전반적인 목적은 클라이언트가 컴포지트를 사용할 때 구현하게 쉽게 만들어야 한다. 클라이언트가 오직 컴포넌트 인터페이스로만 동작하고 리프-컴포지트 구분을 할 필요가 없어야 한다.
위험요소
- 계층 구조에 추가되는 것을 제한하기 어렵다. 다양한 유형의 리프노드가 시스템에 존재한다면 클라이언트는 해당 동작이 가능한지 런타임에 확인해야 한다.
- 노드 재사용을 위해 캐쉬를 사용하고 노드 수가 많다면 기본적인 계층구조를 만드는 것이 복잡해 질 수 있다.
반응형