Go는 언어 차원에서 동시성 실행을 지원합니다. 함수 호출 앞에 go를 붙이면 새로운 goroutine에서 함수를 실행할 수 있고, channel을 사용하면 goroutine 사이에서 값을 전달하면서 실행 순서도 동기화할 수 있습니다.go f()를 호출하면 f는 현재 goroutine과 별도로 실행됩니다. 하지만 새 goroutine을 시작했다는 사실만으로 프로그램이 그 작업의 종료를 기다려 주지는 않습니다. main 함수가 반환되면 실행 중인 다른 goroutine이 남아 있더라도 process가 종료되므로, 결과가 필요한 작업은 channel이나 sync.WaitGroup 같은 명시적인 동기화 수단으로 기다려야 합니다.
디렉터리 구조는 다음과 같이 구성합니다.
sources/
└── goroutines-channels/
└── main.go
sources 디렉터리에서 실행합니다.
cd sources
go run ./goroutines-channels
Goroutine의 기본 동작
일반 함수 호출은 함수가 끝날 때까지 다음 코드로 진행하지 않습니다.
package main
import "fmt"
func printMessage(message string) {
fmt.Println(message)
}
func main() {
printMessage("작업 완료")
fmt.Println("main 종료")
}
실행 결과는 호출 순서대로 출력됩니다.
작업 완료
main 종료
함수 호출 앞에 go를 붙이면 새로운 goroutine에서 실행됩니다.
package main
import "fmt"
func printMessage(message string) {
fmt.Println(message)
}
func main() {
go printMessage("작업 완료")
fmt.Println("main 종료")
}
이 코드는 작업 완료가 출력되지 않을 수 있습니다. 새 goroutine이 실행되기 전에 main이 종료될 수 있기 때문입니다.
goroutine의 실행 순서는 고정되어 있지 않습니다. 출력 순서나 실행 시점을
time.Sleep으로 맞추는 방식은 정확한 동기화가 아닙니다.
다음과 같이 임의로 기다리는 코드는 예제에서는 동작할 수 있지만, 실제 작업이 언제 끝날지 보장하지 못합니다.
func main() {
go printMessage("작업 완료")
time.Sleep(100 * time.Millisecond)
}
작업 완료를 기다려야 한다면 channel 또는 sync.WaitGroup을 사용해야 합니다.
WaitGroup으로 goroutine 종료 기다리기
sync.WaitGroup은 여러 goroutine의 종료를 기다릴 때 사용합니다.
package main
import (
"fmt"
"sync"
)
func runTask(taskName string, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("%s 완료\n", taskName)
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go runTask("작업 A", &wg)
go runTask("작업 B", &wg)
wg.Wait()
fmt.Println("모든 작업 완료")
}
실행 결과에서 작업 A와 작업 B의 순서는 달라질 수 있지만, 모든 작업 완료는 두 작업이 모두 끝난 뒤 출력됩니다.
작업 B 완료
작업 A 완료
모든 작업 완료
각 메서드의 역할은 다음과 같습니다.
| 메서드 | 역할 |
|---|---|
Add(n) |
기다릴 작업 수를 n만큼 증가 |
Done() |
작업 하나가 끝났음을 알림 |
Wait() |
작업 수가 0이 될 때까지 대기 |
Done() 호출을 빠뜨리지 않도록 일반적으로 함수 시작 부분에 defer wg.Done()을 작성합니다.
func runTask(wg *sync.WaitGroup) {
defer wg.Done()
// 실제 작업
}
Add는 새로운 goroutine 안이 아니라 goroutine을 시작하기 전에 호출하는 것이 안전합니다.
wg.Add(1)
go func() {
defer wg.Done()
// 작업
}()
다음처럼 작성하면 Wait가 Add보다 먼저 실행될 가능성이 생길 수 있으므로 피해야 합니다.
go func() {
wg.Add(1)
defer wg.Done()
// 작업
}()
wg.Wait()
Channel로 값 전달하기
channel은 특정 타입의 값을 goroutine 사이에서 전달하는 통로입니다.
resultChannel := make(chan int)
chan int는 int 값을 전달하는 channel입니다.
값을 보내려면 channel 오른쪽에 <-를 사용합니다.
resultChannel <- 100
값을 받으려면 channel 왼쪽에 <-를 사용합니다.
result := <-resultChannel
다음 예제는 goroutine이 계산한 결과를 main goroutine으로 전달합니다.
package main
import "fmt"
func sum(a, b int, resultChannel chan int) {
resultChannel <- a + b
}
func main() {
resultChannel := make(chan int)
go sum(10, 20, resultChannel)
result := <-resultChannel
fmt.Println(result)
}
실행 결과:
30
main goroutine은 다음 코드에서 값을 받을 수 있을 때까지 기다립니다.
result := <-resultChannel
반대로 sum goroutine도 수신하는 쪽이 준비될 때까지 송신 지점에서 기다릴 수 있습니다. 이처럼 channel은 값 전달뿐 아니라 goroutine 사이의 실행 순서를 맞추는 동기화 역할도 합니다.
Unbuffered channel의 동기화
buffer 크기를 지정하지 않고 만든 channel은 unbuffered channel입니다.
ch := make(chan string)
unbuffered channel에서는 송신 goroutine과 수신 goroutine이 모두 준비되어야 값 전달이 완료됩니다.
package main
import "fmt"
func main() {
done := make(chan struct{})
go func() {
fmt.Println("작업 시작")
fmt.Println("작업 완료")
done <- struct{}{}
}()
<-done
fmt.Println("main 종료")
}
실행 결과:
작업 시작
작업 완료
main 종료
struct{}는 저장할 데이터가 없는 빈 구조체입니다. 완료 신호만 전달할 때 chan struct{}를 자주 사용합니다.
done := make(chan struct{})
이 예제에서 channel은 실제 결과 값을 전달하기보다 작업 완료 사실을 알리는 신호로 사용됩니다.
Buffered channel의 동작
buffered channel은 make의 두 번째 인수로 buffer 크기를 지정합니다.
ch := make(chan int, 2)
buffer에 빈 공간이 있으면 수신자가 즉시 준비되어 있지 않아도 송신이 완료될 수 있습니다.
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
fmt.Println(<-ch)
fmt.Println(<-ch)
}
실행 결과:
10
20
buffer 크기가 2이므로 두 번의 송신은 즉시 저장될 수 있습니다. 하지만 buffer가 가득 찬 상태에서 추가로 송신하면 빈 공간이 생길 때까지 대기합니다.
ch := make(chan int, 2)
ch <- 10
ch <- 20
ch <- 30 // 수신자가 없으므로 여기서 대기
channel buffer는 단순히 속도를 높이는 설정이 아닙니다. buffer 유무와 크기에 따라 송신이 대기하는 시점이 달라지고, 프로그램의 동기화 의미도 달라집니다.
| channel | 송신 완료 조건 |
|---|---|
| unbuffered | 수신자가 값을 받을 준비가 되어 있어야 함 |
| buffered | buffer에 빈 공간이 있으면 완료 가능 |
buffer를 크게 만들면 일부 대기가 줄어들 수 있지만 다음 문제를 숨길 수도 있습니다.
- 생산 속도가 소비 속도보다 계속 빠른 구조
- 소비 goroutine이 중단된 상태
- 종료 신호 또는 취소 처리가 없는 구조
- 예상보다 많은 값을 메모리에 쌓는 구조
따라서 buffer 크기는 처리량, 동시에 대기할 수 있는 작업 수, 생산자와 소비자의 속도 차이를 근거로 정해야 합니다.
송신 전용과 수신 전용 channel
함수 parameter에 channel 방향을 명시하면 잘못된 사용을 compile time에 막을 수 있습니다.
chan T
송신과 수신이 모두 가능한 channel입니다.
chan<- T
송신만 가능한 channel입니다.
<-chan T
수신만 가능한 channel입니다.
다음 예제에서 produce는 값을 보내기만 하고, consume은 값을 받기만 합니다.
package main
import "fmt"
func produce(out chan<- int) {
out <- 100
}
func consume(in <-chan int) {
value := <-in
fmt.Println(value)
}
func main() {
ch := make(chan int)
go produce(ch)
consume(ch)
}
함수 내부에서 허용되지 않은 방향으로 사용하면 compile error가 발생합니다.
func produce(out chan<- int) {
value := <-out // compile error: 송신 전용 channel에서 수신 불가
fmt.Println(value)
}
func consume(in <-chan int) {
in <- 100 // compile error: 수신 전용 channel에 송신 불가
}
양방향 channel은 함수에 전달될 때 송신 전용 또는 수신 전용 channel로 제한할 수 있습니다.
ch := make(chan int)
go produce(ch)
consume(ch)
방향을 함수 signature에 표시하면 함수가 channel을 어떻게 사용하는지 바로 알 수 있고, 수신 측이 channel을 닫거나 송신 측이 값을 읽는 실수를 줄일 수 있습니다.
Channel 닫기와 range 수신
close는 channel에 더 이상 값을 보내지 않는다는 사실을 수신 측에 알립니다.
close(ch)
일반적으로 값을 모두 보낸 송신 측이 channel을 닫습니다.
package main
import "fmt"
func produce(out chan<- int) {
defer close(out)
for number := 1; number <= 3; number++ {
out <- number
}
}
func main() {
numbers := make(chan int)
go produce(numbers)
for number := range numbers {
fmt.Println(number)
}
}
실행 결과:
1
2
3
for range는 channel에서 값을 계속 받다가 channel이 닫히고 남아 있는 값까지 모두 처리되면 반복을 종료합니다.
channel을 닫는 것은 자원을 해제하는 동작이라기보다 더 이상 값이 오지 않는다는 신호입니다. 모든 channel을 반드시 닫아야 하는 것은 아닙니다. 수신 측이 종료를 판단해야 하거나 range 반복을 끝내야 할 때 닫는 것이 필요합니다.
닫힌 channel에 값을 보내면 panic이 발생합니다. 이미 닫힌 channel을 다시 닫아도 panic이 발생합니다. 여러 송신 goroutine이 하나의 channel을 함께 사용한다면 누가 언제 닫을지 명확하게 설계해야 합니다.
값을 하나씩 받으면서 channel이 닫혔는지 확인할 수도 있습니다.
value, ok := <-ch
| 값 | 의미 |
|---|---|
ok == true |
channel에서 정상적으로 값을 받음 |
ok == false |
channel이 닫혔고 더 받을 값이 없음 |
예제:
package main
import "fmt"
func main() {
ch := make(chan int, 1)
ch <- 10
close(ch)
value, ok := <-ch
fmt.Println(value, ok)
value, ok = <-ch
fmt.Println(value, ok)
}
실행 결과:
10 true
0 false
닫힌 channel에서 남은 값을 모두 받은 뒤 다시 수신하면 해당 타입의 zero value와 false가 반환됩니다.
여러 작업 결과 수집하기
여러 goroutine이 처리한 결과를 하나의 channel로 모을 수 있습니다.
package main
import (
"fmt"
"sync"
)
type Result struct {
TaskID int
Value int
}
func calculate(taskID, number int, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
results <- Result{
TaskID: taskID,
Value: number * number,
}
}
func main() {
var wg sync.WaitGroup
results := make(chan Result)
numbers := []int{2, 3, 4}
wg.Add(len(numbers))
for index, number := range numbers {
taskID := index + 1
go calculate(taskID, number, results, &wg)
}
go func() {
wg.Wait()
close(results)
}()
for result := range results {
fmt.Printf("작업 %d 결과: %d\n", result.TaskID, result.Value)
}
}
실행 순서는 매번 달라질 수 있습니다.
작업 3 결과: 16
작업 1 결과: 4
작업 2 결과: 9
이 예제에서 역할은 다음과 같이 나뉩니다.
- 작업 goroutine은 계산 결과를
results에 보냅니다. WaitGroup은 모든 작업의 종료를 확인합니다.- 별도의 goroutine은 모든 작업이 끝난 뒤
results를 닫습니다. main은range로 결과를 수신합니다.
main에서 먼저 wg.Wait()를 호출한 뒤 결과를 읽으면 unbuffered channel 송신이 막혀 deadlock이 발생할 수 있습니다.
wg.Wait()
for result := range results {
fmt.Println(result)
}
작업 goroutine은 results <- value에서 수신자를 기다리고, main은 작업 goroutine이 끝나기를 기다리는 상태가 되기 때문입니다. 따라서 결과를 수신하는 흐름과 작업 종료를 기다리는 흐름이 서로 막지 않도록 설계해야 합니다.
select로 여러 channel 기다리기
select는 여러 channel 송수신 중 실행 가능한 case를 선택합니다.
package main
import (
"fmt"
"time"
)
func main() {
resultChannel := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
resultChannel <- "처리 완료"
}()
select {
case result := <-resultChannel:
fmt.Println(result)
case <-time.After(500 * time.Millisecond):
fmt.Println("시간 초과")
}
}
작업 결과가 먼저 도착하면 결과를 출력하고, 지정한 시간이 먼저 지나면 시간 초과를 출력합니다.
처리 완료
여러 case가 동시에 실행 가능한 경우에는 그중 하나가 선택됩니다. 따라서 select case의 작성 순서가 우선순위를 보장하지는 않습니다.
default를 추가하면 기다리지 않는 non-blocking 동작을 만들 수 있습니다.
select {
case value := <-ch:
fmt.Println(value)
default:
fmt.Println("받을 값이 없습니다")
}
다만 반복문에서 default를 계속 실행하면 CPU를 불필요하게 사용할 수 있으므로 실제 필요성이 있을 때만 사용해야 합니다.
context로 goroutine 취소하기
서버 요청, 외부 API 호출, 장시간 처리처럼 중간 취소가 필요한 작업에는 context.Context를 사용하는 것이 일반적입니다.
package main
import (
"context"
"fmt"
"time"
)
func run(ctx context.Context, results chan<- int) {
for number := 1; ; number++ {
select {
case <-ctx.Done():
fmt.Println("작업 취소")
return
case results <- number:
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
results := make(chan int)
go run(ctx, results)
for result := range results {
fmt.Println(result)
if result == 3 {
cancel()
break
}
}
time.Sleep(10 * time.Millisecond)
}
실행 결과:
1
2
3
작업 취소
ctx.Done()은 취소 또는 deadline 만료 시 닫히는 수신 전용 channel입니다. 작업 goroutine은 select에서 이 신호를 확인하고 종료할 수 있습니다.
실제 프로그램에서는 취소 후 goroutine이 완전히 끝났는지 WaitGroup이나 별도의 완료 channel로 확인하는 것이 더 안전합니다. 위 예제의 마지막 Sleep은 출력 확인을 위한 단순화이며, 작업 완료 동기화의 대체 수단으로 사용하면 안 됩니다.
Deadlock이 발생하는 경우
channel 송수신 상대가 존재하지 않으면 goroutine이 계속 기다릴 수 있습니다. 모든 goroutine이 더 이상 진행할 수 없는 상태가 되면 runtime은 deadlock을 감지할 수 있습니다.
다음 코드는 값을 받을 goroutine이 없으므로 송신에서 멈춥니다.
package main
func main() {
ch := make(chan int)
ch <- 10
}
실행하면 다음과 비슷한 오류가 발생합니다.
fatal error: all goroutines are asleep - deadlock!
수신만 있고 송신이 없는 경우도 같습니다.
func main() {
ch := make(chan int)
value := <-ch
_ = value
}
buffered channel도 무조건 deadlock을 막아 주는 것은 아닙니다.
func main() {
ch := make(chan int, 1)
ch <- 10
ch <- 20
}
첫 번째 값이 buffer를 채운 뒤 두 번째 송신은 수신자를 기다리므로 멈춥니다.
channel을 사용할 때는 다음 흐름을 확인해야 합니다.
- 누가 값을 보내는가?
- 누가 값을 받는가?
- 송신과 수신 횟수가 맞는가?
- 누가 channel을 닫는가?
- 수신 측은 언제 반복을 종료하는가?
- 취소되거나 오류가 발생하면 goroutine이 종료되는가?
Nil channel의 동작
선언만 하고 make로 초기화하지 않은 channel의 zero value는 nil입니다.
var ch chan int
nil channel에 대한 송신과 수신은 계속 대기합니다.
ch <- 10
value := <-ch
nil channel을 닫으면 panic이 발생합니다.
close(ch)
select에서는 nil channel의 case가 선택되지 않습니다. 이 성질을 이용해 특정 channel case를 동적으로 비활성화할 수 있지만, 초보 단계에서는 초기화하지 않은 channel 때문에 goroutine이 멈추지 않았는지 먼저 확인하는 것이 중요합니다.
공유 변수와 data race
여러 goroutine이 같은 변수를 동시에 읽고 쓰면 data race가 발생할 수 있습니다.
package main
import "sync"
func main() {
var wg sync.WaitGroup
count := 0
for index := 0; index < 1000; index++ {
wg.Add(1)
go func() {
defer wg.Done()
count++
}()
}
wg.Wait()
}
count++는 하나의 원자적 연산이 아니므로 여러 goroutine이 동시에 실행하면 결과를 신뢰할 수 없습니다.
race detector로 확인할 수 있습니다.
go run -race ./goroutines-channels
공유 상태를 보호하는 대표적인 방법은 다음과 같습니다.
- channel을 통해 값이나 소유권을 전달
sync.Mutex로 공유 변수 보호- 단순한 수치 연산에는
sync/atomic검토
channel이 모든 공유 상태 문제를 자동으로 해결하는 것은 아닙니다. channel로 pointer, slice, map을 전달한 뒤 여러 goroutine이 같은 내부 데이터를 동시에 수정하면 여전히 data race가 발생할 수 있습니다.
전체 예제
다음 예제는 worker goroutine, 작업 channel, 결과 channel, channel 방향, WaitGroup, close, range를 함께 사용합니다.
main.go:
package main
import (
"fmt"
"sync"
)
type Job struct {
ID int
Value int
}
type Result struct {
JobID int
Value int
}
func worker(
workerID int,
jobs <-chan Job,
results chan<- Result,
wg *sync.WaitGroup,
) {
defer wg.Done()
for job := range jobs {
calculatedValue := job.Value * job.Value
fmt.Printf(
"worker %d: 작업 %d 처리\n",
workerID,
job.ID,
)
results <- Result{
JobID: job.ID,
Value: calculatedValue,
}
}
}
func main() {
const workerCount = 2
jobs := make(chan Job)
results := make(chan Result)
var workerGroup sync.WaitGroup
workerGroup.Add(workerCount)
for workerID := 1; workerID <= workerCount; workerID++ {
go worker(workerID, jobs, results, &workerGroup)
}
go func() {
for jobID, value := range []int{2, 3, 4, 5} {
jobs <- Job{
ID: jobID + 1,
Value: value,
}
}
close(jobs)
}()
go func() {
workerGroup.Wait()
close(results)
}()
for result := range results {
fmt.Printf(
"작업 %d 결과: %d\n",
result.JobID,
result.Value,
)
}
fmt.Println("모든 작업 완료")
}
실행합니다.
cd sources
go run ./goroutines-channels
출력 예:
worker 1: 작업 1 처리
worker 1: 작업 3 처리
작업 1 결과: 4
작업 3 결과: 16
worker 2: 작업 2 처리
worker 2: 작업 4 처리
작업 2 결과: 9
작업 4 결과: 25
모든 작업 완료
worker와 결과 출력 순서는 실행할 때마다 달라질 수 있습니다. 중요한 것은 다음 순서가 보장된다는 점입니다.
jobs가 닫히면 worker의for range가 종료됩니다.- 모든 worker가 종료되면
workerGroup.Wait()가 반환됩니다. - 이후
results가 닫힙니다. results의 모든 값을 받은 뒤main의for range가 종료됩니다.- 마지막에
모든 작업 완료가 출력됩니다.
정리
| 용어 | 의미 |
|---|---|
| goroutine | Go runtime이 관리하는 동시 실행 단위 |
go f() |
f를 새로운 goroutine에서 실행 |
| channel | goroutine 사이에서 값을 전달하고 실행을 동기화하는 통로 |
| unbuffered channel | 송신자와 수신자가 준비되어야 값 전달 완료 |
| buffered channel | 빈 buffer가 있으면 수신자가 즉시 없어도 송신 가능 |
chan<- T |
T 값을 보내기만 하는 channel |
<-chan T |
T 값을 받기만 하는 channel |
close(ch) |
더 이상 값을 보내지 않음을 알림 |
sync.WaitGroup |
여러 goroutine의 종료를 기다리는 동기화 도구 |
select |
여러 channel 연산 중 실행 가능한 case 선택 |
go f()는 함수를 동시에 실행하도록 시작할 뿐, 종료를 기다리거나 결과를 자동으로 수집하지 않습니다. 결과와 완료 시점이 중요하다면 channel이나 sync.WaitGroup으로 명시적으로 동기화해야 합니다.
channel은 단순한 값 저장소가 아닙니다. 송신과 수신이 대기하는 시점에 따라 goroutine의 실행 순서를 조절합니다. 특히 buffer 크기는 성능뿐 아니라 프로그램의 동기화 방식에도 영향을 주므로 이유 없이 크게 설정하지 않는 것이 좋습니다.
함수 signature에는 가능한 경우 chan<- T와 <-chan T를 사용해 channel의 책임을 제한하는 것이 좋습니다. 값을 모두 보낸 송신 측이 channel을 닫고, 수신 측은 range 또는 value, ok := <-ch로 종료를 판단하는 구조가 명확합니다.
동시성 코드는 실행 순서가 매번 달라질 수 있으므로 출력 순서에 의존하지 않아야 합니다. 공유 데이터를 직접 수정한다면 channel만으로 안전하다고 가정하지 말고, go run -race 또는 go test -race로 data race도 확인해야 합니다.
참고 자료
- Go 언어 사양 - Go statements: https://go.dev/ref/spec#Go_statements
- Go 언어 사양 - Channel types: https://go.dev/ref/spec#Channel_types
- Go 언어 사양 - Send statements: https://go.dev/ref/spec#Send_statements
- Go 언어 사양 - Receive operator: https://go.dev/ref/spec#Receive_operator
- Go 공식 문서 - Effective Go, Concurrency: https://go.dev/doc/effective_go#concurrency
- Go 공식 블로그 - Pipelines and cancellation: https://go.dev/blog/pipelines
- Go 표준 라이브러리 - sync.WaitGroup: https://pkg.go.dev/sync#WaitGroup
- Go 공식 문서 - Data Race Detector: https://go.dev/doc/articles/race_detector
'프로그래밍 > Go' 카테고리의 다른 글
| Go context (0) | 2026.07.21 |
|---|---|
| Go select, timeout, close, context (0) | 2026.07.20 |
| Go embedding과 메서드 승격 이해하기 (0) | 2026.07.15 |
| Go 인터페이스의 암시적 구현과 Java 인터페이스와의 차이 (0) | 2026.07.15 |
| Go struct와 receiver (0) | 2026.07.15 |
댓글