Dart 문자열 조작, 제대로 하기 👉
Dart 문자열 조작, 제대로 하기 👉
여러분이 만드는 앱에 이모지가 들어가거나 여러 언어를 지원하고 있나요? 그렇다면 이 글을 꼭 읽어보세요. Dart가 문자열 조작에서 이런 데이터를 안전하게 다루는 새로운 방법을 도입했거든요.
이 글은 Tao Dong님이 2020년 6월 30일에 작성한 글로, 읽는 데 약 9분 정도 걸려요.
본문
들어가며: 왜 문자열 길이가 헷갈릴까?
이모지가 일상 대화를 지배하고 상용 앱에서 다국어 지원이 늘어나기 전에 설계된 많은 프로그래밍 언어가 그렇듯, Dart는 문자열을 UTF-16 코드 유닛(UTF-16 code unit)의 연속으로 표현해요. 이 인코딩 방식은 대부분의 경우 문제없이 동작했어요. 그런데 국제화가 늘어나고, 어떤 언어와도 함께 쓰이는 이모지가 등장하면서 이 인코딩이 가진 근본적인 문제가 모두의 문제가 되어버렸죠.
예를 하나 볼게요.
문자열 "Hello👋"에서 사용자가 인식하는 각 글자는 하나의 코드 유닛에 매핑되는데, 손을 흔드는 이모지 👋만은 그렇지 않아요. 이 매핑 때문에 바로 이 문자열의 길이가 헷갈리는 문제가 생겨요. 아래 코드의 출력이 6일까요, 7일까요?
print('Hello👋'.length);
사용자 입장에서는 철학적으로 접근하지 않는 한 이 문자열에 분명 6개의 글자가 있어요. 하지만 Dart의 String API는 length가 7, 정확히 말하면 7개의 UTF-16 코드 유닛이라고 알려줘요. 이 차이가 별별 결과를 만들어내는데, 이유는 아주 많은 텍스트 조작 작업이 String API에서 문자 인덱스를 사용하기 때문이에요. 예를 들어 "Hello👋"[5]는 👋 이모지를 돌려주지 않아요. 대신 이모지의 첫 번째 코드 유닛을 나타내는 깨진 글자를 돌려주죠.
좋은 소식은 Dart에 이 문제를 해결할 새 패키지가 있다는 거예요. 바로 characters 패키지인데, 이 패키지는 UTF-16 코드 유닛이 아니라 사용자가 인식하는 글자(user-perceivable characters) 단위로 동작해요. 다만 Dart 프로그래머인 여러분은 언제 이 characters 패키지를 써야 할지 알아야 해요. 우리 조사에 따르면 경험 많은 Dart 프로그래머조차 텍스트 조작 코드를 읽을 때 이런 문제를 쉽게 놓친다고 해요. 이 글에서는 특별히 주의를 기울여야 하고, Dart String 대신 characters 패키지를 쓰길 고려해야 할 흔한 상황 몇 가지를 살펴볼게요.
주의해야 할 시나리오들
이번 섹션에서는 흔한 텍스트 조작 시나리오 몇 가지를 살펴보면서, 왜 Dart의 String API를 쓰면 문제가 생기는지, 그리고 characters 패키지를 쓰면 어떻게 더 믿을 만한 결과를 얻는지 보여드릴게요. 아래 예시들은 기본적으로 사람이 입력한 문자열(앱 개발자가 예상하지 못한 언어의 글자나 이모지가 포함될 수 있는 문자열)을 다룬다고 가정해요.
시나리오 1: 문자열의 글자 수 세기
사용자가 입력한 텍스트가 정해진 글자 수를 넘었는지 확인하는 함수를 만든다고 해볼게요. 이 함수는 한도에 도달하지 않았으면 남은 글자 수를 양수로, 한도를 넘었으면 초과한 글자 수를 음수로 돌려줘요.
String API로 짜면 아주 간단해요:
// String API를 쓰는 구현.
// 사용자가 인식하는 글자가 아니라
// UTF-16 코드 유닛의 개수를 센다.
int remainingCapacity(String input, int limit) {
var length = input.length;
return limit - length;
}
그런데 이 코드의 문제를 다음 테스트가 드러내줘요:
test('remainingCapacity', (){
var limit = 140;
input = 'Laughter 😀 is the sensation of feeling good all over and showing it principally in one place.';
expect(remainingCapacity(input, limit), equals(47));
});
테스트 결과는 이렇게 나와요:
Expected: <47>
Actual: <46>
String에 편리한 extension method를 제공하는 characters 패키지로 이 함수를 다시 쓰면 올바른 글자 수를 얻을 수 있어요:
int checkMaxLength(String input, int limit) {
var length = input.characters.length;
return limit - length;
}
시나리오 2: 부분 문자열 추출하기
이번에는 문자열에서 마지막 글자를 지우고 새 문자열로 돌려주는 함수를 구현한다고 해볼게요. 이 문자열도 사용자 입력에서 온다고 가정할게요.
이 함수는 String의 substring 메서드로 쉽게 구현할 수 있어요:
String skipLastChar(String text) {
return text.substring(0, max(0, text.length - 1));
}
그런데 좋은 이모지 테스트 하나가 이 코드를 금방 깨뜨려요:
test('skipLastChar(text)', () {
var string = 'Hi 🇩🇰';
expect(skipLastChar(string), equals('Hi '));
});
테스트 결과:
Expected: ‘Hi ’
Actual: ‘Hi 🇩???’
Which: is different. Both strings start the same, but the actual value also has the following trailing characters: 🇩???
characters 패키지는 skipLast(int count) 같은 고수준 메서드를 제공해서 이 경우를 손쉽게 처리해줘요. 이 코드 조각을 이렇게 다시 쓸 수 있어요:
String skipLastChar(String text) {
return text.characters.skipLast(1).toString();
}
시나리오 3: 이모지를 기준으로 문자열 나누기
세 번째 시나리오에서는 주어진 이모지를 기준으로 문자열을 나누고 싶어요. String의 split 메서드를 쓰는 함수는 이렇게 생겼어요:
List splitEmojiSeparatedWords(String text, String separator) {
return text.split(separator);
}
잘 동작할까요? 아마 99%의 경우에는 잘 동작해요. 그런데 아래 테스트는 위 코드가 꽤 놀라운 결과를 만들어내는 예시를 보여줘요:
test('splitEmojiSeparatedWords(String text, String separator)', () {
var text = 'abc👨👩👧👦👧abc👧abc👧abc';
var separator = '👧';
List<String> expected = ['abc👨👩👧👦', 'abc', 'abc', 'abc'];
expect(td.splitEmojiSeparatedWords(text, separator), equals(expected));
});
테스트 결과:
Expected: ['abc👨👩👧👦', 'abc', 'abc', 'abc']
Actual: ['abc👨👩','👦', 'abc', 'abc', 'abc']
Which: was 'abc👨👩' instead of 'abc👨👩👧👦' at location [0]
문자열을 나눴을 때 왜 👨👩👧👦가 두 개의 이모지 👨👩가 되어버린 걸까요? 그건 👨👩👧👦가 사실 네 개의 서로 다른 이모지(👨👩👧👦)로 만들어져 있기 때문이에요. 문자열을 👧를 기준으로 나누면 "abc👨👩👧👦"가 "abc👨👩"과 "👦" 두 부분으로 갈라져버린 거죠.
이 문제는 Characters 클래스의 split 메서드를 쓰면 피할 수 있어요:
List<String> splitEmojiSeparatedWords(String text, String separator) {
// Split returns an iterable, which we need to convert to a list.
return [...text.characters.split(separator.characters)];
}
시나리오 4: 인덱스로 특정 글자 접근하기
텍스트 조작에서는 문자열에서 특정 글자를 인덱스(위치)로 접근하는 일이 흔해요. 예를 들어 아래 코드 조각은 사용자가 두 개의 서로 다른 텍스트 필드에 입력한 이름과 성에서 이니셜을 만드는 함수를 보여줘요:
String createInitials(String firstName, String lastName) {
return firstName[0].toUpperCase() + lastName[0].toUpperCase();
}
하지만 이 글 서두에서 봤듯이, UTF-16 기반 문자열에서 인덱스를 쓰는 건 위험할 수 있어요. 아래 테스트 케이스로 위 코드가 올바른지 확인해볼게요:
test("createInitials(firstName, lastname)", () {
var firstName = 'étienne';
var lastname = 'bézout';
expect(td.createInitials(firstName, lastname), equals('ÉB'));
});
테스트 결과:
Expected: ‘ÉB’
Actual: ‘EB’
Which: is different.
테스트가 왜 실패했을까요? 그건 "É"라는 글자가 "E"와 악센트 기호의 결합일 수 있기 때문이에요. characters 패키지를 쓰면 이 문제도 쉽게 피할 수 있어요:
String createInitials(String firstName, String lastName) {
return '${firstName.characters.first}${lastName.characters.first}';
}
연습 문제: 텍스트 말줄임 처리
이번엔 여러분 차례예요. 이 시나리오에서는 앱이 메시지 목록을 한 줄에 하나씩 표시해야 해요. 메시지 길이가 주어진 글자 수 한도를 넘으면 텍스트가 넘친 부분을 말줄임표(…)로 표시하는 함수를 구현한 코드를 검토해야 한다고 해볼게요.
String textOverflowEllipsis(String text, int limit) {
if (text.length > limit) {
return text.substring(0, limit - 3) + '…';
} else {
return text;
}
}
이 코드 조각의 잠재적 문제를 드러낼 테스트를 직접 만들어볼 수 있나요? 그리고 characters 패키지를 써서 어떻게 다시 쓰면 될까요? 정답은 이 글의 맨 끝에 있어요.
완화 조치와 가능한 장기 해결책
Dart 사용자들이 위에서 설명한 이런 함정들에 항상 경계를 유지하길 기대하는 건 무리예요. 실제로 우리가 진행한 실험에서, 참가자들이 characters 패키지와 그 패키지가 해결하려는 문제에 대한 정보를 두 페이지나 받고 불과 몇 분이 지난 뒤에도, Dart 사용자의 **53.7%**가 첫 번째 시나리오(글자 수 세기)에서 보여준 문제를 알아차리지 못했어요. 그래서 우리는 개발자가 텍스트 조작에 가장 적절한 API를 고르도록 돕기 위해 두 단계로 나눈 접근법을 취하고 있어요.
단기적으로는 Flutter 프레임워크와 Dart analyzer에 characters 패키지를 좀 더 쉽게 찾고 호출할 수 있게 하는 완화 조치들을 도입하고 있어요. 여기엔 몇 가지 단계가 들어가요:
TextField위젯의 내부 구현에서characters패키지를 사용한다. 자세한 내용은 이 PR과 이 디자인 문서를 참고해요.characters패키지의 API를 Flutter 프레임워크를 통해 노출한다. 이 작업이 끝나면 Flutter 사용자는String에서 자동 완성할 때 나타나는 extension methodString.characters를 통해 이 API를 발견할 가능성이 높아져요. 이 작업의 진행 상황은 이 이슈에서 추적돼요: https://github.com/flutter/flutter/issues/55593- Flutter 프레임워크의 API 문서와 샘플 코드를 업데이트해서,
TextField.onChanged콜백 같은 곳에서 해당되는 경우Characters클래스 사용을 권장한다. 이 작업은 https://github.com/flutter/flutter/issues/55598 에서 추적되고, 관련 세부 내용은 이 문서에 있어요. - Dart analyzer가 사용자 입력 텍스트를 다루는 콜백 템플릿을 자동 완성할 때
String객체를Characters객체로 변환하도록 제안하게 한다. 예를 들어 사용자가onChanged에서 자동 완성하면 IDE가 아래 조각의 모든 내용을 채워줄 수 있어요. 이 작업은 https://github.com/dart-lang/sdk/issues/41677 에서 추적돼요.
TextField(
onChanged: (String value) {
// Converting String to Characters to handle emojis
// and non-English characters more robustly.
var myText = value.characters;
}
)
이런 완화 조치들이 도움이 되긴 하지만, Flutter 프로젝트라는 맥락에서 수행되는 문자열 조작에만 한정돼 있어요. 이 조치들이 나온 뒤엔 그 효과를 신중하게 측정해야 해요. Dart 언어 레벨에서의 더 완전한 해결책은 아마 기존 코드 중 적어도 일부의 마이그레이션을 요구할 거예요. 다만 몇 가지 옵션(예를 들어 static extension types)이 breaking change를 감당할 만하게 만들어줄 수도 있어요. 그 트레이드오프를 완전히 이해하려면 더 많은 기술적 조사가 필요해요.
여러분이 할 수 있는 일
characters 패키지로 문자열 문제를 고치는 방법에 대한 인식을 높이는 데 함께 도와주세요:
- 여러분 코드 안에서
String.length나String.substring을 쓰는 곳을 찾아보세요. 그 문자열이 사용자 입력에서 비롯됐을 가능성이 있다면characters패키지를 쓰도록 코드를 다시 작성해보세요. - 이 글을 Dart 커뮤니티의 다른 사람들과 공유해주세요.
- StackOverflow에서 Dart 텍스트 조작에 관한 기존 답변을 업데이트해보세요. 채택된 답변이
StringAPI의 이 한계를 놓쳤다면, 그 위험을 다시 알려주세요. - 위에 나열된 GitHub 이슈에 댓글을 달아 여러분의 생각과 의견을 알려주세요.
그럼 행복한 코딩 되세요 😉!
감사의 말
이 글을 검토해준 Kathy Walrath, Lasse Nielsen, Michael Thomson에게 감사드려요. 또한 우리의 사용자 조사에 참여해준 개발자분들께도 감사드려요. 그분들의 참여 덕분에 Dart와 Flutter 팀이 Dart String API의 이 한계를 다루는 어려움을 더 잘 이해할 수 있었어요.
PS: 연습 문제의 정답이에요.
// Prerequisite: add the characters package as a dependency in your pubspec.yaml.
import 'package:characters/characters.dart';
void main(List<String> arguments) {
print(textOverflowEllipsis('😸cats', 10));
print(textOverflowEllipsis('🦏rhinoceroses', 10));
}
// This function converts text overflow to an ellipsis
// when the text's length exceeds the given character limit.
String textOverflowEllipsis(String text, int limit) {
var myChars = text.characters;
if (myChars.length > limit) {
return '${myChars.take(limit - 1)}…';
} else {
return text;
}
}