본문 바로가기

study/design pattern

[디자인패턴][행위패턴] 책임 사슬 Chain of Responsibility - C++

728x90

[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.


Chain of Responsibility

어떤 시스템을 구성하고 있는 여러 개의 서로 다른 컴포넌트들이 어떤 메시지를 역할에 따라 주고받으며 처리할 수 있다. 개념적으로는 구현하기 쉬워 보인다. 어떤 처리를 수행할 담당 컴포넌트의 목록만 있으면 된다.


시나리오

컴퓨터 게임에서 크리처들이 있다고 하자. 공격력과 방어력 두 가지 값을 속성으로 갖는다고 하자.

struct Creature {
    // 생성자와 << 연산자 구현
    
    std::string name;
    int attack;
    int defense;
}

 

게임이 진행되면서 크리처는 처리되어야 하는 존재다. 아이템을 습득할 수도 있고 마법에 당할 수도 있다. 어떤 경우든 공격력 또는 방어력 값은 이벤트에 맞춰 변경되어야 한다. 이를 위해서 CreatureModifier를 호출한다고 하자. 한 번에 여러 개의 이벤트가 발생하여 CreatureModifier가 여러 번 호출될 수도 있다. 따라서 변경 작업을 크리처별로 쌓아 놓고 순서대로 속성이 변할 수 있도록 해야 한다.


포인터 사슬

아래의 CreatureModifier 구현은 책임 사슬의 전통적인 구현 방법을 보여준다.

class CreatureModifier {
public:
    explicit CreatureModifier(Creature &creature) : creature(creature) {}
    
    void add(CreatureModifier *cm) {
        if ( nullptr != next ) {
            next->add(cm);
        }
        else {
            next = cm;
        }
    }
    
    virtual void handle() {
        if ( nullptr != next ) {
            next->handle(); //< 핵심 부분
        }
    }
    
protected:
    Creature &creature; //< 참조 대신 포인터나 shared_ptr를 사용할 수도 있음
    
private:
    CreatureModifier next(nullptr);
};

 

위 코드에 대해 간단히 설명해보자.

  • Creature 참조를 넘겨받아 저장하고 변경할 준비를 한다.
  • 실질적으로 하는 일이 많지는 않지만 추상 클래스는 아니다.
  • 멤버 변수 next는 부가적으로 현재의 변경 작업에 뒤따르는 또 다른 CreatureModifier를 가리킨다. 실 구현체는 CreatureModifier를 상속받는 무엇이든 가능하다.
  • add() 멤버 함수는 다른 크리처에 대한 변경 작업을 현재의 변경 작업 사슬에 연결한다. 재귀적으로 일어나며, 현재 사슬이 next로 nullptr를 가리키고 있으면 인자로 주어진 변경 작업을 그대로 설정하고 재귀 호출에는 진입하지 않는다.
  • handle() 멤버 함수는 사슬의 다음 항목을 처리한다. 이 함수는 그 자체로는 아무런 작업을 수행하지 않는다.

 

이 구현은 딱히 특별할 게 없다. 하지만 이 클래스를 상속받아 실질적인 작업들이 추가되면 이 구현의 의미가 더 명확해진다.

 

예를 들어 크리처의 공격력을 두 배로 키우는 변경 작업이 다음과 같이 정의될 수 있다.

class DoubleAttackModifier : public CreatureModifier {
public:
    explicit DoubleAttackModifier(Creature &creature) : CreatureModifier(creature) {}
    
    void handle() override {
        creature.attack *= 2;
        CreatureModifier::handle();
    }
};

 

이제 무언가 의미 있는 것을 하는 것 같다. 이 클래스는 CreatureModifier를 상속받아 handle() 메서드 안에서 두 가지 작업을 한다. 하나는 공격력을 두 배로 키우는 것이고 다른 하나는 부모 클래스의 handle() 메서드를 호출하는 것이다.

부모의 handle()을 호출하는 부분이 매우 중요하다. 변경 작업의 사슬이 연이어질 수 있으려면 중간의 어느 클래스에서도 handle()의 구현부 마지막에서 부모의 handle()을 호출하는 것을 빠뜨리지 않아야 한다.

 

다음 예는 공격력이 2 이하인 크리처의 방어력을 1 증가시키는 변경 작업을 수행한다.

class IncreateDefenseModifer : public CreatureModifier {
public:
    explicit IncreaseDefenseModifier(Creature &creature) : CreatureModifier(creature) {}
    
    void handle() ovierride {
        if ( creature.attack <= 2 ) {
            creature.defense += 1;
        }
        
        CreatureModifier::handle();
    }
};

위의 예시에도 마지막에 부모를 호출하고 있다. 이러한 변경 작업의 정의를 활용해 다음과 같이 복합적인 변경 작업을 크리처에 적용할 수 있다.

Creature goblin("Goblin", 1, 1);
CreatureModifier root(goblin);
DoubleAttackModifier r1(goblin);
DoubleAttackModifier r1_2(goblin);
IncreaseDfenseModifier r2(goblin);

root.add(&r2);
root.add(&r1_2);
root.add(&r2);

root.handle();

std::cout << goblin<< std::endl;
// 출력 결과 "name: Goblin attack : 4 defense: 1"

 

또 다른 흥미로운 예를 살펴보자. 크리처에 마법을 걸어 어떤 보너스도 받을 수 ㅇ벗게 만드는 변경 작업을 적용하고 싶다고 하자. 언뜻 생각하기에는 쉽게 구현이 어려울 것 같지만, 쉽게 할 수 있다. 단순히 부모의 handle()을 호출하지 않기만 하면 되기 때문이다. 그렇게 하면 전체 책임 사슬의 호출이 생략된다.

class NoBonusesModifer : public CreatureModifier {
public:
    explicit NoBonusesModifier(Creature &creature) : CreatureModifier(creature) {}
    
    void handle() override {
        // nothing
    }
}

브로커 사슬

포인터를 이용한 사슬의 예는 매우 인위적이었다. 실제 게임에서는 크리처가 임의로 보너스를 얻거나 잃을 수 있어야 한다. 이어 붙이는 것만 가능한 연결 리스트로는 임의의 변경 작업을 지원할 수 없다. 게다가 일반적인 게임이라면 크리처의 상태를 영구적으로 변경하는 것이 아닌, 원본은 남겨두고 임시로 변경 작업이 적용되게 할 것이다.

 

책임 사슬을 구현하는 또 다른 방법으로 중앙 집중화된 컴포넌트를 두는 것이 있다. 이 컴포넌트는 발생할 수 있는 모든 변경 작업 목록을 관리하고, 특정 크리처의 상태를 변경 작업 이력이 모두 반영된 상태로 구할 수 있게 한다.

이러한 컴포넌트를 이벤트 브로커라고 부른다. 이 컴포넌트는 참여하는 모든 컴포넌트를 연결하는 역할을 하기 때문이다. 이것은 매개자(Meditator) 패턴이기도 하고, 모든 이벤트를 모니터링한 결과를 조회할 수 있게 하기 때문에 관찰자(Observer) 패턴이기도 하다.

 

이벤트 브로커를 하나 만들어보고, 앞의 예와 같이 게임을 예로 들어보도록 하자. 먼저, 게임의 실행에 대한 모든 것을 담는 Game 클래스를 하나 만들자.

struct Game { //< 매개자
    signal<void<Query&)> queries;
};

상태 조회 명령을 전송하기 위해 Boost.Signals2 라이브러리를 사용한다. 모르는 라이브러리로, 책의 내용을 온전히 따라야겠다. 이 라이브러리는 어떤 신호를 발생시키고 그 신호를 기다리고 있는 모든 수신처가 신호를 처리할 수 있게 한다.

 

어떤 크리처의 상태를 임의의 시점에 조회할 수 있게 하고 싶다고 하자. 단순히 크리처의 필드를 읽을 수도 있지만 문제가 있다. 크리처에 가해진 변경 작업이 모두 완료되어 결과값이 확정된 이후에 읽어야 하기 때문이다. 따라서, 조회 작업을 별도의 객체에 캡슐화하여 처리하기로 한다. (커맨드(Command) 패턴이다.) 이 객체는 다음과 같이 정의된다.

struct Query {
    std::string creature_name;
    
    enum Argument {
        attack,
        defense
    } argument;
    
    int result;
};

어떤 크리처의 특정 상태 값에 대한 조회라는 개념을 캡슐화하고 있다. 이 클래스를 사용하기 위해 필요한 것은 크리처의 이름과 조회할 상태 값의 종류이다. Game::queries에서 변경 작업들을 적용한 최종 결과값을 받기 위해, Query 객체에 대한 참조를 사용한다.

 

이제 Creature의 정의를 살펴보자. 앞서 보았던 정의와 거의 같지만, Game의 참조가 추가되었다.

class Creature {
public:
    Creature(Game &game, ...) : game(game), ... {
        ...
    }
    
public:
    std::string name;
    // 다른 멤버들..
    
private:
    Game &game;
    int attack;
    int defense;
};

 

attack, defense 변수가 private으로 선언되었다. 이 값들은 변경 작업들이 반영된 최종 박싱 별도의 get 멤버 함수로 얻어져야 함을 의미한다.

int Creature::get_attack() const {
    Query q(name, Query::Argument::attack, attack);
    game.queries(q);
    return q.result;
}

 

여기서 마법 같은 일이 일어난다. 단순히 값을 리턴하거나 포인터 기반의 정적 책임 사슬을 이용하는 대신, 목적하는 인자로 Query 객체를 만든 다음 Game::queries에 넘기면 각각 조회 객체를 검사하여 처리 가능한 경우 결과를 채워준다.

 

이제 변경 작업을 구현해보자. 이번에도 베이스 클래스를 만들지만, handle() 메서드가 없다.

class CreatureModifier {
public:
    CreatureModifier(Game &game, Creature &creature) : game(game), creature(creature) {}
    
priavte:
    Game &game;
    Creature &creature;
}

 

변경 작업의 베이스 클래스는 특이한 부분이 없다. 사실, 전혀 사용하지 않을 수도 있다. 이 클래스가 하는 일은 생성자가 올바른 인자로 호출되는 것을 보증하는 역할뿐이다. 하지만 앞서 베이스 클래스를 이용하는 방식을 사용했으므로 관례를 따르기로 한다. CreatureModifier를 상속받아 변경 작업 클래스가 실제로 어떻게 구현될 수 있는지 보자.

class DoubleAttackModifier : public CreatureModifier {
public:
    DoubleAttackModifier(Game &game, Creature &creature) : CreatureModifier(game, creature) {
        conn = game.queries.connect([&](Query &q){
            if ( q.creature_name == creature.name && q.argument == Query::Argument::attack) {
                q.result *= 2;
            }
        });
    }
    
    ~DoubleAttackModifier() {
        conn.disconnect();
    }
    
private:
    connection conn;
}

 

생성자와 소멸자에서 주요 작업들이 수행되고, 추가적인 메서드는 필요하지 않다. 생성자에서 Game에 대한 참조를 통해 Game::queries에 접근하며, 공격력을 두 배로 증가시키는 람다 함수를 조회 이벤트에 연결한다. 이 람다 함수는 ㅁ너저 몇 가지 확인 작업을 해야 한다. 인자로 주어진 작업을 적용할 수 있는 크리처인지, 조회 이벤트가 공격력에 대한 것이 ㅁ자는지. 이 두 가지 확인에 대한 정보는 모두 Query의 참조에 들어 있다. 그리고 변경할 초기값도 있다.

 

객체 소멸 시 이벤트 연결을 해제할 수 있도록 연결 정보도 저장해야 한다. 변경 작업이 임시적으로 적용되고 유효한 조건을 벗어났을 때 더 이상 적용되지 않게 할 수 있다.

Game game;
Creature goblin(game, "StrongGoblin", 2, 2);
std::cout << goblin << std::endl;
// 이름: Strong Goblin 공격력: 2 방어력: 2

{
    DoubleAttackModifier dam(game, goblin);
    std::cout << goblin << std::endl;
    // 이름: Strong Goblin 공격력: 4 방어력: 2
}

std::cout << goblin<< std::endl;
// 이름: Strong Goblin 공격력: 2 방어력: 2

 

범위 밖에서는 고블린의 공격력과 방어력이 원래대로 돌아간다.


요약

책임 사슬은 컴포넌트들이 어떤 명령이나 조회 작업을 차례대로 처리할 수 있게 하는 매우 단순한 디자인 패턴이다. 책임 사슬의 가장 단순한 구현은 포인터 사슬이며, 이론적으로 vector나 list로 대체할 수 있다.

좀 더 복잡한 브로커 사슬의 구현은 매개자 패턴과 관찰자 패턴을 활용한다. 이를 통해 조회 이벤트에 수신처로 등록된 변경 작업들이 최종 값을 클라이언트에 반환하며, 반환 전에 전달된 원본 객체를 상황에 맞게 수정할 수 있다.

728x90