디자인 패턴 공부 - 중재자 패턴

이혁진·2023년 1월 27일
0

중재자 패턴

중재자 패턴은 쌍방향으로 소통하는 객체들이 있을 때, 소통하는 방법을 캡슐화하는 패턴이다. 소통하는 방법을 하나의 클래스로 몰아넣었기 때문에(SRP) 응집도가 높은 코드가 된다. 따라서 변경의 전파가 덜 일어난다. 응집도에 대해서 따로 또 공부한 내용이 있는데 아래에 정리해놓았다.

일단 생긴건 이렇게 생겼다. 이러면 되게 복잡한데,


위에서 비행기와 헬기끼리 통신하는 것은 복잡하고 의존 관계가 서로 많이 생기니까, 관제탑에서 소통을 대신해주듯, 여러 컴포넌트(Colleague)의 소통을 Mediator가 담당하는 것이다. 아까 맨 위에 인터페이스 막 쓰고 하는거는 중재자 패턴에서 중재자와 컴포넌트를 더욱 유연하게 확장할 수 있게 하기 위함이고, ConcreteMediator과 ConcreteColleague(ColleagueA, ColleagueB)만 있어도 소통을 캡슐화하는 것은 충분하다.

구현

일단 패턴 적용 이전 코드이다. Guest, CleaningService, Gym, Restaurant가 있고, 그들이 전달하는 메시지는 다음과 같다.

Guest --clean()--> CleaningService
Gym --clean()--> CleaningService
Guest --getTower()--> CleaningService
Guest --dinner()--> Restaurant
Restaurant --clean()--> CleaningService

어떤 객체에게 전달하는 메시지란 그것이 가진 메소드를 말한다. 특정 객체에게 뭔갈 해달라는 메시지를 전달하고 싶다면, 그것을 해당 객체에 메소드로 구현하면 된다. 다른 말로 하면, 특정 객체의 메소드들은 받아서 처리할 메시지의 목록을 말한다고 할 수도 있겠다.

구체적인 구현은 다음과 같다.

public class Restaurant {
	private CleaningService cleaningService = new CleaningService();
    
    public void dinner() {
    	System.out.println("dinner " + guest);
    }
    
    public void clean() {
    	cleaningService.clean(this);
    }
}

public class CleaningService {
	public void clean(Gym gym) {
    	System.out.println("clean " + gym);
    }
    
    public void getTower(Guest guest, int numberOfTower) {
    	System.out.println(numberOfTower + "towers to" + guest);
    }
    
    public void clean(Restaurant restaurant) {
    	System.out.println("clean " + restaurant);
	}
}

public class Guest {
	private Restaurant restaurant = new Restaurant();
    private CleaningService cleaningService = new CleaningService();
    
    public void clean() {
    	restaurant.dinner(this);
    }
    
    public void getTower(int numberOfTower) {
    	cleaningService.getTower(this, numberOfTower);
    }
}

public class Gym {
	private CleaningService cleaningService;
    
    public void clean() {
    	cleaningService.clean(this);
    }
}

public class Main {
	public static void main(String[] args) {
    	Guest guest = new Guest();
        guest.getTower(3);
        guest.dinner();
        
        Restaurant restaurant = new Restaurant();
        restaurant.clean();
    }
}

Result:
	3 towers to <Behavior.Mediator._01_before.Guest@6e8cf4c6>
	dinner <Behavior.Mediator._01_before.Guest@6e8cf4c6>
    clean <Behavior.Mediator._01_before.Restaurant@12edcd2>
    

결과는 잘 나오는데, 이러면 객체끼리 소통이 너무 많아진다. 다수의 의존 관계가 있을 때, 서로 다 하면 n(n-3)개의 의존이다. 반면 중재자 패턴으로 하나로 모으면 의존은 2n이다. 객체가 많을수록 패턴 적용 시 응집도 뿐만 아니라 의존 관계의 절대적인 양이 줄어든다. (응집도 배제하면 n=6부터 패턴의 효과가 있구먼!)

그냥 중재자 패턴 안쓰고, Mediator 추상클래스와 Colleague 추상클래스를 가지고 맨 위에 있었던 어려운 버전의 중재자 패턴을 써볼 것이다.

우선 추상클래스들

public abstract class Colleague {
	// 뭔가 상태 변화하면 그거 중재자한테 보낸다.(=중재자의 메소드를 호출한다.)그렇게 하기 위해서 중재자를 필드로 넣는다.
	protected final Mediator mediator;
    
    // 생성자 주입
    public Colleague(Mediator mediator) {
    	this.mediator = mediator;
    }
 	
    // 상태가 변화했을 때 호출하면 된다. 중재자의 메시지 보내는 메소드를 호출하고, 그 메소드 안에서 또 메시지를 받을 클래스의 응답 메소드를 호출한다. Map은 전달할 데이터, msg는 전달할 메시지이다.
    public abstract void send(String msg, Map<String, Object> par);
    
    // 아까 중재자에서 메시지 보내는 메소드를 호출한다 했는데, 그 메소드가 아래 있는 receive를 호출한다. 중재자에 등록된 모든 객체의 receive가 mediator의 구현에 따라서 receive가 호출될지 말지가 결정된다.(메시지가 전달될 지 말지)
    public abstract void receive(Colleague sender, String msg, Map<String, Object> par);
}

public abstract class Mediator {
	// 해당 중재자에 참여할 colleague들을 담을 리스트
    // 조회가 많으니까 ArrayList가 맞는 듯?
	private final List<Colleague> colleagueList;
    
    protected Mediator() {
    	this.colleague = new ArrayList<>();
    }
    
    // colleague를 해당 Mediator에 참여시킨다.
    public void addUser(Colleague colleague) {
    	this.colleagueList.add(colleague);
    }
    
    // Colleague 탈퇴 기능
    public void deleteuser(Colleague colleague) {
    	this.colleagueList.remove(colleague);
    }
    
    // 메시지를 보내는 Colleague의 send 안에서 호출되는 함수이다. send되었을 때 이게 호출되면서, 응답을 받을 Colleague들의 receive를 호출하게 된다. 이때, 보내는 쪽은 자신에게 보내지는 것을 제외하기 위해서 if절을 활용한다. 여기에서 어떤 객체들을 응답에서 제외할 지 정할 수 있다. 발신자(user)와 메시지, 그리고 메시지에 필요한 데이터(par)을 전달한다.
    public void sendMessage(String msg, Colleague user, Map<String, Object> par) {
    	for(Colleague c : this.colleagueList) {
        	c.receive(user, msg, par);
        }
    }
}

우선 Colleague 추상클래스를 상속하는 ConcreteColleague를 구현한다.

public class CleaningService extends Colleague{
	public CleaningService(Mediator mediator) {
		super(mediator);
	}

	@Override
	public void send(String msg, Map<String, Object> par) {
    	// 보낼 메시지가 없으니까 미구현
	}

	@Override
	public void receive(Colleague sender, String msg, Map<String, Object> par) {
    	// 발신자가 Gym이고 clean 메시지이면 그걸 수행
		if(msg.equals("clean") && sender instanceof Gym) {
			System.out.println("start gym cleaning");
		} // 마찬가지
		else if(msg.equals("clean") && sender instanceof Restaurant) {
			System.out.println("start restaurant cleaning");
		} // 수건 달라는 메시지면 수건 줌
		else if(msg.equals("getTower")) {
			try {
				System.out.println("give " + par.get("name") + " " + par.get("numberOfTower") + " towers");
			} catch (Exception e) {
				System.out.println(e.getMessage());
			}
		}
		else {

		}
	}
}

public class Guest extends Colleague {
	private final String name;

	public Guest(Mediator mediator, String name) {
		super(mediator);
		this.name = name;
	}

	// 메시지 보낼거면 이 메소드를 호출한다.
    @Override
	public void send(String msg, Map<String, Object> par) {
		mediator.sendMessage(msg, this, par);
	}

	@Override
	public void receive(Colleague sender, String msg, Map<String, Object> par) {
		// 게스트는 메시지 보내기만 하고, 받진 않으니 미구현
	}


	// 수건 달라는 메시지를 보낸다. 필요한 정보를 해시맵에 넣고, 이 객체에 있는 send를 호출하여 메시지를 전달.
	public void getTower(int numberOfTower) {
		Map<String, Object> par = new HashMap<>();
		par.put("numberOfTower", numberOfTower);
		par.put("name", this.name);
		this.send("getTower", par);
	}

	// 마찬가지
	public void eatDinner() {
		Map<String, Object> par = new HashMap<>();
		par.put("name", this.name);
		this.send("eatDinner", par);
	}
}

public class Gym extends Colleague {
	public Gym(Mediator mediator) {
		super(mediator);
	}

	@Override
	public void send(String msg, Map<String, Object> par) {
		mediator.sendMessage(msg, this, par);
	}

	@Override
	public void receive(Colleague sender, String msg, Map<String, Object> par) {
    
	}

	public void clean() {
		send("clean", null);
	}
}

public class Restaurant extends Colleague {
	public Restaurant(Mediator mediator) {
		super(mediator);
	}

	@Override
	public void send(String msg, Map<String, Object> par) {
		mediator.sendMessage(msg, this, par);
	}

	@Override
	public void receive(Colleague sender, String msg, Map<String, Object> par) {
		if(msg.equals("eatDinner")) {
			System.out.println(par.get("name") + " eat dinner");
		}
	}

	public void clean() {
		send("clean", null);
	}
}

public class Main {
	public static void main(String[] args) {
		// Mediator mediator = new FrontDesk();
		Mediator mediator = new PseudoMediator();
        
		Guest guest1 = new Guest(mediator, "gtw");
		Guest guest2 = new Guest(mediator, "mj");
		Gym gym = new Gym(mediator);
		CleaningService cleaningService = new CleaningService(mediator);
		Restaurant restaurant = new Restaurant(mediator);

		mediator.addUser(guest1);
		mediator.addUser(guest2);
		mediator.addUser(gym);
		mediator.addUser(restaurant);
		mediator.addUser(cleaningService);
        
        /*
		pseudoMediator.addUser(new Colleague(pseudoMediator) {
			@Override
			public void send(String msg, Map<String, Object> par) {
				// 구현
			}

			@Override
			public void receive(Colleague sender, String msg, Map<String, Object> par) {
				// 구현
			}
		});
        */

		guest1.getTower(3);
		guest2.eatDinner();
		restaurant.clean();
		gym.clean();
	}
}	

Result :
	give gtw 3 towers
	mj eat dinner
	start restaurant cleaning
	start gym cleaning

주석들 보면 인터페이스 기반의 장점을 톡톡히 확인할 수 있다. Colleague에 다른 타입 객체 넣어도 딴거 안터지고 다 돌아가고, User 추가할 때에도 기존코드 하나도 안건드리고 할 수 있다. 위에서는 익명클래스로 넣었지만, Colleague를 상속하는 클래스 만들어서 넣어도 된다.(OCP)

그냥 receiver까지 정해서 메시지 날렸으면 Mediator에서 뭔가 할 수 있었을 텐데, 좀 아쉬운 설계이긴 하다.

장점

장점은 높은 응집도이다. 뭔가 Colleague간 문제가 터지면 Mediator만 보면 된다. 단점은 구조가 복잡해지고, 의존관계가 Mediator에 집중되어, Mediator가 터지면 다른 Colleague들이 다 터진다.

예시1 - ExecutorService

ExcutorService란 여러 쓰레드 작업을 효율적으로 처리하기 위해 제공되는 자바의 라이브러리이다. 쓰레드 쓸 때(잘 모르지만)

작업1 - 쓰레드1  작업2 - 쓰레드2  작업3 - 쓰레드3
작업1 - 쓰레드1  작업4 - 쓰레드2  작업3 - 쓰레드3
작업1 - 쓰레드1  작업4 - 쓰레드2  작업5 - 쓰레드3

이렇게 막 꼬여서 메시지 보낼테니까, 중간에서 ExecutorService가 중개하는 듯 하다.

다시 말하면, 각기 다른 Thread를 생성해서 작업하고 처리가 끝나면 그걸 제거하는 작업을 클래스마다 손수 했다면(한 메서드가 여러 쓰레드에 메시지 보냄, 인자 보낼 때 또 뭐 하니까 쌍방향 M:N 소통이 활발하게 발생한다고 볼 수 있겠디)

ExecuterService가 그걸 중간에서 다 알아서 여러 Thread에 메시지 보내면서 해준다.

ExecutorService executorService = new ThreadPoolExecutor(
		int corePoolSize, 
		int maximumPoolSize, 
		long keepAliveTime, 
		TimeUnit unit, 
		BlockingQueue<Runnable> workQueue);
        

수행할 작업들 Runnable 구현해서 run에 넣고, 그거를 큐에넣어서 workQueue로 넘겨주면 되는 모양.

예시2 - DispatcherServlet

디스패처 서블릿은 HTTP 프로토콜 요청을 가장 먼저 받아 적절한 컨트롤러에 메시지를 전달한다. 스프링 MVC에서 요청을 모두 중재해주는 아주 핵심적인 역할을 한다고 한다.
클라이언트에서 요청이 날아오면 WAS의 서블릿 컨테이너(서블릿은 요청 처리 단위인듯?)가 받는데, 그러면 디스패쳐 서블릿이 딱 가서 요청 받아가지고 MVC 컨테이너의 적절한 컨트롤러에다가 전달해주는 듯 하다.

여기서 디스패쳐 서블릿이 Mediator라고 볼 수 있겠고, 그 안에 있는

이 친구들이 ColleagueList라고 볼 수 있겠다. Colleague는 물론 컨트롤러들을 말한다. 또 이렇게 생긴 친구가 있다.

여기에 있는 DoDispatch가 아까 거기에서 sendMessage에 해당되는 모양이다. 그 안을 살펴보면

여기에서 현재 요청에 맞는 핸들러(컨트롤러?)를 얻고

또 어댑터에 핸들러를 넣어줘야하나보다.

여기에서 실제로 컨트롤러의 메소드가 실행되는가보다. 아까 Mediator 안의 sendMessage() 안에서 colleague.response()를 호출하는 거랑 비슷한 모양(물론 중간에 뭐가 또 있겠지만)

아까 설계랑 다른 점은 매핑하는 부분이 Mediator에 있다는 것이다. 내가 한 거는 요청을 모든 Colleague에게 다 뿌리고 응답받은 Colleague 들이 요청을 받아 조건문으로 해당 Colleage가 처리를 할지 안할지를 정했는데, 디스패쳐서블릿은 여기에서(Mediator)에서 직접 어떤 Colleage가 처리를 할 지 정한다. 원래는 이게 맞는데, 구현할 방식이 안떠올라서 어쩔 수 없었다. 전체적인 과정은 다음 사진에서 볼 수 있다.

아무튼 이렇게 하면 서블릿들이 직접 MVC 컨테이너에서 컨트롤러 찾아 헤멜 필요 없고, 디스패쳐 서블릿만 보면 된다고 할 수 있겠다. ExecuterService에서 쓰레드 처리할 때 여러 쓰레드 다 관리하지 않고 이 클래스만 보면 되는 거랑 비슷하다고 볼 수 있겠다.

응집도

우아한 객체지향이라는 세미나에서 응집도에 관한 이야기를 들었는데, 높은 응집도가 왜 변경에 취약한지 설명하시는 방법이 매우 인상깊었다. 응집도란 하나의 클래스에 있는 로직이 얼마나 관련되어있는가?를 말한다. 가령 클래스 하나에 밥먹기 관련 로직과 잠자기 관련 로직이 섞여 있다고 하자. 이 클래스는 아침먹기, 점심먹기, 저녁먹기 로직이 있는 클래스보다 응집도가 낮다. 잠자기보다는 아침먹기, 점심먹기 등이 서로 연관이 있기 때문이다.

이러한 응집도가 중요한 이유는 변경 가능성 때문이다. 응집도가 높을수록 서로 관련된 로직만 담겨있기에, 변경될 이유가 해당 주제에 국한되기 때문이다. 낮을수록 여러 주제가 한 클래스에 담겨있으므로 변경될 이유가 다양하다. 위에 예시를 들어 설명해보겠다. 회사의 정책에 따라서 전자의 클래스가 바뀔 확률은 (밥먹기 변경 가능성 + 잠자기 변경 가능성)이고, 후자의 경우는 (밥먹기 변경 가능성)이다. 응집도가 낮을수록 더욱 그 확률의 차이는 늘어날 것이다.

이러한 변경이 단 하나의 클래스에서만 쉽게 일어난다고 보는 것은 오산이다. 하나의 클래스에 관련없는 주제가 섞여 있다는 것은

Class A : 밥먹기, 잠자기, 똥싸기
Class B : 아침먹기, 점심먹기, 잠자기, 요리하기
Class C : 요리하기

어떤 주제의 로직이 클래스를 횡단하여 여러 곳에 존재할 확률이 높다는 것이다. 응집도를 높이기 위해선 하나의 클래스에 담겨있어야 할 내용이 찢어져서 여러 곳에 담겨있는 셈이다. 그래서 잠자기 관련 코드나 정책이 바뀌면 그것이 찢어져 들어간 클래스에 변경이 연쇄적으로 일어난다. 응집도가 높다면 변경이 해당되는 클래스 하나에서만 일어날 것이다.

하지만 이러한 높은 응집도를 만들 때에는 결합도 역시 높아질 수 있다. 높은 응집도로 개선하기 위해선 여러 클래스에 흩어진 로직을 하나의 클래스에 담게 되는데, 그 여러 클래스는 그 로직을 수행하기 위해 응집된 클래스에 의존하게 되고, 응집된 클래스는 수많은 클래스와 결합한 클래스가 되기 때문이다.

profile
한양대학교 정보시스템학과 22학번 이혁진 입니다

0개의 댓글