Go
고친 사람 github-actions[bot]
Go 는 서버 프로그램을 짓는 데 많이 쓰는 프로그래밍 언어입니다. 코드를 미리 컴파일해서 실행 파일 하나로 만들어 줍니다. 여러 일을 동시에 돌리는 장치가 언어 안에 들어 있습니다.
쉽고 빠른 이해
Go 는 코드를 실행 파일 하나로 만들어 주는 언어입니다. 그 파일 하나를 서버에 올리면 따로 설치할 것 없이 바로 돕니다.
서버는 요청 수천 개를 한꺼번에 받습니다. 요청마다 운영체제 스레드를 하나씩 붙이면 금방 무거워집니다. Go 는 고루틴이라는 실행 단위를 언어가 직접 만들어 이 짐을 덜어 줍니다. 고루틴은 스레드보다 메모리를 훨씬 적게 씁니다.
- 동시에 돌릴 함수 앞에
go를 붙입니다 - Go 의 런타임(실행 파일에 같이 들어가 도는 관리 코드)이 그 함수들을 적은 수의 스레드 위에 나눠 얹습니다
- 함수끼리는 통로를 만들어 값을 주고받습니다
대가도 있습니다. 실행 파일에 런타임이 같이 들어가서 파일이 커집니다. 예외 대신 오류를 값으로 돌려주므로 오류를 확인하는 코드를 줄마다 직접 씁니다.
그래서 요청을 많이 받는 서버나 명령줄 도구를 짤 때 Go 를 자주 고릅니다. 반대로 잠깐의 멈춤도 허용되지 않는 실시간 제어나 아주 작은 기기에는 잘 맞지 않습니다. 쓰지 않는 메모리를 런타임이 회수하느라 잠깐씩 멈추고, 파일도 크기 때문입니다.
상세
이 절은 Go 가 어떤 물건인지를 다섯 가지 특징으로 봅니다. 실행 파일을 만드는 방식, 고루틴과 채널로 여러 일을 동시에 돌리는 방식, 메모리 회수, 오류를 다루는 방식, 타입을 잇는 방식입니다. 고루틴과 채널은 두 절로 나눠 보지만 한 특징입니다. 마지막 절은 다섯 특징이 저마다 무엇을 내주었는지 한데 모읍니다.
백엔드 개발자에게 익숙한 Java 와 견주면서 봅니다.
Go 는 Robert Griesemer, Rob Pike, Ken Thompson 세 사람이 2007년에 설계를 시작했습니다. 2009년 11월에 오픈 소스 프로젝트로 공개됐습니다.
실행 파일 하나로 배포한다
Go 는 컴파일러로 소스 코드를 미리 기계어로 옮깁니다. 기계어는 CPU(Central Processing Unit, 중앙 처리 장치)가 바로 읽는 명령입니다. 그래서 결과물이 곧 돌릴 수 있는 실행 파일입니다.
Java 는 사정이 다릅니다. Java 컴파일러가 만드는 것은 바이트코드입니다. 바이트코드는 CPU 가 아니라 가상 머신이 읽도록 만든 중간 형태의 명령입니다. 이것을 돌리려면 서버에 JVM(Java Virtual Machine, 자바 가상 머신)이 먼저 설치돼 있어야 합니다. Go 의 실행 파일은 이런 준비물이 없어도 돕니다.
그럴 수 있는 까닭은 정적 링크입니다. 정적 링크는 프로그램이 쓰는 라이브러리를 실행 파일 안에 미리 합쳐 넣는 방식입니다. Go 는 기본으로 이렇게 링크합니다.
Go 의 런타임도 실행 파일에 같이 넣습니다. 런타임은 프로그램이 도는 동안 뒤에서 메모리와 실행 단위를 관리하는 코드입니다. 맨 위 요약에서 말한 관리 코드가 이것입니다.
고루틴 — 런타임이 관리하는 실행 단위
한 프로그램 안에서 여러 일을 겹쳐 진행하는 것을 동시성이라고 합니다. 서버가 요청 여러 개를 한꺼번에 처리하려면 동시성이 있어야 합니다.
보통은 운영체제가 주는 스레드로 동시성을 얻습니다. 스레드는 운영체제가 번갈아 CPU 를 나눠 주는 실행 흐름입니다. 다만 스레드 하나가 차지하는 메모리가 커서 수만 개를 띄우기 어렵습니다.
그 메모리의 큰 몫은 스택입니다. 스택은 함수를 부를 때마다 지역 변수와 돌아갈 곳을 쌓아 두는 메모리 구역입니다. 실행 흐름마다 스택이 하나씩 붙으므로, 스택을 크게 잡을수록 흐름 하나가 먹는 메모리도 커집니다.
Go 는 여기에 고루틴을 둡니다. 고루틴은 Go 런타임이 직접 관리하는 실행 흐름입니다. 맨 위 요약에서
go 를 붙여 띄운다고 한 실행 단위가 이것입니다.
고루틴은 처음에는 몇 킬로바이트짜리 스택만 들고 태어납니다. 스택은 필요할 때 자랍니다. 그래서 한 프로그램 안에서 고루틴을 수십만 개 만들 수도 있습니다.
고루틴은 운영체제 스레드와 일대일로 붙지 않습니다. 런타임이 고루틴 여럿을 적은 수의 스레드에 나눠 돌립니다.
시스템 호출은 파일 읽기나 네트워크 수신처럼 프로그램이 운영체제에 일을 맡기는 호출입니다. 이 호출이 끝나기를 기다리는 동안 그 스레드는 멈춰 있습니다. 그러면 런타임이 나머지 고루틴을 다른 스레드로 옮겨 계속 돌립니다.
flowchart TD
subgraph G["고루틴 — 수가 많다"]
G1["고루틴 1"]
G2["고루틴 2"]
G3["고루틴 3"]
G4["고루틴 4"]
end
R["Go 런타임"]
subgraph T["운영체제 스레드 — 수가 적다"]
T1["스레드 1"]
T2["스레드 2"]
end
G1 --> R
G2 --> R
G3 --> R
G4 --> R
R --> T1
R --> T2
그림은 고루틴 넷이 스레드 둘 위에서 도는 모습입니다. 어느 고루틴을 어느 스레드에 얹을지는 런타임이 정합니다. 코드를 쓰는 사람은 이 배분을 신경 쓰지 않습니다.
채널로 값을 주고받는다
고루틴끼리 값을 넘길 때는 채널을 씁니다. 채널은 한쪽이 값을 넣고 다른 쪽이 꺼내는 통로입니다. 꺼내는 쪽은 값이 들어올 때까지 기다립니다.
여러 흐름이 같은 값을 동시에 고치면 값이 엉킵니다. 흔히는 잠금을 걸어 한 번에 한 흐름만 그 값을 만지게 합니다. 채널로 값을 넘기면 넘기는 순간에 두 흐름이 순서를 맞추므로 따로 잠금을 걸지 않아도 됩니다.
아래 코드는 고루틴 하나가 계산한 값을 채널로 보내고, 원래 흐름이 그 값을 받아 찍습니다.
ch := make(chan int) // 정수 통로
go func() { // 고루틴 시작
ch <- 1 + 2 // 3 을 넣는다
}()
fmt.Println(<-ch) // 3
go 한 낱말이 함수를 새 고루틴으로 띄웁니다. <-ch 는 채널에서 값을 꺼내는 표기입니다. 값이 올
때까지 거기서 멈춥니다.
메모리는 가비지 컬렉터가 회수한다
Go 는 가비지 컬렉션을 씁니다. 가비지 컬렉션은 더 안 쓰는 메모리를 런타임이 찾아서 자동으로 돌려받는 일입니다. 개발자가 메모리를 직접 풀어 주지 않아도 됩니다.
이 방식은 동시성 코드에서 특히 손이 덜 갑니다. 여러 고루틴이 같은 값을 볼 때 누가 그 메모리를 풀어야 하는지 정하지 않아도 되기 때문입니다.
대신 회수하는 동안 프로그램이 잠깐 멈추거나 CPU 를 나눠 씁니다. CPU 안에는 명령을 처리하는 코어가 여럿 들어 있습니다. Go 는 회수 작업을 본 프로그램과 다른 코어에서 나란히 돌려 이 멈춤을 줄입니다.
오류는 예외가 아니라 값이다
Java 는 실패를 예외로 던지고 try-catch 로 받습니다. Go 에는 이 구문이 없습니다. 함수는 결과와
함께 error 값을 하나 더 돌려줍니다. 부른 쪽은 그 값을 바로 확인합니다.
f, err := os.Open("a.txt")
if err != nil {
return err // 호출한 쪽에 넘긴다
}
파일을 못 여는 일은 흔한 실패입니다. Go 는 이런 실패를 특별한 사건으로 다루지 않고 보통의 반환값으로
다룹니다. 그래서 오류가 어디서 생기고 어디로 가는지 코드에 다 드러납니다. 대가로 if err != nil
이 코드 곳곳에 되풀이됩니다.
복구할 수 없는 상황에는 panic 을 씁니다. panic 이 나면 지금 함수가 멈춥니다. 그 함수를 부른
함수들도 호출을 거슬러 올라가며 차례로 끝납니다. 맨 위까지 올라가면 프로그램이 죽습니다.
recover 는 이 거슬러 올라감을 중간에서 멈추는 함수입니다. 올라가는 도중에 recover 를 부르면
프로그램이 죽지 않고 계속 돕니다. 서버 프로그램은 요청 하나의 panic 이 서버 전체를 끄지 않도록
이 장치를 둡니다.
선언 없이 맞춰지는 인터페이스
인터페이스는 「이런 메서드를 가진 타입」을 이름 붙여 둔 약속입니다. Java 에서는 클래스가
implements 로 어느 인터페이스를 따르는지 미리 선언합니다. Go 에는 이 선언이 없습니다.
아래는 Write 메서드 하나를 요구하는 인터페이스입니다.
type Writer interface {
Write(p []byte) (int, error)
}
이 모양의 Write 메서드를 가진 타입은 따로 적지 않아도 Writer 로 쓰입니다. 그래서 이미 만든 타입에
나중에 인터페이스를 새로 붙일 수 있습니다. 이렇게 모양만 맞으면 통하는 방식을 구조적 타이핑이라고
부릅니다.
Go 에는 클래스 상속도 없습니다. 타입 사이의 위아래 관계를 미리 짓지 않고 인터페이스로만 잇습니다.
무엇을 내주었나
위 특징은 저마다 무언가를 내주고 얻은 것입니다. 한데 모으면 이렇습니다.
| 고른 것 | 얻는 것 | 내준 것 |
|---|---|---|
| 정적 링크 | 실행 파일 하나로 배포 | 파일이 커진다. 인사말만 찍는 프로그램도 몇 메가바이트다 |
| 고루틴과 채널 | 동시 처리 코드를 짧게 쓴다 | 스레드를 직접 다루는 세밀한 조정은 런타임에 맡긴다 |
| 가비지 컬렉션 | 메모리를 직접 풀지 않는다 | 회수 작업이 CPU 와 짧은 멈춤을 가져간다 |
| 오류를 값으로 | 실패 경로가 코드에 드러난다 | 오류 확인 코드가 줄마다 되풀이된다 |
| 상속 없음 | 타입 관계가 얕다 | 클래스 계층으로 짜던 설계를 다시 짜야 한다 |
그래서 Go 는 요청을 많이 받는 서버, 명령줄 도구, 컨테이너에 넣어 배포하는 프로그램을 짤 때 자주 고릅니다. 서버에 준비물 없이 파일 하나만 올리면 됩니다. 동시에 많은 연결을 붙잡기도 쉽습니다. 반대로 멈춤이 조금도 허용되지 않는 실시간 제어나 아주 작은 기기에서는 가비지 컬렉션과 파일 크기가 걸림돌이 될 수 있습니다.
관련 항목
Go 를 이루는 구성 요소
고루틴 · 채널 · 런타임 · 가비지 컬렉션 · 인터페이스 · panic · recover · defer · 제네릭 · 구조적 타이핑
Go 가 바탕에 두는 개념
컴파일러 · 기계어 · 실행 파일 · 정적 링크 · 동시성 · 스레드 · 예외 · 상속 · CSP
같은 일을 두고 겨루는 프로그래밍 언어
Java · Python · Rust · C · C++ · Kotlin · JavaScript
Go 로 짠 제품
Docker · Kubernetes · k6 · Prometheus · Terraform
Go 의 실행 파일과 맞세워지는 실행 방식
Go 가 속하는 분류와 배포 방식
다른 이름: Go 언어 · Golang · golang · 고랭