Swift 문자열에 추가된 텍스트를 추적하려면 기존 문자열 인덱스를 보관하는 대신 이름이 지정된 인코딩의 오프셋을 저장하세요. 이 가이드에서는 간단한 로컬 Mac 텍스트 미리보기 계산을 위해 추가 전용 UTF-16 세그먼트 원장을 만듭니다.
작업은 구체적입니다. 합성 텍스트 조각을 결합하고, 각 조각이 어디에 속하는지 기억한 다음, 나중에 추가 작업을 거친 뒤에도 정확한 내용을 복구합니다. 예제는 전적으로 메모리에서 처리하고 결과를 출력합니다. 세그먼트 경계는 텍스트에 기여한 부분을 나타내며, 화면에 표시되는 문자 사이의 경계와 다를 수 있습니다.
이전 끝 인덱스를 저장하는 방식이 신뢰할 수 없는 이유
흔히 떠올릴 수 있는 방법은 text.endIndex을 저장하고 조각을 추가한 뒤, 저장해 둔 인덱스와 새 text.endIndex을 짝짓는 것입니다. 이는 변경 전 문자열에서 얻은 인덱스와 변경 후 문자열을 섞습니다. Swift의 컬렉션 문서는 변경 작업으로 저장된 인덱스가 무효화될 수 있다고 명시적으로 경고합니다. 짧은 샘플에서 성공했다고 해서 이후 변경에도 일반적으로 안전하다는 보장은 없습니다. Apple의 Collection 문서를 참조하세요.
인덱스를 다시 구성하더라도 두 번째 문제가 있습니다. 텍스트를 추가하면 결합 지점의 문자 경계가 바뀔 수 있습니다. Swift 언어 가이드에서는 "cafe" 뒤에 "\u{0301}"(결합형 악센트)를 붙이는 예를 보여 줍니다. 악센트가 앞 글자에 결합하므로 결과 텍스트는 여전히 Swift Character 값 네 개로 이루어집니다. Strings and Characters를 참조하세요.
따라서 기존 문자 수를 기록한 다음 완성된 문자열에서 그만큼 이동해도 추가된 조각의 시작을 안정적으로 복구할 수 없습니다. 악센트 예에서 추가된 부분은 최종 자소 클러스터 안에서 시작합니다. 이 기여 부분에 해당하는 별도의 표시 문자는 없습니다.
추가 경계와 문자 경계를 서로 다른 개념으로 다루세요. 전자는 텍스트가 어떻게 조립되었는지를 기록하고, 후자는 Swift가 조립된 텍스트를 문자 단위 작업을 위해 어떻게 묶는지를 설명합니다.
원장에 어떤 좌표를 저장해야 할까요?
이 Mac 계산에서는 정수 UTF-16 오프셋과 길이를 사용합니다. Apple 문서에 따르면 NSString은 문자열 위치와 범위를 UTF-16 코드 단위로 표현합니다. Swift의 String.utf16 뷰는 이에 대응하는 코드 단위를 제공하므로, 원장은 측정과 Foundation 부분 문자열 추출에 동일한 좌표계를 사용할 수 있습니다. NSString과 String의 UTF-16 뷰를 참조하세요.
| 읽기 작업 | 적절한 표현 | 조건 |
|---|---|---|
| 원래의 각 조각을 개별적으로 복구 | 소유된 String 값의 배열 |
결합된 텍스트의 좌표가 필요하지 않음 |
| 결합된 텍스트에서 정확한 기여 부분 찾기 | UTF-16 오프셋과 길이 | 앞선 텍스트가 변경되지 않음 |
| 표시 문자 단위로 작업 | 현재 문자열에서 새로 얻은 문자 인덱스 | 세그먼트 경계를 자소 클러스터 경계로 다시 검토해야 함 |
| 임의의 편집에도 위치 유지 | 편집을 인식하는 위치 모델 | 이 추가 전용 원장으로는 충분하지 않음 |
아래 원장에는 명시적인 규칙 네 가지가 있습니다.
- 빈 문자열에서 시작합니다.
- 텍스트 변경은 완전한 Swift
String을 추가하는 작업뿐입니다. - 추가할 때마다 이전 UTF-16 합계와 조각의 UTF-16 길이를 기록합니다.
- 세그먼트 번호는 그 번호를 생성한 원장 인스턴스 또는 스냅샷과 함께 사용해야 합니다.
이 규칙 덕분에 오프셋을 유용하게 쓸 수 있습니다. 접미 문자열을 추가해도 앞선 인코딩 내용은 그대로 유지되므로 이전 구간의 위치도 유지됩니다. 앞선 텍스트를 삽입, 제거, 대체 또는 변환하려면 다른 계약이 필요합니다. 이는 Swift가 자동으로 제공하는 위치 추적 기능이 아니라 문서화된 문자열 표현을 바탕으로 한 설계입니다.
완전한 로컬 세그먼트 원장 만들기
Foundation을 사용할 수 있는 macOS Swift 명령줄 대상을 사용하세요. 아래의 전체 프로그램으로 샘플 최상위 프로그램을 바꾸세요. 구현에서는 Apple 문서에 Swift 시그니처가 다른 String을 받는다고 나와 있는 append(contentsOf:)를 사용합니다. Apple의 append(contentsOf:) 참조 문서를 참조하세요.
import Foundation
struct Segment: Sendable {
let utf16Location: Int
let utf16Length: Int
var foundationRange: NSRange {
NSRange(location: utf16Location, length: utf16Length)
}
}
enum LedgerError: Error {
case unknownSegment(Int)
}
struct SegmentLedger: Sendable {
private(set) var text = ""
private(set) var segments: [Segment] = []
private var utf16Total = 0
@discardableResult
mutating func append(_ fragment: String) -> Int {
let length = fragment.utf16.count
let (newTotal, overflow) =
utf16Total.addingReportingOverflow(length)
precondition(!overflow, "UTF-16 total exceeds Int capacity")
let segment = Segment(
utf16Location: utf16Total,
utf16Length: length
)
text.append(contentsOf: fragment)
segments.append(segment)
utf16Total = newTotal
return segments.count - 1
}
func fragment(at segmentID: Int) throws -> String {
guard segments.indices.contains(segmentID) else {
throw LedgerError.unknownSegment(segmentID)
}
let segment = segments[segmentID]
if segment.utf16Length == 0 {
return ""
}
return (text as NSString).substring(
with: segment.foundationRange
)
}
}
let inputs = ["cafe", "\u{0301}", "", "\u{1F7E6}", " done"]
var ledger = SegmentLedger()
for input in inputs {
ledger.append(input)
}
let expectedLocations = [0, 4, 5, 5, 7]
let expectedLengths = [4, 1, 0, 2, 5]
for id in inputs.indices {
let recovered = try ledger.fragment(at: id)
let segment = ledger.segments[id]
// Compare code units, so the check is encoding-exact.
precondition(Array(recovered.utf16) == Array(inputs[id].utf16))
precondition(segment.utf16Location == expectedLocations[id])
precondition(segment.utf16Length == expectedLengths[id])
print("\(id): location=\(segment.utf16Location), " +
"length=\(segment.utf16Length)")
}
precondition(ledger.text.utf16.count == 12)
precondition(ledger.text.count == 10)
// Later appends must preserve earlier segment contents.
ledger.append("!")
for id in inputs.indices {
let recovered = try ledger.fragment(at: id)
precondition(Array(recovered.utf16) == Array(inputs[id].utf16))
}
// Invalid segment numbers produce an explicit error.
do {
_ = try ledger.fragment(at: -1)
preconditionFailure("Expected an unknown-segment error")
} catch LedgerError.unknownSegment(let id) {
precondition(id == -1)
}
print("All segment checks passed.")
이 프로그램은 가져온 API 문서에 비추어 검토했지만, 사용 가능한 셸에 Swift 실행 파일이 없어 여기서는 실행하지 않았습니다. Mac에서 실행하면 포함된 사전 조건을 통해 샘플이 예상하는 동작을 명확히 확인할 수 있습니다.
샘플이 확인하도록 설계된 내용
예상 세그먼트 좌표는 리터럴 입력값을 기준으로 계산됩니다. 결합형 악센트는 UTF-16 코드 단위 하나를, 빈 조각은 0을, 파란색 정사각형은 2를 차지합니다. 마지막 접미 문자열은 5를 차지합니다. 이는 입력값을 바탕으로 한 예시 계산이며 성능 측정값은 아닙니다.
| 세그먼트 | 입력값 | UTF-16 위치 | UTF-16 길이 |
|---|---|---|---|
| 0 | "cafe" |
0 | 4 |
| 1 | "\u{0301}" |
4 | 1 |
| 2 | "" |
5 | 0 |
| 3 | "\u{1F7E6}" |
5 | 2 |
| 4 | " done" |
7 | 5 |
빈 조각은 다음 세그먼트와 같은 위치를 차지하더라도 고유한 세그먼트 번호를 받습니다. 이렇게 하면 텍스트를 만들어 내지 않으면서 추가 이력을 보존합니다. 이 경우 ""을 명시적으로 반환하면 Foundation 부분 문자열 호출도 피할 수 있습니다.
복구 검사는 의도적으로 UTF-16 배열을 비교합니다. Swift 문자열 동등성은 표준 동등성을 인식하므로, 내부 스칼라 시퀀스가 서로 다른 일부 문자열도 같다고 비교됩니다. 이 원장의 계약에는 인코딩 단위까지 정확히 일치하는 검사가 더 적합합니다. Strings and Characters의 문자열 비교 설명을 참조하세요.
추출에 Foundation을 사용하는 이유
저장된 정수를 NSRange로 변환해 NSString.substring(with:)에 직접 전달합니다. Apple 문서에 따르면 이 메서드는 지정된 범위를 추출하며, 해당 범위가 수신자의 경계 안에 있어야 합니다. 잘못된 범위는 예외를 일으킬 수 있습니다. 원장이 텍스트와 구간을 모두 관리하며 추출 전에 알 수 없는 세그먼트 번호를 거부합니다. substring(with:)를 참조하세요.
기록된 각 경계는 완전한 Swift 문자열을 추가한 결과입니다. 예제에서는 파란색 정사각형의 UTF-16 서로게이트 쌍 중간에 오프셋을 만들지 않습니다. 다만 악센트 예에서 보듯 경계가 결합 문자의 시퀀스 중간에 놓일 수는 있습니다. 이 표현은 기여 부분을 정확히 복구하기 위한 것입니다.
이 Foundation 범위를 만들 때 String.count를 사용하지 마세요. Apple은 NSString.length를 UTF-16 코드 단위 수로 정의합니다. 이는 문자 수와 다른 측정값이며 렌더링된 너비를 나타내지 않습니다. NSString.length를 참조하세요.
변경, 세그먼트 생성, 스냅샷을 함께 유지하기
작고 동기적으로 처리되는 계산이라면 값 타입만으로 충분합니다. 한 소유자가 조각을 추가하고 결과를 읽습니다. 비공개 설정자를 사용하면 호출자가 세그먼트 테이블과 별개로 결합된 텍스트를 바꾸지 못합니다. 캐시된 UTF-16 합계는 추가할 때마다 커지는 결합 문자열을 다시 세는 작업도 피하게 해 줍니다.
여러 비동기 작업이 하나의 원장에 기여해야 한다면 원장을 단일 액터 뒤에 두거나, Mac 인터페이스에 속한 경우 @MainActor에서 유지하세요. 동기화 격리된 하나의 메서드 안에서 추가 작업과 메타데이터 갱신을 수행합니다. Swift 문서에 따르면 액터 격리된 동기 작업은 해당 메서드 안의 중단 지점에서 끊기지 않습니다. Concurrency를 참조하세요.
텍스트와 세그먼트 메타데이터를 하나의 값 스냅샷으로 함께 반환하세요. 텍스트와 세그먼트 테이블을 별도로 기다려 읽지 마세요. 두 호출 사이에 다른 추가 작업이 발생할 수 있습니다. 예제의 Sendable 선언은 격리 경계를 넘어 전달하기 적합한 값을 나타냅니다. 공유 변수에 대한 동시 쓰기를 직렬화하지는 않습니다. Swift의 Sendable 문서는 전달 가능한 값과 보호되는 변경 가능 상태의 차이를 설명합니다.
기여 순서가 중요하다면 추가하기 전에 순서를 정하세요. 액터는 원장 내부의 일관성을 보호하지만, 어떤 조각을 먼저 둘지는 애플리케이션이 결정해야 합니다.
이 오프셋으로 더 이상 작업을 해결할 수 없는 경우 알기
구간을 재사용하기 전에 다음 순서로 확인하세요.
- 소유권 확인. 세그먼트 번호가 이 원장 또는 호환되는 추가 전용 파생 원장에 속하나요?
- 변경 이력 확인. 기록된 구간보다 앞선 내용이 바뀌었나요? 그렇다면 원장을 다시 만들거나 편집을 인식하는 모델을 사용하세요.
- 단위 확인. 사용하는 쪽의 좌표가 UTF-16 코드 단위인가요? 문자 수나 UTF-8 바이트 수로 대체하지 마세요.
- 원하는 결과 확인. 추가된 기여 부분을 정확히 복구하려는 건가요, 아니면 표시 문자 하나 전체를 선택하려는 건가요? 두 작업에 필요한 경계는 다를 수 있습니다.
문자 단위 선택으로는 추가된 기여 부분 하나만 항상 표현할 수 있는 것은 아닙니다. 악센트는 앞 글자와 같은 표시 문자에 속합니다. 데스크톱 작업에서 강조 표시를 하려면 명시적인 표시 규칙을 선택하세요. 선택 영역을 포함된 문자 전체로 확장하거나 기여 부분을 세그먼트 목록에 별도로 표시할 수 있습니다. 확장하면 앞선 세그먼트의 텍스트도 포함되므로, 정확한 복구를 위해 원래 오프셋을 유지하세요.
정확한 추가 추적에 필요한 불변 조건은 간단합니다. 앞부분을 변경하지 않고, 좌표 단위에 이름을 붙이며, 각 구간을 텍스트와 함께 보관하세요. 이 계약 아래에서는 변경 작업을 거쳐도 단 하나의 String.Index를 유지하지 않고 이전 기여 부분을 복구할 수 있습니다.
출처
Collection — Apple.
Strings and Characters — Swift.org.
String — Apple.
NSString — Apple.
append(contentsOf:) — Apple.
substring(with:) — Apple.
length — Apple.
Concurrency — Swift.org.
Sendable — Swift.org.