본문 바로가기
C.W.K.
Lesson 06 of 06 · published

똑같아 보이는 문자열, 날짜, 바이트

~16 min · swift-for-apple, unicode, dates, iso8601, data

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"하루에 세 번이나 Foundation에 대한 뻔한 짐작이 정반대로 틀렸어. 매번 확인하는 데 필요한 건 열 줄짜리 스크립트뿐이었고."

Swift 문자열은 뜻을 비교하고, 파일은 바이트를 비교해

Swift의 String ==은 유니코드 정규 동등성으로 비교해. 한글을 완성형으로 쓴 것(NFC, UTF-8로 6바이트)이랑 자모로 풀어 쓴 것(NFD, 18바이트)은 같은 문자열이야. 사람이 입력한 글자를 비교할 땐 아주 좋아. 근데 그 문자열이 파일 이름이나 해시 입력, 파일 시스템이 보게 될 키로 쓰이는 순간 위험해져. macOS는 디렉터리 목록에서 풀어 쓴 이름을 주고, 텍스트 필드는 완성형을 줄 수 있어. 둘을 각각 해시하면 노트 하나에 사이드카 폴더가 두 개 생기고, 기록이 반씩 나뉘어 들어가. 그런데도 에러는 하나도 안 나. 가족 편집기 하나가 Rust로 된 파일 키 함수를 Swift로 옮기다가 이걸 알게 됐어. "이 둘은 달라야 한다"는 자기 테스트가 실패했거든. Swift에선 둘이 같다고 나오니까. 명시적으로 정규화하고(precomposedStringWithCanonicalMapping이 NFC야), 바이트가 중요할 땐 utf8 바이트로 비교해.

같은 순간을 적는 올바른 표기가 두 가지

웹 클라이언트는 캡처 시각을 2026-09-10T12:17:29+09:00으로 찍었어. 네이티브 클라이언트는 2026-09-10T10:45:13.146Z로 찍었고. 둘 다 올바른 ISO 8601이야. 근데 목록이 문자열 그대로 정렬했더니, 현지 시각 19시 45분 캡처가 12시 17분 캡처 밑으로 가라앉았어. 캡처 앱이 존재하는 이유나 다름없는 최신 항목이 아예 안 들어온 것처럼 보였지. 비교 전엔 Date로 파싱해. 그리고 소수점 초를 켠 ISO8601DateFormatter는 소수점 없는 표기를 거부하고 그 반대도 마찬가지니까, 둘 다 시도해. 망가진 타임스탬프가 최신 항목인 척 못 하게, 파싱 안 되는 건 일부러 맨 밑으로 보내.

비슷한 함정 하나 더. 위치 측정값의 시각은 UTC로 찍힌 순간이고, 캡처 자체의 시각은 사람이 실제로 보는 벽시계 기준 오프셋을 달고 있어. 측정값으로 만든 라벨엔 "13:25"라고 떴는데, 같은 화면 다른 곳엔 같은 시각이 "22:25"로 나왔어. 필드가 둘이면 역할도 둘이야. 화면엔 사람이 뜻하는 쪽을 그려.

안 보이는 문자, 비었는데 있는 데이터

  • 받아쓰기가 U+FFFC를 남길 수 있어. iOS에서 SwiftUI TextField(axis: .vertical)에 받아쓰기로 입력했더니, 바인딩된 문자열 맨 앞에 OBJECT REPLACEMENT CHARACTER가 남았어. 받는 쪽 언어 모델은 그걸 첨부 이미지 표시로 읽고, 있지도 않은 첨부 파일을 찾아 헤맸어. 기기 밖으로 나가는 텍스트에선 U+FFF9부터 U+FFFC까지 지워.
  • "쓸 수 있다"랑 "들어 있다"는 달라. PNG를 약속만 한 페이스트보드 항목은 data(forType:)에 nil이 아니라 0바이트로 대답해. "nil이면 불완전"으로 짠 가드는 약속만 한 항목에선 절대 안 걸려. 비어 있는 게 실패라면 비었는지를 확인해.

Code

바이트가 다른 같은 문자열과 ISO 8601 표기 두 가지·swift
import Foundation

// NFC vs NFD
let composed = "한글"
let decomposed = composed.decomposedStringWithCanonicalMapping
print(composed == decomposed)                              // true
print(Array(composed.utf8) == Array(decomposed.utf8))      // false (6 vs 18 bytes)
let fileKey = decomposed.precomposedStringWithCanonicalMapping   // normalize before bytes matter

// Two valid spellings of time
let native = "2026-09-10T10:45:13.146Z"      // 19:45 in Seoul
let web = "2026-09-10T12:17:29+09:00"        // 12:17 in Seoul
print(native < web)                          // true — text order says the newer one is older

enum CaptureClock {
    // One formatter with fractional seconds refuses "...13Z"; one without refuses "...13.146Z".
    static func instant(_ text: String) -> Date? {
        let fractional = ISO8601DateFormatter()
        fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return fractional.date(from: text) ?? ISO8601DateFormatter().date(from: text)
    }
}

print(CaptureClock.instant(native)! > CaptureClock.instant(web)!)   // true — compare instants, not strings

// Text leaving the device
func stripObjectReplacement(_ text: String) -> String {
    String(String.UnicodeScalarView(text.unicodeScalars.filter { !(0xFFF9...0xFFFC).contains($0.value) }))
}

External links

Exercise

Spark에 CaptureClock을 넣어. 하나는 ISO 8601 표기 두 가지를 다 읽는 함수, 다른 하나는 최신 항목이 먼저 오고 파싱 안 되는 건 맨 끝으로 보내는 비교 함수야. 코드 블록의 예시 문자열 둘에 엉터리 문자열 하나를 더해서 테스트를 써. 그 뒤에 제목을 NFC로 정규화하는 fileKey(for:) 함수를 추가하고, 완성형이랑 풀어 쓴 입력이 같은 키를 만든다는 걸 바이트 비교로 증명해봐.
Hint
비교 함수의 네 경우를 명시적으로 다 적어. 둘 다 파싱됨, 왼쪽만, 오른쪽만, 둘 다 안 됨. nil을 대충 흘려보내면 망가진 타임스탬프가 최신순 목록 맨 위로 올라가.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.