문자와 바이트 표현
비트와 바이트의 표현 범위, 코드 포인트와 문자 집합의 역할, ASCII·EUC-KR·MS949·UTF 계열의 차이를 데이터 왕복 관점에서 설명합니다.
파일과 네트워크에는 String 자체가 흐르지 않습니다.
저장 장치와 소켓이 다루는 것은 바이트이고, 문자는 약속된 문자 집합을 통해 바이트열로 바뀝니다.
보내는 쪽과 받는 쪽이 같은 약속을 사용해야 원래 글자가 복원됩니다.
한글 깨짐은 글꼴 문제처럼 보이지만 실제로는 인코딩과 디코딩의 약속이 어긋난 경우가 많습니다.
한 비트는 두 상태를 표현하고 여덟 비트인 한 바이트는 256개 조합을 가집니다.
자바 byte의 숫자 범위는 -128부터 127이지만 메모리에 놓인 여덟 비트 조합은 그대로입니다.
음수로 출력된 바이트가 파일에서 사라지거나 다른 값으로 저장되는 것은 아닙니다.
숫자를 사람이 해석하는 방식과 물리적인 비트 패턴을 분리해 생각해야 합니다.
기본 문자 집합 의존에 따른 재현성 손실
아래 프로그램은 소스 문자열을 바이트로 만들고 되돌리지만 문자 집합을 지정하지 않습니다. 개발 컴퓨터와 배포 서버의 기본값이 다르면 같은 코드가 서로 다른 파일을 만들 수 있습니다. 컨테이너 이미지 교체나 운영체제 설정 변경만으로도 데이터 형식이 바뀌는 숨은 의존성입니다.
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("bytes=" + Arrays.toString(bytes));
System.out.println("restored=" + restored);
}
}한 프로세스 안에서는 우연히 같은 기본값을 두 번 써서 왕복에 성공할 수 있습니다. 그러나 파일을 다른 시스템이 읽거나 HTTP 헤더가 UTF-8을 선언하면 이 성공은 의미가 없습니다. 영속 데이터와 통신 프로토콜에는 문자 집합을 명시하고 기본값은 콘솔처럼 환경 규칙이 분명한 접점에서만 제한적으로 사용합니다.
비트 패턴과 Java byte 부호
자바의 byte는 부호 있는 정수이므로 최상위 비트가 1인 패턴을 음수로 표시합니다.
파일 형식에서 0부터 255까지의 값을 비교할 때는 Byte.toUnsignedInt를 사용하면 의도가 드러납니다.
캐스팅으로 값을 줄인 뒤 다시 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);
}
}
}출력 숫자가 -1이어도 비트는 11111111이며 부호 없음 관점에서는 255입니다.
인코딩 결과 배열을 로그로 볼 때 음수가 보이는 이유도 같습니다.
바이트 배열을 다른 문자 집합으로 다시 해석하지 않는 한 부호 표시는 문자 손상과 무관합니다.
문자 집합과 인코딩의 역할
문자 집합은 문자에 코드 포인트라는 번호를 부여합니다. 인코딩은 그 번호를 어떤 바이트 순서로 기록할지 정합니다. 유니코드는 세계 여러 문자의 코드 포인트를 정의하고, UTF-8과 UTF-16은 이를 바이트로 표현하는 서로 다른 방식입니다. 같은 유니코드 문자도 인코딩에 따라 길이와 바이트열이 달라집니다.
- ASCII는 영문자, 숫자, 기본 기호를 7비트 범위에 배치한다.
- ISO-8859-1은 한 바이트의 나머지 범위를 서유럽 문자에 사용한다.
- EUC-KR은 제한된 한글 집합을 두 바이트 조합으로 표현한다.
- MS949는 EUC-KR 계열을 확장해 더 많은 한글을 다룬다.
- UTF-8은 ASCII와 같은 바이트를 유지하면서 다른 문자는 가변 길이로 기록한다.
- UTF-16은 코드 단위를 두 바이트 중심으로 두며 보조 문자는 두 코드 단위가 필요하다.
char와 사용자 글자의 차이
자바 char는 UTF-16 코드 단위 하나입니다.
BMP 밖의 문자는 상위·하위 서로게이트 두 개로 표현되므로 String.length()가 사람이 보는 글자 수와 다를 수 있습니다.
결합 문자나 이모지 시퀀스까지 고려하면 코드 포인트 수도 화면의 자소 묶음 수와 같지 않을 수 있습니다.
제한 길이를 정할 때 바이트, UTF-16 단위, 코드 포인트, 화면 문자 중 무엇을 세는지 명시해야 합니다.
public final class CodePointInspector {
public static void main(String[] args) {
inspect("A");
inspect("가");
inspect("😀");
}
private static void inspect(String text) {
int units = text.length();
int points = text.codePointCount(0, text.length());
String codePoints = text.codePoints()
.mapToObj(value -> "U+" + Integer.toHexString(value).toUpperCase())
.reduce((left, right) -> left + " " + right)
.orElse("");
System.out.printf("text=%s units=%d points=%d codes=%s%n",
text, units, points, codePoints);
}
}이모지는 length()가 2지만 코드 포인트 수는 1입니다.
문자열을 charAt으로 임의 절단하면 서로게이트 쌍을 반으로 자를 수 있습니다.
코드 포인트 구분은 offsetByCodePoints로 찾고, 실제 화면 글자 구분이 필요하면 국제화 라이브러리의 grapheme 분할 기능을 사용합니다.
UTF-8 규칙과 외부 접점 검사
UTF-8은 ASCII 바이트 호환성, 언어 독립성, 웹 생태계 지원 때문에 새 텍스트 형식의 일반적인 선택입니다. 그렇다고 아무 바이트나 UTF-8로 읽어도 된다는 뜻은 아닙니다. 오래된 MS949 파일을 UTF-8로 디코딩하면 잘못된 시퀀스가 대체 문자로 변하거나 예외가 발생합니다. 파일 메타데이터, 프로토콜 헤더, 사용자 선택처럼 인코딩을 알 수 있는 근거를 받아야 합니다.
UTF-16 파일은 바이트 순서를 알려주는 BOM을 사용할 수 있지만 모든 형식이 BOM을 요구하는 것은 아닙니다. UTF-8 BOM도 일부 도구에서는 데이터로 취급됩니다. 서명 바이트를 추측 알고리즘처럼 남용하지 말고 형식 사양을 따릅니다. 시스템 내부 문자열은 유니코드로 처리하고, 입출력 접점에서만 명시적 Charset으로 변환하면 책임 위치가 선명해집니다.
판단 기준
| 점검 대상 | 통과 조건 | 실패 징후 |
|---|---|---|
| 저장 형식 | Charset이 사양에 기록됨 | 기본값 호출이 남음 |
| 길이 제한 | 세는 단위가 이름에 드러남 | length를 글자 수로 단정 |
| 바이트 비교 | 부호 없음 변환 뒤 검사 | 음수 출력으로 데이터 손상 판단 |
| 외부 입력 | 디코딩 오류 정책이 있음 | 대체 문자를 조용히 허용 |
| 왕복 검증 | 대표 다국어 표본이 같음 | 영문 표본만 시험 |
연습 문제
여러 문자열이 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));
}
}
}ASCII는 UTF-8에서 한 글자당 한 바이트이고 한글은 세 바이트입니다. 보조 문자 이모지는 UTF-8에서 네 바이트, UTF-16BE에서도 서로게이트 두 단위라 네 바이트입니다. 이 결과는 화면 너비가 아니라 선택한 인코딩의 저장 비용을 보여 줍니다.
문자 저장 규칙의 최종 확인
문자 데이터 문제를 해결하려면 화면의 글자에서 곧바로 파일을 보지 말고 코드 포인트, 인코딩, 바이트열, 디코딩 순으로 구분을 추적합니다. 모든 외부 형식에 Charset을 명시하고 다국어·보조 문자·잘못된 입력까지 검증했을 때 환경이 바뀌어도 같은 텍스트를 복원할 수 있습니다.