1. 템플릿 메서드 패턴 Template Method
템플릿 메서드 패턴이란?
- 상위 클래스에서는 전반적인 흐름을 정의한다.
- 흐름을 정하는 것이 핵심이다. -> 알고리즘
- 하위 클래스에서는 상위에서 정의한 메서드를 구현한다.
-> 하위 클래스에게 위임한다.
의도와 동기
- 오퍼레이션의 알고리즘 골격을 정의하고, 서브 클래스가 구체화한다.
- 추상화된 함수를 통해 알고리즘의 일부 단계를 정의하여 템플릿 메서드의 처리 순서를 변하지 않게 한다.
클래스 다이어그램
템플릿 메서드를 프레임워크에서 많이 사용한다.
참고로 라이브러리와 프레임워크의 핵심적인 차이는 제어권의 여부이다. 프레임워크 -> 제어권을 프레임워크가 갖는다.
템플릿 메서드 -> 시나리오, 알고리즘을 정의함으로써 이미 정의된 것을 구현만 하도록 하기 때문에 프레임워크에서 많이 사용된다.
객체 협력
- AbstractClass
서브 클래스들이 반드시 구현해야 하는 알고리즘 처리 단계 내의 기본 오퍼레이션이 무엇인지를 정의한다. 서브 클래스에서 이들 오퍼레이션들을 구현한다.
- ConcreteClass
상위 클래스에서 선언된 추상 메서드를 구현하거나 이미 구현된 메서드를 재정의한다
예시
게임을 한다고 해보자. 플레이어가 레벨을 가질 수 있는데, 레벨에 따라서 할 수 있는 일과 할 수 없는 일이 다르다.
go(int) 메서드를 보자. 일반메서드인데 추상으로 정의된 기능을 갖고 오퍼레이션을 한다. go 라는 메서드는 하위에서 정의되면 안되기 때문에 final로 선언한다.
player는 해당 레벨의 인스턴스로 생성되어 플레이를 할 수 있도록 한다.
이때, go 메서드를 템플릿으로 사용한다.
다음과 같은 추상 클래스를 선언한다.
public abstract class PlayerLevel {
public abstract void run();
public abstract void jump();
public abstract void turn();
public abstract void showLevelMessage();
final public void go(int count)
{
run();
for(int i=0; i<count; i++){
jump();
}
turn();
}
}
여기서 각각의 메서드들은 추상 메서드로서 구현체들이 직접 구현을 하지만, go라는 시나리오는 변하지 않는다.
level에 따른 차이를 살펴보자.
public class BeginnerLevel extends PlayerLevel{
@Override
public void run() {
System.out.println("천천히 달립니다");
}
@Override
public void jump() {
System.out.println("Jump 하지 못함.");
}
@Override
public void turn() {
System.out.println("Turn 하지 못함.");
}
@Override
public void showLevelMessage() {
System.out.println("***** 초보자 레벨입니다. *****");
}
}
또 다른 레벨로서 슈퍼레벨이 있다.
public class SuperLevel extends PlayerLevel{
@Override
public void run() {
System.out.println("빨리 달립니다.");
}
@Override
public void jump() {
System.out.println("Jump 가능.");
}
@Override
public void turn() {
System.out.println("Turn 가능.");
}
@Override
public void showLevelMessage() {
System.out.println("***** 슈퍼 레벨입니다. *****");
}
}
이제 해당 레벨과 연관된 객체로서 Player를 만들자.
public class Player {
private PlayerLevel level;
public Player() {
level= new BeginnerLevel();
level.showLevelMessage();
}
public PlayerLevel getLevel() {
return level;
}
public void upgradeLevel(PlayerLevel level) {
this.level = level;
level.showLevelMessage();
}
public void play(int count){
level.go(count);
}
}
이제 미리 정의한 시나리오를 사용하는 예제 코드를 돌려보자.
public class TemplateMethodMain {
public static void test(){
Player player = new Player();
player.play(1);
player.play(2);
SuperLevel sLevel = new SuperLevel();
player.upgradeLevel(sLevel);
player.play(3);
}
public static void main(String[] args) {
test();
}
}
결론
- 코드 재사용을 위한 기본 기술
- 프레임워크에서 많이 사용됨
- 상위에서 공통 부분, 공통 로직 흐름을 정의하고 구현체들에 따라서 다른 오퍼레이션을 하도록 함
2. 팩토리 메서드 패턴 Factory Method Pattern
팩토리 메서드 패턴이란?
- 인스턴스 생성을 위한 패턴
- 템플릿 메서드랑 비슷하게 하위 클래스에게 구체 메서드의 구현을 위임하는 것이지만, 팩토리는 객체 생성에 한정한다.
의도와 동기
- 생성을 할 때 new를 해도 되는데 왜 굳이 팩토리 메서드를 통해서 할까?
- 여러 상황에 따라 유연하게 제어할 수 있음
- 어떤 클래스의 인스턴스를 생성할지에 대한 결정을 서브 클래스에서 결정하게 함
- 여러 상황에 따라 각각 생성될 수 있는 객체 생성을 하위 클래스에 위임
클래스 다이어그램
차들이 각각 생산이 될 때 여러 메서드들이 있는데 creation을 분리한다.
팩토리 메서드에서 프러덕트를 생성해서 반환을 하는데, product 종류가 여러개일 수 있다.
실제 concrete factory에서 concrete product를 만든다.
객체 협력
- Product
팩토리 메소드가 생성하는 객체의 인터페이스를 정의한다.
- ConcreteProduct
Product 클래스에 정의된 인터페이스를 실제로 구현한다.
- Creator
Product 타입의 객체를 반환하는 팩토리 메소드를 선언한다. Creator 클래스는 팩토리 메소드를 기본적으로 구현하는데, 이 구현에서는 ConcreateProduct 객체를 반환한다. Product 객체의 생성을 위해 팩토리 메소드를 호출한다.
- ConcreteCreator
ConcreteProduct 의 인스턴스를 반환하기 위해 팩토리 메소드를 재정의 한다.
예시
자동차를 만드는 예시를 통해 살펴보자.
Car Type을 정의한다.
public abstract class Car {
String carType;
public String toString() {
return carType;
}
}
여러 종류의 Car를 만들어보자.
public class Santafe extends Car{
Santafe(){
carType = "Santafe";
}
}
public class Sonata extends Car{
Sonata(){
carType = "Sonata";
}
}
이제 자동차를 만드는 추상 팩토리를 정의하자.
public abstract class CarFactory {
public abstract Car createCar(String name);
public abstract Car returnMyCar(String name);
}
해당 추상메서드의 구체 method는 다음과 같이 구현할 수 있다.
@Override
public Car createCar(String name) {
Car car = null;
if (name.equals("sonata")) {
car = new Sonata();
}
else if(name.equals("santafe")) {
car = new Santafe();
}
return car;
}
Factory는 기본적으로 생성을 담당하지만 관리를 하는 로직도 추가로 구현할 수 있다.
HashMap<String, Car> carMap = new HashMap<>();
특정 유저의 이름을 받고 매칭되는 Car를 반환해주기
@Override
public Car returnMyCar(String name) {
// Jame는 Sonata, Tomas는 Santafe 인 경우
Car myCar = carMap.get(name);
if(myCar == null) {
if(name.equals("James")){
myCar = new Sonata();
}
else if(name.equals("Tomas") ){
myCar = new Santafe();
}
carMap.put(name, myCar);
}
return myCar;
}
팩토리를 통한 생성 사용하기
@Test
public static void test(){
CarFactory factory = new HyundaiFactory();
Car newCar = factory.createCar("sonata");
System.out.println(newCar);
Car myCar = factory.returnMyCar("Tomas");
Car hisCar = factory.returnMyCar("Tomas");
System.out.println(myCar == hisCar);
}
결론
- 상황에 따라 다양한 인스턴스 생성을 할 수 있음
3. 전략 패턴 Strategy Pattern
전략 패턴이란?
- 정책, 알고리즘을 교체하여 사용할 수 있는 패턴
- 아주 많이 사용되는 패턴
의도와 동기
- 서비스를 개발하다보면 어떤 기능에 대해 한가지 방식으로만 돌아가지 않는 경우가 많다.
- 해당하는 기능들을 클래스를 만들어 정책, 전략, 알고리즘으로 분류하여 필요에 따라 사용하는 것이다.
- 클라이언트와 독립적인 다양한 알고리즘을 적용하도록 함
- 조건들에 대한 분기 (if-else, swith)를 해결
클래스 다이어그램
- Strategy
정책이 수행해야 하는 기능들을 인터페이스로 선언한다.
- ConcreteStrategy
Strategy에 선언된 여러 기능들을 구현.
다양한 정책들이 구현될 수 있다.
- Context
어떤 ConcreteStrategy 가 수행 될 것인지에 따라 정책을 선택한다.
Strategy에 선언된 메서드 기반으로 접근한다.
Strategy 클래스와 Context 클래스는 선택한 알고리즘이 동작하도록 협력한다.
* 참고 : 전략패턴의 클래스 규모가 작을 경우 Flyweight 패턴으로 정의하는 것이 좋다. 예를 들어 어떤 Sort를 구현할지에 대해서 정책적으로 정할 때
예시
고객센터에 고객들을 상담원들과 매칭해주는 알고리즘을 생각해보자.
라운도 로빈 방식이 있을 수 있고, 가장 적은 Job을 수행한 사람에게 배분하는 방식이 있을 수 있고, 우선순위 방식이 있다.
정책에 따라 다른 배분 법을 사용해야 한다.
1. 순서대로 배분하기 :
모든 상담원이 동일한 건수를 처리하도록 들어오는 순서대로 배분합니다.
2. 짧은 대기열을 찾아 배분하기 :
고객 대기 시간을 줄이기 위해 상담을 하지 않는 상담원이나 가장 짧은 대기 열을 보유한 상담원에게 배분합니다.
3. 우선 순위에 따라 배분하기 :
고객의 등급에 따라 등급이 높은 고객의 전화를 우선 가져와 업무 능력이 좋은 상담원에게 우선 배분 합니다.
이때, 이러한 정책을 실시간으로 변경할 수도 있게 하려면 ?
어떤 일을 해야하는지에 대한 인터페이스를 먼저 정의하자.
public interface Scheduler {
public void getNextCall();
public void sendCallToAgent();
}
상담원에게 어떻게 배분할지와 상담원 쪽에서 호출하는 기능을 정의한다.
구체적인 전략들을 다음과 같이 정의한다.
public class LeastJob implements Scheduler{
@Override
public void getNextCall() {
System.out.println("상담 전화를 순서대로 대기열에서 가져옵니다");
}
@Override
public void sendCallToAgent() {
System.out.println("현재 상담업무가 없거나 상담대기가 가장 작은 상담원에게 할당합니다.");
}
}
public class RoundRobin implements Scheduler{
@Override
public void getNextCall() {
System.out.println("상담 전화를 순서대로 대기열에서 가져옵니다.");
}
@Override
public void sendCallToAgent() {
System.out.println("공평하게 다음 순서 상담원에게 배분합니다.");
}
}
// 고객등급이 높은 고객부터 대기열에서 가져와 업무 능력이 높은 상담원 우선으로 배분합니다.
public class PriorityAllocation implements Scheduler{
@Override
public void getNextCall() {
System.out.println("고객 등급이 높은 고객의 전화를 먼저 가져옵니다.");
}
@Override
public void sendCallToAgent() {
System.out.println("업무 skill 값이 높은 상담원에게 우선적으로 배분합니다.");
}
}
이를 사용하는 예시코드를 다음과 같이 살펴보자.
@Test
public static void test() throws IOException {
System.out.println("전화 상담 할당 방식을 선택 하세요.");
System.out.println("R : 한명씩 차례로 할당 ");
System.out.println("L : 쉬고 있거나 대기가 가장 적은 상담원에게 할당 ");
System.out.println("P : 우선순위가 높은 고객 먼저 할당 ");
System.out.println("A : 상담원이 상담 가져가기 ");
int ch = System.in.read();
Scheduler scheduler = null;
if(ch == 'R' || ch == 'r'){
scheduler = new RoundRobin();
}
else if(ch == 'L' || ch == 'l'){
scheduler = new LeastJob();
}
else if(ch == 'P'|| ch == 'p'){
scheduler = new PriorityAllocation();
}
else if(ch == 'A'|| ch == 'a'){
scheduler = new AgentGetCall();
}
else{
System.out.println("지원되지 않는 기능입니다.");
return;
}
scheduler.getNextCall();
scheduler.sendCallToAgent();
}
여기서 요청을 받는 부분 역시 Command로서 클래스로서 관리할 수 있다. 이때 support 메서드를 통해서 요청에 따른 선택에 맞는 scheduler를 선택하도록 한다.
이를테면 다음과 같다.
public class CallCenter {
private Map<Character, Scheduler> schedulerMap;
public CallCenter() {
schedulerMap = new HashMap<>();
schedulerMap.put('R', new RoundRobin());
schedulerMap.put('L', new LeastJob());
schedulerMap.put('P', new PriorityAllocation());
schedulerMap.put('A', new AgentGetCall());
}
public void handleRequest(char option) {
Scheduler scheduler = schedulerMap.get(Character.toUpperCase(option));
if (scheduler != null) {
scheduler.getNextCall();
scheduler.sendCallToAgent();
} else {
System.out.println("지원되지 않는 기능입니다.");
}
}
}
결론
- 여러 다양한 알고리즘이 많거나 할 때 메인 코드에 영향을 주지 않으면서도 수정, 추가할 수 있어 용이하다.
- 정책 클래스를 필요에 따라 선택하도록 구현하여 유지 보수가 용이하다.
4. 브릿지 패턴 Bridge Pattern
브릿지 패턴이란?
- 전략패턴과 비슷하게 기능과 구현부를 분리하는 패턴이다.
- 추상화와 구현을 분리 -> 각각 독립적으로 변경할 수 있게 한다.
의도와 동기
- 계층화를 깔끔하게 하는 데 의의가 있다.
- 기능과 구현이 혼재하면 상속의 관계가 복잡해짐
-> 두 계층을 분리하고 서로의 사이에 다리(Bridge)를 놓는 것
클래스 다이어그램
- Abstraction (List)
추상화 개념의 상위 클래스이고 객체 구현자(Implemntor)에 대한 참조자를 관리
- RefinedAbstraction (Stack, Queue)
추상화 개념의 확장된 기능을 정의
- Implementor (AbstractList)
구현 클래스에 대한 선언을 제공
- 하위 클래스가 구현해야 하는 기능들을 선언한다. (자바의 인터페이스)
Implementor와 Abstraction의 메서드 이름은 서로 다를 수 있다.
- ConcreteImplementor (Array, LinkedList )
Implementor에 선언된 기능을 실제로 구현한다. 여러 구현방식의 클래스가 만들어 질 수 있다.
예시
예를 들어 선형 자료 구조를 구현한다고 해보자. List를 구현하되 특정 자료 구조인 Stack, Queue, Deque와 같이 특정 기능을 제공하는 자료구조를 구현해야 한다.
이때 List를 구현하는 방법은 크게 Array와 LinkedList 가 있다.
가령 하나의 Stack을 구현한다고 할 때 Array로 구현 할 수도 있고, LinkedList로 구현할 수도 있다. 이를 브릿지 패턴을 적용하여 구현해보자.
먼저 추상 List 인터페이스를 다음과 같이 선언한다.
public interface AbstractList<T> {
public void addElement(T obj);
public T deleteElement(int i);
public int insertElement(T obj, int i);
public T getElement(int i);
public int getElementSize();
}
해당하는 인터페이스를 사용하는 리스트를 다음과 같이 구현한다.
public class List<T>{
AbstractList<T> impl;
public List(AbstractList<T> list) {
impl = list;
}
public void add(T obj) {
impl.addElement(obj);
}
public T get(int i) {
return impl.getElement(i);
}
public T remove(int i) {
return impl.deleteElement(i);
}
public int getSize() {
return impl.getElementSize();
}
}
중요한 것은 이제 이 Impl을 어떻게 정의할 것이냐이다. 이에 대해서는 생성자를 통해 주입되도록 한다.
즉, 생성자로 들어오는 매개 변수가 무엇으로 구현되어 있느냐에 따라 실제 기능이 다르게 작동한다.
이 List를 상속하는 자료구조를 다음과 같이 작성해보자.
public class Queue<T> extends List<T> {
public Queue(AbstractList<T> list) {
super(list);
System.out.println("Queue를 구현합니다.");
}
public void enQueue(T obj) {
impl.addElement(obj);
}
public T deQueue() {
return impl.deleteElement(0);
}
}
public class Stack<T> extends List<T> {
public Stack(AbstractList<T> list) {
super(list);
System.out.println("Stack을 구현합니다.");
}
public void push(T obj) {
impl.insertElement(obj, 0);
}
public T pop() {
return impl.deleteElement(0);
}
}
이제 구체적인 구현체들을 작성해보자.
public class ArrayImpl<T> implements AbstractList<T> {
ArrayList<T> array;
public ArrayImpl(){
array = new ArrayList<T>();
System.out.println("Array로 구현합니다.");
}
@Override
public void addElement(T obj) {
array.add(obj);
}
@Override
public T deleteElement(int i) {
return array.remove(i);
}
@Override
public int insertElement(T obj, int i) {
array.add(i, obj);
return i;
}
@Override
public int getElementSize() {
return array.size();
}
@Override
public T getElement(int i) {
return array.get(i);
}
}
import java.util.LinkedList;
public class LinkedListImpl<T> implements AbstractList<T>{
LinkedList<T> linkedList;
public LinkedListImpl() {
linkedList = new LinkedList<T>();
System.out.println("LinkedList로 구현합니다.");
}
@Override
public void addElement(T obj) {
linkedList.add(obj);
}
@Override
public T deleteElement(int i) {
return linkedList.remove(i);
}
@Override
public int insertElement(T obj, int i) {
linkedList.add(i, obj);
return i;
}
@Override
public int getElementSize() {
return linkedList.size();
}
@Override
public T getElement(int i) {
return linkedList.get(i);
}
}
그렇다면 구현체들을 사용하는 예시를 살펴보자.
import org.junit.jupiter.api.Test;
public class BridgePatternMain {
@Test
public static void test(){
Queue<String> arrayQueue = new Queue<>(new ArrayImpl<>());
arrayQueue.enQueue("aaa");
arrayQueue.enQueue("bbb");
arrayQueue.enQueue("ccc");
System.out.println(arrayQueue.deQueue());
System.out.println(arrayQueue.deQueue());
System.out.println(arrayQueue.deQueue());
System.out.println("=========================");
Queue<String> linkedQueue = new Queue<>(new LinkedListImpl<>());
linkedQueue.enQueue("aaa");
linkedQueue.enQueue("bbb");
linkedQueue.enQueue("ccc");
System.out.println(linkedQueue.deQueue());
System.out.println(linkedQueue.deQueue());
System.out.println(linkedQueue.deQueue());
System.out.println("=========================");
Stack<String> arrayStack = new Stack<>(new ArrayImpl<>());
arrayStack.push("aaa");
arrayStack.push("bbb");
arrayStack.push("ccc");
System.out.println(arrayStack.pop());
System.out.println(arrayStack.pop());
System.out.println(arrayStack.pop());
System.out.println("=========================");
Stack<String> linkedStack = new Stack<>(new LinkedListImpl<>());
linkedStack.push("aaa");
linkedStack.push("bbb");
linkedStack.push("ccc");
System.out.println(linkedStack.pop());
System.out.println(linkedStack.pop());
System.out.println(linkedStack.pop());
System.out.println("=========================");
}
public static void main(String[] args) {
test();
}
}
이렇게 List를 구현하는 기능과 구현체를 분리하여 브릿지(Bridge)를 두는 것이 브릿지 패턴이다. List는 추상화된 인터페이스인 AbstractList를 사용하고, 구현체들은 AbstractList를 구현한 ArrayImpl과 LinkedListImpl이다.
이렇게 함으로써 List를 구현하는 방법이나 기능을 변경하더라도 List를 사용하는 코드에는 영향을 주지 않게 된다. 예를 들어, Stack이나 Queue를 구현할 때 Array로 구현하든 LinkedList로 구현하든 List를 사용하는 코드는 변경되지 않는다. List의 기능을 확장하는 경우에도 List를 사용하는 코드는 변경되지 않으며, 확장된 기능을 추가한 RefinedAbstraction인 Stack과 Queue를 만들어 사용할 수 있다.
이렇게 List와 구현체들을 분리하여 다리를 놓은 것이 브릿지 패턴의 핵심 아이디어라고 할 수 있다.
결론
- 구현과 기능의 낮은 결합도 -> 기능이 구현 방식에 얽매이지 않게 한다.
-> 런타임에서 다양한 구현을 선택할 수 있다.
- 기능과 구현이 독립적으로 확장되며 클라이언트는 기능의 인터페이스만 사용하므로 구체적인 구현을 몰라도 된다.
참고자료
- 패스트 캠퍼스 (박은종의 객체지향 설계를 위한 디자인패턴 with 자바)
'Lecture' 카테고리의 다른 글
자바 디자인 패턴: 상태 패턴, 옵저버 패턴, 메멘토 패턴, 파사드 패턴, 중재자 패턴 (0) | 2023.07.06 |
---|---|
자바 디자인 패턴: 데코레이터 패턴, 콤포지트 패턴, 어댑터 패턴 (0) | 2023.07.05 |
자바 디자인 패턴: 인스턴스 생성 패턴 - 싱글톤, 프로토타입, 빌더, 추상 팩토리 (1) | 2023.07.05 |
디자인 패턴을 배워야 하는 이유, 객체지향 설계와 SOLID, 클래스 다이어그램 (0) | 2023.07.05 |
대규모 서비스에서 발생하는 데이터 처리, 백엔드 엔지니어링의 역할과 범위 (0) | 2023.07.04 |