44. 문자열 비교·정렬·대소문자 변환의 문화권 의존성 (Ordinal vs Culture)
난이도 중해설 — 문자열 비교·정렬·대소문자 변환의 문화권 의존성 (Ordinal vs Culture)
난이도: 중상
요약
.NET 의 문자열 API 는 기본값이 문화권 의존(culture-sensitive) 인 것과 서수(ordinal) 인 것이
섞여 있다. == 와 Equals(string) 은 서수, CompareTo·Compare·StartsWith·IndexOf·
ToLower/ToUpper 는 현재 문화권을 따른다. 이 비대칭 때문에 "로컬에서는 되는데 특정 지역
서버에서만 깨지는" 버그가 생긴다. 대표 사례가 터키어의 점 없는 i 문제다.
핵심 개념
두 가지 비교 방식
- Ordinal: UTF-16 코드 유닛 값을 그대로 비교한다. 문화권과 무관하고, 빠르며, 결정론적이다. "같은 바이트열인가"를 묻는다.
- Culture-sensitive (linguistic): 유니코드 대조 알고리즘(UCA)에 따라 정렬 키(sort key) 를
만들어 비교한다. 다중 레벨 가중치를 쓴다.
- 1차(primary): 기본 문자.
a와á와A가 같은 가중치. - 2차(secondary): 발음 구별 기호.
a<á. - 3차(tertiary): 대소문자.
a<A. - 문화권별 예외 규칙: 터키어의
i/I, 덴마크어의aa=å, 헝가리어의dzs같은 확장 등.
- 1차(primary): 기본 문자.
.NET 5 이후 대부분의 플랫폼에서 문화권 비교는 ICU 를 사용한다(그 이전 Windows 는 NLS). 런타임 버전과 OS 에 따라 결과가 달라질 수 있다는 점 자체가 문화권 비교의 위험 요소다.
터키어의 i 문제
터키어 알파벳은 점 있는 i 와 점 없는 ı 를 다른 글자로 취급한다.
I(U+0049) ↔ı(U+0131) 이 대소문자 쌍İ(U+0130) ↔i(U+0069) 이 대소문자 쌍
따라서 터키어 문화권에서 "I".ToLower() 는 "i" 가 아니라 "ı" 다.
단계별 내부 동작 — 출력 예측
A: a.ToLower() == b
ToLower() 는 현재 문화권을 따른다. == 는 서수 비교다.
- en-US:
"INDIGO".ToLower()→"indigo"→A: True - tr-TR:
"INDIGO".ToLower()→"ındıgo"(점 없는 ı 두 개) →A: False
문화권 의존 변환 결과를 서수로 비교하는, 최악의 조합이다.
B: OrdinalIgnoreCase
서수 비교 + ASCII/유니코드 단순 케이스 폴딩. 문화권과 무관.
- en-US, tr-TR 모두
B: True
C: CurrentCultureIgnoreCase
문화권 규칙으로 대소문자를 무시한 언어적 비교.
- en-US:
C: True - tr-TR:
I와i는 케이스 쌍이 아니므로C: False
D: cmd.ToLower().StartsWith("file")
ToLower() 도 StartsWith(string) 도 둘 다 문화권 의존이다.
- en-US:
"file_upload".StartsWith("file")→D: True - tr-TR:
"FILE_UPLOAD".ToLower()→"fıle_upload"→D: False
명령어·프로토콜 토큰 파싱을 문화권 의존 API 로 하면 안 되는 이유를 그대로 보여준다. 정확히 이 패턴이 과거 여러 제품에서 "터키어 로캘에서 로그인 불가" 버그를 만들었다.
E: string.Compare vs string.CompareOrdinal
"co-op" vs "coop".
string.Compare(s1, s2): 문화권 비교. 하이픈은 구두점으로 별도 가중치를 받아 정렬 키가 달라진다. .NET 5+ 의 ICU 기본 설정(alternate = non-ignorable)에서는 구두점이 문자보다 앞서 정렬되어 음수(보통-1)다. 반면 레거시 NLS 워드 정렬(.NET Framework 또는System.Globalization.UseNls=true)에서는 하이픈이 1차 가중치에서 무시되어 부호가 양수로 뒤집힌다. 즉 반환값의 크기뿐 아니라 부호까지 런타임·플랫폼에 의존한다.string.CompareOrdinal(s1, s2):'-'(U+002D=45) 와'o'(U+006F=111) 의 코드 유닛 차이를 그대로 반환하는 구현이라-66같은 값이 나온다. 이 값도 문서상 보장되지 않는다.
교훈: 비교 함수의 반환값을 부호가 아닌 구체적 숫자로 비교하지 말 것. 그리고 문화권 비교와 서수 비교는 순서 자체가 뒤집힐 수 있으므로, 정렬 결과를 두 방식 사이에 섞어 쓰면 안 된다.
F: List<string>.Sort()
Comparer<string>.Default → string.CompareTo → 현재 문화권 비교.
- en-US(그리고 tr-TR 도 이 입력에서는 동일): 1차 가중치로
a/Á가 같은 그룹이므로F: apple,Ápple,Banana,Zulu - 만약
StringComparer.Ordinal로 정렬했다면 코드 유닛 순서 (B=66 <Z=90 <a=97 <Á=193)로Banana,Zulu,apple,Ápple
같은 데이터가 정렬 방식에 따라 완전히 다른 순서가 된다. 서버 A 와 서버 B 의 로캘이 다르면 정렬된 랭킹·목록 동기화가 어긋나고, 이진 탐색이나 병합 로직이 조용히 깨진다.
흔한 오해·함정
- "
==도 문화권을 따를 것이다" — 아니다.==와Equals(string)은 서수다. 반면CompareTo,Compare,StartsWith,EndsWith,IndexOf(문자열 인자),ToLower,ToUpper,Sort는 문화권 의존이다. 이 비대칭이 혼란의 근원이다.- 예외적으로
IndexOf(char)는 서수다.IndexOf(string)은 문화권 의존이다. 이 둘이 다른 결과를 내는 것이 대표적 함정이다.
- 예외적으로
ToLower()로 정규화 후 비교 — 문화권 의존이라 위험하고, 불필요한 할당도 생긴다.string.Equals(a, b, StringComparison.OrdinalIgnoreCase)가 정답이다. 문화권 무관 소문자화가 꼭 필요하면ToLowerInvariant()를 쓴다.Dictionary<string, T>의 기본 비교자 — 기본은EqualityComparer<string>.Default이고 이는 서수다. 대소문자 무시가 필요하면StringComparer.OrdinalIgnoreCase를 명시적으로 생성자에 넘긴다.StringComparer.CurrentCultureIgnoreCase를 키 비교자로 쓰면 프로세스의 로캘이 바뀌거나 다른 서버와 데이터를 주고받을 때 해시/동등성이 어긋나 조회 실패나 중복 키가 생긴다. 영속화·직렬화되는 키에 문화권 비교자를 쓰는 것은 사실상 항상 버그다.- 유니코드 정규화와 별개 —
"é"(U+00E9)와"é"(결합 문자)은 서수 비교로는 다르다. 문화권 비교는 같다고 본다. 캐릭터명 중복 검사라면 서수만으로 부족하고string.Normalize(NormalizationForm.FormC)로 정규화한 뒤 서수 비교해야 한다. 여기에 혼동 문자(homoglyph) 대책까지 필요하면 별도 정책이 있어야 한다. - 보안 판정에 문화권 비교 금지 — 경로·확장자·명령어·권한 문자열 검사는 반드시 서수. 문화권 비교는 서로 다른 바이트열을 같다고 판정할 수 있어 우회 통로가 된다.
실무 규칙
| 용도 | 권장 |
|---|---|
| 식별자·코드·명령어·프로토콜 토큰 | StringComparison.Ordinal |
| 대소문자 무시 식별자(아이템 코드 등) | StringComparison.OrdinalIgnoreCase |
| 사용자에게 보여줄 목록 정렬 | StringComparer.CurrentCulture |
| 서버 간·저장소에 남는 정렬 순서 | StringComparer.Ordinal (재현 가능해야 하므로) |
| 캐릭터명 중복 검사 | 정규화(FormC) + OrdinalIgnoreCase + 도메인 정책 |
Dictionary/HashSet 키 |
StringComparer.Ordinal(IgnoreCase) 명시 |
List<T>.Sort() 뿐 아니라 Array.Sort, OrderBy, SortedDictionary, SortedSet 도 모두
Comparer<string>.Default(= 문화권 비교)를 기본으로 쓴다. 저장·전송되는 순서라면 전부
StringComparer.Ordinal 을 명시적으로 넘겨야 한다.
분석기 규칙 CA1307(비교에 StringComparison 지정), CA1309(Ordinal 사용), CA1310
(StringComparison 명시)을 켜면 대부분 컴파일 타임에 잡힌다.
면접 포인트
==,Equals,CompareTo,StartsWith,IndexOf의 기본 비교 방식이 서로 다른 이유와, 그 비대칭이 만드는 실제 버그(터키어 i, 한글 자모 분리, 결합 문자).- 유니코드 대조 알고리즘의 다중 레벨 가중치. 왜
a와A와á가 1차 가중치에서 같은가. Dictionary<string, T>의 키 비교자 선택이 왜 성능이 아니라 정확성 문제인가. 문화권 비교자를 키에 쓰면 어떤 상황에서 조회가 실패하는가.- 서버 간에 정렬된 목록을 동기화할 때 문화권 정렬을 쓰면 안 되는 이유. 결정론과 재현성.