Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 유니코드 정규화 완벽 가이드: NFD부터 NFKC까지

최근 저는 대부분의 Ruby 문자열 메서드를 특정 유니코드 문자로 테스트해 예상치 못한 동작이 있는지 확인하는 글을 발행했습니다. 결과적으로 많은 메서드들이 예상과 다르게 동작했습니다.

그 글에 대한 비판 중 하나는 테스트에 정규화되지 않은(normalized) 문자열을 사용했다는 것이었습니다. 솔직히 말하면 저 역시 유니코드 정규화에 대해 잘 알지 못했습니다. 아마 많은 Ruby 개발자들도 마찬가지일 겁니다.

정규화를 활용하면 제 테스트에서 예상치 못하게 동작했던 많은 유니코드 문자열을 Ruby의 문자열 메서드와 잘 호환되는 형태로 변환할 수 있습니다. 다만 주의할 점이 있습니다:

  1. 변환이 항상 완벽하지는 않습니다. 일부 유니코드 시퀀스는 어떤 방법을 써도 Ruby의 문자열 메서드가 오작동합니다.
  2. 정규화는 수동으로 직접 해야 합니다. Ruby, Rails, 데이터베이스 어디도 기본적으로 자동 정규화를 수행하지 않습니다.

이 글은 Ruby에서의 유니코드 정규화에 대한 간결한 입문서입니다. 여러분만의 탐구를 시작하는 출발점이 되기를 바랍니다.

문자열 정규화해 보기

String#unicode_normalize 메서드는 Ruby 2.2에서 도입되었습니다. 순수 Ruby로 작성되어 있어, C 언어를 활용하는 utf8_proc나 unicode 젬 같은 정규화 라이브러리보다는 속도가 느립니다.

정규화가 필요한 이유는 유니코드에서 하나의 문자를 표현하는 방법이 여러 가지이기 때문입니다. 예를 들어 문자 "Å"는 코드 포인트 "\u00c5"로 표현할 수도 있고, 글자 "A"와 링(ring) 기호를 조합한 "A\u030A"로 표현할 수도 있습니다.

정규화는 한 형태를 다른 형태로 변환해 줍니다:

"A\u030A".unicode_normalize        #=> 'Å' ("\u00C5"와 동일)

물론 유니코드 정규화 방법이 하나뿐이라면 세상이 훨씬 단순하겠죠! 실제로는 네 가지 정규화 방식, 즉 정규화 형식(normalization forms)이 존재하며, 낯선 약어로 불립니다: NFD, NFC, NFKD, NFKC.

String#unicode_normalize는 기본적으로 NFC를 사용하지만, 다른 형식을 인자로 지정할 수도 있습니다:

"a\u0300".unicode_normalize(:nfkc)       #=> 'à' ("\u00E0"와 동일)

그렇다면 이것은 실제로 무엇을 의미할까요? 네 가지 정규화 형식은 각각 어떤 작업을 수행할까요? 하나씩 살펴보겠습니다.

정규화 형식(Normalization Forms)

정규화 연산에는 두 가지 종류가 있습니다:

  • 결합(Composition): 여러 코드 포인트로 구성된 문자를 단일 코드 포인트로 변환합니다. 예를 들어 "a\u0300""\u00E0"가 되며, 둘 다 문자 à를 인코딩하는 방식입니다.
  • 분해(Decomposition): 결합의 반대 연산으로, 단일 코드 포인트 문자를 여러 코드 포인트로 변환합니다. 예를 들어 "\u00E0""a\u0300"가 됩니다.

그리고 결합과 분해는 각각 두 가지 방식으로 수행될 수 있습니다:

  • 정준(Canonical): 글리프(glyph)를 그대로 보존합니다. 예를 들어 "2⁵"는 일부 시스템이 윗첨자 5 문자를 지원하지 않더라도 "2⁵"로 유지됩니다.
  • 호환성(Compatibility): 글리프를 호환성 문자로 대체할 수 있습니다. 예를 들어 "2⁵""2 5"로 변환됩니다.

이 두 가지 연산과 두 가지 옵션이 조합되어 네 가지 "정규화 형식"이 만들어집니다. 아래 표에 입력과 출력 예시와 함께 정리했습니다:

이름 설명 입력 출력
NFD 정준 분해 Å "\u00c5" Å "A\u030A"
NFC 정준 분해 후 정준 결합 Å "A\u030A" Å "\u00c5"
NFKD 호환성 분해 ẛ̣ "\u1e9b\u0323" ṩ "\u0073\u0323\u0307"
NFKC 호환성 분해 후 정준 결합 ẛ̣ "\u1e9b\u0323" ṩ "\u1e69"

이 표를 잠시 들여다보면 약어가 나름의 논리를 갖고 있다는 것을 알 수 있습니다:

  • "NF"는 "Normalization Form", 즉 정규화 형식을 의미합니다.
  • "D"는 분해(DDecomposition)
  • "C"는 결합(Composition)
  • "K"는 호환성(KoKompatibility) :)

더 많은 예제와 훨씬 심도 있는 기술적 설명이 필요하다면 유니코드 표준 부록 #15(Unicode Standard Annex #15) 문서를 참고하세요.

정규화 형식 선택하기

어떤 정규화 형식을 사용할지는 수행하려는 작업에 따라 달라집니다. 아래 권장 사항은 유니코드 정규화 FAQ(Unicode Normalization FAQ)를 기반으로 합니다.

문자열 호환성을 위해서는 NFC 사용

Ruby의 문자열 메서드가 대부분의 유니코드와 원활하게 작동하도록 만드는 것이 목표라면 NFC를 사용하는 것이 좋습니다. String#unicode_normalize의 기본값이 NFC인 데에는 이유가 있습니다.

  • 가능한 경우 멀티 코드 포인트 문자를 단일 코드 포인트로 결합합니다. 멀티 코드 포인트 문자야말로 문자열 메서드 문제의 주된 원인입니다.
  • 글리프를 변경하지 않기 때문에 최종 사용자는 입력한 텍스트의 변화를 전혀 눈치채지 못합니다.

다만 모든 멀티 코드 포인트 문자가 단일 코드 포인트로 결합될 수 있는 것은 아닙니다. 이런 경우에는 Ruby의 문자열 메서드가 여전히 제대로 동작하지 않습니다:

s = "\u01B5\u0327\u0308"          # => "Ƶ̧̈", 결합 불가능한 문자
s.unicode_normalize(:nfc).size    # => 3, 문자가 하나뿐인데도 3을 반환

보안과 DB 호환성을 위해서는 NFKC 사용

사용자명처럼 보안과 관련된 텍스트를 다루거나, 텍스트가 데이터베이스와 잘 호환되는 것이 주요 관심사라면 NFKC가 적절한 선택일 가능성이 높습니다.

  • 잠재적으로 문제가 될 수 있는 문자를 호환성 문자로 변환합니다.
  • 이후 모든 문자를 단일 코드 포인트로 결합합니다.

왜 이것이 보안에 중요한지 살펴보겠습니다. 사용자명이 "HenryIV"인 사용자가 있다고 상상해 보세요. 악의적인 공격자는 새로운 사용자명 "HenryⅣ"를 등록해 이 사용자를 사칭하려 할 수 있습니다.

네, 둘은 똑같아 보입니다. 바로 그게 핵심입니다. 하지만 실제로는 서로 다른 두 문자열입니다. 전자는 ASCII 문자 "IV"를 사용하는 반면, 후자는 로마 숫자 4를 나타내는 유니코드 문자 "Ⅳ"("\u2163")를 사용합니다.

고유성 검증(uniqueness validation) 전에 NFKC로 문자열을 정규화하면 이런 종류의 공격을 방지할 수 있습니다. NFKC는 유니코드 "\u2163"을 ASCII 글자 "IV"로 변환해 주기 때문입니다.

a = "Henry\u2163"
b = "HenryIV"
a.unicode_normalize(:nfc) == b.unicode_normalize(:nfc)   # => false, NFC는 글리프를 보존하므로
a.unicode_normalize(:nfkc) == b.unicode_normalize(:nfkc) # => true, NFKC는 둘 다 ASCII "IV"로 평가하므로

마치며

이제 유니코드 정규화를 깊이 살펴보고 나니, Ruby와 Rails 커뮤니티에서 이 주제가 더 큰 화두가 되지 않는다는 점이 조금 의외입니다. Rails가 자동으로 처리해 줄 것이라 기대할 수도 있겠지만, 제가 확인한 바로는 그렇지 않습니다. 그리고 사용자가 입력한 데이터를 정규화하지 않으면 Ruby의 많은 문자열 메서드가 신뢰할 수 없게 됩니다.

독자 여러분 중 제가 모르는 내용을 아신다면 트위터(@StarrHorne)나 이메일(starr@honeybadger.io)로 알려주세요. 유니코드는 방대한 주제이고, 제가 모든 것을 알지 못한다는 건 이미 증명된 바 있습니다. :)