목차
개요
일본어 단어 학습 앱에서 어느 날 다음과 같은 Crashlytics 보고가 올라왔습니다.
Fatal Exception: java.lang.RuntimeException
... string is not well-formed UTF-16 ...
크래시가 발생한 단어는 𩸽(ほっけ, 호케 — 일본 가정에서 자주 구워 먹는 생선) 이었습니다. 이 한 글자를 화면에 한 글자씩 분리해 표시하는 위젯에 넣자마자 Android 단말에서 앱이 죽습니다.
원인은 Dart 의 String.split('') 가 UTF-16 코드 유닛 단위 로 문자열을 자르기 때문입니다. 𩸽 처럼 이모지나 일부 한자(유니코드의 보충 평면 영역, U+10000 이상)는 UTF-16 에서 surrogate pair(2개의 코드 유닛)로 표현되므로, split('') 은 이 페어를 두 조각으로 쪼개 lone surrogate 를 만들어냅니다. lone surrogate 는 유효한 UTF-16 이 아니므로 platform channel 을 통해 Android 측에 전달되는 순간 “string is not well-formed UTF-16” 으로 거부됩니다.
이 글에서는 다음을 정리합니다.
String.split('')의 동작이 왜 “한 글자씩” 이 아닌가- 유니코드 보충 평면과 UTF-16 surrogate pair 의 짧은 배경
runes기반의 안전한 분할 헬퍼와 적용 패턴- 함정을 잡아두는 테스트 작성법
왜 split('') 이 문제인가
Dart 의 String 은 사실 UTF-16 코드 유닛이 순서대로 이어진 것 입니다. 문자(character/grapheme)가 이어진 것이 아닙니다. 이 사실은 다음 두 줄로 즉시 확인됩니다.
print('𩸽'.length); // 2 (코드 유닛 2개)
print('𩸽'.split('').length); // 2 (각각 lone surrogate 1개씩)
사람의 직관으로는 ”𩸽 은 한 글자” 지만, Dart 입장에서는 [0xD867, 0xDE3D] 두 코드 유닛입니다. split('') 은 이 두 유닛을 각각 길이 한개짜리 문자열로 분리합니다. 분리 결과의 두 조각은 각각 다음과 같습니다.
- 첫 번째 조각: high surrogate(U+D867) 만 단독으로 존재
- 두 번째 조각: low surrogate(U+DE3D) 만 단독으로 존재
UTF-16 에서 high/low surrogate 는 반드시 쌍으로만 존재해야 유효한 문자열입니다. 따라서 이 분리 결과를 그대로 Flutter 의 Text 같은 위젯에 넣으면, platform channel 의 인코딩 단계에서 invalid UTF-16 으로 판정되어 다음과 같은 케이스에서 크래시가 발생합니다.
- Android 의
TextView등이 platform channel 을 통해 받은 문자열을 검증할 때 - StandardMessageCodec 이 String 을 직렬화할 때
- 일부 네이티브 텍스트 측정 / 셰이핑 API 가 well-formed UTF-16 을 요구할 때
iOS 가 같은 입력에서 크래시하지 않더라도 그것은 OS / 텍스트 엔진의 관용도 차이일 뿐, lone surrogate 를 만든다는 행위 자체가 잘못 입니다. 보이지 않는 곳에서 텍스트 측정값이 어긋나거나 폰트 폴백이 실패하는 등 silent 한 회귀가 함께 일어날 수 있습니다.
유니코드 보충 평면과 surrogate pair
배경을 짧게 정리합니다.
- 유니코드는 코드포인트를 17개의 “plane” 으로 나눠 관리합니다.
- BMP(Basic Multilingual Plane, U+0000 ~ U+FFFF) 가 0번째 plane 으로 가장 자주 쓰는 문자가 들어갑니다. ASCII, 한글, 대부분의 한자(漢字), 카타카나/히라가나 등이 모두 BMP 입니다.
- Supplementary Plane(보충 평면, U+10000 ~ U+10FFFF) 은 1~16번째 plane 으로, BMP 에 들어가지 못한 추가 문자들이 들어갑니다.
UTF-16 인코딩은 BMP 문자는 코드 유닛 1개로, 보충 평면 문자는 코드 유닛 2개(surrogate pair)로 표현합니다.
'A' (U+0041) → [0x0041]
'漢' (U+6F22) → [0x6F22]
'😀' (U+1F600) → [0xD83D, 0xDE00] ← surrogate pair
'𩸽' (U+29E3D) → [0xD867, 0xDE3D] ← surrogate pair
일본어 학습 앱이 자주 만나는 보충 평면 문자의 예입니다.
𩸽(U+29E3D, 호케 — 생선)𠮷(U+20BB7, 요시 — 성씨 “吉” 의 이체자, 흔히 보이는 식당 간판)𠀋(U+2000B, 죠 — 인명 한자)- 그리고 모든 이모지의 절반 이상(😀, 🎉, 🍣 등)
CJK Unified Ideographs Extension B (U+20000~U+2A6DF) 이후의 모든 한자, 그리고 거의 모든 이모지 가 보충 평면에 속합니다. 한자 학습이나 이모지 처리 어느 쪽이든 보충 평면을 피해갈 수 없습니다.
수정 — runes 기반 분할 헬퍼
Dart 는 코드포인트 단위 순회를 위해 String.runes 를 제공합니다. runes 는 surrogate pair 를 자동으로 합쳐 하나의 코드포인트로 다룹니다.
print('𩸽'.runes.length); // 1
이를 활용한 분할 헬퍼는 간단합니다.
/// 문자열을 유니코드 코드포인트 단위로 분할한다.
///
/// `String.split('')` 은 UTF-16 코드 유닛 단위로 분할하므로 보충 평면
/// (예: 𩸽 U+29E3D, 이모지) 문자의 surrogate pair 를 깨뜨려
/// "string is not well-formed UTF-16" 크래시를 일으킨다.
/// 이 함수는 `runes` 를 사용해 코드포인트 단위로 분할하여 surrogate pair 를
/// 한 글자로 보존한다.
List<String> splitByCodePoint(String text) {
return text.runes.map((r) => String.fromCharCode(r)).toList();
}
핵심은 단 한 줄(text.runes.map((r) => String.fromCharCode(r)).toList())입니다. String.fromCharCode(int) 는 인자가 보충 평면 코드포인트일 경우 자동으로 올바른 surrogate pair 로 변환된 길이 2짜리 String 을 만들어 줍니다. 따라서 결과 리스트의 각 요소는 사람이 인지하는 “한 글자” 와 일치합니다.
splitByCodePoint('𩸽'); // ['𩸽']
splitByCodePoint('𩸽の魚'); // ['𩸽', 'の', '魚']
splitByCodePoint('a😀b'); // ['a', '😀', 'b']
splitByCodePoint(''); // []
참고:
runes도 만능은 아닙니다. 결합 문자(예:e+ ´ =é)나 ZWJ 이모지 시퀀스(👨👩👧)는 여러 개의 코드포인트가 모여 한 grapheme 을 이룹니다. 이런 grapheme 단위로 정확히 자르려면characters패키지의String.characters를 써야 합니다. 단, 본 fix 의 목적은 “surrogate pair 가 깨지지 않게 한다” 이므로runes로 충분합니다. 어느 정밀도가 필요한지는 도메인에 따라 다릅니다.
적용 — 호출 지점 일괄 교체
문제의 본질은 .split('') 이 사용된 모든 곳에 동일한 함정이 존재한다는 것입니다. 단일 호출만 고치면 다른 위젯에서 같은 크래시가 다시 나옵니다. .split('') 호출을 grep 으로 모두 찾아 일괄 교체합니다.
# 프로젝트 전체에서 .split('') 호출 찾기
grep -rn "\.split('')" lib/
제가 개발하고 있는 프로젝트에서는 다음과 같은 공통 패턴으로 수정하였습니다.
// Before
word.split('').map((char) { ... });
// After
splitByCodePoint(word).map((char) { ... });
STT 비교 로직에서는 CJK 텍스트를 글자 단위로 토큰화하는 경로에서 같은 패턴이 쓰였습니다.
// Before — split('') 으로 토큰화
return text
.replaceAll(' ', '')
.split('')
.where((c) => c.isNotEmpty)
.toList();
// After — 코드포인트 단위 분할로 보충 평면 한자(U+29E3D 𩸽 등)를 보존
return splitByCodePoint(text.replaceAll(' ', ''))
.where((c) => c.isNotEmpty)
.toList();
이 변경 자체는 위험도가 낮습니다. BMP 문자만 들어 있는 경우의 동작은 완전히 같고, 보충 평면 문자가 들어왔을 때만 동작이 “올바른 방향” 으로 달라집니다.
테스트 — split('') 과 명시적으로 대조
이 fix 에서 가장 학습 가치가 높은 부분은 테스트입니다. 헬퍼의 정상 동작을 검증하는 데 그치지 않고, split('') 과 splitByCodePoint 가 다르게 동작한다는 사실 자체를 테스트로 박아둡니다. 이렇게 해두면 미래에 누군가 헬퍼 호출을 다시 split('') 로 되돌리면 테스트가 명확하게 실패합니다.
group('`splitByCodePoint`', () {
test('BMP 문자를 코드포인트 단위로 분할한다', () {
expect(splitByCodePoint('漢字'), ['漢', '字']);
expect(splitByCodePoint('ご飯'), ['ご', '飯']);
expect(splitByCodePoint('abc'), ['a', 'b', 'c']);
});
test('빈 문자열은 빈 리스트를 반환한다', () {
expect(splitByCodePoint(''), <String>[]);
});
test('보충 평면(SMP) 한자를 한 글자로 보존한다', () {
// 𩸽 (U+29E3D, ほっけ): UTF-16에서 surrogate pair로 표현됨
expect(splitByCodePoint('𩸽'), ['𩸽']);
expect(splitByCodePoint('𩸽の魚'), ['𩸽', 'の', '魚']);
});
test('이모지를 한 글자로 보존한다', () {
// 😀 (U+1F600): UTF-16에서 surrogate pair로 표현됨
expect(splitByCodePoint('😀'), ['😀']);
expect(splitByCodePoint('a😀b'), ['a', '😀', 'b']);
});
test('`split` 과 달리 surrogate pair 를 깨지 않는다', () {
// String.split('')는 UTF-16 코드 유닛 단위로 분할하므로 surrogate pair가 깨진다
expect('𩸽'.split('').length, 2);
// splitByCodePoint는 코드포인트 단위로 분할하므로 한 글자로 유지된다
expect(splitByCodePoint('𩸽').length, 1);
});
});
마지막 테스트의 두 줄이 핵심입니다.
expect('𩸽'.split('').length, 2); // 함정의 존재를 명시
expect(splitByCodePoint('𩸽').length, 1); // 헬퍼의 약속을 명시
테스트가 버그의 본질을 문서화 합니다. 코드를 처음 읽는 사람이 두 줄을 보고 즉시 “왜 헬퍼가 필요한가” 를 이해할 수 있습니다. 단순히 동작 검증 이상의 가치를 갖는 패턴입니다.
위젯 측에도 보충 평면 문자가 포함된 단어를 렌더링하는 테스트를 추가합니다. 헬퍼를 사용하는 위젯마다 같은 형태로 한 줄짜리 테스트를 박아두면 됩니다.
testWidgets('보충 평면 한자(𩸽)를 포함한 단어를 크래시 없이 렌더링한다', (tester) async {
await tester.pumpWidget(MaterialApp(home: WordText(word: '𩸽')));
expect(tester.takeException(), isNull);
});
tester.takeException() 이 null 임을 단언하면 “렌더링 도중 예외가 나오지 않았다” 가 보장됩니다.
정리
.split('') 함정과 해결을 한 줄씩 요약하면 다음과 같습니다.
- Dart 의
String은 UTF-16 코드 유닛이 순서대로 이어진 것 이다..length,.split(''),[]인덱싱이 모두 코드 유닛 단위로 동작한다. - 보충 평면 문자(U+10000 이상)는 UTF-16 에서 surrogate pair(2 코드 유닛)로 표현 된다.
split('')은 이 페어를 두 조각으로 쪼개 lone surrogate 를 만든다. - lone surrogate 는 유효한 UTF-16 이 아니다. Android 의 platform channel / TextView 가 well-formed UTF-16 을 요구하는 지점에서 “string is not well-formed UTF-16” 크래시로 이어진다.
- 수정은
runes기반의splitByCodePoint헬퍼 한 개로 충분 하다..split('')호출을 grep 으로 모두 찾아 일괄 교체한다. - 테스트로 함정의 존재를 명시한다.
'𩸽'.split('').length == 2와splitByCodePoint('𩸽').length == 1을 같은 테스트에 두면 미래의 회귀를 막을 수 있고, 테스트 자체가 문서가 된다. - 일본어 / 한자 학습 앱 + 이모지를 다루는 앱은 모두 잠재적 영향권 이다.
𩸽,𠮷,😀어느 하나라도 사용자 입력 / DB / STT 결과로 들어올 수 있다면 같은 함정이 잠복해 있다.
.split('') 은 “한 글자씩 처리” 의 이름표를 단 채로 사실은 코드 유닛 처리를 하는 함수입니다. Dart/Flutter 프로젝트에서 한 번쯤 grep -rn "\.split('')" lib/ 를 돌려, 잠복한 보충 평면 폭탄이 없는지 점검해 두시는 것을 권합니다.
관련 자료
- Dart —
Stringclass - Dart —
String.runes - Dart —
characterspackage (grapheme 단위 처리) - Unicode — Supplementary Characters FAQ
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.