Rediscovering the Schwartzian Transform: Why I Had to Comment on a Flutter Performance Article
Schwartzian Transform 다시 발견하기 — Flutter 성능 글에 댓글을 남긴 이유
문자열 날짜를 정렬 비교 때마다 파싱하면 Flutter UI가 멈출 수 있습니다. 글쓴이는 Schwartzian Transform으로 정렬 키를 한 번만 계산하는 방법을 설명하고, Dart 3 Records를 활용한 구현과 1만 건 벤치마크 결과를 소개합니다.
- 주제
AI 요약
Flutter에서 기계 상태 변경 1만 건을 시간순으로 보여주는 타임라인이 멈추는 원인은 정렬 비교 함수 안에서 매번 실행하는 DateTime.parse()였습니다. 글쓴이는 비교 때마다 키를 다시 계산하지 않고, 먼저 각 항목의 정렬 키를 뽑아 저장한 뒤 정렬하고 원래 항목을 복원하는 Schwartzian Transform을 설명합니다. 이 방식은 Map → Sort → Map 순서로 동작하며, Perl에서 시작해 Python의 DSU(Decorate-Sort-Undecorate) 패턴 등 여러 언어에서 쓰여 왔습니다.
반복 계산이 만드는 프레임 손실
정렬은 평균적으로 O(N log N)번 비교합니다. 항목이 10,000개라면 비교 횟수는 약 133,000회입니다. 비교 함수에서 양쪽 항목의 날짜 문자열을 모두 파싱하면 DateTime.parse() 호출은 215,000회를 넘을 수 있습니다. 글쓴이는 ISO-8601 파싱에 정규식·문자열 분할·달력 계산·메모리 할당이 들어가며, 모바일 기기나 브라우저의 메인 isolate에서 이런 연산이 프레임 손실을 부른다고 설명합니다. 60 FPS의 프레임 예산은 16ms, 120 FPS에서는 8ms입니다.
기존 Flutter 사례에서는 날짜를 미리 파싱한 값을 보관하는 전용 helper class를 만들고, 그 값을 기준으로 정렬해 멈춤을 해결했습니다. 글쓴이는 이 접근이 Schwartzian Transform과 같은 원리라고 짚습니다. 정렬 키를 먼저 계산하고, 값이 준비된 뒤 비교하면 됩니다.
sortedBy도 키를 캐시하지 않습니다
Dart의 package:collection에는 sortedBy()가 있지만, 글쓴이가 살펴본 구현에서는 정렬 과정 중 keyOf를 반복 호출합니다. 따라서 sortedBy((a) => DateTime.parse(a.start))처럼 작성해도 날짜 파싱을 한 번으로 줄이지 못합니다. 글에 따르면 이 구현은 병합 정렬 과정에서 키를 다시 평가합니다.
글쓴이가 1만 개의 ISO-8601 날짜 항목으로 실행한 벤치마크에서는 일반 List.sort()가 DateTime.parse()를 215,462회 호출해 186ms 걸렸습니다. package:collection의 sortedBy()는 127,590회 호출해 107ms를 기록했습니다. Schwartzian Transform은 항목마다 한 번씩, 총 10,000회 파싱해 14ms가 걸렸습니다. 글쓴이는 각각을 기준 성능, 1.7배 빠른 결과, 13.3배 빠른 결과로 제시합니다. sortedBy()도 비교 횟수가 적은 병합 정렬 덕분에 나아졌지만, 키 계산을 캐시하지 않아 불필요한 파싱이 남습니다.
Dart 3 Records로 구현하기
Dart 3에서는 임시 클래스를 선언하지 않고 Record에 정렬 키와 원본 항목을 함께 담을 수 있습니다. 각 항목을 (key: DateTime.parse(item.start), item: item) 형태로 변환한 뒤 key로 정렬하고, 마지막에 item만 꺼내면 됩니다. Record는 타입 검사를 유지하므로 Map<String, dynamic>을 쓰거나 런타임 캐스팅을 할 필요도 없습니다.
글쓴이는 이 패턴을 재사용하도록 Iterable<T> 확장 메서드 sortedByExpensive()와 sortedByCompareExpensive()를 제안합니다. 두 메서드는 먼저 각 항목의 키를 계산해 Record 목록에 저장한 다음 정렬하고 원본 항목을 반환합니다. keyOf는 항목마다 정확히 한 번 실행됩니다.
언제 적용할까요
키 계산이 날짜 파싱, 정규식 실행, JSON 조각 해석, 디스크 메타데이터 조회, 문자열 해싱처럼 비싸고, 정렬할 항목도 수백~수천 개 이상이면 적용을 고려할 만합니다. 반면 age나 timestamp처럼 이미 존재하는 정수·DateTime 속성을 읽는 경우에는 중간 Record 목록 할당이 불필요한 메모리 작업을 더할 수 있습니다. 이 방식은 반복 계산을 줄이는 대신 임시 목록과 Record를 할당하므로, 키 계산 비용과 메모리 비용을 함께 따져야 합니다.
dev.to 반응
- @shieldxbot — Flutter에서 큰 데이터셋을 정렬할 때 성능 저하는 정렬 중 비싼 비교 로직이 몇 번 실행되는지에 달린 경우가 많습니다. 대부분은 정렬 알고리즘부터 최적화하려고 하지만, Schwartzian Transform은 무거운 계산과 실제 비교 단계를 깔끔하게 분리합니다. 고빈도 데이터 처리에서도 비슷한 병목을 봤습니다. 정렬 중 속성을 반복해서 읽거나 복잡한 객체 변환을 하면 프레임 속도가 크게 떨어집니다. 미리 계산한 값을 Dart 3 Records에 묶으면 임시 wrapper class를 만들던 예전 방식보다 코드가 읽기 쉽고, 불필요한 가비지 컬렉션 부담도 줄이는 데 도움이 됩니다.
- @randalschwartz — 사려 깊은 댓글 감사합니다, @shieldx! 자주 놓치는 점을 짚으셨습니다. 개발자는 정렬 알고리즘을 최적화하거나 복잡한 isolate 분리를 먼저 떠올리지만, 실제 원인은 비교 루프에서 키를 반복 계산하는 일일 때가 많습니다. 키 추출과 비교를 분리하는 데 가장 큰 이점이 있습니다. Dart 3 Records에 관한 말씀도 맞습니다. 사용자 정의 wrapper class의 오버헤드와 GC 부담을 피하면 이 변환을 무거운 구조적 우회가 아니라 자연스러운 언어 기능처럼 쓸 수 있습니다. 고빈도 데이터 처리 경험과도 맞닿았다니 기쁩니다.
원문: dev.to / 번역·요약: Trawling