100GB 로그 파일을 시간순으로 정렬해야 합니다. 서버 메모리는 8GB. 어떻게 해야 할까요?

개념 정의

외부 정렬(External Sort)은 **메모리에 모든 데이터를 올릴 수 없을 때 **, 디스크(외부 저장소)를 활용하여 정렬하는 기법입니다.

핵심 아이디어는 간단합니다:

  1. 데이터를 메모리에 들어가는 크기로 나눈다
  2. 각 조각을 메모리에서 정렬한다
  3. 정렬된 조각들을 병합한다

왜 필요한가

  • 대용량 로그 파일 정렬
  • 데이터베이스의 ORDER BY (인덱스 없는 경우)
  • MapReduce의 셔플(Shuffle) 단계
  • 대량 데이터 ETL 파이프라인

메모리가 아무리 커도, 데이터는 항상 메모리보다 클 수 있습니다.

2-Way 외부 정렬

가장 기본적인 형태입니다.

Phase 1: 정렬된 런(Run) 생성

PLAINTEXT
원본 파일: [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)

PLAINTEXT
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개의 런을 동시에 병합 하여 패스 횟수를 줄입니다.

구현

JAVA
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가 클수록 패스 횟수가 줄어들지만, 동시에 열어야 하는 파일이 많아집니다

패스 횟수

PLAINTEXT
런 수: R
한 번에 병합하는 런 수: K

패스 횟수 = ⌈log_K(R)⌉

예: 런 1000개, K=100 → 패스 2번 (100개씩 → 10개 → 1개)

Replacement Selection

런을 더 길게 만드는 기법입니다. 힙을 사용하여 메모리 크기의 약 2배 길이의 런을 생성합니다.

JAVA
// 개념: 힙에서 최솟값을 출력하고, 새 원소가 출력된 값보다 크면 힙에 추가
// 작으면 다음 런으로 넘김
// 평균적으로 메모리 크기의 2배인 런이 생성됨

런이 길어지면 런의 개수가 줄어들고, 병합 패스 횟수도 줄어듭니다.

I/O 최적화

외부 정렬에서 가장 큰 병목은 ** 디스크 I/O**입니다.

버퍼링

JAVA
// 입력 버퍼: 각 런에 대해 블록 단위로 읽기
// 출력 버퍼: 결과를 블록 단위로 쓰기
// 버퍼 크기 = 가용 메모리 / (K + 1)
//   K개 입력 버퍼 + 1개 출력 버퍼

더블 버퍼링

읽기/쓰기를 오버랩하여 I/O 대기 시간을 줄입니다.

순차 접근

외부 정렬은 디스크를 ** 순차적으로** 접근합니다. 랜덤 접근보다 훨씬 빠르기 때문에, 외부 정렬은 SSD보다 HDD에서도 비교적 잘 동작합니다.

백엔드/실무에서의 연결

MySQL의 filesort

MySQL에서 인덱스를 사용할 수 없는 ORDER BY를 실행하면 filesort 가 발생합니다.

SQL
-- EXPLAIN으로 filesort 확인
EXPLAIN SELECT * FROM orders ORDER BY total_price;
-- Extra: Using filesort

sort_buffer_size를 초과하면 디스크에 임시 파일을 만들어 외부 정렬을 수행합니다.

SQL
-- sort_buffer_size 확인
SHOW VARIABLES LIKE 'sort_buffer_size';
-- 기본값: 256KB ~ 수 MB

MapReduce Shuffle

MapReduce의 Map 출력을 Reduce 입력으로 전달하는 Shuffle 단계가 외부 정렬입니다.

PLAINTEXT
Map 출력 → 파티셔닝 → 각 파티션 내 정렬 → Reduce로 전달
                        ↑ 외부 정렬

Hadoop은 각 Map 태스크의 출력을 정렬된 스필(spill) 파일로 만들고, K-way merge로 병합합니다.

대용량 로그 정렬

BASH
# 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로 간단한 외부 정렬 전체 예제

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가 병목이므로, 버퍼링과 순차 접근으로 최적화합니다.
댓글 로딩 중...