서비스 중개자 (Service Locator)
서비스를 구현한 구체 클래스는 숨긴 채로 어디에서나 서비스에 접근할 수 있게 한다.
동기
메모리 할당이나 로그, 난수 생성 등을 사용하지 않는 게임코드는 찾아보기 어려울 정도로 정말 많이 사용된다.
이런 시스템은 게임 전체에서 사용 가능해야 하는 일종의 서비스라고 볼 수 있다.
바로 직전에 만들어봤던 오디오 시스템만 해도 여러 게임 시스템과 연결되어 있었다.
AudioSystem::playSound(VERY_LOUD_BANG); // 정적 클래스
AudioSystem::instance()->playSound(VERY_LOUD_BANG); // 싱글턴
둘 중 무엇이 되었던 간에 오디오 시스템을 사용하는 클래스는 강한 커플링도 함께 생긴다.
물론 사운드를 출력하려면 어느 정도의 커플링을 피할 수는 없지만 오디오를 구현한 구체 클래스를 바로 접근할 수 있게 하는 것은 별로 옳지 않다.
전화번호부에 비유를 해보자. 누군가의 연락처를 알고 싶다면 전화번호부에서 이름으로 찾으면 된다. 만약 이사를 가게된다면 전화국을 통해 전화번호부를 업데이트하면 다른 사람들이 새로운 주소를 찾아볼 수 있다.
진짜 주소가 아닌 대리 주소를 적어도 상관이 없다. 그저 정보를 찾는 쪽(호출하는 쪽)에서 전화번호부를 통해 나를 찾게 함으로써 찾을 방법은 한곳에서 편하게 관리할 수 있다.
이것이 서비스 중개자 패턴의 핵심이다. 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스 타입이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해준다.
패턴
서비스는 여러 기능을 추상 인터페이스로 정의한다. 구체 서비스 제공자는 이런 서비스 인터페이스를 상속받아 구현한다. 이와 별도인 서비스 중개자는 서비스 제공자의 실제 타입과 이를 등록하는 과정은 숨긴채 적절한 서비스 제공자를 찾아 서비스에 대한 접근을 제공한다.
언제 쓸 것인가?
전역적으로 접근하는것은 싱글턴에서도 언급했듯이 문제가 생기기 쉽기 때문에 절제해서 사용하는 게 좋다.
만약 접근해야 할 객체가 있다면 전역 메커니즘을 먼저 떠올리는 대신 필요한 객체를 인수로 넘겨줄 수 없는지부터 생각해 볼 필요가 있다.
다만 직접 객체를 넘기는 방식이 불필요하거나 오히려 코드를 읽기 어렵게 만드는 경우도 있다.
예를 들어 로그나 메모리 관리 같은 시스템이 모듈의 공개 API에 포함되어 있으면 안된다.
렌더링 함수의 매개변수에는 렌더링에 관련된 것만 있어야지 로그 같은 게 섞여 있을 필요가 없을 뿐더러 있어서도 안된다.
게임 내에서 오디오나 디스플레이 시스템은 단 하나만이 존재하는데 이걸 굳이 인수로 넘겨주는것은 쓸데없이 복잡성만 늘리는 셈이 된다.
서비스 중개자 패턴은 사실상 싱글턴 패턴이지만 좀 더 유연하고 더 설정하기 좋은 싱글턴 패턴이다.
물론 근간은 싱글턴이기 때문에 잘못 사용하면 싱글턴의 단점이 그대로 나타난다.
주의사항
두 코드가 커플링되는 의존성을 런타임 시점까지 미루는 부분이 가장 어렵다.
유연성을 얻는 대신 코드만 봐서는 어떤 의존성을 사용하는지 알기 어렵다는 비용이 발생한다.
싱글턴이나 정적 클래스와 달리 서비스 객체를 등록해야만 사용할 수 있기 때문에 필요한 객체가 없을 때를 대비해야 한다.
서비스 중개자는 전역에서 접근 가능하기 때문에 각 서비스들은 어느 환경에서나 문제 없이 동작해야만 한다.
특정한 곳에서만 사용하고 특정할 때에는 사용하면 안 된다면 정확히 정해진 곳에서만 실행되는 걸 보장할 수 없기 때문에 서비스로는 적합하지 않다.
만약 어떤 클래스가 특정 상황에서만 실행되어야 한다면 서비스 중개자 패턴은 적용하지 않는 것이 안전하다.
예제
class Audio {
public:
virtual ~Audio() {}
virtual void playSound(int soundID) = 0;
virtual void stopSound(int soundID) = 0;
virtual void stopAllSounds() = 0;
};
이벤트 큐에서 다뤘던 오디오 시스템을 서비스 중개자를 통해서 다른 코드에 오디오 시스템을 제공하도록 변경한다.
우선 인터페이스를 정의한다.
class ConsoleAudio : public Audio {
public:
virtual void playSound(int soundID) override { ... }
virtual void stopSound(int soundID) override { ... }
virtual void stopAllSounds() override { ... }
};
인터페이스 만으로는 아무것도 할 수 없기 때문에 구체 클래스를 구현한다.
우선 단순한 중개자부터 시작하자.
class Locator {
public:
static Audio* getAudio() { return service_; }
static void provide(Audio* service) { service_ = service; }
private:
static Audio* service_;
};
정적 함수인 getAudio가 중개 역할을 하게 된다.
Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);
Audio 클래스가 제공하는 인터페이스를 통해 서비스 인스턴스를 반환받는다.
ConsoleAudio* audio = new ConsoleAudio();
Locator::provice(audio);
중개자가 서비스를 등록하는 방법이다. 매우 단순하다. getAudio를 호출하기 전에 먼저 서비스 제공자를 외부 코드에서 등록해주면 된다.
playSound를 호출하는 쪽에서는 추상 클래스의 인터페이스만 알 뿐 구체 클래스에 대해서는 전혀 모른다는 것이 핵심이다.
중개자인 Locator 클래스 역시 서비스 제공자의 구체 클래스와는 커플링되지 않는다.
어떤 구체 클래스가 사용될지는 서비스를 제공하는 초기화 코드에서만 알아낼 수 있다.
재미있는점은 상위 추상 클래스인 Audio 자신은 서비스 중개자를 통해 여기저기에 접근된다는 사실을 모른다.
Audio 인터페이스만 놓고 보면 일반적인 상위 추상 클래스이므로 꼭 서비스 중개자 패턴용으로 만들지 않은 기존의 클래스에도 서비스 중개자 패턴을 적용할 수 있다.
구현과 적용 자체는 어렵지 않고 단순하지만 서비스 제공자가 서비스를 등록하기 전에 서비스를 사용하려고 접근하면 nullptr을 반환하기 때문에 호출하는 쪽에서 nullptr 검사를 하지 않으면 크래시되는 문제가 있다.
호출하는 쪽에서 nullptr 검사를 일일이 수행하는 대신 아예 null객체를 반환하도록 하면 훨씬 깔끔해진다.
서비스 제공자를 찾지 못하거나 만들지 못해서 nullptr을 반환해야 할 때, 대신 같은 인터페이스를 구현한 널 객체를 생성해서 반환해준다.
class NullAudio : public Audio {
public:
virtual void playSound(int soundID) override { /* 아무것도 하지 않는다 */ }
virtual void stopSound(int soundID) override { /* 아무것도 하지 않는다 */ }
virtual void stopAllSounds() override { /* 아무것도 하지 않는다 */ }
};
널 객체 자체는 아무런 기능이 구현되어 있지 않지만 받는 쪽에서는 실제 객체를 받은 것처럼 안전하게 작업을 진행할 수 있다.
class Locator {
public:
static void initialize() {
service_ = &nullService_;
}
static Audio& getAudio() { return *service_; }
static void provide(Audio* service) {
if (service == nullptr)
service_ = &nullService_;
else
service_ = service;
}
private:
static Audio* service_;
static NullAudio nullService_;
};
이제 Locator는 언제나 유효한 객체를 반환한다는 점이 보장되기 때문에 호출하는 쪽에서는 서비스가 준비되었는지를 신경쓰거나 nullptr 검사를 할 필요가 전혀 없이 그냥 호출만 하면 된다.
덧붙여서 널 객체는 의도적으로 특정 서비스를 찾지 못하게 하고 싶을때도 사용할 수 있다.
어떤 시스템을 잠시 못쓰게 하고 싶다면 그냥 서비스를 등록하지 않으면 된다. 그러면 알아서 널 객체를 반환해준다.
게임을 개발하다 보면, 어떤 일이 벌어지는지 알기 위해서 원하는 이벤트에 간단하게 로그를 설치해야 할 때가 있다.
이럴 때는 보통 로그를 생성하는 함수를 이곳저곳에 집어넣게 되는데, 시간이 지나면 로그가 너무 많아지는 문제가 생긴다.
AI 프로그래머는 사운드 출력에 관심이 없고, 사운드 개발자는 AI의 상태 변화에 관심이 없는 등 필요 없는 로그가 있음에도 일괄적으로 기록되기 때문에 원하는 로그를 찾기 위해서 다른 사람들이 기록한 로그까지 같이 뒤져봐야 한다.
조건적으로 로그를 남기고 싶은 시스템이 서비스로 노출되어 있다면, 데코레이터 패턴을 적용시켜서 원하는 로그만 온오프하거나 최종 빌드시에 로그를 전부 제거시킬수도 있다.
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_;
};
다른 오디오 서비스 제공자를 래핑하면서 동일한 인터페이스를 상속받는다.
기능 요청을 받으면 로그를 기록하고 실제 기능 요청은 래핑한 서비스에 전달한다.
void enableAudioLogging() {
// 기존 서비스 decorate
Audio* service = new LoggedAudio(Locator::getAudio());
Locator::provide(service);
}
로그를 키고싶으면 위의 함수를 호출해주면 되고 비활성화 할때는 로그 기능이 빠진 서비스 제공자를 등록하면 된다.
만약 널객체에 데코레이터 패턴을 적용한다면 기능은 빠지고 로그만 출력시킬 수도 있다.
디자인 결정
◾ 서비스는 어떻게 등록되는가?
◾ 외부 코드에서 등록
예제에서 본 방식이 이에 해당하고 가장 일반적으로 사용하는 방법이다.
빠르고 간단하며 서비스 제공자를 어떻게 만들지 제어할 수 있고 게임 실행 도중에도 서비스를 교체할 수 있다.
하지만 서비스 중개자가 외부 코드에 의존한다는 단점이 있다.
◾ 컴파일할 때 바인딩
class Locator {
public:
static Audio& getAudio() { return service_; }
private:
#if DEBUG
static DebugAudio service_;
#else
static ReleaseAudio service_;
#endif
};
전처리기 매크로를 이용해서 컴파일 타임에 등록한다.
컴파일 타임에 작업이 이루어지기 때문에 매우 빠르고 서비스가 항상 사용 가능하다는 장점이 있지만 서비스를 쉽게 변경할 수 없다. 만약 변경하려면 컴파일을 다시 해야한다.
◾ 런타임에 설정 값 읽기
보통 서비스 제공자 설정 파일을 읽어서 리플렉션으로 원하는 서비스 제공자를 런타임에 생성한다.
여러 장점이 있지만 복잡하고 서비스 등록 시간이 걸린다는 단점이 있다.
◾ 서비스를 못 찾으면 어떻게 할 것인가?
◾ 사용자가 알아서 처리하게 한다
가장 간단한 방법이다. 못찾으면 알아서 해결하라고 nullptr을 반환하고 책임을 떠넘기면 된다.
실패 처리를 사용자 쪽에서 정할 수 있다는 장점은 있지만 매번 실패처리를 해야하는 번거로움이 생긴다.
◾ 게임을 멈춘다
서비스를 못찾으면 단언문을 넣어서 게임을 중단시켜버린다.
문제를 해결해주지는 않지만 누구에게 문제가 있는지 분명하게 보여준다.
서비스 중개자쪽에서 처리하기 때문에 사용자 측에서 실패처리를 하지 않아도 되지만 찾지 못하면 게임이 얄짤없이 종료된다.
◾ 널 서비스를 반환한다
실패처리를 사용자가 하지 않아도 되고 게임이 멈추지도 않는다.
게임이 멈추지 않는다는 점은 장점임과 동시에 단점이 될 수도 있는데, 서비스를 찾지 못했는데도 동작한다면 디버깅하기가 어려워진다.
널 서비스를 반환하는 형태가 가장 좋아보이지만 의외로 단언문을 걸어서 게임을 중단시키는 경우가 가장 많이 사용된다.
게임이 출시될쯤이면 이미 테스트를 많이 통과했을 것이고 게임이 실행될 환경도 어느정도 구체화되기 때문에 서비스를 찾지 못할 가능성은 매우 적다.
◾ 서비스의 범위는 어떻게 잡을 것인가?
여태까지는 전역적인 접근을 허용했고 이게 일반적이지만 특정 클래스 및 하위 클래스에만 접근을 제한시킬수도 있다.
전역에서 접근이 가능하다면 전체 코드에서 같은 서비스를 사용하도록 할 수 있지만 언제 어디에서 서비스가 사용되는지를 제어할 수 없다.
특정 클래스에만 접근을 제한시킨다면 커플링을 제어할 수 있지만 중복 작업을 해야 할 수도 있다.
각자의 서비스에 참조해야 하기 때문에 코드 중복을 피할 수 없다.
하위 클래스 샌드박스 패턴을 적용시켜봤자 결국 최상위 클래스와 커플링이 생긴다.
서비스가 특정 분야에 한정되어 있다면 특정 클래스로 접근 범위를 좁히는 편이 좋다.
'도서 > 게임 프로그래밍 패턴' 카테고리의 다른 글
최적화 패턴 : 더티 플래그 (0) | 2023.03.03 |
---|---|
최적화 패턴 : 데이터 지역성 (0) | 2023.03.03 |
디커플링 패턴 : 이벤트 큐 (0) | 2023.02.28 |
디커플링 패턴 : 컴포넌트 (0) | 2023.02.27 |
행동 패턴 : 타입 객체 (0) | 2023.02.27 |