Go라는 언어와 설계 철학
이 챕터에서 다루는 것
Go의 문법을 배우기 전에, Go가 어떤 문제를 풀려고 만들어진 언어인지를 먼저 잡아 둔다. 이걸 알고 나면 뒤에서 만나는 이상해 보이는 결정들(예외가 없다, 상속이 없다, 미사용 변수가 컴파일 에러다)이 전부 하나의 일관된 목표에서 나왔다는 게 보인다.
문제: 언어가 아니라 코드베이스가 문제였다
Go는 2007년 구글 내부에서 시작됐다. 당시 구글이 겪던 건 "이 알고리즘을 어떻게 표현하지"가 아니라 다음과 같은 것들이었다.
- 빌드가 너무 느리다. C++ 서버 하나를 고치고 다시 빌드하는 데 수십 분이 걸린다.
- 의존성 그래프가 통제 불능이다. 헤더 하나를 건드리면 무엇이 다시 빌드되는지 아무도 모른다.
- 언어가 너무 크다. 같은 일을 하는 방법이 다섯 가지라, 코드 리뷰가 스타일 논쟁으로 흐른다.
- 동시성이 라이브러리 수준에 머문다. 스레드와 락은 언어가 아니라 관습으로 관리된다.
- 팀이 계속 바뀐다. 새 사람이 들어와 남의 코드를 읽고 6개월 안에 기여할 수 있어야 한다.
즉 Go의 설계 목표는 "표현력이 가장 높은 언어"가 아니라 **"큰 팀이 큰 코드베이스를 오래 유지할 수 있는 언어"**였다. 이 문장이 이 강의 전체를 관통하는 기준이다.
설계 결정 다섯 가지
1. 단순성 — 기능을 더하는 대신 뺐다
Go의 예약어는 25개다. 비교하자면 Java는 50개 안팎, C++은 90개를 넘는다.
break default func interface select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var
여기서 중요한 건 개수 자체가 아니라 **"같은 일을 하는 방법이 하나뿐"**이라는 점이다.
반복문은 for 하나뿐이고(while도 do-while도 없다), 삼항 연산자가 없고, 함수 오버로딩과
기본 인자도 없다. 다른 언어에서 오면 처음엔 답답하다. 그 대가로 얻는 건 남의 코드를 읽는
속도다.
2. 빠른 컴파일
Go의 import 규칙은 순환을 금지하고, 컴파일된 패키지가 자기 의존성의 정보를 요약해서 들고
있다. 그래서 컴파일러가 헤더를 재귀적으로 파고들 필요가 없다. 실제로 이 기계에서
net/http 서버 한 개를 빌드해 보면 이렇다.
go build -a -o srv . # -a: 캐시를 무시하고 표준 라이브러리까지 전부 다시 빌드
go build -a -o srv . 14.41s user 2.42s system 496% cpu 3.392 total
표준 라이브러리를 통째로 다시 컴파일하는 데 벽시계 기준 3.4초다. 캐시가 살아 있는 평상시 빌드는 이렇다.
go build -o srv . 0.07s user 0.22s system 470% cpu 0.063 total
0.06초. 이 속도가 "빌드 걸어 놓고 커피 마시러 간다"라는 습관 자체를 없앤다.
위 수치는 이 기계(darwin/arm64, Go 1.26.5)에서 측정한 값이다. 절대값은 하드웨어에 따라 다르지만, 콜드 빌드와 웜 빌드의 자릿수 차이는 어디서든 비슷하게 재현된다.
3. 정적 링크 단일 바이너리
go build의 결과물은 실행 파일 하나다. 런타임도, GC도, 스케줄러도 전부 그 안에 들어 있다.
go build -o srv .
ls -lh srv
-rwxr-xr-x@ 1 sgn04088 staff 7.6M Aug 8 13:12 srv
7.6MB짜리 파일 하나를 서버에 복사하면 그게 배포의 끝이다. JVM 설치도, pip install도,
node_modules도 필요 없다. Docker 이미지를 scratch나 distroless로 만들 수 있는 것도 이
성질 덕분이다(12-7에서 실제로 만든다).
"정적 링크"라고 했지만 macOS에서는 시스템 라이브러리 몇 개는 여전히 동적으로 붙는다.
otool -L srv
srv:
/usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)
/usr/lib/libresolv.9.dylib (compatibility version 0.0.0, current version 0.0.0)
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 0.0.0, current version 0.0.0)
/System/Library/Frameworks/Security.framework/Versions/A/Security (compatibility version 0.0.0, current version 0.0.0)
macOS는 시스템 콜을 직접 호출하는 것을 지원하지 않아 libSystem을 거쳐야 하고, DNS 조회에
libresolv, 시스템 인증서 저장소를 읽으려고 Security와 CoreFoundation이 붙었다.
전부 OS가 항상 제공하는 것들이라 "이 파일 하나만 복사하면 된다"는 성질은 그대로다.
Linux에서 CGO_ENABLED=0으로 빌드하면 정말로 아무것도 링크되지 않은 완전 정적 바이너리가
나온다. 이 차이는 12-6에서 다시 다룬다.
4. 동시성이 언어에 들어가 있다
Go에서 동시 실행 단위는 라이브러리 타입이 아니라 go라는 문법이다.
package main
import (
"fmt"
"sync"
)
func main() {
words := []string{"simple", "fast", "boring", "on", "purpose"}
lengths := make([]int, len(words))
var wg sync.WaitGroup
for i, w := range words {
wg.Go(func() {
lengths[i] = len(w)
})
}
wg.Wait()
for i, w := range words {
fmt.Printf("%-8s %d\n", w, lengths[i])
}
}
go run ./01-why-go
simple 6
fast 4
boring 6
on 2
purpose 7
지금은 세부 문법을 몰라도 된다. 눈여겨볼 것은 세 가지다.
wg.Go(...)가 다섯 개의 고루틴(goroutine) 을 띄운다. 고루틴은 OS 스레드가 아니라 런타임이 관리하는 경량 실행 단위라, 수십만 개를 띄워도 문제가 없다.- 루프 변수
i와w를 클로저가 그대로 캡처하는데도 안전하다. Go 1.22부터 루프 변수가 반복마다 새로 만들어지기 때문이다. 예전 코드에서 보이는i := i같은 재선언은 이제 필요 없다(2-6에서 자세히 다룬다). - 출력 순서가 고정돼 있다. 고루틴 실행 순서는 무작위지만, 각자 자기 인덱스에만 쓰고 마지막에 순서대로 읽기 때문이다. 동시성 코드에서 출력이 결정적이려면 이런 설계가 필요하다는 감각을 지금부터 들여 두면 좋다.
만약 Java나 Python의 스레드 풀에 익숙하다면, 여기엔 풀 크기도 executor도 shutdown도 없다는 점이 낯설 것이다. 스케줄링은 런타임의 일이다.
5. 툴체인이 표준으로 딸려 온다
포매터(gofmt), 정적 분석기(go vet), 테스트 러너(go test), 벤치마크, 프로파일러,
의존성 관리, 문서 뷰어가 전부 go 명령 하나에 들어 있다. 어떤 프로젝트를 열어도
go test ./...가 동작한다. 이 강의 Part 1이 전부 도구 이야기인 것도 그래서다 — Go에서
도구는 나중에 얹는 게 아니라 언어의 일부다.
Go가 일부러 빼놓은 것
기능이 없는 게 아니라 없애기로 결정한 것들이다. 각각의 이유를 알아 두면 뒤에서 덜 당황한다.
| 없는 것 | 대신 무엇을 쓰나 | 왜 뺐나 |
|---|---|---|
| 예외(try/catch) | error를 값으로 반환 | 제어 흐름이 코드에 보이게 하려고 |
| 클래스 상속 | 임베딩 + 인터페이스 조합 | 깨지기 쉬운 상위 클래스 문제를 원천 차단 |
| 함수 오버로딩·기본 인자 | 이름이 다른 함수, 옵션 구조체 | 호출 지점만 보고 어느 함수인지 알 수 있게 |
| 삼항 연산자 | if/else | 중첩 삼항으로 읽기 어려워지는 걸 막으려고 |
| 포인터 산술 | 슬라이스 | 메모리 안전성과 GC |
| 미사용 변수 허용 | (컴파일 에러) | 죽은 코드가 쌓이는 걸 막으려고 |
예외를 뺀 것이 실제로 어떤 차이를 만드나
Java나 Python에 익숙하다면 이 코드가 장황해 보일 것이다.
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("설정 파일 읽기: %w", err)
}
같은 일을 예외로 쓰면 세 줄이 한 줄이 된다. 대신 os.ReadFile이 실패했을 때 어디로
튀는지가 코드에 안 보인다. Go는 그 정보를 시그니처와 호출 지점에 강제로 노출시키는 쪽을
택했다. 타이핑은 늘고, 읽을 때의 추론은 줄어든다. 이게 Go가 반복적으로 하는 교환이다.
Part 4-6에서 이 결정을 본격적으로 다룬다.
제네릭은 왜 12년이나 늦었나
Go 1.0이 2012년, 제네릭이 들어온 Go 1.18이 2022년이다. 이유는 "필요 없다고 생각해서"가 아니라 컴파일 속도와 언어 단순성을 해치지 않는 설계를 찾는 데 그만큼 걸려서다. 그래서 Go의 제네릭은 C++ 템플릿처럼 강력하지 않다. 예를 들어 메서드에는 타입 파라미터를 붙일 수 없다. 이것도 의도된 제약이다(5-2에서 다룬다).
여기서 얻을 교훈은 기능 목록이 아니라 태도다. Go는 기능을 추가할 때 "이걸 넣으면 코드를 읽는 사람이 알아야 할 것이 얼마나 늘어나는가"를 먼저 계산한다.
Go가 잘 안 맞는 곳
균형을 위해 적어 둔다. 다음 영역에서는 다른 선택지가 낫다.
- GC 일시 정지를 절대 허용할 수 없는 시스템 — 하드 리얼타임, 커널, 오디오 DSP. Rust나 C.
- 수치·과학 계산과 머신러닝 — 생태계가 Python에 압도적으로 몰려 있다.
- 복잡한 타입 수준 추상화가 이득인 도메인 — 파서 콤비네이터, 대수적 데이터 타입을 적극적으로 쓰는 설계. Go의 타입 시스템은 의도적으로 얕다.
- 데스크톱·모바일 GUI — 표준 해법이 없다.
Go가 강한 곳은 명확하다. 네트워크 서비스, CLI 도구, 인프라 소프트웨어(Docker, Kubernetes, Terraform, Prometheus가 전부 Go다). 이 강의도 결국 그쪽으로 간다.
흔히 하는 오해
- "Go는 쉬운 언어다." 문법은 작지만, 슬라이스의 백킹 배열 공유, nil을 담은 인터페이스, 채널 소유권 같은 지점에서 정확히 이해하지 않으면 조용히 틀리는 코드가 나온다. 문법이 작다는 건 배울 게 적다는 뜻이지 실수할 여지가 적다는 뜻이 아니다.
- "고루틴은 스레드다." 아니다. 고루틴은 2KB 남짓한 스택으로 시작하고 런타임이 OS 스레드에 다중화한다. 7-1에서 다룬다.
- "Go는 객체지향이 아니다." 메서드도 있고 다형성도 있다. 없는 건 상속뿐이다.
- "에러를 일일이 처리하는 건 보일러플레이트다." 처리하지 않으려면
_로 버려야 하고, 그건 대부분 버그다. 4-6에서 다시 이야기한다.
정리
- Go는 "표현력"이 아니라 **"대규모 팀의 장기 유지보수"**를 최적화한 언어다.
- 빠른 컴파일, 단일 정적 바이너리, 언어 차원의 동시성, 표준 툴체인이 그 목표를 떠받친다.
- 예외·상속·오버로딩이 없는 것은 결함이 아니라 읽는 사람의 부담을 줄이려는 교환이다.
- 문법이 작다는 것과 함정이 없다는 것은 다르다. 이 강의는 그 함정들을 정면으로 다룬다.
연습문제
-
위
01-why-go예제를go build로 빌드해 바이너리 크기를 확인하고,fmtimport를 빼고println(내장 함수)만 쓰도록 고쳐서 다시 빌드해 보자. 크기가 얼마나 줄어드는가? 힌트:fmt는 리플렉션을 끌고 들어온다. -
예제의
wg.Go(...)블록 안을lengths[i] = len(w)대신fmt.Printf("%-8s %d\n", w, len(w))로 바꿔서 여러 번 실행해 보자. 출력 순서가 매번 같은가? 왜 그런지 설명해 보자. -
지금 쓰는 다른 언어에서 "같은 일을 하는 방법이 여러 개"인 사례를 세 가지 떠올려 보자. 각각이 코드 리뷰에서 논쟁거리가 된 적이 있는가? Go가 그중 무엇을 어떻게 잘라냈는지 찾아보자.