소년코딩

서비스 중개자 패턴, Service Mediator Pattern

서비스를 구현한 구체 클래스는 숨김 채로 어디에서나 서비스에 접근할 수 있게 한다.

객체나 시스템 중에는 거의 모든 코드에서 사용되는 것들이 있다. 게임 코드 중에서 메모리 할당, 로그, 난수 생성을 쓰지 않는 곳은 찾아보기 어렵다. 이런 시스템은 게임 전체에서 사용 가능해야 하는 , 일종의 서비스라고 볼 수 있다.

예를 들어 오디오 시스템만 해도 메모리 할당 같은 저수준 시스템만큼은 아니지만 여러 게임 시스템과 연결되어 있다. 돌이 땅으로 굴러 떨어지면서 깨진다든지(물리), NPC가 총을 쏜다든지(AI), 사용자가 메뉴를 고를 때 확인음을 낸다든지(사용자 인터페이스) 하는 식이다.

이런 코드에서는 다음과 같이 오디오 시스템을 호출할 수 있어야 한다.

// 정적 클래스를 쓸 수도 있고
AudioSystem::playSound(VERY_LOUD_BANG);

// 싱글턴을 쏠 수도 있다.
AudioSystem::instance()->playSound(VERY_LOUD_BANG);

둘 다 원하는 결과는 얻을 수 있지만 강한 커플링도 함께 생긴다. 오디오 시스템을 접근하는 모든 곳에서 AudioSystem이라는 구체 클래스뿐만 아니라 정적 클래스 또는 싱글턴으로 만든 접근 메커니즘까지 직접 참조하게 된다.

오디오를 구현한 구체 클래스를 바로 접근할 수 있게 하는 건 우편물 하나 받겠다고 수많은 이방인에게 집 주소를 알려주는 것돠 다를 바 없다. 이보다는 호출하는 쪽에서 전화번호부를 통해서 찾게 함으로써, 찾을 방법을 한곳에서 편리하게 관리할 수 있다.

이게 서비스 중개자 패턴의 핵심이다. 서비스 중개자 패턴은 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스 자료형이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해준다.


패턴

서비스(service)는 여러 기능을 추상 인터페이스로 정의한다. 구체 서비스 제공자(service provider)는 이런 서비스 인터페이스를 상속받아 구현한다. 이와 별도인 서비스 중개자(service locator)는 서비스 제공자의 실제 자료형과 이를 등록하는 과정은 숨긴채 적절한 서비스 제공자를 찾아 서비스에 대한 접근을 제공한다.


예제 코드

이전의 이벤트 큐 패턴에서 다룬 오디오 시스템 문제로 돌아가보자. 이번에는 서비스 중개자를 통해서 다른 코드에 오디오 시스템을 제공하겠다.

서비스

오디오 API부터 시작한다. 오디오 API는 오디오 서비스가 제공할 인터페이스다.

class Audio {
public:
    virtual ~Audio() {}
    virtual void playSound(int soundID) = 0;
    virtual void stopSound(int soundID) = 0;
    virtual void stopAllSounds() = 0;
};

중요한 것은 이 클래스가 추상 인터페이스일 뿐 실제 구현은 바인딩되어 않다는 점이다.

서비스 중개자

오디오 인터페이스만으로는 아무것도 할 수 없다. 구체 클래스를 구현해야 한다.

class ConsolAudio : public Audio {
public:
    virtual void playSound(int soundID) {
        // 콘솔의 오디오 API를 이용해 사운드를 출력한다...
    }
    virtual void stopSound(int soundID) {
        // 콘솔의 오디오 API를 이용해 사운드를 wndwl한다...
    }
    virtual void stopAllSounds(int soundID) {
        // 콘솔의 오디오 API를 이용해 모든 사운드를 중지한다...
    }
};

인터페이스와 구현 클래스는 준비되었다. 이제 이 둘을 묶어주는 서비스 중개자를 보자.

단순한 중개자

가장 단순한 형태의 중개자 코드부터 살펴본다. (이런 기법을 '의존성 주입'이라고 한다. : 어떤 클래스가 다른 클래스에 의존한다.)

class Locator {
public:
    static Audio* getAudio() { return service_; }
    static void provide(Audio* service) { service_ = service; }

private:
    static Audio* service_;
};

정적 함수인 getAudio()가 중계 역할을 한다. 어디에서나 부를 수 있는 getAudio 함수는 아래와 같이 Audio 서비스 인스턴스를 반환한다.

Audio* audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);

Locator가 오디오 서비스를 '등록하는' 방법은 굉장히 단순하다. 어디에서 getAudio를 호출하기 전에 먼저 서비스 제공자를 외부 코드에서 등록해주면 된다. 게임이 시작될 때 다음과 같은 코드를 실행한다.

ConsoleAudio* audio = new ConsoleAudio();
Locator::provide(audio);

playSound()를 호출하는 쪽에서는 Audio라는 추상 인터페이스만 알 뿐 ConsoleAudio라는 구체 클래스에 대해서는 전혀 모른다는 게 핵심이다. Locator 클래스 역시 서비스 제공자의 구체 클래스와는 커플링이 되지 않는다. 어떤 구체 클래스가 실제로 사용되는지는 서비스를 제공하는 초기화 코드에서만 알 수 있다.

디커플링은 이뿐만이 아니라 Audio 인터페이스도 자기가 서비스 중개자를 통해서 여기저기로부터 접근된다는 사실을 모른다. Audio 인터페이스만 놓고 보면 일반적인 추상 상위 클래스다. 즉, 꼭 서비스 중개자 패턴용으로 만들지 않은 기존 클래스에도 이 패턴을 적용할 수 있다. 이런 점은 '서비스'를 제공하는 클래스의 형태 자체에 영향을 미치는 싱글턴 패턴과 정반대다.

널 서비스

지금까지의 코드는 단순하면서도 유연하지만 한 가지 큰 단점이 있다. 만약 서비스 제공자가 서비스를 등록하기 전에 서비스를 사용하려고 시도하면 NULL을 반환한다. 이때 호출하는 쪽에서 NULL 검사를 하지 않으면 크래시된다.

이럴 때 써먹을 수 있는 널 객체(Null Object) 디자인 패턴이 있따. 핵심은 객체를 찾지 못하거나 만들지 못해 NULL을 반환해야 할 때, 대신 같은 인터페이스를 구현한 특수한 객체를 반환하는 데있다. 이런 특수 객체에는 아무런 구현이 되어 있지 않지만, 객체를 반호나하는 쪽에서 '진짜'객체를 받은 것처럼 안전하게 작업을 진행할 수 있다.

널 객체 패턴을 사용하려면 다음과 같이 '널(null)' 서비스 제공자를 정의한다.

class NullAudio :: public Audio {
public:
    virtual void playSound(int soundID) { /* 아무것도 하지 않는다. */ }
    virtual void stopSound(int soundID) { /* 아무것도 하지 않는다. */ }
    virtual void stopAllSounds(int soundID) { /* 아무것도 하지 않는다. */ }
};

NullAudio는 Audio 서비스 인터페이스를 상속받지만 아무 기능도 하지 않는다. 이제 Locator 클래스를 다음과 같이 바꾼다.

class Locator {
public:
    static void initialize() {
        service_ = &nullService_;
    }
    static Audio& getAudio() { return *service_; }
    static void provide(AUdio* service) {
        if(service == NULL) // 널 서비스로 돌려 놓는다.
            service_ = &nullService_;
        else
            service_ = service;
    }
private:
    static Audio* service_;
    static NullAudio nullService_;
};

호출하는 쪽에서는 '진짜' 서비스가 준비되어 있는지를 신경 쓰지 않아도 되고 NULL 반환 처리도 피료 없다. Locator는 항상 유효한 객체를 반환한다는 점을 보장한다.

로그 데커레이터

게임 코드 안에서 어떤 일이 벌어지는지 알기 위해 원하는 이벤트에 간단하게 로그를 설치해야 할 떄가 있다.

이럴 대는 보통 log() 함수를 코드 여기저기에 집어넣는데. 이러다 보면 로그가 너무 많아지는 문제가 생긴다. AI 프로그래머는 사운드 출력에 관심이 없고, 사운드 개발자는 AI 상태 변화에 관심이 없다. 하지만 둘 다 로그를 추가했기 때문에 원하는 로그를 보기 위해 다른 사람 로그까지 같디 뒤져봐야 한다.

원하는 로그만 켰다 껏다 할 수 있고, 최종 빌드에는 로그를 전부 제거할 수 있다면 가장 이상적이다. 조건적으로 로그를 남기고 싶은 시스템이 서비스로 노출되어 있다면, GoF의 데코레이터(decorator) 패턴으로 이를 해결할 수 있다. 사운드 서비스 제공자를 다음과 같이 정의해보자.

class LoggedAudio : public Audio {
public:
    LoggedAudio(Audio& wrapped) : wrapped_(wrapped) {}
    virtual void playSound(int soundID) {
        log("사운드 출력");
        wrapped_.playSound(soundID);
    }
    virtual void stopSound(int soundID) {
        log("사운드 중지");
        wrapped_.stopSound(soundID);
    }
    virtual void stopAllSounds() {
        log("모든 사우든 중지");
        wrapped_.stopAllSounds();
    }

private:
    void log(const char* message) {
        // 로그를 남기는 코드...
    }
    Audio& wrapped_;
};

LoggedAudio 클래스는 다른 오디오 서비스 제공자를 래핑하는 동시에 같은 인터페이스를 상속받는다. 실제 기능 요청은 내부에 있는 서비스에 전달하고, 대신 사운드가 호출될 때마다 로그를 남긴다. 오디오 로그 기능을 켜고 싶다면 다음과 같이 하면 된다.

void enableAudioLogging() {
    // 기존 서비스를 데코레이트한다.
    Audio* service = new LoggedAudio(Locator::getAudio());
    // 이 값으로 바꿔치기한다.
    Locator::provide(service);
}

More

  • 서비스 중재자 패턴은 여러 면에서 싱글턴 패턴과 비슷하다. 어느 쪽이 더 필요에 맞는지 살펴본 뒤에 결정하자.
  • 유니티 프레임워크에서는 GetComponent()에서 컴포넌트 패턴과 함께 서비스 중개자 패턴을 사용한다.
  • XNA 프레임워크의 핵심 클래스인 Game에 서비스 중개자 패턴이 포함되어 있다. 이 클래스에는 Game.Services 송성이 들어 있어서 어떤 종류의 서비스라도 등록해 쓸 수 있다.

이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/service-locator.html 입니다.


디자인패턴

by 소년코딩

추천은 글쓴이에게 큰 도움이 됩니다.

악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.

댓글 로드 중…

블로그 정보

소년코딩 - 소년코딩

소년코딩, 자바스크립트, C++, 물리, 게임 코딩 이야기

최근에 게시된 이야기