문자와 바이트: 유니코드와 UTF-8
글자가 코드 포인트라는 번호를 거쳐 바이트로 저장되는 과정과 같은 글자가 UTF-8·UTF-16·MS949에서 서로 다른 바이트가 되는 모습을 확인하고, Java의 byte와 char, String.length()가 실제로 무엇을 나타내는지 배웁니다.
한글로 저장한 파일을 다른 프로그램으로 열었더니 �꽌�슱처럼 엉뚱한 글자가 나오거나, 터미널에 한글 대신 ??가 찍히는 일을 한 번쯤 겪어 보았을 것입니다. 글꼴이 없어서 생기는 문제처럼 보이지만, 대부분은 글자를 바이트로 바꾼 규칙과 바이트를 다시 글자로 읽은 규칙이 서로 달라서 생깁니다. 파일과 네트워크에는 String이 그대로 흐르지 않습니다. 저장 장치와 소켓이 다루는 것은 바이트뿐이라서, 글자는 정해진 규칙에 따라 바이트열로 바뀌어 저장되고 전송됩니다.
글자를 바이트로 바꾸는 일을 인코딩(encoding), 바이트를 글자로 되돌리는 일을 디코딩(decoding)이라고 합니다. 보내는 쪽과 받는 쪽이 같은 규칙을 써야 원래 글자가 돌아옵니다. 이 글에서는 바이트가 무엇이고 Java가 바이트를 어떻게 다루는지부터 시작해, 글자에 번호를 붙이는 유니코드와 그 번호를 바이트로 적는 여러 방식을 실제 바이트로 비교합니다. 그다음 Java의 char와 String.length()가 무엇을 세는지 확인하고, 인코딩을 지정하지 않았을 때 어떤 일이 생기는지 봅니다.
비트와 바이트, 그리고 Java의 byte
비트(bit)는 0과 1 두 상태 가운데 하나를 나타내는 가장 작은 단위이고, 비트 여덟 개를 묶은 것이 바이트(byte)입니다. 비트 하나는 2가지, 둘은 4가지 상태를 나타내므로 여덟 개는 2⁸, 즉 256가지 조합을 나타냅니다. 바이트를 사람이 읽을 때는 보통 16진수 두 자리로 적습니다. 16진수 한 자리가 비트 네 개에 해당하므로 0000 0000은 00, 1111 1111은 FF가 되고, 한 바이트는 00부터 FF까지로 적을 수 있습니다. 이 글의 바이트 출력도 모두 이 표기를 씁니다.
그런데 Java의 byte 타입은 이 256가지 조합을 0부터 255가 아니라 -128부터 127까지의 정수로 해석합니다. byte가 부호 있는(signed) 타입이기 때문입니다. 맨 앞 비트가 0인 00부터 7F까지는 0부터 127로 그대로 읽히고, 맨 앞 비트가 1인 80부터 FF까지는 부호 없이 읽은 값에서 256을 뺀 음수로 읽힙니다. 그래서 FF(255)는 -1, 80(128)은 -128입니다. 이런 방식을 2의 보수(two's complement) 표현이라고 하는데, 메모리에 놓인 비트 여덟 개는 그대로이고 그것을 숫자로 읽는 방식만 다르다는 점이 중요합니다.
다음 프로그램은 네 가지 비트 패턴을 byte에 담아, 부호 있는 값과 부호 없는 값, 비트를 나란히 출력합니다. Byte.toUnsignedInt는 바이트를 0부터 255 사이의 int로 바꿔 주는 메서드입니다.
public final class UnsignedByteView {
public static void main(String[] args) {
byte[] packet = {(byte) 0x00, (byte) 0x7F, (byte) 0x80, (byte) 0xFF};
for (byte value : packet) {
int unsigned = Byte.toUnsignedInt(value);
String bits = String.format("%8s", Integer.toBinaryString(unsigned))
.replace(' ', '0');
System.out.printf("signed=%4d unsigned=%3d bits=%s%n",
value, unsigned, bits);
}
byte last = packet[3];
System.out.println("last == 0xFF ? " + (last == 0xFF));
System.out.println("toUnsignedInt(last) == 0xFF ? "
+ (Byte.toUnsignedInt(last) == 0xFF));
}
}처음 네 줄은 반복문이 바이트마다 찍은 줄이고, 마지막 두 줄은 마지막 바이트를 0xFF와 두 가지 방식으로 비교한 결과입니다. 같은 바이트를 비교했는데 두 결과가 같은지 보세요.
signed= 0 unsigned= 0 bits=00000000
signed= 127 unsigned=127 bits=01111111
signed=-128 unsigned=128 bits=10000000
signed= -1 unsigned=255 bits=11111111
last == 0xFF ? false
toUnsignedInt(last) == 0xFF ? true처음 네 줄은 같은 비트 패턴이 두 방식으로 어떻게 읽히는지 보여 줍니다. 비트가 11111111인 마지막 바이트는 byte로는 -1이지만 부호 없이 읽으면 255입니다. 데이터가 망가진 것이 아니라 같은 여덟 비트를 다르게 읽었을 뿐이므로, 인코딩한 바이트 배열을 출력했을 때 음수가 보여도 놀랄 필요는 없습니다.
마지막 두 줄은 이 차이 때문에 생기는 흔한 버그를 보여 줍니다. last == 0xFF에서 0xFF는 int 255이고, byte인 last는 비교하기 전에 int로 바뀝니다. 이때 값 -1이 그대로 유지되어 int -1이 되므로 255와 비교한 결과는 false입니다. 파일 형식의 머리 부분에 적힌 바이트처럼 0부터 255 범위로 정의된 값을 비교할 때는 Byte.toUnsignedInt(b)나 b & 0xFF로 부호 없는 값을 먼저 만든 뒤 비교해야 합니다.
더 깊이: b & 0xFF가 부호 없는 값을 만드는 이유
byte를 int로 넓힐 때 Java는 값을 보존하려고 맨 앞 비트를 위쪽 24비트에 그대로 복사합니다. 이를 부호 확장(sign extension)이라고 하며, 그래서 바이트 FF는 int로 FF FF FF FF, 즉 -1이 됩니다. 여기에 0xFF(00 00 00 FF)를 비트 AND 하면 위쪽 24비트가 모두 0으로 지워지고 아래 8비트만 남아 00 00 00 FF, 즉 255가 됩니다. Byte.toUnsignedInt도 같은 계산을 하는 메서드인데, 이름에 의도가 드러난다는 장점이 있습니다.
글자에 번호를 붙이고, 번호를 바이트로 적기
컴퓨터가 글자를 저장하려면 먼저 글자마다 번호를 정해야 합니다. 이 번호를 코드 포인트(code point)라고 하고, 어떤 글자들에 어떤 번호를 붙일지 정한 목록을 문자 집합(character set)이라고 합니다. 오늘날 기준이 되는 문자 집합은 유니코드(Unicode)입니다. 유니코드는 세계 여러 문자에 U+0000부터 U+10FFFF까지의 번호를 붙이며, 번호는 U+ 뒤에 16진수를 적어 나타냅니다. 예를 들어 A는 U+0041, 가는 U+AC00, 이모지 😀는 U+1F600입니다.
번호가 정해졌다고 바로 저장할 수 있는 것은 아닙니다. U+1F600은 한 바이트에 들어가지 않는 큰 수이므로, 이 수를 몇 바이트에 어떤 순서로 나눠 적을지를 또 정해야 합니다. 이 규칙이 문자 인코딩(character encoding)입니다. 유니코드라는 하나의 번호표를 두고도 UTF-8, UTF-16 같은 여러 인코딩이 있고, 인코딩마다 같은 글자를 서로 다른 바이트로 적습니다.
Java는 이 둘을 묶어 Charset이라는 클래스로 다룹니다. Charset 하나는 'Java 문자열과 바이트열을 서로 바꾸는 이름 붙은 규칙'이어서, 어떤 글자를 다룰 수 있는지와 그 글자를 어떤 바이트로 적는지를 함께 담고 있습니다. 그래서 Java 문서에서는 '문자 집합'과 '인코딩'을 거의 같은 뜻으로 쓰는 경우가 많고, 이 교재도 그렇게 씁니다. Charset 사용법은 다음 글에서 자세히 다루고, 이 글에서는 이름으로 Charset을 얻어 getBytes에 넘기는 정도만 사용합니다.
같은 글자가 인코딩마다 다른 바이트가 되는 모습
다음 프로그램은 글자 다섯 개를 일곱 가지 인코딩으로 바꿔, 각각 어떤 바이트가 되는지 16진수로 출력합니다. String.getBytes(Charset)은 문자열을 지정한 인코딩의 바이트 배열로 바꾸고, HexFormat은 바이트 배열을 16진수 문자열로 바꿔 줍니다.
import java.nio.charset.Charset;
import java.util.HexFormat;
import java.util.List;
public final class EncodingDump {
public static void main(String[] args) {
List<String> texts = List.of("A", "é", "가", "똠", "😀");
List<String> names = List.of("US-ASCII", "ISO-8859-1", "EUC-KR", "MS949",
"UTF-8", "UTF-16BE", "UTF-16LE");
HexFormat hex = HexFormat.ofDelimiter(" ").withUpperCase();
StringBuilder header = new StringBuilder(String.format("%-12s", ""));
for (String text : texts) {
header.append(String.format("%-13s", String.format("U+%04X", text.codePointAt(0))));
}
System.out.println(header.toString().stripTrailing());
for (String name : names) {
Charset charset = Charset.forName(name);
StringBuilder row = new StringBuilder(String.format("%-12s", name));
for (String text : texts) {
row.append(String.format("%-13s", hex.formatHex(text.getBytes(charset))));
}
System.out.println(row.toString().stripTrailing());
}
}
}출력은 표 모양입니다. 첫 줄에는 다섯 글자의 코드 포인트가, 그 아래 일곱 줄에는 인코딩마다 각 글자를 바꾼 바이트가 16진수로 나옵니다.
U+0041 U+00E9 U+AC00 U+B620 U+1F600
US-ASCII 41 3F 3F 3F 3F
ISO-8859-1 41 E9 3F 3F 3F
EUC-KR 41 3F B0 A1 3F 3F
MS949 41 3F B0 A1 8C 63 3F
UTF-8 41 C3 A9 EA B0 80 EB 98 A0 F0 9F 98 80
UTF-16BE 00 41 00 E9 AC 00 B6 20 D8 3D DE 00
UTF-16LE 41 00 E9 00 00 AC 20 B6 3D D8 00 DE맨 윗줄은 차례로 A, é, 가, 똠, 😀의 코드 포인트입니다. 나머지 줄은 인코딩 하나에 해당하며, 각 인코딩의 특징은 다음과 같습니다.
- US-ASCII는 영문자와 숫자, 기본 기호 128개만 정의한 7비트 문자 집합이라
00부터7F까지만 씁니다.A의41은 ASCII 번호이자 유니코드 번호입니다. - ISO-8859-1은 한 바이트의 나머지 절반을 써서
é같은 서유럽 문자를 더했습니다. 이 문자 집합에서는 바이트 값이 곧 유니코드 번호여서é(U+00E9)가E9가 됩니다. - EUC-KR은 한국 표준 문자 집합(KS X 1001)을 쓰는 인코딩으로, 한글 한 음절을 두 바이트로 적습니다. 다만 자주 쓰는 완성형 한글 2,350자만 들어 있어서
똠은 표현하지 못합니다. - MS949는 마이크로소프트가 EUC-KR을 확장한 인코딩입니다.
가처럼 EUC-KR에 있던 글자는 같은 바이트로 적고, 빠져 있던 글자를 더해 현대 한글 11,172자를 모두 표현하므로똠이8C 63이 됩니다. CP949라고도 부르며, Java에서의 정식 이름은x-windows-949입니다. - UTF-8은 유니코드 전체를 1바이트부터 4바이트까지의 가변 길이로 적습니다. ASCII 범위의 글자는 ASCII와 똑같은 한 바이트로 적고,
é는 2바이트,가와똠은 3바이트, 😀는 4바이트입니다. - UTF-16BE와 UTF-16LE는 16비트(2바이트) 단위로 적습니다.
U+FFFF까지의 글자는 2바이트, 그보다 번호가 큰 😀는 2바이트 단위 두 개로 모두 4바이트입니다. 두 인코딩은 2바이트 안에서 큰 자리를 먼저 쓰느냐(BE, big-endian) 작은 자리를 먼저 쓰느냐(LE, little-endian)만 다릅니다.
표에서 3F가 자주 보이는 것도 눈여겨봐야 합니다. 3F는 물음표 ?의 ASCII 코드입니다. getBytes는 대상 인코딩으로 표현할 수 없는 글자를 만나면 예외를 던지는 대신 그 인코딩의 대체 바이트로 바꾸는데, 이 표의 인코딩들은 모두 ?를 씁니다. 그래서 똠을 EUC-KR로 바꾸면 조용히 ? 한 바이트가 되고, 이 바이트만 보고서는 원래 글자가 무엇이었는지 알 수 없습니다. 이렇게 정보가 사라지는 것을 막는 방법은 다음 글에서 다룹니다.
더 깊이: UTF-8이 코드 포인트를 바이트로 나누는 방식
UTF-8은 코드 포인트의 크기에 따라 다음 네 가지 틀 가운데 하나를 고르고, x 자리에 코드 포인트의 비트를 앞에서부터 채웁니다.
| 코드 포인트 범위 | 바이트 수 | 비트 배치 |
|---|---|---|
U+0000부터 U+007F까지 | 1 | 0xxxxxxx |
U+0080부터 U+07FF까지 | 2 | 110xxxxx 10xxxxxx |
U+0800부터 U+FFFF까지 | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000부터 U+10FFFF까지 | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
가(U+AC00)는 셋째 틀에 해당합니다. AC00을 2진수로 쓰면 1010 1100 0000 0000이고, 이 16비트를 4·6·6비트(1010, 110000, 000000)로 나눠 틀에 넣으면 11101010 10110000 10000000, 16진수로 EA B0 80이 됩니다. 앞의 표와 같은 값입니다.
이 설계에는 이유가 있습니다. 첫 바이트의 앞쪽 비트만 보면 글자가 몇 바이트짜리인지 알 수 있고, 이어지는 바이트는 모두 10으로 시작하므로 중간 어디에서 읽기 시작해도 다음 글자의 시작을 찾을 수 있습니다. 또 여러 바이트로 된 글자의 바이트는 모두 80 이상이라서, /나 , 같은 ASCII 바이트가 한글 글자의 일부로 잘못 나타나는 일이 없습니다. 반대로 10으로 시작하는 바이트가 홀로 나오거나 틀이 중간에 끊긴 바이트열은 올바른 UTF-8이 아닙니다. 바로 다음 절에서 이런 바이트를 UTF-8로 읽으면 어떻게 되는지 확인합니다.
다른 인코딩으로 읽으면 생기는 일
인코딩마다 같은 글자를 다른 바이트로 적는다는 것은, 바이트를 읽는 쪽도 어떤 인코딩으로 적었는지 알아야 한다는 뜻입니다. 다음 프로그램은 서울을 UTF-8과 MS949로 각각 인코딩한 뒤, 일부러 다른 인코딩으로 디코딩해 봅니다. new String(bytes, charset)은 바이트 배열을 지정한 인코딩으로 읽어 문자열을 만듭니다.
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public final class KoreanMojibake {
public static void main(String[] args) {
Charset ms949 = Charset.forName("MS949");
HexFormat hex = HexFormat.ofDelimiter(" ").withUpperCase();
byte[] utf8 = "서울".getBytes(StandardCharsets.UTF_8);
byte[] legacy = "서울".getBytes(ms949);
System.out.println("UTF-8 bytes : " + hex.formatHex(utf8));
System.out.println("MS949 bytes : " + hex.formatHex(legacy));
System.out.println("UTF-8 bytes read as MS949 : " + new String(utf8, ms949));
System.out.println("MS949 bytes read as UTF-8 : " + new String(legacy, StandardCharsets.UTF_8));
System.out.println("UTF-8 bytes read as UTF-8 : " + new String(utf8, StandardCharsets.UTF_8));
}
}앞의 두 줄은 두 인코딩이 만든 바이트이고, 뒤의 세 줄은 그 바이트를 읽은 결과입니다. 바이트를 만든 인코딩과 읽은 인코딩이 같은 줄은 마지막 줄뿐입니다.
UTF-8 bytes : EC 84 9C EC 9A B8
MS949 bytes : BC AD BF EF
UTF-8 bytes read as MS949 : �꽌�슱
MS949 bytes read as UTF-8 : ����
UTF-8 bytes read as UTF-8 : 서울같은 서울이 UTF-8로는 6바이트, MS949로는 4바이트입니다. 셋째 줄은 UTF-8 바이트를 MS949로 읽은 결과입니다. MS949 디코더는 EC 84를 올바른 두 바이트 조합으로 인정하지 않아 EC를 대체 문자 �(U+FFFD)로 바꾸고, 이어지는 84 9C를 MS949의 꽌으로 읽습니다. 뒤쪽 세 바이트도 같은 식으로 �와 슱이 됩니다.
넷째 줄은 반대 경우입니다. UTF-8 규칙에서 BC, AD, BF는 이어지는 바이트(10으로 시작)인데 앞에 시작 바이트가 없고, EF는 3바이트 글자의 시작인데 뒤가 끊겼습니다. 그래서 네 바이트가 모두 �가 되었습니다. 마지막 줄처럼 인코딩할 때와 같은 규칙으로 읽어야만 서울이 돌아옵니다.
이 결과에서 기억할 점이 두 가지 있습니다. 첫째, 디코딩은 바이트가 규칙에 맞지 않아도 대개 예외 없이 끝나고 �나 엉뚱한 글자가 섞인 문자열을 돌려줍니다. 프로그램이 멈추지 않으니 잘못을 알아채기 어렵습니다. 둘째, �로 바뀐 자리의 원래 바이트는 그 문자열에서 되찾을 수 없습니다. 그래서 깨진 문자열을 나중에 고치려 하기보다 처음부터 올바른 인코딩으로 읽어야 하며, 규칙에 맞지 않는 바이트를 조용히 넘기지 않고 예외로 받는 방법은 다음 글에서 다룹니다.
Java의 char는 UTF-16 코드 단위입니다
Java의 String은 글자를 UTF-16 방식으로 다룹니다. 문자열을 이루는 char 하나는 16비트 값, 즉 UTF-16의 코드 단위(code unit) 하나입니다. U+0000부터 U+FFFF까지의 글자는 코드 단위 하나에 들어가므로 char 하나로 충분하지만, 😀(U+1F600)처럼 그보다 번호가 큰 글자는 코드 단위 두 개, 즉 char 두 개로 나타냅니다. 이 두 char를 서로게이트 쌍(surrogate pair)이라고 하고, 앞쪽을 상위 서로게이트, 뒤쪽을 하위 서로게이트라고 부릅니다. 앞 표에서 😀의 UTF-16BE 바이트가 D8 3D DE 00이었는데, 이것이 바로 char 두 개 D83D와 DE00입니다.
그래서 기본 타입과 리터럴에서 보았듯 String.length()는 글자 수가 아니라 코드 단위 수를 돌려줍니다. 코드 포인트 수가 필요하면 codePointCount를 쓰고, 코드 포인트를 하나씩 꺼내려면 codePoints()를 씁니다. 그런데 코드 포인트 수도 사람이 화면에서 세는 글자 수와 항상 같지는 않습니다. 여러 코드 포인트가 모여 화면에서 한 글자로 보이는 경우가 있기 때문이며, 사람이 한 글자로 인식하는 이 단위를 자소 묶음(grapheme cluster)이라고 합니다.
다음 프로그램은 여섯 문자열을 세 가지 단위로 세어 봅니다. 정규 표현식의 \X는 자소 묶음 하나와 일치하는 패턴(Java 9부터 지원)이라서, 일치하는 횟수를 세면 자소 묶음 수가 됩니다. 결합 문자처럼 눈으로 구별하기 어려운 문자열은 소스에 \u 이스케이프로 적고, 출력에는 이름표를 붙였습니다.
import java.util.regex.Pattern;
public final class CodePointInspector {
private static final Pattern GRAPHEME = Pattern.compile("\\X");
public static void main(String[] args) {
inspect("A", "A");
inspect("가", "가");
inspect("😀", "😀");
inspect("엄지+피부색", "👍🏽");
inspect("e+악센트", "e\u0301");
inspect("한(자모 3개)", "\u1112\u1161\u11AB");
}
private static void inspect(String label, String text) {
int units = text.length();
int points = text.codePointCount(0, text.length());
long graphemes = GRAPHEME.matcher(text).results().count();
String codes = text.codePoints()
.mapToObj(value -> String.format("U+%04X", value))
.reduce((left, right) -> left + " " + right)
.orElse("");
System.out.printf("%s: units=%d points=%d graphemes=%d codes=%s%n",
label, units, points, graphemes, codes);
}
}줄마다 units는 length()가 센 char 개수, points는 코드 포인트 수, graphemes는 자소 묶음 수입니다. 세 숫자가 서로 달라지는 줄이 어디인지 찾아보세요.
A: units=1 points=1 graphemes=1 codes=U+0041
가: units=1 points=1 graphemes=1 codes=U+AC00
😀: units=2 points=1 graphemes=1 codes=U+1F600
엄지+피부색: units=4 points=2 graphemes=1 codes=U+1F44D U+1F3FD
e+악센트: units=2 points=2 graphemes=1 codes=U+0065 U+0301
한(자모 3개): units=3 points=3 graphemes=1 codes=U+1112 U+1161 U+11ABA와 가는 세 숫자가 모두 1입니다. 😀는 코드 포인트 하나지만 서로게이트 쌍이라 length()가 2입니다. 넷째 줄은 엄지 이모지(U+1F44D) 뒤에 피부색을 바꾸는 수정자(U+1F3FD)가 붙은 것으로, 둘 다 U+FFFF보다 커서 char로는 네 개지만 화면에는 피부색이 바뀐 엄지 하나로 보입니다. 다섯째 줄은 e 뒤에 앞 글자 위에 악센트를 얹는 결합 문자(U+0301)가 온 것으로, 화면에는 é 한 글자로 보입니다.
마지막 줄은 한글 자모인 첫소리 ㅎ(U+1112), 가운뎃소리 ㅏ(U+1161), 끝소리 ㄴ(U+11AB)이 이어져 한 한 글자로 보이는 경우입니다. 이 문자열은 완성형 한(U+D55C) 한 글자와 화면에서는 똑같아 보이지만 코드 포인트가 다르므로, equals로 비교하면 false가 나옵니다. 이런 문자열을 같은 글자로 비교해야 한다면 java.text.Normalizer로 정규화(normalization)해 표현을 하나로 맞춘 뒤 비교합니다. 예를 들어 Normalizer.normalize(text, Normalizer.Form.NFC)는 이 자모 세 개를 완성형 한으로 바꿉니다.
그러면 무엇을 세야 할까요? 그 숫자를 어디에 쓰느냐에 따라 다릅니다. 파일 크기나 전송량, 바이트 단위로 정해진 저장 공간처럼 저장과 전송에 관한 제한은 인코딩한 뒤의 바이트 수로 따져야 합니다. substring이나 charAt처럼 Java 문자열의 위치를 다룰 때는 코드 단위를 씁니다. 화면에 보이는 글자 수를 제한하는 기능이라면 자소 묶음 단위로 세야 합니다. 길이 제한을 정할 때는 이 가운데 무엇을 세는지 이름이나 문서에 분명히 적어 두는 것이 좋습니다. 예를 들어 maxLength보다 maxUtf8Bytes가 오해의 여지가 적습니다.
문자열을 자를 때 서로게이트 쌍을 끊지 않기
length()와 substring이 코드 단위를 기준으로 하다 보니, 문자열을 아무 위치에서나 자르면 서로게이트 쌍이 반으로 갈릴 수 있습니다. 다음 프로그램은 "😀A"에서 앞의 한 글자만 잘라 내려다 실수하는 경우와 올바르게 자르는 경우를 비교합니다.
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public final class SafeTruncate {
public static void main(String[] args) {
String text = "😀A";
String broken = text.substring(0, 1);
System.out.println("broken length=" + broken.length()
+ " highSurrogate=" + Character.isHighSurrogate(broken.charAt(0)));
System.out.println("broken utf8=" + Arrays.toString(broken.getBytes(StandardCharsets.UTF_8)));
int end = text.offsetByCodePoints(0, 1);
String safe = text.substring(0, end);
System.out.println("safe length=" + safe.length()
+ " utf8=" + Arrays.toString(safe.getBytes(StandardCharsets.UTF_8)));
}
}앞의 두 줄은 substring(0, 1)로 자른 결과이고, 마지막 줄은 코드 포인트 단위로 위치를 계산해 자른 결과입니다. 잘라 낸 문자열을 UTF-8로 바꾼 바이트를 보면 😀가 온전히 남았는지 알 수 있습니다.
broken length=1 highSurrogate=true
broken utf8=[63]
safe length=2 utf8=[-16, -97, -104, -128]substring(0, 1)은 char 하나, 즉 😀의 상위 서로게이트만 잘라 냅니다. 짝이 없는 서로게이트는 어떤 글자도 아니므로, 이것을 UTF-8로 인코딩하면 getBytes가 대체 바이트 ?(63)로 바꿔 버립니다. 반면 offsetByCodePoints(0, 1)은 0번 위치에서 코드 포인트 하나만큼 나아간 위치, 여기서는 2를 돌려주므로 서로게이트 쌍을 통째로 포함해 자릅니다. 마지막 줄의 네 값은 앞 표의 F0 9F 98 80을 부호 있는 byte로 출력한 것입니다.
코드 포인트 단위로 잘라도 끝난 것은 아닙니다. e와 결합 악센트 사이처럼 자소 묶음의 중간은 여전히 끊길 수 있습니다. 화면에 보이는 글자 단위로 잘라야 한다면 앞 예제처럼 \X 정규 표현식으로 자소 묶음을 하나씩 찾으면서 자를 위치를 정합니다.
더 깊이: 서로게이트 쌍 계산과 String의 내부 저장
U+FFFF보다 큰 코드 포인트는 다음 규칙으로 두 char로 나뉩니다. 코드 포인트에서 0x10000을 빼면 20비트 수가 남는데, 위쪽 10비트를 0xD800에 더한 것이 상위 서로게이트이고 아래쪽 10비트를 0xDC00에 더한 것이 하위 서로게이트입니다. 😀라면 0x1F600에서 0x10000을 뺀 0xF600을 20비트로 쓴 0000111101 1000000000에서, 위쪽 10비트 0x3D로 D83D가, 아래쪽 10비트 0x200으로 DE00이 나옵니다. 유니코드는 D800부터 DFFF까지의 번호를 이 용도로 비워 두고 글자를 배정하지 않으므로, 서로게이트를 일반 글자와 헷갈릴 일이 없습니다.
String이 UTF-16으로 동작한다는 것은 메서드의 의미에 관한 이야기입니다. JDK 9부터는 메모리를 아끼려고, 모든 글자가 U+00FF 이하인 문자열을 내부에 글자당 1바이트(Latin-1)로 저장합니다. 이를 압축 문자열(compact strings)이라고 합니다. 하지만 length(), charAt, substring 같은 메서드는 저장 방식과 관계없이 언제나 UTF-16 코드 단위를 기준으로 동작하므로, 코드를 작성할 때는 이 차이를 신경 쓰지 않아도 됩니다.
인코딩을 지정하지 않으면 기본 문자 집합을 씁니다
getBytes()와 new String(bytes)처럼 Charset을 받지 않는 메서드는 JVM의 기본 문자 집합(default charset)을 씁니다. JDK 18부터는 JEP 400에 따라, 따로 설정하지 않는 한 실행 환경과 관계없이 기본 문자 집합이 UTF-8입니다. 이 교재가 기준으로 삼는 JDK 25도 마찬가지이고, 이 글의 예제를 실행한 JDK 21도 같습니다. 그 전에는 운영체제와 지역 설정에 따라 기본값이 정해져서, 같은 프로그램이 한국어 Windows에서는 MS949로, UTF-8 지역 설정을 쓰는 Linux나 macOS에서는 UTF-8로 동작했습니다.
다음 프로그램은 문자 집합을 지정하지 않고 문자열을 바이트로 바꿨다가 되돌립니다. 기본 문자 집합과 함께 System.out이 화면에 출력할 때 쓰는 문자 집합도 출력합니다.
import java.nio.charset.Charset;
import java.util.Arrays;
public final class ImplicitCharset {
public static void main(String[] args) {
String source = "서울 café";
byte[] bytes = source.getBytes();
String restored = new String(bytes);
System.out.println("default=" + Charset.defaultCharset());
System.out.println("stdout=" + System.out.charset());
System.out.println("bytes=" + Arrays.toString(bytes));
System.out.println("restored=" + restored);
System.out.println("same=" + source.equals(restored));
}
}UTF-8을 쓰는 터미널에서 실행한 결과입니다. 앞의 두 줄이 두 문자 집합의 이름이고, 마지막 줄 same=은 바이트로 바꿨다가 되돌린 문자열이 원래와 같은지를 보여 줍니다.
default=UTF-8
stdout=UTF-8
bytes=[-20, -124, -100, -20, -102, -72, 32, 99, 97, 102, -61, -87]
restored=서울 café
same=truebytes의 처음 여섯 값은 서울의 UTF-8 바이트 EC 84 9C EC 9A B8을 부호 있는 byte로 출력한 것입니다. 이어서 32는 공백, 99·97·102는 c, a, f이고, 마지막 -61과 -87은 é의 C3 A9입니다. 같은 기본 문자 집합으로 인코딩하고 디코딩했으니 원래 문자열이 그대로 돌아왔습니다.
이제 같은 프로그램을 환경만 바꿔 실행해 보겠습니다. 아래 명령의 LANG=C는 Linux에서 지역 설정을 ASCII만 쓰는 기본값으로 바꾸고, -Dfile.encoding=COMPAT는 JDK 17까지의 방식대로 운영체제 설정에서 기본 문자 집합을 정하라는 옵션입니다.
LANG=C java -Dfile.encoding=COMPAT ImplicitCharsetdefault=US-ASCII
stdout=US-ASCII
bytes=[63, 63, 32, 99, 97, 102, 63]
restored=?? caf?
same=false이번에는 기본 문자 집합이 US-ASCII가 되었습니다. US-ASCII에는 서, 울, é가 없으므로 getBytes()가 세 글자를 모두 63(?)으로 바꿨고, 되돌린 문자열도 ?? caf?가 되어 원문과 달라졌습니다. 정보는 화면에 출력할 때가 아니라 인코딩하는 순간에 이미 사라진 것입니다.
-Dfile.encoding=COMPAT 없이 LANG=C만 바꾸면 결과가 또 다릅니다.
LANG=C java ImplicitCharsetdefault=UTF-8
stdout=US-ASCII
bytes=[-20, -124, -100, -20, -102, -72, 32, 99, 97, 102, -61, -87]
restored=?? caf?
same=true기본 문자 집합은 UTF-8이라 바이트도 맞고 same=true인데, 화면에는 ?? caf?가 찍혔습니다. System.out은 기본 문자 집합이 아니라 운영체제의 콘솔·지역 설정에 맞춘 별도의 문자 집합(System.out.charset())으로 출력하기 때문입니다. 데이터는 멀쩡하고 출력 단계에서만 글자가 바뀐 것입니다. 1장에서 한글이 ?로 출력되면 소스가 아니라 터미널의 인코딩 설정을 의심하라고 한 것도 이 때문입니다.
세 실행 결과가 보여 주듯, 문자 집합을 지정하지 않은 코드는 실행 환경에 따라 결과가 달라질 수 있습니다. JDK 18부터 기본값이 UTF-8로 고정되면서 이런 일은 크게 줄었지만, 옛 JDK에서 실행되거나 누군가 옵션으로 기본값을 바꾸면 여전히 생깁니다. 더 근본적인 이유도 있습니다. 파일이나 통신 메시지가 어떤 인코딩인지는 그 데이터의 형식이 정하는 것이지 프로그램을 실행하는 컴퓨터가 정하는 것이 아닙니다. 그래서 파일, 네트워크, 다른 시스템과 주고받는 데이터에는 getBytes(StandardCharsets.UTF_8), new String(bytes, StandardCharsets.UTF_8)처럼 언제나 문자 집합을 직접 적습니다.
더 깊이: 문자 집합과 관련된 시스템 속성
JVM은 시작할 때 문자 집합과 관련된 시스템 속성 몇 가지를 정합니다. JDK 17에서 생긴 native.encoding은 운영체제 설정에서 알아낸 문자 집합 이름으로, JDK 17까지라면 기본 문자 집합이 되었을 값입니다. file.encoding은 기본 문자 집합의 이름이며 JDK 18부터는 따로 지정하지 않으면 UTF-8입니다. JDK 19부터 표준 속성이 된 stdout.encoding은 System.out이 쓰는 문자 집합의 이름입니다.
앞의 LANG=C 환경에서 이 값들을 출력해 보면 native.encoding과 stdout.encoding은 ANSI_X3.4-1968(US-ASCII의 다른 이름)이고 file.encoding은 UTF-8이었습니다. System.out의 문자 집합만 바꾸고 싶다면 java -Dstdout.encoding=UTF-8 ImplicitCharset처럼 지정할 수 있지만, 터미널이 실제로 그 인코딩을 표시할 수 있어야 글자가 제대로 보입니다.
새 텍스트에는 UTF-8을 쓰고, 입력의 인코딩은 확인하기
새로 만드는 텍스트 파일과 통신 형식에는 대개 UTF-8을 씁니다. 유니코드의 모든 글자를 표현할 수 있고, ASCII 범위에서는 바이트가 ASCII와 같아서 영문 위주의 기존 도구와 잘 어울리며, UTF-16과 달리 바이트 순서를 따질 필요도 없기 때문입니다. JSON의 표준 문서(RFC 8259)처럼 시스템 사이에 주고받는 데이터를 UTF-8로 인코딩하도록 정해 둔 형식도 많습니다.
다만 새 데이터를 UTF-8로 쓴다고 해서 모든 입력이 UTF-8인 것은 아닙니다. 오래된 시스템이 만든 MS949 파일을 UTF-8로 읽으면 앞에서 본 것처럼 �투성이가 됩니다. 입력의 인코딩은 파일 형식의 명세, HTTP 응답의 Content-Type 헤더, 사용자의 선택처럼 확실한 정보에서 알아내야 합니다. 바이트만 보고 인코딩을 항상 정확히 알아내는 방법은 없기 때문입니다.
파일 맨 앞에 BOM(byte order mark)이라는 U+FEFF 글자를 붙이는 경우도 있습니다. UTF-16에서는 이 글자의 바이트 순서(FE FF인지 FF FE인지)로 BE와 LE를 알리고, UTF-8에서는 EF BB BF 세 바이트가 되는데 UTF-8 파일이라는 표시로 붙이는 프로그램이 있습니다. Java의 UTF-8 디코더는 이 세 바이트를 지우지 않고 U+FEFF 글자로 남겨 두므로, BOM이 붙은 파일을 그대로 읽으면 첫 줄 맨 앞에 보이지 않는 글자가 하나 섞입니다. BOM을 다루는 방법은 다음 글에서 UTF-16의 변형들과 함께 살펴봅니다.
정리
- 파일과 네트워크에는 바이트만 흐릅니다. 글자는 인코딩으로 바이트가 되고 디코딩으로 돌아오며, 양쪽이 같은 규칙을 써야 원래 글자가 복원됩니다.
- Java의
byte는 -128부터 127까지의 부호 있는 값입니다. 음수로 출력되어도 비트는 그대로이며, 0부터 255로 비교해야 할 때는Byte.toUnsignedInt나& 0xFF를 씁니다. - 코드 포인트는 유니코드가 글자에 붙인 번호이고, 인코딩은 그 번호를 바이트로 적는 방법입니다. 같은
가가 UTF-8로는EA B0 80, UTF-16BE로는AC 00, MS949로는B0 A1입니다. getBytes와new String은 표현할 수 없거나 규칙에 맞지 않는 입력을?나�로 조용히 바꿉니다. 바뀐 자리의 원래 정보는 되찾을 수 없습니다.char는 UTF-16 코드 단위 하나이고String.length()는 코드 단위 수입니다. 바이트, 코드 단위, 코드 포인트, 자소 묶음 가운데 무엇을 세는지 구분해야 합니다.- JDK 18부터 기본 문자 집합은 UTF-8이지만
System.out의 문자 집합은 따로 정해집니다. 외부와 주고받는 데이터에는 언제나 문자 집합을 직접 지정합니다.
연습 문제
1. 네 가지 길이를 함께 출력하기
ABC, 한글, A😀 세 문자열이 UTF-8과 UTF-16BE에서 차지하는 바이트 수를 출력하세요. UTF-16 코드 단위 수와 코드 포인트 수도 함께 출력하고, 네 숫자가 어떤 입력에서 서로 달라지는지 설명하세요.
정답과 해설
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
public final class TextStorageBudgetSolution {
private static int bytes(String text, Charset charset) {
return text.getBytes(charset).length;
}
public static void main(String[] args) {
for (String text : List.of("ABC", "한글", "A😀")) {
int points = text.codePointCount(0, text.length());
System.out.printf("%-4s units=%d points=%d utf8=%d utf16be=%d%n",
text, text.length(), points,
bytes(text, StandardCharsets.UTF_8),
bytes(text, StandardCharsets.UTF_16BE));
}
}
}실행 결과:
ABC units=3 points=3 utf8=3 utf16be=6
한글 units=2 points=2 utf8=6 utf16be=4
A😀 units=3 points=2 utf8=5 utf16be=6동일 문자열의 코드 단위·코드 포인트·저장 바이트
문자 저장 · 네 가지 길이
동일 문자열의 코드 단위·코드 포인트·저장 바이트
A😀는 코드 포인트 2개지만 UTF-16 코드 단위는 3개입니다.
| 문자열 | UTF-16 단위 | 코드 포인트 | UTF-8 바이트 | UTF-16BE 바이트 |
|---|---|---|---|---|
| ABC | 3 | 3 | 3 | 6 |
| 한글 | 2 | 2 | 6 | 4 |
| A😀 | 3 | 2 | 5 | 6 |
- ABC
- UTF-16 단위: 3
- 3
- 코드 포인트: 3
- 3
- UTF-8 바이트: 3
- 3
- UTF-16BE 바이트: 6
- 6
- 한글
- UTF-16 단위: 2
- 2
- 코드 포인트: 2
- 2
- UTF-8 바이트: 6
- 6
- UTF-16BE 바이트: 4
- 4
- A😀
- UTF-16 단위: 3
- 3
- 코드 포인트: 2
- 2
- UTF-8 바이트: 5
- 5
- UTF-16BE 바이트: 6
- 6
연습 문제의 리터럴을 인코딩 규칙으로 계산한 값입니다. UTF-16BE는 BOM을 추가하지 않으며, 어느 열도 화면 너비를 뜻하지 않습니다.
ABC는 모든 글자가 ASCII라서 코드 단위, 코드 포인트, UTF-8 바이트 수가 모두 3이고, UTF-16BE에서만 글자당 2바이트씩 6바이트입니다. 한글의 두 음절은 UTF-8에서 각각 3바이트라 6바이트이고, UTF-16BE에서는 코드 단위 하나씩이라 4바이트입니다. 한글처럼 U+0800부터 U+FFFF까지의 글자는 UTF-16으로 적는 편이 UTF-8보다 짧습니다.
A😀는 코드 포인트가 2개지만 이모지가 서로게이트 쌍이라 코드 단위는 3개입니다. UTF-8에서는 A 1바이트와 😀 4바이트를 더해 5바이트이고, UTF-16BE에서는 코드 단위 세 개를 2바이트씩 적어 6바이트입니다. UTF-16BE는 BOM을 붙이지 않으므로 BOM 바이트는 이 계산에 들어가지 않습니다.
출력의 칸이 어긋나 보이는 것도 이 글의 내용과 관련이 있습니다. %-4s는 문자열을 4칸에 맞춰 왼쪽 정렬하는데, 이때 칸 수를 length(), 즉 코드 단위 수로 계산합니다. 그래서 한글(2단위) 뒤에는 공백이 2개, A😀(3단위) 뒤에는 1개 붙습니다. 하지만 터미널은 한글과 이모지를 대개 두 칸 너비로 그리기 때문에 화면에서는 줄이 맞지 않습니다.
2. 문자열 하나를 더 계산하기
1번 프로그램의 목록에 가😀를 추가하면 코드 단위 수, 코드 포인트 수, UTF-8 바이트 수, UTF-16BE 바이트 수는 각각 얼마일까요? 화면에서 이모지를 한 글자로 본다고 해서 length()를 2로 예상해도 될까요?
정답과 해설
코드 단위는 가의 1개와 😀의 2개를 더해 3개이고, 코드 포인트는 2개입니다. UTF-8은 가 3바이트와 😀 4바이트를 더해 7바이트, UTF-16BE는 코드 단위 세 개를 2바이트씩 적어 6바이트입니다. 실제로 추가해 실행하면 units=3 points=2 utf8=7 utf16be=6이 출력됩니다. 화면에는 두 글자로 보이지만 length()는 코드 단위를 세므로 2가 아니라 3입니다.
3. PNG 파일인지 확인하는 코드의 버그
PNG 이미지 파일은 언제나 89 50 4E 47 0D 0A 1A 0A 여덟 바이트로 시작합니다. 다음 코드는 파일의 첫 바이트가 89인지 확인하려고 했지만, PNG 파일의 머리 부분을 넣어도 false를 출력합니다. 이유를 설명하고 고치세요.
byte[] header = {(byte) 0x89, 0x50, 0x4E, 0x47};
System.out.println(header[0] == 0x89);정답과 해설
0x89는 137이라서 byte가 나타낼 수 있는 127을 넘습니다. 그래서 header[0]에 저장된 값은 137에서 256을 뺀 -119이고, 비교할 때 int -119로 바뀌어 int 137과 같지 않습니다. 실제로 header[0]을 출력하면 -119가 나옵니다. 부호 없는 값으로 바꿔서 비교하거나, 비교할 상수를 byte로 바꿔서 비교하면 됩니다.
System.out.println(Byte.toUnsignedInt(header[0]) == 0x89); // true
System.out.println(header[0] == (byte) 0x89); // true바이트 하나만 비교한다면 두 방법 모두 괜찮습니다. 하지만 여러 바이트를 읽어 길이나 번호 같은 숫자로 조합할 때는 각 바이트를 Byte.toUnsignedInt로 먼저 0부터 255 사이의 값으로 바꿔야 계산이 맞습니다.
다음 글
Charset과 인코딩 오류 처리에서는 Charset 객체를 얻고 비교하는 방법을 익히고, 잘못된 인코딩으로 읽은 경우와 대체 문자로 정보가 사라진 경우를 구별합니다. 또 getBytes처럼 조용히 바꾸는 대신 예외로 알려 주는 엄격한 변환 방법을 배웁니다.