디자인 패턴 3. 옵저버 패턴(Observer Pattern)

2020. 5. 5. 05:20Programming/Design Pattern

옵저버는 프로토스의 정찰기 유닛이 아니다

위키백과에서는 옵저버 패턴을 아래와 같이 설명하고 있습니다.

옵서버 패턴(observer pattern)은 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다. 주로 분산 이벤트 핸들링 시스템을 구현하는 데 사용된다. 발행/구독 모델로 알려져 있기도 하다. 

얘 아님

객체의 상태 변화를 관찰한다, 가 이 설명의 핵심이라 할 수 있는데요, 자비스도 아니고 어떻게 일개 프로그램 따위가 객체의 상태 변화를 관찰씩이나 할 수 있다는 것일까요? 바로 포켓몬 GO 구현을 예로 들어 알아보도록 합시다.

글 2개가 연속으로 포켓몬 소재이긴 한데, 사실 전 포켓몬을 그렇게 좋아하진 않습니다. 진짜에요.

이 글에서 설명할 포켓몬 GO의 실제 구현은 제가 설명하는 방식과 같지 않을 것입니다. 100% 장담할 수 있습니다. 어디까지나 '옵저버 패턴'을 이해할 수 있는 쉬운(?) 예시로서 각색된 것일 뿐이니 착오 없으시기 바랍니다.

포켓몬 GO를 만들어라

포켓몬 GO는, 실제 위치를 기반으로 플레이하는 증강 현실 게임입니다. 모바일 기기를 들고 돌아다니면서, 해당 지역에 나타나는 포켓몬들을 포획하거나, 체육관에 도전하는 등의 여러 가지 행위를 할 수 있습니다. 핵심은 바로 실제 위치 기반이라는 점입니다. 동대문에 나타난 포켓몬을 잡으려면 동대문에 가야하고, 포켓스탑을 통해 아이템을 지원 받으려면 근처의 포켓스탑까지 직접 걸어가야만 합니다. 이번 포스팅에서는 이 포켓몬 GO의 개발자의 관점에서 이야기를 해보고자 합니다.

우리는 지금 포켓몬 GO의 거리 화면을 개발하는 업무를 맡고 있습니다. 거리 화면은 아래와 같은 구성입니다.

  • 지도 - 증강 현실 게임이므로, 눈에 보이는 지도는 현실의 지도와 동일한 구성이어야 합니다. 거리 화면에 이 지도는 발판(?)으로 무조건 등장해야만 합니다.
  • 포켓몬 - 플레이어의 근처에는 포켓몬이 있을 수 있습니다. 이러한 포켓몬들도 화면에 표시해줘야 합니다.
  • 포켓스탑 - 지역의 랜드마크는 '포켓스탑'이 되기도 합니다. 이 포켓스탑 근처에 다가가면, 플레이에 도움이 되는 아이템을 보급받을 수 있습니다.

일단 화면 구성에 대해서는 이해가 끝났습니다만... 제일 중요한 것을 아직 확인하지 않았습니다. 이 게임은 실제 위치를 기반으로 하고 있습니다. 즉, 기기의 gps를 이용해 현재 위치를 가져온다는 뜻입니다. 플레이어가 이동할 때마다 그 정보를 어떻게 얻어올 것이며, 거리 화면을 어떻게 갱신해야 할까요? 때마침 사수가 와서 이런 말을 합니다.

sensor 라이브러리에 AbstractGpsSensor 추상 클래스가 있어요. 모바일 기기가 이동할 때마다 그 클래스가 가지는 위도와 경도가 바뀌는데, 구체적으로 어떤 원리로 이게 되는지는 아실 필요 없구요. (사실 저도 잘 몰라요) 제일 중요한 건 onPositionChanged() 라는 추상 메소드에요. GpsSensor는 위도, 경도가 바뀔 때마다 onPositionChanged()를 호출하니까 이 메소드 안에서 화면 갱신이 일어나게 구현하시면 됩니다.

사수가 말한 대로 실제 클래스를 까보니, getLatitude(), getLongitude()를 통해 위도와 경도를 알 수 있겠네요. 무엇보다 제일 신기한 건, onPositionChanged()가 알아서 호출이 된다는 것입니다. (아마 기기 OS 어딘가에 그렇게 하도록 코드가 짜여져 있을지도 모르겠네요) 만일 이런 구조가 아니었다면 while (true) 문을 사용해서 1초에 한번씩 계속 getLatitude()와 getLongitude()를 호출해야 했을 지도 모를 일입니다.

몇 주의 시간이 지난 뒤, 우리는 마침내 지도(MapView), 포켓몬(PokemonView), 포켓스탑(PokeStopView)을 구현하는 데 성공했습니다. 그래서 아래와 같이 구성해봅니다.

뿌듯해 하면서 깃헙에 PR을 올렸는데, 갑자기 팀원들한테서 무수히 많은 수정의 요청이 들어옵니다.

"onPositionChanged() 내부에 구현체를 직접 지정해서 메소드를 호출하는 부분이 어색해요. 변경 사항이 생길 때마다 이 부분을 전부 손대실 건가요?"
"제 의견도 같습니다. 가급적 구현체보다는 인터페이스를 이용하는 것이 클래스간 결합도를 줄일 수 있는 좋은 방법입니다."

칭찬 한 마디 없이 칼같은 리뷰들만 주르륵 달리자 부아가 치밀지만(?), 이내 우리는 수긍하고 맙니다. 왜냐면 사수가 다시 와서 이런 말을 하고 갔거든요.

어.. 생각해보니 게임 기획에 체육관이 있는데, 여기에선 빠졌네요. PokeStopView 말고도 GymView를 만드시거나, PokeStopView와 GymView를 합쳐서 새로운 View를 만드시는 게 좋을 거 같아요.

결국 체육관도 화면에 표시되어야 하므로 1) GymView 클래스를 만들고, 2) GpsSensor 클래스 내부에 멤버로 추가해주고, 3) onPositionChanged()에서 수정까지 해줘야만 했습니다. 어찌저찌 이 부분을 끝내고 "이제 빠진 거 없죠? 더 이상 안 바뀌죠"라고 물어보자 사수가 이렇게 답합니다.

바뀔 지 안 바뀔 지 어떻게 압니까? 이건 계속 업데이트되는 게임이고, 앞으로 어떤 기능이 추가될 지 우리도 모르는데

구현체보다 인터페이스를 참조하자

앞으로 어떤 기능이 추가될 지 모르는데, 코드를 이런 식으로 방치해두면 미래의 내가 불쌍해질 것만 같은 느낌이 듭니다. 자세히 보니, 거리 화면의 구성 요소들 모두가 위도와 경도의 값을 받고, 이에 따라 그래픽을 표시해준다는 공통된 흐름을 따르고 있습니다. 이거, 인터페이스화가 가능할 것 같네요. 그래서 아래와 같이 View 인터페이스를 작성해 봅니다.

모든 View 구현체들을 하나의 View 인터페이스로 통일을 한 후에, 이걸 Collection의 형태로 관리하게 되었습니다. 다형성과 상속의 힘으로 여러가지 View에 대해 일괄적으로 update()를 호출할 수 있게 되었습니다.

이렇게 바꾼다면 나중에 새로운 View가 생기더라도 변경의 폭이 줄어듭니다. 적어도 GpsSensor 클래스는요. 나름의 해답을 찾은 것 같네요.

그 정도 추상화로는 모자라다

그렇게 코드를 올리고 다른 일에 집중하고 있을 때, 사업 팀에서 새로운 스펙이 추가되었음을 알려옵니다. 바로 "포켓몬 알"입니다.

안녕하세요. 포켓몬 GO의 다음 버전에서는 포켓몬 알이 추가될 예정입니다. 포켓몬 알은 어떠어떠한 경로로 얻을 수 있으며, 플레이어가 알을 가진 채로 일정 걸음 수를 넘게 걸으면 부화합니다.

그리고 사수가 와서 포켓몬 알 부화에 관한 업무를 맡깁니다.

현재 Gps 정보에 접근하는 GpsSensor를 XX님이 손대셨으니, 포켓몬 알 부화도 맡아주시면 될 거 같아요. 위도, 경도 변화값에서 걸음 수를 계산해서 포켓몬 알을 부화시킬지를 결정하면 될 것 같네요.

단순히 API만 참조해서 살만 좀 붙였을 뿐인데, 갑자기 일이 하나가 더 들어왔습니다. 심지어 '알 부화' 기능은 지금까지 우리가 개발해 왔던 '표시' 기능과 전혀 관련이 없습니다. 즉, 만들어 둔 View 인터페이스를 재활용 할 수 없다는 뜻입니다. 그렇다면 다시 구현체를 호출하도록 바꿔야만 할까요? 인터페이스를 쓰라고 했던 조언을 괜히 귀담아 들은 것일까요? 정답은 더 고도의 추상화가 필요했다 입니다.

옵저버 패턴으로 해결하자

출처 : 위키백과 - 옵서버 패턴(https://ko.wikipedia.org/wiki/%EC%98%B5%EC%84%9C%EB%B2%84_%ED%8C%A8%ED%84%B4)

옵저버 패턴은 주제(Subject)옵저버(Observer)로 구성되는 매우 심플한 패턴입니다. 주제로부터 어떠한 변화가 생긴다면, 옵저버는 이 변화를 감지할 수 있습니다. 이게 어떻게 가능할까요? 아래와 같은 코드를 보도록 합시다.

Subject 인터페이스는 Observer를 등록하는 메소드 registerObserver()와 Observer들에게 상태 변화를 알리는 notifyObservers() 메소드를 가지고 있습니다. 여기서 Subject의 구현체는 바로 GpsSensor이고, onPositionChanged()에서 호출될 대상들이 바로 Observer들입니다. onPositionChanged()에서 해줘야 할 것은 notifyObservers()를 호출함으로써 "상태에 변화가 발생했음"을 알리는 것입니다. 그 이후의 일들은 더 이상 GpsSensor에서 알 수도 없고, 알 필요도 없습니다. 이 값을 어떤 클래스들이 받게 될 지는 모르지만, 어찌되었던 Subject-Observer의 느슨한 연관관계를 통해 잘 전달되었다는 것이 중요하죠.

이제 화면 표시에 관련된 요소들 뿐 아니라, gps와 관련된 정보를 받는 클래스라면 뭐든지 Observer를 구현시킴으로서 상태 변화를 관찰할 수 있도록 할 수 있습니다. 아래는 이에 기반하여 만들어진 PokemonEggManager 클래스 입니다.

View class는 Observer 인터페이스를 구현한 AbstractView 추상 클래스를 만들어서 한 번 래핑하는 것이 더 편하겠죠?

부연 설명

옵저버 패턴은 포켓몬 GO의 예시보다도 더 유용하게 쓰일 수 있습니다. 

옵저버 패턴은 상태 변화 전달을 유동적으로 조절할 수 있다

registerObserver() 외에, unregisterObserver()를 사용함으로써, 상황에 따라 얼마든지 옵저버를 넣거나 제외해서 주제의 상태 변화가 도달되는 범위를 조절할 수 있습니다.

예를 들어, 포켓몬 알을 가지고 있지 않은 트레이너라면 PokemonEggManager가 계속 GpsSensor 값을 가져와 걸음을 계산하는 것은 아무런 의미가 없습니다. 메인 스레드에서 사용자가 포켓몬 알을 가지고 있지 않다면 PokemonEggManager를 등록하지 않다가, 포켓몬 알을 취득했을 때부터 PokemonEggManager를 등록하게 한다면, 쓸데없는 메소드 호출을 줄일 수 있습니다.

그냥 notify()를 호출할 때에 상태 변화에 대한 값을 전달하면 안 되나요?

그래도 됩니다. GUI 프로그래밍에서는 마우스 클릭, 이동, 키 입력, 시간 경과 등의 여러가지 이벤트를 Observer 패턴을 통해 전달하도록 하고 있습니다. 이 때 발생하는 정보를 이벤트 객체(Event)에 래핑해서 보내는데요. 마우스에 관련된 이벤트라면 마우스 커서의 x좌표와 y좌표가 몇이었는지, 키에 관련된 이벤트라면 무슨 키가 눌렸는지 등의 정보가 데이터 클래스의 형태로 EventListener로 전달됩니다. 이를 통해 우리 개발자들은 특정 키가 입력되었을 때 특정 동작을 하도록 구현을 할 수 있습니다. 자바스크립트에서 흔히 볼 수 있는 onClick과 onChanged 이벤트, 콜백 함수에 인자로 전달되는 event 안에 들어가는 target 등이 이러한 예입니다.