행동 패턴 - 책임 연쇄(Chain of Responsibility)
- 출처: https://refactoring.guru/design-patterns/chain-of-responsibility
- 출처: GoF의 디자인 패턴
- 책임 연쇄 패턴
메시지를 보내는 객체와 받는 객체의 결합도를 없애기 위한 패턴이다. 어떤 요청을 핸들러들의 체인에 넘기면, 각 핸들러는 해당 요청을 처리할지 다음 핸들러로 넘길지를 결정한다.
- 문제점
온라인 주문 시스템을 구축한다고 가정해보자. 많은 기능들이 필요하겠지만 유저에 대한 인증이나, Admin 권한을 가진 사용자의 경우 모든 주문을 조회한다던지 하는 기능들이 필요할것이다.
시스템이 비대해져가면서 비밀번호 brute force 어택을 막기 위한 기능, 요청에 대한 validation, 같은 요청에 대해 cache를 반환하는 기능이 필요할수도 있다.
이 상태에서 또 다른 기능을 추가하면 로직은 복잡해진다. 하나를 변경할 때 다른 기능에 영향을 줄 수도 있고, 만약 이 기능들중 일부분의 기능이 다른 기능구현에 필요하다면 중복코드가 발생한다. 이렇게 되면 시스템을 관리하며 유지보수하기가 매우 힘들어진다.
- 해결책
다른 행동 패턴들과 유사하게 책임 연쇄 패턴도 핸들러라는 단일 객체를 사용한다. 위의 문제점과 같은 상황에서는 각 단계별 행동들이 단일 메소드를 가지는 클래스가 되고, 요청은 단일 메소드의 인자가 된다.
책임 연쇄 패턴은 핸들러들을 연결하여 체인 형태로 구성한다. 체인에서 각 핸들러들은 다음 핸들러를 참조하는 필드를 가지고 있으며, 요청을 처리하고 넘긴다.
또 하나의 특징이 있는데 마치 알고리즘에서 더 탐색할 필요가 없는 그래프 경로를 탐색하지 않는 가지치기 처럼, 각 핸들러는 요청을 다음 핸들러에 넘길지 말지 결정할 수 있다.
주문 시스템에서 만약 올바른 요청이 들어오면 각 핸들러들은 자신의 로직을 수행 한 후, 요청을 주문 시스템까지 넘길것이다. 이렇게 되면 모든 핸들러들은 요청을 처리하게 된다.
약간 다른접근법도 있다. 요청을 받으면 각 핸들러들은 자신이 처리할지 말지를 결정하다가 자신이 처리할 수 있으면 더 이상 요청을 넘기지 않는다. 그래서 요청을 1개의 핸들러만 처리하거나 어떠한 핸들러도 처리하지 않게 된다. 이런 접근법은 GUI 에서 이벤트처리를 할 때 사용된다.
예를 들어 버튼을 하나 클릭하여 발생하는 이벤트는 해당 버튼을 감싸고 있는 컨테이너(ex - 판넬), 그리고 전파되다가 최상위 객체인 윈도우까지 전파된다.
여기서 중요한점은 각 핸들러들은 하나의 클래스만 구현하며, 해당 클래스는 execute 와 같은 단일 메소드만 가진다는 점이다. 이렇게 되면 실행시점에 핸들러 체인을 구성할 수 있게 된다.
- 실생활 예제
새로운 하드웨어를 컴퓨터에 설치한다고 가정하자. Window와 linux 에 설치하여 부팅을 했는데, linux 에서는 동작을 하지 않는다. 희망을 안고 기술지원 고객센터에 전화를 한다.
사람이 아닌 자동응답기가 몇 가지 솔루션을 제시하지만 늘 그렇듯 제대로된 솔루션은 없다. 상담원을 연결하겠냐는 말에 상담원을 연결한다. 하지만 상담원의 솔루션도 별로 썩 만족스럽진 않다. 결국에는 상담원은 엔지니어를 연결해준다. 하드웨어 드라이버를 다운받을 수 있게 도와주어 linux 에서도 해당 하드웨어가 동작하게 되었다.
위 예제에서 자동응답기, 상담원, 엔지니어는 각각 핸들러다. 자동응답기 솔루션만으로 문제가 해결되었다면 상담원, 엔지니어와는 상담할 필요가 없다. 하지만 문제가 심각해지면 응답기, 상담원, 엔지니어는 각각 자기가 제시할 수 있는 솔루션을 제시해야한다.
- 시나리오
GUI 인터페이스에서 사용자에게 도움말 기능을 제공한다고 가정해보자. F1을 누르면 도움말을 제공하는데, 어떤 Button 에서 눌렀는지 어떤 Panel 에서 눌렀는지에 따라 적절한 도움말을 제공해야 한다.
단순하게 구현하면 어렵진 않지만 코드가 복잡해진다. 일반적으로 GUI 인터페이스에서 특정 Button을 클릭하면 이벤트 버블링이 일어난다. (Web app 구현에 익숙한 사람들은 Javascript 에서 e.stopPropagation()의 개념을 떠올리면 이해하는데 도움이 될 것이다.) 도움말 기능을 단순하게 구현하면 Button1 -> Panel1 로 이벤트 버블링이 일어난다고 할 때, Button1이 도움말 기능을 제공하지 여부와 Button1이 제공하지 않는다면 최종적으로 어떤 객체가 도움말을 제공하는지에 대한 참조자를 경우의 수대로 코딩해야 한다.
이때 책임 연쇄 패턴을 적용하면 이벤트가 일어난 요소에서 나의 상위요소의 참조자만 알고 있고, 해당 이벤트가 내가 처리하는지 그게 아니라면 다음 참조자에 이벤트를 넘기게만 구현하면 자연스럽게 최종적으로 해당 이벤트를 처리해야하는 요소까지 도달할 수 있게 된다. 즉, 해당 요소마다 최종처리자(도움말 기능을 제공하는 요소)를 코드에 명시할 필요가 없으며, 이벤트 처리자를 미리 명시하지 않으므로, 상황에 따라 이벤트 처리자가 동적으로 구성될 수 있는 장점도 있다.
- Java 예제
이제 코드로 구현해보자. 우선 체인을 구성하는데 필요한 Handler Interface 를 아래와 같이 정의하였다. 메소드 인자로 요청을 넘기는 형태로 구성할 수도 있지만, 해당 요소들(Button, Dialog, Panel)마다 help message를 속성으로 정의하였기 때문에 아래와 같은 메소드 시그니처로 정의하였다.
public interface HelpHandler {
void showHelpMessage();
}
그리고 기본 handler인 BaseHandler를 정의했다. elementName은 test시 가시적으로 확인하기 편하도록 추가한 속성이다. 속성을 살펴보면 도움말 제공에 이용될 helpMessage 와 책임 연쇄 패턴에서 다음 처리자를 참조하는 속성인 container 가 있다. 우리의 예제에서는 상위 요소를 참조한다. (ex - button의 경우 자신을 감싸는 panel)
도움말 제공 기능의 핵심인 showHelpMessage()를 살펴보면 helpMessage가 존재할 시 sysout 을 이용해 자신이 처리하며, 존재하지 않으면 다음 처리자의 도움말 제공 기능을 호출한다.
public class BaseHandler implements HelpHandler{
protected String elementName;
protected HelpHandler container;
protected String helpMessage;
public BaseHandler(String elementName) {
super();
this.elementName = elementName;
}
public void setHelpMessage(String helpMessage) {
this.helpMessage = helpMessage;
}
public void setContainer(HelpHandler container) {
this.container = container;
}
@Override
public void showHelpMessage() {
if(helpMessage != null) {
System.out.println(helpMessage);
}
else if(container != null){
container.showHelpMessage();
}
}
}
Button 요소는 help message 표시기능을 수행시 특별한 기능은 필요 없다고 가정하였다. 그래서 기본 handler인 BaseHandler를 그대로 이어받는다.
public class Button extends BaseHandler{
public Button(String elementName) {
super(elementName);
}
}
그 다음은 Container 클래스를 정의하였다. Button 요소는 하위요소가 없는 말단 요소라고 가정하여 곧바로 BaseHandler를 정의하였고, 하위 요소를 가질 수 있는 Dialog나 Panel 같은 클래스가 Container 가 된다.
만약 confirm disalog 에서 확인, 취소 버튼이 있다면 Confirm dialog > panel > OK or Cancel button 순으로 자식들의 하위요소를 갖는다고 생각하면 된다.
public class Container extends BaseHandler {
protected List<BaseHandler> children;
public Container(String elementName) {
super(elementName);
children = new ArrayList<>();
}
public void addChildren(BaseHandler child) {
child.setContainer(this);
this.children.add(child);
}
}
Panel 은 Container를 상속받는다. 이 때 showHelpMessage 기능을 재정의하였는데, 아래의 코드에서는 BaseHandler의 기본 기능과 별 차이가 없지만, 실제 프로그램을 구현할 시에는 Dialog 나 Panel 에서 단순히 메시지를 뿌려주는것이 아닌 추가 동작도 수행할 수 있기 때문에 재정의할 수도 있음을 보여주었다.
public class Panel extends Container{
private String panelHelpMessage;
public Panel(String elementName) {
super(elementName);
}
public void setPanelHelpMessage(String panelHelpMessage) {
this.panelHelpMessage = panelHelpMessage;
}
@Override
public void showHelpMessage() {
if(panelHelpMessage != null) {
System.out.println("panelHelpMessage: " + panelHelpMessage);
}
else {
System.out.println("pass a request to next reference: " + super.elementName);
super.showHelpMessage();
}
}
}
이제 Test를 해보자. ok, cancel 버튼은 panel 요소 하위에 있고, panel은 confirmDialog 하위요소에 있다. 처음에 ok 버튼에서 도움말 기능을 호출하면 메시지 기능이 없는 요소들은 다음 핸들러 들을 호출한다.
하지만 ok 버튼에 도움말을 추가하고, 도움말 기능을 호출하면 자신이 해당 기능을 처리하고 다음 핸들러를 호출하지 않는다. TestCase는 아래 다이어그램을 살펴보면 이해가 빠를 것이다.
public static void main(String[] args) {
Button ok = new Button("ok button in confirmDialog");
Button cancel = new Button("cancel button in confirmDialog");
Panel panel = new Panel("parentPanel");
panel.addChildren(ok);
panel.addChildren(cancel);
Dialog confirmDialog = new Dialog("confirmDialog");
confirmDialog.setHelpMessage("confirmDialog Help Msg: Please click ok or cancel button!");
confirmDialog.addChildren(panel);
ok.showHelpMessage();
System.out.println("");
System.out.println("===== add ok button help message;");
ok.setHelpMessage("OK Button Help Msg: Please click ok button");
ok.showHelpMessage();
}
===== Result =====
pass a request to next reference: parentPanel
pass a request to next reference: confirmDialog
confirmDialog Help Msg: Please click ok or cancel button!
===== add ok button help message;
OK Button Help Msg: Please click ok button