Quick Sort가 평균적으로 가장 빠른데, 왜 Java는 객체 배열을 Merge Sort 기반으로 정렬할까요? 여기엔 "안정성"이라는 중요한 이유가 있습니다.

개념 정의

안정 정렬(Stable Sort)은 같은 키를 가진 원소들의 원래 순서가 정렬 후에도 유지 되는 정렬 알고리즘입니다.

PLAINTEXT
원본: [(Alice, 90), (Bob, 80), (Carol, 90)]
키: 점수

안정 정렬 결과:   [(Bob, 80), (Alice, 90), (Carol, 90)]  ← Alice가 Carol보다 먼저
불안정 정렬 결과: [(Bob, 80), (Carol, 90), (Alice, 90)]  ← 순서 바뀜 가능

왜 안정 정렬이 필요한가

다중 키 정렬

안정 정렬을 활용하면 여러 기준으로 정렬할 때 이전 정렬 결과를 유지할 수 있습니다.

JAVA
// 학생을 이름순으로 먼저 정렬하고, 그다음 성적순으로 정렬
// 안정 정렬이면 같은 성적 내에서 이름순이 유지됨
students.sort(Comparator.comparing(Student::getName));   // 1차: 이름
students.sort(Comparator.comparing(Student::getScore));  // 2차: 성적 (안정 정렬)

// 사실 Java에서는 Comparator 체이닝이 더 깔끔합니다
students.sort(Comparator.comparing(Student::getScore)
                        .thenComparing(Student::getName));

데이터베이스 ORDER BY

SQL의 ORDER BY는 같은 값에 대해 원래 순서를 보장하지 않습니다(표준에 명시 없음). 하지만 많은 DB 엔진이 안정 정렬을 사용합니다.

SQL
-- 두 번째 기준이 없으면 같은 score의 순서는 보장되지 않음
SELECT * FROM students ORDER BY score;

-- 안정적인 결과를 원하면 명시적으로
SELECT * FROM students ORDER BY score, name;

정렬 알고리즘별 안정성

알고리즘안정성평균 시간최악 시간공간
Bubble Sort안정O(n²)O(n²)O(1)
Insertion Sort안정O(n²)O(n²)O(1)
Merge Sort안정O(n log n)O(n log n)O(n)
TimSort** 안정**O(n log n)O(n log n)O(n)
Quick Sort불안정O(n log n)O(n²)O(log n)
Heap Sort불안정O(n log n)O(n log n)O(1)
Selection Sort불안정O(n²)O(n²)O(1)

Java의 정렬 알고리즘

원시 타입: Dual-Pivot Quick Sort

JAVA
int[] arr = {3, 1, 4, 1, 5, 9};
Arrays.sort(arr); // Dual-Pivot Quick Sort (불안정)

원시 타입은 값만 있으므로 "같은 값의 순서"가 의미 없습니다. 따라서 더 빠른 Quick Sort를 사용합니다.

객체 타입: TimSort

JAVA
Integer[] arr = {3, 1, 4, 1, 5, 9};
Arrays.sort(arr); // TimSort (안정)

List<String> list = Arrays.asList("banana", "apple", "cherry");
Collections.sort(list); // TimSort (안정)

객체는 여러 필드를 가지므로 안정성이 중요합니다. TimSort는 안정 정렬이면서도 실제 데이터에서 매우 효율적입니다.

TimSort 동작 원리

TimSort는 Python의 Tim Peters가 만든 하이브리드 정렬로, Merge Sort + Insertion Sort 를 결합합니다.

핵심 아이디어

  1. **Run 탐지 **: 데이터에서 이미 정렬된 연속 구간(run)을 찾습니다.
  2. ** 작은 run 확장 **: run이 너무 작으면 Insertion Sort로 확장합니다.
  3. **Run 병합 **: 찾은 run들을 Merge Sort 방식으로 병합합니다.
PLAINTEXT
원본: [1, 3, 5, 2, 4, 8, 7, 6]

Run 탐지:
  Run 1: [1, 3, 5]      (이미 오름차순)
  Run 2: [2, 4, 8]      (이미 오름차순)
  Run 3: [6, 7]         (내림차순 → 뒤집어서 오름차순)

병합:
  [1, 3, 5] + [2, 4, 8] → [1, 2, 3, 4, 5, 8]
  [1, 2, 3, 4, 5, 8] + [6, 7] → [1, 2, 3, 4, 5, 6, 7, 8]

왜 빠른가?

현실 세계의 데이터는 ** 이미 부분적으로 정렬되어 있는 경우가 많습니다 **. TimSort는 이런 패턴을 활용하여 불필요한 비교를 줄입니다.

  • 거의 정렬된 데이터: O(n)에 가까움
  • 역순 정렬된 데이터: O(n log n)
  • 완전히 랜덤한 데이터: O(n log n)

Merge Sort 구현 (안정 정렬의 대표)

JAVA
public static void mergeSort(int[] arr, int left, int right) {
    if (left >= right) return;

    int mid = left + (right - left) / 2;
    mergeSort(arr, left, mid);
    mergeSort(arr, mid + 1, right);
    merge(arr, left, mid, right);
}

private static void merge(int[] arr, int left, int mid, int right) {
    int[] temp = new int[right - left + 1];
    int i = left, j = mid + 1, k = 0;

    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) {   // <= 가 안정성의 핵심!
            temp[k++] = arr[i++];
        } else {
            temp[k++] = arr[j++];
        }
    }

    while (i <= mid) temp[k++] = arr[i++];
    while (j <= right) temp[k++] = arr[j++];

    System.arraycopy(temp, 0, arr, left, temp.length);
}

arr[i] <= arr[j]에서 <=가 안정성의 핵심입니다. 같은 값이면 왼쪽(먼저 있던) 원소를 먼저 가져갑니다.

Quick Sort가 불안정한 이유

JAVA
// 피벗 기준으로 교환하는 과정에서 순서가 바뀜
// 예: [3a, 2, 3b, 1] 피벗=3a
// 파티셔닝 후: [1, 2, 3a, 3b] 또는 [1, 2, 3b, 3a] → 보장 없음

Quick Sort를 안정하게 만들 수 있지만(인덱스를 추가 키로 사용), 추가 공간이 필요하여 장점이 사라집니다.

불안정 정렬을 안정하게 만들기

원래 인덱스를 함께 저장하면 어떤 정렬이든 안정하게 만들 수 있습니다.

JAVA
// 원래 인덱스와 함께 정렬
int[][] indexed = new int[n][2];
for (int i = 0; i < n; i++) {
    indexed[i] = new int[]{arr[i], i};
}

Arrays.sort(indexed, (a, b) -> {
    if (a[0] != b[0]) return Integer.compare(a[0], b[0]);
    return Integer.compare(a[1], b[1]); // 같은 값이면 원래 인덱스 순
});

백엔드/실무에서의 연결

API 응답의 정렬 일관성

REST API에서 같은 요청에 대해 항상 같은 순서의 응답을 반환해야 클라이언트가 예측 가능합니다.

JAVA
// 안정 정렬로 일관된 결과 보장
@GetMapping("/users")
public List<User> getUsers(@RequestParam String sortBy) {
    List<User> users = userRepository.findAll();
    // Java의 List.sort()는 TimSort → 안정 정렬
    users.sort(Comparator.comparing(u -> getFieldValue(u, sortBy)));
    return users;
}

로그 정렬

시간순으로 정렬된 로그에서 같은 타임스탬프의 로그는 원래 순서(도착 순서)를 유지해야 합니다. 안정 정렬이 필수입니다.

이벤트 처리

메시지 큐에서 같은 파티션의 이벤트 순서는 보장되어야 합니다. 이벤트를 재정렬할 때 안정 정렬을 사용하면 타임스탬프가 같은 이벤트의 순서가 유지됩니다.

Comparator 작성 시 주의사항

JAVA
// 나쁜 예: 뺄셈은 오버플로우 위험
(a, b) -> a.getValue() - b.getValue()

// 좋은 예: Integer.compare 사용
(a, b) -> Integer.compare(a.getValue(), b.getValue())

// 더 좋은 예: Comparator.comparing 사용
Comparator.comparing(Entry::getValue)

주의할 점

다중 기준 정렬에서 불안정 정렬을 사용하는 실수

이름순으로 정렬한 뒤 점수순으로 다시 정렬할 때, 불안정 정렬을 사용하면 같은 점수 내에서 이름 순서가 보장되지 않습니다. Java의 Collections.sort()는 TimSort(안정)이므로 안전합니다.

primitive 배열의 Arrays.sort()가 불안정임을 모르는 실수

Java에서 Arrays.sort(int[])는 Dual-Pivot Quick Sort(불안정)를 사용합니다. 안정 정렬이 필요하면 Integer[]로 변환하거나 직접 정렬을 구현해야 합니다.


정리

구분안정 정렬불안정 정렬
대표Merge Sort, TimSort, Insertion SortQuick Sort, Heap Sort
같은 값 순서유지보장 안 됨
Java 적용객체 배열 (Arrays.sort(Object[]))원시 타입 배열 (Arrays.sort(int[]))
사용 시점다중 키 정렬, 순서 보존 필요순수 성능, 원시 타입

기억할 포인트:

  • 안정 정렬은 ** 같은 키의 원래 순서를 유지 **합니다.
  • Java의 Arrays.sort(Object[])Collections.sort()TimSort(안정) 를 사용합니다.
  • TimSort는 이미 부분 정렬된 데이터 에서 특히 빠릅니다.
  • 다중 키 정렬, API 응답 일관성, 로그 순서 보존 등에서 안정 정렬이 중요합니다.
댓글 로딩 중...