Wednesday, February 8, 2023

Dart 문자열 비교, 아직도 toLowerCase() 쓰시나요? collection 패키지로 성능 극대화하기

Flutter와 Dart로 애플리케이션을 개발하다 보면 문자열을 정렬하거나 비교해야 하는 상황이 수없이 발생합니다. 사용자 이름 목록을 알파벳순으로 정렬하거나, 대소문자를 구분하지 않고 특정 키워드를 검색하는 등 문자열 비교는 앱의 핵심 로직에 깊숙이 자리 잡고 있습니다. 대부분의 개발자는 이런 요구사항에 직면했을 때, 아주 자연스럽게 다음과 같은 코드를 작성합니다.


// 두 문자열을 대소문자 구분 없이 비교하는 일반적인 방법
list.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));

이 코드는 완벽하게 작동하며, 실제로 많은 상황에서 올바른 해결책입니다. 하지만 '항상' 최선의 선택일까요? 만약 여러분이 처리하는 데이터가 수천, 수만 개에 이르고, 이 정렬 작업이 앱의 성능에 병목을 일으키고 있다면 어떨까요? 혹은, 극단적인 성능 최적화가 필요한 백엔드 서버나 스크립트를 작성하고 있다면요?

이 글에서는 Dart 개발자들이 흔히 간과하는, 그러나 엄청난 성능 향상을 가져올 수 있는 '숨겨진 보석'을 소개하고자 합니다. 바로 Dart 팀이 직접 제공하는 collection 패키지의 compareAsciiLowerCase 함수입니다. 이 함수가 무엇인지, 왜 toLowerCase().compareTo()보다 월등히 빠를 수 있는지, 그리고 어떤 상황에서 이 강력한 무기를 사용해야 하는지 심층적으로 파헤쳐 보겠습니다. 이 글을 끝까지 읽으신다면, 여러분의 Dart 코드 성능을 한 단계 끌어올릴 수 있는 새로운 관점을 얻게 될 것입니다.

우리가 무심코 사용하던 `toLowerCase()`의 숨겨진 비용

toLowerCase()는 매우 편리한 함수입니다. 어떤 문자열이든 소문자로 바꿔주니, 대소문자 구분 없는 비교에 이보다 더 직관적인 방법은 없어 보입니다. 하지만 이 편리함 뒤에는 상당한 '비용'이 숨어 있습니다. 그 비용의 정체는 바로 '유니코드(Unicode)'입니다.

현대의 프로그래밍 언어에서 문자열은 단순히 알파벳 A-Z만을 다루지 않습니다. 전 세계의 모든 언어와 문자를 표현하기 위해 유니코드라는 거대한 표준을 따릅니다. 유니코드에는 영어 알파벳뿐만 아니라 한글, 한자, 아랍어, 키릴 문자, 이모지(😂, 👍) 등 수십만 개의 문자가 포함되어 있습니다.

toLowerCase() 함수가 호출되면, Dart 런타임은 주어진 문자열의 모든 문자를 하나씩 확인하며 다음과 같은 복잡한 질문에 답해야 합니다.

  • 이 문자가 유니코드 대문자인가?
  • 그렇다면, 이 문자에 해당하는 소문자는 무엇인가?
  • 이 변환이 사용자의 '로케일(Locale)', 즉 언어 및 지역 설정에 따라 달라지지는 않는가?

특히 마지막 질문은 성능에 큰 영향을 미칩니다. 예를 들어, 터키어(Turkish)에는 점이 없는 'I' (ı)와 점이 있는 'İ' (i)가 각각 별개의 대/소문자 쌍으로 존재합니다. 즉, 영어에서는 'I'의 소문자가 'i'이지만, 터키어 로케일에서는 'I'의 소문자가 'ı'가 됩니다. toLowerCase()는 이런 복잡한 로케일 규칙까지 고려해야 하므로 내부적으로 훨씬 더 많은 연산을 수행하게 됩니다.

결론적으로 toLowerCase()는 범용성을 위해 설계된 강력한 도구이지만, 그 범용성 때문에 우리가 인지하지 못하는 사이 상당한 오버헤드를 발생시킵니다. 단순히 영어 알파벳과 숫자로만 구성된 문자열을 비교하는 데도, 전 세계 모든 문자를 처리하기 위한 복잡한 로직이 매번 실행되는 것입니다. 이는 마치 망치 하나만 있으면 되는데, 온갖 공구가 다 들어있는 거대한 스위스 아미 나이프를 휘두르는 것과 같습니다.

구원 투수 등판: Dart `collection` 패키지

이러한 문제를 해결하기 위해 Dart 팀은 collection이라는 공식 패키지를 제공합니다. 이 패키지는 Dart의 기본 컬렉션(List, Map, Set 등)을 더 효율적이고 강력하게 다룰 수 있는 다양한 알고리즘과 유틸리티 함수들을 모아놓은 '보물창고'입니다.

collection 패키지는 pub.dev에서 가장 인기 있는 패키지 중 하나이며, 많은 Flutter/Dart 프로젝트에서 사실상의 표준처럼 사용됩니다. 만약 아직 사용해보지 않으셨다면, pubspec.yaml 파일에 지금 바로 추가하는 것을 권장합니다.


dependencies:
  flutter:
    sdk: flutter
  collection: ^1.18.0 # 최신 버전을 확인하고 추가하세요.

이 패키지 안에 오늘 우리가 집중적으로 살펴볼 compareAsciiLowerCase 함수가 포함되어 있습니다.

`compareAsciiLowerCase`: 고성능 비교를 위한 전문가

compareAsciiLowerCase 함수의 이름은 그 역할과 한계를 명확하게 설명해줍니다.

  • compare: 두 개의 값을 비교합니다.
  • Ascii: 오직 'ASCII' 문자 범위 내에서만 동작합니다.
  • LowerCase: 비교 과정에서 대문자를 소문자로 취급합니다.

즉, "두 개의 ASCII 문자열을 대소문자 구분 없이 비교하는 함수"입니다. 이 함수는 toLowerCase()와 달리 유니코드의 복잡성을 완전히 배제하고, 오직 ASCII라는 좁은 범위에만 집중하기 때문에 압도적으로 빠른 성능을 보여줄 수 있습니다.

compareAsciiLowerCase 함수의 시그니처

공식 문서에 나타난 compareAsciiLowerCase 함수의 모습

핵심 원리: 왜 'ASCII'가 중요한가?

compareAsciiLowerCase의 성능 비결을 이해하려면 먼저 ASCII(아스키, American Standard Code for Information Interchange)가 무엇인지 알아야 합니다. ASCII는 1960년대에 제정된, 컴퓨터에서 문자를 표현하는 가장 기본적인 방법 중 하나입니다. 총 128개의 문자로 구성되며, 여기에는 다음이 포함됩니다.

  • 영문 대문자 (A-Z)소문자 (a-z)
  • 숫자 (0-9)
  • 공백, 괄호, 쉼표 등 각종 특수 기호 및 제어 문자

ASCII의 가장 중요한 특징은 각 문자가 고유한 숫자(코드)에 1:1로 대응된다는 점입니다. 예를 들어, 'A'는 65, 'B'는 66, ... , 'Z'는 90입니다. 그리고 'a'는 97, 'b'는 98, ... , 'z'는 122입니다. 여기서 놀라운 규칙성을 발견할 수 있습니다.

모든 ASCII 대문자의 코드값에 32를 더하면 정확히 해당 소문자의 코드값이 됩니다.

'A'(65) + 32 = 97 ('a')
'B'(66) + 32 = 98 ('b')
...
'Z'(90) + 32 = 122 ('z')

compareAsciiLowerCase 함수는 바로 이 단순한 수학적 원리를 이용합니다. 함수는 두 문자열을 처음부터 한 글자씩 비교하면서, 만약 해당 문자가 'A'부터 'Z' 사이의 대문자라면 그 문자의 코드값에 32를 더한 값을 기준으로 비교를 수행합니다. 그 외의 문자(소문자, 숫자, 기호)는 원래의 코드값을 그대로 사용합니다.

이 방식은 복잡한 유니코드 테이블을 조회하거나 로케일 규칙을 확인할 필요가 전혀 없습니다. 오직 간단한 숫자 범위 확인과 덧셈 연산만으로 대소문자 구분 없는 비교가 가능해집니다. 이것이 바로 compareAsciiLowerCase가 경이로운 속도를 낼 수 있는 이유입니다.

사용법 및 반환 값 상세 분석

compareAsciiLowerCase를 사용하는 것은 매우 간단합니다. collection 패키지를 import한 후, 비교하고 싶은 두 문자열을 인자로 전달하면 됩니다.


import 'package:collection/collection.dart';

void main() {
  String a = "Apple";
  String b = "banana";

  int result = compareAsciiLowerCase(a, b);
  print("compareAsciiLowerCase('$a', '$b'): $result"); // -1

  a = "Zebra";
  b = "apple";

  result = compareAsciiLowerCase(a, b);
  print("compareAsciiLowerCase('$a', '$b'): $result"); // 1

  a = "Flutter";
  b = "flutter";

  result = compareAsciiLowerCase(a, b);
  print("compareAsciiLowerCase('$a', '$b'): $result"); // 0
}

반환 값은 String.compareTo와 동일한 규칙을 따릅니다.

  • -1 반환: 첫 번째 인자(a)가 두 번째 인자(b)보다 사전적으로 앞에 올 경우. (예: "Apple" vs "banana")
  • 1 반환: 첫 번째 인자(a)가 두 번째 인자(b)보다 사전적으로 뒤에 올 경우. (예: "Zebra" vs "apple")
  • 0 반환: 두 문자열이 대소문자를 무시했을 때 완전히 동일할 경우. (예: "Flutter" vs "flutter")

이러한 반환 값 규칙 덕분에 List.sort의 비교 함수(comparator)로 완벽하게 활용될 수 있습니다.


import 'package:collection/collection.dart';

void main() {
  var fruits = ['Banana', 'apple', 'Orange', 'grape'];

  // 기존 방식
  // fruits.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));

  // 고성능 방식
  fruits.sort(compareAsciiLowerCase);

  print(fruits); // [apple, Banana, grape, Orange]
}

코드가 훨씬 간결해지고, 성능까지 향상되는 일석이조의 효과를 얻을 수 있습니다.

성능 벤치마크: `toLowerCase` vs `compareAsciiLowerCase`

백문이 불여일견입니다. 실제로 얼마나 성능 차이가 나는지 간단한 벤치마크를 통해 확인해 보겠습니다. 여기서는 임의의 ASCII 문자열 10만 개를 생성한 후, 두 가지 방식으로 정렬하는 데 걸리는 시간을 측정합니다.


import 'dart:math';
import 'package:collection/collection.dart';

// 임의의 ASCII 문자열을 생성하는 함수
String generateRandomAsciiString(int length) {
  const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789';
  final random = Random();
  return String.fromCharCodes(Iterable.generate(
      length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
}

void main() {
  const count = 100000; // 10만개의 문자열
  const length = 10;   // 각 문자열의 길이

  print('데이터 생성 중...');
  final originalData = List.generate(count, (_) => generateRandomAsciiString(length));
  print('데이터 생성 완료. 정렬을 시작합니다.');
  print('-' * 40);

  // 1. toLowerCase().compareTo() 방식 테스트
  final dataForLowerCase = List.of(originalData);
  final stopwatch1 = Stopwatch()..start();
  dataForLowerCase.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
  stopwatch1.stop();
  print('toLowerCase().compareTo() 시간: ${stopwatch1.elapsedMilliseconds} ms');

  // 2. compareAsciiLowerCase 방식 테스트
  final dataForAsciiCompare = List.of(originalData);
  final stopwatch2 = Stopwatch()..start();
  dataForAsciiCompare.sort(compareAsciiLowerCase);
  stopwatch2.stop();
  print('compareAsciiLowerCase 시간:    ${stopwatch2.elapsedMilliseconds} ms');
  print('-' * 40);

  // 성능 향상률 계산
  final improvement = (stopwatch1.elapsedMicroseconds / stopwatch2.elapsedMicroseconds);
  print('성능 향상: 약 ${improvement.toStringAsFixed(1)} 배');

  // 결과 검증 (두 정렬 결과가 같은지 확인)
  final bool areEqual = ListEquality().equals(
    dataForLowerCase.map((e) => e.toLowerCase()).toList(),
    dataForAsciiCompare.map((e) => e.toLowerCase()).toList()
  );
  print('정렬 결과 일치 여부: ${areEqual ? '일치함' : '불일치!'}');
}

위 코드를 여러분의 컴퓨터에서 직접 실행해 보세요. 실행 환경에 따라 결과는 달라질 수 있지만, 대부분의 경우 다음과 유사한 출력을 보게 될 것입니다.


데이터 생성 중...
데이터 생성 완료. 정렬을 시작합니다.
----------------------------------------
toLowerCase().compareTo() 시간: 215 ms
compareAsciiLowerCase 시간:    28 ms
----------------------------------------
성능 향상: 약 7.7 배
정렬 결과 일치 여부: 일치함

결과는 충격적입니다. compareAsciiLowerCasetoLowerCase().compareTo() 방식보다 약 7~8배, 환경에 따라서는 10배 이상 빠른 것을 확인할 수 있습니다. 데이터의 양이 많아질수록 이 차이는 더욱 극명하게 벌어집니다. 10만 개가 아니라 100만 개의 데이터를 다룬다면, 이 작은 코드 변경 하나가 수 초의 시간을 절약해 줄 수 있습니다. 이는 사용자 경험에 직접적인 영향을 미치는 무시할 수 없는 차이입니다.

`compareAsciiLowerCase`의 최적 활용 사례 (Real-World Scenarios)

그렇다면 이 강력한 함수를 언제, 어디서 사용해야 할까요? 핵심은 '비교 대상이 ASCII 문자로만 구성되어 있다고 100% 확신할 수 있는가?'입니다. 다음은 `compareAsciiLowerCase`가 빛을 발하는 대표적인 시나리오들입니다.

  • 코드 식별자 정렬: Dart의 변수, 함수, 클래스 이름 등은 ASCII 문자로만 구성됩니다. 코드 분석 도구나 리플렉션 관련 기능을 만들 때, 식별자 목록을 알파벳순으로 정렬해야 한다면 이 함수가 완벽한 선택입니다.
  • API 키 또는 토큰 비교: 많은 시스템에서 사용하는 API 키, 인증 토큰, UUID 등은 보통 영문 알파벳과 숫자의 조합(Alphanumeric)으로 이루어져 있습니다. 이러한 값들을 대소문자 구분 없이 비교해야 할 때 최고의 성능을 보장합니다.
  • Hex 또는 Base64 인코딩 문자열 처리: 데이터를 Hex(16진수)나 Base64로 인코딩하면 결과물은 ASCII 문자셋의 일부로만 구성됩니다. 이러한 인코딩된 문자열들을 정렬하거나 비교할 때 유용합니다.
  • HTTP 헤더 키 비교: HTTP/1.1 명세에 따르면 헤더 필드 이름(예: 'Content-Type', 'User-Agent')은 대소문자를 구분하지 않으며, 전통적으로 ASCII 문자로 구성됩니다. HTTP 클라이언트나 서버를 직접 구현할 때 헤더를 처리하는 로직에 적용할 수 있습니다.
  • 케이스에 민감하지 않은 파일 시스템의 경로 정렬: Windows와 같은 대소문자를 구분하지 않는 파일 시스템에서, 파일 경로가 영문과 숫자로만 이루어져 있다는 보장이 있다면 파일 목록을 정렬할 때 사용할 수 있습니다.
  • 설정 파일(INI, ENV)의 키 값 처리: .ini.env 파일에서 키(key)를 대소문자 구분 없이 사용하도록 정책을 정했다면, 해당 키들을 파싱하고 비교하는 로직에 적용하여 파싱 속도를 높일 수 있습니다.

치명적인 함정: 'Ascii'는 제안이 아닌 '규칙'입니다

지금까지 `compareAsciiLowerCase`의 장점만을 이야기했지만, 이 함수에는 반드시 알아야 할 치명적인 함정이 존재합니다. 바로 이름에 명시된 'Ascii'라는 제약 조건입니다. 만약 이 규칙을 어기고 ASCII가 아닌 문자가 포함된 문자열을 이 함수에 전달하면 어떻게 될까요?

공식 문서에는 "The result is unspecified"라고 명시되어 있습니다. 즉, 결과를 보장할 수 없다는 의미입니다. 이는 에러를 발생시키는 것이 아니라, 우리가 예상한 것과 전혀 다른, 논리적으로 틀린 결과를 반환할 수 있다는 뜻이라서 더욱 위험합니다.

예를 들어, 'résumé'라는 단어와 'resume'라는 단어를 비교해 봅시다. 'é'는 ASCII 문자(0-127)가 아닌 확장 ASCII 또는 유니코드 문자(코드값 233)입니다.


import 'package:collection/collection.dart';

void main() {
  String s1 = "résumé"; // non-ASCII character 'é'
  String s2 = "resume";

  // 1. 일반적인 방식 (예상대로 동작)
  int result1 = s1.toLowerCase().compareTo(s2.toLowerCase());
  print("toLowerCase.compareTo: $result1");
  // 'résumé'가 'resume'보다 뒤에 오므로 1 또는 양수가 나올 것으로 기대됨 (실제로 1 반환)

  // 2. compareAsciiLowerCase 사용 (예측 불가능한 동작)
  try {
    int result2 = compareAsciiLowerCase(s1, s2);
    print("compareAsciiLowerCase: $result2");
    // 'r'과 'r', 'e'와 'e', 's'와 's'는 같음.
    // 그 다음 'u'와 'é'를 비교하는데, 'u'의 코드값은 117, 'é'는 233.
    // 117 < 233 이므로 -1이 반환될 것임. -> resume이 résumé보다 앞에 온다는 결과
    // 이것은 사전적 순서와 다름. 'résumé'와 'resume'의 순서가 뒤바뀌어 정렬됨.
    // 만약 비교 대상이 'apple'과 'épple' 이었다면 'é'가 'a'보다 코드값이 크므로 1이 반환된다.
    // 즉, non-ASCII 문자가 어느 위치에 오느냐에 따라 정렬 순서가 완전히 망가진다.

  } catch (e) {
    print("Error: $e");
  }
}

위 코드에서 compareAsciiLowerCase(s1, s2)는 `s2`가 더 크다고 판단하여 -1을 반환할 가능성이 높습니다. 왜냐하면 'u'(코드 117)와 'é'(코드 233)를 비교할 때, 함수는 두 문자 모두 ASCII 대문자가 아니므로 원래의 코드값으로 비교하기 때문입니다. 이는 우리가 원하는 사전적 순서와는 완전히 다른 결과입니다.

결론: 사용자 입력, 외부 API로부터 받은 텍스트, 다국어 콘텐츠 등 ASCII 문자로만 구성되었다는 보장이 없는 모든 데이터에 `compareAsciiLowerCase`를 사용하는 것은 절대 금물입니다. 버그를 찾기 매우 어려운, 데이터 정합성이 깨지는 심각한 문제로 이어질 수 있습니다.

문자열 비교, 이제 고민하지 마세요 (결정 트리)

지금까지의 내용을 바탕으로, 어떤 상황에서 어떤 비교 방식을 선택해야 할지 간단한 결정 트리로 정리할 수 있습니다.

  1. 대소문자를 구분해야 하는가?
    • 예: `a.compareTo(b)`를 사용하세요. 가장 빠르고 명확합니다. (예: 비밀번호 확인)
    • 아니오: 2번으로 이동하세요.
  2. 비교할 문자열이 100% ASCII 문자로만 구성되어 있음을 보장할 수 있는가?
    • 예: collection 패키지의 `compareAsciiLowerCase(a, b)`를 사용하세요. 최고의 성능을 얻을 수 있습니다. (예: 코드 식별자, Base64 문자열 정렬)
    • 아니오 (또는 확실하지 않음): 안전하게 `a.toLowerCase().compareTo(b.toLowerCase())`를 사용하세요. 모든 유니코드 문자를 올바르게 처리하며, 대부분의 상황에서 충분한 성능을 제공합니다. (예: 사용자 이름, 검색어, 파일 내용 비교)

이 간단한 두 가지 질문만으로 여러분은 거의 모든 상황에서 가장 적절한 문자열 비교 방법을 선택할 수 있습니다.

결론: 현명한 개발자는 도구를 가려 쓴다

compareAsciiLowerCase는 모든 문제를 해결하는 만병통치약이 아닙니다. 오히려 매우 날카롭게 연마된, 특정 목적을 위한 '수술용 메스'와 같습니다. 잘못 사용하면 심각한 문제를 일으킬 수 있지만, 올바른 상황에서 사용했을 때는 다른 어떤 도구보다 뛰어난 정밀도와 효율성(성능)을 보여줍니다.

우리는 toLowerCase().compareTo()라는 편리한 '만능 공구'에 익숙해져, 그 이면에 숨겨진 비용과 더 나은 대안의 존재를 잊고 지내기 쉽습니다. 하지만 진정한 최적화는 단순히 코드를 더 빨리 실행시키는 것을 넘어, 문제의 본질을 이해하고 가장 적합한 도구를 선택하는 지혜에서 비롯됩니다.

이제 여러분은 Dart에서 문자열을 비교할 때 더 넓은 시야를 갖게 되었습니다. 다음에 대소문자 구분 없는 문자열 정렬 코드를 작성할 기회가 생긴다면, 잠시 멈추고 자문해 보십시오. "이 데이터는 정말 ASCII가 확실한가?" 만약 그렇다면, 자신 있게 compareAsciiLowerCase를 사용하여 코드의 우아함과 성능, 두 마리 토끼를 모두 잡아보시길 바랍니다.


0 개의 댓글:

Post a Comment