
Sollang으로 QUIC를 직접 구현해 봤습니다
공개 로그외부 QUIC DLL을 붙이는 대신 Sollang으로 QUIC v1·v2를 직접 구현하면서, 언어의 타입 추론과 불변성, self-host 컴파일러까지 함께 다듬은 이야기입니다.
안녕하세요. 이번에는 Sollang에 QUIC를 구현한 이야기를 소개합니다.
처음에는 웹서버를 만들기 위한 기초로 소켓 라이브러리를 추가하려고 했습니다. TCP와 UDP가 동작하고 나니 자연스럽게 QUIC까지 눈이 갔고요.
처음 작업 방향은 Rust용 QUIC 라이브러리를 붙이는 쪽으로 흘렀습니다. 그런데 생각해 보니 제가 원했던 것은 “Sollang에서 QUIC를 호출할 수 있다”가 아니었습니다. Sollang으로 QUIC를 구현하고 싶었던 것이죠.
언어를 만들고 있는데 정작 중요한 프로토콜은 다른 언어로 만든 DLL 뒤에 숨겨 놓는다면 조금 아쉽겠다는 생각도 들었습니다. 그래서 방향을 바꿨습니다. 외부 라이브러리를 붙이는 코드는 걷어 내고, QUIC 프로토콜을 Sollang으로 처음부터 구현했습니다.

여기서 “런타임에 내장한다”는 말도 다시 생각하게 됐습니다.
모든 Sollang 프로그램에 QUIC 코드를 무조건 넣는 것은 제가 원한 모습이 아니었습니다. 사용자가 <code>sys.quic</code>을 가져왔을 때 필요한 표준 라이브러리 소스가 프로그램에 함께 컴파일되는 방식이 더 자연스럽습니다. QUIC를 사용하지 않는 프로그램은 그만큼 가벼운 상태를 유지하고요.
운영체제와 맞닿는 가장 아래쪽에는 <code>sys.socket</code>이 있습니다. Windows와 Linux의 UDP 소켓을 다루는 이 작은 경계 위로 QUIC 패킷, TLS 핸드셰이크, 암호화, 스트림과 손실 복구를 Sollang 코드로 쌓았습니다.
그래서 이제 <code>sollang_quic.dll</code>도, Rust crate도 필요하지 않습니다. QUIC를 사용하면 Sollang으로 작성한 QUIC가 실행 파일 안으로 들어갑니다.
표준은 QUIC v1과 v2를 함께 기준으로 삼았습니다. TLS 1.3도 예전 RFC 8446이 아니라 2026년 7월에 이를 대체한 RFC 9846을 기준으로 다시 확인했습니다. Initial, Handshake, 1-RTT 패킷과 버전 협상, 스트림, DATAGRAM, 키 갱신까지 구현했고, 필요한 SHA-2, HMAC, HKDF, AES-GCM, X25519, Ed25519도 Sollang 표준 라이브러리에 들어갔습니다.
물론 QUIC의 모든 초안 기능을 다 구현했다는 뜻은 아닙니다. 예를 들어 multipath 같은 범위는 이번 안정 계약에 포함하지 않았습니다. 구현한 것과 아직 구현하지 않은 것을 분명하게 나누는 편이 좋을 것 같습니다.
그런데 QUIC를 구현하면서 오히려 더 많이 보인 것은 컴파일러였습니다.
암호나 프로토콜 코드는 정적인 상수와 고정 길이 데이터가 많습니다. 그런데 이미 타입과 길이를 알 수 있는데도 <code>UInt32(1)</code>처럼 타입을 반복해서 쓰거나, 고정 데이터를 growable 배열로 만든 다음 <code>push</code>를 계속 호출하고 있었습니다.
이건 라이브러리 코드만 예쁘게 고친다고 해결되는 문제가 아니었습니다. 자연스럽게 쓴 코드가 컴파일되지 않는다면 소스를 우회할 것이 아니라 컴파일러를 고쳐야 했습니다.
그래서 기대 타입이 있으면 숫자 타입을 생략할 수 있게 했고, 배열 내용으로 길이를 알 수 있으면 고정 배열로 추론하도록 했습니다. 수정하지 않는 바인딩은 불변이 기본이고, 불필요한 mutable 표시나 중복 타입 표기에는 Snnn 경고가 나오도록 했습니다.
중첩된 <code>when</code>도 꽤 거슬렸습니다. 성공 경로마다 블록이 하나씩 깊어지는 대신, <code>Result</code>를 반환하는 함수에서는 다음처럼 <code>?</code>로 평평하게 이어갈 수 있습니다.
sendPing endpoint: socket.Endpoint -> Result<Bool, socket.SocketError> uses Network {
socket.connect(endpoint)? => connection
socket.sendText(connection, "ping")? => count
socket.shutdown(connection)?
Result<Bool, socket.SocketError>.Ok(count == UIntSize(4))
}
조건이 맞을 때 아무 일도 하지 않고 실패할 때만 처리하는 <code>if else </code>도 <code>unless</code>로 표현할 수 있게 했습니다. 여러 값을 차례로 비교하던 코드는 subject 방식의 <code>when</code>으로 정리했고요. 실제 QUIC 코드를 작성하면서 Sollang다운 표현이 무엇인지 계속 되묻게 된 것 같습니다.
작업 중에는 컴파일러 문제가 계속 발견돼서 잠시 걱정도 됐습니다.
문제를 고칠수록 구조가 흔들리는 것인지, 아니면 보이지 않던 문제가 드러나면서 더 단단해지는 것인지 구분하기 어려웠거든요. 이번에는 그 판단 기준을 “같은 불변식이 C# 부트스트랩과 self-host 컴파일러 양쪽에서 유지되는가”에 두었습니다.
제어문 안의 고정 배열이 잘못된 메모리 구조로 내려가던 문제, enum <code>when</code>이 다른 타입의 SSA 값을 결과로 고르던 문제, Arena가 만든 <code>Text</code>를 사용하는 동안 같은 Arena를 다시 수정하던 문제도 소스에서 피하지 않고 컴파일러에서 고쳤습니다. 그리고 각각을 회귀 테스트로 남겼습니다.
최종적으로 Windows에서는 1259개, Linux에서는 1258개의 전체 회귀 테스트가 통과했습니다. 두 플랫폼 모두 Stage 2와 Stage 3가 같은 LLVM으로 수렴했고, 네이티브 컴파일러의 전체 CLI 동등성도 확인했습니다. 브라우저용 컴파일러와 진단 테스트도 함께 통과했고요. C# 부트스트랩 빌드 역시 경고 0, 오류 0입니다.
이번 작업을 마치고 나니 QUIC 하나를 추가했다기보다, Sollang으로 제법 큰 프로토콜을 직접 작성해 보면서 언어와 컴파일러를 다시 들여다본 느낌이 듭니다.
“이 코드는 왜 이렇게 장황해야 하지?”, “컴파일러가 이미 알고 있는 것을 왜 사용자가 또 써야 하지?”, “이 오류를 소스에서 피하는 것이 맞나?” 같은 질문이 꽤 많은 개선으로 이어졌습니다.
아직 갈 길은 멀지만, 이런 과정을 거치면서 Sollang의 구조가 조금씩 단단해지는 것 같네요.
Windows와 Linux 설치본은 Sollang 0.4.260817 GitHub 릴리스에서 받을 수 있습니다. 이번 버전부터 공개 패키지는 self-host로 만든 네이티브 <code>sollang</code>과 표준 라이브러리만 담습니다.
댓글 0개
댓글을 남기려면 로그인해 주세요.
아직 이어진 댓글이 없습니다. 첫 댓글을 남겨 보세요.