통신 메커니즘으로 채널 사용

완료됨

Go의 채널은 Goroutine 간 통신 메커니즘입니다. Go의 동시성 접근 방식을 다룰 때 "메모리를 공유하여 통신하는 것이 아니라 통신을 통해 메모리를 공유합니다."라고 말한 적이 있습니다. 한 goroutine의 값을 다른 값으로 전송해야 하는 경우 채널을 사용합니다. 그 작동 방식과 이를 사용해 동시 Go 프로그램을 작성하는 방법을 살펴보겠습니다.

채널 구문

채널은 데이터를 주고 받는 통신 메커니즘이므로 형식도 있습니다. 즉, 채널에서 지원하는 종류의 데이터만 보낼 수 있습니다. 키워드 chan를 채널의 데이터 형식으로 사용하지만 int 형식처럼 채널을 통해 전달되는 데이터 형식도 지정해야 합니다.

채널을 선언하거나 함수에서 채널을 매개 변수로 지정해야 할 때마다 chan int와 같은 chan <type>을 사용해야 합니다. 채널을 만들려면 기본 제공 make() 함수를 사용합니다.

ch := make(chan int)

채널은 두 가지 작업을 수행할 수 있는데, 데이터 전송과 데이터 수신입니다. 채널에 포함된 작업 유형을 지정하려면 채널 연산자 <-를 사용해야 합니다. 또한 채널에서 데이터를 주고 받으면 작업이 차단됩니다. 그 이유는 잠시 후에 알게 됩니다.

채널에서 데이터를 전송만 하도록 하려면 채널 다음에 <- 연산자를 사용합니다. 채널에서 데이터를 수신하도록 하려면 이 예에서와 같이 채널 앞에 <- 연산자를 사용합니다.

ch <- x // sends (or writes ) x through channel ch
x = <-ch // x receives (or reads) data sent to the channel ch
<-ch // receives data, but the result is discarded

채널에서 사용할 수 있는 다른 작업은 채널을 닫는 것입니다. 채널을 닫으려면 기본 제공 close() 함수를 사용합니다.

close(ch)

채널을 닫으면 해당 채널에서 다시 데이터가 전송되지 않습니다. 닫힌 채널에 데이터를 보내려고 하면 프로그램이 panic 상태가 됩니다. 그리고 닫힌 채널에서 데이터를 받으려 할 경우 전송된 모든 데이터를 읽을 수 있습니다. 모든 후속 ‘읽기’에서 0 값이 반환됩니다.

이전에 만든 프로그램으로 돌아가 채널을 사용하여 슬립 기능을 제거해 보겠습니다. 먼저 다음과 같이 main 함수에서 문자열 채널을 만들어 보겠습니다.

ch := make(chan string)

그리고 슬립 줄 time.Sleep(3 * time.Second)을 제거하겠습니다.

이제 Goroutine 간 통신에 채널을 사용할 수 있습니다. checkAPI 함수에서 결과를 출력하는 대신 코드를 리팩터링하고 채널을 통해 해당 메시지를 전송해보겠습니다. 해당 함수의 채널을 사용하려면 채널을 매개 변수로 추가해야 합니다. checkAPI 함수는 다음과 같아야 합니다.

func checkAPI(api string, ch chan string) {
    _, err := http.Get(api)
    if err != nil {
        ch <- fmt.Sprintf("ERROR: %s is down!\n", api)
        return
    }

    ch <- fmt.Sprintf("SUCCESS: %s is up and running!\n", api)
}

텍스트를 출력하면 안 되므로 fmt.Sprintf 함수를 사용해 채널 간에 형식이 지정된 텍스트만 전송해야 합니다. 또한 채널 변수 뒤에 <- 연산자를 사용하여 데이터를 보냅니다.

이제 다음과 같이 채널 변수를 보내고 출력할 데이터를 수신하도록 main 함수를 변경해야 합니다.

ch := make(chan string)

for _, api := range apis {
    go checkAPI(api, ch)
}

fmt.Print(<-ch)

채널 앞에 <- 연산자를 사용하므로 채널에서 데이터를 읽어 들여야 합니다.

프로그램을 다시 실행하면 다음과 같은 출력이 표시됩니다.

ERROR: https://api.somewhereintheinternet.com/ is down!

Done! It took 0.007401217 seconds!

적어도 슬립 함수를 호출하지 않고 작동하고 있죠? 하지만 아직 우리가 원하는 작업은 수행하지 않습니다. Goroutine 중 하나의 출력만 표시됩니다. 우리가 만든 것은 5개입니다. 다음 섹션에서 이 프로그램이 이런 방식으로 작동하는 이유를 살펴보겠습니다.

버퍼 없는 채널

make() 함수를 사용하여 채널을 만들 때 기본 동작인 버퍼 없는 채널이 만들어집니다. 버퍼 없는 채널은 사용자가 데이터를 받을 준비가 될 때까지 전송 작업을 차단합니다. 앞서 말한 것처럼, 송수신 중에는 작업이 차단됩니다. 이러한 차단 작업 역시 첫 번째 메시지를 수신하자마자 이전 섹션의 프로그램이 중지된 이유에 속합니다.

프로그램은 채널에서 읽고, 일부 데이터가 도착할 때까지 대기하기 때문에 fmt.Print(<-ch)이 프로그램을 차단한다고 말할 수 있습니다. 항목이 생기자마자 다음 줄로 진행되고 프로그램이 완료됩니다.

Goroutine 나머지는 어떻게 되었나요? 아직 실행 중이지만 하나는 더 이상 수신 대기하고 있지 않습니다. 그리고 프로그램이 일찍 완료되었기 때문에 일부 Goroutine은 데이터를 보내지 못했습니다. 이를 증명하기 위해 다음과 같이 다른 fmt.Print(<-ch)를 추가해 보겠습니다.

ch := make(chan string)

for _, api := range apis {
    go checkAPI(api, ch)
}

fmt.Print(<-ch)
fmt.Print(<-ch)

프로그램을 다시 실행하면 다음과 같은 출력이 표시됩니다.

ERROR: https://api.somewhereintheinternet.com/ is down!
SUCCESS: https://api.github.com is up and running!
Done! It took 0.263611711 seconds!

이제 두 API의 출력이 표시됩니다. fmt.Print(<-ch) 줄을 계속 추가할 경우 채널로 전송되는 모든 데이터를 읽게 됩니다. 그러면 데이터를 더 읽지 않아 아무도 데이터를 더 이상 전송하지 않으면 어떻게 될까요? 다음과 같은 예를 들 수 있습니다.

ch := make(chan string)

for _, api := range apis {
    go checkAPI(api, ch)
}

fmt.Print(<-ch)
fmt.Print(<-ch)
fmt.Print(<-ch)
fmt.Print(<-ch)
fmt.Print(<-ch)
fmt.Print(<-ch)

fmt.Print(<-ch)

프로그램을 다시 실행하면 다음과 같은 출력이 표시됩니다.

ERROR: https://api.somewhereintheinternet.com/ is down!
SUCCESS: https://api.github.com is up and running!
SUCCESS: https://management.azure.com is up and running!
SUCCESS: https://graph.microsoft.com is up and running!
SUCCESS: https://outlook.office.com/ is up and running!
SUCCESS: https://dev.azure.com is up and running!

작동은 하지만 프로그램이 완료되지 않습니다. 마지막 출력 줄은 데이터를 받을 것으로 예상하기 때문에 이를 차단하고 있습니다. Ctrl+C 같은 명령을 사용하여 프로그램을 닫아야 합니다.

이전의 예는 데이터를 읽고 받을 때 작업이 차단됨을 보여줍니다. 이 문제를 해결하려면 다음 예와 같이 for 루프의 코드를 변경하여 전송할 데이터를 받으면 됩니다.

for i := 0; i < len(apis); i++ {
    fmt.Print(<-ch)
}

버전에 문제가 발생한 경우 프로그램의 최종 버전은 다음과 같습니다.

package main

import (
    "fmt"
    "net/http"
    "time"
)

func main() {
    start := time.Now()

    apis := []string{
        "https://management.azure.com",
        "https://dev.azure.com",
        "https://api.github.com",
        "https://outlook.office.com/",
        "https://api.somewhereintheinternet.com/",
        "https://graph.microsoft.com",
    }

    ch := make(chan string)

    for _, api := range apis {
        go checkAPI(api, ch)
    }

    for i := 0; i < len(apis); i++ {
        fmt.Print(<-ch)
    }

    elapsed := time.Since(start)
    fmt.Printf("Done! It took %v seconds!\n", elapsed.Seconds())
}

func checkAPI(api string, ch chan string) {
    _, err := http.Get(api)
    if err != nil {
        ch <- fmt.Sprintf("ERROR: %s is down!\n", api)
        return
    }

    ch <- fmt.Sprintf("SUCCESS: %s is up and running!\n", api)
}

프로그램을 다시 실행하면 다음과 같은 출력이 표시됩니다.

ERROR: https://api.somewhereintheinternet.com/ is down!
SUCCESS: https://api.github.com is up and running!
SUCCESS: https://management.azure.com is up and running!
SUCCESS: https://dev.azure.com is up and running!
SUCCESS: https://graph.microsoft.com is up and running!
SUCCESS: https://outlook.office.com/ is up and running!
Done! It took 0.602099714 seconds!

이 프로그램은 수행해야 하는 작업을 수행합니다. 슬립 함수는 더 이상 사용하지 않고 채널만 사용합니다. 동시성을 사용하지 않을 때는 완료하는 데 거의 2초가 걸렸는데 이제는 약 600ms가 걸립니다.

마지막으로 버퍼 없는 채널이 송수신 작업을 동기화한다고 할 수 있습니다. 동시성을 사용하는 경우에도 통신이 동기화됩니다.