← 문제로

44. 문자열 비교·정렬·대소문자 변환의 문화권 의존성 (Ordinal vs Culture)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 문자열 비교·정렬·대소문자 변환의 문화권 의존성 (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 같은 확장 등.

.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 명시)을 켜면 대부분 컴파일 타임에 잡힌다.

면접 포인트

  1. ==, Equals, CompareTo, StartsWith, IndexOf 의 기본 비교 방식이 서로 다른 이유와, 그 비대칭이 만드는 실제 버그(터키어 i, 한글 자모 분리, 결합 문자).
  2. 유니코드 대조 알고리즘의 다중 레벨 가중치. 왜 a 와 A 와 á 가 1차 가중치에서 같은가.
  3. Dictionary<string, T> 의 키 비교자 선택이 왜 성능이 아니라 정확성 문제인가. 문화권 비교자를 키에 쓰면 어떤 상황에서 조회가 실패하는가.
  4. 서버 간에 정렬된 목록을 동기화할 때 문화권 정렬을 쓰면 안 되는 이유. 결정론과 재현성.