Files.readAllLines()가 파일이 10GB일 때 OOM을 내는 이유 — I/O 모델의 선택

10GB 파일을 Files.readAllLines()로 읽었다. 파일이 작을 때는 잘 동작한다. 하지만 10GB 파일에서는 OutOfMemoryError가 발생한다 — 모든 줄을 메모리에 한 번에 올리기 때문이다. Files.lines()를 쓰면 한 줄씩 스트리밍으로 읽으므로 OOM 없이 처리할 수 있다. (Files API)

10GB 파일을 읽었더니 OutOfMemoryError가 났다. 왜? 전체 파일을 메모리에 올렸기 때문이다. 이 글은 Files.lines()로 스트리밍 읽기를 하는 법, Path로 파일을 다루는 법, 그리고 레거시 java.io를 버려야 하는 이유를 풀어간다.

java.io vs java.nio — 두 가지 I/O 패러다임

flowchart TD
    subgraph io["java.io (레거시, Java 1.0)"]
        FILE["File (객체)"]
        FIS["FileInputStream / FileReader<br/>(바이트/문자 스트림)"]
        BIS["BufferedInputStream / BufferedReader<br/>(버퍼링 래퍼)"]
    end
    subgraph nio["java.nio.file (현대, Java 7+)"]
        PATH["Path (경로 추상화)"]
        FILES["Files (정적 메서드)"]
        BYTECH["ByteChannel / FileChannel<br/>(채널 기반)"]
    end
항목 java.io (레거시) java.nio.file (현대)
경로 표현 File (레거시) Path
파일 조작 File 메서드 Files 정적 메서드
예외 IOException IOException + NoSuchFileException 등 구체적
심볼릭 링크 처리 어려움 LinkOption 지원
파일 속성 제한적 FileAttribute, PosixFilePermission 풍부
스트리밍 읽기 BufferedReader.lines() Files.lines() (Stream)

새 코드에서는 항상 java.nio.file(Path/Files)을 쓴다. java.io.File은 레거시 — 많은 메서드가 직관적이지 않고, 에러 처리가 부실하다.

Path — 플랫폼 독립 경로

// Java 25
import java.nio.file.Path;

Path relative = Path.of("data", "input", "file.txt");   // data/input/file.txt
Path absolute = Path.of("/home/user/data/file.txt");

// 경로 조작
Path parent = absolute.getParent();      // /home/user/data
Path fileName = absolute.getFileName();  // file.txt
Path root = absolute.getRoot();          // /
Path resolved = parent.resolve("output.txt");   // /home/user/data/output.txt
Path normalized = Path.of("a/b/../c").normalize();  // a/c

Files — 파일 읽기/쓰기의 표준

// Java 25
import java.nio.file.Files;
import java.nio.file.Path;

// 전체 읽기 — 작은 파일에만
List<String> allLines = Files.readAllLines(Path.of("small.txt"));
String content = Files.readString(Path.of("config.json"));   // Java 11+
byte[] bytes = Files.readAllBytes(Path.of("data.bin"));

// 스트리밍 읽기 — 큰 파일에
try (var lines = Files.lines(Path.of("huge.log"))) {
    lines.filter(l -> l.contains("ERROR"))
         .forEach(System.out::println);
}
// Files.lines()는 Stream<String>을 반환 → 지연 평가, 한 줄씩 처리

Files.lines()Stream<String>을 반환한다 — 파일 전체를 메모리에 올리지 않는다. 10GB 파일도 OOM 없이 처리할 수 있다. 단, try-with-resources로 반드시 닫아야 한다 (파일 핸들 누수 방지).

파일 쓰기

// Java 25
// 작은 파일 — 한 번에 쓰기
Files.writeString(Path.of("output.txt"), "Hello, Files!");   // Java 11+
Files.write(Path.of("lines.txt"), List.of("line1", "line2", "line3"));

// 큰 파일 — 스트리밍 쓰기
try (var writer = Files.newBufferedWriter(Path.of("large.csv"))) {
    for (String row : hugeData) {
        writer.write(row);
        writer.newLine();
    }
}

// 파일 복사 (Java 9+ transferTo)
try (var in = Files.newInputStream(Path.of("src.bin"));
     var out = Files.newOutputStream(Path.of("dst.bin"))) {
    in.transferTo(out);   // 한 번에 복사
}

버퍼링 — 왜 필요한가

디스크 I/O는 블록 단위로 수행된다. 1바이트씩 읽으면 매 읽기마다 시스템 콜이 발생하여 극히 느리다. 버퍼를 사용하면 한 번에 큰 블록을 읽어 메모리에 두고, 애플리케이션은 버퍼에서 읽는다.

// Java 25 — 버퍼링 없음 (느림)
try (var reader = Files.newBufferedReader(Path.of("big.txt"))) {
    // 이미 BufferedReader (버퍼링 있음) — OK
}

// Java 25 — 버퍼링 직접 지정
try (var raw = Files.newInputStream(Path.of("big.bin"));
     var buffered = new java.io.BufferedInputStream(raw)) {   // 8KB 버퍼
    int b;
    while ((b = buffered.read()) != -1) {
        process(b);
    }
}

Files.newBufferedReader()는 이미 버퍼링을 제공한다. Files.newInputStream()은 raw 스트림이므로 BufferedInputStream으로 감싸야 한다. 버퍼 크기는 기본 8KB이며, 큰 파일에서는 더 큰 버퍼(32KB ~ 64KB)로 성능을 개선할 수 있다.

파일 및 디렉토리 조작

// Java 25
import static java.nio.file.StandardCopyOption.*;

// 파일 존재 확인
boolean exists = Files.exists(Path.of("data.txt"));

// 파일 생성
Files.createFile(Path.of("new.txt"));
Files.createDirectories(Path.of("a/b/c"));   // 중간 디렉토리도 생성

// 복사 및 이동
Files.copy(Path.of("src.txt"), Path.of("dst.txt"), REPLACE_EXISTING);
Files.move(Path.of("old.txt"), Path.of("new.txt"), ATOMIC_MOVE);

// 삭제
Files.delete(Path.of("temp.txt"));   // 없으면 NoSuchFileException
Files.deleteIfExists(Path.of("temp.txt"));   // 없어도 OK

// 디렉토리 순회
try (var stream = Files.list(Path.of("/var/log"))) {
    stream.filter(Files::isRegularFile)
          .filter(p -> p.toString().endsWith(".log"))
          .forEach(System.out::println);
}
// Files.list()는 지연 평가 Stream — 디렉토리가 수만 파일이 있어도 OK

// 디렉토리 트리 재귀 순회
Files.walk(Path.of("/project"))
    .filter(Files::isRegularFile)
    .filter(p -> p.toString().endsWith(".java"))
    .forEach(System.out::println);

실습 — 파일 처리 패턴

// Java 25 — FileIODemo.java
import java.nio.file.*;
import java.io.IOException;

public class FileIODemo {
    public static void main(String[] args) throws IOException {
        Path dir = Path.of("demo-files");
        Files.createDirectories(dir);

        // 파일 생성
        Path dataFile = dir.resolve("data.txt");
        Files.writeString(dataFile, """
            Line 1: Hello
            Line 2: World
            Line 3: Java 25
            """);

        // 스트리밍 읽기 + 필터
        System.out.println("--- 필터 결과 ---");
        try (var lines = Files.lines(dataFile)) {
            lines.filter(l -> l.contains("Java"))
                 .forEach(System.out::println);
        }

        // 파일 정보
        System.out.println("\n--- 파일 정보 ---");
        System.out.println("크기: " + Files.size(dataFile) + " bytes");
        System.out.println("마지막 수정: " + Files.getLastModifiedTime(dataFile));
        System.out.println("읽기 가능: " + Files.isReadable(dataFile));

        // 정리
        Files.deleteIfExists(dataFile);
        Files.deleteIfExists(dir);
        System.out.println("\n정리 완료");
    }
}
java FileIODemo.java
--- 필터 결과 ---
Line 3: Java 25

--- 파일 정보 ---
크기: 42 bytes
마지막 수정: 2026-07-10T14:30:00Z
읽기 가능: true

정리 완료

확인할 것: Files.lines()로 파일을 스트리밍 읽기하며 필터를 적용한다. 전체 파일을 메모리에 올리지 않는다. Files의 정적 메서드로 파일 정보 조회, 생성, 삭제를 수행한다.

Charset — 문자 인코딩

// Java 25 — 명시적 인코딩 지정
import java.nio.charset.StandardCharsets;

// UTF-8로 읽기/쓰기 (명시)
String content = Files.readString(Path.of("utf8.txt"), StandardCharsets.UTF_8);
Files.writeString(Path.of("output.txt"), "Hello", StandardCharsets.UTF_8);

// 시스템 기본 인코딩 (환경에 따라 다름 — 위험)
// String bad = Files.readString(Path.of("data.txt"));  // 인코딩 명시 안 함

프로덕션에서는 항상 명시적으로 StandardCharsets.UTF_8을 지정한다. 시스템 기본 인코딩은 OS, 로케일, JVM 설정에 따라 달라져 — Windows는 MS949, Linux는 UTF-8 등 — 인코딩을 명시하지 않으면 크로스 플랫폼 버그가 발생한다.

WatchService — 디렉토리 변경 감지 (Java 7+)

// Java 25 — 디렉토리의 파일 생성/수정/삭제 감지
try (var watcher = FileSystems.getDefault().newWatchService()) {
    Path dir = Path.of("/var/log");
    dir.register(watcher,
        StandardWatchEventKinds.ENTRY_CREATE,
        StandardWatchEventKinds.ENTRY_MODIFY,
        StandardWatchEventKinds.ENTRY_DELETE);

    WatchKey key = watcher.take();   // 변경 발생까지 블로킹
    for (WatchEvent<?> event : key.pollEvents()) {
        System.out.println(event.kind() + ": " + event.context());
    }
    key.reset();
}

WatchService는 OS 수준의 파일 변경 알림(inotify, FSEvents)을 활용한다 — 폴링(polling) 없이 이벤트 기반으로 파일 변경을 감지한다. 로그 모니터링, 설정 파일 핫 리로드 등에 유용하다.

직렬화(Serialization) — Java 직렬화는 위험하다

Java의 내장 직렬화(Serializable 인터페이스 + ObjectOutputStream)는 보안 위험이 크다 — 역직렬화 시 임의 코드 실행이 가능하다. (Oracle 직렬화 필터 가이드)

// Java 25 — 레거시 직렬화 (보안 위험)
import java.io.*;

// Serializable 구현 (보안 경고)
class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private int age;
}

// 직렬화
try (var oos = new ObjectOutputStream(
        new FileOutputStream("user.ser"))) {
    oos.writeObject(new User());
}

// 역직렬화 — 위험! 악의적인 바이트 스트림으로 임의 객체 생성 가능
try (var ois = new ObjectInputStream(
        new FileInputStream("user.ser"))) {
    User user = (User) ois.readObject();   // 위험: 검증되지 않은 데이터
}

현대적 대안 — JSON

// Java 25 — JSON 직렬화 (외부 라이브러리: Jackson, Gson)
// record와 JSON 라이브러리 조합이 권장됨
public record UserDTO(String name, int age) {}

// Jackson 사용 예 (의사코드 — Jackson은 외부 라이브러리)
// ObjectMapper mapper = new ObjectMapper();
// String json = mapper.writeValueAsString(new UserDTO("Alice", 30));
// UserDTO user = mapper.readValue(json, UserDTO.class);

Java 직렬화(Serializable)를 새 코드에서 쓰지 않는다. 대신 JSON(Jackson, Gson), Protocol Buffers, Avro 등 안전한 포맷을 쓴다. (JEP-154 직렬화 필터링) 역직렬화가 불가피하면 ObjectInputFilter로 화이트리스트를 적용한다.

요약 — 이 글의 결론

  • java.nio.file(Path/Files)이 현대 Java 파일 I/O의 표준이다. java.io.File은 레거시 — 새 코드에서 쓰지 않는다.
  • 큰 파일은 Files.lines()로 스트리밍 읽기. Files.readAllLines()는 전체를 메모리에 올리므로 대용량 파일에서 OOM 위험이 있다.
  • 버퍼링은 I/O 성능의 핵심. 1바이트씩 시스템 콜을 부르는 대신, 버퍼로 큰 블록을 읽는다. Files.newBufferedReader()는 기본 버퍼링을 제공한다.
  • Files.walk()로 디렉토리 트리를 재귀 순회한다. Files.list()는 1단계만 순회, Files.walk()는 전체 하위 트리를 Stream으로 반환한다.
  • 문자 인코딩은 항상 명시적으로. StandardCharsets.UTF_8을 지정하지 않으면 크로스 플랫폼 인코딩 버그가 발생한다.

생각해 볼 문제

  1. Files.lines()Files.readAllLines()의 반환 타입 차이는? 어느 것이 항상 try-with-resources를 필요로 하는가?
  2. FileChannel vs InputStream — 채널 기반 I/O가 스트림 기반보다 나은 점은 무엇인가?
  3. Path.of("a/./b/../c").normalize()의 결과를 예측해 보자.
  4. Files.copy()에서 REPLACE_EXISTING 없이 이미 존재하는 파일에 복사하면 어떻게 되는가?
  5. 메모리 매핑(MappedByteBuffer)이 일반 파일 읽기보다 빠른 상황은 언제인가?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 18. thread sync  (0) 2026.07.12
Java - 17. annotation-reflection  (0) 2026.07.12
Java - 15. datetime  (0) 2026.07.12
Java - 14. optional  (0) 2026.07.12
Java - 13. stream  (0) 2026.07.12