...
Chain Of Responsibility Pattern
책임 연쇄 패턴(Chain Of Responsibility Pattern, COR)은 클라이어트의 요청에 대한 세세한 처리를 하나의 객체가 몽땅 하는 것이 아닌, 여러개의 처리 객체들로 나누고, 이들을 사슬(chain) 처럼 연결해 집합 안에서 연쇄적으로 처리하는 행동 패턴이다.
이러한 처리 객체들을 핸들러(handler)라고 부르는데, 요청을 받으면 각 핸들러는 요청을 처리할 수 있는지, 없으면 체인의 다음 핸들러로 처리에 대한 책임을 전가한다. 한마디로 책임 연쇄라는 말은 요청에 대한 책임을 다른 객체에 떠넘긴다는 소리이다. 떠넘긴다고 하니까 부정적인 의미로 들릴수도 있겠지만, 이러한 체인 구성은 하나의 객체에 처리에 대한 책임을 요청을 보내는 쪽(sender)과 요청을 처리하는(receiver) 쪽을 분리하여 각 객체를 부품으로 독립시키고 결합도를 느슨하게 만들며, 상황에 따라서 요청을 처리할 객체가 변하는 프로그램에도 유연하게 대응할 수 있다는 장점을 가지고 있다. 특히나 중첩 if-else문들을 최적화하는데 있어 실무에서도 많이 애용되는 패턴중 하나이기도 하다.
좀 더 접근하기 쉽게 실생활로 비유를 든다면, 예를들어 소비자가 문의를 위해 고객센터에 전화를 걸었다고 해보자. 그러면 아래와 같은 순서로 요청을 처리해본 경험을 한 번 씩은 겪어 봤을 것이다.
- 첫 번째로, 자동 응답기 음성 로봇이 응답하게 된다. 그런데 만일 음성 로봇이 제시하는 해결책에 대해서 1부터 4까지 선택사항중 해당하는 번호가 없다면 아마 여러분도 그렇고 대다수의 사람들이 기타 버튼을 눌러 상담원 연결을 누를 것이다.
- 두 번째로, 상담사가 전화를 받았지만 길게 통화한 결과 제대로된 기술적인 도움을 받지 못했다고 한다. 그러면 상담원이 직접 엔지니어에게 전화를 연결해주게 된다.
- 세 번째로, 엔지니어와 통화하고 적합한 솔루션을 제시해줌으로써 문제가 해결되어 통화를 종료한다.
마치 소비자의 요청에 대해 각 처리 서비스들이 무엇인가 체인처럼 연결되는 흐름이 되는 것 처럼 보일 것이다.
여기서 핸들러(Handler)는 자동 응답기 로봇, 상담사, 엔지니어가 된다. 즉, 각 핸들러에서 요청을 처리할 수 있는지의 여부를 따지고 불가능하면 다음 핸들러로 떠넘기는 과정을 진행한 것을 코드로 구현한 것이 책임 연쇄 패턴인 것이다.
책임 연쇄 패턴 구조
- Handler : 요청을 수신하고 처리 객체들의 집합을 정의하는 인터페이스
- ConcreteHandler : 요청을 처리하는 실제 처리 객체
- 핸들러에 대한 필드를 내부에 가지고 있으며 메서드를 통해 다음 핸들러를 체인시키고 다음 바라본다.
- 자신이 처리할 수 없는 요구가 나오면 바라보고 있는 다음 체인의 핸들러에게 요청을 떠넘긴다.
- ConcreteHandler1 - ConcreteHandler2 - ConcreteHandler3 - ... 이런식으로 체인 형식이 구성되게 된다.
- Client : 요청을 Handler 전달한다
여기서 핸들러끼리 체이닝 되는 구조는 어떤 형태이든 상관이 없다. 리스트형 일수도 있고 선형 일 수도 있고 트리 형태일 수도 있다.
책임 연쇄 패턴 특징
패턴 사용 시기
- 특정 요청을 2개 이상의 여러 객체에서 판별하고 처리해야 할때
- 특정 순서로 여러 핸들러를 실행해야 하는 경우
- 프로그램이 다양한 방식과 종류의 요청을 처리할 것으로 예상되지만 정확한 요청 유형과 순서를 미리 알 수 없는 경우
- 요청을 처리할 수 있는 객체 집합이 동적으로 정의되어야 할 때 (체인 연결을 런타임에서 동적으로 설정)
패턴 장점
- 클라이언트는 처리 객체의 체인 집합 내부의 구조를 알 필요가 없다.
- 각각의 체인은 자신이 해야하는 일만 하기 때문에 새로운 요청에 대한 처리객체 생성이 편리해진다.
- 클라이언트 코드를 변경하지 않고 핸들러를 체인에 동적으로 추가하거나 처리 순서를 변경하거나 삭제할 수 있어 유연해진다
- 요청의 호출자(invoker)와 수신자(receiver) 분리시킬 수 있다.
- 요청을 하는 쪽과 요청을 처리하는 쪽을 디커플링 시켜 결합도를 낮춘다
- 요청을 처리하는 방법이 바뀌더라도 호출자 코드는 변경되지 않는다.
패턴 단점
- 실행 시에 코드의 흐름이 많아져서 과정을 살펴보거나 디버깅 및 테스트가 쉽지 않다.
- 충분한 디버깅을 거치지 않았을 경우 집합 내부에서 무한 사이클이 발생할 수 있다.
- 요청이 반드시 수행된다는 보장이 없다. (체인 끝까지 갔는데도 처리되지 않을 수 있다)
- 책임 연쇄로 인한 처리 지연 문제가 발생할 수 있다. 다만 이는 트레이드 오프로서 요청과 처리에 대한 관계가 고정적이고 속도가 중요하면 책임 연쇄 패턴 사용을 유의하여야 한다.
예제를 통해 알아보는 Chain Of Responsibility 패턴
책임을 분리하여 연결짖기
사용자로부터 url 문자열을 입력받으면, 각 url의 프로토콜, 도메인, 포트를 파싱해서 정보를 출력해주는 프로그램을 만든다고 해보자.
클린하지 않은 문제의 코드 ❌
일반적으로는 아래와 같이 처리문을 하나의 메서드로 통짜로 구성할 것이다.
class UrlParser {
public static void run(String url) {
// protocol 파싱
int index = url.indexOf("://");
if (index != -1) {
System.out.println("PROTOCOL : " + url.substring(0, index));
} else {
System.out.println("NO PROTOCOL");
}
// domain 파싱
int startIndex = url.indexOf("://");
int lastIndex = url.lastIndexOf(":");
System.out.print("DOMAIN : ");
if (startIndex == -1) {
if (lastIndex == -1) {
System.out.println(url);
} else {
System.out.println(url.substring(0, lastIndex));
}
} else if (startIndex != lastIndex) {
System.out.println(url.substring(startIndex + 3, lastIndex));
} else {
System.out.println(url.substring(startIndex + 3));
}
// port 파싱
int index2 = url.lastIndexOf(":");
if (index2 != -1) {
String strPort = url.substring(index2 + 1);
try {
int port = Integer.parseInt((strPort));
System.out.println("PORT : " + port);
} catch (NumberFormatException e) {
e.printStackTrace();
}
}
}
}
class Client {
public static void main(String[] args) {
String url1 = "http://www.youtube.com:80";
System.out.println("INPUT: " + url1);
UrlParser.run(url1);
String url2 = "https://www.inpa.tistory.com:443";
System.out.println("INPUT: " + url2);
UrlParser.run(url2);
String url3 = "http://localhost:8080";
System.out.println("INPUT: " + url3);
UrlParser.run(url3);
}
}
동작에는 문제는 없지만, 이런식으로 구성할 경우 만일 path나 queryString도 구하는 새로운 처리 로직이 추가될 경우 메서드 로직을 통짜로 수정해야할 것이고 이전의 로직과 겹치는게 없는지 복기해야 한다. 당장 코드만 봐도 프로토콜, 도메인, 포트를 구하는데 있어 indexOf() 메서드를 자체적으로 사용하고 있어 겹치는 부분이 있는 것을 볼 수 있다.
또한 사용자가 포트 정보는 원하지 않을 경우 코드를 지울수는 없으니 포트 파싱 부분만 뺀 비슷한 코드로 이루어진 메서드를 따로 생성해야 한다. 이는 요구에 대한 처리를 중앙 집권적으로 모두 가지고 있기 때문에 발생하는 현상이다.
책임 연쇄 패턴을 적용한 코드 ✔️
그렇다면 프로토콜만을 파싱하는 책임과 도메인만을 파싱하는 책임 이렇게 처리 책임을 각 객체들로 분리하고 이들 끼리 체인으로 연결함으로써 좀더 유연하게 프로그램 로직을 구성할 수 있게 된다. 이를 그림으로 표현하자면 아래와 같다.
핸들러는 본인의 역할만을 수행하고 추가 처리 로직이 필요하다면 유연하게 체인을 추가해주면 되며 체인을 구조적으로 다양하게 구성할수 있게 된다.
책임 연쇄를 적용하는 방법은 그리 어렵지 않다. 기존의 코드에서 각 담당하는 부분을 따로 핸들러(Handler) 객체로 내빼주면 된다.
전략 패턴이 전략 알고리즘 코드를 객체화 한 것이고, 상태 패턴은 객체의 상태를, 명령 패턴이 커맨드를 객체화 한 것과 같이, 책임 연쇄 패턴은 조건문의 요청 처리 로직 자체를 객체화 한 것으로 보면 된다. 그리고 일반적인 조건 분기문 같은 경우 다중으로 구성될 수 있으니 이를 체인으로 객체끼리 연결함으로써, 각 if문 로직을 클래스로 표현하였다고 보면 된다.
// 구체적인 핸들러를 묶는 인터페이스 (추상 클래스)
abstract class Handler {
// 다음 체인으로 연결될 핸들러
protected Handler nextHandler = null;
// 생성자를 통해 연결시킬 핸들러를 등록
public Handler setNext(Handler handler) {
this.nextHandler = handler;
return handler; // 메서드 체이닝 구성을 위해 인자를 그대로 반환함
}
// 자식 핸들러에서 구체화 하는 추상 메서드
protected abstract void process(String url);
// 핸들러가 요청에 대해 처리하는 메서드
public void run(String url) {
process(url);
// 만일 핸들러가 연결된게 있다면 다음 핸들러로 책임을 떠넘긴다
if (nextHandler != null)
nextHandler.run(url);
}
}
class ProtocolHandler extends Handler {
@Override
protected void process(String url) {
int index = url.indexOf("://");
if (index != -1) {
System.out.println("PROTOCOL : " + url.substring(0, index));
} else {
System.out.println("NO PROTOCOL");
}
}
}
class DomianHandler extends Handler {
@Override
protected void process(String url) {
int startIndex = url.indexOf("://");
int lastIndex = url.lastIndexOf(":");
System.out.print("DOMAIN : ");
if (startIndex == -1) {
if (lastIndex == -1) {
System.out.println(url);
} else {
System.out.println(url.substring(0, lastIndex));
}
} else if (startIndex != lastIndex) {
System.out.println(url.substring(startIndex + 3, lastIndex));
} else {
System.out.println(url.substring(startIndex + 3));
}
}
}
class PortHandler extends Handler {
@Override
protected void process(String url) {
int index = url.lastIndexOf(":");
if (index != -1) {
String strPort = url.substring(index + 1);
try {
int port = Integer.parseInt((strPort));
System.out.println("PORT : " + port);
} catch (NumberFormatException e) {
e.printStackTrace();
}
}
}
}
class Client {
public static void main(String[] args) {
// 1. 핸들러 생성
Handler handler1 = new ProtocolHandler();
Handler handler2 = new DomianHandler();
Handler handler3 = new PortHandler();
// 2. 핸들러 연결 설정 (handler1 → handler2 → handler3)
handler1.setNext(handler2).setNext(handler3);
// 3. 요청에 대한 처리 연쇄 실행
String url1 = "http://www.youtube.com:80";
System.out.println("INPUT: " + url1);
handler1.run(url1);
System.out.println();
String url2 = "https://www.inpa.tistory.com:443";
System.out.println("INPUT: " + url2);
handler1.run(url2);
System.out.println();
String url3 = "http://localhost:8080";
System.out.println("INPUT: " + url3);
handler1.run(url3);
}
}
중첩된 if-else 문 재구성하기
첫번째 패턴 예제는 중앙 집권화한 로직을 핸들러 객체로 분리하여 책임을 분산시키는 방법을 소개했다면, 두번째 패턴 예제는 중첩된 if-else 조건문을 책임 연쇄 패턴으로 유연하며 가독성 좋게 구성하는 사례를 들어볼 것이다.
클린하지 않은 문제의 코드 ❌
서버가 있고 사용자가 미들웨어를 통해 로그인 하는 과정을 자바의 클래스로 간단하게 표현해 보았다. 코드가 길어보여 어려워보이지만 간단하게 구성해본 예제이다. 서버는 각종 사용자 정보들을 Map 자료형태로 지니고 있고, 미들웨어는 각종 로그인 인증 로직들을 관리하며, 클라이언트에서 각종 미들웨어의 boolean 메서드를 통해 조건 분기문을 통해 로그인 과정을 처리한다.
class Server {
private Map<String, String> users = new HashMap<>();
// 서버에 유저 등록
public void register(String email, String password) {
users.put(email, password);
}
// 서버에 해당 이메일이 가입되어 있는지
public boolean hasEmail(String email) {
return users.containsKey(email);
}
// 서버에 해당 계정의 비밀번호가 일치하는지
public boolean isValidPassword(String email, String password) {
return users.get(email).equals(password);
}
}
class Middleware {
private int limit = 3;
private int count = 0;
Server server;
public Middleware(Server server) {
this.server = server;
}
// 로그인 횟수 시도
public boolean limitLoginAttempt() {
if (count > limit) {
System.out.println("로그인 요청 횟수 제한 !!");
return false;
}
count++;
return true;
}
// 아이디, 패스워드 인증
public boolean authorize(String email, String password) {
if (!server.hasEmail(email)) {
System.out.println("가입된 이메일 계정이 없습니다 !");
return false;
}
if (!server.isValidPassword(email, password)) {
System.out.println("패스워드가 다릅니다 !");
return false;
}
System.out.println("로그인 완료 !!");
return true;
}
// 사용자가 admin 계정인지, 혹은 일반 user 계정인지 검증
public boolean authentication(String email) {
if (email.equals("inpa@tistory.com")) {
System.out.println("Hello, admin!");
return true;
} else {
System.out.println("Hello, user!");
return false;
}
}
// 만일 관리자가 아닌 일반 유저일 경우 유저의 요청에 대해선 모두 로깅
public void logging() {
System.out.println("요청을 로깅합니다.");
}
}
class Client {
public static void main(String[] args) throws IOException {
// 1. 서버 생성 및 등록
Server server = new Server();
server.register("john@naver.com", "123123");
server.register("kail@google.com", "456456");
server.register("inpa@tistory.com", "789789");
// 2. 인증 로직을 처리하는 미들웨어 생성
Middleware middleware = new Middleware(server);
// 3. 클라이언트로부터 로그인 시도
do {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
System.out.print("\nEmail: ");
String email = reader.readLine();
System.out.print("Password: ");
String password = reader.readLine();
// 만일 로그인 횟수가 남아있을 경우
if (middleware.limitLoginAttempt()) {
// 이메일 패스워드 인증
if (middleware.authorize(email, password)) {
// 인증되었을 경우 일반 유저 계정인지 관리자 계정인지 검증
if (middleware.authentication(email)) {
// ...
} else {
// 관리자가 아닌 일반 유저 계정일 경우 로깅 처리
middleware.logging();
}
break;
} else {
continue;
}
} else {
throw new RuntimeException("로그인 시도 횟수 초과로 프로그램을 종료합니다");
}
} while (true);
}
}
동작은 문제없어 보이지만 딱봐도 클라이언트 쪽 실행 코드가 굉장히 복잡하다. 여러 인증 로직들을 하나하나 따져가며 만일 '참'일 경우 다음 인증 로직을 따져가는 중첩 if-else문 형태로 되어 있다.
이런 코드는 코드를 복기할때나 테스트할때 굉장히 난해하게 되며 특히나 if문 중첩은 단계가 늘어날 수록 중첩 블록이 분기에서 안정적으로 작동하는지 확신하기도 어렵다. 거기다 만일 새로운 인증 메서드가 추가된다면 분기문 전체를 뜯어 고쳐야 될 수 있다.
책임 연쇄 패턴을 적용한 코드 ✔️
물론 각 인증 로직들이 별개의 메서드로 분리되어 있지만, 결국 실행부에선 복잡한 if-else로 구성되는건 마찬가지이고 하나의 미들웨어 클래스에서 중앙 집권 적인 책임을 지니고 있기 때문에, 책임 연쇄 패턴으로 인증 로직들을 별개의 핸들러 클래스로 분리하고 체이닝 함으로써 if-else 형태를 보다 유연하게 구성할 수가 있게 된다.
이번 패턴 적용 예제는 첫번째 예제와는 약간 차이를 두었는데, 핸들러 메서드를 추상 메서드로 각 객체별로 구체화 하는 것이 아닌, 부모의 check() 메서드를 자식 핸들러에서 오버라이드(Override)하고, 만일 체인 호출이 필요할 경우 super 키워드를 통해 부모 메서드를 호출함으로써 다음 핸들러를 호출하는, 자바 클래스의 상속 특징을 이용하였다라고 볼 수 있다.
또한 요청에 대한 로직 및 결과를 모두 핸들러 클래스에게 위임 했으니, 복잡한 if-else 분기에 따른 로직을 처리하기 위해 핸들러에서 flag 라는 정수를 반환하도록 하여, 실행부에서 정수값에 따라 루프문 동작을 제어하는 식으로 구성하였다.
// Handler 역할
abstract class Middleware {
// 다음 체인으로 연결될 핸들러
protected Middleware nextMiddleware = null;
// setter 메서드를 통해 연결시킬 핸들러를 등록
public Middleware setNext(Middleware middleware) {
this.nextMiddleware = middleware;
return middleware; // 메서드 체이닝 구성을 위해 인자를 그대로 반환함
}
// 핸들러가 요청에 대해 처리하는 메서드
public short check(String email, String password) {
/*
flag는 클라이언트 실행부에서 while문을 빠져나가기 위한 조건값으로 사용된다
-2 : Exception 일으킴
-1 : break 동작 (루프문 종료)
0 : continue 동작 (처음부터 콘솔 입력 받기)
1 : 그대로 처리를 진행함
*/
short flag = 1;
// 만일 핸들러가 연결된게 있다면 다음 핸들러로 책임을 떠넘긴다
if (nextMiddleware != null) {
flag = nextMiddleware.check(email, password);
}
return flag;
}
}
// ConcreteHandler 역할
class LimitLoginAttemptMiddleware extends Middleware {
private int limit = 3; // 로그인 시도 최대 횟수 3회
private int count = 0;
@Override
public short check(String email, String password) {
short flag = 1;
if (count > limit) {
System.out.println("로그인 요청 횟수 제한 !!");
flag = -2; // Exception 플래그
} else {
flag = super.check(email, password);
}
count++;
return flag;
}
}
class AuthorizeMiddleware extends Middleware {
private Server server;
public AuthorizeMiddleware(Server server) {
this.server = server;
}
@Override
public short check(String email, String password) {
short flag = 1;
if (!server.hasEmail(email)) {
System.out.println("This email is not registered!");
flag = 0; // continue 플래그
} else if (!server.isValidPassword(email, password)) {
System.out.println("Wrong password!");
flag = 0; // continue 플래그
} else {
flag = super.check(email, password);
}
return flag;
}
}
class AuthenticationMiddleware extends Middleware {
@Override
public short check(String email, String password) {
short flag = 1;
if (email.equals("inpa@tistory.com")) {
System.out.println("Hello, admin!");
flag = -1; // break 플래그
} else {
System.out.println("Hello, user!");
flag = super.check(email, password);
}
return flag;
}
}
class LoggingMiddleware extends Middleware {
@Override
public short check(String email, String password) {
System.out.println("요청을 로깅합니다.");
return -1; // break 플래그
}
}
class Client {
public static void main(String[] args) throws IOException {
// 1. 서버 생성 및 등록
Server server = new Server();
server.register("john@naver.com", "123123");
server.register("kail@google.com", "456456");
server.register("inpa@tistory.com", "789789");
// 2. 인증 로직을 처리하는 핸들러 생성
LimitLoginAttemptMiddleware middleware1 = new LimitLoginAttemptMiddleware();
AuthorizeMiddleware middleware2 = new AuthorizeMiddleware(server);
AuthenticationMiddleware middleware3 = new AuthenticationMiddleware();
LoggingMiddleware middleware4 = new LoggingMiddleware();
// 3. 핸들러 체인
middleware1
.setNext(middleware2)
.setNext(middleware3)
.setNext(middleware4);
// 4. 클라이언트로부터 로그인 시도
do {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
System.out.print("\nEmail: ");
String email = reader.readLine();
System.out.print("Password: ");
String password = reader.readLine();
// 핸들러부터 정수 flag를 받아, 정수값에 따라 루프문 다음 동작을 처리
short result = middleware1.check(email, password);
if (result == -2) {
throw new RuntimeException("로그인 시도 횟수 초과로 프로그램을 종료합니다");
} else if (result == -1) {
break;
} else if (result == 0) {
continue;
}
} while (true);
}
}
오히려 기존 코드보다 디자인 패턴을 적용한 코드가 양도 많아지고 복잡성이 증가한 것처럼 느낄 수 있겠지만, 실행부를 좀더 한눈에 알 수 있게 심플하게 처리했다라는 점에서 의의를 가질 수 있다.
실무에서 찾아보는 Chain Of Responsibility 패턴
Java
- java.util.logging.Logger의 log()
- javax.servlet.Filter의 doFilter()
사용자가 요청시 서블릿에 접근하기 전에 다양한 필터를 거치도록 할 수 있다. 만약 아래와 같은 컨트롤러가 있고, "/hello"로 요청을 받으면 필터를 타도록한다.
@ServletComponentScan
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
}
@WebFilter(urlPatterns = "/hello") // 특정 url 서블릿에 접근할시 다음 필터를 적용
public class MyFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("게임 참여");
chain.doFilter(request, response);
System.out.println("게임 끝");
}
}
Spring Framework
스프링 프로젝트에 Spring Securiry 의존성을 추가하고 아래와 같이 Security 필터에 대한 체인을 설정을 할 수 있다.
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 체인 필터를 설정하는 코드
@Override
protected void configure(HttpSecurity http) throws Exception {
// 모든 페이지의 접근권한을 인증없이 허가
http.authorizeRequests().anyRequest().permitAll().and().addFilter(new MyFilter());
}
}
# 참고자료
코딩으로 학습하는 GoF의 디자인 패턴 - 백기선
https://refactoring.guru/design-patterns/chain-of-responsibility
GIS DEVELOPER 유튜브 - Chain of Responsibility
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.