Java 입출력 | File, BufferedReader, NIO

이 글의 핵심

작은 파일에서는 잘 되던 Files.readAllLines가 대용량 로그에서 메모리를 모두 차지하는 문제처럼 입출력은 파일 크기에 따라 선택이 달라집니다. 전통적인 java.io 스트림과 NIO Files API를 비교하고, try-with-resources로 스트림을 닫는 습관과 줄 단위 처리 방식을 선택하는 기준을 정리합니다.

들어가며

java.io·java.nio 계열은 바이트·문자 스트림으로 파일과 네트워크를 다루는 출발점입니다. 읽기/쓰기 경로를 열고 닫는 책임을 try-with-resources로 묶는 패턴이 흔합니다.

java.io의 스트림은 두 갈래로 나뉩니다. InputStream/OutputStream은 바이트를 다루고, Reader/Writer는 문자를 다룹니다. 텍스트 파일은 디스크에 바이트로 저장되어 있으므로, Reader는 내부적으로 바이트를 읽어 문자셋(UTF-8, MS949 등)에 따라 문자로 디코딩합니다. 이미지나 압축 파일처럼 텍스트가 아닌 데이터를 Reader로 읽으면 디코딩 과정에서 바이트가 손상되므로 반드시 바이트 스트림을 써야 합니다.

BufferedReader가 FileReader를 감싸는 형태는 데코레이터 패턴입니다. 가장 안쪽 스트림이 실제 파일에 연결되고, 바깥 스트림이 버퍼링이나 줄 단위 읽기 같은 기능을 덧붙입니다. 이 구조 덕분에 필요한 기능만 조합할 수 있지만, 처음 보면 new A(new B(new C(...))) 형태가 번거롭게 느껴집니다. Java 7에 들어온 java.nio.file의 Files 유틸리티(Path.of, Files.readString은 Java 11부터)는 자주 쓰는 조합을 메서드 하나로 줄여 줍니다.


java.io로 텍스트 파일 읽기

BufferedReader

텍스트 파일을 효율적으로 읽는 가장 일반적인 방법입니다:

import java.io.*;
public class FileReadExample {
    public static void main(String[] args) {
        // try-with-resources 구문: 자동으로 리소스를 닫아줌
        // () 안에 선언된 리소스는 try 블록이 끝나면 자동으로 close() 호출
        try (BufferedReader br = new BufferedReader(
                new FileReader("file.txt"))) {
            
            // BufferedReader: 내부 버퍼(보통 8KB)를 사용해 I/O 횟수 감소
            // FileReader를 감싸서 성능 향상
            
            String line;
            // readLine(): 한 줄씩 읽어옴 (줄바꿈 문자 제외)
            // 파일 끝에 도달하면 null 반환
            // (line = br.readLine()): 읽으면서 동시에 line 변수에 할당
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
            
        } catch (FileNotFoundException e) {
            // 파일이 존재하지 않을 때 발생
            System.out.println("파일을 찾을 수 없습니다");
        } catch (IOException e) {
            // 파일 읽기 중 발생하는 기타 입출력 오류
            System.out.println("파일 읽기 오류: " + e.getMessage());
        }
        // try 블록이 끝나면 br.close()가 자동 호출됨
    }
}

try-with-resources는 try 괄호 안에 선언한 리소스를 블록이 끝날 때(정상 종료든 예외든) 선언의 역순으로 close()합니다. BufferedReader를 닫으면 감싸고 있는 FileReader까지 함께 닫히므로 안쪽 스트림을 따로 닫을 필요는 없습니다. Java 7 이전에는 finally에서 close()를 부르고, close()마저 IOException을 던질 수 있어 다시 try-catch로 감싸야 했습니다. 이 과정에서 본문의 원래 예외가 close()의 예외에 덮여 사라지는 문제가 흔했는데, try-with-resources는 close() 예외를 원래 예외의 getSuppressed()에 붙여 두기 때문에 원인이 보존됩니다.

catch 순서도 의미가 있습니다. FileNotFoundException은 IOException의 하위 클래스이므로 더 구체적인 예외를 먼저 잡아야 하며, 순서를 바꾸면 exception FileNotFoundException has already been caught 컴파일 에러가 납니다.

성능 비교:

  • FileReader 단독으로 read()를 반복: 문자마다 메서드 호출과 동기화 비용이 들어 느림
  • BufferedReader + FileReader: 큰 덩어리를 한 번에 읽어 두고 메모리에서 꺼냄 → 빠름

FileReader vs BufferedReader

두 방식의 성능 차이를 명확히 이해해봅시다:

// FileReader: 한 문자씩 읽기 (느림)
try (FileReader fr = new FileReader("file.txt")) {
    int ch;
    // read(): 한 문자를 읽어서 int로 반환 (0~65535)
    // 파일 끝이면 -1 반환
    // 문제점: 문자 하나마다 read() 호출, 디코더 처리, 락 획득이 반복됨
    while ((ch = fr.read()) != -1) {
        // int를 char로 캐스팅해서 출력
        System.out.print((char) ch);
    }
}
// 1000자 파일 → read() 호출 1000번 (디코더 내부 버퍼 덕분에 시스템 콜은 훨씬 적음)
// BufferedReader: 버퍼링 사용 (빠름)
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
    String line;
    // readLine(): 한 줄 전체를 읽어서 String으로 반환
    // 내부적으로 버퍼(보통 8KB)에 미리 읽어놓고 사용
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
}
// 1000자 파일 → 버퍼를 한두 번 채우고 나머지는 메모리에서 처리

성능 차이가 나는 이유:

정확히 말하면 FileReader도 내부 디코더(StreamDecoder)가 8KB 정도의 바이트 버퍼를 가지고 있어서, 문자 하나를 읽을 때마다 운영체제에 시스템 콜을 보내지는 않습니다. 느린 이유는 문자마다 read()를 호출하면서 매번 락을 잡고 디코더 상태를 확인하는 오버헤드 때문입니다. 반면 순수 바이트 스트림인 FileInputStream의 read()는 버퍼가 전혀 없어 정말로 바이트마다 시스템 콜이 발생하므로, 차이가 훨씬 극적입니다. 수 MB 파일을 FileInputStream.read()로 한 바이트씩 읽으면 체감될 만큼 느리고, BufferedInputStream으로 감싸기만 해도 크게 빨라집니다.

결론: 한 글자·한 바이트 단위로 반복해서 읽거나 쓰는 코드라면 항상 BufferedReader/BufferedWriter(바이트라면 BufferedInputStream/BufferedOutputStream)로 감쌉니다. read(char[])처럼 큰 배열 단위로 읽는 코드라면 버퍼링의 이득은 줄어듭니다.

문자셋도 함께 봐야 합니다. new FileReader("file.txt")는 문자셋을 지정하지 않아 JVM 기본 문자셋을 씁니다. Java 17까지는 이 기본값이 운영체제 설정을 따랐기 때문에, 한국어 Windows에서는 MS949로, Linux 서버에서는 UTF-8로 읽혀 같은 파일이 환경에 따라 깨졌습니다. Java 18부터는 기본값이 UTF-8로 바뀌었지만(JEP 400), 여러 버전을 오가는 코드라면 new FileReader("file.txt", StandardCharsets.UTF_8)(Java 11+)처럼 항상 명시하는 것이 안전합니다. 저는 로컬 Windows에서는 멀쩡하던 CSV 파서가 서버에 올라가자 한글이 �로 바뀌는 문제를 겪는 경우를 입출력 관련 문제 중 가장 흔하게 봅니다.


BufferedWriter로 쓰기와 추가(append) 모드

BufferedWriter

import java.io.*;
public class FileWriteExample {
    public static void main(String[] args) {
        try (BufferedWriter bw = new BufferedWriter(
                new FileWriter("output.txt"))) {
            
            bw.write("첫 번째 줄");
            bw.newLine();
            bw.write("두 번째 줄");
            bw.newLine();
            
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

BufferedWriter는 write()한 내용을 먼저 메모리 버퍼에 쌓아 두었다가 버퍼가 차거나 flush()·close()가 호출될 때 실제 파일에 씁니다. 그래서 try-with-resources 없이 스트림을 닫지 않고 프로그램을 끝내면 마지막 버퍼 분량이 파일에 기록되지 않아 파일이 비어 있거나 뒷부분이 잘립니다. 에러 메시지 없이 조용히 데이터가 사라지므로 원인을 찾기 어려운 버그입니다. 반대로 로그처럼 즉시 파일에 반영되어야 하는 내용이라면 중요한 지점마다 flush()를 호출해야 합니다.

newLine()은 운영체제의 줄바꿈 문자(Windows는 \r\n, Linux·macOS는 \n)를 씁니다. 다른 시스템이 읽을 파일 형식(예: 특정 줄바꿈을 요구하는 프로토콜)이라면 newLine() 대신 write("\n")처럼 명시하는 편이 결과를 예측하기 쉽습니다.

추가 모드

FileWriter의 두 번째 인자 true가 추가(append) 모드입니다. 이 인자를 빠뜨리면 기존 파일 내용이 경고 없이 지워지므로, 로그를 누적하는 코드에서는 이 부분을 가장 먼저 확인해야 합니다.

// 덮어쓰기 (기본)
try (BufferedWriter bw = new BufferedWriter(
        new FileWriter("output.txt"))) {
    bw.write("새 내용");
}
// 추가 모드
try (BufferedWriter bw = new BufferedWriter(
        new FileWriter("output.txt", true))) {
    bw.write("추가 내용");
}

추가 모드라도 여러 프로세스가 같은 파일에 동시에 쓰면 줄이 섞일 수 있습니다. 한 JVM 안의 여러 스레드가 쓰는 경우라면 하나의 Writer를 공유하고 동기화하거나, 로깅처럼 흔한 용도라면 직접 구현하기보다 Logback·Log4j2 같은 로깅 라이브러리에 맡기는 편이 안전합니다.


NIO.2 Files와 Path (Java 7+)

Files 클래스

java.nio.file 패키지는 Java 7에서 NIO.2라는 이름으로 추가되었습니다. 예전 java.io.File 클래스는 delete()가 실패해도 false만 돌려줘 이유를 알 수 없었고, 심볼릭 링크나 파일 권한을 다루는 기능도 부족했습니다. Files의 메서드는 실패하면 NoSuchFileException, AccessDeniedException, DirectoryNotEmptyException처럼 원인이 드러나는 예외를 던집니다. 아래 예제의 Path.of, Files.readString, Files.writeString은 Java 11에서 추가된 메서드이며, Java 8에서는 Paths.get과 Files.readAllBytes를 대신 씁니다.

import java.nio.file.*;
import java.io.IOException;
import java.util.List;
public class NIOExample {
    public static void main(String[] args) throws IOException {
        Path path = Path.of("file.txt");
        
        // 파일 읽기 (전체)
        String content = Files.readString(path);
        System.out.println(content);
        
        // 파일 읽기 (줄 단위)
        List<String> lines = Files.readAllLines(path);
        for (String line : lines) {
            System.out.println(line);
        }
        
        // 파일 쓰기
        Files.writeString(Path.of("output.txt"), "Hello, NIO!");
        
        // 여러 줄 쓰기
        List<String> linesToWrite = List.of("라인 1", "라인 2", "라인 3");
        Files.write(Path.of("output.txt"), linesToWrite);
    }
}

Files의 읽기·쓰기 메서드는 문자셋을 지정하지 않으면 JVM 기본값이 아니라 UTF-8을 씁니다. FileReader와 기본 동작이 다르다는 점이 헷갈리기 쉬운데, 결과적으로 Files 쪽이 환경에 덜 의존합니다. 대신 UTF-8이 아닌 파일(예: MS949로 저장된 오래된 CSV)을 Files.readAllLines로 읽으면 MalformedInputException: Input length = 1이 발생합니다. 이 에러가 나면 파일 인코딩을 확인하고 Files.readAllLines(path, Charset.forName("MS949"))처럼 문자셋을 넘기면 됩니다.

readString과 readAllLines는 파일 전체를 한 번에 메모리에 올립니다. 설정 파일이나 작은 템플릿에는 가장 간단한 방법이지만, 크기를 예측할 수 없는 파일에는 쓰지 말아야 합니다. writeString과 write도 기본적으로 파일을 새로 만들거나 기존 내용을 덮어쓰므로, 추가하려면 StandardOpenOption.APPEND와 StandardOpenOption.CREATE 옵션을 함께 넘깁니다.

파일 조작

import java.nio.file.*;
// 파일 존재 확인
boolean exists = Files.exists(Path.of("file.txt"));
// 파일 삭제
Files.deleteIfExists(Path.of("temp.txt"));
// 파일 복사
Files.copy(
    Path.of("source.txt"),
    Path.of("dest.txt"),
    StandardCopyOption.REPLACE_EXISTING
);
// 파일 이동
Files.move(
    Path.of("old.txt"),
    Path.of("new.txt"),
    StandardCopyOption.REPLACE_EXISTING
);
// 디렉토리 생성
Files.createDirectories(Path.of("dir/subdir"));

Files.exists()로 확인한 뒤 작업하는 방식은 확인과 작업 사이에 다른 프로세스가 파일을 지우거나 만들 수 있어(TOCTOU 경쟁 조건) 결과를 보장하지 않습니다. 가능하면 먼저 확인하지 말고 바로 작업한 뒤 예외를 처리하는 편이 정확합니다. deleteIfExists가 존재하는 이유도 이것입니다.

copy와 move는 대상 파일이 이미 있으면 기본적으로 FileAlreadyExistsException을 던지므로, 덮어쓰려면 예제처럼 REPLACE_EXISTING을 넘깁니다. move는 같은 파일 시스템 안에서는 이름만 바꾸는 빠른 작업이지만, 다른 드라이브나 마운트로 옮기면 복사 후 삭제로 동작해 시간이 걸리고 중간에 실패할 수 있습니다. 설정 파일을 안전하게 교체해야 한다면 임시 파일에 먼저 쓰고 ATOMIC_MOVE 옵션으로 옮기는 방식이 쓰입니다. createDirectories는 중간 경로까지 모두 만들고 이미 있어도 예외를 던지지 않지만, createDirectory는 부모 폴더가 없거나 폴더가 이미 있으면 예외를 던집니다.

상대 경로 Path.of("file.txt")는 JVM을 실행한 작업 디렉토리 기준입니다. IDE에서 실행하면 프로젝트 루트, java -jar로 실행하면 명령을 입력한 위치가 기준이 되므로, “IDE에서는 되는데 배포하면 NoSuchFileException이 난다”는 문제가 여기서 생깁니다. 애플리케이션에 포함된 리소스 파일은 파일 경로가 아니라 getClass().getResourceAsStream("/config.properties")처럼 클래스패스로 읽어야 JAR 안에서도 동작합니다.


로그 파일에서 에러 줄만 골라 저장하기

import java.io.*;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
public class LogProcessor {
    public static void processLog(String inputPath, String outputPath) {
        try {
            List<String> lines = Files.readAllLines(Path.of(inputPath));
            
            List<String> errors = lines.stream()
                .filter(line -> line.contains("ERROR"))
                .collect(Collectors.toList());
            
            Files.write(Path.of(outputPath), errors);
            
            System.out.println("에러 로그 " + errors.size() + "개 추출");
            
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    public static void main(String[] args) {
        processLog("app.log", "errors.log");
    }
}

이 코드는 짧고 읽기 쉽지만, 파일 전체를 readAllLines로 메모리에 올린다는 한계가 있습니다. 로그 파일은 하루에 수 GB까지 커질 수 있는데, 문자열로 올리면 Java 문자열 객체의 오버헤드까지 더해져 파일 크기보다 훨씬 많은 힙을 차지합니다. 결국 java.lang.OutOfMemoryError: Java heap space로 프로그램이 멈춥니다. 작은 파일로 테스트할 때는 전혀 드러나지 않다가 운영 로그에 돌렸을 때 처음 터지는 전형적인 문제입니다.

크기를 예측할 수 없는 파일은 한 줄씩 흘려보내며 처리해야 합니다.

try (Stream<String> lines = Files.lines(Path.of(inputPath));
     BufferedWriter out = Files.newBufferedWriter(Path.of(outputPath))) {
    lines.filter(line -> line.contains("ERROR"))
         .forEach(line -> {
             try {
                 out.write(line);
                 out.newLine();
             } catch (IOException e) {
                 throw new UncheckedIOException(e);
             }
         });
}

Files.lines는 파일을 지연(lazy) 방식으로 읽어 한 번에 한 줄만 메모리에 둡니다. 주의할 점은 이 스트림이 내부에 열린 파일 핸들을 갖고 있어서 반드시 try-with-resources로 닫아야 한다는 것입니다. 일반 컬렉션 스트림처럼 쓰고 닫지 않으면 파일 핸들이 누수되어, 오래 실행되는 서버에서는 결국 Too many open files 에러가 납니다. 람다 안에서는 체크 예외인 IOException을 던질 수 없어 UncheckedIOException으로 감쌌는데, 이 번거로움이 싫다면 BufferedReader의 while 루프로 쓰는 것도 좋은 선택입니다.


IO와 NIO 요약

  1. BufferedReader/Writer: 버퍼링으로 성능 향상
  2. try-with-resources: 자동 리소스 닫기
  3. NIO Files: 간결한 현대적 API
  4. Path: 파일 경로 표현
  5. 파일 조작: 복사, 이동, 삭제

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Files.readAllLines와 BufferedReader 중 무엇을 써야 하나요?

A. Files.readAllLines는 파일 전체를 한 번에 List<String>으로 메모리에 올리므로, 설정 파일처럼 작은 파일을 간단히 읽을 때 편합니다. 로그처럼 크기를 예측하기 어려운 파일은 BufferedReader로 한 줄씩 읽거나 Files.lines로 스트림 처리해야 메모리 부족을 피할 수 있습니다. 두 경우 모두 한글 파일이라면 StandardCharsets.UTF_8처럼 문자셋을 명시해 환경에 따라 글자가 깨지는 문제를 막습니다.