728x90

이 글은 [유니코드] 유니코드 문자열을 정규화 해야하는 이유에 이어지는 글입니다.

 

UTF-8


UTF-8은 8비트(1바이트)로 인코딩한다는 것을 의미한다. UTF-8은 아스키코드와 완벽하게 호환되며, 표현하려는 문자에 따라 최소 1바이트에서 최대 6바이트까지 사용한다.

 

https://en.wikipedia.org/wiki/UTF-8

 

 

‘바이트 1’부터 ‘바이트 6’까지 있는 1과 0은 고정된 비트 값이며, 사용하는 바이트 수에 따라 달라진다. ‘x’ 값은 유니코드를 저장하는 데 사용할 비트 영역이다.

 

‘1 바이트 수’ 문자는 첫 번째 비트 값이 0이므로 0을 제외한 나머지 비트 7개로 문자를 표현한다. 0부터 127까지의 수로 문자를 표현하는 아스키 코드와 같은 규칙을 사용하므로 UTF-8은 아스키 코드와 완벽히 호환된다는 것이다.

 

‘2 바이트 수’ 문자는 비트 값이 110으로 시작된다. 이 경우에는 2바이트로 UTF-8 문자를 읽어야 한다. 첫 번째 바이트의 110xxxxx에서 110을 제외한 비트 5개, 두 번째 바이트의 10xxxxxx에서 10을 제외한 비트 6개를 조합하면 총 11개의 비트로 UTF-8 문자를 만들 수 있다.

 

예를 들어 ‘안’을 구성하는 16진 수 값은 0xec, 0x95, 0x88이다.

  • 0xec: 1110 1100
  • 0x95: 1001 0101
  • 0x88: 1000 1000

해당 값을 차례대로 나열하면 다음과 같다.

  • 11101100 100110101 10001000

위의 표를 보면 첫 번째 바이트가 1110으로 시작할 때는 3바이트가 1개의 글자가 되는 것을 알 수 있다. 두 번째 바이트와 세 번째 바이트에 있는 비트 값 10은 UTF-8 형식을 구성하는 용도로만 사용하므로 실제로 값을 읽을 때는 사용하지 않는다.

 

따라서 첫번째, 두 번째, 세 번째 형식 구성 비트들을 모두 제거하고 다시 한번 조합하면 다음과 같은 값을 만들 수 있다.

  • 1100010101001000

해당 값을 16진수로 변환하면 0xC548이다.

 

https://www.fileformat.info/info/unicode/char/c548/index.htm

 

 

보통 일반적인 문자는 3바이트 내로 처리되며, 4바이트 영역에는 이모지 같은 문자가 있으며, 고대 문자 같은 것을 사용하지 않는 한 5바이트 이상을 쓰는 경우는 거의 없다고 한다.

 

UTF-16


UTF-16은 16비트(2바이트)로 인코딩 하는 것을 의미한다. UTF-16은 2바이트 또는 4바이트만 사용하기 때문에 아스키 코드와 호환되지 않는다.

 

유니코드에는 문자의 종류에 따라 기본 다국어 평면(Basic Multilingual Plane, BMP), 보충 다국어 평면(Supplenmentray Multilingual Plane, SMP), 상형 문자 보충 평면(Supplementary Ideographic Plane, SIP), 특수 목적 보충 평면(Supplenmentray Special-purpose Plane, SSP) 등 평면 4개가 있고, 바이트 수는 표현하려는 문자가 어떤 평면에 속하는지에 따라 결정된다.

fun String.toUtf16Hex(): String {
    val utf16Bytes = this.toByteArray(StandardCharsets.UTF_16)
    println("Total UTF-16 Bytes: ${utf16Bytes.size}")
    return utf16Bytes.joinToString(" ") { byte -> String.format("0x%02X", byte) }
}
Total UTF-16 Bytes: 12
UTF-16 Hexadecimal Bytes: 0xFE 0xFF 0xC5 0x48 0xB1 0x55 0xD5 0x58 0xC1 0x38 0xC6 0x94
  • 그런데 ‘안녕하세요’ 문자열의 16진수 출력 결과 총 12바이트를 사용했고 맨 앞에 2 바이트 값인 0xFE, 0xFF 값이 추가된 것을 볼 수 있다.
  • 한 글자당 2바이트로 추측이 되었으나 왜 10바이트가 아닌 12바이트 일까? 바로 BOM 때문이다.

 

바이트 순서 표시

 

UTF-16과 UTF-32는 바이트 순서 표시(byte order mark, BOM)을 사용한다. BOM은 문자열 가장 맨 앞 2바이트에 0xFEFF(U+FEFF)로 표기하여 사용한다는 것을 의미한다.

 

또한 0xFE와 0xFF 중 어떤 문자가 먼저 오는지에 따라 little endian, big endian으로 나뉜다. 그래서 두 방식에 따라 문자열 인코딩 시 바이트 데이터를 조합하는 순서가 바뀌게 된다.

 

BOM을 이용하여 바이트 표현 순서를 정하는 이유는 CPU 설계에 따라 바이트 값을 처리하는 순서가 다르기 때문이다. 같은 0xFEFF를 CPU가 읽을 때 리틀 엔디언 방식은 0xFF 다음 0xFE을 읽으며, 빅 엔디언 방식은 그 반대이다.

 

UTF-8에 BOM이 없는 이유는 무엇일까? BOM에 해당 하는 값이 있지만(0xEF, 0xBB, 0xBF) 1바이트 단위로 글자를 변환하기 때문에 글자를 읽는 순서가 달라도 영향을 받지 않는다. 따라서 UTF-8은 BOM을 사용할 필요가 없고 권장하지도 않는다.

하지만 MS Excel에서는 utf-8 csv 파일을 읽는 경우 BOM으로 저장되지 않는 경우 한글이 깨지게 된다. 그렇지 않으면 별도의 절차를 통해 올바르게 열어야 깨지지 않는다..

 

UTF-32


UTF-32는 모든 문자를 고정된 4바이트 길이로 사용한다. 이 특징을 제외하면 UTF-16과 동일한 규칙을 사용하기 때문에 더 많은 바이트만 사용하는 것 외에는 별다른 특징이 없다.

 

요약


UTF-8

  • 오늘날 가장 많이 사용하는 문자열 인코딩이며 최소 1바이트, 최대 6바이트 사용
    • 대부분 4바이트 내로 처리
  • 아스키 코드와 호환 가능
  • 윈도우, 자바, 임베디드를 제외한 거의 모든 환경에서의 문자열 처리 표준
  • JSON은 UTF-8 인코딩만 사용하며, 다른 문자열 인코딩은 표준에서 지원하지 않음

 

UTF-16

  • 자바와 윈도우는 유니코드를 사용하기 전부터 고정된 2바이트 길이의 문자 집합을 사용 한다.
    • 그래서 멀티 바이트라고도 한다.
  • 2바이트 또는 4바이트의 길이의 문자열을 사용하며, 아스키 코드와 호환되지 않는다.
  • UTF-16 기반 환경에서 UTF-8을 사용할 때는 사용 영역을 명확히 구분하는게 좋다.
    • 내부에서는 16을 사용하되 외부 통신시 8로 변환하여 사용하는 등

 

UTF-32

  • 4바이트를 고정적으로 사용
  • 반드시 사용해야 하는 환경이 아니라면 사용하지 않는다

 

참고 출처


728x90
728x90

이슈


Mac에서 업로드한 파일의 이름을 데이터베이스에 저장하여 사용하고 있었는데 해당 값에 대한 검색 결과에 누락되어 제대로 동작하지 않는 문제를 겪었다. 살펴보니, 이는 유니코드의 정규화 차이 때문인 것을 알게 되었다.

 

원인


유니코드 정규화(Unicode normalization)는 모양이 같은 여러 문자들이 있을 경우 이를 기준에 따라 하나로 통합해 주는 일을 가리킨다. 유니코드 정규화에는 다음과 같은 네 가지 방법이 있다. 이중 한글 처리와 관련된 것은 NFD(소리 마디를 첫가끝 코드로 분해)와 NFC(첫가끝 코드를 소리 마디로 결합)이다.

 

NFD (Normalize Form D)

NFD는 모든 음절을 Canonical Decomposition(정준 분해)하여 한글 자모 코드를 이용하여 저장하는 방식이다.

  • 한 (U+D55C) → ᄒ (U+1112) + ᅡ (U+1161) + ᆫ (U+11AB)

이 방식은 현대 한글과 옛 한글을 동일한 방식으로 저장한다는 장점이 있지만 NFC 방식과 비교하여 텍스트의 크기가 커진다는 문제가 있다.

NFD는 macOS 시스템에서 주로 사용한다.

 

NFC (Normalize Form C)

NFC는 모든 음절을 Canonical Decomposition(정준 분해) 후 Canonical Composition(정준 결합) 하는 방식이다.

  • ᄒ (U+1112) + ᅡ (U+1161) + ᆫ (U+11AB) → 한(U+D55C)

이 방식을 사용하면 NFD 방식보다 텍스트의 사이즈는 작아지게 된다. 하지만, 옛 한글 자모의 결합으로 이루어진 한글 음절 코드가 없으므로 이 음절은 Canonical Composition 하지 못하므로 자소가 분리된 체로 저장하게 된다. 이로 인해, 현대 한글과 옛 한글이 다른 방식으로 저장되므로 텍스트를 처리할 때 유의해야 한다.

NFC는 많은 GNU/Linux 시스템, Windows에서 주로 사용한다.

 

해결방법


Java에서는 아래처럼 유니코드 정규화 기능을 지원하고 있다. 따라서 필요한 값 혹은 모든 사용자 입력값을 Unicode 정규화를 사용하여 해결할 수 있을 것이다.

import java.text.Normalizer

fun String.normalizeToNfc(): String {
    return if (!Normalizer.isNormalized(this, Normalizer.Form.NFC)) {
        Normalizer.normalize(this, Normalizer.Form.NFC)
    } else {
        this
    }
}

fun String.normalizeToNfd(): String {
    return Normalizer.normalize(this, Normalizer.Form.NFD)
}

 

평소에 문자 집합과 문자열 인코딩을 알고 있었다면, 위와 같은 인코딩 관련 문제가 발생했을 때 쉽고 빠르게 해결할 수 있었을 것이다. 문자가 깨지거나 보이지 않은 것이 운영 체재, 개발 환경이 달라서인지, 다른 서비스나 라이브러리가 맞지 않아서인지 파악할 수 있을 것이다.

 

문자 집합과 인코딩


엄격하게 구분해보면 문자 집합과 문자열 인코딩이라는 용어를 구분하여 함께 사용한다.

 

문자 집합

컴퓨터에서 문자를 나타내기 위해 사용할 수 있는 문자들의 집합을 의미한다.
  • ASCII, Unicode, ISO-8859-1 등이 문자 집합에 해당된다.

 

문자열 인코딩

문자를 코드로 표현하는 방식을 의미한다.
  • 문자 인코딩 방식으로는 UTF-8, UTF-16, UTF-32 등이 있다.

 

ASCII


아스키 코드는 처음으로 표준을 정립한 문자열 인코딩 방식이다. 사용할 수 있는 문자의 종류에는 대문자, 소문자, 아라비아 숫자, 공백 및 특수 문자들이 있으면 문자를 표현할 때는 0부터 127까지, 총 128개의 숫자를 사용한다.

 

아스키코드는 영어를 제외한 다른 언어를 표현할 수 없다. 그래서 각 나라에서 컴퓨터를 사용하기 시작했을 때는 아스키코드 대신 독자적인 문자 집합과 인코딩 방식을 만들어 사용했다.

 

EUC-KR(CP949)


한글을 표현하는 방법으로 EUC-KR 문자 집합을 만들었다. EUC-KR은 한국 산업 표준으로 지정된 한국어 문자 집합으로 문자 하나를 표현하기 위해 2바이트를 사용한다. 단, 아스키코드 문자를 표현할 때는 1바이트를 사용하기 때문에 아스키코드와 호환된다.

 

EUC-KR은 모든 글자가 완성된 형태로만 존재하는 완성형 코드이다. 따라서 초성, 중성, 종성을 조합해 문자를 만들 수 없기 때문에 표현할 수 없는 한글이 일부 존재한다. 유니코드 2.0 버전에서 초성, 중성, 종성에 해당하는 코드로 나눠 표현하는 조합형 글자를 만들면 표현할 수 없는 글자들을 만들 수 있다.

 

따라서 EUC-KR로 영문자 ‘Hello’와 한글 ‘안녕하세요’ 문자열은 실제 문자열 길이와 버퍼 길이가 다르다. 아스키코드 영역에 있는 글자를 표현할 때는 1바이트를 사용하지만, 한글 문자를 표현할 때는 2바이트를 사용하기 때문이다.

 

CP949는 EUC-KR을 확장한 문자 집합으로 EUC-KR과 같은 문자열 인코딩이나, 더 많은 문자를 표현할 수 있다. EUC-KR로 표기하더라도 실제는 CP949 문자 집합을 사용하는 경우가 많다.

 

유니코드


과거에는 EUC-KR처럼 국가별로 독자적인 문자 집합과 인코딩 방식을 사용했다. 따라서 전 세계 사용자를 대상으로 하는 프로그램이나 웹 페이지를 만들려면 언어별로 다른 인코딩 방식을 사용해야 했다.

 

이런 문제를 해결하기 위해 ISO에서 동일한 규칙으로 모든 언어를 표현할 수 있는 유니코드 문자 집합을 만들었다. 최초 버전인 1.0은 1991년에 제정됐고, 현재 최신 버전은 2024년 9월 12일에 발표된 16.0이다.

 

유니코드 문자 집합을 표현하는 문자열 인코딩은 총 세 가지로 UTF-8, UTF-16, UTF-32가 있다, 아스키코드나 EUC-KR처럼 문자 집합에 해당하는 하나의 인코딩 규칙만 존재하는 것이 아니다. 이 중 ASCII와 호환이 가능하면서 유니코드를 표현할 수 있는 UTF-8 인코딩이 가장 많이 사용된다. 

 

 

나머지는 이어지는 [유니코드] 유니코드 인코딩 다음글에 다루고자 한다.

 

참고 출처


728x90

+ Recent posts