Serde를 활용해 직렬화를 리플렉션처럼 이용하는 패턴, TLS와 인밴드 시그널링으로 직렬화/역직렬화 경계를 넘기는 기법, 그리고 MiniJinja와 unix-ipc 사례를 통해 현재 Serde의 한계와 바람직한 개선점을 살펴봅니다.
작성일: 2021년 11월 14일
프로그래머들이 러스트의 장점을 이야기할 때 비교적 빨리 serde를 “쓰는 즐거움”의 예시로 꼽곤 한다. Serde는 러스트를 위한 직렬화(Ser)와 역직렬화(De) 프레임워크다. 비교적 포맷에 독립적이며 JSON, YAML을 비롯한 다양한 포맷을 다룰 수 있다.
이 모든 것에도 불구하고, serde로 할 수 있는 일은 많고 그중엔 공유할 만한 흥미로운 사용 사례들이 있다.
serde의 아주 흥미로운 사용 사례 중 하나는 일종의 리플렉션 프레임워크처럼 써서 러스트 구조체를 원래는 구조체를 직접 지원하지 못하는 다른 “환경”에 노출하는 것이다. 개발자가 직렬화 가능한 객체를 직렬화한 뒤, 곧바로 약간 다른 형식으로 다시 역직렬화하는 상황을 생각해보자. 역직렬화 대신, 직렬화 호출을 “포착”하는 커스텀 직렬화기(serializer)만으로도 해결할 수 있다. 예컨대 이 패턴은 일반적으로 IPC, 템플릿 엔진의 컨텍스트 전달, 포맷 변환에 쓰인다.
실제로는 어떻게 보일까? 내 MiniJinja 템플릿 엔진을 사용자의 관점에서 살펴보자. MiniJinja는 런타임에 템플릿이 평가할 수 있도록 구조화된 데이터를 전달하기 위한 핵심 데이터 모델로 serde를 사용한다. 개발자 입장에서 보면 다음과 같다:
rustuse minijinja::{context, Environment}; use serde::Serialize; #[derive(Serialize, Debug)] pub struct User { name: String, } fn main() { let mut env = Environment::new(); env.add_template("hello.txt", "Hello {{ user.name }}!") .unwrap(); let template = env.get_template("hello.txt").unwrap(); let user = User { name: "John".into(), }; println!("{}", template.render(context!(user)).unwrap()); }
여기서 기본 Serialize 구현을 통해 serde로 직렬화할 수 있는 User라는 구조체를 정의했다. 이 객체는 context!(user)를 통해 템플릿에 전달된다. 이는 user라는 단일 키와 그 값으로 해당 변수를 갖는 맵을 만든다. 목표는 템플릿 엔진이 name 같은 사용자 “속성”에 접근할 수 있게 하는 것이다. 러스트는 본질적으로 동적이지 않기 때문에 보통 이런 일을 런타임에 수행하는 것은 불가능하다. 그러나 serde가 Serialize 트레이트를 다음과 같이(의사 코드로) 구현해주기 때문에 가능하다:
rustimpl Serialize for User { fn serialize<&'a, S>(&'a self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { let s = serializer.serialize_struct("User", 1); s.serialize_field("name", &self.name)?; s.end() } }
보통 직렬화기는 구조체를 문자열이나 파일로 쓰면서 JSON으로 인코딩한다. 하지만 serde 인터페이스는 그런 방식을 강제하지 않는다. 실제로 MiniJinja는 구조체를 템플릿 엔진이 다룰 수 있는 메모리 내 구조로 바로 인코딩한다.
이 패턴은 새롭지 않다. serde 자체도 이 패턴을 쓴다. 예컨대 serde의 flatten 기능(혹은 그와 유사한 표현이 필요한 일부 enum 표현)을 사용할 때, serde는 내부 버퍼링 모드를 활성화하여 데이터를 serde 데이터 모델 전체를 표현할 수 있는 내부 Content 타입에 저장한다. 이 컨텐트는 이후 단계에서 다시 다른 직렬화기에 전달될 수 있다.
나는 이 패턴을 MiniJinja뿐 아니라 insta 스냅샷 테스트 도구의 “마스킹(redaction)”에도 사용한다. 테스트 실행에서 비결정적 데이터로 인해 스냅샷이 불안정해지는 것을 피하기 위해 먼저 내부 포맷으로 직렬화하고, 그 위에 마스킹 단계를 수행한 다음, 최종 선호 포맷(예: YAML)으로 직렬화한다.
MiniJinja가 여기서 serde를 사용하는 방식이 흥미로운 점은, 직렬화와 직렬화기 사이에 serde와 호환되지 않는 데이터를 전달할 수 있다는 것이다. 앞서 언급했듯 serde에는 특정 데이터 모델이 있고 여기에 맞지 않으면 문제가 생긴다. 예를 들어 serde가 인코딩할 수 있는 가장 큰 정수는 i128이다. 임의 정밀도 정수를 포맷에 담고 싶다면 운이 없다. 완전히 그렇진 않다. 인밴드 시그널링을 사용해 추가 데이터를 전달할 수 있기 때문이다. 예를 들어 serde의 JSON 직렬화기는 임의 정밀도 정수를 이렇게 표현한다. 단일 키-값 객체에 특수 키를 예약해, 내부 JSON 직렬화/역직렬화기 조합에 “임의 정밀도 정수를 직렬화해야 한다”고 알린다. 대략 이렇게 생겼다:
json{"$serde_json::private::Number": "value"}
하지만 보듯이, 누군가 이런 JSON 문서를 조작해 만들면 serde JSON은 이를 임의 정밀도 정수로 받아들인다. 썩 좋진 않다. 또한 “value” 부분 역시 serde와 호환되어야 한다. 임의 정밀도 정수의 경우 문자열로 표현하면 되니 괜찮다. 하지만 직렬화기와 역직렬화기 사이에 넘기고 싶은 것이 전혀 직렬화 불가능한 것이라면 어떻게 할까?
여기서 스레드 로컬 저장소(TLS)를 영리하게 쓰는 방법이 깔끔한 우회책이 된다.
MiniJinja의 경우 런타임 값의 내부 표현은 Value라는 타입이다. 예상하듯 정수, 부동소수, 문자열, 리스트, 객체 등 여러 값을 담을 수 있다. 하지만 serde가 전혀 모르는 데이터도 담을 수 있다. 특히 “안전(safe)” 문자열(이스케이프가 필요 없는 안전한 HTML을 담는 문자열)이나 “동적(dynamic)” 값이 있다. 후자는 특히 흥미로운데 직렬화할 수 없다.
동적 값이란 무엇인가? 본질적으로 템플릿에 직접 전달되어야 하는 상태 있는 객체에 대한 핸들이다. MiniJinja 템플릿의 루프 변수 예시를 보자:
html<ul> {% for item in seq %} <li>{{ loop.index }}: {{ item }}</li> {% endfor %} </ul>
MiniJinja(및 Jinja2)는 루프 자체의 상태에 접근할 수 있도록 특수 변수 loop를 제공한다. 예를 들어 현재 루프 반복 횟수에 접근하려면 loop.index를 사용할 수 있다. MiniJinja에서의 동작 방식은 “루프 컨트롤러”를 템플릿에 직접 전달하고, 그 참조 카운트된 값을 Value 자체에 저장하는 것이다. 내부적으로는 대략 다음이 일어난다:
rustpub struct LoopState { len: AtomicUsize, idx: AtomicUsize, } let controller = Rc::new(LoopState { idx: AtomicUsize::new(!0usize), len: AtomicUsize::new(len), });
루프가 순회할 때마다 컨트롤러의 인덱스를 증가시킨다:
rustcontroller.idx.fetch_add(1, Ordering::Relaxed);
컨트롤러 자체는 다음과 같이 컨텍스트에 직접 추가된다:
rustlet template_side_controller = Value::from_object(controller);
이를 위해 컨트롤러는 MiniJinja 내부의 Object 트레이트를 구현해야 한다. 최소 구현은 다음과 같다:
rustimpl Object for LoopState { fn attributes(&self) -> &[&str] { &["index", "length"][..] } fn get_attr(&self, name: &str) -> Option<Value> { let idx = self.idx.load(Ordering::Relaxed) as u64; let len = self.len.load(Ordering::Relaxed) as u64; match name { "index" => Some(Value::from(idx + 1)), "length" => Some(Value::from(len)), _ => None, } } }
템플릿 엔진 쪽에서는 index 속성 조회 시 get_attr()를 호출해야 한다는 것을 알고 있다.
이론은 이렇다. 그런데 이게 serde를 어떻게 통과할까? Value::from_object가 호출되면 전달된 값은 그대로 Value 객체 안으로 이동(move)한다. 이는 잘 동작하며 특별한 처리가 필요 없다. 특히 참조 카운트가 이미 사용되고 있기 때문이다. 하지만 이제 LoopState처럼 스스로는 Serialize를 구현하지 않은 값이 어떻게 직렬화될 수 있을까? 답은 스레드 로컬 저장소와 협력하는 직렬화기/역직렬화기에 있다.
MiniJinja의 Value 구현 내부에는 다음 코드 조각이 숨어 있다:
rustconst VALUE_HANDLE_MARKER: &str = "\x01__minijinja_ValueHandle"; thread_local! { static INTERNAL_SERIALIZATION: AtomicBool = AtomicBool::new(false); static LAST_VALUE_HANDLE: AtomicUsize = AtomicUsize::new(0); static VALUE_HANDLES: RefCell<BTreeMap<usize, Value>> = RefCell::new(BTreeMap::new()); } fn in_internal_serialization() -> bool { INTERNAL_SERIALIZATION.with(|flag| flag.load(atomic::Ordering::Relaxed)) }
아이디어는 Value가 특수한 형태의 내부 직렬화가 사용될 때를 안다는 것이다. 이 내부 직렬화는 직렬화 결과의 수신자가 이 형식을 이해하는 역직렬화기라는 사실을 전제로 한다. 따라서 데이터를 직접 직렬화하는 대신 TLS에 값을 숨겨두고, serde 직렬화기에는 핸들만 직렬화한다. 역직렬화기는 그 핸들을 역직렬화하여 TLS에서 다시 값을 꺼낸다.
그러면 앞서의 루프 컨트롤러는 대략 다음처럼 직렬화된다:
rustimpl Serialize for Value { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { // enable round tripping of values if in_internal_serialization() { use serde::ser::SerializeStruct; let handle = LAST_VALUE_HANDLE.with(|x| x.fetch_add(1, atomic::Ordering::Relaxed)); VALUE_HANDLES.with(|handles| handles.borrow_mut().insert(handle, self.clone())); let mut s = serializer.serialize_struct(VALUE_HANDLE_MARKER, 1)?; s.serialize_field("handle", &handle)?; return s.end(); } // ... here follows implementation for serializing to JSON etc. } }
이것이 JSON으로 기록된다면 대략 다음과 같이 보일 것이다:
json{"\u0001__minijinja_ValueHandle": 1}
그리고 루프 컨트롤러는 VALUE_HANDLES의 핸들 1에 저장된다. 이제 거기서 값을 어떻게 꺼낼까? MiniJinja의 경우 역직렬화는 사실 일어나지 않는다. 오직 직렬화만 있고, 직렬화기가 메모리 내 객체를 조립할 뿐이다. 따라서 필요한 것은 직렬화기가 인밴드로 신호된 핸들을 이해하고, 밴드 밖(out-of-band)의 값을 찾는 것이다:
rustimpl ser::SerializeStruct for SerializeStruct { type Ok = Value; type Error = Error; fn serialize_field<T: ?Sized>(&mut self, key: &'static str, value: &T) -> Result<(), Error> where T: Serialize, { let value = value.serialize(ValueSerializer)?; self.fields.insert(key, value); Ok(()) } fn end(self) -> Result<Value, Error> { match self.name { VALUE_HANDLE_MARKER => { let handle_id = self.fields["handle"].as_usize(); Ok(VALUE_HANDLES.with(|handles| { let mut handles = handles.borrow_mut(); handles .remove(&handle_id) .expect("value handle not in registry") })) } _ => /* regular struct code */ } } }
위의 예시는 이런 식의 “악용” 방법 중 하나다. 같은 패턴은 실제 직렬화와 역직렬화가 모두 쓰이는 경우에도 활용할 수 있다. MiniJinja에서는 사실상 한 메모리 내 형식을 다른 메모리 내 형식으로 변환하기 위해 직렬화 코드를 사용하기 때문에 직렬화만으로 충분했다. 프로세스 간 데이터를 전달하려면 실제 직렬화가 필요하고 상황은 조금 더 까다로워진다. 예를 들어 프로세스 간 데이터를 주고받는 IPC 시스템을 만든다고 하자. 효율을 위해 큰 메모리 덩어리에는 공유 메모리를 쓰거나, 열린 파일을 파일 디스크립터 형태로 전달해야 할 수도 있다(그 파일이 소켓일 수도 있기 때문). 내 실험적 크레이트 unix-ipc에서는 바로 이 일을 했다.
여기서는 직렬화기가 파일 디스크립터를 둘 수 있는 보조 저장소를 마련한다. 역시 TLS를 사용한다.
API는 대략 다음과 같다:
rustpub fn serialize<S: Serialize>(s: S) -> io::Result<(Vec<u8>, Vec<RawFd>)> { let mut fds = Vec::new(); let mut out = Vec::new(); enter_ipc_mode(|| bincode::serialize_into(&mut out, &s), &mut fds) .map_err(bincode_to_io_error)?; Ok((out, fds)) }
사용자 입장에서는 모든 것이 투명하다. Serialize 구현이 파일 객체를 만나면 IPC 직렬화를 써야 하는지 확인하고, 그렇다면 FD를 보관해둘 수 있다. enter_ipc_mode는 기본적으로 fds를 스레드 로컬 변수에 바인딩하고, register_fd가 이를 등록한다. 예를 들어 내부 핸들 타입은 다음과 같이 직렬화된다:
rustimpl<F: IntoRawFd> Serialize for Handle<F> { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: ser::Serializer, { if is_ipc_mode() { // effectively a weird version of `into_raw_fd` that does // consume let fd = self.extract_raw_fd(); let idx = register_fd(fd); idx.serialize(serializer) } else { Err(ser::Error::custom("can only serialize in ipc mode")) } } }
반대쪽에서는 이렇게 한다:
rustimpl<'de, F: FromRawFd + IntoRawFd> Deserialize<'de> for Handle<F> { fn deserialize<D>(deserializer: D) -> Result<Handle<F>, D::Error> where D: de::Deserializer<'de>, { if is_ipc_mode() { let idx = u32::deserialize(deserializer)?; let fd = lookup_fd(idx).ok_or_else(|| de::Error::custom("fd not found in mapping"))?; unsafe { Ok(Handle(Mutex::new(Some(FromRawFd::from_raw_fd(fd))))) } } else { Err(de::Error::custom("can only deserialize in ipc mode")) } } }
사용자는 그냥 Handle::new(my_file)을 IPC 채널로 주고받으면 되고, 그러면 “그냥 동작한다”.
아쉽게도 위의 모든 것은 스레드 로컬 저장소와 인밴드 시그널링에 의존한다. 그다지 멋지지 않다. 언젠가 serde 2.0이 나온다면, 위와 같은 일을 더 낫게 달성할 방법이 있길 바란다.
사실 오늘날 serde에는 위 해킹들과 관련된 문제가 꽤 있다:
그럼에도 불구하고, serde를 더 갈아엎기 전에 할 수 있는 “악용”은 아직 많다. 다만 데이터 모델을 확장하는 데 필요한 꼼수를 줄이면서도 더 친화적인, 가상의 차기 serde가 어떤 모습일지 슬슬 생각해볼 때가 된 것 같다.
이 글은 rust 태그로 분류되었습니다.