본문 바로가기

study/design pattern

[디자인패턴][행위패턴] 커맨드 Command - C++

728x90

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


Command Pattern

커맨드 패턴은 어떤 객체를 활용할 때 직접 그 객체의 API를 호출해 조작하는 대신, 작업을 어떻게 하라고 명령을 보내는 방식을 제안한다. 여기서 명령은 무엇을 어떻게 하라는 지시가 담긴 클래스 그 이상도 이하도 아니다.


시나리오

은행의 마이너스 통장을 생각해 보자. 마이너스 통장은 잔고와 인출 한도가 있다. 입금과 출금 동작을 수행해야 한다. 이때 모든 입출금 내역을 기록해야 한다고 하자. 또한, 입출금 클래스는 이미 기존에 만들어져 검증되었고 동작 중이어서 수정할 수 없는 상황이다.

struct BankAccount {
    int balance = 0;
    int overdraft_limit = -5000;
    
    void deposit(int amount) {
        balance += amount;
        std::cout << "deposited " << amount << ", balance is now " << balance << "\n";
    }
    
    void withdraw(int amount) {
        if ( balance - amount >= overdraft_limit ) {
            balance -= amount;
            std::cout << "withdrew " << amount << ", balance is now " << balance << "\n";
        }
    }
};

커맨드 패턴의 구현

먼저 커맨드 인터페이스를 정의하자. 인터페이스를 이용해 은행 계좌를 대상으로 한 작업 정보를 캡슐화하는 커맨드 객체를 만들 수 있다.

struct Command {
    virtual void call() const = 0;
};

커맨드에는 다음과 같은 정보들이 포함된다.

  • 작업이 적용될 계좌
  • 실행될 작업 종류
  • 입금 또는 출금의 금액
struct BankAccountCommand : Command {
    BankAccount &account;
    enum Action {
        deposit,
        withdraw,
    } action;
    int amount;
    
    BankAccountCommand(BankAccount &account, const Action action, const int amount) : 
        account(account), action(action), amount(amount) {
    }
};

클라이언트는 이런 정보를 제공하면서 커맨드를 입금 또는 출금 작업을 실행한다.

void call() const override {
    swicth ( action ) {
    case deposit:
        account.deposit(amount);
        break;
        
    case withdraw:
        account.withdraw(amount);
        break;
    }
}

되돌리기(Undo) 작업

커맨드는 계좌에 일어난 작업드렝 대한 정보를 담고 있다. 어떤 작업에 의한 변경 내용을 되돌려서 그 작업이 행해지기 이전 상태로 되돌릴 수 있다.

되돌리기 작업은 커맨드 인터페이스의 추가 기능으로 넣을지 아니면 또 하나의 커맨드로 처리할지 결정해야 한다. 여기서는 편의상 커맨드 인터페이스의 추가 기능으로 구현한다. 디자인 차원에서 의사 결정으로, 인터페이스 분리 원칙을 따르는 것이 바람직하다. 예를 들어 커맨드 중에 비가역적으로 적용되는 것들이 있다면 되돌리기 인터페이스가 클라이언트에 혼란을 준다. 이런 경우는 되돌리기 가능한 커맨드와 일반 커맨드를 분리하는 것이 좋다.

아래의 되돌리기가 추가된 Command 인터페이스를 보자. 멤버 함수의 const 속성이 의도적으로 삭제되었다.

struct Command {
    virtual void call() = 0;
    virtual void undo() = 0;
}

아래의 undo() 구현을 보면, 출금에 실패하더라도 작업을 되돌리기 할 때는 실패한 작업이라는 사실이 확인되지 않는다. 따라서, 출금 작업의 성공 여부를 리턴하도록 withdraw()를 수정해야 한다.

void undo() override {
    switch ( action ) {
    case withrdraw:
        account.deposit(amount);
        break;
        
    case deposit:
        account.withdraw(amount);
        break;
    }
}

bool withdraw(int amount) {
    if ( balance - amount >= override_limit ) {
        balance -= amount;
        std::cout << "withdrew " << amount << ", balance now " << balance << "\n";
        return true;
    }
    return false;
}

최종적으로 BankAccountCommand 전체는 다음과 같이 수정된다.

struct BankAccountCommand : Command {
    ...
    bool withdrawal_succeeded;
    
    BankAccountCommand(BankAccount &account, const Action action, const int amount) :
        ... , withdrawal_succeeded(false) {
    }
    
    void call() override {
        switch ( action ) {
        ...
        case withdraw:
            withdrawal_succeeded = account.withdraw(amount);
            break;
        }
    }
    
    void undo() override {
        switch ( action ) {
        case withdraw:
            if ( withdrawal_succeeded ) {
                account.deposit(amount);
            }
            break;
        ...
        }
    }
}

이 예제는 커맨드 작업 관련 정보 뿐만 아니라 임시 정보 저장도 충분히 가능하다는 것을 보여준다. 이런 방식으로 금융 거래 이력에 대한 감사도 가능하다.


컴포지트 커맨드

이체는 다음 두 커맨드로 수행될 수 있다.

  1. 계좌 A에서 $X만큼 출금
  2. 계좌 B에 $X만큼 입금

두 커맨드를 각각 호출하는 대신 하나의 "계좌 이체" 커맨드로 감싸서 처리하면 편할 것이다. 이는 컴포지트 패턴에서 지향하는 것과 동일하다. 컴포지트 커맨드의 골격을 만들자. std::vector<BankAccountCommand>를 상속받을 것이다. std::vector는 버추얼 소멸자가 없어 일반적으로 바람직하지 않다.

struct CompositeBankAccountCommand : std::vector<BankAccountCommand>, Command {
    CompositeBankAccountCommand(const initializer_list<value_type> &items) : 
        std::vector<BankAccountCommand>(items) {
    }
    
    void call() override {
        for ( auto &cmd : *this ) {
            cmd.call();
        }
    }
    
    void undo() override {
        for ( auto it = rbegin(); it != rend(); ++it ) {
            it->undo();
        }
    }
};

CompositeBankAccountCommand는 vector이면서 Command다. 즉, 컴포지트 디자인 패턴을 따르고 있다. 생성자는 편리한 initializer_list를 이용해 인자를 받고 있고, undo(), call() 두 작업을 구현했다. undo()는 커맨드의 역순으로 수행된다는 것을 확인하자.

이체작업은 아래와 같이 만들 수 있따. 베이스 클래스의 생성자를 재사용해 두 커맨드로 계좌 이체 커맨드 객체를 초기화한다. 베이스 클래스의 call() / undo() 구현도 재사용한다.

struct MoneyTransferCommand : CompositeBankAccountCommand {
    MoneyTransferCommand(BankAccount &from, BankAccount &to, int ammount) : CompositeBankAccountCommand {
            BankAccountCommand(from, BankAccountCommand::withdraw, amount),
            BankAccountCommand(to, BankAccountCommand::deposit, amount)
        } {
    }
};

여기서도 앞서 나왔던 문제인 출금 실패 케이스가 고려되어야 한다. 이때는 전체 명령 사슬 취소가 되어야 한다. 이 예외 처리를 지원하기 위해 아래와 같은 변화가 필요하다.

  • Command에 success 플래그 추가
  • 각 작업의 수행마다 성공, 실패 여부 기록
  • 성공한 명령에 대해서만 undo 명령이 수행
  • 명령의 되돌림을 주의 깊게 수행하는 클래스를 커맨드 클래스 간에 위치

추가적으로 모든 구성 커맨드가 성공적으로 실행되어야만 성공하는 더 강한 조건을 생각해 볼 수도 있다.


명령과 조회의 분리

명령과 조회를 분리(Command Query Separation, CQS)한다는 개념은 어떤 시스템에서의 작업이 크게 보았을 때 다음의 두 종류 중 한 가지로 분류될 수 있다는 것에서 제안되었다.

  • 명령 : 어떤 시스템의 상태 변화를 야기하는 작업 지시들로, 어떤 결괏값의 생성이 없는 것
  • 조회 : 어떤 결괏값을 생성하는 정보 요청으로, 그 요청을 처리하는 시스템의 상태 변화를 일으키는 것

직접적으로 상태에 대한 읽기, 쓰기 작업을 노출하는 객체는 그러한 것들을 private으로 바꾸고, 각 상태에 대한 get/set 함수들 대신 단일 인터페이스로 바꿀 수 있다.

 

예를 들어 아래와 같이 get/set 멤버 함수 없이 command/query 멤버 함수를 사용할 수 있다. 아무리 속성과 기능이 늘어나더라도 두 API만으로 처리한다. 즉, 커맨드만으로 크리처와의 상호작용을 수행할 수 있다.

class Creature {
public:
    Creature(int strength, int agility) : strength(strength), agility(agility) {
    }
    
    void process_command(const CreatureCommand &cc);
    void process_query(const CreatureQuery &q) const;
private:
    int strength, agility;
};

API의 인자로 들어간 CreatureCommand와 CreatureQuery는 각각 전용 클래스로 정의된다. 여기서 조회 결과가 함수 리턴 값으로 전달되는 것으로 가정하고 조회 객체 자체에 따로 저장하지는 않는다. 하지만 앞서 보았듯이 조회 객체에 값을 저장할 필요가 생길 수도 있다.

enum class CreatureAbility {
    strength,
    agility,
};

struct CreatureCommand {
    enum Action (
        set,
        increaseBy,
        decreaseBy,
    ) action;
    
    CreatureAbility ability;
    int amount;
};

struct CreatureQuery {
    CreatureAbility ability;
};

다음은 process_command()와 process_query()의 구현이다.

void Creature::process_command(const CreatureCommand &cc) {
    int *ability;
    switch ( cc.ability ) {
    case CreatureAbility::strength:
        ability = &strength;
        break;
    case CreatureAbility::agility:
        ability = &agility;
        break;
    }
    switch ( cc.action ) {
    case CreatureCommand::set:
        *ability = cc.amount;
        break;
    case CreatureCommand::increaseBy:
        *ability += cc.amount;
        break;
    case CreatureCommand::decreaseBy:
        *ability -= cc.amount;
        break;
    }
}

int Creature::process_query(const CreatureQuery &q) const {
    switch ( q.ability ) {
    case CreatureAbility::strength:
        return strength;
    case CreatureAbility::agility:
        return agility;
    }
    return 0;
}

명령과 조회에 로깅이 필요하거나 객체를 유지해야 한다면 두 멤버 함수만 변경하면 된다. 유일한 문제는 익숙한 get/set 방식의 API를 고집하는 클라이언트를 어떻게 지원하느냐이다.

이때 process_...() 함수를 감싼 형태로 get/set 함수를 만들 수 있다.


요약

커맨드 디자인 패턴은 단순하다. 인자를 전달해 메서드를 호출하는 직접적인 방법으로 객체에 일을 시키는 대신 작업 지시 내용을 감싸는 특별한 객체를 두어 객체와 커뮤니케이션한다.

어떤 명령을 수행하지 않고 조회만 수행하는 커맨드 객체는 조회 객체라 부른다. 조회 객체는 많은 경우 메서드 리턴 값으로 정보를 전달하는 불변 객체다.

 

728x90