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을 지정하지 않으면 크로스 플랫폼 인코딩 버그가 발생한다.
생각해 볼 문제
Files.lines()와Files.readAllLines()의 반환 타입 차이는? 어느 것이 항상try-with-resources를 필요로 하는가?FileChannelvsInputStream— 채널 기반 I/O가 스트림 기반보다 나은 점은 무엇인가?Path.of("a/./b/../c").normalize()의 결과를 예측해 보자.Files.copy()에서REPLACE_EXISTING없이 이미 존재하는 파일에 복사하면 어떻게 되는가?- 메모리 매핑(
MappedByteBuffer)이 일반 파일 읽기보다 빠른 상황은 언제인가?
참고
- Files API (Java 25) - 접근 2026-07-10
- Path API (Java 25) - 접근 2026-07-10
- java.nio.file 패키지 - 접근 2026-07-10
- StandardCharsets - 접근 2026-07-10
'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 |
