1. Chunking — 파일을 조각으로 읽기
이전 강의에서 이야기했듯이, chunking은 파일 전체를 메모리에 올리지 않고 일부씩 처리할 수 있게 해줍니다. 이는 특히 대용량 데이터를 다룰 때 중요합니다. 파일이 10 MB 정도라면 대개 문제가 없습니다 — 전체를 로드해 어떤 방식으로든 작업할 수 있습니다. 하지만 파일이 10 GB에 이르고 RAM이 8 GB에 불과하며, 수십 개의 탭이 열린 브라우저와 IDE까지 켜져 있다면 어떻게 해야 할까요? 이런 파일을 통째로 읽으려 하면 보통 비극으로 끝납니다: OutOfMemoryError, 프로그램 멈춤, 그리고 개발자의 눈물.
이런 대용량 파일은 현실에서 흔합니다: 한 달치 서버 로그는 수십 GB에 달할 수 있고, 대형 CSV 파일에는 수백만 행이 있으며, 동영상, 아카이브, 데이터베이스 덤프는 그보다 더 큽니다.
핵심은 같습니다. ‘코끼리를 통째로 먹으려’ 하지 말고 조각내서 처리하세요. 바로 chunking이 파일을 관리 가능한 단위로 나눠 이러한 데이터를 안전하고 효율적으로 처리하게 해줍니다.
Chunking 다시 한 번
Chunk(조각, 블록)은 특정 크기의 파일 일부를 의미합니다. 모든 것을 한 번에 읽는 대신, 예를 들어 4 MB(또는 64 KB, 1 MB 등 상황에 따라)씩 읽습니다.
원리:
- 파일을 읽기 위한 스트림을 엽니다.
- 고정 크기의 바이트 배열인 버퍼를 생성합니다.
- 파일 끝에 도달할 때까지 루프에서 버퍼로 읽습니다.
- 각 ‘조각’을 개별적으로 처리합니다.
예시: 큰 파일을 조각 단위로 복사하기
매우 큰 파일을 복사해야 한다고 합시다. 이를 ‘프로답게’ 수행하는 프로그램을 작성해 봅시다.
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class BigFileCopy {
public static void main(String[] args) throws IOException {
String source = "bigfile.dat";
String dest = "bigfile_copy.dat";
int bufferSize = 4 * 1024 * 1024; // 4 MB
try (FileInputStream in = new FileInputStream(source);
FileOutputStream out = new FileOutputStream(dest)) {
byte[] buffer = new byte[bufferSize];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
// 진행률 출력이나 데이터 처리를 추가할 수 있습니다
}
}
System.out.println("복사가 완료되었습니다!");
}
}
Java에서 파일 작업에는 보통 표준 스트림 FileInputStream과 FileOutputStream을 사용합니다. 현대 디스크에서는 약 4 MB 버퍼가 읽기/쓰기 효율에 충분한 경우가 많습니다. 루프에서 프로그램은 파일의 조각을 읽자마자 새 파일에 바로 기록하며, 전체 파일을 메모리에 보관하려 들지 않습니다.
이 접근법은 RAM을 절약하고 OutOfMemoryError 같은 오류를 피하며, 100 GB 이상처럼 매우 큰 파일도 처리할 수 있게 해줍니다.
2. 데이터 처리를 위한 chunking
종종 목표는 단순 복사가 아니라, 예를 들어 특정 문자열을 찾거나, 등장 횟수를 세거나, 무언가를 치환하는 일 등입니다.
예시: 큰 텍스트 파일에서 문자열 검색
파일이 텍스트라면 문자 스트림으로 행 단위 읽기가 더 편리합니다:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class BigFileSearch {
public static void main(String[] args) throws IOException {
String file = "biglog.txt";
String keyword = "ERROR";
int count = 0;
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.contains(keyword)) {
count++;
}
}
}
System.out.println("ERROR가 포함된 줄 " + count + "개를 찾았습니다");
}
}
왜 이것이 기가바이트급 파일에서도 동작할까요?
- BufferedReader는 파일을 조각 단위로 읽습니다(기본 버퍼는 8 KB지만 더 크게 지정할 수 있음).
- 메모리에는 매 순간 오직 한 줄만 존재합니다.
버퍼 크기: 무엇을 선택할까?
황금률: 버퍼가 너무 작으면 디스크 I/O가 늘고, 너무 크면 메모리를 낭비합니다.
- 현대 HDD/SSD에는 보통 64 KB–4 MB 버퍼가 잘 동작합니다.
- 네트워크 스토리지나 매우 빠른 SSD라면 더 크게(8–16 MB) 설정해도 됩니다.
- 텍스트 파일이라면 BufferedReader의 버퍼를 키워도 좋습니다.
직접 실험하세요! 서로 다른 버퍼로 실행 시간을 측정해 보세요. 때로는 버퍼를 키우면 2–3배 빨라지지만, 때로는 거의 영향이 없습니다.
3. Memory-mapped files (파일을 메모리에 매핑)
이게 무엇일까요?
Memory mapping은 운영체제 메커니즘을 이용해 파일을 프로세스 메모리에 ‘바로’ 매핑하는 방법입니다. Java에서는 java.nio의 MappedByteBuffer 클래스를 사용합니다. 파일이 마치 커다란 바이트 배열이 된 것처럼, 각 조각을 명시적으로 읽고 쓰지 않아도 직접 다룰 수 있습니다.
이 방식은 매우 큰 파일을 다룰 때 특히 유용합니다. 운영체제가 필요한 부분만 메모리에 적재하며, 여러분은 파일의 어떤 위치든 일반 배열처럼 접근할 수 있습니다. Memory-mapped files는 임의 접근 속도가 매우 빠릅니다. 예를 들어, 파일 전체를 로드하지 않고 여러 위치의 조각을 빠르게 읽어야 할 때 적합합니다.
코드에서는 어떻게 보일까요?
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
public class MemoryMappedRead {
public static void main(String[] args) throws Exception {
String fileName = "bigfile.dat";
try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
FileChannel channel = file.getChannel()) {
long fileSize = channel.size();
int chunkSize = 1024 * 1024 * 128; // 128 MB — 하나의 매핑 크기
long position = 0;
while (position < fileSize) {
long size = Math.min(chunkSize, fileSize - position);
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);
// buffer에서 배열처럼 데이터를 읽습니다
for (int i = 0; i < size; i++) {
byte b = buffer.get(i);
// 바이트 처리(예: 특정 값을 찾기)
}
position += size;
}
}
System.out.println("메모리 매핑으로 읽기 완료!");
}
}
RandomAccessFile과 FileChannel을 사용하면 파일에 저수준으로 접근할 수 있습니다. channel.map 호출은 파일의 구간을 메모리에 매핑합니다. 데이터 접근은 MappedByteBuffer 버퍼를 통해 이루어집니다.
memory mapping의 장점은?
- 파일의 서로 다른 부분에 대한 임의 접근이 매우 빠릅니다.
- 사용 가능한 RAM보다 큰 파일도 다룰 수 있습니다(운영체제가 필요한 페이지만 적재).
- 현대 데이터베이스, 인덱스, 대용량 로그 등에서 널리 사용됩니다.
단점은?
- 쓰기에 항상 적합하지 않습니다(특히 네트워크 파일 시스템에서).
- 매핑 크기에 제한이 있습니다(보통 32-bit JVM에서는 하나의 매핑당 최대 2 GB).
- 파일을 닫지 않으면 파일이 ‘잠긴’ 채로 남을 수 있습니다(특히 Windows).
- 모든 파일 작업이 빨라지는 것은 아닙니다 — 단순 순차 읽기라면 일반 버퍼가 대체로 뒤지지 않습니다.
4. 실용 예시
예시 1: 메모리 매핑으로 큰 파일에서 부분 문자열 검색
예를 들어 10 GB 파일에서 특정 바이트 시퀀스(예: 문자열 "SECRET")를 찾고 싶다고 합시다.
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
public class MemoryMappedSearch {
public static void main(String[] args) throws Exception {
String fileName = "hugefile.bin";
byte[] target = "SECRET".getBytes(StandardCharsets.UTF_8);
try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
FileChannel channel = file.getChannel()) {
long fileSize = channel.size();
int chunkSize = 128 * 1024 * 1024; // 128 MB
long position = 0;
while (position < fileSize) {
long size = Math.min(chunkSize, fileSize - position);
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);
for (int i = 0; i < size - target.length; i++) {
boolean found = true;
for (int j = 0; j < target.length; j++) {
if (buffer.get(i + j) != target[j]) {
found = false;
break;
}
}
if (found) {
System.out.println("다음 위치에서 발견: " + (position + i));
// 여기서 중단하거나 계속할 수 있습니다
}
}
position += size;
}
}
}
}
주의:
부분 문자열이 두 개의 청크 사이에서 ‘끊어질’ 수 있다면, 찾고자 하는 시퀀스 길이만큼 청크 간에 오버랩을 두어야 합니다.
5. 유용한 팁
언제 chunking을 쓰고, 언제 — memory mapping을 쓸까?
- Chunking — 텍스트, 바이너리, 로그, 아카이브 등 어떤 파일에도 적용 가능한 범용 접근입니다. 순차 처리에 좋습니다.
- Memory mapping — 임의 접근, 대형 인덱스/데이터베이스, 거대한 파일에 대한 빠른 검색에 매우 효율적입니다.
무엇을 선택할지 모르겠다면 — chunking부터 시작하세요! Memory mapping은 강력하지만 더 ‘저수준’의 도구로, 주의가 필요합니다.
권장 사항
- try-with-resources를 사용해 스트림과 채널을 자동으로 닫으세요.
- 동시에 너무 많은 파일을 열지 마세요: 운영체제에는 열린 디스크립터 수에 제한이 있습니다.
- 매핑을 너무 큰 조각으로 만들지 마세요 — 특히 32-bit JVM에서는 오류로 이어질 수 있습니다.
- 병렬 처리에는 파일을 청크로 나누어 별도 스레드에서 처리할 수 있습니다(단, 디스크를 과도하게 몰아붙이거나 메모리를 초과하지 않도록 주의).
6. 대용량 파일 작업에서 흔한 실수
실수 1: 큰 파일 전체를 메모리에 올리려는 시도.
매우 흔한 문제 — 특히 초보자에게. 파일이 1–2 GB를 넘는다면 chunking이나 행 단위 읽기를 사용하세요. 그렇지 않으면 프로그램이 OutOfMemoryError로 ‘다운’될 수 있습니다.
실수 2: 지나치게 작은 버퍼.
512바이트 버퍼는 최적화가 아니라 성능 자살에 가깝습니다. 64 KB 이상을 사용하세요.
실수 3: 스트림이나 채널을 닫지 않음.
파일 디스크립터가 남아 파일이 삭제되지 않거나 JVM을 재시작하기 전까지 해제되지 않을 수 있습니다. try-with-resources를 사용하세요.
실수 4: memory mapping을 잘못 사용.
매핑 중에 다른 프로세스가 파일을 변경하면 비일관성 데이터나 오류가 발생할 수 있습니다. 자주 바뀌는 파일에는 memory mapping을 사용하지 마세요.
실수 5: 부분 문자열 검색 시 청크 오버랩을 고려하지 않음.
검색 문자열이 두 청크의 ‘경계’에 걸릴 수 있다면, 청크 사이에 그 문자열 길이만큼 반드시 오버랩을 두세요.
GO TO FULL VERSION