rusty_paseto를 만들며 ‘올바른 일은 쉽게, 잘못된 일은 어렵게’ 만드는 API를 Rust의 타입 시스템과 계층화로 구현한 방법을 설명한다.
URL: https://www.rodriguez.today/articles/designing-rusty-paseto
2019년 rusty_paseto를 만들기 시작했을 때—PASETO 토큰 명세를 Rust로 구현한 라이브러리—저는 단지 “정확성”만을 생각한 것이 아니었습니다. 새벽 2시 장애 상황에서 이 라이브러리를 쓰게 될 개발자, 스택 오버플로에서 복붙을 하며, 암호학 전문가가 되지 않고도 토큰이 그냥 잘 동작하길 바라는 그 개발자를 떠올렸습니다.
모든 설계 결정을 이끈 질문은 이것이었습니다. 어떻게 하면 올바른 길은 쉽게, 잘못된 길은 어렵게 만들 수 있을까?
이것이 바로 “성공의 함정(pit of success)”입니다. 이 용어는 Rico Mariani가 만들어냈는데, 사용자가 자연스럽게 올바른 사용법으로 “떨어지게” 만드는 API를 가리킵니다. 대부분의 API는 “절망의 함정(pit of despair)”입니다. 쉬운 길이 버그로 이어지고, 정확성을 얻으려면 힘겹게 기어 올라가야 하죠. 성공의 함정은 이를 뒤집습니다. 저항이 가장 적은 길이 곧 올바른 길이 됩니다. Rust의 타입 시스템은 이러한 제약을 코드에 직접 인코딩하게 해 주어, 실행 전에 위반을 잡아냅니다.
먼저 토큰에 대한 맥락을 조금 설명하겠습니다(제가 말하는 토큰이 “크립토 브로”의 그것이라고 오해하지 않도록). 웹사이트에 로그인하면 보통 토큰을 받는데, 이는 여러분의 신원을 증명하는 암호학적으로 서명된 데이터입니다. 브라우저는 각 요청마다 이 토큰을 함께 보내고, 서버는 재인증 없이도 사용자가 누구인지 검증할 수 있습니다. 이런 토큰은 API 보안, 싱글 사인온(SSO), 모바일 앱 등 곳곳에서 사용됩니다.
PASETO(Platform-Agnostic Security Tokens)는 JWT(JSON Web Tokens)의 보안 함정을 피하도록 설계된 토큰 명세입니다. JWT는 다양한 암호 알고리즘을 선택할 수 있게 해 주었고, 그중에는 문제가 있는 것으로 드러난 것들도 있었습니다. 반면 PASETO는 더 “의견이 있는(opinionated)” 접근을 취합니다. 각 버전이 사용할 알고리즘을 정확히 지정하여, 설정 실수로 발생하는 취약점의 한 부류를 제거합니다.
하지만 명세가 올바르다고 해서 오용이 사라지는 것은 아닙니다. 2015년 보안 연구자 Tim McLean은 JWT 라이브러리에 대한 “알고리즘 혼동(algorithm confusion)” 공격을 시연했습니다. 라이브러리가 알고리즘 선택을 처리하는 방식의 허점을 이용해 공격자가 유효한 토큰을 위조할 수 있었죠. 문제는 암호학 자체가 아니라, 검증 동작을 결정하는 데 사용자 제어 입력(알고리즘 헤더)을 신뢰한 API 설계였습니다.
대부분의 보안 라이브러리는 두 가지 방식 중 하나로 실패합니다. 너무 로우레벨이라 개발자가 실수하거나, 너무 하이레벨이라 엣지 케이스를 처리할 수 없습니다. 개발자는 원시 암호 프리미티브로 스스로 발을 쏘거나(footgun), 자신의 사용 사례가 추상화에 맞지 않을 때 추상화와 싸우게 됩니다.
저는 다른 것을 원했습니다. 복잡성을 점진적으로 공개(progressive disclosure)하는 방식입니다. 처음은 단순하게 시작하고, 필요할 때만 더 깊이 들어가도록요.
핵심 통찰은, 프로그래밍 언어의 타입 시스템이 다른 언어에서는 문서로만 표현되는 보안 제약을 코드로 인코딩할 수 있다는 점입니다. Rust에 익숙하지 않다면 핵심은 이렇습니다. Rust 컴파일러는 코드가 “돌아가는지”뿐 아니라, 타입 시스템이 허용하는 방식으로 데이터를 사용하는지도 검사합니다. 그 결과 일부 보안 취약점을 포함해 특정 범주의 버그는 애초에 작성 자체가 불가능해집니다.
PASETO의 버전(version)과 목적(purpose) 체계를 생각해봅시다. 버전 4 토큰은 현대적 암호 프리미티브를 사용합니다(암호화는 XChaCha20-Poly1305, 서명은 Ed25519). 그리고 purpose에서 local은 대칭키 암호화(같은 키로 암호화/복호화)이고, public은 비대칭 서명(개인키로 서명, 공개키로 검증)을 뜻합니다.
version: "v4", purpose: "local" 같은 문자열 매개변수를 받는 대신, rusty_paseto는 이를 서로 다른 타입으로 인코딩합니다. 컴파일러는 이들을 근본적으로 호환되지 않는 별개의 범주로 취급합니다.
// 이것들은 문자열 값이 아니라 서로 다른 타입입니다
PasetoBuilder::<V4, Local>::default()
.build(&key_v4_local)? // ✅ 컴파일됨
PasetoBuilder::<V4, Local>::default()
.build(&key_v3_public)? // ❌ 타입 에러: 컴파일 타임에 잡힘
V4 Local 토큰을 V3 Public 키로 “물리적으로” 만들 수가 없습니다. 컴파일러가 거부합니다. 이는 프로덕션에서 실패할 수도 있는 런타임 체크가 아닙니다. 컴파일 타임 보장으로, 버그의 한 부류를 통째로 제거합니다. 실수는 배포 후 프로덕션에서가 아니라, 코드를 작성한 직후 에디터에서 몇 초 만에 잡힙니다.
이는 능력 기반(capability-based) API에도 확장됩니다. 암묵적 assertion(토큰에 바인딩되는 추가 인증 데이터)은 V3와 V4 토큰만 지원합니다. 이를 문서에 적어두고 개발자가 읽길 기대하는 대신, 마커 트레이트(marker trait)를 사용해 V1이나 V2에서는 해당 메서드가 아예 존재하지 않게 만들었습니다. 잘못된 사용은 “권장되지 않음”이 아니라 “불가능”이 됩니다.
rusty_paseto는 서로 다른 세 레이어로 구성되며, 각각은 이전 레이어 위에 쌓입니다. 이는 인터페이스 설계에서 “점진적 공개(progressive disclosure)”라고도 불리는 패턴입니다.
Core는 암호 프리미티브만 제공합니다. JSON 직렬화도 없고, 클레임 처리도 없고, 어떤 의견도 없습니다. 최대한의 제어가 필요하거나 커스텀 직렬화 요구가 있는 드문 사용자에게 적합합니다.
Generic은 클레임 시스템과 JSON 직렬화를 추가하지만, 기본값은 없습니다. 클레임은 토큰 내부의 키-값 쌍으로, 누가 발급했는지, 언제 만료되는지, 누구를 위한 것인지 등을 담습니다. 이 레이어에서는 만료 시간, 검증 규칙, 무엇을 검사할지 등 모든 것을 사용자가 통제합니다.
Batteries-included(prelude)는 대부분의 개발자가 머물러야 할 곳입니다. 합리적인 기본값을 제공합니다: 1시간 만료, 자동 시간 검증. 한 줄로 안전한 토큰을 만들 수 있습니다.
let token = PasetoBuilder::<V4, Local>::default().build(&key)?;
끝입니다. 1시간 후 만료되며, 올바른 타임스탬프를 갖고, 그냥 잘 동작하는 토큰을 얻습니다.
이 네이밍은 의도적입니다. “batteries included”는 무엇이 포함되는지 신호를 줍니다. prelude에서 임포트하면 의견에 동의하는 것입니다. generic이나 core로 내려가면 “내가 뭘 하는지 안다”는 신호가 됩니다.
제가 좋아하는 설계 패턴 중 하나는 위험한 작업을 문법적으로도 분명하게 드러나게 하는 것입니다.
만료되지 않는 토큰을 만들고 싶나요? 많은 라이브러리에서는 만료(expiration) 필드를 빼면 됩니다. 하지만 무기한 토큰은 보안 위험입니다. 하나가 탈취되면 영원히 유효하니까요. rusty_paseto에서는 만료를 그냥 생략할 수 없습니다. 이렇게 작성해야 합니다.
PasetoBuilder::<V4, Local>::default()
.set_no_expiration_danger_acknowledged()
.build(&key)?
danger_acknowledged를 직접 타이핑해야 합니다. 불리언 플래그에 숨겨져 있지도 않고, 설정 깊숙한 곳에 묻혀 있지도 않습니다. 코드는 문자 그대로 “이게 위험하다는 걸 이해했다”고 말합니다.
비슷하게, V1 공개키 기능은 프로젝트 설정에서 v1_public_insecure라는 이름을 가집니다. 이를 활성화하면 의존성 매니페스트에 “insecure”가 등장합니다. 이는 보안 권고(V1 public 토큰에는 알려진 약점이 있음)를 반영하며, 문서를 읽지 않아도 프로젝트 설정 레벨에서 눈에 띄도록 합니다.
제가 특히 좋아하는 미묘한 설계 선택이 하나 있습니다. 토큰 파서는 생성자가 두 개입니다.
| 생성자 | 동작 |
|---|---|
PasetoParser::default() | 만료 및 시간 관련 클레임을 자동 검증 |
PasetoParser::new() | 자동 검증 없음 |
일반적으로는 new()가 주요 생성자이지만, 여기서는 반대로 했습니다. default()가 안전한 경로입니다. 시간 검증을 자동으로 받습니다. 이 검증을 건너뛰고 싶다면(테스트 목적이거나, 비정상적인 수명을 가진 토큰을 다뤄야 하는 경우 등) 명시적으로 new()를 호출해야 합니다.
개발자가 “당연히” 고를 선택, 즉 기본값을 선택하면 보안 동작을 얻게 됩니다. 옵트아웃은 의식적인 결정이 필요합니다.
빌더 패턴은 메서드를 체이닝하면서 단계적으로 객체를 구성하게 해 줍니다. 그런데 rusty_paseto에서는 반환 타입이 무엇이 실패할 수 있는지를 중요한 방식으로 전달합니다.
builder
.audience("api") // Self 반환 (실패할 수 없음)
.subject("user-123") // Self 반환 (실패할 수 없음)
.claim("user_id", 42)? // Result 반환 (실패할 수 있음)
.build(&key)?
audience()나 subject() 같은 단순 세터는 빌더 자체를 반환합니다. 실패할 수 없으니 에러 처리가 필요 없습니다. 하지만 claim()은 예약된 클레임 이름(예: 만료를 뜻하는 exp)을 실수로 덮어쓰지 않도록 검증하므로, 반드시 확인해야 하는 Result를 반환합니다.
코드에서 ? 연산자는 어떤 작업이 실패할 수 있는지 정확히 알려줍니다. 문서를 보지 않아도 코드만 읽고 실패 가능 지점을 이해할 수 있습니다.
rusty_paseto를 만들며 암호학과 Rust를 넘어 적용되는 원칙 몇 가지를 다시 확인했습니다.
제약은 자유를 준다. 시스템이 가능한 것을 더 많이 제한할수록 개발자가 내려야 할 결정이 줄고, 실수할 여지도 줄어듭니다. 처음에는 장황해 보이는 명시적 제한이, 시간이 지나면 버그의 한 부류를 막아주는 가드레일이 됩니다.
인터페이스에는 두 명의 사용자가 있다. 당장의 사용자는 일을 끝내고 싶습니다. 미래의 유지보수자는 코드가 무엇을 왜 하는지 이해해야 합니다. 잘 설계된 인터페이스는 둘 다를 만족시킵니다. 즉각적 사용을 안내하는 동시에, 나중에 읽는 사람에게 의도를 문서화합니다.
레이어링은 선택지를 보존한다. 관심사를 레이어로 분리하면 사용자를 특정 선택으로 몰지 않습니다. 단순함이 필요한 사람은 단순함을 얻고, 제어가 필요한 사람은 제어를 얻습니다. 어느 쪽도 다른 쪽을 희생시키지 않습니다.
올바른 일을 쉽게 만들어라. 좋은 설계란 가능한 모든 오용을 막는 것이 아닙니다. 올바른 사용이 최소 저항 경로가 되게 만드는 것입니다. 안전한 옵션이 편리한 옵션이기도 할 때, 개발자는 자연스럽게 그것을 선택합니다.
이 패턴은 정확성이 중요한 어떤 도메인에도 그대로 옮겨갈 수 있습니다. PASETO의 구체적 세부사항보다 중요한 것은 설계 방법론입니다. 중요한 제약을 식별하고, 인터페이스에 명시적으로 드러내며, 문서가 “권할” 수밖에 없는 것을 시스템이 강제하게 하라.
rusty_paseto는 crates.io와 GitHub에서 사용할 수 있습니다. PASETO 토큰에 관심이 있거나, 이런 패턴이 실제로 어떻게 작동하는지 보고 싶다면 한 번 살펴보세요.