toLowerCase로 대소문자 무시 비교가 틀리는 이유: 유니코드 케이스 폴딩
'straße'와 'STRASSE'는 toLowerCase()로 비교하면 다릅니다. 대소문자 변환과 케이스 폴딩의 차이, 터키어 i, 정규식 /iu의 단순 폴딩, 정규화와 함께 비교하는 법을 Unicode 17.0과 ECMAScript 2026 기준으로 정리했습니다.
대소문자를 무시하고 문자열을 비교할 때 흔히 이렇게 씁니다.
a.toLowerCase() === b.toLowerCase()
한글에는 대소문자가 없지만 사용자 이름이나 태그에는 라틴 문자가 섞여 들어옵니다. straße와 STRASSE를 넣으면 false가 나옵니다. STRASSE는 strasse가 되는데 ß는 이미 소문자라 그대로이기 때문입니다. 둘 다 대문자로 바꾸면 ß가 SS가 되어 같아집니다. 방향에 따라 답이 바뀐다면 비교에 맞는 연산이 아닙니다.
케이스 폴딩
유니코드 표준 3.13절은 대소문자 변환과 케이스 폴딩을 구분합니다. 폴딩의 주된 목적은 대소문자를 무시한 비교입니다. 기본 케이스 폴딩은 CaseFolding.txt에서 상태가 C와 F인 전체(full) 매핑을 쓰며, 여기서 ß는 ss로 접힙니다. Python의 str.casefold()가 이 연산으로, 'straße'.casefold()는 'strasse'입니다. ECMAScript에는 유니코드의 전체 기본 케이스 폴딩을 구현하는 내장 String.prototype 메서드가 없습니다.
터키어 i
터키어와 아제르바이잔어에서는 ı와 I, i와 İ가 짝이고, SpecialCasing.txt는 이를 tr, az 조건으로 둡니다. Java의 toLowerCase()는 기본 로캘을 따릅니다. 공식 문서는 터키어 로캘에서 "TITLE".toLowerCase()가 "tıtle"이 된다고 경고하고, 식별자나 프로토콜 키에는 Locale.ROOT를 쓰라고 권합니다. JavaScript의 toLowerCase()는 로캘과 무관하며, toLocaleLowerCase("tr")를 써야 "tıtle"이 나옵니다.
정규식 /iu
i에 u나 v 플래그를 더하면 ECMAScript는 각 문자를 CaseFolding.txt의 단순(simple) 또는 공통(common) 매핑, 즉 상태 S와 C로 바꾼 뒤 비교합니다. 이 매핑은 항상 한 코드 포인트를 돌려주므로 ß는 ss가 되지 않고, 켈빈 기호 K(U+212A)는 k로 접힙니다. 그래서 /^straße$/iu.test("STRASSE")는 false, /^k$/iu.test("\u212A")는 true입니다.
정규화
Å(U+00C5)와 A+U+030A는 폴딩하면 각각 å와 a+U+030A가 되고, 폴딩은 정규화 형태를 보존하지 않습니다. 그래서 표준은 정규 대소문자 무시 일치(D145)를 NFD(toCasefold(NFD(X)))끼리 비교하는 것으로 정의합니다. NFC와 NFD는 글자 수가 도구마다 다른 이유에서 다뤘습니다.
표준 5.18절은 원문은 그대로 두고 폴딩한 값을 따로 저장해 비교에 쓰는 방식을 설명합니다. 위 실행 결과는 Unicode 16.0 데이터를 쓰는 Node.js 24.13.0과 Python 3.14.7에서 확인했고, 여기 쓴 매핑은 Unicode 17.0에서도 같습니다.
참고
- 유니코드 표준 17.0, 3.13절 Default Case Algorithms
- 유니코드 표준 17.0, 5.18절 Case Mappings
- CaseFolding.txt 17.0.0, SpecialCasing.txt 17.0.0
- ECMA-262 2026(17판): 22.1.3.26, 22.1.3.28, 22.2.2.7.3
- ECMA-402 2026(13판): 20.1.2.1
- Java SE 27
String.toLowerCase() - Python 3.14
str.casefold()
링크와 내용은 2026년 10월 7일에 확인했습니다.