클라우드 Mac에서 iOS 캘린더 내보내기 검증하기

클라우드 Mac에서 iOS 캘린더 내보내기 검증하기

클라우드 Mac에서 iOS 캘린더 내보내기 검증하기

사용자가 앱에서 내보낸 일정을 캘린더로 가져왔더니 제목의 쉼표가 사라지고, 종일 일정은 하루 더 길게 표시됩니다. 이런 문제는 대개 빌드 실패가 아니라 내보내기 로직이 문자열, 바이트, 날짜를 같은 종류의 데이터처럼 취급해서 생깁니다. 클라우드 Mac에서 Swift로 고정된 테스트 샘플을 실행하면 ICS 파일을 먼저 검사하고 실제 가져오기 결과까지 확인할 수 있습니다. 사용자가 스크린샷을 보내 줄 때까지 기다릴 필요가 없습니다.

먼저 검증 범위를 정하기

이번에는 단일 이벤트 내보내기만 다룹니다. 제목과 설명은 텍스트로, 시간이 지정된 이벤트는 UTC로, 종일 이벤트는 날짜로 처리합니다. 반복 규칙, 참석 초대, 알림은 포함하지 않습니다. 나중에 이런 기능을 추가한다면 별도의 샘플을 만들어야 합니다. “파일이 열리면 통과”라는 기준을 그대로 적용해서는 안 됩니다.

이벤트의 UID, DTSTAMP, 입력 날짜는 고정합니다. 운영 환경에서는 UID를 이벤트와 함께 안정적으로 저장해야 하며, 내보낼 때마다 새로 생성하면 안 됩니다. 같은 이벤트를 다시 가져왔을 때의 동작은 캘린더 클라이언트마다 다르므로, UID가 일정하다고 해서 자동 업데이트가 보장되는 것은 아닙니다.

형식 검사는 “파일이 정해진 규칙을 따르는가”를 확인하고, 가져오기 검사는 “사용자에게 올바른 이벤트가 보이는가”를 확인합니다. 어느 한쪽이 다른 쪽을 대신할 수는 없습니다.

작업 디렉터리에 아래 스크립트를 calendar-check.swift로 저장합니다. Foundation만 사용하므로 Xcode 명령줄 도구를 설치하고 사용할 도구로 선택하면 실행할 수 있습니다.

문자 수로 자르지 말고 바이트 단위로 줄 접기

ICS의 텍스트 값에서는 역슬래시, 세미콜론, 쉼표, 줄바꿈을 이스케이프해야 합니다. 먼저 입력의 줄바꿈을 통일하고, 이스케이프한 다음, 마지막으로 줄을 접습니다. 순서를 바꾸면 줄 이어쓰기 표식까지 이스케이프하거나 이스케이프 시퀀스를 본문으로 잘못 처리할 수 있습니다.

줄을 접을 때는 CRLF를 제외한 각 물리적 줄의 길이를 75옥텟 이하로 맞추는 것이 권장됩니다. 이어지는 줄은 공백 하나로 시작하며, 이 공백도 한 바이트를 차지합니다. 길이를 계산할 때 String.count를 사용해서는 안 됩니다. UTF-8 배열을 그대로 잘라서도 안 됩니다. 한글이나 이모지가 잘못된 인코딩으로 깨질 수 있기 때문입니다.

import Foundation

func escapeText(_ value: String) -> String {
    value
        .replacingOccurrences(of: "\r\n", with: "\n")
        .replacingOccurrences(of: "\r", with: "\n")
        .replacingOccurrences(of: "\\", with: "\\\\")
        .replacingOccurrences(of: ";", with: "\\;")
        .replacingOccurrences(of: ",", with: "\\,")
        .replacingOccurrences(of: "\n", with: "\\n")
}

func fold(_ value: String) -> String {
    var lines: [String] = []
    var current = ""
    var bytes = 0

    for scalar in value.unicodeScalars {
        let piece = String(scalar)
        let size = piece.utf8.count
        if bytes + size > 75 {
            lines.append(current)
            current = " "
            bytes = 1
        }
        current += piece
        bytes += size
    }

    lines.append(current)
    return lines.joined(separator: "\r\n")
}

let title = String(repeating: "项目复盘🧑🏽‍💻", count: 12)
let description = "范围,A;B\\C\r\n第二行"
let logicalLines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//MiniVPS//Calendar Check//ZH",
    "BEGIN:VEVENT",
    "UID:calendar-check-001",
    "DTSTAMP:20260929T000000Z",
    "DTSTART:20260930T010000Z",
    "DTEND:20260930T020000Z",
    "SUMMARY:" + escapeText(title),
    "DESCRIPTION:" + escapeText(description),
    "END:VEVENT",
    "END:VCALENDAR"
]

let output = logicalLines.map(fold).joined(separator: "\r\n") + "\r\n"
let physicalLines = output.components(separatedBy: "\r\n")
precondition(physicalLines.last == "")
precondition(physicalLines.dropLast().allSatisfy {
    !$0.isEmpty && $0.utf8.count <= 75
})

let withoutCRLF = output.replacingOccurrences(of: "\r\n", with: "")
precondition(!withoutCRLF.contains("\r"))
precondition(!withoutCRLF.contains("\n"))

let unfolded = output.replacingOccurrences(of: "\r\n ", with: "")
precondition(unfolded == logicalLines.joined(separator: "\r\n") + "\r\n")
precondition(escapeText(description) == "范围\\,A\\;B\\\\C\\n第二行")

try Data(output.utf8).write(
    to: URL(fileURLWithPath: "calendar-check.ics"),
    options: .atomic
)

여기서는 유니코드 스칼라 단위로 문자열을 누적해 각 물리적 줄이 유효한 UTF-8이 되도록 합니다. 조합형 이모지는 두 물리적 줄에 걸쳐 나뉠 수 있지만, 줄 이어쓰기 표식을 제거하면 원문이 복원되어야 합니다. 스크립트의 줄 펼치기 일치 검사는 줄을 접는 과정에서 문자가 빠지거나 추가되는 일을 막기 위한 것입니다. 이 검사는 예제의 생성기를 검증할 뿐, 범용 ICS 파서는 아닙니다.

절대 시각과 종일 날짜를 구분하기

시간이 지정된 이벤트에는 Z가 붙은 UTC 값을 사용해 특정 시점을 나타냅니다. 로컬 캘린더에서 입력을 받았다면 사용자가 선택한 시간대를 적용해 시점을 해석한 뒤 형식을 변환해야 합니다. 로컬 시간 문자열 뒤에 Z만 덧붙여서는 안 됩니다.

반면 종일 이벤트를 먼저 UTC 자정으로 변환해서는 안 됩니다. 위 예제의 시작 및 종료 속성을 다음과 같이 바꿉니다.

DTSTART;VALUE=DATE:20260930
DTEND;VALUE=DATE:20261001

이는 9월 30일 하루를 뜻합니다. 종료 날짜는 이벤트에 포함되지 않습니다. 월이나 연도를 넘어가더라도 같은 규칙을 따릅니다. 프로그램에서 다음 날을 계산할 때는 사용할 역법을 명시한 날짜 연산을 사용해야 합니다. 타임스탬프에 고정된 초 단위를 더하는 방식은 피해야 합니다.

제품 요구사항이 “현지 시간으로 매일 오전 9시”라면 UTC 단일 이벤트만으로는 그 의미를 표현할 수 없습니다. 시간대 식별자, 시간대 정의, 반복 규칙을 별도로 설계해야 하며, 가져오는 클라이언트가 알아서 추측하도록 맡겨서는 안 됩니다.

가져오기 결과로 의미 검증하기

스크립트를 실행한 다음, 생성된 파일을 로컬로 가져와 테스트 전용 캘린더에 가져옵니다.

xcrun swift calendar-check.swift
shasum -a 256 calendar-check.ics

전송 후에는 해시를 다시 계산해 같은 파일을 테스트하고 있는지 확인합니다. 원격 데스크톱에서 파일이 보인다고 해서 전송이나 내용이 올바르다는 뜻은 아닙니다.

항목별로 확인합니다. 중국어와 이모지가 온전한지, 쉼표·세미콜론·역슬래시가 그대로 남아 있는지, 설명이 정확히 두 줄인지, 시간이 지정된 이벤트의 길이가 한 시간인지, 종일 이벤트가 하루만 차지하는지 살펴봅니다. 프로젝트에서 실제로 지원하는 클라이언트마다 최소 한 번은 검증해야 합니다. 특정 클라이언트가 형식 오류를 허용하는 동작을 형식 표준으로 여겨서는 안 됩니다.

빈 제목, 매우 긴 설명, 월 경계를 넘는 종일 이벤트, 같은 UTC 이벤트를 서로 다른 시간대에서 표시하는 경우도 샘플에 추가해야 합니다. 시간을 수동으로 확인할 때는 클라이언트의 시간대도 함께 기록합니다. 올바른 시간대 변환을 시간 오차로 잘못 판단하지 않기 위해서입니다.

실패 원인을 추적할 수 있는 증거 남기기

CI에는 생성된 파일, 입력 샘플 식별자, 스크립트 버전, 실행 종료 코드를 보관합니다. 검사에 실패하면 배포를 중단해야 합니다. 오류를 잡아 놓고 이전 파일을 계속 업로드해서는 안 됩니다. 실제 이벤트의 제목과 설명에는 개인정보가 들어 있을 수 있으므로, 회귀 테스트 산출물에는 합성 데이터만 남깁니다.

검증 게이트는 세 계층으로 나눕니다. 바이트 계층에서는 CRLF와 줄 펼치기 일치 여부를, 속성 계층에서는 필수 필드와 날짜 유형을, 의미 계층에서는 가져온 뒤의 날짜·지속 시간·본문을 확인합니다. 반복 이벤트나 알림을 추가할 때는 어느 계층에 어떤 규칙과 실패 샘플을 추가하는지 명확히 해야 합니다.

마지막으로 내보내기 로직에 최소 반례를 하나 추가합니다. 쉼표 이스케이프를 제거하거나, 종일 이벤트의 종료 날짜를 시작일과 같은 날로 바꾸거나, UTF-8 인코딩 중간을 잘라 봅니다. 테스트가 이런 결함을 잡아내야 내보내기가 보장해야 할 규칙을 실제로 보호한다고 할 수 있습니다. 현재 구현이 실행된다는 사실만 확인하는 것으로는 부족합니다.

자주 묻는 질문

ICS 줄 접기 기준은 75글자인가요?

권장 기준은 글자 수가 아니라 75옥텟입니다. 한글과 이모지는 UTF-8에서 여러 바이트를 사용하며, 이어지는 줄의 첫 공백도 길이에 포함됩니다.

종일 일정의 DTEND에는 마지막 날짜를 넣나요?

아닙니다. DTEND는 일정에 포함되지 않는 종료 날짜입니다. 하루 일정은 다음 날짜를 넣고 DTSTART와 DTEND 모두 VALUE=DATE를 사용합니다.

다음 빌드는 클라우드 워크스테이션에서

Mac mini 한 대를 단독으로 사용하며, 가상 머신이 아닙니다. 작업에 맞는 대여 기간을 선택하세요. 실제 사용 가능 여부는 콘솔에 실시간으로 표시됩니다.

클라우드 Mac 대여하기

구성을 먼저 확인하고 작업을 시작하세요

Desk M4의 요금제와 추가 옵션을 확인하거나, 시작 가이드를 보고 첫 연결을 준비하세요.