'스테이트'에 해당되는 글 2건

  1. 2006.09.19 디자인 개선
  2. 2004.01.30 스테이트
dev.log2006. 9. 19. 09:47

설계의 가장 기본은 추상화이다. 이놈과 저놈이 다른게 무엇이고 공통점이 무엇인지를 파악해서 공통점을 묶어서 추상개념으로 승화시켜야 하는 법이다.

앞에서 든 음악 재생의 예는, 추상화를 제대로 하지 못해서 실패한 디자인이다. '재생'과 '정지'를 음악 재생의 한 과정으로서가 아니라, 추상적 실체로 바라보았다면 훨씬 나은 디자인이 되었을 것이다.

단지 음악을 '재생'하고 '정지'하는 기능만 필요한 상황에서는 애초의 설계가 그리 나쁜것은 아니다. 간단한 기능에는 간단한 설계. 이런 간단한 상황에서 Overkill은 금물이다.

 

class music
{
public:
    music();
    ~music();

    void update();
    void play( char* name );
    void stop();

protected:
    void* data;
    bool playing;
};

 

void music::update()
{
    if ( playing )
        continue_playing( data );
}


이제 여기에 '일시정지'와 '계속재생'이 추가된다. 프로젝트에서 이 부분을 맡은 개발자는 다음과 같이 기능을 추가했다.

class music
{
public:
    music();
    ~music();

    void update();
    void play( char* name );
    void stop();
    void pause();
    void resume();

protected:
    void* data;
    bool playing;
    bool paused;
};

void music::update()
{
    if ( playing )
        if ( !paused )
            continue_playing( data );
}

이 순간이 결정적 순간이다. 이 개발자는 이 기능을 추가하는 순간에 한번 고민을 해 봤어야 한다. data는 항상 쓰는 자원이니까 별도로 치면, 이 객체가 처할 수 있는 상태는 4가지이다.

A : playing is true, paused is false

B : playing is true, paused is true

C : playing is false, paused is false

D : playing is false, paused is true

여기에서, 상태A는, 재생이 시작되었고, 일시정지되지 않은 상황이다. 그냥 '재생'되고 있는 상황. 상태 B는, 일단 재생이 시작되었다가 일시정지된 경우이다. 우리가 일상적으로 생각하는 '일시정지'의 개념과 매우 잘 부합한다. 상태C는 재생되고 있지도 않고, 일시정지 되지도 않은 상황이다. 즉, 그냥 '정지'.

문제는 상태 D이다. 객체가 이 상태에 있을 때는 정의되지도 않았고, 우리의 일상적인 개념과 잘 부합하지도 않는다. 어떻게, 음악이, 재생하지도 않았는데, 일시정지될수 있단 말인가.

 

여기에서의 적합한 추상화는, 음악을 상태기계로 보고, '재생', '일시정지', '정지'의 3가지 상태를 만들어 주는 것이다.

class music
{
public:
    music();
    ~music();

    void update();
    void play( char* name );
    void stop();
    void pause();
    void resume();

protected:
    void* data;
    int state;
};

void music::update()
{
    switch ( state )
    {
    case STATE_STOPPED:  do_nothing(); break;
    case STATE_PLAYING:   continue_playing(); break;
    case STATE_PAUSED:    do_nothing(); break;
    default: stop();
    }
}

 

일단 지금은, 보기 흉한 switch 같은 것은 무시하기로 하자. 좀 더 명확해 진 것으로 만족해 보자. 이제 play(), stop(), pause(), resume(), update() ('시간의 경과'역시 입력의 하나로 취급할 수 있다) 등의 함수는 사실은 music이라는 객체의 상태를 바꿔주는 상태전이 입력으로 파악할 수 있는 것이다. 또한 객체가 알 수 없는 상태에 빠졌을 때에도 명시적으로 검증할 수 있는 상태로 편리하게 되돌릴 수도 있는 것이다. (위의 default 케이스)

 

이제 여기에 페이드-인/아웃을 추가해 보자. 이제 간단히 FI/FO상태를 추가하기만 하면 된다.

void music::update()
{
    switch ( state )
    {
    case STATE_STOPPED:  do_nothing(); break;
    case STATE_PLAYING:   continue_playing(); break;
    case STATE_PAUSED:    do_nothing(); break;
    case STATE_FADEIN:      increase_volume(); break;
    case STATE_FADEOUT:   decrease_volume(); break;
    default: stop();
    }
}

이걸로 끝일까? 위에서 update()역시 '시간의 경과'를 나타내는 상태전이 입력의 하나로 볼수 있다고 했다. 따라서 상태가 하나 추가된다면, 다른 모든 입력에 대해서 이 상태가 어떻게 행동해야 하나를 고려해야 한다. 따라서 다음과 같이 고려해야 한다.

void music::play( char* name )
{
    switch ( state )
    {
    case STATE_STOPPED:  read_data(); state = STATE_PLAYING; break;
    case STATE_PLAYING:   break;
    case STATE_PAUSED:    state = STATE_PLAYING; break;
    case STATE_FADEIN:      break;
    case STATE_FADEOUT:   restore_volume(); state = STATE_PLAYIN; break;
    default: stop();
    }
}

void music::stop()
{ ............ }

void music::pause()
{ ............ }

......................

 

'상태'라는 추상 개념을 도입함으로써, 기능이 추가될 때 무엇을 해야 하는지가 명확해 졌다.

이제 문제는 도처에 널부러져 있는 보기 흉한 switch-case이다. 상태가 하나 추가될 때마다 모든 가능한 입력에 대해 행동을 정의해 줘야 하는데, 하나의 상태가 하는 행동이 여러군데에 흩어져 있는 것이다. "Fade-in 할때 이러저러할 걸 바꿔주세요"라는 요청이 있을 때마다, update()에서 한줄 고치고, play()에서 한줄 고치고, pause()에서 또 한줄 고치는 일을 마냥 하다보면, 마우스 휠이 닳아서 못쓰게 될 지경이 될 것이다.

이때에 생각할 수 있는 것은 여러가지이지만 내가 적합하다고 생각하는 쪽은 State 패턴(GoF, p305)이다. 트랙백의 덧글에서처럼 MVC패턴을 적용할 수도 있지만, MVC패턴은 원래 다중 View를 생각해야 할 때 적합한 패턴이고, 음악 재생의 예는 View보다는 객체 자체의 행동을 캡슐화해야 하는 상황인데, 이때에는 State패턴이 보다 적합하리라는 것이 나의 생각이다.

State 패턴을 적용하면, 대략 다음과 같아진다.

class music
{
public:
    music();
    ~music();

    void update();
    void play( char* name );
    void stop();
    void pause();
    void resume();

protected:
    void* data;
    struct state* cur_state;
    struct state
    {
        music* context;
        state( music* m ) : context( m ) {}
        virtual void handle_play( char* name );
        virtual void handle_stop();
        virtual void handle_pause();
        virtual void handle_resume();
        virtual void handle_fadein();
        virtual void handle_fadeout();
        virtual void handle_update();
    };

    struct state_playing : public state
    { .......... };

    struct state_stopped : public state
    { .......... };

    struct state_paused : public state
    { .......... };

    struct state_fadein : public state
    { .......... };

    struct state_fadeout : public state
    { .......... };

};

 

그럼 update()와 다른 함수들은 대략 다음과 같이, 현재 상태로 입력을 포워딩하는 구조로 바뀐다.

void music::update()
{
    cur_state->handle_update();
}

 

void music::play( char* name )
{
    cur_state->handle_play();
}

 

이제부터는 기능이 추가되어야 한다면, 적절한 상태로 추상화 한 후, 상태 객체를 만들어서 끼워 넣는 식으로 디자인 변경이 쉬워진다. 또한 앞으로의 변경사항에 어떻게 대처해야 하는지, 현재 구조를 바꾸지 않고도 기능을 추가할 수 있는 방향을 제시할 수 있다는 점에서 더 나아진 디자인이다.

잘된 디자인이란, 모든 상상 가능한 기능을 구현할 수 있는 '틀'이 아니다. (그러한 것은 존재하지 않는다) 오히려 전혀 예상치 못한 요구가 발생했을 때에, 그 요구를 해결하기 위해 디자인이 어떻게 바뀌어야 하는가에 대한 방향을 제시할 수 있는 디자인이어야 한다고, 나는 생각한다.

 

Posted by uhm
dev.log2004. 1. 30. 02:44
스테이트 머신은 복잡한 계의 행동을 모델링하는 간단한 기법으로 널리 애용되곤 한다. 스테이트 머신에 대한 자세한 설명은 여기서는 하지 않겠지만, 상태에 따라 외부의 자극에 다르게 반응하는 계를 모델링하는 기법이라고 설명하면 될듯 하다. 스테이트 머신에는 여러 종류가 있지만, 여기서는 가장 간단한 Deterministic Finite State Automata만을 고려하기로 한다.
오늘 누군가가 스테이트 머신 클래스를 만든 코드라고 한 것을 본 적이 있다. 대략적으로 다음과 같은 형상이었다.
void state_machine::handler()
{
    int state = INITIAL_STATE;
    while ( !is_final( state ) )
    {
        int s = fetch_stimuli();
        switch ( state )
        {
        case STATE0:
            state = on_state0( s );
            break;
        case STATE1:
            state = on_state1( s );
            break;
        // 다른 스테이트를 처리하는 케이스
        // ....
        default:
            state = INVALID_STATE;
        }
    }
}
int state_machine::on_state0( int s )
{
    switch ( s )
    {
    case STIMULI0:
        // 스테이트0에서 입력0를 처리하는 코드
        // ....
        return SOME_STATE;
    case STIMULI1:
        // 스테이트1에서 입력1을 처리하는 코드
        // ....
        return ANOTHER_STATE:
    // 다른 입력을 처리하는 케이스
    // ....
    default:
        return STATE0;
    }
}


이건 클래스만 썼을 뿐이지 C코드 그대로의 설계방식이다. 전혀 객체지향적이지 않다. 문제점이 보이는가?
어떤 스테이트 머신이 있을때, 그 머신의 스테이트0와 스테이트1에서의 행동은 많든 적든 다른 점이 있기 마련이다. 객체지향의 세계에서는 다른 일을 하는 개체는 다른 클래스로 만들어야 한다. 구현하고자 하는 기능을 그냥 <클래스에 때려박는다>고 해서 객체지향이 되는 것이 아니라는 것이다.

혹시 디자인 패턴 쪽을 공부해 보지 않은 분이 있다면 객체지향 설계 방법론에서 디자인 패턴은 한번 봐둘만한 가치는 있음을 말하고 싶다. 여기에서 나타나는 것이 state pattern, 혹은 strategy pattern이다. 스테이트 패턴은 어떤 객체의 스테이트를 하나의 객체로 모델링하는 기법이다.
위 스테이트 머신을 스테이트 패턴을 써서 고친다면 다음과 같이 변할 것이다.

우선, 객체의 스테이트를 나타내는 state 추상 클래스를 선언한다.

class state
{
public:
    virtual state* handle_stimuli( int s ) = 0;
};


이제 각 스테이트를 표현하는 클래스를 선언한다.
class state0 : public state
{
protected:
    state* handle_stimuli( int s )
    {
        switch ( s )
        {
        case STIMULI0:
            return handle_stimuli0();
        case STIMULI1:
            return handle_stimuli1();
        // 다른 입력을 처리하는 케이스
        // ....
        default:
            return new state0();
        }
    }
};


이제 메인 루프에서는 선언된 추상클래스의 인터페이스만 호출하면 된다.

void state_machine::handler()
{
    state* cur_state = new initial_state();
    while ( !is_final( state ) )
    {
        int s = fetch_stimuli();
        state* new_state = cur_state->handle_stimuli( s );
        delete cur_state;
        cur_state = new_state;
    }
    delete cur_state;
}


혹 동적 메모리의 할당과 해제가 빈번히 일어나는 것이 신경쓰인다면, 각 상태 객체를 미리 스테이트 머신 객체에 등록시켜 놓고 빠르게 접근하는 방법을 생각해도 되겠다. 이런 경우에는 Prototype패턴을 응용해 보면 좋을 것이다. 혹은, placement new 연산자를 오버로딩하여 사용하는 방법을 써도 좋다.

구현상의 개선이야 그렇다 치고, 이제는 설계상의 개선을 한번 찾아보자. 위 설계에 개선의 여지가 없는가? 당연히 있다. 문제는 스티뮬리를 처리하는 handle_stimuli()메소드의 구현 방식에 있다. 여기서는 스티뮬리의 값이 따라서 객체의 행동을 선택해주는 방식을 취하고 있다. 그렇다면 행동을 취하는 정보는 state객체에 있는 것이 아니라 스티뮬리에 있는 것이 된다. 그렇다면 행동의 선택은 이미 스티뮬리에 따라 주어진 것인데, 구태여 state객체가 스티뮬리에 따르는 행동의 선택에 관여할 필요는 없다는 결론이 나온다.
즉, 스테이트 객체가 스티뮬리를 처리하는 방식은 스티뮬리에 따라 결정되므로, 스티뮬리가 자기 자신을 처리하는 방식을 알고 있는 것이 합리적인 설계이다. 즉, 스티뮬리를 객체로 모델링하는 것이 정답이다. 여기에는 Command패턴을 쓰는 것이 적절하다.

우선 스티뮬리를 나타낼 추상클래스 stimuli를 선언한다.
class stimuli
{
public:
    virtual state* handle( state* s ) = 0;
};


이제 각 스티뮬리를 표현하는 클래스를 작성한다.
class stimuli0 : public stimuli
{
protected:
    state* handle( state* s )
    {
        return s->handle_stimuli0();
    }
};


그럼 이제 state객체의 선언이 바뀌게 된다.

class state0 : public state
{
protected:
    state* handle_stimuli( stimuli& s )
    {
        return s.handle( this );
    }
};


그러면 메인루프는 다음과 같아진다.

void state_machine::handler()
{
    state* cur_state = new initial_state();
    while ( !is_final( state ) )
    {
        stimuli& s = fetch_stimuli();
        state* new_state = cur_state->handle_stimuli(s);
        delete cur_state;
        cur_state = new_state;
    }
    delete cur_state;
}

처음의 코드와 가장 나중의 코드를 비교해 보면, 어느 쪽이 더 깔끔한지 판단해 보시기 바란다.

Posted by uhm