go generic을 이용해서 promise를 만들어 보자.

김성현·2022년 4월 12일
0
post-thumbnail

Go언어의 가장 강력한 기능은 내 생각에는, 단연코 goroutine이다.

경험상, goroutine은 병렬처리 환경에서 성능을 향상시키기 위한 세심한 조정 없이도 쉽게 병렬처리를 활용한 성능 향상을 달성할 수 있었다.

goroutine은 병렬처리가 가능한 알고리즘이라면 병렬작업의 분배에 대한 고려없이 무지성으로 goroutine을 늘리는 것만으로도 훌륭한 성능을 뽑아준다.

예를 들어 실제 경험했던 사례는 10000x20000이미지를 렌더링 할때, 줄 단위로 총 20000만개의 goroutine을 생성하는 형태로 goroutine을 이용했는데 이때 거의 CPU 스레드 개수에 거의 근접한 성능 향상을 경험한 적이 있었다.

만약 이게 스레드였다면 복잡한 작업 분배를 거쳐야 하지만, gorountine은 이런 상황에서는 매우 간편하게 성능 향상을 경험할 수 있다

이런 장점이 있어 나는 gorountine을 매우 선호하지만, 반면에 불편하게 생각하는 점도 있다.

일단 기본적으로 goroutine은 동기화를 수행하는 것이 번거롭다.

아래 코드를 생각해 보자.

ch := make(chan int)
defer close(ch)
go func(){
	ch <- 10
}()
i := <- ch

위 코드에서는 goroutine의 리턴값 하나를 받기 위해 채널을 생성하고, 닫고, 수신하고, 송신하는 코드가 들어갔다.

그래서 이런 상황에 만약 이런식의 문법이 작동한다면 어떨까? 라고 항상 생각해 왔다.

i := await go func(){
	return 10
}()

즉, gorountine의 expression평가를 Promise[T] 형태로 추론할 수 있는 문법을 원했다.

실제로 사람 생각은 다들 비슷한지 이런 일을 해주는 go 라이브러리가 간혹 레딧에서 보이기도 했는데 지금까지 그런 라이브러리들을 사용하지 않은 이유는 아래와 같았기 때문이다.

var i = promise.Go(func(){return 1}).Await()

여기서 i 타입은 다들 당연히 int 형이라고 생각했겠지만, 과거 go는 제네릭이 없어서 이런 라이브러리들은 항상 interface{}타입을 사용했다.

따라서 이 i 값을 사용하려면 항상 .(int), 즉 타입 어설션을 통해 반드시 강제로 타입을 변경해서 사용해야 했다.

이런 과정은 번거로울 뿐더러 타입 어설션 자체가 성능을 약간씩 잡아먹는다는 거슬리는 점도 있었다.

하지만 이제는 제네릭이 있으니, 과거에는 번거롭고 거슬려서 안쓰던 Promise 스타일의 Goroutine 래퍼를 작성해 보도록 하겠다.

지금부터 만들 Promise Like한 코드는 ECMA 스크립트의 Promise 표준과 유사하게 구현하였다.

실제 구현

전체 코드는 해당 github 저장소에 있다.

여기서는 함수 구현부까지 보여주면 너무 길어지므로 함수와, 메서드, 구조체 시그니처만 적어보도록 하겠다.

package promise

import (
	"sync"
)

type Promise[T any] struct {
	result  chan T
	fail    chan error
	then    []func(T)
	catch   []func(error)
	finally []func()
}

func New[T any](handler func(resolve func(T), reject func(error))) *Promise[T] { ... }
func Resolve[T any](data T) *Promise[T] { ... }
func Reject[T any](data error) *Promise[T] {...}

func (prom *Promise[T]) Await() (T, error) {...}
func (prom *Promise[T]) Then(handler func(T)) *Promise[T] {...}
func (prom *Promise[T]) Catch(handler func(error)) *Promise[T] {...}
func (prom *Promise[T]) Finally(handler func()) *Promise[T] {...}

func All[T any](promises ...*Promise[T]) *Promise[[]T] { ... }

아마 nodejs를 아는 분들이라면 굳이 설명도 필요 없겠지만, 하나 차이가 있다면 .Await 메서드인데, 이는 go 문법에는 await 표현식이 없기 때문에 메서드로 await를 유사하게 구현하였다.

await를 promise 앞이 아니라 . 뒤에 붙이는 방식은 rust에서 본 방법이기도 하다.
개인적으로는, await가 앞에 붙는것을 더 선호하지만 사실 코딩하다 보면 뒤에 있는게 편하기도 했다..

그래서 이를 실제 사용한다면 이런 식으로 작성할 수 있다.

foo := promise.New(func(resolve func(string), reject func(error)){
	resolve("Hello")
})
bar := promise.New(func(resolve func(string), reject func(error)){
	resolve("Promise")
})
ok, _ := promise.all(foo, bar).Await()
msg := ok[0] + ok[1]
// msg는 'HelloPromise', 혹은 'PromiseHello'이다.
// 이는 내 구현에서 promise.New가 goroutine을 이용하기 때문이다.

해당 구현에는 기존 ECMA 기반 promise와 많은 차이점이 있지만 내가 생각하기에 가장 크고 불편한 차이점은 Promise.all의 타입 추론이다.

우선 js는 타입 추론을 못하지만, 사실상 표준 후계자나 다름없는 타입스크립트 덕에 Promise.all은 다음과 같은 시그니처를 가진다.


    /**
     * Creates a Promise that is resolved with an array of results when all of the provided Promises
     * resolve, or rejected when any Promise is rejected.
     * @param values An array of Promises.
     * @returns A new Promise.
     */
    all<T1, T2, T3>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>]): Promise<[T1, T2, T3]>;

    /**
     * Creates a Promise that is resolved with an array of results when all of the provided Promises
     * resolve, or rejected when any Promise is rejected.
     * @param values An array of Promises.
     * @returns A new Promise.
     */
    all<T1, T2>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>]): Promise<[T1, T2]>;

    /**
     * Creates a Promise that is resolved with an array of results when all of the provided Promises
     * resolve, or rejected when any Promise is rejected.
     * @param values An array of Promises.
     * @returns A new Promise.
     */
    all<T>(values: readonly (T | PromiseLike<T>)[]): Promise<T[]>;

출처 : lib.es2015.promise.d.ts#L35-L113

글에는 길이를 감안해 생략했지만 T1, ... T10까지의 Promise.all에 대한 오버로딩이 존재하고, 타입스크립트는 크기가 정해진 배열을 튜플로 사용 가능하기에 Promise.all의 매개변수가 다른 타입들을 가져도 타입 추론까지 가능하다.(단 T10까지 있으니 최대 10개 이하의 길이까지만 가능하다.)

여기서 가장 중요한 것은 튜플인데, 파이썬, 러스트, 타입스크립트등등을 다루면서 쓸모없는 보일러플레이트를 제거할 수 있는 가장 좋은 문법적 설탕은 튜플이였다.

그런데 안타깝게도, Go는 튜플을 지원하지 않는다.

함수 오버로딩도 마찬가지다.

따라서 불가피하게 Promise.all은 하나의 타입 배열만 지원하게 되었다.

만약 차후에 이 기능에 대한 업데이트가 있다면 다시 한번 다뤄보고 싶은 부분이 이 부분이다.

profile
수준 높은 기술 포스트를 위해서 노력중...

0개의 댓글