Service Locator
- 서비스를 구현하는 구체적인 클래스에 사용자를 커플링하지 않고, 어디에서나 서비스에 접근할 수 있게한다.
동기
- 거의 모든 코드에서 사용되는 것들.
- 메모리 할당, 로그, 난수 생성
- 일종의 서비스
싱글턴 등의 사용 => 강한 커플링
ex. 오디오 시스템은 다음과 같이 호출할 수 있어야함.
1 2 3 4 5
// Use a static class? AudioSystem::playSound(VERY_LOUD_BANG); // Or maybe a singleton? AudioSystem::instance()->playSound(VERY_LOUD_BANG);
이는 강한 커플링도 생기게 한다.
오디오를 구현한 구체 클래스를 바로 접근할 수 있게 하는건, 우편 물 하나 받겠다고 수많은 이방인에게 집 주소를 알려주는 것과 같음.
- 개인정보가 너무 노출됨.
- 주소가 바뀌면 모두에게 바뀐 주소를 알려줘야함.
- 전화번호부가 있으면, 이름과 주소를 찾을 수 있음.
- 호출하는 쪽에서 전화번호부를 통해서 찾게 함으로써, 우리를 찾을 방법을 한곳에서 편리하게 관리할 수 있다.
- 핵심: 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스 자료형이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해줌.
The pattern
- 서비스(service): 여러 기능을 추상 인터페이스로 정의
- 구체적인 서비스 제공자(service provider): 인터페이스를 상속받아 구현
- 서비스 중개자(service locator): 서비스 제공자의 실제 타입과 이를 등록하는 과정은 숨긴채 적절한 서비스 제공자를 찾아 서비스에 대한 접근을 제공.
언제 사용?
싱글턴 패턴에서의 문제와 마찬가지로 절제해서 사용하는 게 좋다.
접근해야 할 객체가 있다면 전역 메커니즘 대신, 필요한 객체를 인수로 넘겨줄 수는 없는지부터 생각해야함.
- 이 방법은 커플링을 명확하게 보여줌
- 하지만, 넘기는 방식이 불필요하거나, 코드를 읽기 어렵게 하기도 한다.
- 또한 로그나 메모리 관리 같은 시스템이 모듈의 공개 API에 포함되어 있어선 안 된다.
- 어떤 시스템은 본질적으로 하나임.
- 오디오, 디스플레이 등.
- 이를 깊숙이 들어있는 함수에 전달하는 것은 복잡성만 증가시킴
- 이 패턴은 더 유연하고 더 설정히기 좋은 싱글턴 패턴이다.
- 잘만 사용하면, 런타임 비용은 거의 들이지 않고도 코드를 훨씬 유연하게 만들 수 있다
반대로 잘못 사용하면 싱글턴 패턴의 나쁜 점은 전부 있으면서, 실행 성능까지 떨어진다.
- 잘만 사용하면, 런타임 비용은 거의 들이지 않고도 코드를 훨씬 유연하게 만들 수 있다
주의사항
- 두 코드가 커플링되는 의존성을 런타임 시점까지 미루는 부분이 어려움.
- 유연성은 얻을 수 있지만, 코드만 봐서는 어떤 의존성을 사용하는지를 알기 어려움.
서비스가 실제로 등록되어 있어야함
- 싱글턴, 정적 클래스는 항상 인스턴스가 준비되어 있음.
- 서비스 중개자 패턴에서는 서비스 객체를 등록해야 하기 때문에, 필요한 객체가 없을 때를 대비해야 한다.
서비스는 누가 자기를 가져다 놓는지 모른다
서비스 중개자 == 전역에서 접근 가능.
- 모든 코드에서 서비스를 요청하고 접근할 수 있음.
- 즉, 서비스는 어느 환경에서나 문제없이 동작해야한다.
- 서비스는 정확히 정해진 곳에서만 실행되는 걸 보장할 수 없음.
- 어떤 클래스가 특정 상황에서만 실행되어야 한다면, 전체 코드에 노출되는 이 패턴을 적용하지 않는게 안전함.
예제 코드
- 이벤트 큐에서 다룬 오디오 시스템 문제로 돌아가보자.
- 이번에는 서비스 중개자를 통해서 오디오 시스템을 제공하게 할것.
The service
- 오디오 API부터 시작.
1
2
3
4
5
6
7
8
class Audio
{
public:
virtual ~Audio() {}
virtual void playSound(int soundID) = 0;
virtual void stopSound(int soundID) = 0;
virtual void stopAllSounds() = 0;
};
- 위와 같이 간단히 인터페이스를 구현할 수 있다.
The service provider
- 인터페이스를 구체적인 클래스를 구현해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ConsoleAudio : public Audio
{
public:
virtual void playSound(int soundID)
{
// Play sound using console audio api...
}
virtual void stopSound(int soundID)
{
// Stop sound using console audio api...
}
virtual void stopAllSounds()
{
// Stop all sounds using console audio api...
}
};
A simple locator
의존성 주입: Locator 클래스는 Audio 서비스의 인스턴스가 필요하다. 보통은 중개자가 서비스 인스턴스를 직접 생성해준다. 이를 의존성 주입에서는 특정 객체가 필요로 하는 의존 객체를 외부 코드에서 주입해준다고 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Locator
{
public:
static Audio* getAudio() { return service_; }
static void provide(Audio* service)
{
service_ = service;
}
private:
static Audio* service_;
};
- 정적인 함수
getAudio()
가 중개 역할을 한다.
1
2
Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);
- Locator가 오디오 서비스를 ‘등록하는’ 방법은 굉장히 단순하다.
- 게임이 시작될 때 외부 코드에서 등록
1
2
ConsoleAudio *audio = new ConsoleAudio();
Locator::provide(audio);
playSound()
를 호출하는 쪽에서는 Audio라는 추상 인터페이스만 알 뿐 구체 클래스에 대해서는 전혀 모름.구현된 클래스가 실제로 사용되는지는 서비스를 제공하는 초기화 코드에서만 알 수 있다.
또한 Audio 인터페이스도 자기가 서비스 중개자를 통해서 여기저기로부터 접근된다는 사실을 모른다.
꼭 서비스 중개자 패턴용으로 만들지 않은 기존 클래스에도 이 패턴을 적용할 수 있다.
- 싱글턴과 정반대(‘서비스’를 제공하는 클래스의 형태 자체에 영향을 미침)
널 서비스
위 예제의 단점
- 서비스 등록 전 서비스 사용 시도 => NULL 반환
- 호출하는 쪽에서 NULL을 검사하지 않으면 크래시 일어남.
시간적 결합(temporal coupling): 두 가지 다른 코드를 정해진 순서대로 실행해야만 제대로 동작하는것. 제거하는것이 유지보수하기 좋다.
널 객체 패턴을 사용할 수 있음
- NULL 반환 대신 인터페이스를 구현한 특수한 객체를 반환
1
2
3
4
5
6
7
class NullAudio: public Audio
{
public:
virtual void playSound(int soundID) { /* Do nothing. */ }
virtual void stopSound(int soundID) { /* Do nothing. */ }
virtual void stopAllSounds() { /* Do nothing. */ }
};
NullAudio는 Audio 서비스 인터페이스를 상속받지만 아무 기능도 하지 않는다.
Locator 클래스를 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Locator
{
public:
static void initialize() { service_ = &nullService_; }
static Audio& getAudio() { return *service_; }
static void provide(Audio* service)
{
if (service == NULL)
{
// Revert to null service.
service_ = &nullService_;
}
else
{
service_ = service;
}
}
private:
static Audio* service_;
static NullAudio nullService_;
};
getAudio()가 서비스를 포인터가 아닌 레퍼런스로 반환하게 바뀜
C++에서 레퍼런스는 절대 NULL이 될 수 없음, 레퍼런스를 반환한다는 것은 코드를 사용하는 쪽에서 항상 객체를 받을 수 있다고 기대해도 된다는것.
초기화 함수를 먼저 호출해야함, NULL 검사 분기문을 provide에 둘 수 있어서 서비스에 접근할 때마다 생길 수 있는 CPU 낭비를 조금 줄일 수 있다.
호출하는 쪽에서 진짜 서비스가 준비되어 있는지를 신경 쓰지 않아도 되고 NULL 반환 처리도 필요 없다.
Locator는 항상 유효한 객체를 반환한다는 점을 보장한다.
널 서비스는 의도적으로 특정 서비스를 못 찾게 하고 싶을 때에도 유용하다.
개발하는 동안 오디오 기능을 끌 수 있음.
로그 데커레이터
서비스 중개자 패턴을 활용한 ‘데커레이션으로 감싼 서비스(decorated service)’
로그를 남겨 무슨 일이 벌어지는지 확인하고 싶은 경우.
- log()함수를 코드 여기저기 집어넣어야한다.
- 이러다 로그가 너무 많아지는 문제가 발생한다.
- 원하는 로그만 볼 수 있게해야하고, 최종 빌드에는 로그를 전부 제거하는 것이 이상적이다.
- 조건적으로 로그를 남기고 싶은 시스템이 서비스로 노출되어 있다면 GoF의 데커레이터 패턴(장식자 패턴)으로 해결할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class LoggedAudio : public Audio
{
public:
LoggedAudio(Audio &wrapped)
: wrapped_(wrapped)
{}
virtual void playSound(int soundID)
{
log("play sound");
wrapped_.playSound(soundID);
}
virtual void stopSound(int soundID)
{
log("stop sound");
wrapped_.stopSound(soundID);
}
virtual void stopAllSounds()
{
log("stop all sounds");
wrapped_.stopAllSounds();
}
private:
void log(const char* message)
{
// Code to log message...
}
Audio &wrapped_;
};
- LoggedAudio 클래스는 다른 오디오 서비스 제공자를 래핑하는 동시에 같은 인테페이스를 상속받는다.
로그를 남기면서, 실제 기능 요청은 참조하는 서비스에 전달한다.
- 다음과같이 로그 기능을 킬 수 있다.
1
2
3
4
5
6
7
8
void enableAudioLogging()
{
// Decorate the existing service.
Audio *service = new LoggedAudio(Locator::getAudio());
// Swap it in.
Locator::provide(service);
}
- 사운드를 비활성화 해서 로그만을 보고 확인할 수 있다.
디자인 결정
- 핵심적인 질문들을 고려해 다양하게 달라질 수 있다.
서비스는 어떻게 등록되는가?
외부 코드에서 등록
- 예제가 이에 해당.
가장 일반적인 방법
- 빠르고 간단: getAudio()는 단순히 반환만, 이는 컴파일러에서 인라인으로 성능 손해 없이 깔끔한 추상 계층을 둘 수 있다.
- 서비스 제공자를 어떻게 만들지 제어할 수 있다:
- 온라인용, 로컬용 입력
- 온라인용: 네트워크를 통해 반대편에 전달 가능, 다른 코드는 구별못함, Locator는 IP 주소 같은 것을 알아야함. Locator는 다른 유저 IP 주소는 커녕 원격인지도 모름, 어떤 값을 서비스 제공자에게 전달해야하는지 모름
- 외부에서 제공: 게임 네트워크 코드에서 온라인용 서비스 제공자 객체를 IP주소와 함께 생성한 뒤에 서비스 중개자에 전달하면 됨.
- 이러면 서비스 중개자도 문제없이 온라인 플레이 서비스를 중개할 수 있다.
- 게임 실행 도중에 서비스를 교체할 수 있다
- 개발중 서비스를 끄는 등 활용
- 서비스 중개자가 외부 코드에 의존한다는 단점이 있다
- 초기화가 제대로 안된 상태 == 크래시 위험
컴파일할 때 바인딩
- 전처리기 매크로를 이용해 컴파일할 대 등록
1
2
3
4
5
6
7
8
9
10
11
12
class Locator
{
public:
static Audio& getAudio() { return service_; }
private:
#if DEBUG
static DebugAudio service_;
#else
static ReleaseAudio service_;
#endif
};
빠르다
- 모든 작업이 컴파일할 때 긑나기 때문에 런타임에 따로 할 일이 없다.
- 컴파일러가
getAudio()
를 인라인으로 바꾼다면 속도 측면에서도 좋음
서비스는 항상 사용가능:
- 선택된 서비스를 소유, 컴파일 후면 서비스 준비 완료
서비스를 쉽게 변경 불가능
- 재컴파일해야함.
런타임에 설정 값 읽기
기업용 소프트웨어 분야 쪽에서 보통 사용하는 방식
중개자는 요청받은 실제 구현을 런타임에 찾아냄.
일부 프로그래밍 언어에서는
리플렉션
을 이용해 런타임에 타입 시스템과 상호작용할 수있다, 이름만으로 클래스를 찾은 뒤에 생성자를 호출해 인스턴스를 생성할 수 있다.(리스프, 파이썬 같은 동적 타이핑 언어, C#, 자바)보통 설정 파일을 로딩한 뒤, 리플렉션으로 원하는 서비스 제공자 클래스 객체를 런타임에 생성.
- 다시 컴파일하지 않고도 서비스를 교체할 수 있다
- 유연하지만, 바꾼 설정 값을 적용하려면 게임 재시작해야함.
- 실행 도중에 서비스를 교체할 수 있는 방식보다는 덜 유연
프로그래머가 아니어도 서비스를 변경할 수 있다
- 등록 과정을 코드에서 완전히 빼냈기 때문에 하나의 코드로 여러 설정을 동시에 지원할 수 있다
- 설정만 건드리면, 여러 다른 서버 환경에서 사용 가능.
- 복잡하다
- 파일 로딩=>파싱, 서비스를 동록하는 설정 시스템을 만들어야함.
- 다른 방식보다 상당히 무거움
- 서비스 등록에서 시간이 걸린다는 단점
- 런타임에 설정 값을 사용하려면, 서비스를 등록하기 위해 CPU 사이클을 낭비해야한다.
- 캐시하면 이런 낭비를 최소화할 수 있을지 몰라도, 처음에는 시간 약간 소모됨
- 게임분야에서 게임 경험 향상과 상관없는 곳에서 CPU 사이클을 낭비하는 것은 지양됨
서비스를 못 찾으면 어떻게 할 것인가?
사용자가 알아서 처리하게 한다
- 실패했을 경우 어떻게 처리할지를 사용자가 정할 수 있음
- 서비스 사용자 쪽에서 실패를 처리해야함
- 호출하는 쪽에서 거의 같은 방식으로 오류를 처리하다 보면 굉장히 많은 중복 코드가 코드베이스에 퍼짐.
- 검사를 하나라도 제대로 하지 않으면 크래시가 생길 수 있음
게임을 멈춘다
- 단언문을 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Locator
{
public:
static Audio& getAudio()
{
Audio* service = NULL;
// Code here to locate service...
assert(service != NULL);
return *service;
}
};
- assert()는 서비스를 찾지 못하는 문제를 해결하지 못함.
- 누구에게 문제가 있는지를 분명하게 보여줌.
- 중개자에서 버그가 난 것을 분명히 알 수 있음.
- 사용자 측에서는 서비스가 없는 경우를 처리하지 않아도 된다.
- 하나의 서비스가 수많은 곳에서 사용될 수 있기 때문에, 코드를 굉장히 줄일 수 있다.
- 서비스를 찾지 못하면 게임이 중단됨
- 고치기 전까지 다른 팀원들이 아무것도 할 수없다.
- 규모가 큰 개발팀에선 다른 프로그래머들이 기다려야함
널 서비스를 반환한다
외부 코드에서는 서비스가 없는 경우를 처리하지 않아도 된다.
서비스를 찾지 못하면 게임이 중단된다.
- 장점이자 단점
- 준비되어 있지 않아도 게임을계속 진행할 수 있다는 점은 장점.
- 미구현된 다른 시스템에 의존할 때 특히 유리
- 의도치 않게 서비스를 찾지 못할 때에도 디버깅하기가 쉽지 않다는 점.
결론
- 단언문을 거는 형태가 제일 많이 사용됨.
- 서비스를 찾지 못할 가능성은 굉장히 작다.
- 규모가 큰 팀이면 널 서비스 추천
- 구현 쉬움, 서비스를 사용하지 못하는 동안 멈추지 않고 계속 개발 가능
- 서비스에 버그가 있더라도 다른 작업을 하는 동안 서비스가 귀찮게 굴면 쉽게 끌 수 있다.
서비스의 범위는 어떻게 잡을 것인가?
- 다음과 같이 특정 클래스 및 그 클래스의 하위 클래스에만 접근을 제한할 수있다.
1
2
3
4
5
6
7
8
9
10
11
class Base
{
// Code to locate service and set service_...
protected:
// Derived classes can use service
static Audio& getAudio() { return *service_; }
private:
static Audio* service_;
};
전역에서 접근 가능한 경우
- 전체 코드에서 같은 서비스를 쓰도록한다
- 보통 이런 서비스(오디오같은)는 단 하나만 존재하는게 맞다.
- 모든 코드에서 같은 서비스에 접근하게 하고, 제공자에 접근 제한하여 생성하지 못하도록 할 수 있다.
- 언제 어디에서 서비스가 사용되는지를 제어할 수 없다
- 전역으로 만들면서 생기는 비용
접근이 특정 클래스에 제한되면
- 커플링 제어 가능: 특정 클래스를 상속받는 클래스들에게만 제한 => 디커플링 유지
- 중복 작업을 해야할 가능성:
- 둘 이상의 서로 상관없는 클래스에서 같은 서비스에 접근해야한다면 각자 그 서비스를 참조해야 한다.
- 서비스를 찾거나 등록하는 작업을 이들 클래스에 대해 중복으로 해줘야 한다.
결론
- 게임 분야에서는 하나의 클래스로 접근 범위를 좁히는것이 저자가 선호하는 것.
- 네트워크 접근하기 위한 서비스 => 온라인 클래스에서만 사용하게
- 로그같은 다양한 곳에서 사용하는 서비스 => 전역에 둔다.
관련 자료
서비스 중개자 패턴: 싱글턴 패턴과 비슷
유니티 프레임워크에서
GetComponent()
에서 컴포넌트 패턴과 함께 서비스 중개자 패턴을 사용한다.마이크로소프트의 XNA 프레임워크의 핵심 클래스의 Game에 서비스 중개자 패턴이 포함되어있다.
- Game.Services 속성 => 어떤 종류의 서비스라도 등록 가능