CodeGym /행동 /JAVA 25 SELF /대용량 파일 처리: chunking, memory mapping

대용량 파일 처리: chunking, memory mapping

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

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에서 파일 작업에는 보통 표준 스트림 FileInputStreamFileOutputStream을 사용합니다. 현대 디스크에서는 약 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.nioMappedByteBuffer 클래스를 사용합니다. 파일이 마치 커다란 바이트 배열이 된 것처럼, 각 조각을 명시적으로 읽고 쓰지 않아도 직접 다룰 수 있습니다.

이 방식은 매우 큰 파일을 다룰 때 특히 유용합니다. 운영체제가 필요한 부분만 메모리에 적재하며, 여러분은 파일의 어떤 위치든 일반 배열처럼 접근할 수 있습니다. 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("메모리 매핑으로 읽기 완료!");
    }
}

RandomAccessFileFileChannel을 사용하면 파일에 저수준으로 접근할 수 있습니다. 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: 부분 문자열 검색 시 청크 오버랩을 고려하지 않음.
검색 문자열이 두 청크의 ‘경계’에 걸릴 수 있다면, 청크 사이에 그 문자열 길이만큼 반드시 오버랩을 두세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION