서비스
교육 철학

서비스 중개자 (Service Locator)

서비스 중개자 (Service Locator)

16.1. 의도

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

16.2. 동기

객체나 시스템 중에는 거의 모든 코드에서 사용하는 것이 있습니다. 게임 코드에서 메모리 할당, 로그, 난수 생성을 쓰지 않는 곳은 찾기 어렵습니다. 이런 시스템은 게임 전체에서 사용할 수 있어야 하는 일종의 서비스라고 볼 수 있습니다.
오디오 시스템만 해도 메모리 할당 같은 저수준 시스템만큼은 아니지만 여러 게임 시스템과 연결됩니다. 돌이 땅으로 굴러 떨어져 깨지는 경우는 물리, 저격수 NPC가 총을 쏘는 경우는 AI, 사용자가 메뉴를 고를 때 확인음을 내는 경우는 사용자 인터페이스에 해당합니다.
이런 코드에서는 다음과 같이 오디오 시스템을 호출할 수 있어야 합니다.
// 정적 클래스를 쓸 수도 있고 AudioSystem::playSound(VERY_LOUD_BANG); // 싱글턴을 쓸 수도 있다. AudioSystem::instance()->playSound(VERY_LOUD_BANG);
C++
복사
둘 다 원하는 결과를 얻지만 강한 커플링도 함께 생깁니다. 오디오 시스템에 접근하는 모든 곳에서 AudioSystem이라는 구체 클래스뿐 아니라, 정적 클래스 또는 싱글턴이라는 접근 메커니즘까지 직접 참조합니다.
물론 사운드를 출력하려면 어느 정도의 커플링은 피할 수 없습니다. 하지만 오디오를 구현한 구체 클래스에 바로 접근하게 만드는 것은 우편물 하나 받겠다고 수많은 이방인에게 집 주소를 알려주는 것과 다를 바 없습니다. 개인정보가 너무 노출될 뿐 아니라 이사라도 가면 모두에게 새 주소를 알려야 합니다.
전화번호부라는 더 좋은 방법이 있습니다. 연락처가 필요하면 이름으로 주소를 찾습니다. 이사할 때 전화국을 통해 전화번호부를 갱신하면 사람들이 새 주소를 찾을 수 있습니다. 꼭 진짜 주소일 필요도 없습니다. 우편 사서함이나 다른 ‘대리’ 주소를 써도 됩니다. 호출하는 쪽이 전화번호부를 통해 우리를 찾게 하면 우리를 찾는 방법을 한곳에서 편리하게 관리할 수 있습니다.
이것이 서비스 중개자 패턴의 핵심입니다. 서비스를 사용하는 코드가 서비스가 누구인지, 즉 서비스를 구현한 구체 클래스의 자료형이 무엇인지, 그리고 어디에 있는지, 즉 클래스 인스턴스를 어떻게 얻는지 몰라도 되게 합니다.

16.3. 패턴

서비스(service) 는 여러 기능을 추상 인터페이스로 정의합니다. 구체 서비스 제공자(service provider) 는 이 인터페이스를 상속해 구현합니다. 이와 별도인 서비스 중개자(service locator) 는 제공자의 실제 자료형과 등록 과정을 숨기면서 적절한 제공자를 찾아 서비스에 접근할 수 있게 합니다.
💡
역할을 나누어 보기 — 사용자 코드는 ‘사운드를 재생한다’는 약속만 압니다. 중개자는 지금 어떤 구현을 제공할지 알고, 실제 오디오 구현이 하드웨어 작업을 수행합니다. 구현을 교체하는 결정이 호출부마다 퍼지지 않도록 모으는 것입니다.
flowchart LR
  A["게임 코드"] --> B["Locator: 서비스 찾기"]
  B --> C["Audio 인터페이스"]
  C --> D["ConsoleAudio: 실제 재생"]
  C --> E["NullAudio: 아무 동작 없음"]
Mermaid
복사

16.4. 언제 쓸 것인가?

무엇이든 프로그램 어디서나 접근할 수 있게 하면 문제가 생기기 쉽습니다. 싱글턴 패턴(6장)의 가장 큰 문제도 이것이었고, 서비스 중개자도 마찬가지입니다. 절제해서 사용하는 편이 좋습니다.
접근해야 할 객체가 있다면 전역 메커니즘 대신 필요한 객체를 인수로 전달할 수 없는지부터 생각해 보세요. 아주 쉬우면서도 커플링을 명확히 보여주는 방법입니다. 대부분은 이것으로 충분합니다.
하지만 직접 객체를 전달하는 방식이 불필요하거나 오히려 읽기 어렵게 만들 때도 있습니다. 로그나 메모리 관리 시스템이 모듈의 공개 API에 포함되어서는 안 됩니다. 렌더링 함수의 매개변수에는 렌더링과 관련된 것만 있어야지 로그 같은 것이 섞이면 곤란합니다.
또 어떤 시스템은 본질적으로 하나뿐입니다. 대부분의 게임 플랫폼에는 오디오나 디스플레이 시스템이 하나만 있습니다. 이런 환경적 특성을 10겹의 메서드 계층을 거쳐 가장 깊숙한 함수까지 전달하면 쓸데없이 복잡성만 늘어납니다.
이럴 때 서비스 중개자가 도움이 됩니다. 뒤에서 보겠지만 더 유연하고 설정하기 좋은 싱글턴 패턴입니다. 잘 사용하면 런타임 비용을 거의 들이지 않고도 코드를 훨씬 유연하게 만들 수 있습니다.
원주 1 반대로 잘못 사용하면 싱글턴 패턴의 나쁜 점을 전부 가지면서 실행 성능까지 떨어집니다.

16.5. 주의사항

서비스 중개자에서 가장 어려운 부분은 두 코드가 결합되는 의존성의 결정을 런타임까지 미룬다는 것입니다. 유연성을 얻지만, 코드만 보고는 어떤 의존성을 사용하는지 알기 어렵다는 비용이 생깁니다.

서비스가 실제로 등록되어 있어야 합니다

싱글턴이나 정적 클래스에서는 인스턴스가 항상 준비되어 있습니다. 코드를 호출할 때 그 객체가 있다는 것이 보장됩니다. 하지만 서비스 중개자는 서비스 객체를 등록해야 하므로 필요한 객체가 없을 때를 대비해야 합니다. 이를 어떻게 해결하고 필요한 서비스를 어떻게 항상 제공할지에 대한 전략은 뒤에서 다룹니다.

서비스는 누가 자기를 가져다 쓰는지 모릅니다

중개자에 전역으로 접근할 수 있으므로 모든 코드가 서비스를 요청하고 접근할 수 있습니다. 즉, 서비스는 어느 환경에서나 문제없이 동작해야 합니다. 어떤 클래스를 게임 루프의 시뮬레이션 중에만 사용해야 하고 렌더링 중에는 사용하면 안 된다면 서비스로 적합하지 않습니다. 정확히 정해진 곳에서만 실행된다고 보장할 수 없기 때문입니다. 특정 상황에서만 실행되어야 하는 클래스라면 전체 코드에 노출하는 서비스 중개자를 적용하지 않는 편이 안전합니다.

16.6. 예제 코드

이벤트 큐(15장)에서 다룬 오디오 시스템으로 돌아갑시다. 이번에는 서비스 중개자를 통해 다른 코드에 오디오 시스템을 제공하겠습니다.

서비스

오디오 서비스가 제공할 인터페이스인 오디오 API부터 시작합니다.
class Audio { public: virtual ~Audio() {} virtual void playSound(int soundID) = 0; virtual void stopSound(int soundID) = 0; virtual void stopAllSounds() = 0; };
C++
복사
실제 오디오 엔진은 훨씬 복잡하겠지만 이 정도로도 기본 아이디어를 이해할 수 있습니다. 중요한 점은 이 클래스가 추상 인터페이스일 뿐, 실제 구현은 바인딩되어 있지 않다는 것입니다.

서비스 중개자

오디오 인터페이스만으로는 아무것도 할 수 없으므로 구체 클래스를 구현해야 합니다. 이 책은 특정 게임 콘솔의 오디오 코드를 만드는 법을 다루지 않기 때문에 실제 코드는 함수 본체 안에 있다고 가정합니다. 그래도 내용을 이해하는 데는 문제가 없습니다.
class ConsoleAudio : public Audio { public: virtual void playSound(int soundID) { // 콘솔의 오디오 API를 이용해 사운드를 출력한다... } virtual void stopSound(int soundID) { // 콘솔의 오디오 API를 이용해 사운드를 중지한다... } virtual void stopAllSounds() { // 콘솔의 오디오 API를 이용해 모든 사운드를 중지한다... } };
C++
복사
인터페이스와 구현 클래스가 준비되었습니다. 이제 둘을 연결하는 서비스 중개자를 보겠습니다.

단순한 중개자

가장 단순한 형태의 중개자 코드부터 살펴봅시다.
class Locator { public: static Audio* getAudio() { return service_; } static void provide(Audio* service) { service_ = service; } private: static Audio* service_; };
C++
복사
원주 2 이런 기법을 의존성 주입이라고 합니다. 처음에는 낯선 용어지만 알고 보면 간단합니다. 어떤 클래스가 다른 클래스에 의존한다고 합시다. 예제에서 Locator는 Audio 서비스 인스턴스가 필요합니다. 보통은 중개자가 서비스 인스턴스를 직접 생성하지만, 의존성 주입에서는 필요한 의존 객체를 외부 코드에서 주입합니다.
정적 함수 getAudio()가 중개 역할을 합니다. 어디서나 호출할 수 있는 이 함수는 다음처럼 Audio 서비스 인스턴스를 반환합니다.
Audio *audio = Locator::getAudio(); audio->playSound(VERY_LOUD_BANG);
C++
복사
Locator에 오디오 서비스를 등록하는 방법은 아주 단순합니다. 어느 곳에서도 getAudio()를 호출하기 전에 외부 코드에서 서비스 제공자를 등록하면 됩니다. 게임이 시작될 때 다음 코드를 실행합니다.
ConsoleAudio *audio = new ConsoleAudio(); Locator::provide(audio);
C++
복사
핵심은 playSound()를 호출하는 쪽이 Audio라는 추상 인터페이스만 알고 ConsoleAudio라는 구체 클래스는 전혀 모른다는 점입니다. Locator 역시 제공자의 구체 클래스와 결합되지 않습니다. 실제로 어떤 구체 클래스를 쓰는지는 서비스를 제공하는 초기화 코드만 압니다.
분리는 여기서 끝나지 않습니다. Audio 인터페이스 역시 중개자를 통해 여기저기서 접근된다는 사실을 모릅니다. Audio만 보면 일반적인 추상 상위 클래스입니다. 꼭 중개자 패턴용으로 만들지 않은 기존 클래스에도 이 패턴을 적용할 수 있습니다. 이는 서비스를 제공하는 클래스 자체의 형태에 영향을 주는 싱글턴 패턴(6장)과 정반대입니다.
💡
의존성이 사라지는 것은 아닙니다 — 호출부는 여전히 Audio 기능이 필요하고, 사용 전에 등록이 끝나 있어야 합니다. 바뀐 것은 구체 구현을 고르는 위치입니다. 중개자 자체와 서비스 등록 순서에 대한 의존성도 함께 관리하세요.

널 서비스 (Null Service)

지금 코드는 단순하면서 충분히 유연하지만 큰 단점이 하나 있습니다. 서비스 제공자를 등록하기 전에 서비스를 사용하려 하면 NULL을 반환합니다. 호출하는 쪽에서 NULL을 검사하지 않으면 프로그램이 중단됩니다.
원주 3 서로 다른 두 코드를 정해진 순서대로 실행해야 올바르게 동작하는 것을 시간적 결합(temporal coupling)이라고도 합니다. 한국어판 옮긴이는 『실용주의 프로그래머』(인사이트, 2014) 28장 ‘시간적 결합’을 참고 자료로 덧붙였습니다. 복잡한 소프트웨어에는 어느 정도 시간적 결합이 있기 마련이지만, 다른 커플링처럼 최대한 제거하는 편이 유지보수에 좋습니다.
여기서 사용할 수 있는 디자인 패턴이 널 객체(Null Object) 입니다. 객체를 찾거나 만들지 못해 NULL을 반환해야 할 때, 대신 같은 인터페이스를 구현한 특수 객체를 반환하는 것이 핵심입니다. 특수 객체에는 아무 동작도 구현하지 않지만, 받는 쪽은 ‘진짜’ 객체를 받은 것처럼 안전하게 작업을 진행합니다.
널 객체 패턴을 사용하려면 다음처럼 널 서비스 제공자를 정의합니다.
class NullAudio : public Audio { public: virtual void playSound(int soundID) { /* 아무것도 하지 않는다. */ } virtual void stopSound(int soundID) { /* 아무것도 하지 않는다. */ } virtual void stopAllSounds() { /* 아무것도 하지 않는다. */ } };
C++
복사
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_; };
C++
복사
원주 4 getAudio()가 포인터 대신 레퍼런스를 반환하도록 바뀌었습니다. C++ 레퍼런스는 이론적으로는 절대 NULL이 될 수 없으므로, 레퍼런스 반환은 사용하는 쪽이 항상 객체를 받는다고 기대해도 된다는 뜻입니다. 또한 NULL 검사는 getAudio()가 아니라 provide()에 있습니다. 대신 Locator가 기본값을 널 객체로 초기화하도록 initialize()를 먼저 호출해야 합니다. 분기를 provide()에 둘 수 있어 서비스를 찾을 때마다 발생할 CPU 낭비를 조금 줄입니다.
호출하는 쪽은 ‘진짜’ 서비스가 준비되어 있는지 신경 쓰거나 NULL 반환을 처리할 필요가 없습니다. Locator는 항상 유효한 객체를 반환한다고 보장합니다.
널 서비스는 의도적으로 특정 서비스를 찾지 못하게 하고 싶을 때도 유용합니다. 시스템을 잠시 사용하지 못하게 하려면 서비스를 등록하지 않으면 됩니다. 그러면 중개자는 기본값으로 널 객체를 반환합니다.
원주 5 개발 중 오디오 기능을 끄면 메모리와 CPU 사용량을 줄일 수 있습니다. 더 중요한 점은 큰 소리가 나는 순간 디버거 중단점이 걸려도 고막을 지킬 수 있다는 것입니다. 아침부터 20ms짜리 괴성을 최고 볼륨으로 한 프레임씩 끊어 듣기를 반복하면 피가 거꾸로 솟을 것입니다.
💡
초기화는 여전히 필요합니다 — 이 예제에서는 initialize()를 먼저 호출해야 service_가 널 객체를 가리킵니다. 널 서비스는 ‘동작하지 않아도 괜찮다’는 정책을 구현합니다. 반드시 있어야 할 서비스를 초기화하지 못한 오류까지 조용히 숨길지는 별도로 결정해야 합니다.

로그 데커레이터

이제 시스템이 꽤 안정되었습니다. 서비스 중개자와 함께 쓸 수 있는 또 다른 기법인 데커레이터로 감싼 서비스(decorated service)를 살펴보겠습니다.
개발 중에는 게임에서 무슨 일이 일어나는지 파악하기 위해 특정 이벤트가 발생할 때마다 로그를 남기곤 합니다. AI 프로그래머라면 개체의 AI 상태가 언제 바뀌는지 알고 싶을 수 있습니다. 사운드 프로그래머는 모든 사운드가 정확히 어떤 순서로 재생되는지 확인하고 싶을 것입니다.
보통은 코드 여기저기에 log() 호출을 넣어 해결합니다. 하지만 로그가 너무 많아지면 문제가 됩니다. AI 프로그래머는 사운드 로그에 관심이 없고, 사운드 프로그래머는 AI 로그에 관심이 없습니다. 두 로그가 함께 출력되면 서로에게 필요 없는 로그를 헤치며 원하는 내용을 찾아야 합니다.
필요한 로그만 선택해서 켜거나 끌 수 있고, 최종 빌드에서는 모든 로그를 없앨 수 있으면 좋겠습니다. 로그를 남길 시스템을 서비스로 제공하고 있다면, GoF의 데커레이터 패턴으로 이를 해결할 수 있습니다.
원주 6 『GoF의 디자인 패턴』에서는 ‘장식자 패턴’이라고 부릅니다(241쪽). — 옮긴이
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_; };
C++
복사
LoggedAudio는 다른 오디오 제공자를 감싸면서도 같은 Audio 인터페이스를 구현합니다. 실제 기능은 감싼 서비스에 맡기고, 모든 사운드 호출을 로그에 남깁니다. 로그 기능은 다음과 같이 켤 수 있습니다.
void enableAudioLogging() { // 기존 서비스를 데커레이트한다. Audio *service = new LoggedAudio(Locator::getAudio()); // 이 값으로 바꿔치기한다. Locator::provide(service); }
C++
복사
이제 모든 오디오 호출은 먼저 로그를 남긴 뒤 실제 오디오 기능을 실행합니다. 널 서비스를 감싸면 사운드를 출력하지 않으면서도 어떤 소리가 재생되었을지 로그로 확인할 수 있습니다.
💡
호출 경로를 따라가 보세요 — 게임 코드 → LoggedAudio → 실제 Audio 순서로 호출됩니다. 게임 코드는 계속 Audio만 사용하므로 바뀌지 않습니다. LoggedAudio가 보관한 레퍼런스의 대상은 데커레이터보다 오래 살아 있어야 하며, 예제에서 new로 만든 데커레이터를 언제 해제할지도 실제 엔진에서는 정해야 합니다.

16.7 디자인 결정

지금까지는 서비스 중개자의 전형적인 구현을 살펴봤습니다. 다음 질문에 어떻게 답하느냐에 따라 구현이 달라질 수 있습니다.

서비스는 어떻게 등록하는가?

외부 코드에서 등록한다
지금까지 예제에서 사용한 방식입니다. 저자가 본 프로젝트에서 가장 흔하게 사용한 방법이기도 합니다.
•
빠르고 간단합니다. getAudio()는 포인터를 반환하기만 합니다. 컴파일러가 이를 인라인하면, 실행 비용을 추가하지 않고 깔끔하게 추상화할 수 있습니다.
•
제공자를 만드는 방법을 제어할 수 있습니다. 로컬 게임과 온라인 게임에 서로 다른 컨트롤러 구현이 있다고 합시다. 온라인 컨트롤러는 입력을 네트워크로 전송하지만, 이를 사용하는 다른 코드는 로컬과 원격 플레이어를 구별하지 않아도 됩니다.
원격 제공자를 만들려면 원격 플레이어의 IP 주소가 필요합니다. 중개자가 직접 제공자를 만들 수 있을까요? 중개자는 IP 주소를 모릅니다. 심지어 원격 플레이어인지도 모르므로 생성자에 어떤 인수를 넘겨야 할지도 알 수 없습니다.
외부에서 서비스를 등록하면 이 문제를 해결할 수 있습니다. 네트워크 코드가 IP 주소를 넣어 온라인 컨트롤러를 만든 뒤 중개자에 등록하면 됩니다. 중개자는 서비스의 인터페이스만 알면서도 서비스를 연결해 줄 수 있습니다.
•
게임 실행 중에도 서비스를 교체할 수 있습니다. 출시 빌드에서는 필요하지 않을 수 있지만 개발과 테스트에 유용합니다. 오디오 서비스를 널 서비스와 서로 바꾸면 오디오 기능을 켜고 끌 수 있습니다.
•
중개자가 외부 코드에 의존합니다. 서비스를 사용하는 쪽은 다른 코드가 미리 등록했으리라고 기대합니다. 초기화가 잘못되면 게임이 죽거나 서비스를 이용하지 못할 수 있습니다.
컴파일할 때 바인딩한다
전처리기 매크로를 이용하면 컴파일할 때 서비스를 등록할 수도 있습니다.
class Locator { public: static Audio& getAudio() { return service_; } private: #if DEBUG static DebugAudio service_; #else static ReleaseAudio service_; #endif };
C++
복사
•
빠릅니다. 컴파일 시점에 일이 끝나므로 런타임에는 할 일이 없습니다. getAudio()를 인라인하면 가능한 한 빠르게 서비스에 접근할 수 있습니다.
•
서비스를 항상 이용할 수 있습니다. 중개자가 선택한 서비스를 직접 갖고 있습니다. 컴파일만 되었다면 서비스가 아직 준비되지 않았을까 봐 걱정하지 않아도 됩니다.
•
서비스를 바꾸기 어렵습니다. 컴파일할 때 연결되므로 바꾸려면 다시 컴파일하고 게임을 다시 실행해야 합니다.
런타임에 설정 값을 읽는다
기업용 애플리케이션에서 볼 법한 서비스 중개자입니다. 서비스가 필요하다고 요청하면, 중개자가 실행 중에 알맞은 구현을 마술처럼 찾아줍니다.
보통은 제공자를 지정한 설정 파일을 읽고, 리플렉션을 이용해 실행 중에 실제 제공자 클래스의 인스턴스를 만듭니다.
원주 7 일부 언어의 리플렉션 기능은 실행 중에 타입 시스템과 상호작용할 수 있게 해줍니다. 클래스 이름으로 클래스를 찾고 생성자를 호출해 객체를 만드는 것도 가능합니다. 리스프, 스몰토크, 파이썬 같은 동적 타입 언어는 이런 기능을 기본으로 갖고 있습니다. 정적 타입 언어인 C#과 자바에도 리플렉션 기능이 있습니다.
•
다시 컴파일하지 않고 서비스를 바꿀 수 있습니다. 컴파일할 때 정하는 방식보다 유연합니다. 다만 설정을 다시 읽으려면 게임을 다시 시작해야 하므로, 실행 중에 직접 서비스를 교체하는 방식보다는 유연하지 않습니다.
•
프로그래머가 아니어도 서비스를 바꿀 수 있습니다. 기획자가 소스를 수정하지 않고 게임 기능을 켜거나 끌 수 있습니다. 더 정확하게 말하면, 프로그래머가 기획자의 코드 수정을 달가워하지 않을 때 유용합니다.
•
같은 코드로 여러 설정을 지원할 수 있습니다. 서비스 등록을 코드에서 떼어냈기 때문입니다. 기업용 웹 애플리케이션에서는 동일한 프로그램을 서버마다 다른 설정으로 실행할 수 있다는 점이 중요합니다. 하드웨어가 표준화된 콘솔 게임에는 큰 이점이 아니었지만, 다양한 모바일 기기를 지원해야 하는 요즘에는 점점 유용해지고 있습니다.
•
복잡합니다. 설정 파일을 읽고 해석하며 서비스를 등록하는 장치가 필요합니다. 게임 플레이와 직접 관련 없는 기능에 이만큼 개발 시간을 써야 하는지 설득하기 어려울 수도 있습니다.
•
서비스를 등록하는 데 실행 시간이 듭니다. 캐시를 이용하면 비용을 최소화할 수 있지만, 최초로 서비스를 찾을 때에는 여전히 시간이 듭니다. 게임 개발자는 플레이어의 경험과 관련 없는 일에 CPU 시간을 쓰는 것을 무척 싫어합니다.

서비스를 못 찾으면 어떻게 할 것인가?

사용하는 쪽에서 처리한다
가장 간단한 방법은 책임을 호출자에게 넘기는 것입니다. 중개자는 서비스를 찾지 못하면 NULL을 반환합니다.
•
호출자가 대응 방법을 결정할 수 있습니다. 어떤 코드는 서비스를 찾지 못한 일을 심각하게 받아들여 게임을 끝낼 수도 있고, 어떤 코드는 무시하고 계속할 수도 있습니다. 중개자가 모든 서비스 이용자에게 맞는 정책을 정할 수 없다면 실패를 반환하고 각자 판단하게 할 수 있습니다.
•
호출자가 실패를 처리해야 합니다. 서비스를 가져올 때마다 결과를 확인해야 합니다. 거의 같은 오류 처리 코드가 코드베이스 곳곳에 반복됩니다. 단 한 곳에서라도 검사를 빠뜨리면 게임이 죽을 수 있습니다.
게임을 멈춘다
서비스를 항상 이용할 수 있다고 컴파일 시점에 보장할 수는 없더라도, 실행 중에는 반드시 존재해야 한다는 계약을 만들 수 있습니다. 가장 간단한 방법은 단정문을 사용하는 것입니다.
class Locator { public: static Audio& getAudio() { Audio* service = NULL; // 서비스를 찾는 코드가 들어간다... assert(service != NULL); return *service; } };
C++
복사
서비스가 없다면 사용자가 서비스에 접근하기 전에 게임을 멈춥니다. 단정문이 문제를 해결해 주지는 않지만, 책임 소재는 분명해집니다. 서비스를 찾지 못했다는 것은 중개자의 버그라는 뜻입니다.
원주 8 단정문이 낯설다면 싱글턴 패턴(6장)을 참고하세요.
•
사용하는 쪽에서 서비스 부재를 처리하지 않아도 됩니다. 서비스를 이용하는 곳이 많다면 상당한 코드를 줄일 수 있습니다. 중개자가 항상 서비스를 제공하겠다고 약속하므로, 이용자의 부담이 줄어듭니다.
•
서비스를 못 찾으면 게임이 멈춥니다. 예를 들어 초기화 코드가 제때 실행되지 않으면 게임을 실행할 수 없습니다. 문제를 빨리 고치도록 강제한다는 장점도 있지만, 담당자가 고칠 때까지 다른 팀원이 작업하지 못한다는 단점도 있습니다. 큰 팀에서는 프로그래머 전원이 한 사람의 수정을 기다려야 할 수도 있습니다.
💡
출시 빌드의 계약도 정하세요 — C/C++의 assert는 빌드 설정에 따라 제거될 수 있습니다. 이 예제를 실제 코드로 옮길 때에는 출시 빌드에서도 반드시 있어야 하는 서비스가 없다면 어떻게 처리할지 별도 정책을 정해야 합니다.
널 서비스를 반환한다
앞서 예제로 살펴본 방법입니다.
•
외부 코드에서 서비스 부재를 처리할 필요가 없습니다. 중개자가 항상 서비스 객체를 반환하므로, 단정문을 사용하는 방식처럼 호출 코드를 간단하게 유지할 수 있습니다.
•
서비스가 없어도 게임이 계속 실행됩니다. 장점과 단점이 모두 있습니다. 아직 준비되지 않은 기능 때문에 게임 전체가 멈추지 않는다는 것은 장점입니다. 특히 큰 팀에서는 아직 구현되지 않은 시스템에 의존하면서 개발해야 하는 일이 많습니다.
반면에, 있어야 할 서비스가 실수로 빠졌을 때는 디버깅이 어려워질 수 있습니다. 게임이 서비스에서 얻은 데이터를 바탕으로 판단한다면, 실제 서비스가 등록되지 않아 널 서비스가 쓰이는 순간 예상과 다른 동작을 할 수 있습니다. 원인이 필요한 서비스의 부재라는 사실을 알아내는 데 시간이 들 수 있습니다.
원주 9 디버그 빌드에서는 널 서비스가 사용될 때마다 로그를 남기면 원인을 찾는 데 도움이 됩니다.
저자가 본 코드에서는 이 세 방식 중 단정문을 가장 많이 사용했습니다. 게임을 출시할 때쯤이면 이미 충분히 테스트했고 실행할 하드웨어도 정해져 있으므로, 서비스를 찾지 못할 가능성이 낮기 때문입니다.
큰 팀에서 개발한다면 널 서비스를 권합니다. 구현이 간단하고, 서비스를 아직 쓸 수 없어도 다른 작업을 계속할 수 있습니다. 서비스에 버그가 있거나 다른 기능을 개발하는 데 방해된다면 잠시 꺼둘 수도 있습니다.

서비스의 범위를 어떻게 정할 것인가?

지금까지는 어디서든 서비스를 사용할 수 있다고 가정했습니다. 서비스 중개자는 보통 그렇게 쓰이지만, 특정 클래스와 그 하위 클래스에서만 이용하도록 제한할 수도 있습니다.
class Base { // 서비스를 찾아 service_에 등록하는 코드... protected: // 서비스를 제공하는 정적 메서드는 하위 클래스에서만 접근할 수 있다. Audio& getAudio() { return *service_; } private: static Audio* service_; };
C++
복사
이렇게 하면 Base를 상속한 클래스만 오디오 서비스를 이용할 수 있습니다. 범위를 정할 때의 장단점을 살펴보겠습니다.
전역에서 접근할 수 있게 한다
•
코드 전체가 같은 서비스를 사용합니다. 보통 서비스는 하나면 충분합니다. 모두가 같은 서비스를 사용하게 하고, ‘진짜’ 제공자를 숨겨두면 여기저기서 제공자를 새로 만드는 일을 막을 수 있습니다.
원주 10 서비스 인터페이스가 공개되어 있다면 다른 구현 클래스를 만들어 서비스를 둘 이상 만드는 것은 여전히 가능합니다. 여기서는 어디서나 접근할 수 있는 서비스가 이미 있으니 단위 테스트용 목 객체 같은 예외가 아니면 굳이 다른 서비스를 만들 이유가 없다는 뜻입니다. — 옮긴이
•
서비스가 언제 어디서 쓰이는지 통제할 수 없습니다. 전역 접근을 허용하는 데 따르는 대가입니다. 싱글턴 패턴(6장)에서 다룬 전역 상태의 문제들을 참고하세요.
특정 클래스에서만 접근할 수 있게 한다
•
결합을 제어할 수 있습니다. 가장 큰 이점입니다. 정해진 클래스의 하위 클래스만 서비스에 접근하므로, 다른 시스템은 이 서비스와 분리할 수 있습니다.
•
일이 중복될 수 있습니다. 서로 관련 없는 두 클래스가 같은 서비스를 써야 한다면, 각각 서비스에 대한 참조를 보관해야 합니다. 서비스를 찾고 등록하는 코드도 중복될 수 있습니다. 공통 상위 클래스를 만들 수도 있지만, 얻는 것보다 잃는 것이 많을 수 있습니다.
저자는 특정 영역에서만 사용하는 서비스라면 한 클래스로 접근을 제한하는 편을 좋아합니다. 예를 들어 네트워크 서비스는 온라인 관련 클래스만 사용하게 할 수 있습니다. 반대로 로그처럼 폭넓게 쓰이는 서비스는 전역에서 접근할 수 있게 합니다.

16.8 관련 자료

•
서비스 중개자는 여러 면에서 싱글턴 패턴(6장)과 비슷합니다. 필요한 것이 무엇인지에 따라 알맞은 쪽을 선택하세요.
•
유니티의 GetComponent()는 서비스 중개자 패턴과 컴포넌트 패턴(14장)을 함께 사용합니다.
•
마이크로소프트 XNA의 핵심 Game 클래스에는 서비스 중개자가 들어 있습니다. Game.Services 속성으로 어떤 서비스든 등록할 수 있습니다.