[Flutter] split('') 함정 — 이모지·일부 한자에서 발생하는 Android 크래시

2026-05-27 hit count image

일부 한자나 이모지가 포함된 단어를 한 글자씩 분리할 때 발생하는 "string is not well-formed UTF-16" Android 크래시의 원인과 수정 방법을 정리합니다.

flutter

개요

일본어 단어 학습 앱에서 어느 날 다음과 같은 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 == 2splitByCodePoint('𩸽').length == 1 을 같은 테스트에 두면 미래의 회귀를 막을 수 있고, 테스트 자체가 문서가 된다.
  • 일본어 / 한자 학습 앱 + 이모지를 다루는 앱은 모두 잠재적 영향권 이다. 𩸽, 𠮷, 😀 어느 하나라도 사용자 입력 / DB / STT 결과로 들어올 수 있다면 같은 함정이 잠복해 있다.

.split('') 은 “한 글자씩 처리” 의 이름표를 단 채로 사실은 코드 유닛 처리를 하는 함수입니다. Dart/Flutter 프로젝트에서 한 번쯤 grep -rn "\.split('')" lib/ 를 돌려, 잠복한 보충 평면 폭탄이 없는지 점검해 두시는 것을 권합니다.

관련 자료

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS