클라우드 Mac에서 iOS 텍스트 강조의 Unicode 경계 검증

클라우드 Mac에서 iOS 텍스트 강조의 Unicode 경계 검증

클라우드 Mac에서 iOS 텍스트 강조의 Unicode 경계 검증

검색 결과에 표시된 이름이 영문일 때는 괜찮다가도, 발음 구별 기호가 붙은 문자나 피부색이 지정된 이모지에서는 강조 표시가 어긋나는 경우가 있습니다. 대개 글꼴 문제가 아니라 범위를 주고받는 규약의 문제입니다. 서버는 바이트 오프셋을 반환하고, UI는 UTF-16 오프셋을 받는데, 비즈니스 로직은 Swift의 문자 수를 기준으로 문자열을 자르는 식입니다. 클라우드 Mac에서 먼저 순수 텍스트 검사를 한 차례 실행하면 원격 데스크톱을 반복해서 조작하는 것보다 원인을 찾기 쉽습니다.

이 방법은 검색 결과 요약, 서식 있는 텍스트의 마킹, 채팅 내용의 선택 영역에 적용할 수 있습니다. 이미 설치된 Xcode 명령줄 도구만 있으면 되며, 네트워크 서비스는 필요하지 않습니다. XcNode의 전용 물리 노드에서도 테스트 입력과 도구 체인 기록을 함께 보관해야 합니다.

먼저 오프셋 단위를 명확히 정하기

Swift의 String.count는 확장 자소 클러스터, 즉 Character의 개수를 셉니다. utf16.count는 UTF-16 코드 단위의 개수를, utf8.count는 바이트 수를 셉니다. 이 세 가지는 서로 바꿔 쓸 수 없습니다. 이모지 하나가 기본 이모지, 피부색 수식자, 폭이 없는 결합자로 구성될 수 있으므로, 눈에는 한 문자로 보여도 내부적으로는 여러 코드 단위를 차지합니다.

Foundation의 NSRange는 문자열 API에서 보통 UTF-16 단위를 사용합니다. text.count를 유효한 범위의 상한으로 삼거나, 정수로 String.Index를 직접 이동시키면 안 됩니다. API 문서에는 최소한 단위, 원문 버전, 빈 범위의 처리 정책, 일치하는 결과가 없을 때의 표현 방식을 명시해야 합니다.

변환에 성공했다는 것은 인덱스를 만들 수 있다는 뜻일 뿐, 범위의 양 끝이 사용자에게 보이는 온전한 문자의 경계에 놓인다는 보장은 아닙니다. 강조 표시에는 경계를 확인하는 두 번째 검증이 필요합니다.

원문이 바뀌면 기존 범위는 무효로 처리해야 합니다. 치환, 대소문자 변환, 삭제를 거친 새 문자열에 이전 검색 결과의 범위를 적용하지 마세요.

짧은 스크립트로 문자 중간에서 잘린 범위 거부하기

다음 내용을 highlight-check.swift로 저장합니다. 먼저 결과 없음과 범위 초과를 처리하고, 인덱스로 변환한 다음, 양 끝이 Character 경계인지 확인합니다. 길이를 뺄셈으로 검사하면 시작 위치와 길이를 먼저 더할 때 발생할 수 있는 정수 오버플로도 피할 수 있습니다.

import Foundation

func highlightRange(
    _ value: NSRange,
    in text: String
) -> Range<String.Index>? {
    let total = text.utf16.count
    guard value.location != NSNotFound,
          value.location >= 0,
          value.length >= 0,
          value.location <= total,
          value.length <= total - value.location,
          let range = Range(value, in: text) else {
        return nil
    }

    var boundaries = Set(text.indices)
    boundaries.insert(text.endIndex)
    guard boundaries.contains(range.lowerBound),
          boundaries.contains(range.upperBound) else {
        return nil
    }
    return range
}

let text = "A👩🏽‍💻e\u{301}Z"
let emoji = (text as NSString).range(of: "👩🏽‍💻")
precondition(highlightRange(emoji, in: text) != nil)
precondition(highlightRange(NSRange(location: 1, length: 1),
                           in: text) == nil)

let accent = "e\u{301}"
precondition(highlightRange(NSRange(location: 0, length: 1),
                           in: accent) == nil)
precondition(highlightRange(NSRange(location: 0, length: 2),
                           in: accent) != nil)
precondition(highlightRange(NSRange(location: NSNotFound, length: 0),
                           in: text) == nil)
precondition(highlightRange(NSRange(location: text.utf16.count,
                                   length: 1),
                           in: text) == nil)

xcrun swift highlight-check.swift를 실행했을 때 종료 코드가 0이면 이 검증 조건들을 통과한 것입니다. 실패하면 실행이 중단됩니다. 검사를 비활성화하는 최적화 옵션은 사용하지 마세요. 정식 테스트 타깃에 넣을 때는 실패한 입력을 쉽게 찾을 수 있도록 샘플 번호가 포함된 테스트 검증문으로 바꿉니다.

여기서는 편집기의 삽입 위치에 필요할 수 있으므로 문자 경계에 놓인 빈 범위를 허용합니다. 검색 강조에만 사용하는 호출 측에서는 반복 검색이 앞으로 진행하지 못하는 일을 막기 위해 길이가 0인 일치 결과를 별도로 거부해야 합니다.

검색할 때 원문의 좌표 유지하기

발음 구별 기호를 무시하려고 먼저 이를 제거한 사본을 만든 뒤, 사본의 오프셋을 원문에 적용하는 것은 흔한 실수입니다. 예를 들어 발음 구별 기호가 포함된 사전 조합 문자와, 문자 뒤에 결합형 발음 구별 기호를 붙인 형태는 같은 글자를 나타내더라도 UTF-16 길이가 같지 않을 수 있습니다.

제품 요구사항상 대소문자나 발음 구별 기호를 무시해야 한다면 원문에서 직접 검색할 수 있습니다. 그러면 반환된 범위가 여전히 원래 문자열을 기준으로 합니다.

let source = "Cafe\u{301}"
let match = source.range(
    of: "cafe",
    options: [.caseInsensitive, .diacriticInsensitive],
    locale: Locale(identifier: "en_US_POSIX")
)
guard let match else {
    fatalError("Expected a search match")
}
let nsRange = NSRange(match, in: source)
precondition(highlightRange(nsRange, in: source) != nil)
precondition(String(source[match]) == source)

로캘을 고정한 것은 이 샘플의 재현성을 확보하기 위해서이지, 모든 언어의 검색에 적용할 수 있는 정답이기 때문은 아닙니다. 이름 검색에서 발음 구별 기호를 무시할지, 튀르키예어의 대소문자를 어떻게 처리할지는 제품 요구사항으로 정한 뒤 각각의 샘플을 추가해야 합니다. 단어 분리나 음역이 필요하다면 원문과의 명시적인 매핑을 유지해야 하며, 길이를 빼서 위치를 추측해서는 안 됩니다.

실패한 샘플을 회귀 테스트의 필수 통과 조건으로 만들기

최소한 일반적인 한자, 서로게이트 쌍, 결합형 발음 구별 기호, 피부색이 지정된 이모지, 결합자로 연결된 이모지, 국기 시퀀스를 포함해야 합니다. 각 유형에서 전체가 일치하는 경우와 내부에서 잘리는 경우를 모두 검사하고, 시작과 끝의 빈 범위, 끝을 초과하는 범위, 결과가 없는 경우도 추가합니다.

모든 유효한 범위에 대해 왕복 변환을 검사합니다. Swift 범위로 변환한 다음 NSRange(range, in: text)로 다시 변환했을 때 결과가 같아야 합니다. 실제로 선택된 텍스트도 검증해야 “범위는 유효하지만 잘못된 위치에 일치한” 경우를 놓치지 않습니다.

긴 텍스트에서는 일치 항목마다 모든 경계를 다시 훑는 방식이 적절하지 않습니다. 동일한 불변 원문에 대해 경계 집합을 한 번만 만들고 여러 일치 항목에서 공유할 수 있습니다. 텍스트가 갱신되면 집합을 다시 생성합니다. 서로 다른 문자열 사이에서 캐시를 재사용해서는 안 됩니다.

UI 검수는 표시 계층의 검증만 보완하기

순수 텍스트 검사를 통과한 다음 시뮬레이터에서 고정된 검색 샘플을 엽니다. 강조 표시가 발음 구별 기호를 분리하지 않는지, 이모지 시퀀스의 일부에만 색이 적용되지 않는지, 줄바꿈 뒤에도 표시가 원문과 일치하는지 확인합니다. 시각적 검사는 렌더링 문제를 확인하는 용도이며, 범위 검증을 대신할 수는 없습니다.

비동기 검색 결과가 돌아오면 먼저 요청에 해당하는 원문 버전을 확인한 뒤 강조 표시를 적용합니다. 입력란의 내용이 이미 바뀌었다면 이전 결과를 버립니다. 검색 범위와 최종 표시 범위도 구분해야 합니다. 표시 계층에서 온전한 문자 전체로 범위를 확장해야 하더라도, 비즈니스 판단에는 원래 일치 결과를 보존하고 확장된 범위는 그리기에만 사용합니다.

최종 통과 기준은 “크래시가 나지 않는다”가 아닙니다. 단위가 명확하고, 범위가 유효하며, 문자 경계가 온전하고, 일치한 텍스트가 정확해야 합니다. 실제로 실패했던 문자열을 고정 샘플에 추가해 두면 다음에 검색 알고리즘을 수정할 때 어떤 동작이 달라졌는지 바로 알 수 있습니다.

자주 묻는 질문

NSRange 변환이 성공하면 안전하게 강조할 수 있나요?

아닙니다. text.indices와 endIndex를 사용해 범위 양끝이 완전한 Character의 경계인지도 검사해야 합니다.

악센트를 제거한 문자열의 오프셋을 재사용해도 되나요?

바로 재사용하면 안 됩니다. 변환 과정에서 UTF-16 길이가 달라질 수 있으므로 원문에서 검색하거나 명시적인 인덱스 대응표를 유지해야 합니다.

개발 작업에 적합한 전용 클라우드 Mac 선택하기

Apple Silicon 기반 물리 노드로, 가상 머신이 아닙니다. 일·주·월·분기 단위로 대여할 수 있으며, 모델과 요금은 공개되어 있습니다. 실제 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.

클라우드 Mac 대여하기 모델 및 대여 기간 보기