외부 정렬 — 메모리에 안 들어가는 데이터를 정렬하는 법
100GB 로그 파일을 시간순으로 정렬해야 합니다. 서버 메모리는 8GB. 어떻게 해야 할까요?
개념 정의
외부 정렬(External Sort)은 **메모리에 모든 데이터를 올릴 수 없을 때 **, 디스크(외부 저장소)를 활용하여 정렬하는 기법입니다.
핵심 아이디어는 간단합니다:
- 데이터를 메모리에 들어가는 크기로 나눈다
- 각 조각을 메모리에서 정렬한다
- 정렬된 조각들을 병합한다
왜 필요한가
- 대용량 로그 파일 정렬
- 데이터베이스의 ORDER BY (인덱스 없는 경우)
- MapReduce의 셔플(Shuffle) 단계
- 대량 데이터 ETL 파이프라인
메모리가 아무리 커도, 데이터는 항상 메모리보다 클 수 있습니다.
2-Way 외부 정렬
가장 기본적인 형태입니다.
Phase 1: 정렬된 런(Run) 생성
원본 파일: [38, 27, 43, 3, 9, 82, 10, 44, 15, 58, 21, 7]
메모리: 4개씩 처리 가능
Run 1: [3, 27, 38, 43] (38,27,43,3을 메모리에서 정렬)
Run 2: [9, 10, 44, 82] (9,82,10,44를 메모리에서 정렬)
Run 3: [7, 15, 21, 58] (15,58,21,7을 메모리에서 정렬)
Phase 2: 병합(Merge)
1차 병합:
Run 1 + Run 2 → [3, 9, 10, 27, 38, 43, 44, 82]
Run 3 → [7, 15, 21, 58] (대기)
2차 병합:
[3, 9, 10, 27, 38, 43, 44, 82] + [7, 15, 21, 58]
→ [3, 7, 9, 10, 15, 21, 27, 38, 43, 44, 58, 82]
2-Way 병합은 한 번에 2개의 런만 합치므로, 런이 많으면 패스(pass) 횟수가 많아집니다.
K-Way 외부 정렬
K개의 런을 동시에 병합 하여 패스 횟수를 줄입니다.
구현
public class ExternalSort {
// Phase 1: 정렬된 런(run) 파일 생성
public static List<File> createSortedRuns(File inputFile, int chunkSize)
throws IOException {
List<File> runs = new ArrayList<>();
BufferedReader reader = new BufferedReader(new FileReader(inputFile));
List<Integer> chunk = new ArrayList<>();
String line;
while ((line = reader.readLine()) != null) {
chunk.add(Integer.parseInt(line.trim()));
if (chunk.size() >= chunkSize) {
runs.add(writeSortedRun(chunk));
chunk.clear();
}
}
if (!chunk.isEmpty()) {
runs.add(writeSortedRun(chunk));
}
reader.close();
return runs;
}
private static File writeSortedRun(List<Integer> chunk) throws IOException {
Collections.sort(chunk); // 메모리 내 정렬
File runFile = File.createTempFile("run_", ".txt");
BufferedWriter writer = new BufferedWriter(new FileWriter(runFile));
for (int val : chunk) {
writer.write(val + "\n");
}
writer.close();
return runFile;
}
// Phase 2: K-Way Merge
public static void kWayMerge(List<File> runs, File outputFile)
throws IOException {
// min-heap: (값, 런 인덱스)
PriorityQueue<int[]> minHeap = new PriorityQueue<>(
Comparator.comparingInt(a -> a[0])
);
BufferedReader[] readers = new BufferedReader[runs.size()];
BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile));
// 각 런의 첫 원소를 힙에 삽입
for (int i = 0; i < runs.size(); i++) {
readers[i] = new BufferedReader(new FileReader(runs.get(i)));
String line = readers[i].readLine();
if (line != null) {
minHeap.offer(new int[]{Integer.parseInt(line.trim()), i});
}
}
// 병합
while (!minHeap.isEmpty()) {
int[] min = minHeap.poll();
writer.write(min[0] + "\n");
// 해당 런에서 다음 원소 읽기
String line = readers[min[1]].readLine();
if (line != null) {
minHeap.offer(new int[]{Integer.parseInt(line.trim()), min[1]});
}
}
writer.close();
for (BufferedReader r : readers) r.close();
// 임시 파일 정리
for (File run : runs) run.delete();
}
}
K-Way Merge의 복잡도
- 각 원소를 힙에 넣고 빼는 비용: O(log K)
- 전체 원소 수 N: O(N log K)
- K가 클수록 패스 횟수가 줄어들지만, 동시에 열어야 하는 파일이 많아집니다
패스 횟수
런 수: R
한 번에 병합하는 런 수: K
패스 횟수 = ⌈log_K(R)⌉
예: 런 1000개, K=100 → 패스 2번 (100개씩 → 10개 → 1개)
Replacement Selection
런을 더 길게 만드는 기법입니다. 힙을 사용하여 메모리 크기의 약 2배 길이의 런을 생성합니다.
// 개념: 힙에서 최솟값을 출력하고, 새 원소가 출력된 값보다 크면 힙에 추가
// 작으면 다음 런으로 넘김
// 평균적으로 메모리 크기의 2배인 런이 생성됨
런이 길어지면 런의 개수가 줄어들고, 병합 패스 횟수도 줄어듭니다.
I/O 최적화
외부 정렬에서 가장 큰 병목은 ** 디스크 I/O**입니다.
버퍼링
// 입력 버퍼: 각 런에 대해 블록 단위로 읽기
// 출력 버퍼: 결과를 블록 단위로 쓰기
// 버퍼 크기 = 가용 메모리 / (K + 1)
// K개 입력 버퍼 + 1개 출력 버퍼
더블 버퍼링
읽기/쓰기를 오버랩하여 I/O 대기 시간을 줄입니다.
순차 접근
외부 정렬은 디스크를 ** 순차적으로** 접근합니다. 랜덤 접근보다 훨씬 빠르기 때문에, 외부 정렬은 SSD보다 HDD에서도 비교적 잘 동작합니다.
백엔드/실무에서의 연결
MySQL의 filesort
MySQL에서 인덱스를 사용할 수 없는 ORDER BY를 실행하면 filesort 가 발생합니다.
-- EXPLAIN으로 filesort 확인
EXPLAIN SELECT * FROM orders ORDER BY total_price;
-- Extra: Using filesort
sort_buffer_size를 초과하면 디스크에 임시 파일을 만들어 외부 정렬을 수행합니다.
-- sort_buffer_size 확인
SHOW VARIABLES LIKE 'sort_buffer_size';
-- 기본값: 256KB ~ 수 MB
MapReduce Shuffle
MapReduce의 Map 출력을 Reduce 입력으로 전달하는 Shuffle 단계가 외부 정렬입니다.
Map 출력 → 파티셔닝 → 각 파티션 내 정렬 → Reduce로 전달
↑ 외부 정렬
Hadoop은 각 Map 태스크의 출력을 정렬된 스필(spill) 파일로 만들고, K-way merge로 병합합니다.
대용량 로그 정렬
# Unix의 sort 명령어도 외부 정렬을 사용
sort --buffer-size=2G --temporary-directory=/tmp bigfile.log
GNU sort는 내부적으로 메모리 제한 내에서 런을 생성하고 K-way merge를 수행합니다.
Spark의 External Shuffle
Apache Spark의 셔플도 외부 정렬 기반입니다. spark.shuffle.spill 설정으로 메모리 초과 시 디스크로 스필하는 동작을 제어합니다.
데이터베이스 인덱스 구축
대량의 데이터에 인덱스를 생성할 때, 키-포인터 쌍을 외부 정렬로 정렬한 후 B-Tree를 구축합니다. 이를 "벌크 로딩(Bulk Loading)"이라 합니다.
Java로 간단한 외부 정렬 전체 예제
public class SimpleExternalSort {
private static final int CHUNK_SIZE = 100_000; // 메모리에 올릴 수 있는 원소 수
public static void sort(String inputPath, String outputPath) throws IOException {
// Phase 1: 정렬된 런 생성
List<File> runs = createSortedRuns(new File(inputPath), CHUNK_SIZE);
// Phase 2: K-Way Merge
kWayMerge(runs, new File(outputPath));
}
public static void main(String[] args) throws IOException {
sort("large_data.txt", "sorted_data.txt");
}
}
주의할 점
디스크 I/O 패턴을 고려하지 않는 실수
외부 정렬에서 성능 병목은 CPU가 아니라 디스크 I/O입니다. 버퍼 크기를 너무 작게 설정하면 빈번한 디스크 접근으로 성능이 급격히 떨어집니다. 가용 메모리의 대부분을 버퍼로 활용해야 합니다.
MySQL의 filesort를 방치하는 실수
EXPLAIN에서 "Using filesort"가 나타나면, sort_buffer_size를 초과하여 디스크 기반 외부 정렬이 수행되고 있다는 뜻입니다. 인덱스를 활용하거나 sort_buffer_size를 조정해야 합니다.
정리
| 단계 | 동작 | 핵심 자료구조 |
|---|---|---|
| Phase 1: 런 생성 | 청크 단위로 메모리 내 정렬 | 내부 정렬 (TimSort 등) |
| Phase 2: K-Way Merge | 모든 런을 동시에 병합 | Min-Heap |
| I/O 최적화 | 버퍼링, 순차 접근 | 입출력 버퍼 |
기억할 포인트:
- 외부 정렬 = 런 생성 + K-Way Merge 두 단계입니다.
- K-Way Merge에서 min-heap이 핵심입니다. K개 런의 선두 원소 중 최솟값을 O(log K)에 선택합니다.
- MySQL의 filesort, MapReduce의 Shuffle, Spark의 External Shuffle 모두 외부 정렬의 응용입니다.
- 디스크 I/O가 병목이므로, 버퍼링과 순차 접근으로 최적화합니다.