유니코드에서 대소문자가 왜 생각보다 복잡한지, 그리고 올바른 처리(특히 대소문자 무시 비교를 위한 case folding)가 왜 필요한지 정리한다.
URL: https://www.b-list.org/weblog/2018/nov/26/case/
2018년 11월 26일 · Django, Pedantics, Python, Unicode
몇 주 전 North Bay Python에서 사용자 이름(usernames)에 관한 발표를 했다. 내용은 대체로 django-registration을 약 12년간 유지보수하며 배운 것들에서 나왔는데, 그 과정에서 “단순한” 것조차 얼마나 복잡해질 수 있는지에 대해 내가 원했던 것보다 훨씬 많은 걸 알게 됐다.
다만 발표 초반에 언급했듯, 이 발표는 흔히 말하는 “프로그래머가 _X_에 대해 믿는 거짓말(falsehoods programmers believe about X)”류의 이야기가 아니었다. 그 유형의 글에 익숙하지 않다면 “falsehoods programmers believe”를 검색해 보면 전형적인 예시들이 잔뜩 나온다. 내가 그런 “falsehoods” 글들에 대해 갖는 문제의식은 간단하다. 많은 글이 “이것들이 틀렸다”고 나열해 놓고는, 왜 틀렸는지 또는 대신 무엇을 해야 하는지까지는 말해주지 않는 경우가 많다는 점이다. 그러면 사람들은 글을 읽고 스스로 똑똑해졌다고 여기며 뿌듯해하겠지만, 근본 문제를 배우지 못했기 때문에 글에 언급되지 않은 “새롭고 흥미로운” 방식으로 계속 틀릴 가능성이 높다고 본다.
그래서 나는 그 발표를 하면서 실제 문제를 설명하고 어떻게 다뤄야 하는지에 대한 제안까지 최대한 하려고 노력했다. 왜냐하면 나는 그 접근을 훨씬 더 좋아하기 때문이다. 다만 발표에서 충분히 시간을 쓰지 못한(사실상 한 장짜리 슬라이드와 다른 몇 장에서의 짧은 곁가지 정도였던) 주제가 하나 있었는데, 바로 대소문자(uppercase/lowercase)가 어떻게 복잡해질 수 있는지에 대한 내용이었다.
내가 발표에서 다뤘던 문제—식별자(identifier)를 대소문자 무시(case-insensitive)로 비교하는 문제—에 대해서는 공식적으로 정답(Right Answer™)이 존재하며, 발표에서는 파이썬 표준 라이브러리만으로 구현할 수 있는 내가 아는 최선의 구현을 제시했다(자세한 내용은 아래의 case folding 섹션을 보라).
하지만 나는 유니코드에서 “대소문자”가 더 깊게 복잡해질 수 있다는 점을 짧게 암시했었다. 이 주제는 흥미롭기도 하고, 텍스트를 처리하는 코드를 설계하고 작성할 때 더 나은 선택을 하는 데 도움이 되기도 하므로, 좀 더 자세히 이야기해 보려 한다. 그러니 “프로그래머가 믿는 거짓말”의 반대편에서, 대소문자를 주제로 한 나의 첫 “프로그래머가 알아야 할 진실”을 시작해 보겠다.
그리고 시작하기 전에 마지막으로 한 가지: 유니코드에는 용어가 많다. 이 글에서는 주로 “uppercase(대문자)”와 “lowercase(소문자)”를 쓰는데, 유니코드 표준에서 대소문자를 말할 때 그 용어를 쓰기 때문이다. “capital/small”이나 “majuscule/minuscule” 같은 다른 용어를 선호해도 괜찮다. 또한 나는 “character(문자)”라는 표현을 자주 쓸 텐데, 어떤 사람에게는 부정확하게 느껴질 수 있다. 실제로 유니코드의 “character” 개념은 많은 사람이 기대하는 것과 항상 일치하지 않기 때문에, 다른 용어를 쓰는 편이 좋은 경우가 종종 있다. 하지만 이 글에서는 유니코드가 사용하는 의미 그대로, 어떤 성질을 ‘부여할 수 있는’ 추상적 실체를 가리키는 뜻으로 “character”를 사용하겠다. 정밀함이 필요할 때는 “code point(코드 포인트)”처럼 더 구체적인 용어를 쓰겠다.
유럽 언어 사용자들은, 자신들의 언어가 문자로 기록될 때 대소문자를 의미 표시로 사용한다는 생각에 익숙하다. 예를 들어 영어에서는 보통 문장을 대문자로 시작하고 이후에는 대체로 소문자로 이어간다. 또한 대부분의 고유명사는 첫 글자를 대문자로 표기하고, 많은 약어/두문자어는 전부 대문자로 표기한다.
대부분 우리는 케이스가 두 가지뿐이라고 생각하곤 한다. “A”가 있고 “a”가 있다. 하나는 UPPER이고 하나는 lower… 맞지?
하지만 유니코드에는 실제로 케이스가 세 가지 있다. 소문자(lowercase)와 대문자(uppercase)가 있고, 여기에 타이틀케이스(titlecase)가 있다. 타이틀케이스는 말 그대로 제목을 쓰는 방식에서 가장 익숙하다. “Avengers: Infinity War”는 타이틀케이스다. 보통 이는 각 단어의 첫 글자를 대문자로 만드는 것을 의미한다(스타일 가이드에 따라 관사, 접속사, 전치사 등 일부 단어는 첫 글자를 대문자로 만들지 않을 수도 있다).
유니코드 표준은 타이틀케이스 문자의 예로 U+01F2 LATIN CAPITAL LETTER D WITH SMALL Z를 든다. 모양은 이렇다: Dz.
이런 문자들은 유니코드의 초기 설계 결정 중 하나—기존 텍스트 인코딩과의 왕복(round-trip) 호환성—의 여파를 처리하기 위해 때때로 필요하다. 유니코드는 결합 문자(combining character) 기능을 이용해 시퀀스를 조합하는 방식을 권장하지만, 많은 기존 시스템은 이미 미리 조합된(pre-composed) 형태를 인코딩하기 위해 공간을 할당해 두었다. 예를 들어 “é”는 ISO-8859-1(“latin-1”)에서 미리 조합된 형태를 갖고 있었고 바이트 값 0xe9로 표현됐다. 유니코드는 원칙적으로 이를 독립된 “e”와 결합 악센트로 쓰길 원하지만, latin-1 같은 기존 인코딩으로부터/로 손실 없이 왕복 변환할 수 있도록 조합된 형태에도 코드 포인트를 부여했다. 예컨대 U+00E9 LATIN SMALL LETTER E WITH ACUTE가 그렇다.
참고 — 코드 포인트 값이 latin-1의 바이트 값과 같더라도 거기에 의존하지 마라. 유니코드 인코딩은 이를 유지해 주지 않을 가능성이 높다. 예를 들어 UTF-8은 코드 포인트 U+00E9를 바이트 시퀀스 0xc3 0xa9로 인코딩한다.
그리고 당연히 일부 기존 인코딩에는 타이틀케이스에 대한 특수 처리가 필요하거나 그것을 나타내는 문자가 있었고, 그런 것들은 유니코드에 있는 그대로 포함됐다. 더 보고 싶다면, 선호하는 유니코드 데이터베이스에서 일반 범주(general category)가 Lt(“Letter,titlecase”)인 문자를 검색해 보라.
유니코드 표준(§4.2)은 케이스에 대한 서로 다른 정의를 세 가지 제공한다. 어떤 정의를 사용할지는 프로그래밍 언어가 이미 선택해 두었을 수도 있고, 그렇지 않다면 무엇을 하려는지에 따라 달라진다. 세 가지 정의는 다음과 같다.
Lu(“Letter, uppercase”)면 대문자이고, Ll(“Letter, lowercase”)면 소문자이다. 유니코드 표준은 이것이 매우 제한적인 정의임을 인정한다. 문자는 일반 범주를 하나만 가질 수 있으므로, “대문자/소문자여야 할 것 같은” 문자라도 일반 범주가 다른 것으로 표시되어 있으면 이 정의를 충족하지 못하기 때문이다.Uppercase를 가지면 대문자이고, Lowercase를 가지면 소문자이다. 이는 (1)의 문자 집합에 더해, 케이스를 나타내는 다른 속성을 가진 문자들을 결합하여 구성된다.처리하는 문자가 제한된 부분집합(특히 문자(letter)들)임을 알고 있다면, 정의 (1)로 충분할 수 있다. 문자 아닌데 글자처럼 보이는(non-letter) 것까지 포함해 더 큰 문자 레퍼토리가 필요하다면, 정의 (2)가 맞을 수 있다. 유니코드 표준은 §4.2에서 다음을 권장한다.
유니코드 문자열을 조작하는 데 관심 있는 프로그래머는, 단일 문자 속성을 직접 다루는 경우가 아니라면, 일반적으로 isLowerCase(그리고 그 함수형 사촌인 toLowerCase) 같은 문자열 함수를 다루게 될 것이다.
여기서 언급된 함수들은 유니코드 표준 §3.13에 정의되어 있다. 형식적으로 말해 위의 정의 (3)은 §3.13의 isLowerCase와 isUpperCase 함수를 사용하는 것인데, 이 함수들은 각각 toLowerCase와 toUpperCase의 고정점(fixed point)이라는 개념으로 정의된다.
당신이 쓰는 프로그래밍 언어가 문자열/개별 문자의 케이스를 검사하거나 변환하는 함수나 메서드를 제공한다면, 그 구현이 위 정의 중 무엇(혹은 어느 것도)을 사용하고 있는지 살펴볼 가치가 있다. 참고로 파이썬 3의 str 타입 메서드 isupper()와 islower()는 정의 (2)를 사용한다.
많은 문자는 눈으로 보고 케이스를 알 수 있다. 예를 들어 “A”는 대문자다. 그리고 이름에도 그대로 들어 있다(LATIN CAPITAL LETTER A). 하지만 이런 방법이 실패하는 경우도 있다. 코드 포인트 U+1D34를 보자. 모양은 이렇다: ᴴ 그리고 유니코드가 붙인 이름은 MODIFIER LETTER CAPITAL H다. 그러면 대문자… 맞지?
하지만 이 문자는 실제로 파생 속성 Lowercase를 가지며, 위의 정의 (2)에 따르면 소문자다. 시각적으로는 대문자 H를 닮았고 이름에도 “CAPITAL”이 들어가지만 말이다.
유니코드 표준 §3.13의 정의 135는 다음과 같이 말한다.
문자 C는, 오직 그리고 오직 C가 Lowercase 또는 Uppercase 속성을 가지거나, General_Category 값이 Titlecase_Letter인 경우에만, cased(대소문자 구분이 있는) 문자로 정의된다.
이는 유니코드의 매우 많은 문자—사실 대다수—가 cased가 아니라는 뜻이다. 즉 그 문자들에 대해 대문자인지 소문자인지 묻는 것은 의미가 없고, 케이스 매핑을 적용해도 아무 효과가 없다. 다만 위의 케이스 정의 (3)은 그런 문자에 대해서도 여전히 어떤 “답”을 반환하긴 한다.
앞의 내용의 결과로, 케이스가 없는 문자에 대해 정의 (3)을 사용해서 “대문자냐/소문자냐”를 물으면 “예(yes)”라는 대답이 나올 수 있다.
유니코드 표준은 예로(표 4-1, 7행) U+02BD MODIFIER LETTER REVERSED COMMA를 든다(모양은: ʽ). 이 문자는 Lowercase도 Uppercase도 아니고, 일반 범주도 Lt가 아니므로 cased가 아니다. 하지만 대문자 매핑을 적용해도 바뀌지 않고, 소문자 매핑을 적용해도 바뀌지 않는다. 그래서 케이스 정의 (3)에 따르면 이 문자는 “당신은 대문자입니까?”와 “당신은 소문자입니까?”라는 질문에 둘 다 “예”라고 답하게 된다.
이게 불필요한 혼란을 불러오는 것처럼 보일 수도 있지만, 정의 (3)이 어떤 유니코드 문자 시퀀스에도 동작하게 해 주며, 케이스 매핑 알고리즘을 더 단순하게 만들 수 있다(케이스가 없는 문자는 그냥 자기 자신으로 매핑되면 되기 때문이다).
유니코드가 모든 문자에 대한 케이스 매핑 테이블을 제공하니, 케이스 매핑(문자열을 한 케이스에서 다른 케이스로 변환하는 것)은 간단한 룩업이라고 생각하기 쉽다. 예를 들어 유니코드 데이터베이스는 U+0041 LATIN CAPITAL LETTER A의 소문자 매핑이 U+0061 LATIN SMALL LETTER A라고 알려준다. 쉽지?
하지만 그게 깨지는 예가 그리스어에 있다. 문자 Σ—즉 U+03A3 GREEK CAPITAL LETTER SIGMA—는 소문자화할 때 위치에 따라 두 문자 중 하나로 매핑된다. 단어의 마지막 위치(어말)에 오면 ς(U+03C2 GREEK SMALL LETTER FINAL SIGMA)로 소문자화된다. 그 외의 위치에서는 σ(U+03C3 GREEK SMALL LETTER SIGMA)로 소문자화된다.
이 때문에 케이스는 전단사(bijective)도 아니고 추이적(transitive)도 아니다. 또 다른 예로 ß(U+00DF LATIN SMALL LETTER SHARP S, _Eszett_로도 알려짐)가 있다. 이것은 대문자화하면 “SS”가 되는데, 이제는 대문자 형태(ẞ, U+1E9E LATIN CAPITAL LETTER SHARP S)도 존재한다. 그리고 “SS”를 소문자화하면 “ss”가 된다. 따라서(유니코드 표준의 케이스 매핑 적용 용어를 사용하자면) toLowerCase(toUpperCase(ß)) != ß이다.
비슷하게, 언어마다 케이스 매핑 규칙이 다르다. 가장 흔한 예는 아마 i(U+0069 LATIN SMALL LETTER I)와 I(U+0049 LATIN CAPITAL LETTER I)가 _대부분_의 로케일에서는 서로 변환되지만, 모든 로케일에서 그런 것은 아니라는 점이다. 로케일 az와 tr(튀르크계 언어)에서는 i가 대문자화되면 İ(U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE)가 되고, I가 소문자화되면 ı(U+0131 LATIN SMALL LETTER DOTLESS I)가 된다. 이걸 제대로 처리하는 것이 말 그대로 생사가 달린 문제였던 적도 있다.
유니코드 자체는 가능한 모든 로케일 민감 케이스 규칙을 처리하지 않는다. 유니코드 데이터베이스는 유니코드 전 범위에 대해 일반적인 로케일 비민감 매핑을 제공하고, 특수 규칙으로는 다음을 제공한다: 일부 합자(ligature) 및 조합(composed) 형태; 리투아니아어; 튀르크계 언어; 그리고 그리스어의 일부 고유 특성. 그러나 그 외는 누락한다. 유니코드 표준 §3.13은 이를 언급하며, 필요할 때 로케일 인지 케이스 규칙을 보완하라고 권장한다.
영어 사용자에게 익숙한 예로는 특정 이름을 타이틀케이스로 만드는 방법이 있다. “o’brian”은 “O’brian”이 아니라 “O’Brian”이 되어야 한다. 하지만 동시에 “it’s” 같은 축약형은 “It’s”가 되어야지 “It’S”가 되어서는 안 된다. 유니코드가 처리하지 않는 또 다른 예는 네덜란드어 “ij”인데, 단어 첫 위치에서는 이를 하나의 단위로 타이틀케이스 처리해야 한다. 네덜란드의 큰 내해는 따라서 “Ijsselmeer”가 아니라 “IJsselmeer”로 타이틀케이스 처리하는 것이 올바르다.
유니코드는 필요하다면 IJ U+0132 LATIN CAPITAL LIGATURE IJ와 ij U+0133 LATIN SMALL LIGATURE IJ를 제공하며, 유니코드의 기본 케이스 매핑은 이 둘을 서로 변환한다(다만 호환 동등성(compatibility equivalence)을 사용하는 유니코드 정규화 형태는, 합자 형태를 사용하더라도 이를 “IJ” 및 “ij” 시퀀스로 분해한다).
마지막으로 발표에서 다룬 주제로 돌아가자. 유니코드에서 케이스가 이렇게 복잡하기 때문에, 대소문자 무시 비교(case-insensitive comparison)는 많은 프로그래밍 언어에서 흔히 제공하는 단순 소문자화/대문자화 함수로 해서는 안 된다. 대소문자 무시 비교를 위해 유니코드는 _케이스 폴딩(case folding)_이라는 개념을 제공하며, 유니코드 표준 §3.13은 toCaseFold 케이스 매핑과 isCaseFolded 함수를 정의한다.
케이스 폴딩을 소문자화와 비슷한 것으로 생각하기 쉽다(그리고 나는 발표에서 파이썬 문서가 이 실수를 저지른다고 지적한다). 하지만 둘은 다르다. 유니코드 표준은 케이스 폴딩된 문자열이 반드시 소문자인 것은 아니라고 경고하며, 케이스 폴딩 결과가 대문자 문자를 포함하게 되는 문자 체계의 예로 체로키(Cherokee)를 든다.
발표의 한 장의 슬라이드는, 파이썬에서 유니코드 기술 보고서(Unicode Technical Report) #36의 권고를 가능한 한 가깝게 따르도록, 먼저 NFKC로 정규화한 다음 그 결과 문자열에 casefold() 메서드(파이썬 3+에서만 사용 가능)를 호출한다. 하지만 이것조차 일부 엣지 케이스를 건너뛰며, 식별자 비교에 대해 권장되는 것과 정확히 일치하는 구현은 아니다.
먼저 나쁜 소식부터: 파이썬은 XID_Start 또는 XID_Continue에 속하지 않는 문자나 Default_Ignorable_Code_Point를 걸러낼 만큼의 유니코드 속성을 노출하지 않는다. 내가 알기로는 NFKC_Casefold 매핑도 지원하지 않는다. 또한 UAX#31 §5.1의 수정된 NFKC를 사용하기 위한 쉬운 방법도 제공하지 않는다.
좋은 소식은, 이런 엣지 케이스 대부분이 “식별자 문자 집합이 NFKC 정규화 하에서 닫혀(closed) 있지 않게 되는” 문제와 관련되어 있고, 문제의 문자들이 실제로 유발하는 보안 위험과는 관련이 적다는 점이다. 그리고 케이스 폴딩은 애초에 정규화를 보존하는 연산으로 정의되지 않는다(따라서 케이스 폴딩 후 다시 NFC로 재정규화하는 NFKC_Casefold 매핑이 존재한다). 일반적으로 비교를 수행할 때 중요한 것은 전처리 후에도 양쪽 문자열이 정규화 상태를 유지하느냐가 아니라, 전처리가 일관되게 적용되어 전처리 후 “달라야 하는 것들만” 실제로 달라지도록 보장하느냐이다. 걱정된다면 케이스 폴딩 후 수동으로 다시 정규화할 수 있다.
North Bay에서 했던 발표처럼, 이 글 또한 주제를 완전히 망라하지는 못했으며, 아마 어떤 단일 블로그 글도 그럴 수는 없을 것이다. 하지만 이 글이 대소문자라는 주제의 복잡성을 일반적으로 개관하는 데 유용하고, 더 관심이 있다면 추가로 읽어볼 수 있도록 충분한 단서들을 제공했기를 바란다. 그리고 이 정도면 멈추기에 충분히 좋은 지점이다.
또, 너무 큰 바람일까? 다른 사람들이 “프로그래머가 믿는 거짓말”을 그만 쓰고 “프로그래머가 알아야 할 진실”을 더 쓰기 시작하도록 영감을 주었으면 한다.