CodeGym /행동 /JAVA 25 SELF /대용량 파일: 청킹(chunking) 패턴

대용량 파일: 청킹(chunking) 패턴

JAVA 25 SELF
레벨 41 , 레슨 2
사용 가능

1. 소개

현대에는 데이터가 비 온 뒤 버섯이 돋듯 더 빠르게 증가합니다. 때로는 수십 또는 수백 기가바이트에 달하는 파일을 다뤄야 하는데, 로그, 데이터베이스 덤프, 거대한 아카이브 등이 그 예입니다. 이런 파일을 통째로 메모리에 읽으려는 시도는 보통 좋지 않은 결말로 이어집니다. 프로그램이 메모리를 모두 잡아먹거나, 고통스럽게 느려지기 때문입니다.

이유는 분명합니다. 메모리는 무한하지 않고, 파일이 그 용량을 넘으면 OutOfMemoryError를 만날 위험이 있습니다. 설령 메모리가 충분하더라도, 하나의 스레드에서 거대한 파일을 순차적으로 읽고 처리하면 몇 시간씩 걸릴 수 있습니다. 여기에 디스크 자체의 한계도 있습니다. 디스크의 읽기 속도는 정해져 있지만, 특히 SSD에서는 여러 스레드를 활용하면 전체 처리 시간을 눈에 띄게 줄일 수 있습니다.

따라서 핵심 결론은 간단합니다. 대용량 파일은 이른바 청크 단위로 나누어 처리하고, 가능하면 병렬로 수행해야 합니다. 이러한 접근이 기가바이트급 데이터를 무리 없이 다루게 해 줍니다.

2. 해결책: 청킹(chunking) 패턴

Chunking은 큰 파일을 작고 관리 가능한 청크(chunks)로 분할하고, 각 청크를 서로 독립적으로 처리하는 패턴입니다.

비유:
수박 한 통을 한 번에 다 먹는 대신, 조각내어 한 조각씩 먹는 겁니다. 더 쉽고 더 빠릅니다!

어떻게 동작하나요?

  1. 파일 크기 확인.
    • File.length() 또는 Files.size(Path)로 파일의 바이트 수를 확인합니다.
  2. 청크 크기 계산 (chunk size).
    • 보통 10–20 MB(과제와 하드웨어에 따라 더 크거나/작을 수 있음)를 선택합니다.
    • 청크 크기는 chunkSize 변수에 보관하고, 성능을 위해 디스크 블록 크기의 배수로 맞추는 것이 좋습니다.
  3. 작업 목록 만들기.
    • 각 작업은 한 청크를 처리합니다: 읽기, 파싱, 암호화, 압축 등.
    • 스레드 풀을 사용해 작업을 병렬 실행할 수 있습니다.

시각화:

+-------------------+
|      File         |
+-------------------+
|  [chunk 1]        |
|  [chunk 2]        |
|  [chunk 3]        |
|  ...              |
|  [chunk N]        |
+-------------------+

3. 병렬 처리 구현

ExecutorService 또는 ForkJoinPool 사용

청크를 병렬로 처리하려면 Java의 표준 멀티스레딩 도구를 사용하세요:

  • ExecutorService — 고정 크기 스레드 풀(Executors.newFixedThreadPool(n)).
  • ForkJoinPool — 재귀 작업과 분할 정복에 적합합니다.

예시:

ExecutorService pool = Executors.newFixedThreadPool(4); // 4개의 스레드

for (int i = 0; i < chunkCount; i++) {
    final int chunkIndex = i;
    pool.submit(() -> {
        processChunk(file, chunkIndex, chunkSize);
    });
}

pool.shutdown();
pool.awaitTermination(1, TimeUnit.HOURS);

각 작업은 자신의 청크를 읽고 독립적으로 처리합니다.

4. 핵심 메커니즘: RandomAccessFile과 FileChannel

RandomAccessFile

RandomAccessFile은 파일 내를 “이동”하며 원하는 위치에서 읽을 수 있게 해 줍니다.

try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
    raf.seek(chunkStart); // 청크 시작 위치로 이동
    byte[] buffer = new byte[chunkSize];
    int bytesRead = raf.read(buffer);
    // 버퍼를 처리
}
  • seek(long pos) — 커서를 원하는 위치로 이동합니다.
  • 필요한 바이트 범위만 읽을 수 있습니다.

FileChannel

FileChannel은 특히 대용량 파일에서 더 현대적이고 빠른 방법입니다.

try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
    ByteBuffer buffer = ByteBuffer.allocate(chunkSize);
    channel.position(chunkStart);
    int bytesRead = channel.read(buffer);
    // 버퍼를 처리
}
  • position(long newPosition) — 읽기 위치를 설정합니다.
  • 원하지 않는 영역을 건드리지 않고 필요한 범위만 읽을 수 있습니다.

5. 청킹과 transferTo/transferFrom 비교

transferTo/transferFrom

FileChannel.transferTo()transferFrom() 메서드는 이른바 zero-copy를 사용할 수 있게 해 줍니다. 아이디어는 간단합니다. 데이터를 JVM 버퍼를 거치지 않고 파일과 스트림 사이에서 직접 복사/이동할 수 있습니다. 그래서 매우 빠릅니다. 단 하나의 제한은 데이터를 “실시간으로” 변경할 수 없고 복사만 가능하다는 점입니다. 그러나 많은 작업에서 이 접근만으로도 대용량 처리 속도를 크게 높일 수 있습니다.

예시:

try (FileChannel src = FileChannel.open(srcPath, READ);
     FileChannel dst = FileChannel.open(dstPath, WRITE)) {
    src.transferTo(0, src.size(), dst);
}

Chunking

정리하면, 청킹은 대용량 파일을 청크(chunks) 단위로 처리하는 방식입니다. 단순 복사뿐 아니라 파싱, 암호화, 압축, 검색 등을 진행할 수 있습니다. 각 청크는 독립적으로 처리할 수 있고, 필요하다면 병렬로도 처리할 수 있어 속도를 눈에 띄게 높일 수 있습니다.

업무가 단순 복사라면 데이터가 직접 빠르게 이동하고 불필요한 복사가 없는 transferTotransferFrom을 사용하는 편이 낫습니다. 하지만 내용 검색, 변경, 분석이 필요하다면 청킹이 필수 도구가 됩니다.

6. 제약과 함정

스레드 오버헤드

  • 스레드를 과도하게 만들면 컨텍스트 스위칭과 자원 경합으로 성능이 떨어질 수 있습니다.
  • 보통 스레드 수는 CPU 코어 수와 같거나 약간 크게 잡습니다.

디스크 한계

  • 스레드가 100개여도 디스크는 자신의 최대 속도보다 빨리 읽을 수 없습니다.
  • SSD에서는 병렬 읽기가 이점을 주지만, HDD에서는 거의 없습니다.

동기화 필요성

  • 청크 처리가 서로 독립적이라면 간단합니다.
  • 공통 결과를 합쳐야 한다면(예: 파일의 모든 숫자 합산), 공유 변수 접근을 동기화해야 합니다(예: AtomicLong 사용 또는 별도 리스트에 결과 수집).

청크 경계

  • 텍스트 파일이라면 주의가 필요합니다. 행이나 문자를 중간에서 자르지 마세요.
  • 바이너리 파일(아카이브, 이미지)이라면 보통 어디서 잘라도 괜찮습니다.
  • 텍스트 파일의 경우 청크를 약간 겹치게 하거나 가장 가까운 줄바꿈을 찾아 맞춥니다.

7. 예시: 대용량 파일에서 숫자 합을 병렬로 계산

문제:
한 줄에 숫자 하나씩 들어 있는, 수백만 개의 숫자를 가진 파일이 있습니다. 이들의 합을 빠르게 계산해야 합니다.

단계별 계획:

  1. 파일 크기를 확인합니다.
  2. 청크 크기를 선택합니다(예: 10 MB).
  3. 각 청크에 대해:
    • 가장 가까운 줄바꿈을 찾아 숫자를 중간에서 자르지 않도록 합니다.
    • 청크를 읽고, 숫자를 파싱하여, 합계를 계산합니다.
  4. 모든 청크의 합계를 모읍니다.

코드 스켈레톤:

ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Long>> results = new ArrayList<>();

for (int i = 0; i < chunkCount; i++) {
    final int chunkIndex = i;
    results.add(pool.submit(() -> {
        // RandomAccessFile을 열고, 청크 경계를 찾는다
        // 읽고, 숫자를 파싱하고, 합계를 계산한다
        long chunkSum = 0L;
        return chunkSum;
    }));
}

long total = 0;
for (Future<Long> f : results) {
    total += f.get();
}
pool.shutdown();
System.out.println("합계: " + total);

8. 정리와 모범 사례

  • Chunking — 대용량 파일 처리를 위한 범용 패턴입니다. 청크로 나누고, 독립적으로 처리하며, 결과를 합칩니다.
  • RandomAccessFile 또는 FileChannel로 원하는 위치부터 읽습니다.
  • 병렬 처리를 위해 ExecutorService 또는 ForkJoinPool을 사용합니다.
  • 단순 복사에는 transferTo/transferFrom(zero-copy)을 사용합니다.
  • 청크 크기, 스레드 수, 디스크 한계를 신중히 조정하세요.
  • 텍스트 파일은 줄 경계를 주의 깊게 처리하세요.
  • 바이너리 파일은 형식의 특수성이 없다면 자유롭게 분할해도 됩니다.

9. 청킹 작업 시 흔한 실수

오류 #1: 파일을 통째로 읽기. 전체 파일을 메모리에 읽으려다 OutOfMemoryError를 맞습니다.

오류 #2: 스레드를 과도하게 생성. 스레드를 너무 많이 만들면 컨텍스트 스위칭 때문에 시스템이 “버벅입니다”.

오류 #3: 행을 중간에서 절단. 텍스트 파일의 줄 경계를 고려하지 않아 “찢어진” 행과 파싱 오류가 발생합니다.

오류 #4: 메서드 오용. transferTo/transferFrom으로 데이터를 처리하려고 합니다 — 작동하지 않습니다. 이 메서드들은 복사용입니다.

오류 #5: 동기화를 잊음. 결과 수집을 동기화하지 않아 잘못된 합계나 다른 버그가 생깁니다.

오류 #6: 자원 누수. 파일/채널을 닫지 않아 자원 누수가 발생합니다.

1
과제
JAVA 25 SELF, 레벨 41, 레슨 2
잠금
아카이브에서 잃어버린 조각 찾기 🔍
아카이브에서 잃어버린 조각 찾기 🔍
1
과제
JAVA 25 SELF, 레벨 41, 레슨 2
잠금
은하 설계도 모듈 분할 🏗️
은하 설계도 모듈 분할 🏗️
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION