Rust 생태계의 iCalendar 구현들이 왜 틀렸는지 짚으며, RFC 5545를 비롯한 관련 표준의 미묘한 규정, 파라미터와 컴포넌트 모델, 시간 의미론과 지속시간 표현, 속성 중복도(multiplicity), 확장 값, 각 크레이트의 문제점과 개선 방향, 그리고 사용 권장안을 다룹니다.
iCalendar에 대해 모두가 틀렸다
crates.io에는 iCalendar의 구현이 아주 많이 존재한다는 점은 정말 멋지다! 안타깝게도 그 구현들이 전부 틀렸다. 이건 멋지지 않다.
Background
iCalendar는 캘린더 데이터를 공유하기 위한 개방형 파일 포맷으로, 1998년에 RFC 2445로 처음 정의되었다. 이 문서는 2009년에 RFC 5545로 대체되었고, 현재는 이를 기준 정의로 삼는다. 이후에도 특히 RFC 7986, 9073, 9074, 9253 등이 RFC 5545를 확장해 왔다.
.ics 파일에서 가장 중요한 데이터 구분 단위는 컴포넌트로, 이름과 0개 이상의 하위 컴포넌트를 가진 레코드에 가깝다. .ics 파일은 VCALENDAR라는 이름의 컴포넌트 목록일 뿐이며, 파일의 거의 모든 데이터는 VCALENDAR의 하위 컴포넌트로 저장된다. 각 컴포넌트는 일련의 속성들을 포함하며, 속성은 이름, 속성 파라미터 목록, 값으로 이루어진 삼중항이다. 각 속성의 값 형식은 그 속성 이름이 결정한다.
예시를 하나 보자. 아래 스니펫은 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 속성의 올바른 모델일 수 없다.
실제로는 속성 파라미터를 테이블로 표현하는 수밖에 없다. 거의 모든 파라미터가 선택 사항이므로 각 속성마다 구체적 파라미터 테이블 타입을 정의할 필요조차 없다. 그리고 모든 속성에는 파라미터 테이블이 있어야 하므로, 열거형을 걷어내고 다음과 같이 만들 수 있다:
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에서 지속시간은 부호 있는 기간으로, 주(week) 또는 일/시/분/초로 측정된다. RFC 5545 § 3.3.6을 그대로 인용하면, “이 형식은 명목상의 지속시간(주와 일)과 실측 지속시간(시, 분, 초)을 나타낼 수 있으며” 그리고 “주와 일의 지속시간은 캘린더에서의 위치에 따라 달라진다.” 즉 아주 분명히 말해 두자면: 일(day)이나 주(week)의 길이에 단일 숫자를 할당할 수 없으므로, 지속시간을 초나 밀리초의 실제 측정값으로 표현할 수 없다.
그런데 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)로 저장된다는 점이다.
먼저 설계와 아키텍처를 확정하고, 그다음 선제적 호환성(forward compatibility)을 염두에 둔 RFC 5545의 완전한 구현에 집중하길 권한다. 그 방향으로 프로젝트가 나아간다면, 나도 기꺼이 기여하겠다!
지금까지 언급하지 않은 크레이트로 calcard가 있다. 이 크레이트는 내 전체 논지를 무력화한다고도 말할 수 있다. 매우 프로페셔널하고, 할당을 최소화하고 파서를 최적화하려는 고민이 분명히 담겨 있다. 더 중요한 건 내가 앞서 논한 다른 크레이트들과 달리 올바른 것으로 보인다는 점이다.
그런데, 글쎄? 크레이트의 거의 모든 요소가 관련 RFC에 의해 합리적으로 뒷받침되며, 특히 명시한 목표—“표준에서 크게 벗어나지 않는 한 비규범적(non-conformant) 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의 고질적 문제를 피하는 명확히 구분된 의미 모델을 가진 JSON 기반 대안 JSCalendar를 정의한다. 현재 내가 아는 한 이를 지원하는 크레이트는 calcard뿐이지만, iCalendar 구현이 어느 정도 안정되면 calico에 JSCalendar 지원도 추가할 생각이다.
2025년 11월 2일 오전 6:15
0