Rust 생태계의 iCalendar 구현들이 왜 틀렸는지, RFC 5545 등 표준의 미묘한 지점(파라미터 타입, 컴포넌트 API 불변식, 지속시간과 시간 의미론)을 짚고 각 크레이트의 문제점과 교정 방향, 대안 및 권장 사항을 제시합니다.
iCalendar에 대해 모두가 틀리고 있다
crates.io에는 iCalendar의 구현이 많이 있습니다. 아주 좋죠! 안타깝게도, 그것들이 전부 틀렸습니다. 이건 별로 좋지 않습니다.
Background
iCalendar는 캘린더 데이터를 공유하기 위한 개방형 파일 포맷으로, 원래 1998년에 RFC 2445에서 정의되었습니다. 이 문서는 2009년에 RFC 5545로 대체되었고, 현재는 이것이 기준선 정의로 자리 잡았습니다. 그 외에도 특히 RFC 7986, 9073, 9074, 9253 등이 수년에 걸쳐 RFC 5545를 확장해 왔습니다.
.ics 파일에서 데이터의 가장 중요한 구분 단위는 컴포넌트(component)입니다. 이는 본질적으로 이름과 0개 이상의 하위 컴포넌트(subcomponent)를 가진 레코드입니다. .ics 파일은 VCALENDAR라는 이름을 가진 컴포넌트들의 리스트일 뿐이며, 파일의 거의 모든 데이터는 VCALENDAR의 하위 컴포넌트로 저장됩니다. 각 컴포넌트는 프로퍼티(property)들의 시퀀스를 포함하는데, 각 프로퍼티는 이름, 프로퍼티 파라미터 목록, 값으로 이루어진 튜플입니다. 프로퍼티의 값 포맷은 그 이름에 의해 결정됩니다.
예를 들어 봅시다. 아래 스니펫은 RFC 5545 § 3.6.1에 나오는 이벤트 컴포넌트의 ABNF 정의입니다.
eventc = "BEGIN" ":" "VEVENT" CRLF
eventprop *alarmc
"END" ":" "VEVENT" CRLF
eventprop = *(
;
; The following are REQUIRED,
; but MUST NOT occur more than once.
;
dtstamp / uid /
;
; The following is REQUIRED if the component
; appears in an iCalendar object that doesn't
; specify the "METHOD" property; otherwise, it
; is OPTIONAL; in any case, it MUST NOT occur
; more than once.
;
dtstart /
;
; The following are OPTIONAL,
; but MUST NOT occur more than once.
;
class / created / description / geo /
last-mod / location / organizer / priority /
seq / status / summary / transp /
url / recurid /
;
; The following is OPTIONAL,
; but SHOULD NOT occur more than once.
;
rrule /
;
; Either 'dtend' or 'duration' MAY appear in
; a 'eventprop', but 'dtend' and 'duration'
; MUST NOT occur in the same 'eventprop'.
;
dtend / duration /
;
; The following are OPTIONAL,
; and MAY occur more than once.
;
attach / attendee / categories / comment /
contact / exdate / rstatus / related /
resources / rdate / x-prop / iana-prop
;
)
이 문법이 설명하는 바는, 이벤트가 BEGIN:VEVENT<CR><LF>와 END:VEVENT<CR><LF>로 경계가 정해지고, 그 사이에는 프로퍼티들의 시퀀스가 나오고 이어서 0개 이상의 VALARM 하위 컴포넌트가 온다는 것입니다. RFC 5545는 임의의 컴포넌트에 대한 문법을 명시적으로 제시하지는 않지만, 이 기본 구조는 모든 컴포넌트에서 사용됩니다.
대부분의 경우 iCalendar를 구현한다는 건 기본적으로 이렇습니다. 문서를 읽고, 아키텍처와 데이터의 형태에 대해 대략 감을 잡은 다음, 필요할 때 특정 영역을 확대해서 보면 됩니다. 여기까지는 쉽습니다. 하지만 자잘한 부분을 해석하려 들기 시작하면, 관련 RFC의 텍스트가 절망적으로 난해해질 수 있습니다. 여러 곳에서 사양이 불충분하고, 구현자가 메워야 할 큰 공백을 남겨두기도 합니다.
제가 실제 구현에서 보아 온 오해들을 몇 가지 짚어 보려고 합니다. 다만 이것들이 실수하기 쉬운 지점이라는 점을 강조하고 싶습니다. 저 역시 제 구현을 만들면서 대부분의 실수를 저질렀습니다. 또한 iCalendar의 핵심 측면이 모호하고 명확히 옳은 해석이 존재하지 않는 것 같은 엣지 케이스도 함께 다루겠습니다.
Parameter Types
Rust로 작업하고 있으니, 세상에서 가장 쉬운 실수는 타입을 가능한 한 엄격하게 정의하는 것입니다. 제 구현을 만들다가 한 달쯤 지나서야 이게 문제라는 걸 깨달았고, 결국 RFC 5545의 대부분의 문법이 x-prop, iana-prop, other-param 같은 작은 포괄(catch-all)을 품고 있다는 사실로 귀결됩니다.
RFC 5545 § 3.8.6.3의 예를 보겠습니다.
trigger = "TRIGGER" (trigrel / trigabs) CRLF
trigrel = *(
;
; The following are OPTIONAL,
; but MUST NOT occur more than once.
;
(";" "VALUE" = "DURATION") /
(";" trigrelparam) /
;
; The following is OPTIONAL,
; and MAY occur more than once.
;
(";" other-param)
;
) ":" dur-value
trigabs = *(
;
; The following is REQUIRED,
; but MUST NOT occur more than once.
;
(";" "VALUE" = "DATE-TIME") /
;
; The following is OPTIONAL,
; and MAY occur more than once.
;
(";" other-param)
;
) ":" date-time
대충 훑어보다가 other-param의 존재를 놓치면, 아래와 같은 타입을 만들게 될 수 있습니다.
pub enum Trigger {
Duration(Duration, Option<Related>),
DateTime(CalendarDateTime),
}
icalendar 크레이트는 iCalendar 위에 좋은 정적 타입 레이어를 제공한다고 내세우기 때문에, 이런 정적 타입을 사용합니다. 그리고 대부분의 경우엔 괜찮습니다. 다만 other-param 규칙이 쓰일 때를 제외하면요. 예컨대 TRIGGER;X-PARAM=100:-PT15M 같은 걸 쓸 수 있고, 다소 이상하긴 해도 완전히 합법입니다. 하지만 이 타입에는 그 파라미터를 표현할 방법이 없으니, TRIGGER 프로퍼티의 올바른 모델이 될 수 없습니다.
실제로는 프로퍼티 파라미터를 테이블로 표현하는 수밖에 없습니다. 거의 모든 파라미터가 옵션이므로, 프로퍼티마다 특정 테이블 타입을 따로 정의할 필요조차 없습니다. 그리고 모든 프로퍼티가 반드시 파라미터 테이블을 가져야 하므로, enum을 걷어내고 아래처럼 만들 수 있습니다.
pub struct Property<V> {
pub parameters: ParameterTable,
pub value: V,
}
pub enum Trigger {
Duration(Property<Duration>),
DateTime(Property<DateTime>),
}
Component APIs
Event 같은 타입을 정의하고 “이 타입은 VEVENT 컴포넌트다”라고 말하였으면, API는 그 사실을 강제해야 합니다. 대부분의 크레이트는 가장 기본적인 불변식조차 강화하지 못하고, 가변 접근을 제공하는 경우에는 불변식을 깨뜨리기 아주 쉽습니다.
쉬운 것부터: ics 크레이트는 Event::new 메서드가 UID와 DTSTAMP 프로퍼티를 위한 두 개의 impl Into<Cow<'_, str>> 값을 그냥 받습니다. 그래서 아래 코드는 잘못된 Event를 만들어 냅니다:
use ics::Event;
// 여기서 이모지는 당연히 유효하지 않다
let invalid_event = Event::new("⚠️", "⚠️");
설령 유효한 Event를 갖고 있더라도, 한 번만 나타나야 하는 프로퍼티를 Event::push로 두 번 추가해서 이를 무효화할 수 있습니다:
use ics::{Event, properties::Class};
let mut valid_event = Event::new(
"d4092ed9-1667-4518-a7c0-bcfaac4f1fc6",
"20181021T190000",
);
// CLASS 프로퍼티는 VEVENT에서 최대 한 번만 나타날 수 있다
valid_event.push(Class::public());
valid_event.push(Class::private());
거의 같은 요령을 icalendar에서도 쓸 수 있는데, 더 쉽습니다. 여기서 Event::new는 아무 인자도 받지 않으므로, 필수 프로퍼티인 UID와 DTSTAMP를 포함하지 않아 자동으로 무효가 됩니다.
use icalendar::Event;
// icalendar에서 모든 이벤트는 처음엔 유효하지 않은 형태로 시작한다
let invalid_event = Event::new();
하지만 icalendar는 또 다른 문제를 도입합니다. Component 트레이트를 통해 Event에 제한 없는 가변 접근을 제공하기 때문에, 필수 프로퍼티 중 하나를 제거해서 Event를 손쉽게 무효화할 수 있습니다.
use icalendar::{Component, Event, Property};
// 가령 수동으로 유효한 이벤트를 구성했다고 하자
let mut event = Event::new();
event.append_property(Property::new(
"UID",
"d4092ed9-1667-4518-a7c0-bcfaac4f1fc6",
));
event.append_property(Property::new(
"DTSTAMP",
"20181021T190000",
));
// 그럼 간단히 다시 무효로 만들 수 있다
event.remove_property("UID");
Durations and Time Semantics
이건 미묘한 지점이므로 강조하고 싶습니다. 캘린더 시스템은 시간을 정확히 전달하는 데 초점을 두지 않습니다. 우리는 그렇게 사용하지 않기 때문입니다. 어떤 이벤트가 하루 동안 지속된다고 말할 때, 우리는 문자 그대로 86,400,000밀리초가 걸린다는 뜻이 아니라 하루가 걸린다는 뜻입니다. ‘하루’는 하나의 의미론적 단위이며 그 길이는 변합니다. 그래서 캘린더 단위로는 유용하지만 시간 측정 단위로는 형편없습니다. 관련 PL(프로그래밍 언어) 용어를 빌리자면, 캘린더 시스템은 ISO 8601 구현으로 정확히 포착할 수 없는 지시적 의미론(denotational semantics)을 갖습니다.
지속시간(duration)은 이를 가장 잘 보여 줍니다. RFC 5545에서 지속시간은 부호가 있는 시간 구간으로, 주 단위이거나 일/시/분/초의 조합으로 측정됩니다. RFC 5545 § 3.3.6을 직접 인용하자면, “이 포맷은 명목상의 지속시간(주와 일)과 실제 지속시간(시, 분, 초)을 표현할 수 있으며”, “주와 일의 길이는 달력에서의 위치에 달려 있습니다.” 따라서 아주 분명히 하자면: 하루나 주의 길이에 단일 숫자를 부여할 수 없으므로, 실제 초나 밀리초 단위의 측정값으로 지속시간을 표현할 수 없습니다.
따라서 icalendar, ickle, web_ical이 지속시간을 표현하는 데 chrono::Duration을 사용할 때, 이는 엄밀히 말해 틀렸습니다. chrono는 지속시간을 부호 있는 밀리초 수로 측정합니다. 그 결과 반복 규칙을 올바르게 처리할 수 없고 .ics 파일을 정확히 렌더링하는 데 실패하게 됩니다.
Quickfire Round: icalendar
며칠 전, 약간의 답답함 속에서 사실상 모든 Rust iCalendar 구현이 틀렸다는 글을 올렸습니다.
crates.io의 모든 iCalendar 구현이 얼마나 틀렸는지 정말 놀랍다
2025년 10월 28일 오전 2:13
이게 이 글의 원래 동기였고, 대체로 농담 삼아 쓴 것이긴 했지만, 관련 스레드에서 특히 icalendar에 대해 제 생각을 더 올리기도 했습니다.
icalendar는 ~괜찮다(정확하진 않지만). 하지만 API가 매우 투박하고, 문서가 형편없으며, 여기저기서 쓸데없이 할당을 한다
* * * * * * https://crates.io/crates/icalendar
2025년 10월 28일 오전 2:20
조금 과했냐고요? 아마요. 하지만 주로 사람들이 어떤 크레이트를 써야 할지에 대한 제 생각을 말한 거였습니다. 어쨌든 중요한 건 하루쯤 뒤에 받은 답글입니다.
나도 그 할당들이 마음에 들지 않는다. 이건 내 ‘가려움을 긁기’ 위해 만든 라이브러리고, 이름을 선점했기 때문에 계속했다. 상단에 친절한 헤더도 있다: "이걸 더 성숙하게 만드는 데 도움 주고 싶나요? 제게 연락하세요. PR과 제안 아주 환영합니다."
2025년 10월 29일 오후 2:15
우리는 잠깐 대화를 나눴고, 결국 나는 이 글에 icalendar에 대한 더 자세한 내용을 포함하여 그냥 글만 올리는 대신 실제로 도움을 주겠다고 제안했습니다. 아래는 내가 icalendar에서 발견했고 정확성을 위해 다룰 가치가 있다고 생각하는 이슈들입니다(이미 다룬 주제는 제외).
Property Multiplicity
모든 프로퍼티는 자신을 둘러싼 컴포넌트 내에서 발생 횟수(다중성, multiplicity)를 가집니다. 예를 들어 UID 프로퍼티는 VEVENT에서는 정확히 한 번 나타나야 하지만, VCALENDAR에서는 최대 한 번만 나타날 수 있습니다.
컴포넌트의 API를 제대로 구성하려면, 각 프로퍼티의 다중성을 타입 시스템에 인코딩해야 합니다. 프로퍼티가 필수라면 컴포넌트를 초기화할 때 반드시 제공되어야 하고, 옵션이라면 최대 한 번만 제공될 수 있어야 합니다.
중요한 점은, 다양한 컴포넌트 타입들 사이에서 모든 프로퍼티 메서드를 공유하는 단일 Component 트레이트를 둘 수 없다는 것입니다. 각 컴포넌트에 대해 관련 메서드의 시그니처를 바꿀 수 있어야 합니다. 어떤 경우에는 컴포넌트 타입만 알아서는 충분치 않고, 어떤 특수 프로퍼티의 값까지 알아야 합니다. 예컨대 VALARM 컴포넌트에서는 프로퍼티의 다중성이 ACTION 프로퍼티의 값에 따라 달라집니다.
Time Semantics and chrono
icalendar에서 chrono 사용은 강하게 말리고 싶습니다. 시간에 대한 의미론적 모델이 다릅니다. chrono와 상호 운용하는 유틸리티를 제공하는 건 아주 합리적입니다. 아마 기능 플래그로 게이트하면 되겠죠. 하지만 파서나 데이터 모델에서 사용해서는 안 됩니다.
이건 이미 지속시간에서 다루었지만, 날짜에서도 유사한 문제가 발생합니다. NaiveDate(즉, 전개 그레고리력, proleptic Gregorian calendar)가 항상 유효한 iCalendar 날짜를 정확히 반영한다는 보장도 없고, 그 반대가 참인지도 불분명합니다.
Extension Values
Class, EventStatus, TodoStatus, ValueType 같은 타입은 RFC 5545가 명시적으로 허용하는 X-로 시작하는 확장 값들을 지원해야 합니다. 등록되지 않은 IANA 값들에 대한 지원도 제공하는 편이 좋을 것입니다. 다만 이는 얼마나 적극적으로 미래의 RFC나 엄격히 정의된 집합 밖의 사양을 잠재적으로 지원할 의향이 있는지에 달려 있습니다.
Other Remarks
여기서는 주로 정확성에 초점을 맞추고 있으므로 icalendar의 아키텍처에 대해 길게 말하진 않겠습니다. 주요한 우려는 Component 트레이트가 너무 엄격해 보인다는 점과, 많은 데이터가 강한 타입의 값이 아니라 원시 String으로 저장되고 있다는 점입니다.
먼저 설계와 아키텍처를 정립하고, 그 다음에는 미래 호환성을 염두에 둔 RFC 5545의 완전한 구현에 집중하길 권합니다. 만약 프로젝트가 그 방향으로 갈 거라면, 저도 기꺼이 기여하겠습니다!
여기까지 언급하지 않은 크레이트가 하나 있는데 calcard입니다. 이 크레이트는 제 전체 주장을 깨부술 수도 있습니다. 꽤 프로페셔널하고, 할당을 최소화하고 파서를 최적화하는 데 많은 고민을 들인 게 분명합니다. 더 중요한 건, 제가 다룬 다른 크레이트들과 달리 올바르게 보인다는 점입니다.
뭐, 일면 그렇습니다. 크레이트의 거의 모든 요소가 관련 RFC들로 합리적으로 뒷받침될 수 있습니다. 특히 명시된 목표, 즉 “표준에서 너무 많이 벗어나지 않는 한 비준거 iCal/vCard/JSCalendar/JSContact 객체를 파싱한다”를 고려하면 그렇습니다.
하지만 저는 그것이 여전히 올바르지 않다고 느낍니다. 아래 예시를 보세요:
use calcard::icalendar::ICalendar;
let input = "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n";
let cal = ICalendar::parse(input).unwrap();
let mut out = String::new();
cal.write_to(&mut out).unwrap();
println!("{out}");
// 출력:
// BEGIN:VCALENDAR
// VERSION:2.0
// END:VCALENDAR
이 코드는 파싱과 기록 모두 성공합니다. 그런데 결과는 명백히 잘못된 값을 만들어 냅니다(VCALENDAR 컴포넌트에는 PRODID 프로퍼티가 반드시 있어야 합니다). 제게는 이것이 일종의 ‘틀림’으로 보입니다. 그렇지 않다면 ICalendar 타입이 실제 iCalendar 객체를 나타낸다고 할 수 없겠죠.
사실 calcard를 그 보장에 비추어 평가하는 일은 어렵습니다. 문서가 거의 없기 때문입니다. 만약 이 크레이트가 명시적으로 ICalendar가 iCalendar 객체를 나타낸다고 말했다면, 저는 그것을 틀렸다고 명확히 단정할 수 있습니다. 하지만 지금은 타입 이름과 몇 줄의 빈약한 주석을 통해 의도와 보장을 추론해야 합니다. 보장과 불변식이 문서화되어 있지 않다면, 저는 사소하지 않은 용도로 calcard를 사용하는 게 매우 불편할 것 같습니다.
Remarks and Recommendations
가까운 단기적으로는 calcard, ical, 혹은 커스텀 구현을 사용할 것을 권합니다. 기술적으로는 libical을 bindgen으로 쓸 수도 있지만, 꽤 고통스러워 보입니다.
중기적으로 저는 calico라는 크레이트를 작업 중입니다. 현재의 선택지에 대해 정확하고 잘 문서화된 대안을 제공하려는 목표입니다. 지금은 사용할 수 없는 상태이고 대대적인 리팩터링 중이지만, 그리 멀지 않아 쓸 만한 릴리스를 낼 수 있기를 바랍니다.
장기적으로는 iCalendar가 근본적으로 결함 있는 표준이라는 것이 분명해 보입니다. 제대로 구현하기가 얼마나 터무니없이 어려운지 보세요. 다른 사람들도 동의합니다! 2021년에 발행된 RFC 8984는 iCalendar의 내재적 문제를 피해 가는 별도의 의미론적 모델을 지닌 JSCalendar라는 JSON 기반 대안을 정의합니다. 현재 이를 지원하는 크레이트로 제가 아는 것은 calcard뿐이지만, iCalendar 구현이 어느 정도 안정화되면 calico에도 JSCalendar 지원을 추가할 생각입니다.
2025년 11월 2일 오전 6:15
0