반응형

Swift에서 문자열 비교

 

Swift의 String은 유니코드 확장 문자소(Extended Grapheme Cluster) 를 단위로 동작합니다. 즉, 사람이 인식하는 “문자” 기준으로 비교·탐색·슬라이싱이 설계되어 있습니다. 이 문서는 실무에서 마주치는 문자열 비교 요구사항을 정확성(국제화)  성능(최적화) 관점에서 정리하고, 올바른 API 선택과 예제를 제공합니다.

 

1. 핵심 요약

목적 권장 API/기법 비고
동등성(같은 글자?) == 유니코드 문자소 동등성. 정규화 차이(NFC/NFD)도 동일로 판단
정렬/사전순(논로컬) a < b 또는 a.compare(b) == .orderedAscending 코드 포인트 기반에 가깝고, 사용자 표시용 정렬로는 부적절
사용자 표시용 정렬/검색 localizedStandardCompare, localizedCaseInsensitiveContains Finder 유사, 로캘 민감(공백/기호 처리 포함)
접두/접미 hasPrefix, hasSuffix 대소문자/발음 무시는 range(of:options:) + .anchored/.backwards
부분 포함 contains(간단), range(of:options:)(옵션 필요) 옵션: .caseInsensitive, .diacriticInsensitive, .widthInsensitive 
다수 후보 매칭 Set<String>.contains 선형 == 반복보다 평균 O(1)
바이트 동일성(프로토콜 키 등) data(using:) 후 바이트 비교 또는 utf8.elementsEqual 문자소 동등성이 아닌 바이트 일치가 필요할 때
성능 파이프라인 lazy + 고수준 API 대량 처리에서 중간 문자열 최소화

사람에게 보여주는 정렬/검색은 반드시 현지화 API 를 쓰세요. 단순 lowercased() 후 비교는 다국어에서 버그의 원인입니다(예: 터키어 i).

 

2. 동등 비교(Equality): ==

Swift의 ==  유니코드 정규화가 달라도 같은 문자면 true 입니다.

let nfc = "café"          // U+00E9 (é)
let nfd = "cafe\u{301}"   // 'e' + 결합 악센트 U+0301
print(nfc == nfd)         // true

이 특성 덕분에 사용자 입력의 조합형/분해형 차이를 자동으로 흡수합니다. 반면 바이트 일치가 필요하다면 아래를 사용합니다.

// 바이트 동일성 (네트워크/해시 키/디스크 포맷 등)
let b1 = nfc.utf8.elementsEqual(nfd.utf8)        // false
let d1 = nfc.data(using: .utf8)! == nfd.data(using: .utf8)! // false

 

3. 정렬/비교 순서


3.1 논로컬 정렬(간단, 규칙적)

let a = ["ä", "a", "b"]
print(a.sorted())  // ["a","ä","b"] (플랫폼/버전에 따라 다소 차이 가능)
  • sorted() 는 로캘 규칙을 고려하지 않습니다. 사용자 표시용 목록 정렬에는 적합하지 않습니다.

3.2 현지화 정렬(사용자 친화)

import Foundation

let cities = ["Zürich","Århus","Örebro","Aachen"]
let sortedLocalized = cities.sorted {
    $0.localizedStandardCompare($1) == .orderedAscending
}
print(sortedLocalized) // 로캘에 맞춘 기대 결과
  • localizedStandardCompare 는 Finder 스타일 비교(숫자 묶음, 기호/악센트 처리 등)를 수행합니다.
  • 보다 엄격한 제어가 필요하면 compare(_:options:range:locale:) 를 사용합니다.
let result = "straße".compare("Strasse",
                              options: [.caseInsensitive, .diacriticInsensitive],
                              range: nil,
                              locale: Locale(identifier: "de_DE"))

 

4. 접두/접미/부분 문자열


4.1 기본

let s = "Swift Programming"
s.hasPrefix("Swift")   // true
s.hasSuffix("ming")    // true
s.contains("gram")     // true

4.2 옵션이 필요할 때: range(of:options:)

let text = "Café au lait"
let ok1 = text.range(of: "CAFE", options: [.caseInsensitive, .diacriticInsensitive]) != nil
// 접두/접미(앵커) + 대소문자/악센트 무시
let prefixOK = text.range(of: "café", options: [.caseInsensitive, .diacriticInsensitive, .anchored]) != nil
let suffixOK = text.range(of: "LAIT", options: [.caseInsensitive, .diacriticInsensitive, .backwards, .anchored]) != nil

안티패턴: s.prefix(n) == "…", s.lowercased().hasPrefix("…")

권장: hasPrefix/hasSuffix 또는 range(of:options:) 로 필요한 옵션만 부여

 

5. 사용자 검색(국제화)

UI 검색은 단순 contains 가 아닌 현지화 + 대소문자/악센트 무시가 보통의 기대입니다.

import Foundation

func userContains(haystack: String, needle: String, locale: Locale = .current) -> Bool {
    haystack.range(of: needle,
                   options: [.caseInsensitive, .diacriticInsensitive],
                   range: nil,
                   locale: locale) != nil
}

// Finder 유사 동작
"File (2) copy".localizedStandardContains("file 2")  // true

 

6. 안전/보안 관점

  • 파일 확장자 검사: 대소문자/로캘 차이를 고려합니다.swift extension String { var isImagePath: Bool { let lower = self.lowercased() return lower.hasSuffix(".png") || lower.hasSuffix(".jpg") || lower.hasSuffix(".jpeg") || lower.hasSuffix(".gif") || lower.hasSuffix(".webp") } }
  • CSV/스프레드시트 수식 주입 방지: =,+,-,@  시작하는 셀을 차단swift func isSafeCSVCell(_ s: String) -> Bool { guard let c = s.first else { return true } return !"=+-@".contains(c) }

 

7. 이모지/합성 문자 고려

합성 이모지는 여러 스칼라의 조합입니다. 문자 수(Character.count)  바이트 수가 크게 다를 수 있습니다.

let flag = "🇰🇷"               // 두 문자처럼 보이지만 2개의 지역표시 기호 조합
print(flag.count)              // 1 (문자소 기준)
print(flag.unicodeScalars.count) // 2

접두/접미/슬라이싱은 항상 문자 경계를 유지하는 고수준 API를 쓰세요.

 

8. 성능 팁


8.1 다수 후보 매칭은 Set로 승격

let allowed: Set<String> = ["GET","POST","PUT","DELETE","PATCH","HEAD","OPTIONS"]
func isAllowed(_ m: String) -> Bool { allowed.contains(m) }  // 평균 O(1)

8.2 대량 필터는 조기 종료 가능한 API

let rows = Array(repeating: "USER-abcdef-12345", count: 100_000)
let prefixed = rows.filter { $0.hasPrefix("USER-") } // 빠름

8.3 불필요한 전역 변환 금지

// 🚫 매번 lowercased()는 비용 + 버그(터키어 i)
s.lowercased().contains("abc")

// ✅ 필요한 비교에서만 옵션
s.range(of: "abc", options: .caseInsensitive) != nil

8.4 바이트 수준 비교(정말 필요할 때만)

// API 키, 해시 키 등
func bytesEqual(_ a: String, _ b: String) -> Bool {
    a.utf8.elementsEqual(b.utf8)
}

 

9. Swift Regex와 정확 매치

Swift 5.7+의 정규식은 문자 경계/유니코드 규칙을 따릅니다.

if let _ = try? /^(?i)caf(e|\u{00E9})$/.wholeMatch(in: "Café") {
    // 대소문자 무시 + é 변형 허용
}

정규식은 강력하지만 오용하면 비용이 큽니다. 정확히 필요한 경우에만 사용하세요(검증/파싱 등).

 

10. 샘플 코드

import Foundation

let hay = Array(repeating: "USER-abcdef-12345", count: 200_000)

func measure(_ name: String, _ body: () -> Void) {
    let t1 = CFAbsoluteTimeGetCurrent(); body(); let t2 = CFAbsoluteTimeGetCurrent()
    print(name, ":", t2 - t1, "sec")
}

measure("hasPrefix") { _ = hay.filter { $0.hasPrefix("USER-") } }
measure("prefix+== (anti)") { _ = hay.filter { $0.prefix(5) == "USER-" } }

let words = (0..<100_000).map { _ in "café" }
measure("contains (case-insensitive)") {
    _ = words.filter { $0.range(of: "CAFE", options: [.caseInsensitive, .diacriticInsensitive]) != nil }
}

실제 수치는 Release 빌드 + 실기기에서 측정하세요. 디버그 빌드는 최적화가 꺼져 있어 의미가 없습니다.

 

11. 흔한 안티패턴과 교정

1) 다수 후보를 == 로 선형 스캔  Set.contains

2) 접두/접미 검사에 prefix/suffix 잘라서 비교  hasPrefix/hasSuffix 또는 range(of:options:) + 앵커

3) 전역 lowercased()/uppercased() → 필요 지점에만 옵션 적용 (.caseInsensitive, .diacriticInsensitive)

4) 사용자 검색에 단순 contains  localizedStandardContains 또는 range(of:options:locale:)

5) 바이트 동일성과 문자 동등성 혼동 → 요구사항을 명확히 구분(키/프로토콜/암호학 vs UI)

 

12. 참조 링크

 

13. 결론

  • 동등성 == (문자소 기준), 정렬/검색 현지화 API 를 사용하세요.
  • 접두/접미 hasPrefix/hasSuffix, 옵션이 필요하면 range(of:options:) + 앵커.
  • 다수 후보 Set으로 승격, 바이트 동일성 utf8/Data 로.
  • 성능은 조기 종료 가능한 API  불필요한 변환을 없애고, 실제로 Release 빌드에서 테스트 해야합니다.
반응형
Posted by 까칠코더
,