VPN과 메시 네트워크의 사실상 표준이 된 WireGuard는 프로토콜 자체는 단순하지만, 이를 사용자 공간(userspace)에서 러스트 같은 언어로 다시 구현하는 일은 생각보다 까다롭다. Firezone은 자사 제품을 만들 때 널리 쓰이는 boringtun을 가져다 썼는데, 2년 넘게 운영하면서 포크에 버그 수정과 API 추가를 반복적으로 덧붙여야 했다. 상류(upstream)로 다시 올릴 수 있도록 기존 API를 계속 유지하려 했지만, 제품 요구사항이 진화할수록 100% 호환을 지키기는 점점 어려워졌다. 의존성 갱신을 빼고도 70건이 넘는 패치가 쌓였고, 결국 그들은 축적된 경험을 바탕으로 처음부터 다시 설계한 구현을 내놨다. 그것이 Apache 2.0 라이선스로 공개되고 crates.io에 올라온 sanstun이다.
sans-IO라는 설계 철학
sanstun의 핵심은 이름에 담긴 'sans-IO'다. 이는 입출력과 부수효과를 상태 및 핵심 로직에서 깨끗하게 분리하도록 시스템을 설계하는 발상이다. 여기서 IO는 파일이나 네트워크만을 뜻하지 않는다. 시간과 난수처럼 외부에 의존하는 모든 요소가 포함된다. 부수효과가 없는 컴포넌트는 조합이 쉽고 테스트하기도 수월하며, 러스트에서는 함수 색칠(async 전파) 문제를 피하고 소유권 규칙과도 잘 맞는다. Firezone이 boringtun에 가한 첫 큰 변경도 시간 처리를 sans-IO로 바꾸는 것이었다. Instant를 인자로 받는 _at 접미사 API를 전부 추가했는데, 이는 시스템 전체를 정확히 시뮬레이션하려면 시간이 외부에서 주입되어야 하기 때문이다.
또 하나 떼어낸 것은 라이브러리 내부 버퍼링이다. 세션이 없을 때 패킷을 보내려 하면 자동으로 핸드셰이크를 열고 패킷을 쌓아두는 동작은 언뜻 편해 보이지만 여러 문제를 낳는다. sanstun의 결론은 단순하다. 라이브러리가 버퍼링하지 않는 것이다. 그 결과 특정 API를 루프 안에서 반복 호출해야 한다는 암묵적 계약이 사라지고, 패킷을 보내려면 Tunnel::begin_encapsulate를 호출하되 세션이 아직 없으면 버퍼링은 호출자의 몫이 된다. 핸드셰이크는 Tunnel::initiate_handshake로 원하는 시점에 직접 시작한다.
타입으로 프로토콜의 실수를 막는다
WireGuard 세션은 비교적 비싼 디피-헬만 키 교환으로 시작한다. 공개키만 알면 누구나 가짜 핸드셰이크를 만들고 출발지 IP를 위조할 수 있는데, 수신 측은 데이터가 가짜임을 알아차리기 전에 이미 자기 몫의 DH 연산을 끝내야 한다. 가짜 요청을 충분히 쏟아부으면 CPU가 무의미한 핸드셰이크에 모두 소진된다. WireGuard는 이를 막기 위해 쿠키 메커니즘으로 출발지 IP 소유를 증명하게 하고, 그에 따라 출발지별로 핸드셰이크를 제한한다. 이 레이트 리밋은 개별 터널이 아니라 인터페이스 전체에 적용된다는 점이 중요하다.
sanstun은 이 구조를 Interface와 Tunnel 타입으로 직접 대응시킨다. 들어오는 모든 패킷은 Interface::receive 하나만을 통과한다. 원시 데이터그램을 파싱하는 유일한 공개 API가 여기이며, 패킷을 해석하고 레이트 리밋을 적용한 뒤 메시지 종류별 열거형 변형을 담은 ReceivedMessage를 돌려주거나, 부하 상태라면 되돌려 보낼 쿠키 응답을 준다. HandshakeInitiation 같은 타입의 생성자는 크레이트 내부로 숨겨져 있어서, Tunnel::handle_handshake_initiation가 공개돼 있어도 반드시 Interface::receive를 먼저 거쳐야만 그 타입을 얻을 수 있다. 결과적으로 레이트 리미터를 빼먹거나 패킷을 엉뚱한 터널로 보내는 실수를 컴파일 단계에서 구조적으로 차단한다. 타입을 잘 쓰면 애초에 깨진 코드를 출하할 수 없게 만드는 러스트 특유의 강점이 그대로 드러나는 대목이다.
병렬 친화적인 암·복호화 분리
sanstun이 가장 내세우는 지점은 병렬 처리다. 프로토콜상 WireGuard는 터널 간 상태를 공유하지 않으므로 피어별 병렬화는 쉽다. 문제는 단일 터널 내부다. WireGuard는 3계층에서 동작하므로 한 세션이 여러 네트워크 흐름을 실어 나르는데, 재전송 공격을 막기 위한 패킷 카운터가 세션 단위로 단조 증가한다. 이 카운터를 여러 스레드에서 건드리려면 동기화가 필요해, 같은 호스트로 향하는 모든 흐름이 ChaCha20-Poly1305 암호화 속도에 묶여 순차 처리되고 만다.
sanstun은 장부 기록과 실제 암·복호화를 떼어내 이를 우회한다. Tunnel::begin_encapsulate는 사실 아무것도 암호화하지 않는다. 현재 세션을 확인하고 패킷 카운터를 예약하며 타이머 상태를 전진시킨 뒤 PendingEncapsulate를 돌려준다. 이 값은 Send를 구현하므로 다른 스레드로 넘겨 무거운 암호화 작업을 맡길 수 있다. 저장소의 cargo bench data_path 결과를 보면 장부 기록 부분은 패킷 크기에 비례해 늘지 않고, 크기에 비례하는 것은 encrypt·decrypt뿐이다. 1280바이트 패킷에서 암호화 신규 패킷의 장부 비용은 2%에 불과해, 암호화를 다른 스레드로 옮기면 이론적으로 45배 이상의 여지가 보인다는 계산이 나온다.
다만 Firezone 스스로도 45배는 실무에서 달성하기 어렵다고 못 박는다. PendingEncapsulate를 다른 스레드로 넘기는 비용과 그 밖의 오버헤드를 무시한 수치이기 때문이다. 그럼에도 암호화를 워커 스레드로 떠넘길 수 있다는 점은 처리량을 크게 끌어올린다. sans-IO 라이브러리답게 병렬화 방식 자체는 강제하지 않으므로, 단순하게 가고 싶다면 encrypt·decrypt를 인라인으로 호출해 병렬화를 아예 포기해도 된다. 설계의 목표는 암호화만 병렬화하면서 Interface 주변은 단일 스레드로 유지해, 내부 레이트 리미터가 락이나 원자적 카운터 없이 보로우 체커의 도움을 받도록 하는 것이었다. WireGuard 위에 VPN이나 메시 네트워크를 올리는 팀이라면, 2년간의 포크 운영에서 길어 올린 이 API 설계가 검토할 만한 참고 사례가 된다. 기존 firezone/boringtun 포크는 이와 함께 지원 중단됐다.