Go Block 모범 사례
Go Block 모범 사례 (Go Block Best Practices)
Clojure core.async의 go 블록은 값싸긴 하지만 완전히 공짜는 아니에요. 그리고 go 블록 안에서 쓸 수 없는 구문이 몇 가지 있는데, 그 경계를 이해하면 채널을 좀 더 깔끔하고 예측 가능하게 다룰 수 있어요. 이 문서는 Timothy Baldridge가 쓴 공식 가이드로, go 블록을 올바르게 쓰는 방법과 그 한계를 함께 다뤄요.
출처: Clojure 공식문서
본문
일반적인 조언
답장을 기다리지 않고 메시지를 보내고 싶을 때, 아래처럼 쓰고 싶은 유혹이 아주 크죠.
(go (>! c 42))
그런데 go 블록은 싸긴 해도 완전히 무료는 아니에요. 그래서 이런 경우에는 다음과 같이 쓰는 걸 권장해요.
(async/put! c 42)
go는 결국 어차피 내부적으로 put!을 호출하게 돼요. 그러니 이렇게 바꿔 써도 실질적인 손해는 없어요.
또한, 콜백 안에서 코드가 호출되는데 back-pressure를 지키고 싶다면, put!과 함께 재귀 함수를 쓰면 아주 쉽게 back-pressure를 존중할 수 있어요.
(defn http-call
"웹 브라우저에 비동기 호출을 하는 함수"
[url callback] ...)
(def urls [url1 url2 url3])
(defn load-urls
"여러 url을 로드한 결과를 채널로 흘려보낸다.
임시 채널이나 go 블록을 만들지 않고 수행한다"
[urls out-c]
(http-call
(first urls)
(fn [response]
(put! out-c response (fn [_] (load-urls (next urls) out-c))))))
(load-urls urls response-chan)
이 예시는 앱에서 채널을 쓰기 시작할 수 있게 해 주는 깔끔한 인터럽 코드인데요, 금방 버려버릴 채널이나 go 블록을 수없이 만들지 않아도 돼요.
한 가지 꼭 기억해 둘 점은 back-pressure를 지키는 게 중요하다는 거예요. core.async의 일반 원칙은 무한(unbounded) 큐는 나쁘다는 것이고, 대기 중인 put의 개수도 제한돼 있어요(현재는 1024개). 또 다른 선택지로는 put을 항상 즉시 받아들이는 버퍼를 가진 채널을 쓰는 건데요, 예를 들어 dropping-buffer나 sliding-buffer가 그런 경우예요.
go 블록에서 지원하지 않는 구문과 기타 제약
go 매크로는 함수가 만들어지는 경계에서 번역을 멈춰요. 그래서 다음 코드는 컴파일에 실패하거나, <!이 go 블록 밖에서 쓰였다는 런타임 오류를 던질 수 있어요.
(go (let [my-fn (fn [] (<! c))] (my-fn)))
이건 꼭 기억해야 할 점이에요. 많은 Clojure 구문이 매크로 안에서 함수를 만들기 때문이죠. 아래는 사람들이 기대한 대로 동작하지 않는 예시들이에요.
(go (map <! some-chan))
(go (for [x xs]
(<! x)))
그런데 doseq 같은 다른 Clojure 구문은 내부적으로 클로저(closure)를 할당하지 않아요.
; 이건 잘 동작한다
(go (doseq [c cs]
(println (<! c)))
아쉽게도 현재로서는 특정 매크로가 go 블록 안에서 기대한 대로 동작할지 알 수 있는 좋은 방법이 없어요. 매크로의 소스를 직접 보거나, 매크로가 생성한 코드를 테스트해 봐야 해요.
왜 그럴까요?
"go 블록 번역이 왜 함수 생성에서 멈추는 걸까?"라는 물음에 대한 가장 좋은 설명은 타입(type) 문제로 귀결돼요. 다음 코드를 봐 볼게요.
(map str [1 2 3])
str의 출력 타입이 문자열이니까 이 코드가 문자열의 seq을 만든다는 건 쉽게 알 수 있어요. 그럼 async/<!의 반환 타입은 뭘까요? go 블록 안에서의 맥락에서는 채널에서 꺼낸 객체예요. 하지만 go 블록은 그 호출을 async/put!에 대한 parking 호출로 번역해야 하죠. 그래서 async/<!의 반환 타입은 실질적으로 Async<Object>나 Promise<Object> 같은 것으로 생각해야 해요. 그런 면에서 (map async/<! chans)의 결과는 "대기 중인 채널 연산들의 seq" 같은데, 이건 전혀 말이 안 되는 값이에요.
요약하면, go 매크로는 상당한 노력을 들이지 않고는 이런 연산을 할 수 없어요. Erjang 같은 다른 언어는 JVM 전체의 모든 코드를 번역하는 방식으로 그런 구문을 허용했는데요. core.async에서는 그렇게 하고 싶지 않아요. 그렇게 하면 일이 복잡해지고, 어떤 한 라이브러리의 로직이 JVM 전체의 코드를 오염시키게 되거든요. 그래서 실용적인 절충안으로 남은 게 바로 (fn [] ...)을 만나면 번역을 멈춘다는 규칙이에요.