니델바가 자주 사용하는 디자인 패턴은 뭐가 있나요?
새로운 동료와 팀을 만나는 자리, 구인 면접 자리에서 가장 많이 들은 질문을 꼽으라면 바로 이것일 것이다. 그때마다 멍청하고 자신있게 '싱글톤이요!' 하고 대답하면 이 대답을 예상했다는 듯이 '아~ 그러시구나, 그거 말고 다른건요?' 하고 물어오던 사람도 부지기수였다. 그만큼 싱글톤 패턴은 게임 디자인에서 아주 중요한 패턴이고, 전역 Instance 접근이라는 매력적인 설계방법 때문에 '누구나 쓰는 디자인 패턴'이 되어버린 것이다. (물론 단점도 있겠지만). 디자인 패턴은 단순 알고리즘을 말하는 것이 아니라 상황에 따라 자주 쓰이는 설계방법론 같은 것이다. 이 말은 내가 여지것 하고있던 프로그래밍은 예전의 누군가가 설계하고 시도해봤을 것이란거고, 마음대로 이름을 붙일 수도 있다는 것. 사실 디자인 패턴의 이름을 대지 못하고 있다 뿐이지 내 프로그래밍은 항상 누군가의 디자인 패턴을 따라 설계되고 있었다. 그 중 하나가 관찰자 패턴(observer pattern)이었는데, 동시처리가 많은 게임 클라이언트 특성상 아주 필수적으로 설계단계에 포함되고, 내가 특히 유용하게 쓰고 있다는 것을 알고 정리해 둔다.
업적 달성
업적achievement 시스템을 추가한다고 해보자. '게임 100판 승리하기', '다리에서 떨어지기', '특정 조건으로 레벨 완료하기', '일일 미션 5개 완수하기'.. 같은 특정 기준을 달성하면 배지 혹은 보상을 얻을 수 있는데, 이 종류는 수백개가 넘는다고 하자.
업적 종류가 광범위하고 달성할 수 있는 방법도 다양하다 보니 깔끔하게 구현하기가 어렵다. 물론 '다리에서 떨어지기'같은 업적은 어떻게든 물리 엔진과 연결해서 충돌 검사 알고리즘의 선형대수 계산 코드 한가운데에서 unlockFallOffBridge()를 호출하고 싶지는 않을 것이다.
특정 기능을 담당하는 코드는 항상 한데 모아두는 게 좋다. 문제는 업적을 여러 게임 플레이 요소에서 발생시킬 수 있다는 점이다. 이런 코드 전부와 커플링되지 않고도 업적 코드가 동작하게 하려면 어떻게 해야 할까? 이럴 때 관찰자 패턴을 쓰면 된다. 어떤 코드에서 흥미로운 일이 생겼을 때 누가 받든 상관없이 알람을 보낼 수 있다.
예를 들어 물체가 평평한 표면에 안정적으로 놓여 있는지, 바닥으로 추락하는지를 추적하는 중력 물리 코드가 있다고 해보자. (보통 유한 상태머신으로 해결한다) '다리에서 떨어지기' 업적을 구현하기 위해 업적 코드를 물리 코드에 곧바로 밀어 넣을 수도 있지만 앞서 말한 것 처럼 코드가 매우 지저분해진다. 대신 다음과 같이 해보자.
void updateEntity(Entity entity){
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if(wasOnSurface & !entity.isOnSurface()){
notify(entity, EVENT_START_FALL);
}
}
이 코드는 '이게 방금 떨어지기 시작했으니 누군지는 몰라도 알아서 해라'라고 알려주는 게 전부다. 업적 시스템은 물리 엔진이 알림을 보낼 때마다 받을 수 있도록 스스로를 등록한다. (물리 엔진을 예로 들었지만 서버에서 무언가를 준다면 제일 편하고, 그걸 사용하면 된다!) 떨어지는 물체가 불쌍한 내 캐릭터가 맞는지, 떨어지기 전에 다리 위에 있었는지 등등을 확인한 뒤에 축포와 함께 업적을 잠금해제하면 된다. 이런 과정을 물리 코드 자체는 알 필요가 전혀 없다.
이렇게 물리 엔진 코드는 전혀 건드리지 않은 채로 업적 목록을 바꾸거나, 업적 시스템을 아예 떼어낼 수도 있다. 물리 코드는 누가 받든 말든 계속해서 알림을 보낼 것이다.
"너무 느려"
사실 이 포스팅을 하게 된 이유이다. 아니, 관찰자 패턴은 결코 느리지 않다. 개발자 몇몇은 '디자인 패턴' 비슷한 이름만 붙어 있어도 쓸데없이 클래스만 많고 우회나 다른 희한한 방법으로 CPU를 낭비할 것으로 지레짐작한다는 것이다. 괜히 '유식한 척' 방법론을 들먹이지 말라고 비꼬기도 한다. 그래서, 관찰자 패턴이 정말 느린 것인가? 에 대해 고찰을 해보자면.
관찰자 패턴은 특히 '이벤트', '메시지', 심지어 '데이터 바인딩'같은 좀 어두운(ㅋㅋ) 친구들과 어울려 다닌다는 얘기 때문에 부당한 평을 받아온듯 하다. 이런 시스템 중 일부는 알림이 있을 때마다 동적 할당을 하거나 큐잉queuing하기 때문에 실제로 느릴 수도 있다. 이래서 패턴의 문서화가 중요하다! 용어가 모호하면 분명하고 간결하게 의사소통할 수 없다. 같은 걸 놓고 사람마다 '관찰자'니 '이벤트'니 '메시징'이니 하는 식으로 다르게 부르는 이유는 누구도 이들 차이를 써두지 않았거나 아무도 그런 글을 읽지 않아서다. 정말 느리다고 하는 것은 그놈의 관찰자 '패턴' 탓인가?
코드 패턴 자체는 전혀 느리지 않다. 그냥 목록을 돌면서 필요한 가상 함수를 호출하면 알림을 보낼 수 있다. 정적 호출보다야 약간 느리긴 하겠지만.. 진짜 성능에 민감한 코드가 아니라면 이 정도 느린게 문제가 되지 않는다. 게다 성능에 민감하지 않은 곳에 가장 잘 맞기 때문에, 동적 디스패치를 써도 크게 상관없다. 이 점만 제외하면 성능이 나쁠 이유가 전혀 없는 것이다. 그저 인터페이스를 통해 동기적으로 메소드를 간접 호출할 뿐 메시징용 객체를 할당하지도 않고, 큐잉도 하지 않는다. 느리다고 표현한 것은 이 패턴을 무시하고 막연히 할당시키는 객체들 덕이리라.
"동적 할당을 너무 많이 해"
게임 개발자를 포함한 많은 프로그래머 무리가 가비지 컬렉션을 지원하는 언어로 이주한 뒤로는, 동적 할당은 더 이 상 예전만큼 무서운 존재가 아니다. 하지만 아무리 관리 언어로 만든다고 해도 게임같이 성능에 민감한 소프트웨어에서는 메모리 할당이 여전히 문제가 된다. 저절로 된다고는 하나 메모리를 회수하다 보면 동적 할당이 당연히 오래 걸릴 수 있다.
실제 게임 코드에서는 고정배열이 아니라 관찰자가 추가, 삭제될 때 크기가 알아서 늘었다 줄어드는 동적 할당 컬렉션을 쓴다. 그럼 눈치챘을 것이다. 대부분 프로그래머들은 이렇게 메모리가 왔다갔다 하는 것을 굉장히 두려워한다(.....)!
물!론. 실제로는 관찰자가 추가될 때만 메모리를 할당하는게 맞다. 알림을 보낼 때는 메소드를 호출할 뿐 동적 할당은 전혀 하지 않는다. 만약 할당을 하고 있다면 패턴을 다시 짜야 한다. 게임 코드가 실행될 때 처음 관찰자를 등록해놓은 뒤에는(유니티 같은 경우 frame 앞단에서!) 그 후 건드리지 않는다면 메모리 할당 자체는 거의 일어나지 않는다.
남은 문제점들
관찰자 패턴을 꺼리게 하던 우려는 대충 해소가 되었다. 앞에서 본 것처럼 관찰자 패턴은 꽤 간단하고 빠르며 메모리 관리 측면에서도 깔끔하게 만들 수 있다. 그렇다면 항상 관찰자 패턴을 써야 하는 걸까?
그건 전혀 다른 얘기다. 싱글톤도 마찬가지지만 다른 모든 디자인 패턴과 마찬가지로 관찰자 패턴 역시 만능은 아니다. 제대로 구현했다고 해도 올바른 해결책이 아닐 수도 있다. 디자인 패턴의 평판이 나빠진 것은 사람들이 좋은 패턴을 상황에 맞지 않는 문제에 적용하는 바람에 문제가 더 심각해진 경우가 많기 때문이라 생각한다. 그런 사람들이 항상 디자인 패턴을 욕하고는 한다. 그렇게 욕먹은 사례가 하나 있다면.
유저의 재화 상태를 보여주는 UI 화면이 있었다. 유저가 상태창을 열면 상태창 UI 객체를 생성한다. 상태창을 닫으면 UI 객체를 따로 삭제하지 않고 GC가 알아서 정리하게 한다. 유저가 게임을 하면서 돈을 얻거나 잃으면 그럴 때마다 알림을 보낸다. 유저를 관찰하던 UI 창은 알림을 받아 재화를 갱신한다. 여기까지는 좋다. 이제 유저가 상태창을 닫을 때 관찰자를 등록 취소하지 않는다면 어떻게 될까?
UI는 더 이상 보이지 않지만 유저 재화의 관찰자 목록에서 여전히 상태창 UI를 참조하고 있기 때문에 GC가 수거해 가지 않는다. 상태창을 열 때마다 상태창 인스턴스를 새로 만들어 관찰자 목록에 추가하기 때문에 관찰자 목록은 점점 커진다. 유저는 게임을 하는 동안 계속해서 이 모든 상태창 객체에 알림을 보낸다. 상태창은 더 이상 화면에 없지만 알림을 받을 때마다 눈에 보이지도 않는 UI요소를 업데이트하느라 CPU 클럭을 낭비한다. 상태창에서 효과음이라도 난다면, 연속해서 같은 효과음이 나는 걸 듣고서야 '아 뭔가 X됐는데..' 라고 눈치챌 것이다.
이는 알림 시스템에서 굉장히 자주 일어나는 문제다 보니 사라진 리스너 문제lapsed listner problem라는 고유한 이름이 붙었을 정도. 얼마나 중요한지 심지어 위키피디아에 따로 페이지가 있을 정도다. 대상이 리스너 레퍼런스를 유지하기 때문에, 메모리에 남아 있는 좀비 UI 객체가 생긴다. 제발, 등록 취소를 제대로 하자. 주의해야 한다!
유니티 C#, 오늘날의 관찰자
옛날(?) 프로그래머 분들의 얘기를 들어보면 이렇다. 그 당시에는 객체지향 프로그래밍이 최신 기법이었다. 모든 프로그래머가 '한 달 안에 OOP 배우기'를 원했고, 관리자는 클래스를 몇 개나 만들었느냐에 따라 프로그래머를 평가했다고는 한다. 프로그래머는 클래스 상속 구조의 깊이와 자신의 실력을 동일시했다.
그런 시절에 관찰자 패턴이 알려지다 보니 당연히 클래스에 많이 의존하게 되었다. 하지만 요즘 프로그래머는 함수형 언어에 더 익숙하다. 당연히 나만 하더라도 함수를 모아놓은 클래스를 따로 관리하는게 편하다고 생각할 정도이니. 알림 하나 받겠다고 인터페이스를 상속받는 건 요즘 기준으로는 아름답지 않다고 표현하더라 (ㅋㅋㅋㅋㅋ). 이런 방식은 무겁고 융통성 없어 보이지만....... 실제로 무겁고 융통성이 없다(?). 한 클래스가 대상 인스턴스별 알림 메소드를 다르게 정의할 수 없다는 점만 해도 그렇다. 그래서 조금만 더 고치면.
메소드나 함수 레퍼런스만으로 '관찰자'를 만드는 것이다. 일급 함수, 그 중에서도 클로저를 지원하는 언어에서는 이렇게 관찰자를 만드는 게 훨씬 일반적이다. 패턴 인터페이스를 상속받지 않고, 함수만으로 만이다! 아주 다행이도 유니티 C#에서는 언어 자체에 event가 있어서 메소드를 참조하는 delegate로 관찰자를 등록할 수 있다. 자바스크립트 메인 이벤트 시스템에서는 EventListener 프로토콜을 지원하는 객체 자체가 관찰자가 되는데, 이것 역시 함수로도 가능하다.
저 관찰자 패턴을 즐겨 써요
어쨌거나 자주 쓰이는 패턴이다. 관찰자 패턴은. 이벤트 시스템이나 다른 유사 관찰자 패턴들이 이제는 너무 흔하고 정형화되어 있다. 싱글톤 만큼이나 유명할 것 같다. 하지만 관찰자 패턴을 이용해 대규모 프로그램을 만들다 보면 관찰자 패턴 관련 코드 중에서 많은 부분이 결국에는 다음과 같은 공통점이 있다는 걸 알게 된다.
1. 어떤 상태가 변했다는 알림을 받는다.
2. 이를 반영하기 위해 UI 상태 일부를 변경한다.
'엥? 유저 재화가 70이 줄었다고? 그럼 보유 재화 Slider 너비를 0.3% 줄일게!' 라고 하는 식이다. 하다 보면 상당히 지겹다. 소프트웨어 엔지니어들은 이런 지루함을 제거하기 위해 오랫동안 노력해 왔다. 그게 함수형 반응형 프로그래밍이니 뭐니 하는 결과로 나타났기도 했고. 이런 프레임워크에서는 보통 '데이터 바인딩'을 지원하는데, 게임의 핵심 코드에 적용하기는 너무 느리고 복잡하다. 하지만 UI같이 게임에서 성능에 덜 민감한 분야에서는 대세가 될 지도..?
기존 방식의 관찰자 패턴 역시 충분히 훌륭하기 때문에 계속 사용될 것이다. 이름에 '함수형'과 '반응형'을 같이 붙여놓은 무언가의 최신 방식(....)보다야 덜 흥미롭겠지만 여전히 단순하고 잘 동작한다. 적어도 내가 맡은 프로젝트에서는. '단순하고 잘 동작한다'는 것은 내가 해결책을 고를 때 가장 중요하게 보는 기준이기도 하다. 그래서 관찰자 패턴은 알게 모르게 나에게 신임을 쌓아 왔고, 이제 나는 관찰자 패턴을 즐겨 쓴다고 이야기한다.