자바 I-O 심화 — try-with-resources, 파일 와칭, 메모리 매핑
파일을 읽고 쓰는 건 기본이지만, 수 GB 파일을 효율적으로 처리하거나, 파일이 변경되면 자동으로 감지하는 건 어떻게 할까요?
try-with-resources 제대로 쓰기
기본 패턴
// 리소스가 자동으로 닫힘 (AutoCloseable 구현 필수)
try (var reader = new BufferedReader(new FileReader("data.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
process(line);
}
} // reader.close() 자동 호출
다중 리소스
try (var input = new FileInputStream("source.bin");
var output = new FileOutputStream("target.bin")) {
input.transferTo(output); // Java 9+: 한 줄로 복사
}
// output.close() → input.close() (선언 역순으로 닫힘)
Suppressed Exception
// close()에서도 예외가 발생하면 suppressed로 기록
try (var resource = new MyResource()) {
throw new RuntimeException("본래 예외");
} // close()에서 예외 발생 → suppressed exception으로 추가
// catch에서 getSuppressed()로 확인 가능
Path와 Files API (NIO.2)
java.io.File 대신 java.nio.file.Path와 Files를 사용하세요.
Path path = Path.of("data", "users.txt"); // 경로 조합
// 파일 읽기
String content = Files.readString(path); // 전체 읽기
List<String> lines = Files.readAllLines(path); // 줄 단위
Stream<String> stream = Files.lines(path); // 스트림 (대용량)
// 파일 쓰기
Files.writeString(path, "hello", StandardOpenOption.APPEND);
Files.write(path, lines);
// 파일 정보
long size = Files.size(path);
boolean exists = Files.exists(path);
FileTime modified = Files.getLastModifiedTime(path);
디렉토리 순회
// 1단계 깊이
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.txt")) {
for (Path entry : stream) {
System.out.println(entry.getFileName());
}
}
// 재귀 순회
try (Stream<Path> paths = Files.walk(dir)) {
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(System.out::println);
}
// find: 조건으로 검색
try (Stream<Path> found = Files.find(dir, 10, // 최대 깊이 10
(path, attrs) -> attrs.size() > 1_000_000)) { // 1MB 이상
found.forEach(System.out::println);
}
WatchService — 파일 변경 감지
디렉토리의 파일 생성, 수정, 삭제를 실시간으로 감지합니다.
public class FileWatcher {
public void watch(Path dir) throws Exception {
WatchService watcher = FileSystems.getDefault().newWatchService();
dir.register(watcher,
StandardWatchEventKinds.ENTRY_CREATE, // 파일 생성
StandardWatchEventKinds.ENTRY_MODIFY, // 파일 수정
StandardWatchEventKinds.ENTRY_DELETE); // 파일 삭제
while (true) {
WatchKey key = watcher.take(); // 이벤트 대기 (블로킹)
for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
Path fileName = (Path) event.context();
log.info("{}: {}", kind.name(), fileName);
// ENTRY_CREATE: config.yml
// ENTRY_MODIFY: config.yml
}
boolean valid = key.reset(); // 다음 이벤트를 받기 위해 리셋
if (!valid) break; // 디렉토리가 삭제되면 종료
}
}
}
활용 예시: 설정 파일 변경 시 자동 리로드, 로그 디렉토리 모니터링.
메모리 매핑 (Memory-Mapped File)
수 GB 파일을 메모리에 전부 올리지 않고, 가상 메모리에 매핑하여 처리합니다.
public long countLines(Path file) throws IOException {
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
// 파일을 가상 메모리에 매핑
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_ONLY, 0, channel.size());
long count = 0;
while (buffer.hasRemaining()) {
if (buffer.get() == '\n') count++;
}
return count;
}
}
메모리 매핑 vs 일반 I/O
| 항목 | 일반 I/O | 메모리 매핑 |
|---|---|---|
| 방식 | read()로 커널 → 사용자 메모리 복사 | 가상 메모리에 직접 매핑 |
| 버퍼 | 사용자 메모리에 할당 | OS가 페이지 캐시로 관리 |
| 대용량 | 청크 단위로 읽어야 함 | 파일 전체를 배열처럼 접근 |
| 적합 | 순차 읽기, 작은 파일 | 랜덤 접근, 대용량 파일 |
비동기 파일 I/O (AsynchronousFileChannel)
AsynchronousFileChannel channel =
AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 비동기 읽기 — 콜백 방식
channel.read(buffer, 0, null, new CompletionHandler<>() {
@Override
public void completed(Integer bytesRead, Object attachment) {
buffer.flip();
System.out.println("읽은 바이트: " + bytesRead);
}
@Override
public void failed(Throwable exc, Object attachment) {
log.error("읽기 실패", exc);
}
});
임시 파일과 디렉토리
// 임시 파일 생성
Path tempFile = Files.createTempFile("prefix-", ".tmp");
// /tmp/prefix-1234567890.tmp
// 임시 디렉토리 생성
Path tempDir = Files.createTempDirectory("upload-");
// JVM 종료 시 자동 삭제
tempFile.toFile().deleteOnExit();
자주 헷갈리는 포인트
- Files.lines()는 닫아야 한다:
Files.lines()는 내부적으로 스트림을 열므로 try-with-resources로 감싸야 합니다.Files.readAllLines()는 즉시 닫히므로 괜찮습니다. - MappedByteBuffer 해제: 메모리 매핑된 버퍼는 GC에 의존하므로 즉시 해제가 어렵습니다. Java 19+의
MemorySegmentAPI가 더 안전합니다. - WatchService 중복 이벤트: 파일을 저장하면 MODIFY 이벤트가 여러 번 발생할 수 있습니다. 디바운싱 로직을 추가하세요.
- 인코딩:
Files.readString()의 기본 인코딩은 UTF-8입니다. 다른 인코딩은Charset을 명시하세요.
정리
| 항목 | 설명 |
|---|---|
| try-with-resources | AutoCloseable 리소스 자동 닫기 |
| Path + Files | NIO.2 파일 API (java.io.File 대체) |
| WatchService | 파일 변경 실시간 감지 |
| MappedByteBuffer | 대용량 파일 메모리 매핑 |
| AsynchronousFileChannel | 비동기 파일 I/O |