Go의 핵심, 고루틴과 인터페이스
한 줄 요약: Go에서
go키워드 하나와 인터페이스 하나를 제대로 쓸 줄 알면, 나머지는 따라온다.
Go를 쓰다가 막히는 지점이 두 곳이다. 고루틴과 인터페이스. 문법은 단순한데 어떻게 쓰는 게 맞는지 감이 안 잡힌다. 특히 다른 언어를 먼저 배운 사람일수록 Go가 제시하는 방식이 낯설게 느껴진다. 스레드나 추상 클래스에 익숙한 방식으로 Go를 쓰면 코드가 복잡해지거나 버그가 생긴다. 이 글은 Go 방식으로 두 개념을 쓰는 감각을 잡는 데 집중한다.
고루틴과 인터페이스는 각각 독립적으로도 강력하지만, 둘을 같이 쓸 때 Go가 가진 힘이 제대로 드러난다. 표준 라이브러리 전반이 이 두 개념 위에 설계되어 있다. 개념을 이해하고 나면 왜 Go 코드가 그런 형태인지가 보이기 시작한다.
고루틴: go 키워드 하나
동시성 코드를 짜본 사람은 안다. 스레드를 만들고, 락을 걸고, 죽은 스레드를 감지하고, 스레드 풀을 관리하는 코드가 실제 로직보다 많아질 때가 있다는 걸. Go는 이 복잡성을 언어 수준에서 흡수한다. 함수 앞에 go를 붙이면 된다.
go fetchData(url)해당 함수는 새 고루틴에서 실행되고, 호출한 쪽은 기다리지 않고 다음 줄로 넘어간다. 비동기 실행이 코드 한 줄로 끝난다.
스레드와 다른 점
OS 스레드는 생성 비용이 비싸다. 스택 메모리만 기본 1MB 이상이고, 컨텍스트 스위칭도 무겁다. 그래서 보통 스레드 풀을 만들고 재사용한다. 고루틴은 시작 스택이 2KB 정도에서 출발하고, Go 런타임이 필요에 따라 늘리고 줄인다. 수천 개를 동시에 띄워도 부담이 없다. 실제로 Go 웹 서버는 요청마다 고루틴 하나를 만드는 방식이 일반적이다. Node.js가 이벤트 루프 하나로 처리하는 것과 달리, Go는 고루틴마다 독립적인 실행 흐름을 준다.
Go 런타임은 M:N 스케줄링을 쓴다. M개의 고루틴을 N개의 OS 스레드에 매핑한다. 고루틴이 I/O에서 블록되면 런타임이 다른 고루틴을 그 스레드에 올린다. 이 과정을 개발자가 직접 제어할 필요가 없다. GOMAXPROCS로 병렬 실행할 스레드 수를 설정할 수 있는데, 기본값은 CPU 코어 수다.
채널로 통신한다
고루틴끼리 데이터를 주고받을 때 공유 메모리보다 채널을 쓰는 게 Go의 방식이다. “메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라”는 Go의 격언이 이 철학을 담고 있다. 공유 상태를 뮤텍스로 보호하는 방식도 가능하지만, 채널을 쓰면 데이터의 소유권이 명확하게 이동하기 때문에 레이스 컨디션이 생길 여지가 줄어든다.
ch := make(chan string)
go func() {
ch <- "done"
}()
result := <-ch
fmt.Println(result)채널은 고루틴 사이의 파이프다. 버퍼 없는 채널은 받는 쪽이 준비될 때까지 보내는 쪽이 대기한다. 버퍼드 채널은 버퍼가 찰 때까지 블록 없이 보낼 수 있다. 작업 큐처럼 생산자와 소비자의 속도 차이를 완충할 때 버퍼드 채널이 유용하다.
여러 고루틴을 동시에 띄우고 모두 끝날 때까지 기다려야 한다면 sync.WaitGroup을 쓴다.
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(url)
}
wg.Wait()wg.Add는 고루틴을 시작하기 전에 호출해야 한다. 고루틴 안에서 호출하면 카운터가 증가하기 전에 wg.Wait이 리턴할 수 있다. defer wg.Done()은 패닉이 발생해도 카운터를 줄여준다.
흔한 실수 두 가지
고루틴 누수. 고루틴을 만들고 끝내는 조건을 빠뜨리면 고루틴이 계속 살아서 메모리를 먹는다. 채널을 읽는 고루틴은 채널이 닫히지 않으면 영원히 블록된 채로 남는다. context를 이용해 취소 신호를 전달하는 습관이 필요하다.
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case v := <-ch:
process(v)
}
}
}(ctx)컨텍스트가 취소되면 ctx.Done()이 닫힌 채널을 돌려준다. select가 이 케이스를 선택하고 고루틴이 종료된다. HTTP 핸들러, 백그라운드 작업, 구독 패턴 등에서 반복적으로 쓰인다.
루프 변수 캡처. Go 1.22 이전에는 루프 변수를 클로저에서 캡처하면 마지막 값만 잡히는 문제가 있었다. Go 1.22부터는 루프마다 새 변수가 바인딩되어 해결됐다. 1.21 이하에서는 인수로 명시적으로 넘겨야 한다. 위 코드에서 go func(u string) 형태로 넘긴 이유가 여기 있다.
인터페이스: 암묵적 구현
Go의 인터페이스는 Java나 C#과 다르다. implements 키워드가 없다. 인터페이스에 정의된 메서드를 가지고 있으면 자동으로 그 인터페이스를 만족한다. 타입을 정의한 패키지가 인터페이스를 알 필요도 없다. 나중에 인터페이스를 정의해도 기존 타입이 자동으로 만족한다.
type Writer interface {
Write(p []byte) (n int, err error)
}이 메서드 하나를 구현한 타입은 어디에서 정의됐든 io.Writer다. os.File, net.Conn, bytes.Buffer, 테스트용 가짜 객체. 전부 같은 인터페이스로 다룰 수 있다.
func saveLog(w io.Writer, msg string) {
fmt.Fprintln(w, msg)
}이 함수는 파일에 쓸 때도, 테스트에서 메모리 버퍼로 확인할 때도 바뀌지 않는다. 실제 파일 대신 bytes.Buffer를 넘기면 디스크 I/O 없이 출력을 검증할 수 있다. 인터페이스 덕분에 테스트 코드가 단순해진다.
작은 인터페이스가 강하다
표준 라이브러리의 인터페이스 대부분은 메서드가 하나 또는 둘이다. io.Reader, io.Writer, io.Closer. 작을수록 구현하기 쉽고, 더 많은 타입이 인터페이스를 만족한다. 인터페이스가 커질수록 구현 부담이 늘고 유연성이 떨어진다. 함수가 데이터를 읽기만 한다면 *os.File 전체가 아니라 io.Reader만 받으면 된다. 그러면 파일뿐 아니라 네트워크 스트림, 압축 스트림, 테스트 픽스처를 전부 받을 수 있다.
인터페이스는 사용하는 쪽(consumer)에서 정의하는 게 맞다. 구현체를 제공하는 패키지가 인터페이스를 선언하면 과도한 추상화로 이어진다. 실제로 필요한 메서드만 뽑아서 호출하는 쪽 패키지에 두면 의존 방향이 명확해진다. 표준 라이브러리가 이 원칙을 따르고 있고, 덕분에 외부 라이브러리의 타입이 io.Reader를 구현하면 표준 라이브러리의 함수들을 그대로 쓸 수 있다.
빈 인터페이스는 남용하지 않는다
any(또는 interface{})는 어떤 타입도 받을 수 있다. 편리해 보이지만 타입 정보가 사라지고, 런타임에 타입 단언이 늘어난다. 컴파일러가 잡을 수 있는 오류가 런타임으로 밀린다.
// 피해야 할 패턴
func process(v any) {
s := v.(string) // 잘못된 타입이 들어오면 런타임 패닉
fmt.Println(s)
}
// 명확한 패턴
func process(v string) {
fmt.Println(v)
}여러 타입을 받아야 한다면 Go 1.18부터 쓸 수 있는 타입 파라미터를 먼저 고려한다. any는 타입이 런타임까지 결정되지 않는 상황에만 쓴다. JSON 파싱, 플러그인 시스템, 로깅 라이브러리 등 특정 영역에서는 불가피하다. 그 외 상황에서는 타입을 명시하는 게 낫다.
둘이 만날 때, worker 패턴
고루틴과 인터페이스가 함께 쓰이는 가장 실용적인 패턴이 worker 풀이다. 처리해야 할 작업이 여러 개이고, 작업의 구체적인 방식은 다를 수 있을 때 쓴다. 이미지 리사이징, 이메일 발송, 외부 API 호출이 같은 파이프라인으로 흘러야 하는 경우를 생각해보면 된다.
type Job interface {
Run() error
}인터페이스로 작업을 추상화한다. Job을 구현하면 어떤 작업이든 같은 파이프라인으로 처리할 수 있다.
func runWorkers(ctx context.Context, jobs <-chan Job, n int) {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
if err := job.Run(); err != nil {
log.Println(err)
}
}
}
}()
}
wg.Wait()
}n개의 고루틴이 채널에서 작업을 꺼내 실행한다. 어떤 작업인지는 모른다. Job 인터페이스만 만족하면 된다. 컨텍스트로 취소 신호를 받고, 채널이 닫히면 고루틴이 종료된다. 인터페이스가 없었다면 작업 타입마다 worker를 따로 만들었을 것이다. 고루틴이 없었다면 순차 실행이거나 복잡한 스레드 관리가 필요했을 것이다. 두 가지가 맞물리면서 간결하고 확장 가능한 구조가 된다. 새 작업 타입을 추가할 때 worker 코드는 건드리지 않아도 된다.
Go를 Go답게 쓴다는 건 결국 이 두 개념을 제대로 이해하고 조합하는 것이다. go 키워드 하나로 동시성을 열고, 인터페이스 하나로 결합을 끊는다. 단순한 도구인데 적용 범위가 넓다. 표준 라이브러리를 읽을 때도, 오픈소스 Go 프로젝트를 볼 때도 이 두 패턴이 반복적으로 나온다. 눈에 익을 때까지 직접 짜보는 것 외에 지름길은 없다.
시작점으로 net/http 패키지 소스를 읽어보는 걸 추천한다. 고루틴으로 요청을 처리하고, http.Handler 인터페이스로 핸들러를 추상화하는 구조가 이 글에서 다룬 패턴을 실제로 어떻게 적용하는지 보여준다. 짧은 코드인데 설계 의도가 명확하게 읽힌다. 그게 Go가 추구하는 방향이다.