앱 목록 정렬 구현: comparator·안정성·성능 점검

정렬 결과가 화면에서 그럴듯해 보여도 comparator가 동점 항목의 순서까지 결정하지 않으면 같은 조건의 다음 요청에서 순서가 달라질 수 있습니다. 무한 스크롤이나 offset 페이지네이션에서는 이 차이가 중복과 누락으로 이어집니다. 또한 Dart의 List.sort()는 원본 목록을 바꾸므로 공유 상태를 정렬할 때는 별도의 판단이 필요합니다.

이 글은 정렬 원리를 요약하는 데서 멈추지 않고 2026년 9월 23일 Dart SDK 3.13.4 안정판에서 comparator 계약, 동점 순서, 페이지 경계, 원본 변경, 5만 항목 정렬을 한 프로그램으로 재현했습니다. 고정 항목 6개와 seed 20260923으로 만든 5만 항목을 사용했고 정적 분석 뒤 VM에서 실행했습니다.

정렬 규칙을 100개 입력 순서로 흔들어 봤다

확인 대상 입력과 방법 관찰 결과
comparator 계약 6개 항목의 모든 조합 전체 순서 comparator는 위반 0/0/0
동점 결과 같은 6개 항목의 seeded permutation 100개 점수만 비교하면 23개 결과, 고유 기준 포함 시 1개
offset 페이지 서로 다른 동점 입력 순서에서 3개씩 조회 점수만 비교하면 D 중복·A 누락
원본 변경 sort() 직접 호출과 복사 후 호출 비교 직접 호출은 원본 변경, 복사본 정렬은 원본 보존
로컬 시간 5만 항목, 준비 실행 1회 제외 후 7회 중앙값 15.085ms, 매회 정렬 불변식 통과
Dart 정렬에서 점수만 비교했을 때 페이지 중복과 누락이 생기고 고유 동점 기준으로 해결된 재현 결과
2026년 9월 23일 Windows와 Dart SDK 3.13.4에서 동점 입력 순서, comparator 계약, offset 페이지 경계와 5만 항목 정렬을 재현한 결과입니다. 실제 서버나 Flutter 기기 측정이 아닙니다.

먼저 comparator 계약을 깨뜨려 실패를 잡았다

Dart의 comparator는 첫 값이 앞서면 음수, 같으면 0, 뒤서면 양수를 반환해야 하며 전체 순서를 표현해야 합니다. 테스트에서는 자기 자신과의 비교, 두 값을 바꿔 비교한 부호, 세 값의 추이 관계를 전수 검사했습니다.

int compareProduct(Product a, Product b) {
  final byScore = b.score.compareTo(a.score);
  if (byScore != 0) return byScore;

  final byCreated = b.createdOrder.compareTo(a.createdOrder);
  if (byCreated != 0) return byCreated;

  return a.id.compareTo(b.id);
}

이 comparator의 반사성·반대칭성·추이성 위반은 각각 0 / 0 / 0이었습니다. 반대로 실패를 탐지하는지 확인하려고 a.score >= b.score ? -1 : 1을 사용한 comparator도 넣었습니다. 같을 때조차 -1을 반환하는 코드에서는 반사성 위반 6개와 반대칭성 위반 18개가 검출됐습니다. 추이성 위반은 이 작은 데이터에서 0이었지만 앞의 두 조건만으로도 유효한 전체 순서가 아닙니다.

return a.score > b.score ? -1 : 1처럼 “같음”을 반환하지 않는 코드는 피합니다. 숫자 차이를 그대로 반환하는 방식도 값 범위와 방향을 검토해야 하므로, 필드 자체의 compareTo() 결과를 단계별로 확인하는 편이 읽기 쉽습니다.

점수만 비교하면 같은 데이터가 23가지로 정렬됐다

A·B·C·D는 같은 점수이고 E·F만 낮은 점수를 갖도록 고정했습니다. 입력 순서만 seed로 100번 섞은 뒤 점수 comparator를 적용하자 서로 다른 출력이 23개 나왔습니다. 실제 예시는 DBCAEF, CBADEF, BADCEF, DACBEF, DABCEF입니다.

Dart 공식 API는 List.sort()가 안정 정렬이라고 보장하지 않으며, comparator가 0으로 보는 서로 다른 객체는 어떤 순서로도 나타날 수 있다고 설명합니다. 이 테스트는 특정 출력 하나가 항상 나온다고 주장하지 않습니다. 오히려 사용자에게 같은 값으로 보이는 동점 안의 순서를 comparator가 결정하지 않았다는 점이 문제입니다.

점수 다음에 생성 순서, 마지막에 고유 ID를 비교하자 100개 입력 순서 모두 BCADEF 하나로 수렴했습니다. 생성 시간이 같을 수도 있으므로 ID 같은 고유 기준을 마지막에 두어야 완전한 순서가 됩니다. 내림차순을 적용하는 필드와 오름차순을 적용하는 ID 방향도 제품 요구사항에 맞게 문서화합니다.

두 번의 페이지 요청에서 D가 겹치고 A가 사라졌다

offset 0에서 3개를 가져오는 첫 요청의 동점 입력 순서는 DBCAEF였고 첫 페이지는 DBC였습니다. offset 3의 두 번째 요청에서는 upstream 입력 순서가 CBADEF로 달라졌고 두 번째 페이지는 DEF였습니다. 두 페이지를 합치면 D가 두 번 보이고 A는 한 번도 보이지 않습니다.

정렬 조건 첫 페이지 둘째 페이지 전체 결과
score만 DBC DEF D 중복, A 누락
score → createdOrder → id BCA DEF 중복 0, 누락 0

클라이언트 목록만 고쳐서는 서버가 서로 다른 순서로 페이지를 잘라 보내는 문제를 해결할 수 없습니다. 데이터베이스 쿼리와 API cursor에도 같은 전체 순서를 적용해야 합니다. 데이터가 조회 사이에 추가·삭제되는 시스템이라면 고유 tie-break와 함께 snapshot 또는 cursor 규칙도 설계해야 합니다.

sort()가 원본을 바꾸는지 문자열로 확인했다

FEDCBA 순서의 목록에 직접 sort(compareProduct)를 호출하자 원본이 BCADEF로 바뀌었습니다. 같은 입력을 펼침 연산자로 복사한 뒤 복사본만 정렬하면 원본은 FEDCBA로 남고 결과만 BCADEF가 됐습니다.

final sorted = [...products]..sort(compareProduct);
// products: FEDCBA
// sorted:   BCADEF

목록을 소유한 함수 안에서 의도적으로 순서를 바꾸는 경우에는 제자리 정렬이 맞을 수 있습니다. 반면 상태 관리 객체, 위젯 입력, 캐시 목록처럼 다른 코드와 공유하는 값이라면 복사 후 정렬하고 결과를 별도 값으로 전달하는 편이 변경 지점을 추적하기 쉽습니다. 컬렉션의 가변성과 타입 인자는 Dart List·Map·Set 선언과 안전한 수정 방법에서 이어서 확인할 수 있습니다.

5만 항목 시간은 경향 확인용이지 프레임 판정값이 아니다

seed 20260923으로 점수·생성 순서·ID를 가진 항목 50,000개를 만들고 한 번의 준비 실행을 제외한 뒤 같은 comparator를 7회 측정했습니다. 마이크로초 원시값은 16296, 15330, 15901, 14449, 15085, 13631, 13327이고 중앙값은 15085µs, 즉 15.085ms였습니다. 각 실행의 comparator 호출은 929,172회, 동점으로 다음 기준까지 간 호출은 337,890회였습니다. 일곱 번 모두 결과가 전체 순서대로 정렬됐는지 다시 검사했습니다.

이 값은 Windows의 명령줄 Dart VM에서 한 번 측정한 수치입니다. Flutter 프레임 시간, 실제 Android·iPhone 성능, AOT 빌드, 문자열 locale 비교, 네트워크나 데이터베이스 비용을 대표하지 않습니다. 화면이 끊기는 문제는 이 수치를 기준값으로 단정하지 말고 실제 기기의 profile 빌드에서 측정합니다. 긴 목록 렌더링 자체의 구성은 Flutter ListView builder·중첩 스크롤 점검과 구분해야 합니다.

동점과 페이지 경계를 함께 결정하는 표

상황 우선 결정 확인할 실패 조건
한 화면 안에서만 임시 정렬 표시 필드 뒤 고유 ID까지 비교 입력을 섞어도 같은 출력인지
공유 목록을 정렬 복사 후 정렬하거나 소유권을 명시 원본을 읽는 다른 화면이 바뀌지 않는지
offset 페이지네이션 서버 정렬에도 고유 tie-break 적용 연속 페이지의 중복·누락
데이터가 자주 추가되는 피드 전체 순서를 담은 cursor 또는 snapshot 검토 조회 사이 삽입 시 경계 이동
locale 문자열 정렬 제품 locale·collation을 서버와 맞춤 대소문자·악센트·한글 순서 차이
작업 처리 순서 화면 정렬과 queue 정책을 분리 우선순위 변경이 FIFO 공정성을 깨는지

작업 실행 순서를 정하는 queue는 사용자에게 보여 주는 목록 정렬과 목적이 다릅니다. 먼저 들어온 작업을 그대로 처리해야 한다면 앱 작업 큐의 FIFO 선택 기준처럼 처리 정책을 별도로 검토합니다. comparator 함수를 공개 API 경계에 전달할 때 타입을 명확히 두는 이유는 Dart var와 명시 타입 선택 실험에서 분석 결과로 확인할 수 있습니다.

이번 재현에서 확인하지 않은 것

  • 명령줄 Dart VM만 실행했고 Flutter 위젯, 실제 단말, profile·release 앱은 측정하지 않았습니다.
  • 페이지 실패는 요청마다 동점 입력 순서가 달라지는 상황을 로컬 permutation으로 재현한 것입니다. 실제 서버나 데이터베이스를 실행하지 않았습니다.
  • 15.085ms는 이 컴퓨터와 이 입력·comparator에 한정된 중앙값이며 일반적인 성능 기준이 아닙니다.
  • 문자열 locale, Unicode 정규화, 데이터베이스 collation, null 필드 정렬은 다루지 않았습니다.
  • Dart가 항상 예시와 같은 동점 출력 순서를 낸다고 보장하지 않습니다. 검증한 핵심은 고유 기준이 없을 때 전체 순서가 결정되지 않는다는 점입니다.

판정에 사용한 Dart 공식 자료