해당 글은 Golang Korea 페이스북 그룹 최흥배님의 번역 글에서 영감을 얻었으며 Go언어를 어느 정도 알고 있는 독자를 대상으로 한다.
Go 언어의 채널(chan)은 특정 자료형의 변수들의 통로이다. 그리고 채널 역시 하나의 자료형이다. 즉, 채널 타입을 받는 채널 역시 문법적으로는 가능하다. 다만, 통로의 통로라는 것은 꽤나 이상하게 들린다. 이렇게 이해부터 쉽지 않은 채널의 채널(이하 chan chan)은 과연 이론의 영역에만 머무를까?
당연히 이 글을 작성한 이유는 chan chan이 나름 실용적으로 이용될 수 있다는 것을 보여주기 위함이다. 필자는 chan chan의 활용법을 3가지로 분류했으며 3개의 글로 나눠서 설명할 예정이다. 해당 글은 이 중 가장 직관적인 활용법에 대해 설명하고자 한다.
채널을 변수로 취급하기
위에서 언급하듯 채널은 특정 자료형의 변수들의 통로이다. 그리고 채널 역시 하나의 변수이다. 그렇다면 말장난으로 보일 수 있지만, 채널의 채널을 통로의 통로로 보는 것이 아니라 채널형 변수의 통로라고 봐도 문제는 없을 것이다. 일단 타입이 채널형일 뿐인 변수의 통로로 보고 해당 변수를 사용할 때만 채널로 취급한다고 생각해보자. 이러한 관점의 변화는 의외로 복잡한 개념을 단순화시킨 만든다.
본론으로 들어가기에 앞서 채널의 특징 중 눈여겨봐야할 것이 있다.
채널은 참조형이다
chan은 참조형이다. 즉, 채널을 새로 만들 경우, 해당 채널은 자체 주소값으로 식별된다는 것이다. 사용자는 그저 해당 채널을 할당한 변수를 넘겨주기만 하면 해당 채널이 복사 과정 없이 그대로 사용된다.
해당 채널의 성질을 미리 말하는 이유는 이번에 설명할 기법을 이해하기 위해서이다.
블로그 구독 서비스
여기서 사용할 예시는 간단한 블로그 구독 서비스이다. 사용자는 블로그를 구독하거나 구독 취소할 수 있다. 블로그는 글을 발행하면 구독한 사용자에 한해 알림을 내보낸다. 물론 사용자는 중간에 구독을 취소하거나 다시 구독할 수 있고, 블로그는 이에 맞춰서 알림을 보내야 한다.
Blog
blog 객체는 Post 객체(블로그 글)를 발행하는 역할을 한다. 글을 발행하면 자동적으로 구독자에게 알림이 가게끔 해야 하며, 사용자로부터 구독 여부가 변경될 수 있다.
//blog.go
package main
import "fmt"
type Post struct {
Title string
Content string
}
type blog struct {
name string
subscribers map[chan<- string] struct{}
subscribe chan chan<- string
unsubscribe chan chan<- string
publish chan Post
}
func Blog(name string) *blog {
b := new(blog)
b.subscribers = make(map[chan<- string] struct{})
b.subscribe = make(chan chan<- string)
b.unsubscribe = make(chan chan<- string)
b.publish = make(chan Post)
b.name = name
go b.run()
return b
}
func (b *blog) SubscribedBy(subscriber chan<- string) {
b.subscribe <- subscriber
}
func (b *blog) UnsubscribedBy(subscriber chan<- string) {
b.unsubscribe <- subscriber
}
func (b *blog) Publish(p Post) {
b.publish <- p
}
func (b *blog) run() {
for {
select {
case sub := <-b.subscribe:
b.subscribers[sub] = struct{}{}
case sub := <-b.unsubscribe:
delete(b.subscribers, sub)
case p := <-b.publish:
fmt.Printf("%s published %s\n", b.name, p.Title)
for subscriber := range b.subscribers {
subscriber <- p.Title
}
}
}
}
blog 객체에서 먼저 눈에 띄는 것은 subscribers map[chan <-string] struct{}일 것이다. 해당 맵은 구독자들의 목록으로 키(key)의 chan <-string은 구독자가 알림을 수신할 채널이다. 맵의 값(value)으로는 struct{}를 이용했다. 이 맵에서 중요한 것은 맵에 포함되어 있는 모든 키를 호출하는 것으로 chan <-string 타입의 집합(set)처럼 사용된다고 볼 수 있다. 이런 경우 맵의 값 타입으로 메모리를 차지하지 않는 struct{}를 사용하는 것이 성능상으로든 관례상으로든 좋다.
subscribe chan chan<- string과 unsubscribe chan chan<- string는 이번 예시의 핵심이다. chan<- string을 채널이라고 생각하면 이해하기 어렵다. 대신 위에서 언급한 구독자의 채널 타입의 변수라고 생각해보자. 즉, 위의 두 채널(subscribe, unsubscribe)은 구독자의 채널 타입의 변수가 이동하는 채널이라고 보면 된다.
Blog 함수는 blog 객체의 초기화와 동시에 서비스를 실행하는 함수이다. 고루틴을 통해 서비스를 실행하고 해당 블로그를 반환한다. 또한 SubscribedBy와 UnsubscribedBy 함수는 구독자의 채널을 매개변수로 받아 서비스에 정해진 채널로 구독자의 채널을 건네는 역할을 한다. 이 때, 매개변수로 전달된 채널은 위에서 언급했듯 참조 타입이기 때문에 해당 구독자의 채널 자체가 그대로 전달된다.
마지막으로 run 함수는 blog 객체의 모든 서비스를 책임지는 함수로 채널에서 값을 받을 때마다 select 문에서 채널에 맞춰 명령을 실행하는 역할을 한다. 위의 두 case는 subscribers 맵을 조작해 구독자의 채널을 등록하거나 제거한다. 마지막 case는 포스트를 발행할 때(Publish 함수) 호출되며 subscribers 맵의 키값을 순회하며 알림을 전송한다.
User
user 객체는 구독한 blog 객체로부터 새 글 알림을 받는 역할을 한다. user는 선택한 블로그의 구독 여부를 변경할 수 있다.
//user.go
package main
import "fmt"
type user struct {
id string
notifier chan string
}
func User(id string) *user {
u := new(user)
u.id = id
u.notifier = make(chan string)
go u.listen()
return u
}
func (u *user) Subscribe(b *blog) {
b.SubscribedBy(u.notifier)
}
func (u *user) Unsubscribe(b *blog) {
b.UnsubscribedBy(u.notifier)
}
func (u *user) listen() {
for {
s := <- u.notifier
fmt.Printf("%s received new post - %s\n", u.id, s)
}
}
user 객체의 notifier은 Blog에서 언급한 구독자 채널이다. 해당 채널을 통해 새 글 알림을 받는다.
User 함수는 user 객체를 초기화하고 서비스를 실행한다. 그 아래의 Subscribe와 Unsubscribe 함수는 그와 짝을 이루는 blog의 SubscribedBy와 UnsubscribedBy 함수를 호출하여 구독 여부를 변경한다.
마지막으로 listen 함수는 구독자 채널에서 알림을 받을 때마다 이를 출력하는 서비스를 제공한다.
이제 위에서 짠 객체들을 이용해 프로그램을 만들어보자.
//main.go
package main
import (
"time"
)
func main() {
b1 := Blog("Just_foo_bar")
b2 := Blog("Interesting_Blog")
u1 := User("Kim")
u2 := User("Lee")
u3 := User("Park")
u1.Subscribe(b1)
u1.Subscribe(b2)
u2.Subscribe(b1)
u3.Subscribe(b2)
b1.Publish(Post{"Foo","bar"})
b2.Publish(Post{"Hello", "world!"})
u1.Unsubscribe(b1)
b1.Publish(Post{"Another foo", "another bar"})
u2.Subscribe(b2)
u1.Subscribe(b2)
b2.Publish(Post{"The answer", "42"})
time.Sleep(1 * time.Millisecond)
}
Just_foo_bar published Foo
Just_foo_bar published Another foo
Lee received new post - Foo
Lee received new post - Another foo
Interesting_Blog published Hello
Kim received new post - Foo
Kim received new post - Hello
Interesting_Blog published The answer
Park received new post - Hello
Park received new post - The answer
Kim received new post - The answer
Lee received new post - The answer
고루틴을 이용하는 만큼 순서는 실행할 때마다 바뀔 수 있다. 하지만 각 사용자가 받는 알림은 항상 같을 것이다. blog 객체를 중심으로 데이터의 흐름을 보면 이해하기 쉽다. blog에서 함수를 호출하면 고루틴으로 실행된 run 함수의 select 문에서 신호 수신 -> 채널에 맞는 서비스 실행 -> 다음 blog의 함수 호출 순서로 진행이 된다. 즉, 각 블로그에서 받은 명령은 순서가 보장이 되며, 이는 동시성 프로그래밍에 있어서 바람직한 방식이다.
결론
해당 활용법은 꽤 직관적이다. 아마 이렇게 쓰일 수 있다는 것을 알고 있는 사람도 많을 것이다. 필자는 이것이 이론의 영역이 아니라 실전에서도 쓰임직한 기술임을 보여주고 싶었다. 위의 코드가 통상적인 코드보다 훨씬 복잡하진 않(을 것이라 믿는)다.🙃
여담이지만 위의 예시에는 디자인 패턴이 들어 있다. 바로 옵저버 패턴이다. user 객체가 Subscribe와 Unsubscribe를 통해 blog 객체의 신호 감지(여기서는 글 발행을 통한 알림)를 시작하거나 중단할 수 있다. 즉, user는 observer, blog는 observed라고 볼 수 있다. 옵저버 패턴을 이용할 때는 먼저 채널의 채널을 고려해 봄직하다.
다음 글에서는 살짝 복잡하지만 성능상 엄청난 이득을 얻을 수 있는 활용법을 소개할 것이다.