Go 백엔드의 역사와 현재: 성능이 아니라 빌드 시간에서 시작한 언어
컨테이너를 실행하는 Docker, 그 컨테이너를 배치하는 Kubernetes, 지표를 모으는 Prometheus, 인프라를 코드로 적는 Terraform, 시크릿을 보관하는 Vault. 클라우드 운영에 쓰는 도구를 늘어놓으면 대부분이 Go로 짜여 있습니다.
그런데 실행 속도만으로 줄을 세우면 Go는 C++보다도 Rust보다도 뒤에 있습니다. 가장 빠른 언어가 아닌데 인프라 계층을 거의 다 가져갔다는 뜻입니다. 그렇다면 무엇으로 고른 걸까요.
Go를 성능으로 설명할 때 어긋나는 것
흔히 Go를 "컴파일 언어라 Python이나 Ruby보다 빠른 언어"로 이해합니다. 틀린 말은 아니지만 그것이 설명의 전부라면 Kubernetes는 C++로 짜였어야 합니다. 구글 안에는 C++ 코드도, C++을 아는 사람도 넘쳤습니다. 속도가 목적이었다면 새 언어를 만드는 것이 가장 비싼 선택지였습니다.
설계자들이 밝힌 목표는 다른 곳에 있었습니다. Rob Pike가 2012년에 쓴 Go at Google: Language Design in the Service of Software Engineering에 이렇게 적혀 있습니다.
The goals of the Go project were to eliminate the slowness and clumsiness of software development at Google, and thereby to make the process more productive and scalable.
없애려던 느림은 프로그램이 도는 속도가 아니라 소프트웨어를 만드는 속도였습니다. 같은 글에서 Pike는 "Go는 프로그래밍 언어 연구가 아니라 소프트웨어 엔지니어링에 관한 것"이라고 더 분명하게 적었습니다. Go의 이상해 보이는 결정들은 대부분 이 한 문장에서 따라 나옵니다.
45분짜리 빌드가 만든 언어
2007년 9월, 구글 안에서 Robert Griesemer, Rob Pike, Ken Thompson 세 사람이 이야기를 시작했습니다. Thompson은 유닉스와 B 언어를 만든 사람이고, Pike는 Plan 9와 UTF-8을 만든 사람입니다. 언어를 새로 만들 자격을 따진다면 부족할 것이 없는 조합입니다.
시작한 자리가 인상적입니다. 위 글에는 이렇게 적혀 있습니다.
The origin myth for Go states that it was during one of those 45 minute builds that Go was conceived.
큰 C++ 바이너리 하나를 빌드하는 데 45분이 걸렸고, 그 시간을 기다리다 새 언어 이야기가 나왔다는 것입니다. 2009년 11월 10일에 오픈소스로 공개됐습니다.
미사용 import가 경고가 아니라 에러인 이유
Go를 처음 만지면 대부분 여기서 한 번 화가 납니다. 쓰지 않는 패키지를 import 해 두면 컴파일이 아예 안 됩니다. 경고가 아니라 에러입니다. 디버깅하다가 fmt를 잠깐 넣고 다시 빼는 것조차 그냥 두면 빌드가 깨집니다.
이것이 취향 문제가 아니라 위의 45분과 직접 이어져 있습니다.
The first step to making Go scale, dependency-wise, is that the language defines that unused dependencies are a compile-time error (not a warning, an error).
C++ 빌드가 지수적으로 불어나는 원인은 헤더가 헤더를 부르는 구조였습니다. 파일 하나가 선언한 의존성이 실제로 쓰는 의존성 전부라는 것을 언어가 강제하면, 컴파일러는 그 아래 트리를 다시 읽지 않아도 됩니다. 처음 30분 동안 짜증나는 규칙이 15년 뒤에도 빌드가 빠른 이유입니다.
저는 이 결정이 Go를 이해하는 가장 좋은 입구라고 봅니다. 언어의 편의를 조금 깎아서 팀의 시간을 벌었습니다. 상속이 없는 것, 예외가 없는 것, 제네릭을 13년 미룬 것도 전부 같은 저울 위에 있습니다. 언어를 예쁘게 만드는 쪽이 아니라 여러 사람이 오래 고치는 쪽에 무게를 뒀습니다.
Go 1 호환성 약속, 문법이 아니라 계약
2012년 3월의 Go 1은 문법 릴리스가 아니라 약속이었습니다. Go 1 and the Future of Go Programs 문서의 한 문장이 내용 전부입니다.
It is intended that programs written to the Go 1 specification will continue to compile and run correctly, unchanged, over the lifetime of that specification.
2012년에 짠 코드가 오늘 툴체인에서 고치지 않고 컴파일된다는 뜻입니다. 14년째 지켜지고 있습니다. Python이 2에서 3으로 넘어가며 11년을 쓴 것, Angular가 1에서 2로 가며 사실상 다른 프레임워크가 된 것, Node 생태계가 CommonJS와 ESM 사이에서 아직도 정리되지 않은 것을 옆에 놓으면 이 약속의 무게가 보입니다.
경계도 문서에 분명히 적혀 있습니다. 컴파일된 패키지의 바이너리 호환성, syscall 패키지, 툴체인의 동작, 성능은 약속 밖입니다. 지키는 것은 언어와 표준 라이브러리의 소스 호환성뿐입니다.
이 약속이 시험받은 적이 있습니다. Go 1.22가 for 루프 변수를 반복마다 새로 만드는 쪽으로 바꿨습니다. 클로저가 루프 변수를 잡아 마지막 값만 보게 되던 오래된 함정을 없애는 변경이었지만, 동작이 달라지는 변경이기도 했습니다. Go 팀은 go.mod에 적힌 언어 버전으로 옛 코드와 새 코드를 갈랐습니다. 언어를 고치면서도 이미 있는 프로그램을 깨지 않는 길을 따로 만든 셈입니다.
이 약속의 값은 언어를 고를 때가 아니라 3년 뒤 그 코드를 다시 열 때 나옵니다. 그래서 고를 때는 거의 보이지 않습니다.
goroutine이 없앤 것은 콜백이 아니라 함수의 색깔
동시성 이야기는 보통 "goroutine은 OS 스레드보다 가볍다"에서 끝납니다. 맞지만 절반입니다.
Bob Nystrom의 What Color Is Your Function?이 나머지 절반을 설명합니다. async/await가 있는 언어에서 함수는 두 가지 색으로 갈립니다. 비동기 함수는 비동기 함수 안에서만 부를 수 있고, 그 색은 호출하는 쪽으로 계속 번집니다. 라이브러리 하나가 비동기가 되면 그것을 쓰는 코드 전체가 비동기가 됩니다.
Go에는 그 구분이 없습니다. 함수는 한 종류뿐이고, 막히는 호출은 막히는 대로 씁니다. 런타임의 스케줄러가 막힌 goroutine을 OS 스레드에서 떼어내고 그 자리에 다른 goroutine을 얹습니다. 파일을 읽는 코드와 HTTP를 부르는 코드가 문법적으로 같아 보이는 것이 이래서 가능합니다.
취소와 마감도 언어가 아니라 관례로 통일돼 있습니다. context.Context가 함수의 첫 인자로 들어가 요청 하나의 수명이 전 계층을 관통합니다. 상위에서 타임아웃을 걸면 DB 드라이버까지 그 취소를 봅니다. 표준 라이브러리가 먼저 그렇게 쓰기 때문에 서드파티도 따라갑니다.
goroutine이 싸면 그만큼 쓰레기도 많이 나오니 GC가 문제 아니냐고 물을 수 있습니다. Go 1.5에서 컬렉터를 다시 짰고, Go 1.8 릴리스 노트는 결과를 이렇게 적었습니다.
Garbage collection pauses should be significantly shorter than they were in Go 1.7, usually under 100 microseconds and often as low as 10 microseconds.
여기까지가 문서로 확인되는 부분입니다. 그 아래로는 워크로드마다 갈립니다. 지연이 마이크로초 단위로 계약에 적히는 자리라면 GC가 있는 언어는 여전히 선택지가 아닙니다.
정적 바이너리 하나가 컨테이너와 맞아떨어진 자리
go build의 산출물은 실행 파일 하나입니다. 기본적으로 정적 링크라 대상 기계에 런타임도 공유 라이브러리도 필요 없습니다. GOOS=linux GOARCH=arm64 go build로 맥에서 리눅스 ARM 바이너리를 뽑는 것도 별도 준비 없이 됩니다. 크로스 컴파일이 특별한 작업이 아니라 환경변수 두 개입니다.
이 성질이 컨테이너와 만났을 때 무슨 일이 생기는지가 Go의 현재 위치를 거의 설명합니다. 베이스 이미지가 필요 없으니 FROM scratch에 바이너리 하나만 넣을 수 있습니다. 이미지 안에 셸도 패키지 관리자도 없어서, 침입자가 들어와도 쓸 도구가 없습니다. 배포와 보안이 같은 결정 하나로 동시에 해결됩니다.
2009년에는 이것이 큰 장점이 아니었습니다. Docker가 2013년에, Kubernetes가 2014년에 나오면서 값이 붙었습니다. 저는 Go의 성공에서 이 부분만큼은 설계보다 타이밍에 가깝다고 봅니다. 정적 링크는 새 아이디어가 아니었고, 그것이 결정적으로 중요해지는 배포 모델이 언어보다 늦게 도착했습니다.
그리고 그 도구들 자신이 Go로 짜이면서 되먹임이 걸렸습니다. Kubernetes 위에서 도는 무언가를 만들려면 클라이언트 라이브러리가 가장 잘 갖춰진 언어를 쓰게 됩니다. 그 언어가 Go입니다.
늦게 온 것들과 최근에 온 것
Go의 릴리스 이력은 "필요한 줄 알면서 오래 미룬 것들"의 목록에 가깝습니다.
| 버전 | 시기 | 들어온 것 |
|---|---|---|
| 1.0 | 2012-03 | 호환성 약속 |
| 1.5 | 2015-08 | 컴파일러 자체 호스팅, GC 재작성 |
| 1.11 | 2018-08 | 모듈 (GOPATH 탈출) |
| 1.18 | 2022-03 | 제네릭, 퍼징, 워크스페이스 |
| 1.21 | 2023-08 | log/slog, PGO 정식 |
| 1.22 | 2024-02 | for 루프 변수 시맨틱 변경 |
| 1.23 | 2024-08 | 이터레이터 (range over func) |
| 1.25 | 2025-08 | 컨테이너 인식 GOMAXPROCS, testing/synctest |
| 1.26 | 2026-02 | Green Tea GC 기본값, crypto/hpke |
의존성 관리가 2018년에, 제네릭이 2022년에 왔습니다. 공개가 2009년이니 각각 9년과 13년입니다. 게으름으로 읽을 수도 있지만, 위의 호환성 약속과 나란히 놓으면 다르게 보입니다. 한 번 넣으면 되돌릴 수 없는 계약이라 늦게 넣었다는 쪽에 가깝습니다.
최근 두 릴리스는 방향이 분명합니다. Go 1.25의 컨테이너 인식 GOMAXPROCS는 리눅스 cgroup의 CPU 제한을 런타임이 직접 읽습니다. 호스트에 코어가 64개 있어도 컨테이너에 2코어만 할당됐다면 그에 맞춥니다. Kubernetes에서 CPU 스로틀링 때문에 꼬리 지연이 튀던 문제를 애플리케이션이 아니라 런타임 쪽에서 없앤 것입니다. 같은 릴리스의 testing/synctest는 동시성 코드 테스트에서 시간을 가짜로 돌립니다. 버블 안의 모든 goroutine이 막히면 시계가 즉시 앞으로 갑니다. 타이머를 기다리는 테스트에 time.Sleep을 넣어 두던 관행이 이걸로 사라집니다.
Go 1.26에서는 실험 단계였던 Green Tea 가비지 컬렉터가 기본값이 됐습니다. 릴리스 노트는 GC를 많이 쓰는 실제 프로그램에서 GC 오버헤드가 10~40% 줄어들 것으로 봅니다. cgo 호출의 기본 오버헤드도 30%가량 줄었습니다.
언어를 키우는 대신 런타임이 도는 자리를 더 잘 이해하게 만드는 쪽입니다. 15년 전 결정과 같은 방향입니다.
Go가 잘 못하는 자리
화면이 있는 것은 Go의 자리가 아닙니다. 데스크톱과 모바일 GUI에 시도가 없지는 않지만 각 플랫폼의 네이티브 도구와 견줄 물건이 아닙니다. 브라우저도 마찬가지입니다. GOOS=js GOARCH=wasm으로 컴파일은 되지만 런타임과 가비지 컬렉터를 통째로 싣기 때문에 번들이 커집니다. 프론트엔드 번들에서 그 크기는 그대로 초기 로딩 시간입니다.
데이터 분석과 머신러닝도 넘어올 이유가 없습니다. NumPy와 PyTorch가 있는 자리를 라이브러리 몇 개로 대체할 수 없습니다.
언어 안쪽에도 불편이 남아 있습니다. 가장 유명한 것이 에러 처리입니다.
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}세 줄짜리 이 덩어리가 함수마다 반복됩니다. Go 팀도 이걸 알고 있어서 7년 동안 세 번 고치려 했습니다. 2018년의 check/handle, 2019년의 try(), 그 뒤의 ? 연산자 제안이 전부 합의에 이르지 못했습니다. 결국 2025년 6월, Robert Griesemer가 [ On | No ] syntactic support for error handling에서 이렇게 정리했습니다.
For the foreseeable future, the Go team will stop pursuing syntactic language changes for error handling. We will also close all open and incoming proposals that concern themselves primarily with the syntax of error handling, without further investigation.
고치지 않기로 한 것입니다. 이걸 두고 갈리는 반응이 언어 취향을 꽤 정확하게 가릅니다. 저는 이 반복이 읽을 때는 나쁘지 않다고 보는 쪽입니다. 에러가 지나가는 자리가 코드에 전부 적혀 있어서 나중에 읽는 사람이 흐름을 놓치지 않습니다. 다만 쓸 때 지루하다는 지적도 그대로 맞습니다.
제네릭도 들어왔을 뿐 넉넉하지는 않습니다. 메서드에는 타입 파라미터를 붙일 수 없어서, 타입에 얹고 싶은 일반화가 패키지 수준 함수로 밀려나는 경우가 자주 생깁니다.
Next.js 백엔드와 갈리는 지점
먼저 층이 다릅니다. Go는 언어이고 Next.js는 React 프레임워크가 서버 쪽으로 자란 것이라, 둘을 나란히 놓는 것 자체가 정확한 비교는 아닙니다. 그래도 실제 결정은 이 모양으로 옵니다. API 서버가 필요한데 Route Handler로 쓸 것인가, Go 서비스를 따로 세울 것인가.
| Next.js (Node) | Go | |
|---|---|---|
| 동시성 | 단일 이벤트 루프, 비동기 I/O | goroutine, 멀티코어 병렬 |
| CPU 작업이 섞이면 | 루프가 막혀 전체가 지연 | 스케줄러가 나눠 실행 |
| 취소·타임아웃 | AbortController, 라이브러리마다 다름 |
context.Context, 전 계층 관통 |
| 배포 산출물 | node 런타임과 node_modules 트리 |
정적 바이너리 하나 |
| 타입 | 컴파일 후 지워짐 | 런타임까지 남음 |
| 화면 | 같은 코드베이스 | 따로 만들어야 함 |
타입 줄은 조금 풀어 쓸 값이 있습니다. TypeScript의 타입은 컴파일이 끝나면 사라집니다. 그래서 요청 본문이나 외부 API 응답 같은 경계에서는 zod 같은 것으로 다시 검증해야 하고, 그 검증을 빠뜨리면 타입은 통과하는데 런타임에서 깨집니다. Go에서도 JSON 언마샬은 실패할 수 있으니 경계 검증이 필요한 것은 같지만, 타입이 지워지지 않아 그 경계가 어디까지인지가 더 좁습니다.
성능 차이는 흔히 과장됩니다. 데이터베이스를 한 번 부르고 JSON을 만들어 돌려주는 흔한 CRUD API라면 두 쪽 다 충분히 빠르고, 병목은 거의 언제나 쿼리 쪽입니다. 차이가 실제로 나타나는 것은 요청 하나가 CPU를 쓰기 시작할 때입니다. Node의 이벤트 루프는 한 요청의 파싱이나 암호 연산이 길어지면 다른 요청까지 함께 기다립니다. Go는 그 일을 다른 코어로 보냅니다.
배포 산출물의 차이도 상황을 봐야 값이 매겨집니다. 이미 컨테이너 여러 개가 도는 서버라면 이미지 하나가 커지는 것이 별일이 아닙니다. 반대로 작은 인스턴스나 엣지 가까운 자리에 무언가를 올려야 한다면 정적 바이너리 하나는 실질적인 차이가 됩니다.
제가 보기에 진짜 기준은 성능이 아니라 경계의 개수입니다. 화면과 API가 같은 도메인을 보고 한 사람이 둘 다 만든다면 Next.js 한 덩어리가 이깁니다. 타입을 프론트와 백에서 그대로 공유하고, 배포가 하나이고, 사이에 직렬화가 없습니다. 여기에 Go를 넣으면 없던 프로세스 경계를 처음으로 만드는 것이고 그 대가는 생각보다 큽니다.
반대로 넘어야 할 경계가 어차피 하나 이상 생기는 설계라면 계산이 뒤집힙니다. 클라이언트가 여럿 붙거나, API가 화면과 다른 수명을 갖거나, 백그라운드에서 오래 도는 작업이 따로 있는 경우입니다. 그런 자리에서는 그 경계 중 하나를 Go로 두는 비용이 거의 0이고, 그 서비스만 따로 배포하고 따로 스케일할 수 있게 됩니다.
"언어는 하나로 통일한다"가 항상 옳은 것은 아닙니다. 다만 둘로 늘리는 데는 값이 붙습니다. 빌드도 배포도 둘이 되고, 사람도 양쪽을 알아야 합니다. 서비스가 하나뿐이고 팀이 작다면 그 값이 이득보다 큽니다.
여기까지가 확실한 부분
역사와 설계 의도는 원문이 남아 있어서 확인됩니다. 45분 빌드도, 미사용 import를 에러로 정한 이유도, 호환성 약속의 범위도 Go 팀이 직접 쓴 문장입니다.
확실하지 않은 것은 그다음입니다. 어느 쪽이 더 나은가는 워크로드와 팀에 달렸고, 위의 표는 판단의 재료이지 답이 아닙니다. 특히 성능 항목은 자기 워크로드에서 재보기 전까지 남의 숫자를 믿을 이유가 없습니다.
함께 읽기
- 멱등성: 메서드가 아니라 구현이 지키는 성질같은 요청을 두 번 보냈습니다.
- 홈페이지에 백엔드는 언제 필요할까? 랜딩페이지부터 업무시스템까지랜딩페이지를 만들 때도 백엔드가 필요하다고 생각하는 경우가 있다. 반대로 로그인, 예약, 결제와 파일 업로드가 있는 서비스를 단순 홈페이지처럼 구성하려는 경우도 있다.
- 시나리오형 FAQ 챗봇: 오래된 워드프레스 홈페이지에 붙이는 방식과 비용홈페이지에 챗봇을 붙이고 싶다는 문의가 왔습니다. 조건은 셋이었습니다. 관리자가 직접 질문과 답을 등록하는 시나리오형일 것, 어떤 질문이 많이 눌리는지 통계가 있을 것, 그리고 홈페이지는 워드프레스라는 것.
- Vercel의 역사: ZEIT와 Now에서 Fluid compute까지Vercel은 흔히 "Next.js를 배포하는 곳"으로 알려져 있습니다. 현재의 결합을 보면 자연스러운 설명이지만, 제품의 출발점은 프레임워크 호스팅보다 단순했습니다. 명령어 하나로 애플리케이션을 인터넷에 올리고, 배포마다 고유한 주소를 부여하는 것이 첫 문제였습니다.
- 홈페이지 플랫폼 이전 체크리스트: 도메인, SEO, 콘텐츠, 폼, 분석 데이터홈페이지 플랫폼 이전은 새 화면을 만드는 작업만이 아닙니다. 기존 URL과 검색 노출, 도메인과 회사 메일, 게시물과 이미지, 문의 폼, 분석 태그까지 함께 옮겨야 비로소 이전이 끝납니다.