Rust의 타입 주도 올바름 (Type-Driven Correctness)
저자 소개
- Microsoft SCHIE (Silicon and Cloud Hardware Infrastructure Engineering) 팀의 수석 펌웨어 아키텍트
- 보안, 시스템 프로그래밍(펌웨어, 운영체제, 하이퍼바이저), CPU 및 플랫폼 아키텍처, C++ 시스템 분야의 업계 전문가
- 2017년(@AWS EC2)부터 Rust로 프로그래밍을 시작했으며, 이후 이 언어의 매력에 깊이 빠져 있습니다.
이 책은 Rust의 타입 시스템을 사용하여 컴파일 자체가 불가능한 방식으로 버그의 전체 클래스를 제거하는 실전을 다룹니다. 자매서인 Rust 패턴이 메커니즘(트레이트, 연관 타입, 타입 상태)을 다룬다면, 이 가이드는 하드웨어 진단, 암호학, 프로토콜 검증, 임베디드 시스템과 같은 실제 환경에 이러한 메커니즘을 적용하는 방법을 보여줍니다.
여기에 소개된 모든 패턴은 한 가지 원칙을 따릅니다: 런타임 검사를 타입 시스템으로 밀어 넣어 컴파일러가 이를 강제하도록 만드는 것입니다.
이 책의 활용 방법
난이도 범례
| 기호 | 레벨 | 대상 |
|---|---|---|
| 🟢 | 입문 (Introductory) | 소유권 및 트레이트에 익숙한 분 |
| 🟡 | 중급 (Intermediate) | 제네릭 및 연관 타입에 익숙한 분 |
| 🔴 | 고급 (Advanced) | 타입 상태, 팬텀 타입, 세션 타입을 배울 준비가 된 분 |
맞춤형 학습 경로
| 목표 | 경로 | 예상 시간 |
|---|---|---|
| 빠른 개요 | 01장, 17장 (참조 카드) | 30분 |
| IPMI / BMC 개발자 | 02, 05, 07, 10, 13장 | 2.5시간 |
| GPU / PCIe 개발자 | 02, 06, 09, 10, 15장 | 2.5시간 |
| Redfish 구현자 | 02, 05, 07, 08, 13, 14장 | 3시간 |
| 프레임워크 / 인프라 | 04, 08, 11, 15, 18장 | 2.5시간 |
| 설계에 의한 올바름 입문 | 01장 → 10장 순서대로, 이후 16장 연습 문제 | 4시간 |
| 전체 심층 분석 | 모든 장을 순서대로 학습 | 7시간 |
장별 목차 요점
| 장 | 제목 | 난이도 | 핵심 아이디어 |
|---|---|---|---|
| 1 | 철학 — 왜 타입이 테스트보다 뛰어난가 | 🟢 | 세 가지 수준의 올바름; 컴파일러가 검증하는 보증으로서의 타입 |
| 2 | 타입이 지정된 명령 인터페이스 | 🟡 | 요청 → 응답을 연결하는 연관 타입 |
| 3 | 단회용 타입 | 🟡 | 암호학을 위한 선형 타입으로서의 이동 의미론 |
| 4 | 역량 토큰 | 🟡 | 제로 비용 권한 증명 토큰 |
| 5 | 프로토콜 상태 머신 | 🔴 | IPMI 세션 및 PCIe LTSSM을 위한 타입 상태 |
| 6 | 차원 분석 | 🟢 | 컴파일러가 단위를 검사하게 만드는 뉴타입 래퍼 |
| 7 | 유효성 검증 경계 | 🟡 | 경계에서 한 번 파싱하고, 타입에 증거를 담아라 |
| 8 | 의무 믹스인 | 🟡 | 재료 트레이트 및 담요 구현(Blanket impls) |
| 9 | 팬텀 타입 | 🟡 | 레지스터 너비, DMA 방향 등 리소스 추적용 PhantomData |
| 10 | 종합 정리 | 🟡 | 7가지 패턴을 하나의 진단 플랫폼에 통합 |
| 11 | 실전에서 유용한 14가지 팁 | 🟡 | Sentinel→Option, 봉인된 트레이트, 빌더 등 |
| 12 | 연습 문제 | 🟡 | 정답이 포함된 6가지 종합 문제 |
| 13 | 참조 카드 | — | 패턴 카탈로그 및 의사결정 순서도 |
| 14 | 타입 수준 보장 테스트하기 | 🟡 | trybuild, proptest, cargo-show-asm |
| 15 | Const Fn | 🟡 | 메모리 맵, 레지스터, 비트필드에 대한 컴파일 타임 증명 |
| 16 | Send & Sync | 🟡 | 컴파일 타임 동시성 증명 |
| 17 | Redfish 클라이언트 가이드 | 🟡 | 8가지 패턴이 결합된 타입 안전한 Redfish 클라이언트 |
| 18 | Redfish 서버 가이드 | 🟡 | 빌더 타입 상태, 소스 토큰, 상태 롤업, 믹스인 |
선수 지식
| 개념 | 학습 소스 |
|---|---|
| 소유권과 빌림 | Rust 패턴 7장 |
| 트레이트와 연관 타입 | Rust 패턴 2장 |
| 뉴타입과 타입 상태 | Rust 패턴 3장 |
| PhantomData | Rust 패턴 4장 |
| 제네릭과 트레이트 경계 | Rust 패턴 1장 |
설계에 의한 올바름(Correct-by-Construction) 스펙트럼
← 덜 안전함 더 안전함 →
런타임 검증 단위 테스트 속성 기반 테스트 설계에 의한 올바름
───────────── ────────── ────────────── ──────────────────────
if temp > 100 { #[test] proptest! { struct Celsius(f64);
panic!("너무 fn test_temp() { |t in 0..200| { // 타입 수준에서 Rpm과
뜨거움"); assert!( assert!(...) // 혼동할 수 없음
} check(42)); }
} }
잘못된 프로그램?
잘못된 프로그램? 잘못된 프로그램? 잘못된 프로그램? 컴파일되지 않음.
실제 서비스 중단. CI 단계에서 실패. CI 단계에서 실패 절대 존재할 수 없음.
(확률적).
이 가이드는 가장 오른쪽 지점에서 작동합니다. 즉, 타입 시스템이 버그를 표현할 수 없기 때문에 버그가 존재하지 않는 영역입니다.
1. 철학 — 왜 타입이 테스트보다 뛰어난가 🟢
학습 목표: 컴파일 타임 올바름의 세 가지 수준(값, 상태, 프로토콜)을 이해하고, 제네릭 함수 시그니처가 어떻게 컴파일러가 확인하는 보증수표 역할을 하는지 배웁니다. 또한 '설계에 의한 올바름(Correct-by-construction)' 패턴이 언제 투자가치가 있고 언제는 아닌지 구분해 봅니다.
런타임 검사의 비용
흔히 볼 수 있는 진단 코드베이스의 런타임 가드(Guard)를 살펴보겠습니다.
fn read_sensor(sensor_type: &str, raw: &[u8]) -> f64 {
match sensor_type {
"temperature" => raw[0] as i8 as f64, // 부호 있는 바이트
"fan_speed" => u16::from_le_bytes([raw[0], raw[1]]) as f64,
"voltage" => u16::from_le_bytes([raw[0], raw[1]]) as f64 / 1000.0,
_ => panic!("알 수 없는 센서 타입: {sensor_type}"),
}
}
이 함수에는 컴파일러가 잡아낼 수 없는 네 가지 실패 모드가 존재합니다.
- 오타:
"temperture"라고 적으면 런타임에 패닉이 발생합니다. - 잘못된 바이트 길이:
fan_speed인데 1바이트만 넘기면 런타임에 패닉이 발생합니다. - 논리적 버그: 호출자가 섭씨(°C) 온도를 반환받아 놓고 RPM인 줄 알고 사용해도 아무도 알려주지 않습니다.
- 누락: 새로운 센서 타입이 추가되었는데 이
match문에 반영되지 않으면 런타임에 패닉이 발생합니다.
이러한 모든 실패는 배포 이후에나 발견됩니다. 테스트가 도움이 될 수는 있겠지만, 그것도 누군가가 그 상황을 예상하고 테스트를 작성했을 때의 이야기입니다. 하지만 타입 시스템은 아무도 예상하지 못한 경우를 포함한 모든 경우를 커버합니다.
올바름의 세 가지 수준
1단계 — 값의 올바름 (Value Correctness)
잘못된 값을 표현 불가능하게 만드세요.
// ❌ 어떤 u16이든 "포트"가 될 수 있음 — 0은 유효하지 않지만 컴파일은 됨
fn connect(port: u16) { /* ... */ }
// ✅ 유효성이 검증된 포트만 존재할 수 있음
pub struct Port(u16); // 필드는 비공개(private)
impl TryFrom<u16> for Port {
type Error = &'static str;
fn try_from(v: u16) -> Result<Self, Self::Error> {
if v > 0 { Ok(Port(v)) } else { Err("포트는 0보다 커야 함") }
}
}
fn connect(port: Port) { /* ... */ }
// Port(0)은 절대 생성될 수 없음 — 불변성이 모든 곳에서 유지됨
하드웨어 예시: SensorId(u8) — 원시 센서 번호를 감싸서 그것이 SDR(Sensor Data Record) 범위 내에 있는지 유효성을 검사합니다.
2단계 — 상태의 올바름 (State Correctness)
잘못된 상태 전이를 표현 불가능하게 만드세요.
use std::marker::PhantomData;
struct Disconnected;
struct Connected;
struct Socket<State> {
fd: i32,
_state: PhantomData<State>,
}
impl Socket<Disconnected> {
fn connect(self, addr: &str) -> Socket<Connected> {
// ... 연결 로직 ...
Socket { fd: self.fd, _state: PhantomData }
}
}
impl Socket<Connected> {
fn send(&mut self, data: &[u8]) { /* ... */ }
fn disconnect(self) -> Socket<Disconnected> {
Socket { fd: self.fd, _state: PhantomData }
}
}
// Socket<Disconnected> 타입에는 send() 메서드가 없음 — 호출 시 컴파일 에러
하드웨어 예시: GPIO 핀 모드 — Pin<Input>은 read()를 가질 수 있지만 write()는 가질 수 없습니다.
3단계 — 프로토콜 올바름 (Protocol Correctness)
잘못된 상호작용을 표현 불가능하게 만드세요.
use std::io;
trait IpmiCmd {
type Response;
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}
// 설명을 위해 단순화함 — 02장에서 net_fn(), cmd_byte(), payload()
// 등을 포함한 전체 트레이트를 볼 수 있습니다.
struct ReadTemp { sensor_id: u8 }
impl IpmiCmd for ReadTemp {
type Response = Celsius;
fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
Ok(Celsius(raw[0] as i8 as f64))
}
}
#[derive(Debug)] struct Celsius(f64);
fn execute<C: IpmiCmd>(cmd: &C, raw: &[u8]) -> io::Result<C::Response> {
cmd.parse_response(raw)
}
// ReadTemp는 항상 Celsius를 반환함 — 실수로 Rpm을 얻는 일은 절대 없음
하드웨어 예시: IPMI, Redfish, NVMe 시스템 명령 — 요청(Request) 타입이 응답(Response) 타입을 결정합니다.
컴파일러가 확인하는 보완 장치로서의 타입
여러분이 다음과 같이 코드를 작성할 때:
fn execute<C: IpmiCmd>(cmd: &C) -> io::Result<C::Response>
여러분은 단순한 함수를 쓰는 것이 아니라 일종의 보증을 하고 있는 것입니다: "IpmiCmd를 구현하는 모든 명령 타입 C에 대해, 그것을 실행하면 정확히 C::Response를 생성한다"는 보증 말입니다. 컴파일러는 빌드할 때마다 이 보증을 검증합니다. 만약 타입이 맞지 않으면 프로그램은 컴파일되지 않습니다.
이것이 Rust 타입 시스템이 강력한 이유입니다. 단순히 실수를 잡아내는 데서 그치지 않고, 컴파일 타임에 올바름을 강제하기 때문입니다.
언제 이러한 패턴을 사용하지 "않아야" 하는가?
'설계에 의한 올바름'이 항상 최선의 선택은 아닙니다.
| 상황 | 권장 사항 |
|---|---|
| 보안이 중요한 경계 (전원 시퀀싱, 암호화) | ✅ 무조건 — 여기서의 버그는 하드웨어를 태우거나 비밀을 유출함 |
| 크레이트 간 공개(Public) API | ✅ 대체로 — 오용 자체가 컴파일 에러가 되어야 함 |
| 3개 이상의 상태를 가진 상태 머신 | ✅ 대체로 — 타입 상태가 잘못된 전이를 방지함 |
| 50줄 이내의 단순한 내부 헬퍼 함수 | ❌ 과함 — 간단한 assert!로 충분함 |
| 하드웨어 프로토타이핑 / 초기 탐색 시 | ❌ 원시 타입부터 — 동작이 명확해진 후에 타입을 정제함 |
| 사용자용 CLI 파싱 | ⚠️ 경계에서는 clap + TryFrom을 쓰되, 내부는 원시 타입도 괜찮음 |
핵심 질문은 이것입니다: "이 버그가 배포 이후에 발생했을 때, 얼마나 심각한가?"
- 팬(Fan)이 멈춰 GPU가 녹음 → 타입 사용
- 잘못된 DER 레코드 전송으로 고객이 틀린 데이터 수신 → 타입 사용
- 단순 디버그 로그 메시지가 약간 틀림 →
assert!사용
핵심 요약
- 올바름의 세 가지 수준 — 값(뉴타입), 상태(타입 상태), 프로토콜(연관 타입) — 각각은 더 넓은 범주의 버그를 제거합니다.
- 보증으로서의 타입 — 모든 제네릭 함수 시그니처는 빌드할 때마다 컴파일러가 확인하는 계약서입니다.
- 비용의 문제 — "이 버그가 배포되면 얼마나 치명적인가?"라는 질문이 타입과 테스트 중 무엇이 적절한 도구인지 결정합니다.
- 타입은 테스트를 보완함 — 타입은 버그의 범주 자체를 제거하고, 테스트는 특정 값이나 엣지 케이스를 확인합니다.
- 멈출 때를 알아야 함 — 일회성 프로토타입이나 단순한 내부 헬퍼는 타입 수준의 강력한 제약이 필요하지 않은 경우가 많습니다.
2. 타입이 지정된 명령 인터페이스 — 요청이 응답을 결정함 🟡
학습 목표: 명령 트레이트의 연관 타입이 어떻게 요청과 응답 사이에 컴파일 타임 결합(binding)을 생성하는지 배웁니다. 이를 통해 IPMI, Redfish, NVMe 프로토콜 전반에서 발생하는 파싱 불일치, 단위 혼동, 암시적 타입 변환 등의 문제를 제거하는 방법을 익힙니다.
타입이 없는 늪 (The Untyped Swamp)
IPMI, Redfish, NVMe Admin, PLDM과 같은 대부분의 하드웨어 관리 스택은 원시 바이트 입력 → 원시 바이트 출력 구조로 시작됩니다. 이는 테스트로도 일부만 잡아낼 수 있는 종류의 버그를 만들어냅니다.
use std::io;
struct BmcRaw { /* ipmitool 핸들 */ }
impl BmcRaw {
fn raw_command(&self, net_fn: u8, cmd: u8, data: &[u8]) -> io::Result<Vec<u8>> {
// ... 실제로는 ipmitool 등을 호출 ...
Ok(vec![0x00, 0x19, 0x00]) // 임시 데이터
}
}
fn diagnose_thermal(bmc: &BmcRaw) -> io::Result<()> {
let raw = bmc.raw_command(0x04, 0x2D, &[0x20])?;
let cpu_temp = raw[0] as f64; // 🤞 0번 바이트가 온도 값이 맞겠지?
let raw = bmc.raw_command(0x04, 0x2D, &[0x30])?;
let fan_rpm = raw[0] as u32; // 🐛 버그: 팬 속도는 2바이트 리틀 엔디언임
let raw = bmc.raw_command(0x04, 0x2D, &[0x40])?;
let voltage = raw[0] as f64; // 🐛 버그: 1000으로 나눠야 함
if cpu_temp > fan_rpm as f64 { // 🐛 버그: 섭씨(°C)와 RPM을 비교하고 있음
println!("문제 발생");
}
log_temp(voltage); // 🐛 버그: 전압(V)을 온도 로그 함수에 전달
Ok(())
}
fn log_temp(t: f64) { println!("온도: {t}°C"); }
| # | 버그 내용 | 발견 시점 |
|---|---|---|
| 1 | 팬 RPM을 2바이트가 아닌 1바이트로 파싱 | 운영 환경에서 새벽 3시 |
| 2 | 전압 수치 보정(Scaling) 누락 | 모든 PSU가 과전압으로 오진됨 |
| 3 | 섭씨(°C)와 RPM을 직접 비교 | 어쩌면 영원히 발견 못 함 |
| 4 | 전압 값이 온도 로거로 전달됨 | 6개월 뒤 과거 데이터를 분석할 때 |
근본 원인: 모든 데이터가 Vec<u8> → f64로 취급되며, 개발자의 "기도"에 의존하고 있습니다.
타이핑된 명령(Typed Command) 패턴
1단계 — 도메인 뉴타입(Newtype) 정의
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Rpm(pub u32); // u32: 원시 IPMI 센서 값 (정수 RPM)
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Volts(pub f64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Watts(pub f64);
Rpm(u32)vsRpm(f64)에 대하여: 이 장에서는 IPMI 센서 읽기 값이 정수이므로u32를 사용합니다. 06장(차원 분석)에서는 산술 연산(평균, 스케일링)을 지원하기 위해f64를 사용합니다. 두 방식 모두 유효하며, 뉴타입 패턴은 내부 타입이 무엇이든 단위 간의 혼동을 방지합니다.
2단계 — 명령 트레이트 정의 (타입 인덱싱된 디스패치)
연관 타입인 Response가 핵심입니다. 이 타입은 각 명령 구조체와 그 반환 타입을 컴파일 타임에 묶어줍니다. 각 구현체는 Response를 특정 도메인 타입으로 고정하므로, execute()는 항상 정확한 타입을 반환하게 됩니다.
pub trait IpmiCmd {
/// 타입 인덱스 — execute()가 무엇을 반환할지 결정합니다.
type Response;
fn net_fn(&self) -> u8;
fn cmd_byte(&self) -> u8;
fn payload(&self) -> Vec<u8>;
/// 파싱 로직을 캡슐화 — 각 명령은 자신의 바이트 레이아웃을 알고 있습니다.
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}
3단계 — 명령별 구조체 구현
pub struct ReadTemp { pub sensor_id: u8 }
impl IpmiCmd for ReadTemp {
type Response = Celsius;
fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.sensor_id] }
fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
if raw.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "응답이 비어 있음"));
}
// 참고: 01장의 예제는 SDR 메타데이터 없이 동작을 보여주기 위해
// `raw[0] as i8 as f64`를 사용했습니다. 여기서는 IPMI 사양 §35.5의
// 공식에 따라 처리합니다. 실무에서는 전체 SDR 공식을 적용하세요:
// 결과 = (M × raw + B) × 10^(R_exp).
Ok(Celsius(raw[0] as f64))
}
}
pub struct ReadFanSpeed { pub fan_id: u8 }
impl IpmiCmd for ReadFanSpeed {
type Response = Rpm;
fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.fan_id] }
fn parse_response(&self, raw: &[u8]) -> io::Result<Rpm> {
if raw.len() < 2 {
return Err(io::Error::new(io::ErrorKind::InvalidData,
format!("팬 속도는 2바이트가 필요하지만 {}바이트 수신", raw.len())));
}
Ok(Rpm(u16::from_le_bytes([raw[0], raw[1]]) as u32))
}
}
pub struct ReadVoltage { pub rail: u8 }
impl IpmiCmd for ReadVoltage {
type Response = Volts;
fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.rail] }
fn parse_response(&self, raw: &[u8]) -> io::Result<Volts> {
if raw.len() < 2 {
return Err(io::Error::new(io::ErrorKind::InvalidData,
format!("전압은 2바이트가 필요하지만 {}바이트 수신", raw.len())));
}
Ok(Volts(u16::from_le_bytes([raw[0], raw[1]]) as f64 / 1000.0))
}
}
4단계 — 실행기 (제로 비용 단형성화)
pub struct BmcConnection { pub timeout_secs: u32 }
impl BmcConnection {
pub fn execute<C: IpmiCmd>(&self, cmd: &C) -> io::Result<C::Response> {
let raw = self.raw_send(cmd.net_fn(), cmd.cmd_byte(), &cmd.payload())?;
cmd.parse_response(&raw)
}
fn raw_send(&self, _nf: u8, _cmd: u8, _data: &[u8]) -> io::Result<Vec<u8>> {
Ok(vec![0x19, 0x00]) // 임시 구현
}
}
5단계 — 네 가지 버그가 모두 컴파일 에러가 됨
fn diagnose_thermal_typed(bmc: &BmcConnection) -> io::Result<()> {
let cpu_temp: Celsius = bmc.execute(&ReadTemp { sensor_id: 0x20 })?;
let fan_rpm: Rpm = bmc.execute(&ReadFanSpeed { fan_id: 0x30 })?;
let voltage: Volts = bmc.execute(&ReadVoltage { rail: 0x40 })?;
// 버그 #1 — 발생 불가능: 파싱 로직이 ReadFanSpeed::parse_response 안에 캡슐화됨
// 버그 #2 — 발생 불가능: 단위 보정이 ReadVoltage::parse_response 안에 캡슐화됨
// 버그 #3 — 컴파일 에러:
// if cpu_temp > fan_rpm { }
// ^^^^^^^^ ^^^^^^^ Celsius vs Rpm → "타입 불일치(mismatched types)" ❌
// 버그 #4 — 컴파일 에러:
// log_temperature(voltage);
// ^^^^^^^ Volts 타입은 Celsius 타입을 기대하는 곳에 전달 불가 ❌
if cpu_temp > Celsius(85.0) { println!("CPU 과열: {:?}", cpu_temp); }
if fan_rpm < Rpm(4000) { println!("팬 속도 낮음: {:?}", fan_rpm); }
Ok(())
}
fn log_temperature(t: Celsius) { println!("온도: {:?}", t); }
fn log_voltage(v: Volts) { println!("전압: {:?}", v); }
IPMI: 혼동할 수 없는 센서 데이터 읽기
새로운 센서를 추가하는 작업은 구조체 하나와 impl 하나로 끝납니다. 파싱 코드가 여기저기 흩어지지 않습니다.
pub struct ReadPowerDraw { pub domain: u8 }
impl IpmiCmd for ReadPowerDraw {
type Response = Watts;
fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.domain] }
fn parse_response(&self, raw: &[u8]) -> io::Result<Watts> {
if raw.len() < 2 {
return Err(io::Error::new(io::ErrorKind::InvalidData,
format!("전력 소비량은 2바이트가 필요하지만 {}바이트 수신", raw.len())));
}
Ok(Watts(u16::from_le_bytes([raw[0], raw[1]]) as f64))
}
}
// bmc.execute(&ReadPowerDraw { domain: 0 })를 호출하는 모든 곳에서
// 자동으로 Watts 타입을 반환받습니다. 다른 곳에 파싱 코드를 둘 필요가 없습니다.
Redfish: 스키마 기반 REST 엔드포인트
Redfish는 더 잘 어울립니다. 각 엔드포인트는 DMTF에서 정의한 특정 JSON 스키마를 반환하기 때문입니다.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct ThermalResponse {
#[serde(rename = "Temperatures")]
pub temperatures: Vec<RedfishTemp>,
#[serde(rename = "Fans")]
pub fans: Vec<RedfishFan>,
}
#[derive(Debug, Deserialize)]
pub struct RedfishTemp {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "ReadingCelsius")]
pub reading: f64,
#[serde(rename = "UpperThresholdCritical")]
pub critical_hi: Option<f64>,
#[serde(rename = "Status")]
pub status: RedfishHealth,
}
#[derive(Debug, Deserialize)]
pub struct RedfishFan {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Reading")]
pub rpm: u32,
#[serde(rename = "Status")]
pub status: RedfishHealth,
}
// (중략: PowerResponse, ProcessorResponse 등도 유사한 방식으로 정의)
/// 타이핑된 Redfish 엔드포인트 — 각 엔드포인트는 자신의 응답 타입을 알고 있습니다.
pub trait RedfishEndpoint {
type Response: serde::de::DeserializeOwned;
fn method(&self) -> &'static str;
fn path(&self) -> String;
}
pub struct GetThermal { pub chassis_id: String }
impl RedfishEndpoint for GetThermal {
type Response = ThermalResponse;
fn method(&self) -> &'static str { "GET" }
fn path(&self) -> String {
format!("/redfish/v1/Chassis/{}/Thermal", self.chassis_id)
}
}
// ... execute() 구현 및 사용 예시 ...
NVMe Admin: Identify와 Log Page의 구분
NVMe 관리 명령도 같은 형태를 따릅니다. 컨트롤러는 명령 코드(Opcode)로 이를 구분하지만, C 언어에서는 호출자가 4KB 완료 버퍼에 어떤 구조체를 씌워야 할지 직접 알고 있어야 합니다. 타입 지정 명령 패턴은 이 과정에서 실수가 발생하는 것을 원천 차단합니다.
pub trait NvmeAdminCmd {
type Response;
fn opcode(&self) -> u8;
fn parse_completion(&self, data: &[u8]) -> io::Result<Self::Response>;
}
// Identify 명령 (Opcode 0x06)은 IdentifyResponse를 반환하도록 타입을 강제함
확장: 명령 스크립트를 위한 매크로 DSL
/// 일련의 타이핑된 IPMI 명령을 실행하고 결과 튜플을 반환합니다.
macro_rules! diag_script {
($bmc:expr; $($cmd:expr),+ $(,)?) => {{
( $( $bmc.execute(&$cmd)?, )+ )
}};
}
fn full_pre_flight(bmc: &BmcConnection) -> io::Result<()> {
let (temp, rpm, volts) = diag_script!(bmc;
ReadTemp { sensor_id: 0x20 },
ReadFanSpeed { fan_id: 0x30 },
ReadVoltage { rail: 0x40 },
);
// 반환 타입: (Celsius, Rpm, Volts) — 모두 자동 추론됨
Ok(())
}
요약
- 연관 타입 = 컴파일 타임 계약 — 명령 트레이트의
type Response는 각 요청을 정확히 하나의 응답 타입과 연결합니다. - 파싱 캡슐화 — 바이트 레이아웃 정보는 호출자가 아닌
parse_response내부에만 존재합니다. - 제로 비용 디바이스 — 제네릭
execute<C: IpmiCmd>는 vtable 없이 직접 호출로 단형성화되어 실행됩니다. - 하나의 패턴, 다양한 프로토콜 — IPMI, Redfish, NVMe, PLDM, MCTP 등 모든 하드웨어 프로토콜에 동일한
trait Cmd { type Response; }형태를 적용할 수 있습니다. - 단계적 복잡도가 직관을 강화함 — 단순한 센서 읽기(IPMI)에서 정교한 JSON(Redfish), 로우 버퍼 구조체 매핑(NVMe)으로 나아가는 과정이 모두 하나의 일관된 패턴으로 설명됩니다.
3. 단회용 타입 — 소유권을 통한 암호학적 보장 🟡
학습 목표: Rust의 이동 의미론(Move semantics)이 어떻게 선형 타입 시스템(Linear type system)처럼 작동하여 논스(Nonce) 재사용, 키 합의 중복, 그리고 우발적인 퓨즈(Fuse) 재프로그래밍을 컴파일 타임에 방지하는지 배웁니다.
논스 재사용의 재앙 (The Nonce Reuse Catastrophe)
인증된 암호화(AES-GCM, ChaCha20-Poly1305 등)에서 동일한 키로 논스를 재사용하는 것은 치명적입니다. 이는 두 평문의 XOR 값을 노출시키고, 심지어 인증 키 자체를 유출할 수도 있습니다. 이는 이론적인 문제가 아닙니다.
- 2016년: TLS의 AES-GCM에 대한 Forbidden Attack — 논스 재사용으로 평문 복구 가능함이 증명됨.
- 2020년: 여러 IoT 펌웨어 업데이트 시스템에서 부실한 난수 생성기(RNG)로 인해 논스가 재사용되는 사례 발견.
C/C++에서 논스는 그저 uint8_t[12]일 뿐입니다. 이를 두 번 사용하는 것을 막을 장치가 없습니다.
// C — 논스 재사용을 막을 수 없음
uint8_t nonce[12];
generate_nonce(nonce);
encrypt(key, nonce, msg1, out1); // ✅ 첫 번째 사용
encrypt(key, nonce, msg2, out2); // 🐛 치명적 버그: 동일한 논스 재사용
선형 타입으로서의 이동 의미론
Rust의 소유권 시스템은 사실상 선형 타입 시스템과 같습니다. Copy를 구현하지 않은 값은 정확히 한 번만 사용(이동)될 수 있기 때문입니다. 암호학 라이브러리인 ring 크레이트는 이 점을 활용합니다.
// ring::aead::Nonce 타입의 특징:
// - Clone 불가
// - Copy 불가
// - 사용 시 값으로 소비(Consume)됨
pub struct Nonce(/* 비공개 필드 */);
Nonce를 seal_in_place() 함수에 전달하면 값이 이동합니다.
fn seal_in_place(
key: &SealingKey,
nonce: Nonce, // ← 참조가 아닌 값으로 "이동"함
data: &mut Vec<u8>,
) -> Result<(), Error> {
// ... 암호화 수행 ...
// 함수가 끝나면 nonce는 소멸됨 — 다시 사용할 수 없음
Ok(())
}
재사용을 시도하면 컴파일 에러가 발생합니다.
fn bad_encrypt(key: &SealingKey, data1: &mut Vec<u8>, data2: &mut Vec<u8>) {
let nonce = Nonce::try_assume_unique_for_key(&[0u8; 12]).unwrap();
seal_in_place(key, nonce, data1).unwrap(); // ✅ nonce가 여기서 이동함
// seal_in_place(key, nonce, data2).unwrap();
// ^^^^^ ERROR: 이동된 값 사용 (use of moved value) ❌
}
컴파일러가 각 논스가 정확히 한 번만 사용됨을 증명합니다. 별도의 테스트가 필요 없습니다.
하드웨어 응용: 일회성 퓨즈(OTP Fuse) 프로그래밍
서버 플랫폼에는 보안 키, 시리얼 번호 등을 저장하는 일회성 프로그래밍 가능(OTP) 퓨즈가 있습니다. 퓨즈 쓰기는 되돌릴 수 없으며, 서로 다른 데이터로 두 번 쓰려고 하면 하드웨어가 영구적으로 손상(Brick)될 수 있습니다.
/// 퓨즈 쓰기 페이로드. Clone/Copy 불가.
pub struct FusePayload {
address: u32,
data: Vec<u8>,
}
impl FuseController {
/// 퓨즈를 프로그래밍함 — 페이로드를 소비하여 중복 쓰기를 방지함.
pub fn program(
&mut self,
payload: FusePayload, // ← 이동(Move) — 두 번 사용할 수 없음
) -> io::Result<()> {
// ... 하드웨어에 쓰기 수행 ...
// payload가 소비되었으므로, 같은 payload로 다시 시도하면 컴파일 에러
Ok(())
}
}
단회용 타입 사용 가이드
| 시나리오 | 단회용(이동) 의미론 권장 여부 |
|---|---|
| 암호학적 논스(Nonce) | ✅ 무조건 — 재사용 시 보안 파괴 |
| 임시 키 합의 (DH, ECDH) | ✅ 무조건 — 재사용 시 순방향 비밀성 약화 |
| OTP 퓨즈 쓰기 | ✅ 무조건 — 중복 쓰기 시 하드웨어 손상 |
| 라이선스 활성화 코드 | ✅ 대체로 — 중복 활성화 방지 |
| 캘리브레이션 토큰 | ✅ 대체로 — 세션당 한 번의 조정 강제 |
| 데이터 버퍼 | ❌ 재사용이 필수적이므로 &mut [u8] 사용 |
핵심 요약
- 이동 = 단회 사용 —
Clone이나Copy가 없는 타입은 정확히 한 번만 소비될 수 있으며, 컴파일러가 이를 강제합니다. - 패턴의 확장성 — 이 패턴은 암호학을 넘어 OTP 퓨즈, 캘리브레이션 토큰, 감사 로그 엔트리 등 "최대 한 번만 발생해야 하는" 모든 로직에 적용됩니다.
- 순방향 비밀성(Forward Secrecy) — 임시 키가 파생된 비밀값으로 이동된 후 메모리에서 즉시 사라지므로 보안이 강화됩니다.
- 의심스러울 땐 Clone을 빼라 — 나중에 추가하는 것은 쉽지만, 공개된 API에서
Clone을 제거하는 것은 파괴적인 변경(Breaking change)입니다.
4. 역량 토큰 — 비용 없는 권한 증명 🟡
학습 목표: 제로 크기 타입(ZST)이 어떻게 컴파일 타임 증명 토큰 역할을 하는지 배웁니다. 이를 통해 권한 계층 구조, 전원 시퀀싱, 그리고 회수 가능한 권한을 런타임 비용 없이 강제하는 방법을 익힙니다.
문제 요망: 무엇을 할 권한이 있는가?
하드웨어 진단에서 일부 작업은 위험합니다:
- BMC 펌웨어 프로그래밍
- PCIe 링크 리셋
- OTP 퓨즈 쓰기
- 고전압 테스트 모드 활성화
C/C++에서는 이러한 작업을 런타임 검사로 보호합니다.
// C — 런타임 권한 검사
int reset_pcie_link(bmc_handle_t bmc, int slot) {
if (!bmc->is_admin) { // 런타임 검사
return -EPERM;
}
// ... 위험한 작업 수행 ...
return 0;
}
위험한 작업을 수행하는 모든 함수는 이 검사를 반복해야 합니다. 하나라도 잊어버리면 권한 상승(Privilege escalation) 버그가 됩니다.
증명 토큰으로서의 제로 크기 타입 (ZST)
**역량 토큰(Capability Token)**은 호출자가 특정 작업을 수행할 권한이 있음을 증명하는 제로 크기 타입(ZST)입니다. 런타임에는 0바이트를 차지하며, 오직 타입 시스템에만 존재합니다.
/// 관리자 권한이 있음을 증명하는 토큰.
/// 제로 크기 타입 — 컴파일 시 완전히 사라짐.
/// Clone/Copy 불가 — 명시적으로 전달되어야 함.
pub struct AdminToken {
_private: (), // 모듈 외부에서 생성을 방지함
}
/// PCIe 링크가 훈련(Trained)되었음을 증명하는 토큰.
pub struct LinkTrainedToken {
_private: (),
}
impl BmcController {
/// 관리자로 인증 — 역량 토큰을 반환함.
/// 이것이 AdminToken을 생성할 수 있는 유일한 방법임.
pub fn authenticate_admin(
&mut self,
credentials: &[u8],
) -> Result<AdminToken, &'static str> {
// ... 자격 증명 검증 ...
Ok(AdminToken { _private: () })
}
/// PCIe 링크 리셋 — 관리자 권한과 링크 훈련 증명이 모두 필요함.
/// 런타임 검사가 필요 없음 — 토큰 자체가 증명이기 때문임.
pub fn reset_pcie_link(
&mut self,
_admin: &AdminToken, // 비용 없는 권한 증명
_trained: &LinkTrainedToken, // 비용 없는 상태 증명
slot: u32,
) -> Result<(), &'static str> {
println!("{slot}번 슬롯의 PCIe 링크 리셋 중...");
Ok(())
}
}
사용 예시:
fn maintenance_workflow(bmc: &mut BmcController) -> Result<(), &'static str> {
let admin = bmc.authenticate_admin(b"secret")?; // 단계 1: 권한 획득
let trained = bmc.train_link()?; // 단계 2: 상태 증명 획득
// 단계 3: 컴파일러가 두 토큰을 모두 요구함
bmc.reset_pcie_link(&admin, &trained, 0)?;
Ok(())
}
이 토큰들은 컴파일된 바이너리에서 0바이트가 됩니다. 함수 시그니처는 "이 함수를 호출하려면 AdminToken을 제시해야 하며, 이를 얻는 유일한 방법은 authenticate_admin()뿐이다"라는 증명 의무를 명시적으로 나타냅니다.
계층적 역량 (Hierarchical Capabilities)
실제 시스템에는 계층이 존재합니다. 관리자는 운영자가 할 수 있는 모든 일을 할 수 있어야 합니다. 이를 트레이트 계층 구조로 모델링할 수 있습니다.
pub trait Authenticated { fn token_id(&self) -> u64; }
pub trait Operator: Authenticated {}
pub trait Admin: Operator {}
// 구체적인 토큰들:
pub struct UserToken { id: u64 }
pub struct AdminCapToken { id: u64 }
// AdminCapToken은 Authenticated, Operator, Admin을 모두 만족함
impl Authenticated for AdminCapToken { fn token_id(&self) -> u64 { self.id } }
impl Operator for AdminCapToken {}
impl Admin for AdminCapToken {}
impl Bmc {
/// 운영자 이상만 진단을 실행할 수 있음
pub fn run_diag(&mut self, _who: &impl Operator, test: &str) -> bool { true }
/// 관리자만 펌웨어를 업데이트할 수 있음
pub fn flash_firmware(&mut self, _who: &impl Admin, image: &[u8]) -> Result<(), &'static str> { Ok(()) }
}
AdminCapToken은 모든 함수에 전달될 수 있지만, UserToken은 run_diag()를 호출할 수 없습니다. 컴파일러가 이 권한 모델 전체를 런타임 비용 0으로 강제합니다.
역량 토큰 사용 시나리오
| 시나리오 | 적용 패턴 |
|---|---|
| 권한이 필요한 하드웨어 조작 | ZST 증명 토큰 (AdminToken) |
| 다단계 시퀀싱 강제 | 상태 토큰 체인 (StandbyOn → AuxiliaryOn → ...) |
| 역할 기반 접근 제어(RBAC) | 트레이트 계층 구조 (Authenticated → Operator → Admin) |
| 시간 제한 권한 | 수명(Lifetime)이 제한된 토큰 (ScopedAdminToken<'a>) |
핵심 요약
- ZST 토큰은 0바이트를 차지함 — 타입 시스템에만 존재하며 LLVM에 의해 완전히 최적화되어 사라집니다.
- 비공개 생성자 = 위조 불가능 — 오직 인증된 모듈의 특정 함수만이 토큰을 발행할 수 있습니다.
- 트레이트 계층으로 권한 수준 모델링 —
Admin: Operator: Authenticated구조는 실제 RBAC 모델을 완벽히 반영합니다. - 자동 권한 회수 — 수명이 할당된 토큰은 세션이 종료되면 빌림 검사기에 의해 자동으로 무효화됩니다.
5. 프로토콜 상태 머신 — 실제 하드웨어를 위한 타입 상태 🔴
학습 목표: 타입 상태(Type-state) 인코딩을 통해 프로토콜 위반(잘못된 순서의 명령, 종료 후 재사용 등)을 어떻게 컴파일 에러로 전환하는지 배웁니다. IPMI 세션 생명주기와 PCIe 링크 훈련(Link training)에 이 패턴을 적용해 봅니다.
관련 장: 01장 (상태 올바름), 04장 (토큰), 09장 (팬텀 타입), 11장 (타입 상태 빌더와 비동기 타입 상태)
문제 요망: 프로토콜 위반
하드웨어 프로토콜은 엄격한 상태 머신을 가집니다. IPMI 세션은 비인증 → 인증됨 → 활성 → 종료됨의 상태를 거칩니다. PCIe 링크 훈련은 감지 → 폴링 → 설정 → L0 단계를 따릅니다. 잘못된 상태에서 명령을 보내면 세션이 깨지거나 버스가 멈출 수 있습니다.
C/C++에서는 열거형(enum)과 런타임 검사로 상태를 추적합니다.
typedef enum { IDLE, AUTHENTICATED, ACTIVE, CLOSED } session_state_t;
int ipmi_send_command(ipmi_session_t *s, uint8_t cmd, uint8_t *data, int len) {
if (s->state != ACTIVE) { // 런타임 검사 — 잊어버리기 쉽습니다
return -EINVAL;
}
// ... 명령 전송 ...
return 0;
}
타입 상태(Type-State) 패턴
타입 상태 패턴에서는 각 프로토콜 상태를 별개의 타입으로 정의합니다. 상태 전이는 이전 상태를 소비하고 새로운 상태를 반환하는 메서드로 구현됩니다. 잘못된 상태에서는 메서드 자체가 존재하지 않기 때문에 컴파일러가 오류를 잡아냅니다.
IPMI 세션 생명주기 예시
use std::marker::PhantomData;
// 상태를 나타내는 제로 크기 마커 타입들
pub struct Idle;
pub struct Authenticated;
pub struct Active;
pub struct Closed;
/// 현재 상태를 타입 매개변수로 갖는 IPMI 세션.
pub struct IpmiSession<State> {
transport: String,
session_id: Option<u32>,
_state: PhantomData<State>,
}
// 전이: Idle → Authenticated
impl IpmiSession<Idle> {
pub fn new(host: &str) -> Self {
IpmiSession { transport: host.to_string(), session_id: None, _state: PhantomData }
}
pub fn authenticate(self, user: &str, pass: &str) -> Result<IpmiSession<Authenticated>, String> {
// self를 소비(consume)하여 Idle 상태를 무효화함
Ok(IpmiSession { transport: self.transport, session_id: Some(42), _state: PhantomData })
}
}
// 전이: Authenticated → Active
impl IpmiSession<Authenticated> {
pub fn activate(self) -> Result<IpmiSession<Active>, String> {
Ok(IpmiSession { transport: self.transport, session_id: self.session_id, _state: PhantomData })
}
}
// Active 상태에서만 사용 가능한 작업들
impl IpmiSession<Active> {
pub fn send_command(&mut self, netfn: u8, cmd: u8, data: &[u8]) -> Vec<u8> {
// 전송 로직...
vec![0x00]
}
pub fn close(self) -> IpmiSession<Closed> {
IpmiSession { transport: self.transport, session_id: None, _state: PhantomData }
}
}
사용 예시:
fn ipmi_workflow() -> Result<(), String> {
let session = IpmiSession::new("192.168.1.100");
// session.send_command(...);
// ❌ 에러: IpmiSession<Idle>에는 send_command 메서드가 없음
let session = session.authenticate("admin", "password")?;
let mut session = session.activate()?;
// ✅ 이제야 send_command를 호출할 수 있음
let response = session.send_command(0x04, 0x2D, &[1]);
let _closed = session.close();
// _closed.send_command(...); // ❌ 에러: 종료된 세션에서는 명령 불가
Ok(())
}
컴파일러는 다음을 보장합니다:
- 활성화 전 인증 필수
- 명령 전송 전 활성화 필수
- 종료 후 명령 전송 불가
타입 상태와 역량 토큰의 결합
진단 프로그램이 활성화된 세션과 관리자 권한을 모두 요구한다고 가정해 봅시다.
/// 펌웨어 업데이트는 활성 세션(타입 상태)과 관리자 토큰(역량 토큰)을 모두 요구함
pub fn firmware_update(
session: &mut IpmiSession<Active>, // 세션이 활성 상태임을 증명
_admin: &AdminToken, // 호출자가 관리자임을 증명
image: &[u8],
) -> Result<(), String> {
// 런타임 검사 불필요 — 시그니처가 곧 검증임
session.send_command(0x2C, 0x01, image);
Ok(())
}
타입 상태 적용 가이드
| 프로토콜 / 상황 | 타입 상태 권장 여부 |
|---|---|
| IPMI/Redfish 세션 생명주기 | ✅ 강력 권장 — 인증 전 명령 전송 방지 |
| PCIe 링크 훈련 (LTSSM) | ✅ 강력 권장 — 링크가 준비되기 전 TLP 전송 방지 |
| TLS 핸드쉐이크 | ✅ 권장 — 핸드쉐이크 순서 강제 |
| 단순한 요청/응답 | ⚠️ 불필요 — 상태가 2개인 경우는 과함 |
| 비상태형 메시지 전송 | ❌ 불필요 — 추적할 상태가 없음 |
핵심 요약
- 잘못된 호출의 원천 차단 — 메서드가 유효한 상태의 타입에만 정의되므로 순서 위반이 물리적으로 불가능합니다.
- 소유권을 통한 전이 — 각 전이 메서드는
self를 소비하므로, 전이 후에도 이전 상태를 붙잡고 있을 수 없습니다. - 복합적 안전성 — 타입 상태와 역량 토큰(04장), 단회용 증명(03장)을 결합하여 복잡한 로직을 하나의 무결한 상태 머신으로 관리할 수 있습니다.
- 제로 비용 — 모든 검사는 컴파일 타임에 수행되며, 실제 바이너리에는 상태 추적을 위한 오버헤드가 남지 않습니다.
6. 차원 분석 — 컴파일러가 단위를 검사하게 만들기 🟢
학습 목표: 뉴타입(Newtype) 래퍼와
uom크레이트가 어떻게 컴파일러를 단위 검사 엔진으로 변모시키는지 배웁니다. 이를 통해 수억 달러 규모의 우주선을 파괴했던 것과 같은 범주의 버그를 사전에 차단하는 법을 익힙니다.관련 장: 02장 (이 장의 타입을 사용하는 명령 인터페이스), 07장 (유효성 검증 경계), 10장 (통합)
화성 기후 궤도선(Mars Climate Orbiter)의 교훈
1999년, NASA의 화성 기후 궤도선은 한 팀이 추력 데이터를 파운드-힘 초(lb·s) 단위로 보냈는데, 제어 팀은 이를 뉴턴-초(N·s) 단위로 받아들이는 바람에 손실되었습니다. 우주선은 예정된 226km가 아닌 57km 고도에서 화성 대기권에 진입하여 공중 분해되었습니다.
이 버그의 근본 원인은 두 값 모두 double 타입이었다는 데 있습니다. 컴파일러는 두 단위를 구분할 수 없었습니다. 물리량을 다루는 모든 하드웨어 진단 도구에도 동일한 범주의 버그가 숨어 있습니다.
// C — 모두 double 타입이며 단위 검사가 없음
double read_temperature(int sensor_id); // 섭씨? 화씨? 켈빈?
double read_voltage(int channel); // 볼트? 밀리볼트?
물리량을 위한 뉴타입(Newtypes)
가장 간단하고 확실한 '설계에 의한 올바름' 접근법은 각 단위를 고유한 타입으로 감싸는 것입니다.
/// 섭씨 온도
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);
/// 화씨 온도
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Fahrenheit(pub f64);
/// 전압 (볼트)
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Volts(pub f64);
// 명시적인 변환 구현:
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self { Fahrenheit(c.0 * 9.0 / 5.0 + 32.0) }
}
이제 컴파일러가 단위 불일치를 잡아냅니다.
fn check_limit(temp: Celsius, limit: Celsius) -> bool {
temp > limit // ✅ 동일한 단위 — 컴파일 성공
}
// fn bad_check(temp: Celsius, voltage: Volts) -> bool {
// temp > voltage // ❌ 에러: 타입 불일치 (Celsius vs Volts)
// }
런타임 비용은 제로입니다. 뉴타입은 컴파일된 바이너리에서 원시 f64 값으로 존재합니다. 래퍼는 오직 타입 시스템상의 개념일 뿐입니다.
센서 파이프라인 적용 예시
원시 ADC 값을 읽어 물리 단위로 변환하고 임계값과 비교하는 전 과정을 타입 수준에서 검사할 수 있습니다.
/// ADC 읽기 — 아직 물리량이 아님
pub struct AdcReading { pub channel: u8, pub raw: u16 }
pub struct TempCal { pub scale: f64, pub offset: f64 }
impl TempCal {
/// 원시 ADC 값을 Celsius로 변환. 반환 타입이 Celsius임을 보장함.
pub fn convert(&self, adc: AdcReading) -> Celsius {
Celsius(adc.raw as f64 * self.scale + self.offset)
}
}
/// 임계값 검사 — 단위가 일치할 때만 컴파일됨
pub struct Threshold<T: PartialOrd> { pub warning: T, pub critical: T }
impl<T: PartialOrd> Threshold<T> {
pub fn check(&self, value: &T) -> bool { *value >= self.critical }
}
이제 Threshold<Celsius>에 Volts 값을 전달하려고 하면 컴파일러가 즉시 오류를 발생시킵니다. 섭씨와 볼트를 비교하는 논리적 실수는 더 이상 발생할 수 없습니다.
uom 크레이트 활용
실제 운영 환경에서는 uom 크레이트를 사용하는 것이 좋습니다. 수백 개의 단위를 지원하며, 자동 단위 변환 및 유도 단위(예: Watt = Volt × Ampere) 계산을 런타임 오버헤드 없이 제공합니다.
// uom 사용 예시 (의사 코드)
let temp = Celsius::new(85.0);
let volt = Volts::new(12.0);
// temp + volt; // ❌ 컴파일 에러 — 온드와 전압은 더할 수 없음
핵심 요약
- 뉴타입은 비용 없이 혼동을 방지함 —
Celsius와Rpm은 내부적으로 같은f64지만, 컴파일러는 이를 완전히 다른 타입으로 취급합니다. - 우주선 손실 버그의 원천 차단 —
Pounds를 기대하는 곳에Newtons를 전달하는 실수는 이제 컴파일 에러입니다. - 제네릭 임계값 검사 —
Threshold<T>패턴을 통해 모든 물리량에 대해 재사용 가능하면서도 타입 안전한 검사 로직을 구현할 수 있습니다. - uom 크레이트 — 복잡한 유도 단위와 산술 연산이 필요할 때 매우 강력한 도구입니다.
7. 유효성 검증 경계 — 검증하지 말고 파싱하라 🟡
학습 목표: 시스템 경계에서 데이터를 단 한 번만 검증하고, 그 유효성 증명을 전용 타입에 담아 두 번 다시 재검증하지 않는 기법을 배웁니다. IPMI FRU 데이터(원시 바이트), Redfish JSON(구조화된 문서), 그리고 IPMI SEL 레코드(중첩된 다형성 데이터)를 예로 들어 실습합니다.
관련 장: 02장 (타입 지정 명령), 06장 (차원 타입), 11장 (봉인된 트레이트, FromStr), 14장 (속성 기반 테스트)
문제 요망: 사방에 흩어진 검증 (Shotgun Validation)
일반적인 코드에서는 검증 로직이 도처에 흩어져 있습니다. 데이터를 받는 모든 함수가 "혹시 모르니" 재검증을 수행합니다.
// C — 코드 전체에 흩어진 검증 로직
int process_fru_data(uint8_t *data, int len) {
if (data == NULL) return -1; // 널 체크
if (len < 8) return -1; // 최소 길이 체크
if (data[0] != 0x01) return -1; // 포맷 버전 체크
if (checksum(data, len) != 0) return -1; // 체크섬 확인
// ... 이후 호출되는 함수들에서 똑같은 체크를 반복 ...
}
이 방식의 문제점:
- 중복 — 동일한 검사 코드가 수십 군데 나타납니다.
- 불완전성 — 실수로 단 한 군데라도 검증을 빼먹으면 보안 취약점이나 버그가 됩니다.
검증하지 말고 파싱하라 (Parse, Don't Validate)
올바른 접근법은 경계에서 단 한 번만 검증하고, 그 성공 증명을 타입에 담는 것입니다.
/// 아직 검증되지 않은 원시 바이트 데이터
pub struct RawFruData(Vec<u8>);
/// 유효성이 검증된 IPMI FRU 데이터.
/// TryFrom을 통해서만 생성될 수 있으며, 일단 생성되면 모든 데이터가 올바름을 보장함.
pub struct ValidFru {
format_version: u8,
board_area_offset: u8,
data: Vec<u8>,
}
impl TryFrom<RawFruData> for ValidFru {
type Error = String;
fn try_from(raw: RawFruData) -> Result<Self, String> {
let data = raw.0;
if data.len() < 8 { return Err("너무 짧음".into()); }
if data[0] != 0x01 { return Err("버전 불일치".into()); }
// ... 모든 유효성 검사 수행 ...
Ok(ValidFru { format_version: data[0], board_area_offset: data[3], data })
}
}
이제 &ValidFru를 인자로 받는 모든 함수는 데이터가 올바르다는 것을 알고 있습니다. 더 이상의 널 체크나 인덱스 범위 체크는 불필요합니다.
다형성 데이터 검증: IPMI SEL 레코드
IPMI 시스템 이벤트 로그(SEL)는 16바이트의 고정 길이를 갖지만, 그 내용의 해석은 이전 바이트에 따라 달라지는 중첩된 구조를 가지고 있습니다.
단계 1: 외부 프레임 파싱
첫 번째 TryFrom은 레코드 타입(시스템 이벤트, OEM 타임스탬프 등)에 따라 적절한 열거형 변형으로 분기합니다.
pub enum ValidSelRecord {
SystemEvent(SystemEventRecord),
OemTimestamped(OemTimestampedRecord),
OemNonTimestamped(OemNonTimestampedRecord),
}
단계 2: 시스템 이벤트 파싱 (센서 타입별 분기)
이후 내부 파싱 로직은 센서 타입(온도, 전압, 메모리 등)에 따라 데이터를 타이핑된 구조체로 변환합니다.
pub enum TypedEvent {
Threshold(ThresholdEvent),
SensorSpecific(SensorSpecificEvent), // 메모리 에러, 팬 고장 등 센서별 특화 데이터
}
단계 3: 타이핑된 데이터 소비
파싱이 완료되면 하위 로직에서는 중첩된 열거형을 패턴 매칭하여 처리합니다. 컴파일러는 모든 케이스(OEM 레코드 포함)가 누락 없이 처리되도록 강제합니다.
핵심 요약
- 검증은 경계에서 한 번만 — 데이터를 신뢰할 수 없는 외부(네트워크, 파일, 하드웨어)에서 앱 내부로 들여오기 직전에 수행합니다.
- 타입이 증거다 — 어떤 함수가
ValidFru타입을 인자로 받는다면, 컴파일러가 그 함수의 호출 전에 검증이 이미 완료되었음을 보증하는 것입니다. - 불변성(Invariants) 유지 — 한 번 파싱된 데이터는 수정 불가능하게(Immutable) 처리하여 유효성 증명이 훼손되지 않도록 합니다.
- 다형성 처리 — 복잡한 유니온(Union) 구조는 중첩된 열거형으로 파싱하여, 런타임의 불안정한 포인터 캐스팅을 컴파일 타임의 타입 안전한 패턴 매칭으로 바꿉니다.
8. 의무 믹스인 — 컴파일 타임 하드웨어 계약 🟡
학습 목표: 재료 트레이트(버스 역량)와 믹스인(Mixin) 트레이트, 담요 구현(Blanket impls)을 결합하여 진단 코드의 중복을 제거하고, 모든 하드웨어 의존성이 컴파일 타임에 충족됨을 보장하는 방법을 배웁니다.
문제 요망: 진단 코드의 중복
서버 플랫폼의 여러 하위 시스템(팬, 온도 센서, 전원 등)은 서로 다른 하드웨어 버스(SPI, I2C, GPIO)를 사용하지만 진단 로직은 매우 유사합니다. 추상화가 없으면 로직의 대부분을 복사-붙여넣기하게 됩니다.
// C — 하 하위 시스템 간에 중복된 로직
int run_fan_diag(spi_bus_t *spi, i2c_bus_t *i2c) {
// ... SPI 센서 읽기 ...
// ... I2C 레지스터 확인 ...
// ... 임계값 비교 ...
}
int run_cpu_diag(i2c_bus_t *i2c, gpio_t *gpio) {
// ... I2C 레지스터 확인 (팬 진단과 동일) ...
// ... GPIO 경고 확인 ...
// ... 임계값 비교 (팬 진단과 동일) ...
}
재료 트레이트 (하드웨어 역량)
각 버스나 주변 장치를 트레이트로 정의합니다. 진단 컨트롤러는 자신이 어떤 버스를 가지고 있는지 선언합니다.
pub trait HasSpi {
type Spi: SpiBus;
fn spi(&self) -> &Self::Spi;
}
pub trait HasI2c {
type I2c: I2cBus;
fn i2c(&self) -> &Self::I2c;
}
// SPI, I2C 버스의 실제 동작 정의
pub trait SpiBus { fn transfer(&self, data: &[u8]) -> Vec<u8>; }
pub trait I2cBus { fn read_reg(&self, addr: u8, reg: u8) -> u8; }
믹스인 트레이트 (진단 동작 제공)
믹스인은 필요한 역량(재료)을 가진 모든 타입에 자동으로 기능을 제공합니다.
/// 팬 진단 믹스인 — SPI와 I2C를 가진 모든 타입에 대해 자동 구현됨
pub trait FanDiagMixin: HasSpi + HasI2c {
fn run_fan_diagnostic(&self) -> bool {
let speed = self.spi().transfer(&[0x80]); // SPI로 속도 읽기
self.i2c().read_reg(0x2E, 0x01); // I2C로 설정 읽기
true
}
}
// 담요 구현(Blanket Implementation) — 조건만 맞으면 공짜로 기능을 얻음
impl<T: HasSpi + HasI2c> FanDiagMixin for T {}
구체적인 컨트롤러: 필요한 기능만 골라 담기
실제 진단 컨트롤러는 자신이 가진 버스 역량만 선언하면, 해당하는 모든 믹스인을 상속받게 됩니다.
/// 메인보드 컨트롤러는 모든 버스를 가지고 있으므로 모든 믹스인을 자동으로 얻음
pub struct BaseBoardController {
spi: LinuxSpi,
i2c: LinuxI2c,
}
impl HasSpi for BaseBoardController {
type Spi = LinuxSpi;
fn spi(&self) -> &LinuxSpi { &self.spi }
}
impl HasI2c for BaseBoardController {
type I2c = LinuxI2c;
fn i2c(&self) -> &LinuxI2c { &self.i2c }
}
// 이제 BaseBoardController는 FanDiagMixin을 자동으로 구현하게 됨
설계에 의한 올바름 (Correct-by-Construction)
이 패턴이 왜 안전한가요?
- 의존성 강제 — SPI 버스 없이
run_fan_diagnostic()을 호출하는 것은 불가능합니다. - 실수 방지 — 컨트롤러에서
HasSpi구현을 제거하면, 이를 사용하는 모든 믹스인 메서드가 컴파일 타임에 사라집니다. - 쉬운 모의 테스트(Mocking) — 실제 버스 대신
MockSpi를 사용하는 컨트롤러를 만들면, 진단 로직은 그대로 유지하면서 테스트할 수 있습니다. - 유연한 확장 — 새로운 하드웨어 플랫폼이 추가되어도 역량만 나열하면 기존 진단 로직을 즉시 사용할 수 있습니다.
핵심 요약
- 재료 트레이트로 하드웨어 역량 선언 —
HasSpi,HasI2c등을 통해 컨트롤러가 무엇을 할 수 있는지 정의합니다. - 믹스인과 담요 구현으로 공통 로직 공유 — 코드 복사 없이 필요한 역량이 있는 곳에만 기능을 주입합니다.
- 플랫폼 독립성 — 진단 로직은 실제 하드웨어 구현이 아닌 역량 트레이트에만 의존합니다.
- 컴파일 타임 계약 — 모든 하드웨어 의존성이 컴파일 시점에 체크되므로, 배포된 코드에서 버스 누락으로 인한 런타임 에러가 발생하지 않습니다.
9. 리소스 추적을 위한 팬텀 타입 🟡
학습 목표:
PhantomData마커가 어떻게 레지스터 너비, DMA 방향, 파일 서술자 상태 등을 타입 수준에서 인코딩하는지 배웁니다. 이를 통해 리소스 불일치 버그 전반을 런타임 비용 없이 방지하는 법을 익힙니다.
문제 요망: 리소스의 혼동
하드웨어 리소스는 코드상에서 비슷해 보이지만 서로 교체해서 사용할 수 없습니다.
- 32비트 레지스터와 16비트 레지스터는 둘 다 "레지스터"입니다.
- 읽기용 DMA 버퍼와 쓰기용 DMA 버퍼는 둘 다
*mut u8처럼 보입니다. - 열린 파일 서술자와 닫힌 파일 서술자는 둘 다
i32입니다.
C 언어에서는 이러한 구분이 모호합니다.
// C — 모든 레지스터가 같아 보임
uint32_t read_reg32(volatile void *base, uint32_t offset);
uint16_t read_reg16(volatile void *base, uint32_t offset);
// 버그: 16비트 레지스터를 32비트 함수로 읽음
uint32_t status = read_reg32(pcie_bar, LINK_STATUS_REG); // 사실은 16비트임!
팬텀 타입 매개변수 (Phantom Type Parameters)
팬텀 타입은 구조체 정의에는 나타나지만 어떤 필드에서도 사용되지 않는 타입 매개변수입니다. 이는 오직 타입 수준의 정보를 담기 위해 존재합니다.
use std::marker::PhantomData;
// 레지스터 너비 마커 — 제로 크기 타입
pub struct Width16;
pub struct Width32;
/// 너비(W)에 의해 매개변수화된 레지스터 핸들.
/// PhantomData<W>는 런타임에 0바이트를 차지하며 오직 컴파일 타임 마커로만 작동함.
pub struct Register<W> {
base: usize,
offset: usize,
_width: PhantomData<W>,
}
impl Register<Width16> {
pub fn read(&self) -> u16 { /* 2바이트 읽기 */ 0 }
}
impl Register<Width32> {
pub fn read(&self) -> u32 { /* 4바이트 읽기 */ 0 }
}
impl PcieConfig {
pub fn vendor_id(&self) -> Register<Width16> {
Register { base: self.base, offset: 0x00, _width: PhantomData }
}
pub fn bar0(&self) -> Register<Width32> {
Register { base: self.base, offset: 0x10, _width: PhantomData }
}
}
이제 컴파일러가 너비 불일치를 잡아냅니다.
let vid: u16 = cfg.vendor_id().read(); // ✅ u16 반환
let bar: u32 = cfg.bar0().read(); // ✅ u32 반환
// let bad: u32 = cfg.vendor_id().read(); // ❌ 에러: u16을 예상함
DMA 버퍼 접근 제어
DMA 버퍼는 방향성을 갖습니다. 호스트에서 디바이스로(쓰기) 또는 디바이스에서 호스트로(읽기)의 방향이 정해져 있습니다. 잘못된 방향으로 접근하면 데이터 오염이나 버스 에러가 발생합니다.
pub struct ToDevice; // 호스트가 쓰고 디바이스가 읽음
pub struct FromDevice; // 디바이스가 쓰고 호스트가 읽음
pub struct DmaBuffer<Dir> {
ptr: *mut u8,
len: usize,
_dir: PhantomData<Dir>,
}
impl DmaBuffer<ToDevice> {
pub fn write_data(&mut self, data: &[u8]) { /* 데이터 쓰기 가능 */ }
}
impl DmaBuffer<FromDevice> {
pub fn read_data(&self) -> &[u8] { /* 데이터 읽기 가능 */ }
}
이제 FromDevice 버퍼에 데이터를 쓰려고 하거나 ToDevice 버퍼에서 데이터를 읽으려 하면 메서드 자체가 존재하지 않아 컴파일 에러가 발생합니다.
핵심 요약
- PhantomData는 비용 없이 타입 정보를 전달함 — 마커는 오직 컴파일러를 위해서만 존재하며 런타임 오버헤드는 없습니다.
- 레지스터 너비 불일치 차단 —
Register<Width16>은u32가 아닌u16만을 반환하도록 강제됩니다. - 구조적인 DMA 방향 강제 —
DmaBuffer<FromDevice>에는write_data()메서드가 아예 정의되지 않습니다. - 차원 타입(06장)과의 결합 — 레지스터 읽기 결과에
Celsius와 같은 물리 단위를 즉시 부여하여 안전성을 극대화할 수 있습니다. - 컴파일 타임 전용 — 팬텀 타입은 컴파일 시점에 결정되는 속성을 인코딩하는 데 적합합니다. 런타임에 변하는 속성은 열거형(enum)을 사용하세요.
10. Const Fn — 컴파일 타임 올바름 증명 🟠
학습 목표:
const fn과assert!가 어떻게 컴파일러를 강력한 증명 엔진으로 변모시키는지 배웁니다. SRAM 메모리 맵, 레지스터 레이아웃, 프로토콜 프레임, 비트 필드 마스크, 클록 트리 등을 런타임 비용 없이 컴파일 타임에 검증하는 법을 익힙니다.
문제 요망: 거짓말하는 메모리 맵
임베디드 및 시스템 프로그래밍에서 메모리 맵은 부트로더, 펌웨어, 데이터 섹션, 스택의 위치를 정의하는 가장 기초적인 설계도입니다. C 언어에서는 보통 #define 상수로 이를 관리하는데, 영역 간의 관계(중첩 여부 등)를 구조적으로 파악하기 어렵습니다.
/* C — 영역이 겹치거나 SRAM 크기를 초과해도 잡아낼 수 없음 */
#define SRAM_BASE 0x20000000
#define SRAM_SIZE (256 * 1024)
#define FW_SIZE (200 * 1024)
#define DATA_SIZE (80 * 1024) // 200 + 80 = 280KB? SRAM(256KB)을 초과함!
이런 실수는 배포 후에야 신비로운 크래시로 나타납니다.
Const Fn: 컴파일러를 증명 엔진으로
Rust의 const fn은 컴파일 타임에 실행될 수 있습니다. const fn 내부의 assert!가 실패하면 컴파일 에러가 발생합니다. 즉, 컴파일러 자체가 여러분의 설계 의도를 검증하는 감사관이 됩니다.
검증된 SRAM 메모리 맵 예시
pub struct Region { pub base: u32, pub size: u32 }
impl Region {
pub const fn new(base: u32, size: u32) -> Self {
assert!(size > 0, "지역 크기는 0보다 커야 합니다");
Self { base, size }
}
pub const fn end(&self) -> u32 { self.base + self.size }
pub const fn overlaps(&self, other: &Region) -> bool {
self.base < other.end() && other.base < self.end()
}
}
pub struct SramMap {
pub total: Region,
pub firmware: Region,
pub data: Region,
}
impl SramMap {
pub const fn verified(total: Region, fw: Region, data: Region) -> Self {
// 중첩 여부 및 전체 크기 초과 여부를 컴파일 타임에 증명
assert!(total.contains(&fw), "펌웨어가 SRAM을 초과함");
assert!(total.contains(&data), "데이터 섹션이 SRAM을 초과함");
assert!(!fw.overlaps(&data), "펌웨어와 데이터 영역이 겹침");
Self { total, firmware: fw, data }
}
}
이제 상수를 정의하기만 하면 컴파일러가 모든 제약 조건을 체크합니다.
// ✅ 모든 조건 만족 — 컴파일 성공
const SRAM: SramMap = SramMap::verified(
Region::new(0x2000_0000, 256 * 1024),
Region::new(0x2000_0000, 128 * 1024),
Region::new(0x2002_0000, 64 * 1024),
);
// ❌ 조건 위반시 컴파일 에러 발생!
레지스터 및 프로토콜 프레임 레이아웃
동일한 기법을 하드웨어 레지스터 맵이나 네트워크 프로토콜 프레임에도 적용할 수 있습니다. 레지스터 오프셋이 정렬(Alignment)되어 있는지, 필드들이 서로 겹치지는 않는지 등을 무조건적으로 보장할 수 있습니다.
// 비트 필드 마스크 중첩 방지 예시
const SPI_EN: BitField = BitField::new(0, 1); // 0번 비트
const SPI_MODE: BitField = BitField::new(1, 2); // 1-2번 비트
const SPI_CLKDIV: BitField = BitField::new(4, 4); // 4-7번 비트
// 컴파일 타임 증명: 어떤 필드도 비트를 공유하지 않음
const _: () = {
assert!(fields_disjoint(&SPI_EN, &SPI_MODE));
assert!(fields_disjoint(&SPI_EN, &SPI_CLKDIV));
// ... 모든 조합 확인 ...
};
핵심 요약
- 상수화된 올바름 —
const fn과assert!를 사용하면 런타임에 발생할 '신비로운 버그'를 컴파일 타임의 '명확한 에러 메시지'로 바꿀 수 있습니다. - 제로 비용 보장 — 모든 검증은 컴파일 시 완료되며, 실제 바이너리에는 검사 로직이 포함되지 않습니다. 결과물은 검사 없이 작성된 C 코드만큼이나 가볍습니다.
- 증거 기반 설계 —
VerifiedAddr와 같은 타입을 통해, 어떤 주소값이 특정 영역 내에 있음이 정적으로 증명된 상태로 안전하게 데이터를 다룰 수 있습니다. - 점진적 정밀도 향상 — 메모리 맵 수준의 거친 검증부터 레지스터 비트 단위의 정밀한 검증까지 동일한 패턴으로 확장 가능합니다.
11. Send & Sync — 컴파일 타임 동시성 증명 🟠
학습 목표: Rust의
Send와Sync자동 트레이트(Auto-traits)가 어떻게 컴파일러를 동시성 감사관으로 변모시키는지 배웁니다. 어떤 타입이 스레드 경계를 넘을 수 있는지, 어떤 타입을 안전하게 공유할 수 있는지를 런타임 비용 없이 컴파일 타임에 증명하는 법을 익힙니다.
문제 요망: 안전망 없는 동시 접근
시스템 프로그래밍에서 주변 장치, 공유 버퍼, 전역 상태는 메인 루프, 인터럽트 핸들러, DMA 콜백 등 다양한 컨텍스트에서 접근됩니다. C 언어에서는 volatile 키워드가 최적화를 막아줄 뿐, 데이터 경합(Data race)에 대해서는 아무런 보호도 제공하지 못합니다.
/* C — 인터럽트와 메인 루프 간의 경합 발생 가능 */
volatile uint32_t sensor_buf[64];
volatile uint32_t buf_index = 0;
void SENSOR_IRQHandler(void) {
sensor_buf[buf_index++] = read_sensor(); // 경합: buf_index 읽기 + 쓰기
}
이런 동시성 버그는 재현하기 매우 어렵고, 주로 운영 환경에서 부하가 걸릴 때 간헐적으로 발생합니다.
Send와 Sync가 증명하는 것
Rust는 컴파일러가 자동으로 유도하는 두 가지 마커 트레이트를 정의합니다.
| 트레이트 | 증명 내용 | 비공식적 의미 |
|---|---|---|
Send | T 타입의 값을 다른 스레드로 안전하게 이동할 수 있음 | "스레드 경계를 넘을 수 있음" |
Sync | 공유 참조 &T를 여러 스레드에서 안전하게 사용할 수 있음 | "여러 스레드에서 동시에 읽을 수 있음" |
이들은 **자동 트레이트(Auto-traits)**입니다. 구조체의 모든 필드가 Send이면 구조체도 Send가 됩니다. 하나라도 !Send인 필드가 있다면 전체 구조체도 스레드 간 이동이 불가능해집니다.
주변 장치 핸들의 스레드 격리
하드웨어 레지스터는 특정 메모리 주소에 고정되어 있으며, 원칙적으로 단일 실행 컨텍스트에서만 접근해야 합니다. 원시 포인터(*const T)는 기본적으로 !Send 및 !Sync이므로, 이를 포함하는 핸들은 자동으로 특정 스레드에 갇히게 됩니다.
/// UART 핸들은 원시 포인터를 포함하므로 자동으로 !Send 및 !Sync입니다.
pub struct Uart { regs: *const u32 }
fn main() {
let uart = Uart::new(0x4000_1000);
// ❌ 컴파일 에러: Uart는 !Send이므로 다른 스레드로 보낼 수 없음
// std::thread::spawn(move || { uart.write_byte(b'B'); });
}
Mutex를 통한 Sync 복구
Cell<T>이나 RefCell<T>은 동기화 장치가 없으므로 !Sync입니다. 하지만 여러 스레드에서 데이터를 공유해야 할 때 Mutex<T>로 감싸면 컴파일러는 이를 안전하다고 판단합니다.
T가Send이면,Mutex<T>는Send + Sync입니다.
이는 단순히 동기화하는 것을 넘어, 동기화되었음을 증명하는 것입니다. Mutex::lock()을 통해서만 데이터에 접근할 수 있도록 강제하기 때문에 "잠금(lock)을 잊어버리는 실수"는 구조적으로 불가능해집니다.
핵심 요약
- 컴파일 타임 동시성 감사 —
Send와Sync는 타입의 구조를 분석하여 동시성 안전성을 정적으로 증명합니다. 런타임 비용은 전혀 없습니다. - 원시 포인터와 자동 제외 — 하드웨어 핸들은 기본적으로 스레드에 격리되며, 필요한 경우에만 명시적으로 권한을 부여하게 됩니다.
- 구조적인 잠금 강제 —
Mutex패턴은 잠금 없이 데이터에 접근하는 행위를 컴파일 타임에 원천 차단합니다. - 함수 경계의 정리 —
F: Send + 'static과 같은 트레이트 바운드는 함수 호출자가 스레드 안전성을 증명해야 한다는 '정리(Theorem)'가 됩니다. - 통합된 안전성 — 타입 상태, 팬텀 타입,
const fn증명과 결합하여 프로토콜 순서, 권한, 값의 범위, 그리고 동시성 안전성까지 완벽한 안전망을 구축할 수 있습니다.
10. 모든 조각의 결합 — 완성된 진단 플랫폼 🟡
학습 목표: 지금까지 배운 7가지 핵심 패턴(02~09장)이 어떻게 하나의 진단 워크플로우로 조화롭게 결합되는지 배웁니다. 인증, 세션 관리, 타이핑된 명령, 감사 토큰, 차원 결과, 검증된 데이터, 그리고 팬텀 타입 레지스터가 런타임 오버헤드 없이 어떻게 함께 작동하는지 확인합니다.
관련 장: 02~09장의 모든 핵심 패턴, 14장 (가정에 대한 테스트)
목표: 7가지 패턴의 통합
이 장에서는 앞서 배운 패턴들을 결합하여 실제와 유사한 서버 상태 점검 워크플로우를 구축합니다.
- 인증 (역량 토큰 — 04장)
- IPMI 세션 열기 (타입 상태 — 05장)
- 타이핑된 명령 전송 (타입 지성 명령 — 02장)
- 감사 로그 작성을 위한 단회용 토큰 (단회용 타입 — 03장)
- 차원 분석 결과 반환 (차원 분석 — 06장)
- FRU 데이터 검증 (유효성 검증 경계 — 07장)
- 타이핑된 레지스터 읽기 (팬텀 타입 — 09장)
통합 워크플로우 예시 (의사 코드)
fn full_diagnostic() -> Result<(), String> {
// 1. 인증 → 역량 토큰 획득
let admin = authenticate("admin", "secret")?;
// 2. 세션 연결 및 활성화 (타입 상태: Idle → Active)
// 활성화를 위해 관리자 토큰(AdminToken)이 필수로 요구됨
let mut session = Session::connect("192.168.1.100").activate(&admin)?;
// 3. 타이핑된 명령 전송 (반환 타입이 명령과 일치함)
let temp: Celsius = session.execute(&ReadTemp { sensor_id: 0 })?;
let fan: Rpm = session.execute(&ReadFanSpeed { fan_id: 1 })?;
// 4. 팬텀 타입이 적용된 PCIe 레지스터 읽기
let pcie = PcieDev::new();
let vid: u16 = pcie.vendor_id.read(); // 컴파일러가 u16임을 보장
// 5. 경계에서 FRU 데이터 검증
let fru = ValidFru::parse(&raw_bytes)?;
// 6. 단회용 감사 토큰 발행
let audit = AuditToken::issue(1001);
// 7. 보고서 생성 및 감사 토큰 소비 (두 번 로깅 불가)
audit.log(&format!("Server: {}, Temp: {:?}", fru.product_name, temp));
// 8. 세션 종료 (타입 상태: Active → 드롭)
session.close();
Ok(())
}
컴파일러가 증명하는 것들
| 버그 클래스 | 방지 방법 | 관련 패턴 |
|---|---|---|
| 비인증 접근 | activate() 호출 시 AdminToken 요구 | 역량 토큰 |
| 잘못된 상태에서의 명령 | execute()는 오직 Active 세션에만 존재 | 타입 상태 |
| 잘못된 응답 타입 처리 | 명령 트레이트에서 응답 타입을 고정함 | 타입 지정 명령 |
| 단위 혼동 (°C vs RPM) | Celsius와 Rpm은 서로 다른 타입임 | 차원 분석 |
| 레지스터 너비 불일치 | Reg<Width16>은 u16만 반환함 | 팬텀 타입 |
| 검증되지 않은 데이터 처리 | ValidFru::parse()를 거쳐야만 데이터 접근 가능 | 유효성 검증 경계 |
| 중복 감사 로그 작성 | 로그 작성 시 AuditToken이 소비됨 | 단회용 타입 |
핵심 요약
- 7가지 패턴의 빈틈없는 조화 — 각 패턴은 독립적으로도 유용하지만, 결합되었을 때 강력한 안전망을 형성합니다.
- 8가지 버그 클래스의 원천 차단 — 위의 표에서 보듯, 수많은 런타임 실수가 컴파일 에러로 전환됩니다.
- 제로 런타임 오버헤드 — 이 모든 보장은 컴파일 시점에 완료됩니다. 생성된 기계어는 아무런 검사도 하지 않는 C 코드만큼이나 빠릅니다. 하지만 C는 버그가 있을 수 있고, 이 코드는 그럴 수 없습니다.
- 점진적 도입 가능 — 이 모든 패턴을 한꺼번에 적용할 필요는 없습니다. 가장 필요한 부분부터 하나씩 도입해 나가세요.
- 확장 가능한 설계 템플릿 — 이 장의 구조는 여러분만의 타입 안전한 진단 워크플로우를 설계할 때 훌륭한 출발점이 될 것입니다.
13. 실습 가이드 — 타입 안전한 Redfish 클라이언트 🟡
학습 목표: 타입 상태 세션, 역량 토큰, 팬텀 타입 리소스 탐색, 차원 분석, 유효성 검증 경계, 빌더 타입 상태, 그리고 단회용 타입을 결합하여 완전하고 오버헤드 없는 Redfish 클라이언트를 구축합니다. 모든 프로토콜 위반은 런타임이 아닌 컴파일 타임 에러가 됩니다.
관련 장: 02장 (타입 지정 명령), 03장 (단회용 타입), 04장 (역량 토큰), 05장 (타입 상태), 06장 (차원 분석), 07장 (유효성 검증 경계), 09장 (팬텀 타입), 10장 (Const Fn), 11장 (팁 4 — 빌더 타입 상태)
Redfish와 타입 안전성
IPMI가 바이트 수준의 프로토콜이라면, Redfish는 REST API 기반의 복잡한 리소스 트리를 다룹니다. Redfish에서는 다음과 같은 위험 요소들이 존재하지만, 타입 기반 설계로 이를 원천 차단할 수 있습니다.
| 위험 요소 | 예시 | 결과(기존 환경) | 해결 방법(타입 기반) |
|---|---|---|---|
| 잘못된 URI | /Chassis/1/Processors (부모가 틀림) | 404 또는 데이터 유실 | 팬텀 기반 리소스 탐색 (09장) |
| 권한 누락 | 일반 사용자가 Manager.Reset 호출 | 403 에러 또는 보안 사고 | 역량 토큰 (04장) |
| 불완전한 PATCH | 필수 설정 항목 누락 | 설정 오염 또는 무시됨 | 빌더 타입 상태 (11장) |
| 단위 혼동 | 온도를 팬 속도와 비교 | 잘못된 임계치 판단 | 차원 분석 (06장) |
실습 1 — 세션 생명주기 (타입 상태)
Redfish 세션은 Disconnected → Connected → Authenticated → Closed와 같은 엄격한 상태 변화를 따릅니다.
pub struct Disconnected;
pub struct Connected;
pub struct Authenticated;
pub struct RedfishSession<S> { /* ... */ }
impl RedfishSession<Connected> {
pub fn login(self, user: &str) -> RedfishSession<Authenticated> { /* ... */ }
}
impl RedfishSession<Authenticated> {
pub fn get(&self, path: &str) -> Json { /* API 호출 가능 */ }
}
// Connected 상태에서는 get() 메서드 자체가 없어 호출 불가능함
실습 2 — 권한 토큰 (역량 토큰)
Redfish의 4가지 권한 수준을 제로 크기 타입(ZST) 토큰으로 인코딩합니다.
pub struct ConfigureManagerToken; // 관리자 권한 증명
fn reset_to_defaults(
session: &RedfishSession<Authenticated>,
_proof: &ConfigureManagerToken, // 이 토큰이 있어야만 호출 가능
) { /* ... */ }
// 관리자로 로그인하지 않으면 해당 토큰을 얻을 수 없어 컴파일 타임에 차단됨
실습 3 — 계층 구조 탐색 (팬텀 타입)
잘못된 URI를 생성하는 것은 불가능해집니다.
let root = RedfishPath::root();
let thermal = root.chassis().instance("1").thermal(); // ✅ 정상적인 경로
// let bad = root.thermal(); // ❌ 컴파일 에러: root 아래에 thermal은 존재하지 않음
실습 4 — 차원 텔레메트리 (차원 분석)
서버에서 응답을 파싱할 때 즉시 단위를 부여합니다.
let thermal = session.get_resource(&thermal_path)?;
// thermal.reading은 Celsius 타입이므로 Rpm 타입과 비교하려 하면 컴파일 에러 발생
핵심 요약
- 복합적인 안전망 — 이 장에서는 지금까지 배운 모든 패턴을 결합하여, 리소스 경로, 권한, 상태 변화, 데이터 단위까지 모든 층위에서 안전한 클라이언트를 만드는 법을 보여줍니다.
- 런타임 에러를 컴파일 에러로 — 404(잘못된 경로), 403(권한 없음), 400(잘못된 요청 형식) 등의 REST 오류 상당수를 코드를 실행하기도 전에 잡아낼 수 있습니다.
- 오버헤드 없는 추상화 — 모든 타입 체크와 토큰 전달은 컴파일 시점에 사라지며, 실제 실행 시에는 효율적인 네트워크 호출만 남습니다.
- 규모에 따른 확장성 — 이 패턴들은 소규모 스크립트부터 수천 대의 서버를 관리하는 대규모 인프라 도구까지 동일하게 적용될 수 있습니다.
14. 실습 가이드 — 타입 안전한 Redfish 서버 🟡
학습 목표: 응답 빌더 타입 상태, 소스 가용성 토큰, 차원 직렬화, 헬스 롤업(Health Rollup), 스키마 버전 관리 등을 결합하여 스키마를 위반하는 응답을 결합할 수 없는 Redfish 서버를 구축합니다. 이는 13장의 클라이언트 실습과 대칭을 이룹니다.
관련 장: 02장 (액션 디스패치), 04장 (소스 가용성 토큰), 06장 (차원 직렬화), 07장 (구성 후 직렬화), 09장 (스키마 버전 관리), 11장 (빌더 타입 상태)
대칭적인 문제: 나쁜 데이터의 배출 방지
13장(클라이언트)에서의 문제는 "나쁜 데이터를 신뢰하는 것"이었다면, 14장(서버)에서의 문제는 **"나쁜 데이터를 배출하는 것"**입니다. 서버가 보내는 단 한 번의 잘못된 응답이 수천 대의 클라이언트에 영향을 미칠 수 있습니다.
C 언어로 작성된 서버는 JSON 객체를 수동으로 조립하며 필수 필드를 누락하거나 단위를 잘못 기입하는 실수를 저지르기 쉽습니다.
실습 1 — 응답 빌더 타입 상태: "직렬화하지 말고 구성하라"
07장의 "검증하지 말고 파싱하라"는 원칙을 서버 측에서 뒤집은 것입니다. 필수 필드가 모두 채워지기 전에는 .build() 메서드가 나타나지 않는 빌더를 사용합니다.
pub struct ComputerSystemBuilder<Name, Uuid, PowerState, Status> { /* ... */ }
// 모든 필수 필드가 채워진 상태에서만 build() 가능
impl ComputerSystemBuilder<HasField, HasField, HasField, HasField> {
pub fn build(self) -> Json { /* 스키마를 준수하는 JSON 생성 */ }
}
이제 개발자가 "Name" 필드 설정을 잊어버리면 컴파일 타임에 에러가 발생하여, 스키마를 위반하는 응답이 생성되는 것을 원천 차단합니다.
실습 2 — 소스 가용성 토큰 (역량 토큰의 변형)
서버 측에서 역량 토큰은 "데이터 소스가 성공적으로 초기화되었음"을 증명합니다. SMBIOS 테이블이 깨졌거나 센서 하위 시스템이 응답하지 않는 경우, 해당 토큰이 없으므로 관련 데이터를 사용하는 함수를 호출할 수 없습니다.
pub struct SmbiosReady; // SMBIOS 초기화 성공 증명
fn populate_from_smbios(
builder: Builder,
_proof: &SmbiosReady, // 이 토큰이 있어야만 SMBIOS 기반 필드 채우기 가능
tables: &Tables,
) { /* ... */ }
실습 3 — 직렬화 경계에서의 차원 타입 (차원 분석)
클라이언트가 °C를 RPM으로 읽는 것을 막는 것처럼, 서버는 RPM 값을 Celsius 필드에 적는 실수를 컴파일 타임에 차단합니다.
pub struct TemperatureMember {
pub reading_celsius: Celsius, // 반드시 Celsius 타입이어야 함
}
// 실수로 RPM 값을 넣으려 하면 컴파일러가 잡아냄
핵심 요약
- 스키마 준수 강제 — 타입 상태 빌더를 통해 Redfish 스키마가 요구하는 필수 필드 누락을 컴파일 시점에 방지합니다.
- 가용성 기반 설계 — 하드웨어 데이터 소스의 성공적인 초기화 여부를 토큰으로 관리하여, 초기화되지 않은 소스에 접근하는 런타임 오류를 차단합니다.
- 단위 안전한 직렬화 — 서버에서 클라이언트로 전달되는 모든 수치 데이터에 단위를 부여하여, 데이터 오염 가능성을 최소화합니다.
- 대규모 시스템의 신뢰성 — 서버 측의 타입 안전성은 인프라 전체의 안정성으로 직결됩니다. "일단 실행해보고 에러를 찾는" 방식에서 "컴파일되면 스키마를 준수함이 보장되는" 방식으로 패러다임을 전환할 수 있습니다.
11. 실무에서 건진 14가지 팁 🟡
학습 목표: 파수꾼(Sentinel) 값 제거, 봉인된 트레이트, 세션 타입,
Pin, RAII,#[must_use]등 실무에서 유용하게 쓰이는 14가지 소규모 기법을 배웁니다. 각 기법은 적은 노력으로 특정 범주의 버그를 완벽히 차단합니다.
팁 1 — 경계에서 파수꾼(Sentinel) 값을 Option으로 변환
하드웨어 프로토콜은 0xFF(센서 없음), 0xFFFF(장치 없음)와 같은 마법 같은 숫자로 가득합니다. 이를 코드 전체에 정수로 들고 다니면 모든 곳에서 체크를 잊지 말아야 합니다. 하나라도 놓치면 255°C라는 엉뚱한 값을 읽게 됩니다.
규칙: 파싱 경계에서 즉시 Option으로 변환하고, 직렬화 직전에만 다시 파수꾼 값으로 돌려놓으세요.
pub struct ThermalEvent {
pub temperature: Option<u8>, // 센서가 0xFF를 보고하면 None
}
// 이제 소비자는 반드시 None 케이스를 처리해야만 하며, 컴파일러가 이를 강제합니다.
팁 2 — 봉인된 트레이트 (Sealed Traits)
누구나 구현할 수 있는 공용 트레이트는 위험할 수 있습니다. 봉인된 트레이트는 외부 크레이트에서 해당 트레이트를 구현하는 것을 막아, 시스템의 무결성을 보호합니다.
mod private { pub trait Sealed {} }
pub trait IpmiCmd: private::Sealed { ... }
// 외부에서는 private::Sealed를 구현할 수 없으므로 IpmiCmd도 구현할 수 없습니다.
팁 3 — 진화하는 열거형을 위한 #[non_exhaustive]
새로운 하드웨어 SKU가 추가될 때마다 기존의 match 문이 깨지는 것을 방지합니다. 외부 소비자가 반드시 와일드카드(_)를 포함하도록 강제하여 하위 호환성을 유지합니다.
팁 4 — 타입 상태 빌더 (Typestate Builder)
필수 필드가 누락된 채로 build()가 호출되는 것을 컴파일 타임에 방지합니다. 모든 필수 설정이 완료되어야만 finish() 메서드가 나타나도록 설계합니다.
팁 5 — 문자열 검증 경계로서의 FromStr
설정 파일, CLI 인자 등의 문자열 데이터를 파싱할 때 FromStr을 사용하면 잘못된 입력(오타 등)을 시스템 진입점에서 즉시 차단할 수 있습니다.
팁 6 — 컴파일 타임 크기 검증을 위한 상수 제네릭 (Const Generics)
하드웨어 버퍼나 레지스터 뱅크의 크기가 고정되어 있다면 상수 제네릭을 사용하여 크기 불일치를 컴파일 타임에 잡아냅니다.
팁 7 — unsafe에 대한 안전한 래퍼
하드웨어 제어를 위해 unsafe가 필요하다면, 이를 안전한 공용 메서드로 감싸서 오남용을 방지하고 코드의 가독성을 높입니다.
팁 8 — 비동기 타입 상태 머신
async 환경에서도 타입 상태 패턴은 유효합니다. 다만 .await 지점을 넘나들 때 소유권 이동에 주의하여 상태 전이를 구현합니다.
팁 9 — 상수 어서션(Const Assertion)을 통한 정제 타입
컴파일 타임에 수치적 제약 조건(예: 팬 ID는 0~7 사이여야 함)을 체크하여 잘못된 고정값이 코드에 포함되는 것을 막습니다.
팁 10 — 정적 리소스 주소 지정 (ZST 필드)
특정 하드웨어 인스턴스(예: 0번 I2C 버스)를 타입 시스템의 일부로 만들어, 런타임 매개변수 없이도 특정 리소스에 안전하게 접근하게 합니다.
팁 11 — 선언적 레지스터 맵
매크로를 사용하여 레지스터의 오프셋, 비트 필드, 권한(R/W)을 선언적으로 정의함으로써 수동 계산 실수를 방지합니다.
팁 12 — Pin과 Drop을 이용한 하드웨어 해제 보장
하드웨어 리소스가 사용 후 반드시 안전한 상태로 돌아가야 한다면 Drop 트레이트와 Pin을 결합하여 리소스 유출과 조기 해제를 방지합니다.
팁 13 — #[must_use]를 통한 무시 방지
함수의 반환값(예: 에러 결과나 권한 토큰)이 무시되면 안 되는 경우 이 속성을 사용하여 개발자의 실수를 경고합니다.
팁 14 — 제로 비용 로깅 및 추적
타입 레벨 마커를 사용하여 런타임 오버헤드 없이 디버그 정보를 남기거나 제거할 수 있는 구조를 만듭니다.
16. 연습 문제 🟡
학습 목표: 실무에서 접할 수 있는 하드웨어 시나리오 — NVMe 관리 명령, 펌웨어 업데이트 상태 머신, 센서 파イ프라인, PCIe 팬텀 타입, 다중 프로토콜 헬스 체크 등에 '올바른 구성(Correct-by-construction)' 패턴을 직접 적용해 봅니다.
관련 장: 02장 (연습 1), 05장 (연습 2), 06장 (연습 3), 09장 (연습 4), 10장 (연습 5)
연습 1: NVMe 관리 명령 (타입 지정 명령)
NVMe 관리 명령을 위한 타입 안전한 인터페이스를 설계하세요.
Identify명령은IdentifyResponse(모델명, 시리얼 등)를 반환해야 합니다.GetLogPage명령은SmartLog(온도, 여분 용량 등)를 반환해야 합니다.- 명령 타입이 응답 타입을 결정하도록 설계하세요 (런타임 디스패치 없음).
NamespaceId뉴타입을 만들어 일반u32와 혼동되지 않게 하세요.
연습 2: 펌웨어 업데이트 상태 머신 (타입 상태)
BMC 펌웨어 업데이트의 생명주기를 모델링하세요.
- 상태:
Idle → Uploading → Verifying → Applying → Rebooting → Complete Uploading과Verifying단계에서만abort()가 가능해야 합니다.Applying단계는 되돌릴 수 없으므로abort()메서드가 없어야 합니다.apply()는 반드시 성공적인 검증(Verifying) 후에 얻은VerifiedImage토큰이 있어야만 호출 가능해야 합니다.
연습 3: 센서 데이터 파이프라인 (차원 분석)
다음 단계를 포함하는 센서 파이프라인을 구축하세요.
- 뉴타입 정의:
RawAdc,Celsius,Fahrenheit,Volts,Watts Celsius와Fahrenheit간의 상호 변환(From) 구현Volts * Amperes = Watts와 같은 물리 법칙을 트레이트로 정의- 제네릭
Threshold<T>체커를 만들어 측정값이 임계치를 넘었는지 확인
연습 4: PCIe 케이퍼빌리티 탐색 (팬텀 타입 + 유효성 경계)
PCIe 설정 공간의 케이퍼빌리티 연결 리스트를 모델링하세요.
RawCapability와 검증된ValidCapability<Kind>를 분리하세요.Kind에는Msi,MsiX,PciExpress등 팬텀 타입을 사용하세요.- 각 케이퍼빌리티마다 고유한 레지스터 접근 메서드를 제공하세요.
연습 5: 다중 프로토콜 헬스 체크 (역량 믹스인)
다양한 하위 시스템의 상태를 점검하는 라이브러리를 만드세요.
HasIpmi,HasRedfish,HasNvmeCli와 같은 재료 트레이트를 정의하세요.- 믹스인(Mixin)을 사용하여 특정 재료가 있을 때만 활성화되는 점검 기능을 만드세요 (예:
HasIpmi + HasRedfish가 있으면BmcHealthMixin활성화).
핵심 요약
- 실제 프로토콜 기반 실습 — NVMe, PCIe 등 시스템 엔지니어가 매일 다루는 기술들을 대상으로 연습합니다.
- 패턴의 조화 — 각 연습 문제는 앞서 배운 핵심 패턴 중 하나 이상을 깊이 있게 다룹니다.
- 직접 해보세요 — 해설을 보기 전에 각 시나리오를 코드로 구현해 보는 것이 가장 효과적인 학습 방법입니다.
- 컴파일러와의 대화 — 의도적으로 틀린 코드를 작성해 보고, 컴파일러가 어떻게 그 실수를 잡아내는지 확인해 보세요.
17. 참조 카드 🟡
14가지 이상의 '올바른 구성(Correct-by-construction)' 패턴 요약 가이드. 패턴 선택 플로우차트, 카탈로그, 결합 규칙, 그리고 '타입을 통한 보장' 치트 시트를 제공합니다.
관련 장: 책의 모든 장 — 이 카드는 책 전체의 내용을 한눈에 정리한 요약본입니다.
패턴 카탈로그 (Pattern Catalogue)
| # | 패턴 | 핵심 개념 | 방지하는 버그 | 장 |
|---|---|---|---|---|
| 1 | 타입 지성 명령 | trait IpmiCmd { type Response; } | 잘못된 응답 타입 처리 | 02장 |
| 2 | 단회용 타입 | struct Nonce (Clone/Copy 불가) | 논스(Nonce)/키 재사용 | 03장 |
| 3 | 역량 토큰 | struct AdminToken { _private: () } | 권한 없는 접근 | 04장 |
| 4 | 타입 상태 | Session<Active> | 프로토콜 순서 위반 | 05장 |
| 5 | 차원 분석 | struct Celsius(f64) | 물리 단위 혼동 | 06장 |
| 6 | 유효성 경계 | struct ValidFru (via TryFrom) | 검증되지 않은 데이터 사용 | 07장 |
| 7 | 역량 믹스인 | trait FanDiagMixin: HasSpi | 버스 접근 권한 누락 | 08장 |
| 8 | 팬텀 타입 | Register<Width16> | 너비/방향 불일치 | 09장 |
| 9 | 파수꾼 → Option | Option<u8> (0xFF 대신) | 파수꾼 값 오인 버그 | 11장 |
| 10 | 봉인된 트레이트 | trait Cmd: private::Sealed | 부적절한 외부 구현 | 11장 |
| 11 | 타입 상태 빌더 | Builder<Set, Missing> | 불완전한 객체 생성 | 11장 |
| 12 | FromStr 검증 | impl FromStr for Level | 잘못된 문자열 입력 | 11장 |
| 13 | 상수 제네릭 | Bank<const N: usize> | 버퍼 크기 불일치 | 11장 |
| 14 | 안전한 래퍼 | MmioRegion::read_u32() | 무분별한 MMIO/FFI 사용 | 11장 |
피해야 할 안티 패턴 (Anti-Patterns)
| 안티 패턴 | 문제점 | 올바른 대안 |
|---|---|---|
fn read() -> f64 | 단위가 모호함 (°C? RPM?) | fn read() -> Celsius |
fn op(is_admin: bool) | 호출자가 거짓말할 수 있음 | fn op(_: &AdminToken) |
fn send(s: &Session) | 세션 상태를 보장 못 함 | fn send(s: &Session<Active>) |
fn process(d: &[u8]) | 검증되지 않은 원시 데이터 | fn process(d: &ValidFru) |
let id: u16 = 0xFFFF | 파수꾼 값을 내부에 들고 다님 | let id: Option<u16> = None |
Builder::new().finish() | 필수 필드 누락 가능 | 타입 상태 빌더 사용 |
impl Clone (비밀키 등) | 단회용 보장을 파괴함 | Clone을 유도하지 않음 |
타입을 통한 보장 (Types as Guarantees)
| 보장 내용 | Rust 표현식 | 예시 |
|---|---|---|
| "이 증거가 존재함" | 타입 그 자체 | AdminToken |
| "내가 증거를 가지고 있음" | 해당 타입의 값 | let tok = authenticate()?; |
| "A이면 B이다" | 함수 fn(A) -> B | fn activate(AdminToken) -> Session<Active> |
| "A와 B 모두 만족" | 튜플 (A, B) | fn op(a: &A, b: &B) |
| "A 또는 B 중 하나" | enum 또는 Result | Result<Session, Error> |
| "결코 일어날 수 없음" | ! (Never 타입) | 생성 자체가 불가능함 |
18. 타입 수준 보장 테스트하기 🟡
학습 목표: 유효하지 않은 코드가 컴파일되지 않음을 테스트하는 법(
trybuild), 유효성 경계에 대한 퍼징(proptest), RAII 불변성 검증, 그리고cargo-show-asm을 통한 제로 비용 추상화 증명법을 배웁니다.관련 장: 03장 (논스에 대한 컴파일 실패 테스트), 07장 (경계에 대한 속성 기반 테스트), 05장 (세션에 대한 RAII)
타입 수준 보장 테스트의 필요성
'올바른 구성(Correct-by-construction)' 패턴은 버그를 런타임에서 컴파일 타임으로 옮깁니다. 하지만 유효하지 않은 코드가 실제로 컴파일에 실패하는지 어떻게 자동화된 테스트로 보장할 수 있을까요? 또한, 유효성 검증 경계가 수만 개의 무작위 입력에도 견딜 수 있는지 어떻게 확인할까요?
1. trybuild를 이용한 컴파일 실패 테스트
trybuild 크레이트를 사용하면 특정 코드가 컴파일되지 않아야 함을 단언(assert)할 수 있습니다. 이는 리팩토링 과정에서 타입 수준의 불변성이 깨지는 것을 방지하는 데 필수적입니다.
#[test]
fn type_safety_tests() {
let t = trybuild::TestCases::new();
// 이 경로의 파일들은 컴파일에 실패해야 테스트가 통과함
t.compile_fail("tests/ui/*.rs");
}
예를 들어, 단회용 타입인 Nonce를 두 번 사용하려 하면 컴파일 에러가 발생해야 합니다.
2. 유효성 경계에 대한 속성 기반 테스트 (Proptest)
07장에서 배운 유효성 검증 경계가 모든 잘못된 입력을 걸러내는지 확인하기 위해 proptest를 사용합니다. 이는 수천 가지의 무작위 입력을 생성하여 경계를 압박합니다.
proptest! {
#[test]
fn valid_fru_never_panics(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
// 어떤 데이터가 들어와도 유효성 검사를 통과한 이후에는 패닉이 발생하지 않아야 함
if let Ok(fru) = ValidFru::try_from(data) {
let _ = fru.board_area(); // 절대 패닉이 나면 안 됨
}
}
}
3. 제로 비용 추상화: 어셈블리로 증명하기
"뉴타입이나 팬텀 타입을 쓰면 런타임 성능이 떨어지지 않을까?" 하는 걱정은 버려도 좋습니다. cargo-show-asm을 통해 확인해보면, 원시 데이터 타입을 쓸 때와 동일한 기계어가 생성됨을 알 수 있습니다.
cargo install cargo-show-asm
cargo asm my_crate::add_rpm # 생성된 어셈블리 확인
결과를 보면 구조체 래퍼는 완전히 제거되고 순수한 레지스터 연산만 남는 것을 확인할 수 있습니다.
핵심 요약
- 실패에 대한 테스트 —
trybuild는 "허용되지 않는 행위"가 여전히 불가능함을 보장합니다. - 무작위 입력을 통한 검증 —
proptest는 수작업으로 만들기 힘든 엣지 케이스들을 찾아내어 경계를 더욱 견고하게 만듭니다. - 증거 기반의 제로 비용 — 어셈블리 분석을 통해 타입 안전성이 성능 저하를 일으키지 않음을 정적으로 확인할 수 있습니다.
- 테스트 피라미드 — 타입 시스템이 가장 넓은 기반을 지탱하고, 그 위에 속성 기반 테스트와 단위 테스트, 그리고 컴파일 실패 테스트가 층층이 쌓여 완벽한 안전망을 형성합니다.