반복자 패턴(1)
정의와 구현
커서 패턴이라고도 하며, 컬렉션을 정해진 순서대로 가져올 때 사용된다. 반복자 패턴은 컬렉션의 순회 작업을 컬렉션에서 분리한 후, 반복자에 넣어 컬렉션과 반복자의 책임이 단일하게 되도록 한다.
완전한 반복자 패턴은 컬렉션과 반복자로 구성된다. 구현이 아닌 인터페이스 기반의 프로그래밍 목적을 달성하기 위해 컬렉션에서는 컬렉션 인터페이스와 컬렉션 구현 클래스가 포함되며, 반복자에는 반복자 인터페이스와 반복자 구현 클래스가 포함된다.

for 반복문이 있는데도 반복자를 사용하는 이유는 무엇일까?
트리나 그래프처럼 복잡한 데이터 구조는 트리의 경우 전위 순회, 중위 순회, 후위 순회 같은 순회 방법이 있으며, 그래프의 경우 깊이 우선 순회, 너비 우선 순회 같은 순회 방법이 있는 등 여러 가지 복잡한 방법이 존재한다. 데이터 구조를 사용하는 클라이언트가 이러한 순회 알고리즘을 직접 구현한다면 개발 비용이 증가하는 것은 필연적이며 실수나 오류가 발생하기 쉽다. 그렇다고 컬렉션 클래스에서 순회를 구현한다면 컬렉션 클래스 코드의 복잡도가 증가한다. 순회 작업을 반복자 클래스로 분할하는 것이 적절하다.
컨테이너와 반복자는 모두 추상 인터페이스를 제공하므로 구현이 아닌 인터페이스 기반으로 작업하기 쉽다. 링크드 리스트를 선순 순회에서 역순 순회로 바꾸는 것처럼 순회 알고리즘을 변경해야 한다면, 다른 코드는 수정하지 않고 반복자 클래스를 LinkedIterator에서 ReservedLinkedIterator로 전환하면 된다.
반복자의 문제
반복자를 사용하여 컬렉션을 순회하는 동안 컬렉션에 요소를 추가하거나 삭제하면 요소가 중복 순회되거나 반대로 아예 순회되지 않을 수 있다. 그러나 항상 오류가 발생하는 것이 아니라 가끔 정상적으로 순회가 가능한 경우도 있기 때문에, 모든 순회 오류가 발생하는 것은 아니며 때로는 정상적인 순회가 가능한 경우도 있으므로 이러한 행위를 상황에 따라 예측할 수 없는 결과 행위 또는 보류 행위라고 한다.
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.remove("a");
}
}
순회하는 동안 컬렉션 요소를 추가하는 경우에도 어떤 동작을 할지 미리 예측할 수 없는 동작에 해당한다.
생각해보기
클래스 안의 클래스라 영향이 갈 것 같고.. 제거하고 사용 못할 것 같다. 또한 다른 반복자에서 next 함수를 호출하면, 커서가 끝이 아닌이상은 뱉을 것 같다
반복자 패턴(2)
스냅샷 기능을 지원하는 반복자
스냅샷이란?
원본 컬렉션의 복사본을 의미하는데, 원본 컬렉션의 요소가 추가되거나 삭제되더라도 스냅샷은 변경되지 않는다. 반복자가 순회하는 대상 객체는 원본 컬렉션이 아닌 스냅샷으로, 순회하는 동안 컬렉션에 요소를 추가 하거나 삭제하여 예기치 않은 결과가 발생하는 것을 방지한다.
여러 복사본 기반의 설계 사상
public class SnapshotArrayIterator<E> implements Iterator<E> {
private int cursor;
private ArrayList<E> snapshot;
public SnapshotArrayIterator(int cursor, ArrayList<E> snapshot) {
this.cursor = cursor;
this.snapshot = new ArrayList<>();
this.snapshot.addAll(snapshot);
}
@Override
public boolean hasNext() {
return cursor < snapshot.size();
}
@Override
public E next() {
E currentItem = snapshot.get(cursor);
cursor++;
return currentItem;
}
}
이 설계 사상은 간단하지만 반복자가 생성될 때마다 복사본을 추가해야 하기 때문에 비용이 상대적으로 높다. 다시 말해 컬렉션에 대해 여러 반복자를 생성하는 경우 많은 메모리를 소비하는 여러 개의 복사본을 생성해야 한다. 그러나 다행히도 Java의 복사는 기본적으로 얕은 복사로, 컬렉션의 객체 자체가 아닌 객체에 대한 참조만 복사된다.
시간값 기반의 설계 사상
이 설계 사상에도 문제가 있는데, 시간 복잡도 O(1) 내에 첨자를 지정하여 요소에 빠르게 접근할 수 있어야 하지만 이 설계 사상에서는 데이터의 삭제는 실제로 삭제되는 것이 아니라 시간값으로 삭제 여부를 표시하는 것이기 때문에 첨자를 통해 빠르게 접근할 수 없다.
컬렉션이 스냅샷 순회와 임의 접근을 모두 지원하려면 어떻게 해야할까?
ArrayList 클래스에 두 개의 배열을 저장하는 방법을 생각해볼 수 있는데, 하나의 배열은 시간값 기반의 삭제 표시 기능을 통해 스냅샷 순회 기능을 지원하고, 다른 배열은 삭제 시 배열에서 직접 삭제하는 방식을 통해 임의 접근을 지원할 수 있다.
비지터 패턴
비지터 패턴은 이해하거나 구현하기 매우 어렵고, 심지어 적용하면 코드의 가독성과 유지 보수성이 떨어지기 때문에 실제로 거의 사용되지 않는다. 매우 특수한 상황이 아니라면 비지터 패턴은 고려할 필요가 없다. 그럼에도 혹시라도 보게 된다면, 해당 코드를 읽고 설계 의도를 알아챌 수 있어야 한다.
도출 과정
하나 이상의 작업을 객체 집합에 적용하여 객체에서 작업을 분리할 수 있다.
이 패턴의 설계 의도는 객체 자체에서 작업을 분리하여 클래스 책임을 단일하게 유지하고 개방 폐쇄 원칙을 충족하는 것이다. 비지터 패턴 코드 구현이 복잡한 이유는 대부분의 객체 지향 프로그래밍 언어에서 함수 오버로딩이 정적으로 바인딩되어 있기 때문이다. 클래스에서 어떤 오버로드 함수가 호출되는지를 결정하는 것은 실행 시간에 매개변수에 전달되는 실제 유형이 아니라, 컴파일 중에 선언된 매개변수 유형에 의해 결정된다.
이중 디스패치
이중 디스패치는 객체의 실행 시간 유형에 따라 실행할 객체의 메서드를 결정하고, 메서드의 실행 시간에 따른 매개변수 유형에 따라 객체에서 실행할 메서드가 결정되는 것을 의미한다. 이중 디스패치가 있으므로 당연히 단일 디스패치도 존재한다. 단일 디스패치는 객체의 실행 시간 유형에 따라 객체에서 실행될 메서드가 결정되지만, 어떤 객체의 메서드가 실행되는지를 결정할 때는 메서드 매개변수의 컴파일 시간 유형에 의해 결정되는 것을 의미한다.
디스패치는 객체지향 프로그래밍 언어에서 메서드 호출을 의미한다. 객체지향에서는 어떤 객체가 다른 객체의 메서드를 호출하는 것을 객체가 다른 객체에 메시지를 보내는 것과 동일시하기 때문이다. 이 메시지는 객체와 메서드의 이름, 메서드의 매개변수를 포함하고 있다.
생각해보기
비지터 패턴은 객체와 동작을 분리하는데, 이것이 객체지향 프로그래밍의 캡슐화 특성을 위반하는가?
> 개방 폐쇄 원칙에 반하지 않기 위해 객체와 동작을 분리하기 때문에, 캡슐화의 특성을 위반한다고 생각하지 않는다? (캡슐화를 위반하는게 되나..?)
메멘토 패턴
주로 데이터의 손실 방지, 취소, 복구에 사용되기 때문에 적용 시나리오의 범위가 명확하고 제한적이다. 대용량 객체의 백업과 복구를 위해 메멘토 패턴을 적용하면 시간과 공간을 효과적으로 절약할 수 있다.
정의 및 구현
스냅숏 패턴으로 불리기도 한다.
캡슐화 원칙을 위반하지 않는다는 전제하에서, 객체의 내부 상태를 획득하고, 이 상태를 객체의 외부에 저장하여, 객체를 이전 상태로 복구할 수 있도록 한다.
정의는 크게 두 가지로 나눠진다.
- 나중에 복구할 수 있도록 복사본을 저장한다.
- 캡슐화 원칙을 위반하지 않고 객체를 백업하고 복원한다.
복사본을 저장하고 복원하는 것이 캡슐화 원칙을 위반하는 이유는 무엇이고, 메멘토 패턴은 어떻게 캡슐화 원칙을 위반하지 않는가?
시간과 공간 최적화
메멘토 패턴을 적용할 때, 백업할 객체가 상대적으로 크거나 백업 빈도가 높으면 스냅숏이 차지하는 메모리가 상대적으로 많아지고, 백업과 복구에 드는 시간이 상대적으로 길어진다.
데이터가 변경될 때마다 복구가 가능하도록 백업을 생성해야 한다고 가정할 때, 백업할 데이터가 크고 자주 백업해야 하는 경우 저장 공간의 소모가 극대화되고, 백업에 필요한 시간이 부족할 수 있다. 이 문제를 해결하기 위해 일반적으로 사용되는 방법은 높은 빈도로 진행되는 백업은 증분 백업으로 처리하고, 전체 백업은 하루 또는 며칠 간격으로 처리하는 것이다.
생각해보기
백업은 아키텍처 설계 또는 제품 설계에서 일반적으로 사용된다. 응용 방법에 대해 생각해보자
> 복사하기 클립보드도 이와 같은 행동이라고 생각하고 응용할 수 있을 것 같고, 카카오톡같은 채팅 어플리케이션에서도 자동 백업? 기능을 만든다면 응용할 수 있을 것 같다.
커맨드 패턴
정의
커맨드 패턴은 요청을 객체로 캡슐화하여, 다른 객체를 다른 요청, 대기열 요청, 로깅 요청과 함께 매개변수로 전달할 수 있도록 하며, 취소 가능한 작업을 지원한다.
커맨드 패턴의 핵심은 함수를 객체로 캡슐화하는 것이다. C언어의 경우 함수 포인터를 통해 함수를 매개변수로 전달할 수 있다. 하지만 C 이외의 대부분의 프로그래밍 언어는 함수를 매개변수로 바꾸어 다른 함수에 전달하거나 변수에 할당할 수도 없다. 하지만 커맨드 패턴을 사용하면 함수를 객체로 캡슐화할 수 있다. 특히 이 함수를 포함하는 클래스를 설계하는 것은 콜백과 유사하다.
명령을 객체로 캡슐화하면 명령의 전송과 실행이 분리될 수 있으며 비동기, 지연, 명령 실행을 위한 대기, 실행 취소, 다시 실행, 저장, 명령에 대한 로깅과 같이 명령에 대해 더 복잡한 작업을 수행할 수 있다.
커맨드 패턴과 전략 패턴의 차이
각 패턴은 두 가지 관점에서 바라봐야 하는데, 첫 번째는 응용 시나리오, 즉 디자인 패턴을 이용하여 해결해야 할 문제이며, 두 번째는 디자인 패턴의 설계 사상과 코드 구현 방식이다. 만약 두 번째 관점인 설계 사상이나 코드 구현 방식에만 중점을 두게 되면 대부분의 디자인 패턴이 비슷하다는 착각을 하게 된다. 하지만 대부분의 디자인 패턴 간의 차이는 주로 응용 시나리오에서 달라진다.
전략 패턴에서 각각의 전략은 동일한 목적을 갖지만 서로 다른 구현 방식을 사용하며, 서로 대체가 가능하다. 반면에 커맨드 패턴에서 각각의 명령은 서로 다른 목적을 가지는 다른 처리 방식을 가지고 있기 때문에 서로 대체할 수 없다.
전략 패턴은 동일한 작업을 다양한 방식으로 수행하기 위해 사용되며, 각 전략은 서로 대체 가능하다.
반면 커맨드 패턴은 서로 다른 작업을 캡슐화하여 실행하는 데 사용되며, 각 명령은 고유한 기능을 가지므로 대체될 수 없다.
생각해보기
이번 절에서 설계한 모바일 게임 백엔드 서버에서 단일 스레드 패턴을 채택한다면, 멀티코어 시스템에서 CPU 리소스 사용을 어떻게 최대화할 수 있을지 생각해보자
> 단일 스레드라는 것이, 하나의 코어에서 작동한다고 생각하고 있는데.. 그렇다면 멀티 코어 시스템에서는 그 만큼 프로세스 개수를 여러 개 띄워야 리소스 사용을 최대화할 수 있는 것이 아닐까?
- “단일 스레드는 반드시 하나의 코어에서만 실행된다”는 생각은 엄밀히 말하면 틀림.
- OS가 적절히 코어를 스케줄링하지만, 동시 실행은 안 되며, 코어 하나만 사용하는 효과와 비슷하다는 의미로 보면 정확
인터프리터 패턴
인터프리터 패턴은 간단한 언어 인터프리터를 구축하는 방법을 사용하는 데 사용된다. 커맨드 패턴에 비해 응용 범위가 작으며, 컴파일러, 규칙 엔진, 정규식과 같은 일부 특정 영역에서는 사용된다.
정의
언어에 대한 문법을 정의하고, 이 문법을 처리하기 위한 인터프리터를 정의한다.
인터프리터 패턴은 문법 규칙에 따라 문장을 해석하는 데 사용되는 통역사이다.
인터프리터 패턴으로 표현식 계산하기
인터프리터 패턴의 코드는 고정 템플릿이 없기 때문에 매우 유연하다. 인터프리터 패턴에서 가장 중요한 개념은 각각의 분석 책임을 클래스로 분할하는 방식을 통해, 크고 포괄적인 분석 클래스를 만들지 않는 것이다. 일반적으로 문법 규칙을 몇 개의 작은 독립 단위로 분할한 다음, 각 단위별로 분석을 마치면 전체 문법 규칙 분석으로 통합하는 것이다.
중재자 패턴
중재자 패턴은 이해하기 쉽고 코드 구현도 매우 간단하다. 그리고 옵저버 패턴과 다소 유사하다.
정의
중재자 패턴은 객체 컬렉션 간의 상호작용을 캡슐화하는 별도의 중재자 객체를 정의하고, 객체 간의 직접적인 상호 작용을 피하기 위해 상호 작용을 중재자 객체에게 위임한다.
중재자 패턴은 중재를 위한 중간 계층을 도입하여 객체 컬렉션 간의 상호 작용 관계나 의존성을 네트워크 형태의 다대다 관계에서 위성 형태의 일대다 관계로 변환한다. 원래 여러 객체와 직접 상호 작용해야 했던 객체는 이제 객체 간의 상호 작용을 최소화하기 위해 하나의 중간 객체와 상호 작용하며, 이로 인해 코드의 복잡도가 낮아지고 코드의 가독성과 유지 보수성이 향상된다.
중재자 패턴의 매우 전형적인 예제는 항공 제어이다.
중재자 패턴은 상호 작용의 복잡도를 낮추는 것 외에도 조정이라는 중요한 역할을 한다. 사용자가 온라인 상태가 아닐 때 중재자인 서버는 메시지를 임시로 보관하는 역할을 한다. 이후 사용자가 온라인이 되면 서버가 다시 사용자에게 메시지를 전달한다.
중재자 패턴과 옵저버 패턴의 차이점
옵저버 패턴에서는 한 참여자가 옵저버이자 옵저버블이 될 수 있지만, 대부분의 경우 일방적인 상호 작용 관계를 가질 뿐만 아니라, 대부분의 참여자는 옵저버와 옵저버블 중 하나의 정체성만 가진다.
행위자 간의 상호작용이 복잡하고 유지 관리 비용이 드는 경우에만 중재자 모델을 사용하는 것을 고려하는 것이 좋다. 중재자 패턴은 약간의 부작용이 있으며, 때로는 크고 복잡한 중재 클래스를 생성해야 할 가능성도 있다. 이 밖에도 참가자의 상태가 변경되거나 다른 참가자가 실행하는 작업의 실행 순서에 대한 특별한 요구 사항이 있다면, 중재자 패턴은 중재 클래스를 사용한다. 또한 참가자의 상태가 변경되고 다른 참가자가 수행하는 작업에 특정 순서 요구 사항이 있는 경우 중재자 패턴은 중개 클래스를 사용하여 서로 다른 참가자의 메소드를 연속적으로 호출하여 순차 제어를 구현할 수 있다.
중재자 패턴의 조정 역할과 옵저버 패턴은 이러한 순서 관련 요구사항을 실행할 수 없다.
