1. 대용량 파일을 단일 스레드로 읽는 것은 벽돌을 한 장씩 나르는 것과 같다
수십 혹은 수백 메가바이트, 심지어 기가바이트 단위의 큰 파일을 다룰 때 단일 스레드로 읽거나 쓰면 금세 병목이 됩니다. 하나의 스레드만으로는 부하를 감당하기 어려운데, 디스크가 데이터를 공급하는 속도가 프로그램의 처리 속도보다 더 빠를 수 있기 때문입니다.
빠른 SSD를 쓰더라도 병목은 디스크가 아니라 오버헤드에서 발생합니다. 컨텍스트 스위칭, 버퍼 처리, 메모리 내 데이터 변환 등의 비용이 커져 성능이 떨어지고, 나머지 CPU 코어는 놀게 됩니다.
예를 들어 거대한 로그에서 단어 수를 세고 싶다고 합시다. 순차적으로 처리하면 하나의 스레드가 파일을 천천히 처리하는 동안 기다려야 합니다. 반면 파일을 여러 조각으로 나누고 각 조각을 여러 스레드에 맡기면 훨씬 빨라집니다. 각 스레드는 자신의 부분만 처리하고, 결과를 합치면 디스크의 잠재력을 거의 최대한 활용할 수 있습니다.
실제로 대역폭이 2 GB/s인 SSD에서도 단일 스레드 읽기는 약 300–500 MB/s에 그치는 경우가 많습니다. 반면 병렬로 읽으면 저장장치가 낼 수 있는 최대에 가까운 성능을 뽑아낼 수 있습니다.
2. 청크 분할(Chunking) — 파일이 당신을 위해 일하게 만들기
파일이 너무 커서 한 번에 처리하기 어렵다면 가장 합리적인 방법은 여러 부분으로 나누는 것입니다. 이 기법을 chunking(chunk는 ‘조각’이라는 뜻)이라고 합니다. 아이디어는 간단합니다. 큰 파일을 여러 논리적 구간으로 나누고 각 스레드에 한 구간씩 맡기는 것입니다.
각 스레드는 시작 오프셋(offset)과 종료 지점을 알고 있습니다. 자신에게 할당된 부분만 읽어 처리한 뒤, 결과를 모아 전체 결과를 만듭니다.
이 접근은 모든 CPU 코어를 동시에 활용하게 해주며, 특히 최신 SSD나 NVMe 드라이브에서 처리 속도를 크게 끌어올립니다. 줄 수 세기, 텍스트 검색, 통계 집계 같은 작업에서 chunking은 터보차저처럼 별다른 노력 없이 속도를 올려줍니다.
청크 크기 선택 방법
청크 크기는 음식의 1인분 양과도 같습니다. 너무 작으면 잘게 나누느라 지치고, 너무 크면 소화(처리)가 힘듭니다. 작업 특성과 머신의 성능에 따라 달라집니다.
보통 스레드당 8–64 MB 범위가 좋은 결과를 냅니다. 대부분의 경우 10–20 MB 정도로 시작하면 무난하지만, 정답은 없고 실험으로 찾는 게 좋습니다. 핵심은 청크가 충분히 커서 스레드 전환 등의 오버헤드가 크지 않으면서도, 캐시를 과도하게 압박하거나 메모리를 다 차지하지 않도록 하는 것입니다.
텍스트를 다루는 경우(예: 단어 수 세기, 패턴 검색)에는 청크가 줄이나 단어를 중간에서 끊지 않도록 하는 게 중요합니다. 일반적으로 조각 간에 약간의 겹침을 두거나, 경계를 가장 가까운 줄바꿈 문자까지 조정해 해결합니다. 이렇게 하면 처리 정확도를 유지하고 결과를 깨끗하고 예측 가능하게 만들 수 있습니다.
3. 위치 기반 접근 도구: FileChannel과 MappedByteBuffer
FileChannel: 위치 지정 I/O
FileChannel은 java.nio.channels 패키지의 클래스로, 파일을 저수준에서 다루며 파일 내 임의 위치에서 데이터를 읽고 쓰는 기능을 제공합니다.
핵심 메서드:
- position(long newPosition) — 읽기/쓰기를 위한 위치(offset)를 설정합니다.
- read(ByteBuffer dst, long position) — 지정한 위치부터 파일의 데이터를 버퍼로 읽습니다(채널의 현재 위치는 바뀌지 않습니다!).
- write(ByteBuffer src, long position) — 지정한 위치부터 파일에 데이터를 기록합니다.
예시: 파일의 일부 읽기
try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
long chunkSize = 16 * 1024 * 1024; // 16 MB
long offset = 0;
ByteBuffer buffer = ByteBuffer.allocate((int) chunkSize);
int bytesRead = channel.read(buffer, offset);
// buffer에는 파일의 처음 16 MB가 들어 있음
}
장점:
- 파일 내 임의의 위치에서 읽기/쓰기가 가능합니다.
- 병렬 처리에 용이합니다. 각 스레드가 자신의 조각만 다룹니다.
MappedByteBuffer: Memory-mapped files
MappedByteBuffer는 파일의 일부를 메모리에 “매핑”하여 사용하는 특별한 버퍼입니다. 운영체제가 디스크와 메모리 간 데이터 로딩/반영을 알아서 처리합니다.
동작 방식:
- 파일의 일부를 메모리에 매핑합니다.
- 버퍼에 읽고 씁니다. 운영체제가 필요한 페이지를 자동으로 적재합니다.
- read/write를 명시적으로 호출하지 않고 메모리 접근으로 처리됩니다.
예시:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
long chunkSize = 16 * 1024 * 1024; // 16 MB
long offset = 0;
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, chunkSize);
// 이제 buffer는 바이트 배열처럼 동작하지만, 접근 시점에 디스크에서 데이터를 읽는다
}
장점:
- 매우 빠릅니다(특히 SSD에서).
- 단순합니다. 배열처럼 읽고 쓰면 됩니다.
단점:
- 가상 메모리를 사용합니다. 파일이 매우 크면 메모리를 “가득” 채울 수 있습니다.
- 언제 메모리에서 해제될지 제어하기 어렵습니다(버퍼가 필요 이상 오래 메모리에 남을 수 있음).
- 32비트 시스템에서 매우 큰 파일(2–4 GB 이상)에는 부적합할 수 있습니다.
4. 예제: 병렬 읽기와 단어 수 세기
대용량 텍스트 파일(예: 10 GB 로그)에서 단어 수를 병렬 처리로 계산해 봅니다.
1단계. 파일을 청크로 나누기
- 파일 크기 구하기: long fileSize = Files.size(path);
- 청크 크기 선택(예: 16 MB).
- 각 청크의 오프셋 계산: offset = chunkIndex * chunkSize;
- 마지막 청크는 크기가 더 작을 수 있습니다.
2단계. 스레드용 작업 생성
- 청크마다 Callable<Integer>(또는 Runnable)을 만들어서:
- FileChannel.read(ByteBuffer, offset) 또는 MappedByteBuffer로 자신의 파일 조각을 엽니다.
- 해당 조각의 단어 수를 셉니다.
- 결과(단어 수)를 반환합니다.
3단계. ExecutorService로 작업 실행
- 스레드 풀 생성: ExecutorService pool = Executors.newFixedThreadPool(N);
- 작업을 풀에 제출: List<Future<Integer>> results = pool.invokeAll(tasks);
- 결과 수집: 모든 Future에서 값을 꺼내 합산합니다.
코드 예시(단순화):
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
public class ParallelWordCount {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
long fileSize = Files.size(path);
int chunkSize = 16 * 1024 * 1024; // 16 MB
int chunks = (int) ((fileSize + chunkSize - 1) / chunkSize);
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < chunks; i++) {
long offset = (long) i * chunkSize;
long size = Math.min(chunkSize, fileSize - offset);
results.add(pool.submit(() -> {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, size);
byte[] bytes = new byte[(int) size];
buffer.get(bytes);
String text = new String(bytes);
// 중요: 단어가 쪼개지지 않도록 청크 경계를 처리해야 함!
return countWords(text);
}
}));
}
int totalWords = 0;
for (Future<Integer> f : results) {
totalWords += f.get();
}
pool.shutdown();
System.out.println("Total words: " + totalWords);
}
private static int countWords(String text) {
// 가장 단순한 방법: 공백으로 분할하고 빈 문자열은 제외
String[] words = text.split("\\s+");
int count = 0;
for (String w : words) {
if (!w.isBlank()) count++;
}
return count;
}
}
주의: 실제로는 청크 경계를 꼼꼼히 처리해서 단어 또는 줄이 두 스레드 사이에서 끊어지지 않도록 해야 합니다. 보통 약간의 overlap(예: +100 바이트)을 두고 시작/끝을 보정합니다.
5. 정리 및 베스트 프랙티스
- 대용량 파일에는 청크 분할과 병렬 처리를 사용하세요.
- 위치 지정 접근에는 FileChannel, memory-mapped 파일에는 MappedByteBuffer를 활용하세요.
- 청크 크기는 실험적으로 정하세요. CPU 캐시와 디스크 대역폭을 기준으로 잡으면 좋습니다.
- 청크 경계를 신중히 처리하세요(특히 텍스트 처리에서).
- 병렬 처리에는 ExecutorService와 스레드 풀을 사용하세요.
- 스레드 수를 과도하게 늘리지 마세요. SSD에서는 보통 2–4개면 충분합니다.
- 메모리 사용량을 주의하세요. MappedByteBuffer는 가상 메모리를 많이 점유할 수 있습니다.
6. 대용량 파일과 chunking에서 자주 발생하는 실수
오류 №1: 전체 파일을 메모리에 읽어들이기. 대용량 파일 처리 시 OutOfMemoryError로 이어질 수 있습니다. 대신 데이터를 청크 단위로 나누어 읽으세요.
오류 №2: 청크 경계 처리 미흡. 줄이나 단어 경계를 고려하지 않고 자르면 데이터가 “쪼개져” 최종 결과가 부정확해질 수 있습니다.
오류 №3: 비효율적인 청크 크기. 너무 작으면 스레드 관리 오버헤드가 커지고, 너무 크면 메모리를 비효율적으로 사용합니다.
오류 №4: FileChannel을 닫지 않음. 리소스 누수로 이어집니다. try-with-resources를 사용해 채널이 반드시 닫히도록 하세요.
오류 №5: 과도한 스레드 수. 스레드가 너무 많으면 디스크가 요청을 처리하지 못해 성능이 오히려 떨어집니다.
GO TO FULL VERSION