Daeseon Yoo
Back to project
·Tech retro·6 min

Non-ASCII Language Traps — Unicode NFC/NFD silent mismatch + short-text fuzzy threshold

ElementMatcher fuzzy 매칭이 한국어 도파민 테스트는 통과했는데 실제 dogfooding에서 실패 — NFC/NFD 정규화 누락 + 짧은 텍스트 threshold 0.7이 너무 관대. 박은 fix: normalize()에 NFC explicit + ≤6자 threshold 0.85 + punctuation strip.

문제 — 무엇이 막혔나, 왜 박아야 했나

Unit test 통과했는데 한국어 dogfooding에서 실패.

ElementMatcher가 "Save" vs "Same" 같은 짧은 영문은 정확하게 차단했지만, 실제 macOS 한글 인터페이스를 테스트하자 "파일" (target) ↔ OCR "파일" (candidate)이 매칭 실패. 같은 텀인데.

근본 원인 2가지:

  1. Unicode NFC ↔ NFD silent mismatch (HIGH severity)

    • Swift String == 는 canonical equivalent (NFC == NFD)를 비교 시점에는 맞춤
    • unicodeScalars 직접 접근 시 NFD/NFC 차이 유지 — normalize() 함수가 unicodeScalars.filter()로 punctuation 제거하면서 이미 NFD인 입력이 그대로 NFD 유지
    • LLM OCR output: "파일" (NFD — ㅍ+ㅏ+ㄹ 자모 분해) vs 사용자 target: "파일" (NFC — 파 + 일 합성 형태)
    • levenshtein() 비교: distance > 0 → 매칭 실패
  2. 짧은 텍스트 fuzzy false positive 너무 관대 (HIGH severity)

    • threshold = 0.7 (기본값) 고정
    • "Save" (4자) vs "Same" (4자): Levenshtein distance = 1, normalized similarity = 0.75 → 통과 위험
    • 한글 1-2자 오타: "닫 + 기" 대신 "닫 + 다" → 0.75 이상 통과 가능
    • 실제 dogfooding: 짧은 메뉴 항목 ("열기", "저장", "삭제" 등)이 OCR noise로 1-2자 손상 → false positive 가능성 높음

Verify phase (4분간 211k tokens 사용한 adversarial synthesis)에서 HIGH로 플래그됨.

결정 분기

3 옵션:

A. 정규화를 LLM 단계에서 처리 + caller책임 전가

B. ⭐ ElementMatcher.normalize() 강화 + length-aware threshold

C. 모든 입력에 대해 threshold 상향 (0.7 → 0.85)

선택: B — normalize() 강화 + conditional short-text threshold.

박힌 거

1. NFC normalization explicit

static func normalize(_ s: String) -> String {
    s.precomposedStringWithCanonicalMapping    // NFC: 한글 자모 합성
        .lowercased()
        .unicodeScalars
        .filter { !CharacterSet.punctuationCharacters.contains($0) }
        .map { String($0) }
        .joined()
        .components(separatedBy: .whitespacesAndNewlines)
        .filter { !$0.isEmpty }
        .joined(separator: " ")
}

핵심: precomposedStringWithCanonicalMapping (NFC) 처음에 호출 → NFD 입력도 NFC로 정규화. unicodeScalars 필터링 후에도 NFC 유지.

2. Length-aware threshold + punctuation strip

// ElementMatcher 상수
static let defaultThreshold: Double = 0.7
static let shortTextThreshold: Double = 0.85    // ≤6자 auto-tighten
 
// fuzzy 단계에서 conditional apply
let effectiveThreshold: Double = (threshold == Self.defaultThreshold && normalizedTarget.count <= 6)
    ? shortTextThreshold
    : threshold

논리:

3. Punctuation 명시 제거

normalize()에 이미 포함:

.unicodeScalars
.filter { !CharacterSet.punctuationCharacters.contains($0) }

".txt" vs "txt", "File:" vs "File" 같은 OCR 오류 흡수 (영문/한글 동일).

4. Confidence tiebreaker

같은 길이 substring 매칭 여러 개일 때:

let substringMatches = effectiveCandidates.filter { box in
    normalize(box.text).contains(normalizedTarget)
}
if let best = substringMatches.sorted(by: { lhs, rhs in
    let lhsLen = normalize(lhs.text).count
    let rhsLen = normalize(rhs.text).count
    if lhsLen != rhsLen { return lhsLen < rhsLen }
    return lhs.confidence > rhs.confidence    // tiebreaker
}).first

가장 짧은 (specific) 매칭 우선, 같으면 confidence 높은 박스 선택.

비용

패턴 — Transferable lesson

1. 언어 dogfooding 전에 안 보이는 Unicode trap

2. 짧은 텍스트는 false positive 가능성 높음 (거리 metric 비용-효율 trade-off)

Commit

f08a603779037049fb5226fabe52132ceb0426bc (2026-05-29T22:52:12-0400)

Changed files: