Go의 동시성을 책임지는 핵심 요소들, 고루틴(Goroutine), 채널(Channel), select 문, 그리고 Context까지 정리해보도록 하겠습니다.
1. 고루틴(Goroutine)
Go 언어의 동시성 프로그래밍은 바로 고루틴에서 출발합니다.
흔히 아는 '스레드'와 비슷하지만, 고루틴은 훨씬 더 가볍고 Go 런타임에 의해 관리되는 특별한 존재입니다.
- 진짜 가벼워요! 일반적인 운영체제(OS) 스레드가 몇 MB의 메모리를 사용하는 반면, 고루틴은 고작 몇 KB로 시작합니다. 필요에 따라 메모리 크기가 유연하게 조절되기 때문에, 수십만 개의 고루틴을 동시에 띄워도 시스템에 큰 부담을 주지 않아요.
- 쉬운 생성: 고루틴을 만드는 건 정말 간단합니다. 함수 호출 앞에
go키워드만 붙이면 끝이에요!
package main
import (
"fmt"
"time"
)
func say(word string) {
for i := 0; i < 3; i++ {
time.Sleep(100 * time.Millisecond) // 잠시 대기
fmt.Println(word)
}
}
func main() {
fmt.Println("메인 함수 시작")
go say("안녕하세요") // '안녕하세요'를 출력하는 고루틴 시작!
go say("Go Lang") // 'Go Lang'을 출력하는 또 다른 고루틴 시작!
time.Sleep(1 * time.Second) // 고루틴들이 작업할 시간을 벌어줌
fmt.Println("메인 함수 종료")
}
이 코드를 실행하면 안녕하세요와 Go Lang이 뒤죽박죽 섞여서 출력될 거예요.
이게 바로 두 개의 고루틴이 동시에 실행되고 있다는 증거입니다.
잠깐, 동시성(Concurrency)과 병렬성(Parallelism)은 달라요!
- 동시성은 여러 작업을 동시에 처리하는 것처럼 보이게 만드는 능력입니다. CPU 코어가 하나라도 여러 작업을 빠르게 번갈아 가며 처리하는 거죠. 마치 한 사람이 여러 개의 공을 저글링하는 것과 같아요.
- 병렬성은 여러 작업을 진짜로 동시에 실행하는 능력입니다. 멀티코어 CPU 환경에서 각 코어가 다른 작업을 실제로 처리하는 것을 의미합니다. 여러 사람이 각자 하나의 공을 저글링하는 것과 비슷하죠.
Go의 고루틴은 주로 동시성을 위한 도구지만, Go 런타임이 알아서 사용 가능한 CPU 코어에 고루틴을 분배해서 병렬성까지도 활용할 수 있도록 해줍니다.
2. 고루틴 간의 소통: 채널(Channel)
여러 고루틴이 각자 자기 일을 한다면, 서로에게 정보를 주고받아야 할 때가 생기겠죠? 이때 사용하는 것이 바로 채널(Channel) 입니다.
Go 언어는 "메모리를 공유하며 통신하지 말고, 통신하며 메모리를 공유하라(Don't communicate by sharing memory; instead, share memory by communicating.)"라는 철학을 가지고 있어요.
즉, 복잡한 락(Lock)이나 뮤텍스 없이 채널을 통해 메시지를 주고받는 방식으로 고루틴 간의 데이터를 안전하게 공유하는 것을 권장합니다.
채널은 고루틴 간의 데이터를 주고받는 '파이프'라고 생각하면 됩니다.
package main
import (
"fmt"
"time"
)
func sendData(ch chan int, data int) {
fmt.Printf("데이터 %d 전송 시작...\n", data)
time.Sleep(500 * time.Millisecond) // 전송 시간 시뮬레이션
ch <- data // 채널로 데이터 전송
fmt.Printf("데이터 %d 전송 완료!\n", data)
}
func main() {
dataChannel := make(chan int) // int 타입 데이터를 주고받을 채널 생성
go sendData(dataChannel, 100) // 고루틴으로 데이터 전송 시작
fmt.Println("메인 함수: 데이터 수신 대기 중...")
receivedData := <-dataChannel // 채널에서 데이터 수신 (데이터가 올 때까지 대기)
fmt.Printf("메인 함수: 데이터 %d 수신 완료!\n", receivedData)
fmt.Println("프로그램 종료")
}
이 예시에서는 sendData 고루틴이 데이터를 채널로 보내면, main 함수가 그 데이터를 받을 때까지 기다립니다.
채널이 없다면 main 함수는 sendData 고루틴의 작업이 끝났는지 알 수 없겠죠?
비버퍼링 vs. 버퍼링 채널:
위 예시처럼 make(chan int)는 비버퍼링 채널입니다.
데이터를 보내는 쪽과 받는 쪽이 동시에 준비되어야만 통신이 이루어집니다.
만약 한쪽이 준비되지 않았다면, 그 작업은 블로킹(Blocking)되어 기다리게 됩니다.
make(chan int, 3)처럼 버퍼 크기를 지정하면 버퍼링된 채널이 됩니다.
이 채널은 지정된 개수만큼의 데이터를 버퍼에 저장할 수 있어서, 버퍼가 가득 차기 전까지는 보내는 쪽이 블로킹되지 않습니다.
받을 때도 버퍼에 데이터가 있다면 즉시 받을 수 있습니다.
3. 고루틴 스케줄링: 자발적 양보와 선점
고루틴이 왜 그렇게 가볍고 효율적으로 동작할까요? 바로 Go 런타임의 스케줄링 덕분입니다. "고루틴은 특정 지점에서 자발적으로 제어권을 양보하거나, 런타임에 의해 선점될 수 있습니다"라는 문장에 그 비밀이 숨어있어요.
- 자발적 양보(Yield) : 고루틴이 스스로 CPU 사용 권한을 다른 고루틴에게 넘겨주는 경우입니다. 주로 I/O 작업(네트워크 통신, 파일 읽기/쓰기 등)이나 채널에서 데이터를 기다릴 때 발생해요. 고루틴이 "잠깐 쉬어야 하니, 다른 고루틴이 일하게 해주세요!"라고 말하는 것과 같죠. 이렇게 하면 CPU 자원을 낭비하지 않고 효율적으로 사용할 수 있습니다.
- 선점(Preemption) : 고루틴이 너무 오랫동안 CPU를 독점할 때, Go 런타임 스케줄러가 강제로 CPU 사용 권한을 빼앗아 다른 고루틴에게 넘겨주는 경우입니다. Go 1.14 버전부터는 CPU를 많이 사용하는 고루틴도 강제로 멈춰 세울 수 있는 비협력적 선점(Non-cooperative Preemption)이 도입되어, 모든 고루틴에게 공평하게 CPU 시간을 분배하고 시스템의 응답성을 높일 수 있게 되었습니다.
이 두 가지 메커니즘 덕분에 개발자는 복잡한 스레드 스케줄링에 신경 쓸 필요 없이 go 키워드 하나로 동시성을 구현하고, Go 런타임이 알아서 최적의 성능을 끌어내줍니다.
4. 여러 채널을 동시에 기다리기: select 문
이제 여러 고루틴이 동시에 작동하고, 서로 채널로 통신하는 상황을 생각해봅시다. 이때 여러 채널 중에서 "누가 먼저 데이터를 보냈지?" 또는 "이 작업이 타임아웃 되지는 않았나?" 등을 동시에 확인하고 싶을 때가 있죠. 이때 사용하는 것이 바로 select 문입니다.
select 문은 여러 case 절에 채널 연산을 넣어두고, 이 중에서 가장 먼저 준비된(데이터를 보내거나 받을 수 있는) case 절을 실행합니다.
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "메시지 from 채널1"
}()
go func() {
time.Sleep(500 * time.Millisecond) // 채널1보다 먼저 도착
ch2 <- "메시지 from 채널2"
}()
select {
case msg1 := <-ch1:
fmt.Println("수신:", msg1)
case msg2 := <-ch2:
fmt.Println("수신:", msg2)
case <-time.After(2 * time.Second): // 2초 타임아웃 설정
fmt.Println("2초 안에 아무것도 오지 않았습니다. 타임아웃!")
}
fmt.Println("Select 예제 종료")
}
이 코드를 실행하면 아마 "수신: 메시지 from 채널2"가 먼저 출력될 거예요. select 문은 채널1과 채널2, 그리고 2초 타임아웃 중 가장 먼저 준비된 것을 실행합니다.
select`는 CPU를 낭비하지 않아요!
select 문이 계속 채널을 '체크'하는 것처럼 보이지만, 사실 select 문은 default 절이 없는 한, 준비된 채널 작업이 나타날 때까지 해당 고루틴을 블로킹(CPU를 사용하지 않고 대기) 상태로 만듭니다.
즉, '바쁜 대기'가 아니라 잠자다가 이벤트가 발생하면 깨어나는 스마트한 방식이죠.default 절이 있다면 즉시 실행하고 빠져나오기 때문에, 무한 루프에서 default 절을 사용할 때는 time.Sleep() 등으로 적절한 대기 시간을 주어 CPU를 과도하게 사용하지 않도록 주의해야 합니다.
5. 복잡한 비동기 작업 제어: context 패키지
이제 고루틴과 채널로 많은 작업을 동시에 처리할 수 있게 되었지만, 실제 애플리케이션에서는 하나의 요청을 처리하기 위해 여러 고루틴이 계층적으로 동작하고, 이들을 중간에 취소하거나 특정 시간 내에 완료해야 하는 복잡한 상황이 발생합니다.
이때 사용하는 것이 바로 Go의 표준 라이브러리인 context 패키지입니다.
context는 주로 다음과 같은 목적을 위해 사용됩니다.
- 취소 신호 전파: 부모 고루틴에서 자식 고루틴들에게 "야, 이제 그만 작업해!"라는 취소 신호를 전달합니다.
- 타임아웃/마감 시간 설정: 특정 시간(타임아웃)이 지나거나 특정 시점(마감 시간)에 도달하면 자동으로 취소 신호를 보냅니다.
- 요청 범위 데이터 전달: HTTP 요청 ID와 같이 요청과 관련된 데이터를 여러 고루틴에 걸쳐 안전하게 전달합니다.
context는 context.Background()나 context.TODO()로 시작해서 WithCancel, WithTimeout, WithDeadline, WithValue 같은 함수들로 새로운 컨텍스트를 파생시켜 사용합니다. 중요한 건, 부모 컨텍스트가 취소되면 모든 자식 컨텍스트도 자동으로 취소된다는 점입니다.
package main
import (
"context" // context 패키지 import
"fmt"
"time"
)
func fetchUserData(ctx context.Context, userID int) {
fmt.Printf("유저 %d 정보 가져오기 시작...\n", userID)
select {
case <-time.After(3 * time.Second): // 3초 걸리는 작업 시뮬레이션
fmt.Printf("유저 %d 정보 가져오기 완료!\n", userID)
case <-ctx.Done(): // 컨텍스트 취소 신호가 오면
fmt.Printf("유저 %d 정보 가져오기 취소됨! 사유: %v\n", userID, ctx.Err())
}
}
func main() {
fmt.Println("Context 예제 시작")
// 5초 타임아웃 컨텍스트 생성
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // 중요! 컨텍스트 사용 후 반드시 cancel 함수 호출하여 리소스 해제
go fetchUserData(ctx, 123) // 유저 정보 가져오는 고루틴 시작
// 메인 고루틴은 잠시 다른 일을 하거나 기다림
time.Sleep(4 * time.Second) // 4초 대기 (fetchUserData는 아직 진행 중)
fmt.Println("메인 함수: 다른 작업 수행 중...")
// ctx의 타임아웃(5초)이 지나면 fetchUserData 고루틴이 자동으로 취소될 것임.
time.Sleep(2 * time.Second) // 총 6초 대기 (타임아웃 발생 확인)
fmt.Println("Context 예제 종료")
}
이 예시에서 fetchUserData 고루틴은 3초가 걸리도록 시뮬레이션했습니다. 하지만 main 함수에서 생성한 ctx는 5초 타임아웃을 가지고 있죠. 만약 fetchUserData가 5초를 넘겼다면, ctx.Done() 채널이 닫히고 fetchUserData는 작업을 취소하게 됩니다. 이처럼 context는 고루틴 간의 계층적인 취소 및 타임아웃 관리를 매우 효과적으로 도와줍니다.
마치며...
Go 언어의 동시성은 고루틴이라는 가볍고 효율적인 실행 단위, 채널이라는 안전한 통신 메커니즘, 그리고 select와 context라는 강력한 제어 도구들의 조합으로 이루어져 있습니다. 이 모든 것들이 유기적으로 연결되어, 우리가 복잡한 분산 시스템이나 고성능 서버를 Go로 쉽고 견고하게 만들 수 있도록 해줍니다.
'Go언어' 카테고리의 다른 글
| Go의 고루틴, Kotlin의 코루틴, 그리고 Java 21의 가상 스레드 (2) | 2025.08.04 |
|---|---|
| Go언어, 동시성 철학: “공유 메모리가 아닌, 통신으로 메모리를 공유하라” (3) | 2025.08.03 |
| Go언어, 인터페이스 (3) | 2025.08.02 |
| Go언어, 메서드와 함수 (2) | 2025.08.02 |
| Go언어, 동적 배열 `슬라이스` (0) | 2025.08.02 |