🔥 채널: 값을 주고받는 통로

1294자
15분

Go에서 채널은 값을 주고받을 수 있는 타입이 지정된 통로라 할 수 있어요. 채널 연산자 <-를 사용하면 채널을 통해 값을 보내거나 받을 수 있답니다.

ch <- v    // v를 채널 ch로 보냅니다.
v := <-ch  // ch에서 값을 받아
           // v에 할당합니다.
 
go

(데이터는 화살표 방향으로 흐릅니다.)

맵이나 슬라이스와 마찬가지로, 채널도 사용하기 전에 생성해야 해요:

ch := make(chan int)
 
go

기본적으로 값을 보내거나 받을 때는 상대편이 준비될 때까지 블록됩니다. 이를 통해 고루틴들이 명시적인 잠금이나 조건 변수 없이도 동기화할 수 있어요.

아래 예제 코드는 슬라이스의 숫자들을 합산하되, 두 개의 고루틴 사이에 작업을 분배합니다. 두 고루틴이 모두 계산을 완료하면, 최종 결과를 계산하죠.

package main
 
import "fmt"
 
func sum(s []int, c chan int) {
	sum := 0
	for _, v := range s {
		sum += v  // 슬라이스의 각 요소를 더합니다.
	}
	c <- sum     // 합계를 c로 보냅니다.
}
 
func main() {
	s := []int{7, 2, 8, -9, 4, 0}
 
	c := make(chan int)
	go sum(s[:len(s)/2], c)  // 슬라이스 앞부분을 더하는 고루틴
	go sum(s[len(s)/2:], c)  // 슬라이스 뒷부분을 더하는 고루틴
	x, y := <-c, <-c         // c에서 결과를 받습니다.
 
	fmt.Println(x, y, x+y)   // 부분합과 전체합을 출력합니다.
}
 
go

위 코드를 단계별로 살펴보면:

  1. sum 함수는 정수 슬라이스 s와 정수형 채널 c를 매개변수로 받아요.
  2. 슬라이스의 모든 요소를 순회하며 sum 변수에 더합니다.
  3. 계산된 부분합을 채널 c로 보냅니다.
  4. main 함수에서는 정수 슬라이스 s를 준비하고,
  5. 정수형 채널 c를 생성합니다.
  6. go 키워드를 사용해 두 개의 고루틴을 시작하는데, 하나는 슬라이스의 앞부분을, 다른 하나는 뒷부분을 더하도록 sum 함수를 호출해요.
  7. 각 고루틴에서 계산한 부분합을 채널 c를 통해 받아 xy에 할당합니다.
  8. 마지막으로 x, y, 그리고 이들의 합을 출력합니다.

위 코드 중 go sum(s[:len(s)/2], c)을 보면 채널 c를 사용하는 방식이 콜백 함수 패턴과 유사하다고 볼 수 있어요.

콜백 함수 패턴에서는 어떤 함수를 호출할 때 그 함수가 완료된 후 실행될 함수(콜백)를 같이 전달하죠. 호출된 함수는 작업을 완료한 후 전달받은 콜백 함수를 실행함으로써 호출자에게 결과를 알려주는 식입니다.

Go 채널을 사용하면 이와 비슷한 패턴을 구현할 수 있어요. 고루틴을 시작할 때 채널을 함께 전달하고, 고루틴은 작업을 완료한 후 그 채널을 통해 결과를 보내는 거죠. 호출자는 채널에서 결과를 읽음으로써 고루틴의 완료를 알 수 있습니다.

예를 들어, go sum(s[:len(s)/2], c)에서는 sum 함수를 고루틴으로 실행하면서 채널 c를 전달하고 있어요. sum 함수는 계산을 완료한 후 그 결과를 채널 c를 통해 보내고, 호출자는 x, y := <-c, <-c에서 그 결과를 받아옵니다.

이렇게 채널을 사용하면 고루틴 간의 통신과 동기화를 간편하게 처리할 수 있어요. 콜백 함수를 사용할 때처럼 비동기적인 실행 흐름을 관리할 수 있으면서도, 고루틴 간의 데이터 전달이 명확해지는 장점이 있죠.

물론 콜백 함수와 채널이 완전히 같은 개념은 아니에요. 콜백은 함수를 값으로 다루는 데 초점이 맞춰져 있고, 채널은 통신과 동기화에 더 특화되어 있습니다. 하지만 비동기적인 실행 흐름을 관리한다는 점에서는 유사한 면이 있다고 할 수 있겠죠?

이처럼 채널을 사용하면 고루틴 간에 값을 안전하게 교환할 수 있답니다. 고루틴들이 서로 기다리며 동기화하는 것도 간단히 처리할 수 있죠.

Swift의 Actor를 흉내내는 채널 활용 예제

Go의 채널을 사용하면 Swift의 Actor와 유사한 동작을 구현할 수 있어요. 아래는 은행 계좌를 모델링하는 예제인데, 각 계좌를 고루틴으로 표현하고 채널을 통해 메시지를 주고받도록 해볼게요.

package main
 
import (
    "fmt"
    "sync"
)
 
type BankAccount struct {
    balance int
    ch      chan func()
}
 
func NewBankAccount(initialBalance int) *BankAccount {
    account := &BankAccount{
        balance: initialBalance,
        ch:      make(chan func()),
    }
    go func() {
        for message := range account.ch {
            message()  // 받은 메시지(함수)를 실행합니다.
        }
    }()
    return account
}
 
func (a *BankAccount) Deposit(amount int) {
    a.ch <- func() {
        a.balance += amount  // 잔액에 입금액을 더합니다.
    }
}
 
func (a *BankAccount) Withdraw(amount int) bool {
    result := make(chan bool)
    a.ch <- func() {
        if a.balance >= amount {
            a.balance -= amount  // 잔액에서 출금액을 뺍니다.
            result <- true
        } else {
            result <- false
        }
    }
    return <-result
}
 
func (a *BankAccount) Balance() int {
    result := make(chan int)
    a.ch <- func() {
        result <- a.balance  // 현재 잔액을 채널로 보냅니다.
    }
    return <-result
}
 
func main() {
    var wg sync.WaitGroup
    account := NewBankAccount(1000)
 
    // 여러 고루틴에서 계좌에 접근합니다.
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            account.Deposit(100)
            if account.Withdraw(50) {
                fmt.Println("출금 성공!")
            } else {
                fmt.Println("잔액 부족!")
            }
        }()
    }
 
    wg.Wait()
    fmt.Printf("최종 잔액: %d", account.Balance())
}
 
go

위 코드에서 BankAccount 구조체는 Swift의 Actor와 유사한 역할을 합니다. 각 계좌는 고유의 고루틴에서 실행되며, 상태를 변경하는 모든 연산은 채널을 통해 전달되는 메시지(함수)를 통해서만 가능해요.

특히 BankAccount 구조체의 ch 필드는 chan func() 타입인데요, 이는 "함수를 전달할 수 있는 채널"을 의미합니다. 즉, ch 채널을 통해 함수(메시지)를 전달하고, 계좌의 고루틴에서 이 함수를 실행함으로써 상태를 변경하는 거죠. 이렇게 함수를 메시지로 사용하면 액터가 수신한 메시지에 따라 내부 상태를 변경하는 것과 유사한 동작을 구현할 수 있습니다.

Deposit, Withdraw, Balance 메서드는 모두 채널을 통해 계좌의 고루틴에 메시지를 전송하고, 필요한 경우 결과를 다른 채널을 통해 받아옵니다.

main 함수에서는 여러 고루틴이 동시에 계좌에 접근하는 상황을 시뮬레이션합니다. 각 고루틴은 입금과 출금을 시도하고, 최종적으로 모든 고루틴이 완료된 후 계좌의 최종 잔액을 출력해요.

이렇게 Go의 채널과 고루틴을 활용하면 Swift의 Actor와 유사한 동시성 패턴을 구현할 수 있답니다. 물론 Go와 Swift는 언어적 특성이 다르므로 완전히 동일하지는 않지만, 메시지 전달을 통해 상태를 관리하는 기본 아이디어는 공유하고 있어요. 이런 방식으로 Go에서도 액터 모델의 장점을 활용할 수 있게 되는 거죠!

고루틴과 채널로 자바스크립트의 Promise와 async/await 흉내내기

Go의 고루틴과 채널을 사용하면 자바스크립트의 Promise와 async/await와 유사한 동작을 구현할 수 있어요. 아래는 그 예시 코드입니다.

package main
 
import (
    "fmt"
    "math/rand"
    "time"
)
 
// Result는 비동기 작업의 결과를 나타냅니다.
type Result struct {
    Value int
    Err   error
}
 
// Promise는 비동기 작업을 나타냅니다.
type Promise struct {
    ch chan Result
}
 
// NewPromise는 새로운 Promise를 생성합니다.
func NewPromise(fn func() (int, error)) *Promise {
    p := &Promise{
        ch: make(chan Result, 1),
    }
    go func() {
        value, err := fn()
        p.ch <- Result{Value: value, Err: err}
    }()
    return p
}
 
// Then은 Promise의 결과를 처리하는 메서드입니다.
func (p *Promise) Then(fn func(int) (int, error)) *Promise {
    return NewPromise(func() (int, error) {
        result := <-p.ch
        if result.Err != nil {
            return 0, result.Err
        }
        return fn(result.Value)
    })
}
 
// Await은 Promise의 결과를 기다리는 함수입니다.
func Await(p *Promise) (int, error) {
    result := <-p.ch
    return result.Value, result.Err
}
 
// 비동기 작업을 모사하는 함수입니다.
func asyncTask() (int, error) {
    time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
    if rand.Intn(2) == 0 {
        return 0, fmt.Errorf("async task failed")
    }
    return rand.Intn(100), nil
}
 
func main() {
    start := time.Now()
 
    p := NewPromise(asyncTask).Then(func(value int) (int, error) {
        fmt.Printf("첫 번째 비동기 작업 결과: %d\n", value)
        return asyncTask()
    }).Then(func(value int) (int, error) {
        fmt.Printf("두 번째 비동기 작업 결과: %d\n", value)
        return value, nil
    })
 
    result, err := Await(p)
    if err != nil {
        fmt.Printf("에러 발생: %v\n", err)
    } else {
        fmt.Printf("최종 결과: %d\n", result)
    }
 
    elapsed := time.Since(start)
    fmt.Printf("소요 시간: %s\n", elapsed)
}
 
go

이 코드에서는 Promise 구조체와 NewPromise, Then, Await 함수를 통해 자바스크립트의 Promise와 유사한 기능을 구현하고 있어요.

  • NewPromise 함수는 비동기 작업을 수행하는 함수를 받아 새로운 Promise를 생성합니다. 이 함수는 고루틴 내에서 실행되며, 작업의 결과는 채널을 통해 전달됩니다.
  • Then 메서드는 Promise의 결과를 받아 새로운 비동기 작업을 수행하고, 그 결과로 새로운 Promise를 반환합니다. 이를 통해 비동기 작업을 연쇄적으로 수행할 수 있죠.
  • Await 함수는 Promise의 결과를 기다리고, 결과와 에러를 반환합니다. 이는 자바스크립트의 await 키워드와 유사한 역할을 합니다.

main 함수에서는 asyncTask 함수를 사용하여 비동기 작업을 흉내 냅니다. 이 함수는 랜덤한 지연 시간 후에 결과 또는 에러를 반환합니다.

NewPromiseThen을 사용하여 비동기 작업을 연쇄적으로 수행하고, 마지막에 Await 함수를 호출하여 최종 결과를 얻습니다.

첫 번째 비동기 작업 결과: 96
두 번째 비동기 작업 결과: 65
최종 결과: 65
소요 시간: 709.187291ms
text

에러가 발생한 경우에는 에러를 처리하고, 그렇지 않으면 최종 결과를 출력하죠.

첫 번째 비동기 작업 결과: 71
에러 발생: async task failed
소요 시간: 936.864007ms
text

이렇게 Go의 고루틴과 채널을 활용하면 자바스크립트의 Promise와 async/await와 유사한 비동기 프로그래밍 패턴을 구현할 수 있답니다. 물론 Go의 동시성 모델과 자바스크립트의 비동기 모델은 차이가 있지만, 비동기 작업을 다루는 기본적인 아이디어는 공유하고 있어요.

이처럼 Go의 동시성 프리미티브는 매우 강력하고 유연해서, 다양한 동시성/비동기 패턴을 구현할 수 있답니다. 채널과 고루틴을 적절히 활용하면 복잡한 비동기 작업도 깔끔하고 안전하게 처리할 수 있게 되죠.