Go, Rust, Zig 세 언어를 직접 써본 경험을 바탕으로, 각 언어가 어떤 가치에 최적화되어 설계되었는지와 그 트레이드오프를 정리한 글입니다.
URL: https://sinclairtarget.com/blog/2025/08/thoughts-on-go-vs.-rust-vs.-zig/
Title: Thoughts on Go vs. Rust vs. Zig
최근에 문득, 내가 항상 “일에 맞는 올바른 도구”를 골라 쓰고 있는 게 아니라, 이미 그 일자리에 깔려 있는 도구를 쓰고 있고, 그게 주로 내가 아는 프로그래밍 언어를 결정해 왔다는 걸 깨달았다. 그래서 지난 몇 달 동안, 회사에서는 쓸 일이 없는 언어들을 시험해 보느라 시간을 꽤 많이 쏟았다. 목표는 숙련도가 아니었다. 각 언어가 무엇에 강점이 있는지에 대해 나만의 의견을 갖는 것에 더 관심이 있었다.
프로그래밍 언어는 정말 수많은 축에서 서로 다르기 때문에, 비교를 하려 들면 결국 자명하지만 1) 지독하게 지루하고 2) 별로 도움이 되지도 않는 결론—“트레이드오프가 있다”—로 흘러가기 쉽다. 트레이드오프가 있다는 건 당연하다. 진짜 중요한 질문은, “이 언어는 왜 이 특정한 트레이드오프 세트를 선택했는가?” 이다.
이 질문이 내게 흥미로운 이유는, 언어를 가습기 사듯이 기능 목록을 놓고 고르고 싶지는 않기 때문이다. 나는 소프트웨어를 만드는 일에 관심이 있고, 그 일을 하는 데 쓰는 도구에도 관심이 있다. 언어가 어떤 트레이드오프를 택하느냐는 그 언어가 어떤 가치관을 표현하는가의 문제다. 나는 그 가치관 중 어떤 것이 나와 잘 맞는지 알고 싶다.
또한 이 질문은, 결과적으로 기능 집합이 상당히 겹치는 언어들 사이의 차이를 분명히 하는 데도 유용하다. “Go vs. Rust”, “Rust vs. Zig” 같은 질문이 인터넷에 그렇게 많이 올라오는 걸 보면, 사람들이 헷갈리고 있는 게 맞을 것이다. “언어 X는 a, b, c 기능이 있고 언어 Y는 a, b만 있어서 X가 웹 서비스를 쓰기에 더 좋다” 같은 식으로 기억해 두는 건 쉽지 않다. 오히려 “언어 X가 웹 서비스를 쓰기에 더 좋은 이유는, 언어 Y는 (가령) 인터넷을 증오하는 사람이 설계해서 인터넷 코드를 단점으로 보도록 되어 있기 때문이다”라고 기억하는 편이 더 쉽다.
여기에는 최근에 내가 실험해 본 세 언어—Go, Rust, Zig—에 대한 인상을 모아 보았다. 각 언어에서의 경험을 하나의 큰 평가로 압축해서, 그 언어가 무엇을 가치 있게 여기는지, 그리고 그 가치를 얼마나 잘 구현하고 있는지를 정리해 보려고 했다. 다소 단순화일 수는 있지만, 뭐랄까, 애초에 여기서 내가 하려는 게 어느 정도 단순화된 편견들을 결정화하는 일이기도 하니까.
Go의 가장 두드러진 특징은 미니멀리즘이다. 흔히 “현대적인 C”라고 불리기도 한다. Go는 C와 같지는 않다. 가비지 컬렉터가 있고 실제 런타임도 존재하기 때문이다. 하지만 “머릿속에 언어 전체를 다 넣어둘 수 있다”는 점에서는 C와 닮았다.
Go를 머릿속에 다 넣어둘 수 있는 이유는, Go에 기능이 정말 적기 때문이다. 오랫동안 Go는 제네릭이 없기로 악명이 높았다. 그건 Go 1.18에서야 바뀌었는데, 그마저도 사람들이 12년 동안 제네릭을 넣어 달라고 애걸복걸한 후에야 추가된 것이다. 현대 언어에는 흔한 기능들—태그된 유니온(tagged union)이라든가 에러 처리용 문법 설탕(syntactic sugar) 같은 것들—은 아직도 Go에 없다.
Go 개발 팀이 언어에 새 기능을 추가할 때 요구하는 기준은 상당히 높은 것으로 보인다. 그 결과, 다른 언어였다면 더 간결하게 표현할 수 있었을 로직을 Go에서는 보일러플레이트 코드를 잔뜩 써서 구현해야만 한다. 하지만 그 결과로 시간이 지나도 안정적이고 읽기 쉬운 언어가 만들어지기도 한다.
Go의 미니멀리즘을 보여주는 또 다른 예로 slice 타입을 생각해 보자. Rust와 Zig에도 slice 타입이 있지만, 이들은 오직 fat pointer일 뿐이다. Go에서 slice는 메모리 상의 연속된 구간을 가리키는 fat pointer이지만, 동시에 크기가 늘어날 수 있어서 Rust의 Vec<T>나 Zig의 ArrayList가 맡는 기능까지 포괄한다. 또한 Go는 메모리를 스스로 관리하기 때문에, slice의 백업 메모리가 스택에 올릴지 힙에 올릴지 역시 Go가 알아서 결정한다. Rust나 Zig에서는 메모리가 어디에 위치하는지에 대해 훨씬 더 깊게 생각해야 한다.
내가 아는 Go의 기원 신화는 대략 이렇다. Rob Pike는 C++ 프로젝트가 컴파일되기를 기다리는 데 진절머리가 났고, 그 C++ 프로젝트에서 다른 프로그래머들이 실수하는 것에도 질려 있었다. 그래서 Go는 C++가 바로크한 곳에서 단순함을 택했다. Go는 “프로그래밍 보병대”를 위한 언어로, 전체 사용 사례의 90% 정도를 다루기에 충분하면서도, 특히 동시성 코드를 쓸 때도 쉽게 이해할 수 있도록 설계되었다.
나는 일에서는 Go를 쓰지 않지만, 써야겠다는 생각을 하고 있다. Go의 미니멀리즘은 회사 환경에서의 협업을 위해서다. 이건 비난이 아니다—회사 환경에서 소프트웨어를 만드는 일은 그 나름의 고유한 문제가 있고, Go는 그 문제들을 잘 해결해 준다.
Go가 미니멀리스트라면, Rust는 맥시멀리스트다. Rust와 자주 함께 따라다니는 태그라인은 “zero-cost abstractions(제로 코스트 추상화)”이다. 나는 여기에 한 줄 더 붙이고 싶다. “제로 코스트 추상화, 그리고 그것을 잔뜩!”
Rust는 배우기 어렵다는 평판이 있다. 나는 Jamie Brandon이 쓴 글에 동의한다. 그는 Rust를 어렵게 만드는 건 라이프타임이 아니다라고 말한다. Rust가 어려운 진짜 이유는, 언어 안에 쑤셔 넣은 개념의 수 때문이다. 이 GitHub 댓글은 이미 여러 사람이 소재로 삼았지만, Rust의 개념 밀도를 완벽하게 보여 주기에 너무 좋은 예다.
타입
Pin<&LocalType>은Deref<Target = LocalType>를 구현하지만DerefMut는 구현하지 않습니다. 타입Pin과&는#[fundamental]이기 때문에Pin<&LocalType>>에 대한impl DerefMut가 가능합니다.LocalType == SomeLocalStruct나LocalType == dyn LocalTrait로 사용할 수 있고,Pin<Pin<&SomeLocalStruct>>를Pin<Pin<&dyn LocalTrait>>로 형변환할 수도 있습니다. (실제로, 두 겹의 Pin!!) 이렇게 하면 “CoerceUnsized를 구현하는데 이상한 동작을 하는” 스마트 포인터 쌍을 stable에서 만들 수 있습니다 (Pin<&SomeLocalStruct>와Pin<&dyn LocalTrait>가 그 “이상한 동작”을 하는 스마트 포인터가 되고, 이들은 이미CoerceUnsized를 구현하고 있습니다).
물론 Rust가 Go처럼 “맥시멀리즘을 목표로” 맥시멀한 것은 아니다. Rust가 복잡한 언어인 이유는, 부분적으로 긴장 관계에 있는 두 목표—안전성과 성능—를 동시에 달성하려 하기 때문이다.
성능 목표는 설명이 필요 없을 정도로 자명하다. “안전성(safety)”이 뜻하는 바는 덜 자명하다. 적어도 내게는 그랬다. (아마 내가 너무 오래 Python 머리가 되어 있어서일지도.) “안전성”은 “메모리 안전(memory safety)”을 뜻한다. 즉 잘못된 포인터를 역참조하거나, 이중 해제(double free)를 하거나 해서는 안 된다는 의미다. 하지만 그보다 더 많은 걸 뜻한다. “안전한” 프로그램은 모든 정의되지 않은 동작(undefined behavior, 흔히 UB라고 부르는 것) 을 피해야 한다.
이 악명 높은 UB란 무엇인가? 가장 잘 이해하는 방법은, 실행 중인 어떤 프로그램에 대해서든 죽음보다 더 나쁜 운명(FATES WORSE THAN DEATH) 이 있다는 걸 떠올려 보는 것 같다. 프로그램에서 뭔가 잘못되면, 그 즉시 프로그램이 종료되는 건 사실 아주 좋은 일이다! 다른 선택지는, 만약 에러가 잡히지 않으면, 프로그램이 예측 불가능한 황혼 지대로 넘어가 버리는 것이다. 그곳에서는 다음 데이터 레이스에서 어느 스레드가 이기느냐, 혹은 특정 메모리 주소에 우연히 어떤 쓰레기 값이 들어 있느냐에 따라 프로그램의 동작이 결정될 수 있다. 이제 당신은 하이젠버그 같은 버그들과 보안 취약점을 갖게 된다. 아주 안 좋다.
Rust는 이런 UB를 런타임 성능 비용을 치르지 않고, 즉 컴파일 타임에 검사하는 방식으로 막으려 한다. Rust 컴파일러는 똑똑하지만, 전지전능하지는 않다. 코드를 검사하려면, 컴파일러는 그 코드가 런타임에 무엇을 할지 이해해야 한다. 그래서 Rust에는 표현력이 높은 타입 시스템과, 다른 언어라면 단지 “겉으로 드러나는 런타임 동작”으로만 존재했을 만한 것들을 컴파일러에게 설명해 줄 수 있는 여러 가지 트레이트들이 존재한다.
이 때문에 Rust는 어렵다. 그냥 하고 싶은 일을 그냥 할 수가 없다! 먼저 Rust가 그 일을 뭐라고 부르는지—어떤 트레이트나 개념이 필요한지를—찾아내고, Rust가 기대하는 방식대로 그것을 구현해야 한다. 하지만 그렇게만 하면 Rust는 다른 언어들이 제공하지 못하는 수준의 동작에 대한 보증을 해 줄 수 있고, 특정 애플리케이션에서는 이게 정말로 결정적인 장점이 된다. Rust는 또한 다른 사람의 코드에 대해서도 보증을 해 주기 때문에, 라이브러리 의존성을 끌어다 쓰는 일이 쉬워지고, 그 결과 Rust 프로젝트의 의존성 갯수는 JavaScript 생태계의 프로젝트 못지 않게 많아지곤 한다.
세 언어 중 Zig가 가장 새롭고, 가장 미성숙한 언어다. 이 글을 쓰는 시점 기준으로 Zig의 버전은 0.14에 불과하다. 표준 라이브러리는 문서화가 거의 전무하고, 사용하는 법을 배우기 위한 최선의 방법은 소스 코드를 직접 읽는 것이다.
사실인지 확신할 수는 없지만, 나는 Zig를 Go와 Rust 둘 다에 대한 반작용(reaction) 으로 보는 걸 좋아한다. Go는 컴퓨터가 실제로 어떻게 돌아가는지를 감추기 때문에 단순하다. Rust는 그 수많은 후프를 뛰어넘도록 강요하기 때문에 안전하다. 그런데 Zig는 당신을 해방시켜 준다! Zig에서는 당신이 우주의 지배자이고, 아무도 당신에게 이래라저래라 할 수 없다.
Go와 Rust에서는, 어떤 객체를 힙에 올려 두는 일이 함수에서 struct에 대한 포인터를 반환하는 것만큼이나 간단하다. 할당은 암묵적으로 일어난다. Zig에서는, 모든 바이트를 직접, 명시적으로 할당해야 한다. (Zig는 수동 메모리 관리 언어다.) 여기서 당신은 C보다도 더 많은 제어권을 갖는다. 바이트를 할당하려면 특정 종류의 allocator에 대해 alloc()을 호출해야 하고, 따라서 해당 사용 사례에 가장 적합한 allocator 구현을 직접 골라야 한다.
Rust에서 가변 글로벌 변수를 선언하는 일은 너무 어려워서 그걸 어떻게 올바르게 하느냐를 두고 긴 포럼 토론 이 있을 정도다. Zig에서는 그냥 하나 만들면 된다. 문제 없다.
Zig에서도 정의되지 않은 동작은 여전히 중요하다. Zig는 이를 “illegal behavior(불법 동작)”라고 부른다. Zig는 이를 런타임에 감지해, 발생하면 프로그램을 크래시 시키려 한다. 이런 검사들이 성능에 미칠 영향이 걱정되는 사람들을 위해, Zig는 프로그램을 빌드할 때 선택할 수 있는 네 가지 “release 모드”를 제공한다. 이들 중 일부에서는 검사들이 비활성화된다. 아이디어는 이렇다. 체크가 켜진 release 모드들에서 프로그램을 충분히 많이 돌려 봐서, 체크가 꺼진 빌드에서도 불법 동작이 없을 거라는 합리적인 확신을 가질 수 있도록 하자는 것이다. 내게는 상당히 실용적인 설계로 보인다.
Zig와 나머지 두 언어의 또 다른 차이는, 객체지향 프로그래밍(OOP)과의 관계다. OOP는 이미 한동안 인기가 시들어졌고, Go와 Rust 모두 클래스 상속은 배제하고 있다. 하지만 Go와 Rust에는 다른 객체지향적 프로그래밍 관용구들을 꽤 지원하는 기능들이 있어서, 마음만 먹으면 여전히 프로그램 전체를 상호작용하는 객체들의 그래프로 구성할 수 있다. Zig에도 메서드는 있지만, private struct 필드가 없고, 런타임 다형성(= 동적 디스패치)을 지원하는 언어 차원의 기능도 없다. std.mem.Allocator는 딱 봐도 인터페이스가 되고 싶어 안달이 나 있는 타입인데도 말이다. 내가 보기엔 이 배제들은 의도적이다. Zig는 데이터 지향 설계(data-oriented design) 를 위한 언어다.
여기에 대해 더 하고 싶은 말이 하나 있다. 나로서는 눈이 번쩍 뜨였던 대목이다. 2025년에 수동 메모리 관리 언어를 새로 만든다는 건 미친 짓처럼 보일 수 있다. 특히 Rust가 “가비지 컬렉션 없이도, 컴파일러에게 그 역할을 맡길 수 있다”는 걸 보여 준 뒤라면 더더욱 그렇다. 하지만 이건 OOP 기능들을 제외하기로 한 선택과 매우 깊이 연관된 디자인 결정이다. Go, Rust, 그리고 그 밖의 수많은 언어들에서는, 보통 객체 그래프의 각 객체마다 작은 메모리 조각을 하나씩 할당하는 방식으로 코드를 짠다. 프로그램은 수천 개의 작은 malloc()과 free() 호출을 몰래 숨기고 있고, 따라서 수천 개의 서로 다른 라이프타임을 갖게 된다. 이게 바로 RAII다. Zig에서 수동 메모리 관리는 엄청나게 번거롭고, 오류를 부르기 쉬운 장부 정리를 요구하는 것처럼 보일 수 있다. 하지만 그건 모든 작은 객체들에 메모리 할당을 끈덕지게 묶어 두려 할 때의 이야기다. 대신, 프로그램의 어느 합리적인 지점들(예컨대 이벤트 루프의 각 이터레이션이 시작될 때) 에서 한 번에 큰 메모리 덩어리를 할당하고 해제한 다음, 그 안에 실제로 처리해야 할 데이터를 쌓아 두고 쓰는 방식도 가능하다. Zig가 장려하는 건 바로 이런 접근법이다.
많은 사람들이 이미 Rust가 있는데 왜 Zig가 있어야 하는지 를 헷갈려하는 듯하다. Zig가 단순해지려 한다는 것만이 이유는 아니다. 내 생각에 더 중요한 차이는 바로 방금 이야기한 부분이다. Zig는 코드에서 객체지향적인 사고를 더 철저하게 걷어내길 원한다.
Zig에는 뭔가 장난스럽고 전복적인 느낌이 있다. 이건 회사식 계층 구조(객체들의) 를 박살 내기 위한 언어다. 대권을 꿈꾸는 자들과 아나키스트를 위한 언어다. 마음에 든다. 하루빨리 안정 버전에 도달하면 좋겠지만, Zig 팀의 현재 최우선 순위는 모든 의존성을 직접 다시 구현하는 것 으로 보인다. Zig 1.0을 보기 전에 이 사람들이 리눅스 커널까지 다시 쓰겠다고 나설 가능성도 없는 건 아니다.