스타크래프트의 프로토스 유닛 옵저버는 항시 클로킹한 상태로 시야를 밝히고 클로킹한 유닛을 감지할 수 있다. 여기서 Observer (옵저버) 는 '관측자' 혹은 '감시자' 라는 뜻이다. 말 그대로 무언가를 감시하는 역할을 한다는 뜻이다.
프로토스의 옵저버가 클로킹 상태의 테란 유닛 고스트나 레이스를 감시하듯, 프로그래밍에서 Observer 패턴이라고 한다면 어떤 '이벤트' 가 일어나는 것을 감시하는 패턴을 의미한다.
안드로이드를 예로 들어 설명하자면 아래와 같은 것들이 이벤트가 발생한 순간이라고 할 수 있다.
- 사용자가 키보드를 눌렀을 때
- 사용자가 어떤 버튼을 터치했을 때
- 호출한 API의 응답 데이터가 수신됐을 때
정리하자면, 아무도 함수로 직접 요청한 적 없지만 시스템에 의해 발생하는 동작들을 이벤트라고 한다.
이러한 이벤트들을 감시하여, 이벤트가 발생할 때마다 미리 정의해둔 어떠한 동작을 즉각 수행하게 해주는 프로그래밍 패턴을 옵저버 패턴이라고 한다. ex) A 라는 버튼이 클릭될 때마다 화면에 '안녕'을 출력하는 동작 등
옵저버 패턴을 활용하면 다른 객체의 상태 변화를 별도의 함수 호출 없이 즉각적으로 알 수 있기 때문에, 이벤트에 대한 처리를 자주 해야 하는 프로그램이라면 매우 효율적인 프로그램을 작성할 수 있다.
그럼 이제 옵저버 패턴의 원리를 살펴보자.
옵저버는 어떤 식으로 구현해볼 수 있을까? 우선 예시를 하나 들어보자.
이벤트를 발생하는 클래스 B 가 있고, 이 B 클래스가 발생하는 이벤트를 수신받고 싶어하는 클래스 A 가 있는 상황을 가정해보자. 그럼 머릿속으로 이벤트 처리 동작을 이렇게 구현해볼 수 있을 것이다.
아래 그림 처럼, 먼저 클래스 A 에서 B 의 이벤트를 수신받기 위해 클래스 B 를 인스턴스화 한 뒤, B 가 자신에게 이벤트가 발생할 때마다 클래스 A 가 갖고있는 메소드를 호출하도록 시키는 것이다.
이렇게 봐선 나쁘지 않은 구조이다.. 라고 생각했다면 틀렸다!
사실은 치명적인 문제가 있는 구조이다. 아래 그림을 보자.
클래스 B 는 이벤트를 정상적으로 발생하고 있지만, A 가 B 를 일방적으로 인스턴스화 한 상황이기 때문에, B 가 자신을 인스턴스화 한 대상에게 접근을 할 방법이 전혀 없다는 점이다. 사실 당연한 이야기이다. 따라서 B 는 A 의 메소드를 호출하지 못 한다.
그럼 어떤 방식으로 옵저버 패턴을 구현해볼 수 있을까?
해답은 '인터페이스' 를 이용하는 것이다. 바로 아래와 같이 말이다!
위 예시에서는 A가 중간에 종을 만들어, B 가 이벤트가 발생할 때마다 A 가 만들어둔 종을 울리도록 한다. A 는 종이 울릴 때마다 이를 알아차리고 이벤트가 감지됐을 때 수행할 동작을 자연스럽게 하는 플로우가 나온다.
즉, 둘 사이에 인터페이스를 하나 끼워넣는 방식이다. A 는 인터페이스를 상속하여 이벤트가 발생할 때마다 실행되게 할 메소드를 구현해둔다. 그리고 B 를 생성할 때 인터페이스 구현체를 전달하여, 이벤트가 발생할 때마다 생성자로 전달받은 A가 구현한 인터페이스 메소드를 호출하면 된다.
- 이때, 이 인터페이스를 ⭐️ Observer (옵저버)라고 부른다. 코틀린에서는 Listener(리스너) 라는 용어를 사용한다.
- 그리고 B 가 구현된 인터페이스 메소드를 호출함으로써 이벤트를 전달하는 행위를 ⭐️ Callback(콜백) 이라고 한다
그럼 한 번 실제로 구현을 해보자. (코틀린을 기준으로 함)
백 마디 말 듣는 것 보다 한 번 타자 쳐보는 게 낫다, 백문이 불여일타
우리는 1부터 100까지 하나씩 세면서 5의 배수를 만날 때마다 이벤트를 발생하는 녀석과, 5의 배수 이벤트를 관찰하고 있다가 이벤트가 수신될 때마다 그 5의 배수를 출력하는 녀석을 구현해볼 것이다.
우선, 이벤트를 수신받는 녀석과 이벤트를 발생하는 녀석을 이어줄 리스너를 만들어준다.
// 이 인터페이스를 상속받아, 이벤트가 발생할 때마다 호출할 메소드를 구현하면 됨
interface EventListener {
fun onEvent(count: Int)
}
이 때, 생성자로 '리스너' 를 받게 된다. 이벤트 발생 시마다 전달받은 리스너의 메소드를 호출해줄 것이다.
// 5의 배수가 감지되면 이벤트를 발생하는 Counter
class Counter(var listener: EventListener) { // 생성자로 EventListener 넘겨받음
fun count() {
for (i in 1..100) { // 1부터 100까지 숫자 세기
if (i % 5 == 0) {
listener.onEvent(i)
}
}
}
}
EventListener
를 상속받아,onEvent()
메소드를 구현해준다. 이렇게 되면,start()
가 호출되었을 때 자신이 구현한EventListener
구현부가Counter()
의 생성자로 전달되고, 5의 배수가 발생할 때마다print("${count}-")
가 실행되어 화면에 5의 배수가 연이어 출력되게 된다!
// 이벤트를 수신받았을 때 화면에 5의 배수를 출력하는 EventPrinter
class EventPrinter: EventListener {
// 리스너를 상속받아 콜백 메소드를 구현함 (5의 배수 출력)
override fun onEvent(count: Int) {
print("${count}-")
}
fun start(){
// this 를 통해 EventListener 구현부를 넘겨줌 (다형성 활용!)
Counter(this).count() //
}
}
아래와 같이 main() 메소드에서
EventPrinter()
를 인스턴스화 하여 start() 를 호출해보자!
// EventPrinter : 이벤트를 수신해서 출력하는, 인터페이스 구현체
// Counter : 숫자를 카운트 하면서 5의 배수가 감지되면 이벤트를 발생시키는 클래스
// EventListener : 위 두 요소를 연결지어줄 옵저버 (리스너)
fun main() {
EventPrinter().start()
}
이렇게 출력되면 성공이다!
우리가 구현한
EventPrinter
클래스를 보면EventListener
를 상속받아 구현하고 있다.
하지만 코틀린에서는 '익명 객체' 를 통해, 임시로 EventListener
를 상속받아 구현한 객체를 만들어 생성자로 넘겨줄 수 있다. 아래 코드와 같이 말이다.
class EventPrinter {
fun start(){
val counter = Counter(object: EventListener {
override fun onEvent(count: Int) {
print("${count}-")
}
})
counter.count()
}
}
오늘은 옵저버 (리스너) 패턴의 개념과, 어떠한 원리로 동작되는지에 대하여 알아보았다. 옵저버 패턴은 다른 객체의 상태 변화를 감지함에 있어 객체끼리 느슨하게 결합되는 형태이기 때문에, 의존성을 제거하고 시스템을 보다 유연하게 해주는 효과가 있다. 또한, 상태가 변화 (이벤트가 발생) 한 것을 감시하고 있는 객체들에게 직접 알리는 것이 아니기 때문에 불필요한 코드를 줄이는 효과가 있다.
또한, 옵저버 패턴만 봐도 알 수 있듯 다양한 디자인 패턴의 구현과 유연한 코드를 위해 '인터페이스'에 대한 높은 이해가 필요한 것 같다. 디자인 패턴 시리즈는 꾸준히 올려볼 예정이다!
유연한 사고, 인터페이스 적극 활용하기
class Counter(var listener: (Int) -> Unit) {
fun count() {
for (i in 1..100) {
if (i % 5 == 0) {
listener(i)
}
}
}
}
class EventPrinter {
fun start() {
Counter({count: Int -> print("${count}-")}).count()
}
}
fun main() {
EventPrinter().start()
}
위 코드처럼 함수만 넘겨주면 더 간결한데 인터페이스로 감싸서 넘겨주는 이유가 무엇인가요? 글에 나온것처럼 더욱 느슨하게 연결하기 위해서 그런것인가요?
오호 옵저버에 대한 이해가 매끄럽게 되는 글이군요 👍