빌드노트
이모지 하나가 String.length()에서 2로 세어진다
String.length()는 UTF-16 코드 단위를 센다. JShell에서 이모지·결합 문자의 길이와 잘린 서로게이트를 비교하고, 코드 포인트 또는 확장 자소군으로 제한하고 자르는 기준을 설명한다.
Java에서 입력 길이를 String.length()로 제한하면 😀 하나도 2로 센다. length()가 세는 것은 UTF-16 코드 단위이기 때문이다. 코드 포인트 수로 바꾸면 이 이모지는 1이 되지만, 결합 문자까지 사용자가 보는 한 글자로 세는 문제는 남는다. 제한 조건과 잘라내기 코드를 고치기 전에 어떤 단위의 길이가 필요한지 정해야 한다.
같은 길이 2라도 안에 든 값은 다르다
Java SE 17의 String 문서 (새 탭에서 열림)는 문자열 인덱스를 char 코드 단위로 설명한다. 보충 평면의 문자는 서로게이트 쌍이라는 두 코드 단위로 표현한다. 😀는 코드 포인트 U+1F600 하나지만, String에서는 D83D DE00 두 자리를 쓴다.
JDK 17의 jshell을 실행하고 아래 입력을 붙여 넣으면 ASCII A, 완성형 한글 가, 이모지 😀, e와 결합 악센트의 길이와 실제 코드 단위를 비교할 수 있다. \u 표기를 사용해 입력기나 복사 과정에서 문자열 구성이 달라지는 것을 피했다.
for (String s : new String[]{"A", "\uAC00", "\uD83D\uDE00", "e\u0301"}) {
System.out.printf("%s: length=%d codePoints=%d units=%s%n",
s, s.length(), s.codePointCount(0, s.length()),
s.chars().mapToObj(c -> String.format("%04X", c)).toList());
}
Windows의 OpenJDK·JShell 17.0.2에서 실행한 출력이다. JShell이 표시하는 프롬프트는 생략했다.
A: length=1 codePoints=1 units=[0041]
가: length=1 codePoints=1 units=[AC00]
😀: length=2 codePoints=1 units=[D83D, DE00]
é: length=2 codePoints=2 units=[0065, 0301]
codePointCount(0, s.length())는 문자열 전체의 코드 포인트 수를 센다. 이모지의 두 코드 단위는 한 쌍이므로 1로 센다. 반면 마지막 입력은 U+0065와 U+0301이라는 별개 코드 포인트 두 개다. 화면에서 악센트 붙은 글자 하나로 보일 수 있어도 코드 포인트 수는 2다. 한글 가의 길이가 1이라는 결과도 이 완성형 입력에 대한 관찰이다. 같은 실행에서 자모 \u1100\u1161로 구성한 가는 길이와 코드 포인트 수가 모두 2였다.
substring은 서로게이트 쌍의 중간도 자른다
A😀B의 코드 단위는 0041 D83D DE00 0042다. 앞의 두 코드 포인트를 남기려면 이모지 뒤인 인덱스 3까지 가져와야 한다. 다음 입력은 숫자 2를 바로 substring에 넣은 결과와, 코드 포인트 수를 인덱스로 변환한 결과를 비교한다. 같은 JShell에서 이어 실행한다.
String text = "A\uD83D\uDE00B";
String broken = text.substring(0, 2);
System.out.printf("length=%d last=%04X high=%b%n",
broken.length(), (int) broken.charAt(1),
Character.isHighSurrogate(broken.charAt(1)));
int max = 2;
int take = Math.min(max, text.codePointCount(0, text.length()));
int end = text.offsetByCodePoints(0, take);
System.out.printf("end=%d prefix=%s%n", end, text.substring(0, end));
length=2 last=D83D high=true
end=3 prefix=A😀
첫 호출은 예외 없이 끝났지만 결과 끝에는 상위 서로게이트 D83D만 남았다. 뒤따라야 할 DE00은 잘렸다. substring 문서 (새 탭에서 열림)의 끝 인덱스는 포함하지 않는 경계이며, 그 경계는 여전히 코드 단위 기준이다. 유효한 인덱스라고 해서 완전한 문자가 남는 것은 아니다. 잘린 값을 화면에 출력한 모양보다 위처럼 코드 단위를 확인하는 편이 분명하다.
코드 포인트 수가 기준이라면 offsetByCodePoints (새 탭에서 열림)로 끝 인덱스를 구한 뒤 자른다. 예제의 max는 0 이상이며 text는 null이 아닌, 서로게이트 쌍이 올바른 문자열이라는 전제다. Math.min은 입력이 제한보다 짧을 때 이동 범위를 넘지 않게 한다. 빈 문자열과 0 제한도 같은 식으로 빈 결과가 됐다. 음수 제한은 호출 전에 거부해야 한다.
이 방법은 이미 손상된 입력을 고쳐 주지는 않는다. 실험에서 broken.codePointCount(0, broken.length())도 2였다. codePointCount 문서 (새 탭에서 열림)에 따르면 짝 없는 서로게이트도 각각 하나로 센다. 따라서 코드 포인트 수를 셌다는 사실을 문자열 유효성 검사로 삼을 수는 없다.
악센트를 함께 남기려면 다른 경계가 필요하다
e\u0301에서 코드 포인트 하나만 남기면 악센트를 잃고 e만 남는다. 이 둘을 한 묶음으로 다루는 기준이 확장 자소군(extended grapheme cluster)이다. Unicode UAX #29의 자소군 규칙 (새 탭에서 열림)은 결합 문자 등을 묶어 사용자 글자 경계에 가깝게 나누는 기준을 정의한다. 여기서는 Unicode 13.0.0 문서를 참고했다.
Java 17의 Pattern 문서 (새 탭에서 열림)는 정규식 \X를 확장 자소군에 대응하는 패턴으로 제공한다. 다음은 짧은 결합 문자 입력에서 코드 포인트 하나와 자소군 하나의 끝을 비교하는 코드다. Java 문자열에서는 역슬래시를 두 번 써서 "\\X"로 전달한다.
import java.util.regex.Pattern;
String accent = "e\u0301";
System.out.println("codePointPrefix="
+ accent.substring(0, accent.offsetByCodePoints(0, 1)));
var parts = Pattern.compile("\\X").matcher(accent).results().toList();
System.out.printf("graphemes=%d end=%d prefix=%s%n",
parts.size(), parts.get(0).end(), parts.get(0).group());
codePointPrefix=e
graphemes=1 end=2 prefix=é
자소군은 하나이고 그 끝 인덱스는 2였다. 그 경계까지 남기면 악센트도 보존된다. 이 입력은 비어 있지 않아 첫 일치를 읽을 수 있다. 실제 입력에 적용할 때는 빈 문자열의 일치 개수가 0이라는 경우도 처리해야 한다. 길이 제한에는 자소군 개수를 쓰고, 잘라낼 때는 남길 마지막 일치의 끝 인덱스를 쓰는 식으로 두 동작의 기준을 맞춘다.
연결 이모지 👩💻도 별도로 비교했다. 코드 단위는 D83D DC69 200D D83D DCBB, 길이는 5, 코드 포인트는 3이었고 \X는 한 묶음으로 나눴다. 이모지의 길이가 항상 2라고 가정해서는 안 된다.
제한 조건에 단위 이름을 붙인다
자기 코드에서 길이를 검사하는 곳과 substring으로 자르는 곳을 함께 찾고, 요구사항을 다음 중 하나로 구체화한다.
- 연동 규약이 UTF-16 코드 단위 수를 요구한다면
length()를 사용한다. 이때😀가 2인 것은 그 규약에 맞는 결과다. - Unicode 코드 포인트 수를 제한한다면
codePointCount로 세고offsetByCodePoints로 자를 경계를 구한다. 결합 문자와 연결 이모지는 여러 개로 셀 수 있다. - 결합 문자와 연결 이모지를 한 묶음으로 다루는 입력 제한이라면 확장 자소군 경계를 사용한다. 앞단 입력 화면과 서버가 같은 규칙·버전으로 세는지도 확인한다.
위 수치는 정적 문자열을 JShell 17.0.2에서 실행한 관찰이다. 출력 글꼴의 실제 모양이나 화면 폭, 브라우저·DB의 길이 제한과 다른 JDK 버전은 검증하지 않았다. 같은 입력으로 결과가 다르면 먼저 JDK 버전과 실제 코드 단위를 대조한다. 가와 자모로 조합한 가처럼 보이는 모양만으로 입력이 같다고 판단하지 않는다. 확인을 마치면 JShell에서 /exit로 종료한다.