안정 정렬 vs 불안정 정렬 — 왜 Merge Sort가 필요한 순간이 있는가
Quick Sort가 평균적으로 가장 빠른데, 왜 Java는 객체 배열을 Merge Sort 기반으로 정렬할까요? 여기엔 "안정성"이라는 중요한 이유가 있습니다.
개념 정의
안정 정렬(Stable Sort)은 같은 키를 가진 원소들의 원래 순서가 정렬 후에도 유지 되는 정렬 알고리즘입니다.
원본: [(Alice, 90), (Bob, 80), (Carol, 90)]
키: 점수
안정 정렬 결과: [(Bob, 80), (Alice, 90), (Carol, 90)] ← Alice가 Carol보다 먼저
불안정 정렬 결과: [(Bob, 80), (Carol, 90), (Alice, 90)] ← 순서 바뀜 가능
왜 안정 정렬이 필요한가
다중 키 정렬
안정 정렬을 활용하면 여러 기준으로 정렬할 때 이전 정렬 결과를 유지할 수 있습니다.
// 학생을 이름순으로 먼저 정렬하고, 그다음 성적순으로 정렬
// 안정 정렬이면 같은 성적 내에서 이름순이 유지됨
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 엔진이 안정 정렬을 사용합니다.
-- 두 번째 기준이 없으면 같은 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
int[] arr = {3, 1, 4, 1, 5, 9};
Arrays.sort(arr); // Dual-Pivot Quick Sort (불안정)
원시 타입은 값만 있으므로 "같은 값의 순서"가 의미 없습니다. 따라서 더 빠른 Quick Sort를 사용합니다.
객체 타입: TimSort
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 를 결합합니다.
핵심 아이디어
- **Run 탐지 **: 데이터에서 이미 정렬된 연속 구간(run)을 찾습니다.
- ** 작은 run 확장 **: run이 너무 작으면 Insertion Sort로 확장합니다.
- **Run 병합 **: 찾은 run들을 Merge Sort 방식으로 병합합니다.
원본: [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 구현 (안정 정렬의 대표)
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가 불안정한 이유
// 피벗 기준으로 교환하는 과정에서 순서가 바뀜
// 예: [3a, 2, 3b, 1] 피벗=3a
// 파티셔닝 후: [1, 2, 3a, 3b] 또는 [1, 2, 3b, 3a] → 보장 없음
Quick Sort를 안정하게 만들 수 있지만(인덱스를 추가 키로 사용), 추가 공간이 필요하여 장점이 사라집니다.
불안정 정렬을 안정하게 만들기
원래 인덱스를 함께 저장하면 어떤 정렬이든 안정하게 만들 수 있습니다.
// 원래 인덱스와 함께 정렬
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에서 같은 요청에 대해 항상 같은 순서의 응답을 반환해야 클라이언트가 예측 가능합니다.
// 안정 정렬로 일관된 결과 보장
@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 작성 시 주의사항
// 나쁜 예: 뺄셈은 오버플로우 위험
(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 Sort | Quick Sort, Heap Sort |
| 같은 값 순서 | 유지 | 보장 안 됨 |
| Java 적용 | 객체 배열 (Arrays.sort(Object[])) | 원시 타입 배열 (Arrays.sort(int[])) |
| 사용 시점 | 다중 키 정렬, 순서 보존 필요 | 순수 성능, 원시 타입 |
기억할 포인트:
- 안정 정렬은 ** 같은 키의 원래 순서를 유지 **합니다.
- Java의
Arrays.sort(Object[])과Collections.sort()는 TimSort(안정) 를 사용합니다. - TimSort는 이미 부분 정렬된 데이터 에서 특히 빠릅니다.
- 다중 키 정렬, API 응답 일관성, 로그 순서 보존 등에서 안정 정렬이 중요합니다.