Self-Dev/Design Patterns R&D

[25] 디자인 패턴 목록 - 행동 디자인 패턴 - 책임 연쇄 패턴(CoR, Chain of Responsiblity)

Khadra 2024. 7. 11. 18:02

출처 : 디자인 패턴에 뛰어들기 - 알렉산더 슈베츠 도서


책임 연쇄 패턴(CoR, Chain of Responsiblity)

핸들러들의 체인(사슬)을 따라 요청을 전달할 수 있게 해주는 행동 디자인 패턴이다.
각 핸들러는 요청을 받으면 요청을 처리할지 아니면 체인의 다음 핸들러로 전달할지를 결정한다.



문제

당신이 온라인 주문 시스템을 개발하고 있다고 가정해본다.

  • 당신은 인증된 사용자들만 주문을 생성할 수 있도록 시스템에 대한 접근을 제한하려고 한다.
  • 또 관리 권한이 있는 사용자들에게는 모든 주문에 대한 전체 접근 권한을 부여하려고 한다.
  • 당신은 약간의 설계 후에 이러한 검사들은 차례대로 수행해야 한다는 사실을 깨닫게 된다.
  • 당신의 앱은 사용자들의 자격 증명이 포함된 요청을 받을 때마다 시스템에 대해 사용자 인증을 시도할 수 있다.
  • 그러나 이러한 자격 증명이 올바르지 않아서 인증에 실패하면 다른 검사들을 진행할 이유가 없다.


요청은 주문 시스템 자체가 처리할 수 있기 전에 일련의 검사들을 통과해야 한다는 예시이다.


다음 몇 달 동안 당신은 이러한 순차 검사들을 몇 가지 더 구현한다.

  • 동료 중 한 명이 검증되지 않은 데이터를 주문 시스템에 직접 전달하는 것은 안전하지 않다고 제안한다.

    • 그래서 당신은 요청 내의 데이터를 정제(sanitize)하는 추가 유효성 검사 단계를 추가한다.
  • 나중에 누군가가 시스템이 무차별 대입 공격에 취약하다는 사실을 발견했으며, 이러한 공격을 방어하기 위해 같은 IP 주소에서 오는 반복적으로 실패한 요청을 걸러내는 검사를 즉시 추가한다.


  • 또 다른 누군가는 같은 데이터가 포함된 반복 요청에 대해 캐시된 결과를 반환하여 시스템 속도를 높일 수 있다고 제안했고, 당신은 적절한 캐시 응답이 없는 경우에만 요청이 시스템으로 전달되도록 하는 또 다른 검사를 추가한다.




코드가 커질수록 더 복잡해진다는 것을 알 수 있는 예시이다.


이미 엉망진창이었던 검사 코드는 당신이 새로운 기능을 추가할 때마다 더욱 크게 부풀어 오를 것이다. 하나의 검사 코드를 바꾸면 다른 검사 코드가 영향을 받기도 했을 것이다.

더 심각한 문제는, 시스템의 다른 컴포넌트들을 보호하기 위해 검사를 재사용하려고 할 때 해당 컴포넌트들에 일부 코드를 복제해야 했다는 것이다.
왜냐하면 컴포넌트들이 필요로 한 것은 검사의 일부였지, 모든 검사는 아니었기 때문이다.
해당 시스템은 이해하기가 매우 어려웠고 유지 관리 비용이 많이 들었으며, 당신은 프로그램 전체를 리팩토링하기로 할 때까지 한동안 코드와 씨름해야할 것으로 보여진다.



해결책

다른 여러 행동 디자인 패턴들과 마찬가지로 책임 연쇄 패턴은 특정 행동들을 핸들러라는 독립 실행형 객체들로 변환한다.

  • 자신이 만든 앱의 경우 각 검사는 검사를 수행하는 단일 메서드가 있는 자체 클래스로 추출되어야 한다.
  • 이제 요청은 데이터와 함께 이 메서드에 인수로 전달된다.

이 패턴은 이러한 핸들러들을 체인으로 연결하도록 제안한다.

  • 연결된 각 핸들러에는 체인의 다음 핸들러에 대한 참조를 저장하기 위한 필드가 있다.
  • 요청을 처리하는 것 외에도 핸들러들은 체인을 따라 요청을 더 멀리 전달한다.
  • 이 요청은 모든 핸들러가 요청을 처리할 기회를 가질 때까지 체인을 따라 이동한다.

가장 좋은 부분은
핸들러가 요청을 체인 아래로 더 이상 전달하지 않고, 추가 처리를 사실상 중지하는 결정을 내릴 수 있다는 것이다.



당신의 주문 관리 시스템에서는 하나의 핸들러가 주문 처리를 수행한 다음 요청을 체인 아래로 더 전달할지를 결정한다.
요청에 올바른 데이터가 포함되어 있다고 가정하면 모든 핸들러들은 인증 확인이든 캐싱이든 그들의 주 행동들을 실행할 수 있다.


핸들러들이 하나씩 줄지어 체인을 형성하는 예시이다.

한편, 약간 다른 조금 더 정식적인 접근 방법이 있다.
이 방식에서는 핸들러가 요청을 받으면 핸들러는 요청을 처리할 수 있는지를 판단한다.

처리가 가능한 경우

  • 핸들러는 이 요청을 더 이상 전달하지 않습니다. 따라서 요청을 처리하는 핸들러는 하나뿐이거나 아무 핸들러도 요청을 처리하지 않는다.
  • 이 접근 방식은 그래픽 사용자 인터페이스 내에서 요소들의 스택에서 이벤트들을 처리할 때 매우 일반적이다.
  • 예를 들어,
    • 사용자가 버튼을 클릭하면 결과 이벤트는 그래픽 사용자 인터페이스 요소 체인을 통해 전파된다.
    • 이 체인은 버튼으로 시작하여 해당 컨테이너들(예: 양식 또는 패널)을 따라 이동한 후 메인 애플리케이션 창으로 끝난다.
    • 또 이 이벤트는 그를 처리할 수 있는 체인의 첫 번째 요소에 의해 처리된다.
    • 즉, 해당 예는 체인이 항상 객체 트리에서 추출될 수 있음을 보여주기 때문이다.



체인은 객체 트리의 가지에서부터 형성될 수 있는 예시이다.

모든 핸들러 클래스들이 같은 인터페이스를 구현하는 것은 매우 중요하다.

  • 각 구상 핸들러는 execute 메서드가 있는 다음 핸들러에만 신경을 써야 한다.
  • 다양한 핸들러들을 사용하여 코드를 핸들러들의 구상 클래스들에 결합하지 않고도 런타임에 체인들을 구성할 수 있다.


실제상황 적용


기술 지원 부서로의 전화는 여러 교환원을 거쳐 이루어질 수 있다.

위 예시로 상황 설명은 해본다.

  • 당신은 당신의 컴퓨터에 새 하드웨어를 구매하여 설치했다.

  • 당신은 컴퓨터 괴짜이기 때문에 당신의 컴퓨터에는 여러 운영 체제가 설치되어 있다.

  • 당신은 이 하드웨어가 지원되는지 확인하기 위해 모든 운영 체제들의 부팅을 시도한다.

  • 윈도우는 하드웨어를 자동으로 감지하고 활성화한다.

  • 그러나 당신이 애지중지하는 리눅스 운영 체제는 새 하드웨어와 작업하는 것을 거부한다.

  • 당신은 약간의 희망을 품고 상자에 적힌 기술 지원 전화번호로 전화하기로 한다.


  • 가장 먼저 들리는 것은 자동 응답기의 로봇 음성이다.

    • 이 음성은 다양한 문제에 대한 9가지 인기 있는 솔루션을 제안하지만, 그 중 어느 것도 당신의 문제와 관련이 없다.
      잠시 후 로봇이 당신을 실제 교환원에게 연결하게 된다.
    • 그러나 이 교환원도 별로 도움이 될만한 제안을 하지 않는다.
    • 그는 당신의 문제를 경청하지 않은 채 사용자 설명서에서 발췌한 긴 문장을 계속 인용한다.
    • '컴퓨터를 껐다가 다시 켜 보셨습니까?' 같은 별 쓸모없는 문구를 10번 이상 들은 후, 당신은 적절한 엔지니어와 연결해 줄 것을 요구한다.

결국 드디어 교환원은 어둡고 외로운 지하 서버실에서 인간적인 접촉을 갈망했던 엔지니어 중 한 명에게 전화를 연결한다.
이 엔지니어는 새 하드웨어에 적합한 드라이브를 어디에서 다운받아야 하는지와 리눅스에 설치하는 방법 등을 알려준다.

드디어 문제가 해결됨으로써 당신은 기쁜 마음으로 통화를 종료한다.



구조



  • 1. 핸들러는 모든 구상 핸들러에 공통적인 인터페이스를 선언한다.

    • 일반적으로 여기에는 요청을 처리하기 위한 단일 메서드만 포함되지만 때로는 체인의 다음 핸들러를 세팅하기 위한 다른 메서드가 있을 수도 있다.
  • 2. 기초 핸들러는 선택적 클래스이며 여기에 모든 핸들러 클래스들에 공통적인 상용구 코드를 넣을 수 있다.

    • 일반적으로 이 클래스는 다음 핸들러에 대한 참조를 저장하기 위한 필드를 정의한다.
    • 클라이언트들은 핸들러를 이전 핸들러의 생성자 또는 세터(setter)에 해당 핸들러를 전달하여 체인을 구축할 수 있다.
    • 또 클래스는 디폴트 핸들러 행동을 구현할 수도 있다.
    • 즉, 다음 핸들러의 존재 여부를 확인한 후 다음 핸들러로 실행을 넘길 수 있다.
  • 3. 구상 핸들러들에는 요청을 처리하기 위한 실제 코드가 포함되어 있다.

    • 각 핸들러는 요청을 받으면 이 요청을 처리할지와 함께 체인을 따라 전달할지를 결정해야 한다.
    • 핸들러들은 일반적으로 자체 포함형이고 불변하며, 생성자를 통해 필요한 모든 데이터를 한 번만 받는다.
  • 4. 클라이언트는 앱의 논리에 따라 체인들을 한 번만 구성하거나 동적으로 구성할 수 있다.

    • 참고로 요청은 체인의 모든 핸들러에 보낼 수 있으며, 꼭 첫 번째 핸들러일 필요는 없습니다.


의사코드

이 예에서 책임 연쇄 패턴은 활성 그래픽 사용자 인터페이스 요소에 대한 상황별 도움말 정보를 표시하는 역할을 한다.


그래픽 사용자 인터페이스 클래스들은 복합체 패턴으로 빌드되고, 각 요소는 그 요소의 컨테이너 요소에 연결된다.
또 언제든지 요소 자체에서 시작하여 그 요소의 모든 컨테이너 요소를 통과하는 요소 체인을 구축할 수 있는 예시이다.

앱의 그래픽 사용자 인터페이스의 구조는 일반적으로 객체 트리로 구성된다.

  • 예를 들어
    • 앱의 기본 창을 렌더링하는 Dialog(대화 상자) 클래스는 객체 트리의 뿌리(root)가 된다.
    • Dialog에는 Panels(패널들)가 포함되어 있다.
    • 여기에는 다른 패널들이나 Buttons(버튼들) 및 TextFields(문자 필드들)와 같은 단순한 하위 설계 요소들이 포함될 수 있다.
    • 간단한 컴포넌트는 컴포넌트에 어떤 도움말 텍스트가 할당되어 있는 한 상황에 맞는 짧은 도구 도움말들을 표시할 수 있다.
    • 그러나 더 복잡한 컴포넌트들은 (예를 들어 설명서에서 발췌한 내용을 표시하거나 브라우저에서 웹페이지를 여는 것과 같은 상황에 맞는 도움말을 표시하는 컴포넌트들) 상황별 도움말을 나타내기 위한 그들의 고유한 방법들을 정의할 수 있다.



도움말 요청이 그래픽 사용자 인터페이스 객체들을 가로질러 이동하는 방법의 예시이다.

사용자가 요소에 마우스 커서를 놓고 F1 키를 누르면 앱은 포인터 아래에 있는 컴포넌트를 감지하고 그에게 도움 요청을 보낸다. 이 요청은 도움말 정보를 표시할 수 있는 요소에 도달할 때까지 모든 요소의 컨테이너를 통과하며 올라간다.



// 핸들러 인터페이스는 요청을 실행하기 위한 메서드를 선언합니다.
interface ComponentWithContextualHelp is
  method showHelp()


// 간단한 컴포넌트들의 기초 클래스.
abstract class Component implements ComponentWithContextualHelp is
  field tooltipText: string

  // 컴포넌트의 컨테이너는 핸들러 체인의 다음 링크 역할을 합니다.
  protected field container: Container

  // 컴포넌트는 도움말 텍스트가 할당되었을 때 도구 설명을 표시합니다. 
  // 그렇지 않으면 컨테이너가 있는 경우 호출을 해당 컨테이너로 전달합니다.
  method showHelp() is
    if (tooltipText != null)
      // 도구 설명 표시하기.
    else
      container.showHelp()


// 컨테이너는 간단한 컴포넌트들과 다른 컨테이너들을 자식으로 포함할 수 있습니다.
// 여기에서 체인 관계들이 설립됩니다. 이 클래스는 부모로부터 showHelp 행동을 상속합니다.
abstract class Container extends Component is
  protected field children: array of Component

  method add(child) is
    children.add(child)
    child.container = this


// 원시적인 컴포넌트들은 디폴트 도움말 구현으로 괜찮을 수 있습니다.
class Button extends Component is
  // …

// 그러나 복잡한 컴포넌트들은 기초 구현을 오버라이드할 수 있습니다. 
// 도움말 텍스트를 새로운 방식으로 제공할 수 없는 경우 
// 컴포넌트는 언제든지 기초 구현을 호출할 수 있습니다. (컴포넌트 클래스 참조).
class Panel extends Container is
  field modalHelpText: string

  method showHelp() is
    if (modalHelpText != null)
      // 도움말 텍스트와 함께 모달 창을 표시합니다.
    else
      super.showHelp()

// …위와 같음…
class Dialog extends Container is
  field wikiPageURL: string

  method showHelp() is
    if (wikiPageURL != null)
      // 위키 도움말 페이지를 엽니다.
    else
      super.showHelp()


// 클라이언트 코드
class Application is
  // 모든 앱은 체인을 다르게 설정합니다.
  method createUI() is
    dialog = new Dialog("Budget Reports")
    dialog.wikiPageURL = "http://..."
    panel = new Panel(0, 0, 400, 800)
    panel.modalHelpText = "This panel does..."
    ok = new Button(250, 760, 50, 20, "OK")
    ok.tooltipText = "This is an OK button that..."
    cancel = new Button(320, 760, 50, 20, "Cancel")
    // …
    panel.add(ok)
    panel.add(cancel)
    dialog.add(panel)

  // 여기에서 무슨 일이 일어날지 상상해 보세요.
  method onF1KeyPress() is
    component = this.getComponentAtMouseCoords()
    component.showHelp()


적용

책임 연쇄 패턴은 당신의 프로그램이 다양한 방식으로 다양한 종류의 요청들을 처리할 것으로 예상되지만 정확한 요청 유형들과 순서들을 미리 알 수 없는 경우에 사용하세요.

  • 이 패턴은 당신이 여러 핸들러를 하나의 체인으로 연결할 수 있도록 해준다.
  • 또 요청을 받으면 바로 각 핸들러에게 이 요청을 처리할 수 있는지 질문한다.
  • 이렇게 해야 모든 핸들러들은 요청을 처리할 기회를 얻는다.

이 패턴은 특정 순서로 여러 핸들러를 실행해야 할 때 사용하세요.

  • 당신은 체인의 핸들러들을 원하는 순서로 연결할 수 있으므로 모든 요청은 정확히 당신이 계획한 대로 체인을 통과한다.

책임 연쇄 패턴은 핸들러들의 집합과 그들의 순서가 런타임에 변경되어야 할 때 사용하세요.

  • 당신이 핸들러 클래스들 내부의 참조 필드에 세터들을 제공하면, 핸들러들을 동적으로 삽입, 제거 또는 재정렬할 수 있을 것이다.


구현방법

  • 1. 핸들러 인터페이스를 선언하고 요청을 처리하는 메서드의 시그니처를 설명하세요.

    • 클라이언트가 요청 데이터를 메서드에 전달하는 방법을 결정해야 한다.
    • 가장 유연한 방법은 요청을 객체로 변환하여 처리 메서드에 인수로 전달하는 것이다.
  • 2. 구상 핸들러들에서 중복된 상용구 코드를 제거하려면 핸들러 인터페이스에서 파생된 추상 기초 핸들러 클래스를 만드는 것도 고려해볼 만하다.

    • 이 클래스에는 체인의 다음 핸들러에 대한 참조를 저장하기 위한 필드가 있어야 한다.
    • 이 클래스를 불변으로 만드는 것을 고려해야 한다.
    • 그러나 런타임에 체인들을 수정할 계획이라면 참조 필드의 값을 변경하기 위한 세터(setter)를 정의해야 한다.
    • 또 처리(핸들링) 메서드를 위한 편리한 디폴트(기본값) 행동을 구현할 수 있다.
      • 이 행동은 남아있는 객체가 없을 때까지 요청을 다음 객체로 넘기는 것이다.
      • 구상 핸들러들은 부모 메서드를 호출하여 이 행동을 사용할 수 있다.
  • 3. 하나씩 구상 핸들러 자식 클래스들을 만들고 그들의 처리 메서드들을 구현하세요.

    • 각 핸들러는 요청을 받았을 때 두 가지 결정을 내려야 한다.
      • 요청을 처리할지의 여부.
      • 체인을 따라 요청을 전달할지의 여부.
  • 4. 클라이언트는 자체적으로 체인을 조립하거나 다른 객체들에서부터 미리 구축된 체인을 받을 수 있다.

    • 후자의 경우 설정 또는 환경 설정에 따라 체인들을 구축하기 위해 일부 공장 클래스들을 구현해야 한다.
  • 5. 클라이언트는 첫 번째 핸들러뿐만 아니라 체인의 모든 핸들러를 활성화할 수 있다.

    • 요청은 어떤 핸들러가 더 이상의 전달을 거부하거나 요청이 체인 끝에 도달할 때까지 체인을 따라 전달된다.
  • 6. 체인의 동적 특성으로 인해 클라이언트는 다음 상황들을 처리할 준비가 되어 있어야 한다.

    • 체인은 단일 링크로 구성될 수 있습니다.
    • 일부 요청들은 체인 끝에 도달하지 못할 수 있습니다.
    • 다른 요청들은 처리되지 않은 상태로 체인의 끝에 도달할 수 있습니다.


장단점

장점

  • 요청의 처리 순서를 제어할 수 있다.
  • 단일 책임 원칙.
    • 작업을 호출하는 클래스들을 작업을 수행하는 클래스들과 분리할 수 있다.
  • 개방/폐쇄 원칙.
    *기존 클라이언트 코드를 손상하지 않고 앱에 새 핸들러들을 도입할 수 있다.

단점

  • 일부 요청들은 처리되지 않을 수 있다.


다른 패턴과의 관계

커맨드, 중재자, 옵서버 및 책임 연쇄 패턴은 요청의 발신자와 수신자를 연결하는 다양한 방법을 다루고 있다.

  • 책임 연쇄 패턴은 잠재적 수신자의 동적 체인을 따라 수신자 중 하나에 의해 요청이 처리될 때까지 요청을 순차적으로 전달한다.
  • 커맨드 패턴은 발신자와 수신자 간의 단방향 연결을 설립한다.
  • 중재자 패턴은 발신자와 수신자 간의 직접 연결을 제거하여 그들이 중재자 객체를 통해 간접적으로 통신하도록 강제한다.
  • 옵서버 패턴은 수신자들이 요청들의 수신을 동적으로 구독 및 구독 취소할 수 있도록 한다.

책임 연쇄 패턴은 종종 복합체 패턴과 함께 사용된다.

  • 잎 컴포넌트가 요청을 받으면 해당 요청을 모든 부모 컴포넌트들의 체인을 통해 객체 트리의 뿌리(root)까지 전달할 수 있다.

책임 연쇄 패턴의 핸들러들은 커맨드로 구현할 수 있다.

  • 많은 다양한 작업을 같은 콘텍스트 객체에 대해 실행할 수 있다.
    • 해당 콘텍스트 객체는 요청의 역할을 합니다. 여기에서의 요청은 처리 메서드의 매개변수를 의미한다.
    • 그러나 요청 자체가 커맨드 객체인 다른 접근 방식이 있다.
    • 이 접근 방식을 사용하면 당신은 같은 작업을 체인에 연결된 일련의 서로 다른 콘텍스트들에서 실행할 수 있다.

책임 연쇄 패턴과 데코레이터는 클래스 구조가 매우 유사하다.

  • 두 패턴 모두 실행을 일련의 객체들을 통해 전달할 때 재귀적인 합성에 의존하나, 몇 가지 결정적인 차이점이 있다.

    • 책임 연쇄 패턴 핸들러
      *서로 독립적으로 임의의 작업을 실행할 수 있다.

      • 또한 해당 요청을 언제든지 더 이상 전달하지 않을 수 있다.
    • 다양한 데코레이터

      • 객체의 행동을 확장한다.
      • 동시에 이러한 행동을 기초 인터페이스와 일관되게 유지할 수 있다.
      • 또한 데코레이터들은 요청의 흐름을 중단할 수 없다.