이론/디자인패턴

서비스 중개자

tsyang 2022. 1. 16. 12:37

개요


중개자 패턴은 서비스를 구현한 구체 클래스를 숨긴 채로 어디에서나 서비스에 접근할 수 있게 해 준다.

 

프로그램에서 메모리 할당, 로그, 난수 생성 등의 시스템은 프로그램 전체에서 사용하는 서비스라고 볼 수 있다. 

 

게임의 경우 오디오 시스템 역시 일종의 서비스일 것이다. UI, 물리엔진, 캐릭터 등 여러 곳에서 오디오를 호출한다. 이러한 경우 정적 클래스나 싱글턴을 사용할 수 있는데 이런 방법은 편한 대신 강한 커플링이 생긴다. (커플링이 생기는 것을 피할 수 없기는 하다.)

 

전화번호부를 생각해보자. 자신이 전화번호를 바꿨다고 해서 내 번호를 사용하는 모든 사람들에게 전화번호가 바뀌었다고 알리는 편보다 전화번호부에서 자신의 전화번호를 수정하는 편이 훨씬 더 간단하다. 호출하는 쪽에서는 전화번호부에서 내 이름을 찾아 전화를 걸면 된다.

 

이런 방식이 중개자 패턴의 핵심이다. 이 패턴은 서비스를 사용하는 코드가 서비스가 무엇이고 어디에 있는지 몰라도 되게 해 준다. 


언제 쓸 거?

무엇이든지 프로그램 어디에서나 접근할 수 있게 하면 문제가 생기기 쉽다. (ex. 싱글턴 패턴, 전역 변수) 중개자 패턴도 마찬가지이므로 절제해서 사용해야 한다. 그럼에도 오디오 시스템이나 로그, 메모리 관리 코드는 중개자 패턴이 도움을 줄 수 있다. 중개자 패턴은 유연하고 설정하기 좋은 싱글턴 패턴이다. 잘만 사용하면 적은 런타임 비용으로 유연한 코드를 만들 수 있다.

 


 

 

구현


서비스(Service)는 추상 인터페이스로 정의한다. 서비스 제공자(Provider)는 이런 서비스 인터페이스를 상속받는 구체이다. 여기에 서비스 중개자(Locator)는 서비스 제공자의 실제 자료형과 이를 등록하는 과정을 숨긴 채 적절한 서비스 제공자를 찾아 서비스를 제공한다.

 

//서비스
class Audio
{
public:
	virtual ~Audio(){}
	virtual void playSound(int id) = 0;
};


//서비스 제공자
class ConsoleAudio : public Audio
{
public:
	virtual void playSound(int id) override
	{
		//콘솔의 오디오 API를 이용해 사운드를 출력
	}
};


//서비스 중개자
class ServiceLocator
{
public:
	static Audio & getAudio()
	{
		return *audioService_;
	}

	//외부 코드에서 서비스를 등록해준다. (의존성 주입)
	static void provideAudio(Audio * service)
	{
		audioService_ = service;
	}

private:
	static Audio* audioService_;
};

 

이렇게 되면 playSound를 호출하는 쪽이나 중개자 클래스에서는 ConsoleAudio라는 구체 클래스를 몰라도 된다. 또한 어떤 클래스도 서비스 중개자 패턴을 적용할 수 있다. 이런 점은 '서비스'를 제공하는 클래스의 형태 자체에 영향을 미치는 싱글턴 패턴과 정 반대이다.

 

서비스가 등록되지 않은 상태에서 playSound를 호출하는 경우 에러가 날 수 있는데, 이런 경우 Audio를 구현했지만 아무것도 하지 않는 널 객체를 사용하면 된다.

 


with 데커레이터

서비스 중개자 패턴을 이용하면 데커레이터를 활용하기도 쉬워진다. 예를 들어, Audio 클래스의 동작을 로그로 남기는 LoggedAudio 데커레이터 클래스가 있다고 하자.

class LoggedAudio : public Audio
{
public:
	LoggedAudio(Audio & wrapped) : wrapped_ (wrapped) {}
	
	void playSound(int id) override
	{
		log("사운드 재생");
		wrapped_.playSound(id);
	}

private:
	void log(const std::string & message){}

	Audio & wrapped_;
};

 

만약 사운드 서비스에 로그 기능을 추가하고 싶다면 다음과 같이 코드를 작성하면 된다.

 

//오디오에 로그 기능을 추가한다.
void enableAudioLogging()
{
	//기존 오디오 서비스를 데커레이트 한다.
	auto audioSerivce = new LoggedAudio(ServiceLocator::getAudio());

	//새로운 오디오 서비스로 바꿔준다.
	ServiceLocator::provideAudio(audioSerivce);
}

 


 

디자인 결정


서비스 어떻게 등록?

 

외부 코드에서 등록

위의 예제 코드와 같은 방법으로, 가장 일반적인 방법이다.

  • 빠르고 간단하다.
  • 런타임에 객체를 만들어 넘겨줄 수 있다. 
  • 게임 실행 도중에 서비스를 교체할 수 있다.
  • 서비스 중개자가 외부 코드에 의존하게 된다.

 

컴파일할 때 등록

class Locator
{
public:
	static Audio& getAudio() { return service_;}
	
private:
#if DEBUG
	static DebugAudio service_;
#else
	static ReleaseAudio service_;
#endif
};
  • 빠르다. 컴파일할 때 이미 등록이 끝나 있기 때문.
  • 서비스는 항상 사용 가능하다. 서비스 등록이 안 되어있는 경우를 고려하지 않아도 된다.
  • 서비스를 변경하려면 컴파일을 다시 해야 한다.

 

 

런타임에 값 읽기

리플렉션 등을 이용해서 설정 파일을 통해 원하는 서비스 제공자 클래스를 생성한다. 

  • 컴파일 없이 서비스 교체가 가능하다.
  • 스크립트로 제어가 가능하다.
  • 등록 과정이 코드에서 완전히 빠졌기 때문에 하나의 코드로 여러 설정을 지원할 수 있다. 요즘은 다양한 디바이스가 생기면서 이 방식이 더욱 적합해지고 있다.
  • 복잡하고 느리다. 파일을 로딩하고 파싱 하고... 하는 과정이 필요하다.

서비스 없으면?

사용자가 알아서 처리

가장 간단한 방법으로 서비스를 찾지 못하면 NULL을 반환하고 사용자가 알아서 하게 한다.

  • 실패했을 때 사용자가 어떻게 처리할지 정할 수 있다. 중개자가 모든 경우에 일반적인 정책을 정할 수 없는 경우 사용자가 알아서 하게 한다.
  • 서비스 사용자 쪽에서 실패를 처리해야 한다. 따라서 굉장히 많은 중복 코드(null 체크 같은)가 퍼지게 된다. 어쩌다가 코드를 빼먹으면 크래시가 생길 수 있다.

 

게임을 멈춘다

없는 서비스를 얻어 오려는 것을 명백한 버그로 간주해서 단언문(assert)을 추가한다.

  • 사용자가 서비스 없는 경우를 고려하지 않아도 된다. 
  • 서비스가 없으면 게임이 중단된다. 이 경우 버그가 잘 보이게 됨으로 빠른 수정이 가능하지만 수정이 될 때까지 다른 프로그래머들이 불편을 겪게 된다. (리모트에 푸시된 경우..)

 

널 서비스 반환

앞의 예제에서 처럼 서비스가 없는 경우 아무것도 하지 않는 널 객체를 반환한다.

  • 외부 코드에서 서비스가 없는 경우를 처리하지 않아도 된다.
  • 서비스가 없을 때도 게임을 계속할 수 있다. 따라서 서비스가 없어도 다른 프로그래머들이 작업을 하는데 문제가 없다. (따라서 규모가 큰 프로젝트에서 유리함)
  • 서비스가 없을 때 그 사실을 알기가 어렵다. 

 

어차피 게임이 출시될 쯤이면 많은 테스트를 통과했기 때문에 서비스가 없는 상황이 발생할 가능성은 극히 적다. 이 점을 염두에 두고 디자인을 정하는 것이 좋다. (규모가 큰 프로젝트는 널 서비스가 추천되고, 일반적으로는 게임을 멈추는 방법을 많이 씀)


서비스 범위는?

보통은 어디에서나 중개자를 통해 서비스에 접근할 수 있도록 한다. 그러나 다음과 같이 특정 클래스를 상속한 그룹에서만 접근을 할 수 있도록 제한할 수 있다.

class Base
{
public:
	//이런 식으로 적당히 서비스를 찾아 등록해준다.
	void init()	{service_ = new ConsoleAudio();	}

//하위 클래스에서만 접근이 가능하다.
protected:
	Audio& getAudio(){return *service_;}

private:
	static Audio* service_;
};

 

전역 접근

  • 전체 코드에서 같은 서비스를 쓴다. 서비스가 단 한 개만 존재하게 된다.
  • 전역 접근의 일반적인 단점을 가진다. 언제 어디에서 서비스가 사용되는지 제어할 수 없다.

 

특정 클래스 제한

  • 커플링을 제어할 수 있다. 
  • 중복 작업이 생길 수 있다. 상관없는 클래스에서 같은 서비스를 사용해야 한다면 각자 그 서비스를 참조하고 등록하는 작업을 해줘야 한다.

 

서비스가 특정 분야에 한정되어 있다면 하나의 클래스로 접근 범위를 좁히는 편이 유리할 수 있다. (ex. 네트워크 서비스) 그러나 오디오나 로그같이 다양한 곳에서 사용하는 서비스는 전역에 두는 편이 유리하다.

 


주의사항

  1. 코드만 봐서 어떤 의존성을 사용하는지 알기 어려움.
  2. 서비스가 실제로 등록되어 있어야 함. 싱글턴이나 정적 클래스는 인스턴스가 준비되어 있다는 것을 어느 정도 보장할 수 있으나 서비스 중개자 패턴은 직접 객체를 등록해야 하기 때문에 찾는 서비스가 없을 때를 대비해야 한다. (위의 디자인 결정 참고)
  3. 누가 서비스를 사용하는지 알기 어려움. 서비스는 기본적으로 전체 코드에 노출된다. 따라서 서비스는 어떤 코드에서나 문제없이 작동해야 한다. 

 


 

etc

유니티 프레임 워크에서 제공하는 GetComponent 메서드에서 컴포넌트를 반환하는 것도 일종의 서비스 중개자 패턴이다.

 

 


참고 : 로버트 나이스트롬, 게임 프로그래밍 패턴, 한빛미디어, [303~318p]

 

'이론 > 디자인패턴' 카테고리의 다른 글

게임 루프  (1) 2022.01.31
이중 버퍼  (0) 2022.01.26
이벤트 큐 패턴  (1) 2022.01.03
컴포넌트 패턴  (1) 2021.12.25
디자인 패턴 - 행동패턴  (0) 2021.12.12