파일 하나를 읽으려는데 InputStream, Reader, BufferedReader, Channel, Buffer... 클래스가 왜 이렇게 많은 걸까? Java I/O가 복잡하게 느껴지는 이유는 역사가 쌓여 있기 때문이다.

바이트 스트림 vs 문자 스트림

Java I/O 는 바이트 스트림과 문자 스트림으로 나뉘어요. JDK 1.0에 바이트 스트림(InputStream/OutputStream)만 있었는데, 한글 같은 멀티바이트 문자의 인코딩 문제 때문에 JDK 1.1에서 문자 스트림(Reader/Writer)이 추가되었습니다.

PLAINTEXT
Java I/O 클래스 계층

바이트 기반                    문자 기반
InputStream                   Reader
├── FileInputStream           ├── FileReader
├── ByteArrayInputStream      ├── StringReader
├── BufferedInputStream       ├── BufferedReader
└── ...                       └── InputStreamReader (바이트→문자 변환)

OutputStream                  Writer
├── FileOutputStream          ├── FileWriter
├── ByteArrayOutputStream     ├── StringWriter
├── BufferedOutputStream      ├── BufferedWriter
└── ...                       └── OutputStreamWriter (문자→바이트 변환)

이 구조를 머리에 넣어두면 "아, 지금 바이트를 다루는 건지 문자를 다루는 건지"만 판단하면 어떤 클래스를 쓸지 바로 결정할 수 있어요.

InputStream / OutputStream — 바이트 기반 I/O

가장 기본이 되는 바이트 스트림부터 볼게요. InputStream은 읽기, OutputStream은 쓰기를 담당합니다.

FileInputStream으로 파일 읽기

전통적인 방식은 finally에서 close()를 해야 해서 코드가 장황해요. try-with-resources로 깔끔하게 쓸 수 있습니다(뒤에서 설명).

JAVA
try (FileInputStream fis = new FileInputStream("data.bin")) {
    int data;
    while ((data = fis.read()) != -1) { // -1이면 EOF
        System.out.print((byte) data + " ");
    }
} catch (IOException e) {
    e.printStackTrace();
}

FileOutputStream으로 파일 쓰기

JAVA
// 파일에 바이트 단위로 쓰기
try (FileOutputStream fos = new FileOutputStream("output.bin")) {
    byte[] data = {72, 101, 108, 108, 111}; // "Hello"의 ASCII 코드
    fos.write(data);
    System.out.println("파일 쓰기 완료");
} catch (IOException e) {
    System.out.println("파일 쓰기 실패: " + e.getMessage());
}

read()는 한 바이트씩 읽어서 int로 반환하고, 파일 끝에 도달하면 -1을 반환해요. write()는 바이트 배열을 받아서 파일에 씁니다.

바이트 배열로 한 번에 읽기

한 바이트씩 읽으면 성능이 떨어져요. 바이트 배열을 사용하면 훨씬 빠릅니다.

JAVA
try (FileInputStream fis = new FileInputStream("data.bin")) {
    byte[] buffer = new byte[1024]; // 1KB 버퍼
    int bytesRead;

    while ((bytesRead = fis.read(buffer)) != -1) {
        // buffer의 0부터 bytesRead까지가 실제 데이터
        System.out.println("읽은 바이트 수: " + bytesRead);
    }
} catch (IOException e) {
    e.printStackTrace();
}

read(buffer)는 버퍼 크기만큼 한 번에 읽고, 실제로 읽은 바이트 수를 반환해요. 이 패턴은 실무에서 매우 자주 쓰입니다.

Reader / Writer — 문자 기반 I/O

텍스트 파일을 다룰 때는 바이트 스트림 대신 Reader/Writer를 쓰는 것이 맞아요. 한글 같은 멀티바이트 문자를 안전하게 처리할 수 있기 때문입니다.

FileReader로 텍스트 읽기

JAVA
// 문자 단위로 파일 읽기
try (FileReader reader = new FileReader("memo.txt")) {
    int ch;
    while ((ch = reader.read()) != -1) {
        System.out.print((char) ch); // int를 char로 변환하여 출력
    }
} catch (IOException e) {
    e.printStackTrace();
}

FileReader는 내부적으로 시스템 기본 인코딩을 사용해요. 인코딩을 명시하고 싶으면 InputStreamReader를 씁니다.

InputStreamReader — 바이트와 문자의 연결 고리

JAVA
// 인코딩을 명시적으로 지정
try (InputStreamReader isr = new InputStreamReader(
        new FileInputStream("memo.txt"), StandardCharsets.UTF_8)) {
    int ch;
    while ((ch = isr.read()) != -1) {
        System.out.print((char) ch);
    }
} catch (IOException e) {
    e.printStackTrace();
}

InputStreamReader는 바이트 스트림을 감싸서 문자 스트림으로 변환하는 브릿지(bridge) 역할을 합니다. FileReader는 시스템 기본 인코딩을 사용하고(Java 18부터 UTF-8 기본), InputStreamReader는 인코딩을 명시적으로 지정할 수 있어요.

FileWriter로 텍스트 쓰기

JAVA
// 문자 단위로 파일 쓰기
try (FileWriter writer = new FileWriter("output.txt")) {
    writer.write("안녕하세요, Java I/O입니다.\n");
    writer.write("한글도 문제없이 쓸 수 있습니다.");
} catch (IOException e) {
    e.printStackTrace();
}

BufferedReader / BufferedWriter — 버퍼링의 중요성

여기서 중요한 개념이 나옵니다. ** 버퍼링(buffering)**이에요.

FileReader로 한 문자씩 읽으면 매번 디스크에 접근해요. 디스크 I/O는 메모리 접근에 비해 수십만 배 느립니다. 그래서 ** 한 번에 큰 덩어리를 읽어서 메모리에 올려두고 **, 거기서 조금씩 꺼내 쓰는 것이 버퍼링이에요.

PLAINTEXT
버퍼 없이: 디스크 → 1문자 → 처리 → 디스크 → 1문자 → 처리 → ...
버퍼 사용: 디스크 → 8KB 한 번에 → 메모리에서 1문자씩 꺼내기 → ...

BufferedReader — 줄 단위 읽기

JAVA
// BufferedReader는 readLine()을 제공한다 — 가장 많이 쓰는 패턴
try (BufferedReader br = new BufferedReader(new FileReader("memo.txt"))) {
    String line;
    while ((line = br.readLine()) != null) { // 한 줄씩 읽기
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

BufferedReader의 장점 두 가지를 기억해두세요.

  1. ** 성능 **: 내부 버퍼(기본 8192자)를 사용해서 디스크 접근 횟수를 대폭 줄여요
  2. ** 편의성 **: readLine()으로 줄 단위 읽기가 가능합니다. FileReader에는 이 메서드가 없어요

BufferedWriter — 버퍼링된 쓰기

JAVA
// BufferedWriter로 효율적으로 쓰기
try (BufferedWriter bw = new BufferedWriter(new FileWriter("output.txt"))) {
    bw.write("첫 번째 줄");
    bw.newLine(); // 시스템에 맞는 줄바꿈 문자 삽입
    bw.write("두 번째 줄");
    bw.newLine();
    bw.write("세 번째 줄");
    // try-with-resources가 끝나면 자동으로 flush + close
} catch (IOException e) {
    e.printStackTrace();
}

bw.newLine()은 운영체제에 맞는 줄바꿈 문자를 넣어줍니다. Windows는 \r\n, Unix/Mac은 \n이에요. \n을 직접 쓰는 것보다 이식성이 좋습니다.

데코레이터 패턴

new BufferedReader(new InputStreamReader(new FileInputStream(...))) 형태가 ** 데코레이터 패턴 **이에요. 기본 기능(바이트 읽기)을 감싸서 부가 기능(문자 변환, 버퍼링)을 추가합니다. Java I/O 전체가 이 패턴으로 설계되어 있어 각 레이어를 자유롭게 조합할 수 있어요.

try-with-resources 활용

지금까지 코드에서 try-with-resources를 이미 사용하고 있었어요. 예외 처리 글에서 다뤘던 내용인데, I/O에서 특히 중요하므로 다시 한번 짚고 넘어갈게요.

전통적인 finally 방식은 close() 안에서도 try-catch가 필요해서 코드가 장황합니다. try-with-resources는 AutoCloseable 객체를 자동으로 닫아줘요.

try-with-resources 기본

JAVA
// try-with-resources — AutoCloseable 구현 객체를 자동으로 닫아준다
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}
// br.close()가 자동 호출된다

훨씬 깔끔하죠. try() 안에 선언한 리소스는 블록이 끝나면 ** 자동으로 close()가 호출 **됩니다. AutoCloseable 인터페이스를 구현한 객체만 여기에 넣을 수 있어요.

여러 리소스 동시에 관리

JAVA
// 세미콜론으로 구분해서 여러 리소스를 선언할 수 있다
try (FileInputStream fis = new FileInputStream("input.bin");
     FileOutputStream fos = new FileOutputStream("output.bin")) {

    byte[] buffer = new byte[1024];
    int bytesRead;
    while ((bytesRead = fis.read(buffer)) != -1) {
        fos.write(buffer, 0, bytesRead); // 파일 복사
    }
} catch (IOException e) {
    e.printStackTrace();
}
// fos.close()가 먼저, 그 다음 fis.close()가 호출된다 (선언 역순)

리소스는 ** 선언 역순 **으로 닫혀요. 먼저 열었으면 나중에 닫는다는 원칙입니다.

Suppressed Exception

try 블록에서 예외가 발생하고 close()에서도 예외가 발생하면, 전통적인 finally 방식에서는 close() 예외가 원래 예외를 덮어써서 디버깅이 어려웠어요. try-with-resources에서는 원래 예외가 보존되고, close() 예외는 Throwable.getSuppressed()로 확인할 수 있습니다.

java.nio — Channel과 Buffer

Java 1.4에서 java.nio (New I/O) 패키지가 등장했습니다. 기존 I/O의 성능 한계를 극복하기 위해서예요. 왜 기존 I/O가 느렸을까요?

  • ** 블로킹 **: read()를 호출하면 데이터가 올 때까지 스레드가 멈춰요
  • ** 단방향 **: InputStream은 읽기만, OutputStream은 쓰기만 가능합니다
  • ** 바이트 단위 **: 대용량 데이터를 처리할 때 비효율적이에요

NIO는 이 문제를 Channel 과 Buffer 라는 개념으로 해결했습니다.

Buffer — 데이터를 담는 컨테이너

Buffer는 데이터를 읽고 쓰는 데 사용하는 메모리 블록이에요. 배열과 비슷하지만, 읽기/쓰기 위치를 추적하는 기능이 내장되어 있습니다.

JAVA
// ByteBuffer 기본 사용법
ByteBuffer buffer = ByteBuffer.allocate(1024); // 1KB 버퍼 생성

// 쓰기 모드 — 데이터를 버퍼에 넣기
buffer.put((byte) 72);  // 'H'
buffer.put((byte) 101); // 'e'
buffer.put((byte) 108); // 'l'
buffer.put((byte) 108); // 'l'
buffer.put((byte) 111); // 'o'

// 쓰기 → 읽기 모드 전환 — 반드시 flip()을 호출해야 한다!
buffer.flip();

// 읽기 모드 — 버퍼에서 데이터 꺼내기
while (buffer.hasRemaining()) {
    System.out.print((char) buffer.get());
}
// 출력: Hello

Buffer의 핵심 속성 세 가지가 있어요.

  • position: 현재 읽기/쓰기 위치
  • limit: 읽기/쓰기 가능한 최대 위치
  • capacity: 버퍼의 총 크기
PLAINTEXT
쓰기 모드 (put 후):
[H][e][l][l][o][  ][  ]...
                ↑              ↑
            position(5)    capacity(1024)

flip() 호출 후 (읽기 모드):
[H][e][l][l][o][  ][  ]...
 ↑              ↑
position(0)  limit(5)

flip()은 쓰기 모드에서 읽기 모드로 전환해요. position을 0으로 되돌리고, limit을 이전 position으로 설정합니다. flip()을 빼먹으면 데이터를 읽을 수 없어요 — NIO에서 가장 흔한 실수입니다.

Channel — 양방향 데이터 통로

Channel은 스트림과 비슷하지만, ** 양방향 **이고 **Buffer를 통해서만 데이터를 주고받습니다 **.

JAVA
// FileChannel로 파일 읽기
try (FileChannel channel = FileChannel.open(
        Path.of("data.txt"), StandardOpenOption.READ)) {

    ByteBuffer buffer = ByteBuffer.allocate(1024);

    // 채널에서 버퍼로 데이터 읽기
    while (channel.read(buffer) != -1) {
        buffer.flip(); // 읽기 모드로 전환

        // 버퍼에서 데이터를 꺼내서 문자열로 변환
        byte[] bytes = new byte[buffer.remaining()];
        buffer.get(bytes);
        System.out.print(new String(bytes, StandardCharsets.UTF_8));

        buffer.clear(); // 버퍼를 비우고 다시 쓰기 모드로
    }
} catch (IOException e) {
    e.printStackTrace();
}

Channel로 파일 쓰기

JAVA
// FileChannel로 파일 쓰기
try (FileChannel channel = FileChannel.open(
        Path.of("output.txt"),
        StandardOpenOption.CREATE,
        StandardOpenOption.WRITE)) {

    String text = "NIO로 쓰는 파일입니다.";
    ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));

    channel.write(buffer); // 버퍼의 내용을 채널에 쓰기
} catch (IOException e) {
    e.printStackTrace();
}

스트림 방식과 비교하면, Channel은 항상 Buffer를 매개로 해요. 데이터가 Channel ↔ Buffer 사이를 오가는 구조입니다.

왜 Channel + Buffer인가?

이 구조의 장점은 다음과 같아요.

  1. ** 양방향 **: 하나의 Channel로 읽기와 쓰기가 모두 가능해요
  2. ** 논블로킹 지원 **: 네트워크 채널에서 논블로킹 I/O를 쓸 수 있습니다 (SocketChannel)
  3. ** 직접 메모리 접근 **: ByteBuffer.allocateDirect()로 OS 수준의 메모리를 직접 사용할 수 있어요
  4. ** 파일 잠금 **: FileChannel.lock()으로 파일 수준 잠금이 가능합니다

java.nio.file.Path와 Files

Java 7에서 java.nio.file 패키지가 추가되면서 파일 조작이 훨씬 편해졌어요. 기존 java.io.File 클래스의 단점을 보완한 것입니다.

Path — 파일 경로 표현

JAVA
// Path 생성
Path path = Path.of("src", "main", "data.txt"); // src/main/data.txt
Path absolute = Path.of("/Users/dev/project/data.txt");

// Path 정보 조회
System.out.println(path.getFileName());    // data.txt
System.out.println(path.getParent());      // src/main
System.out.println(path.toAbsolutePath()); // 절대 경로
System.out.println(path.getNameCount());   // 3 (src, main, data.txt)

// 경로 결합
Path base = Path.of("/Users/dev");
Path full = base.resolve("project/data.txt"); // /Users/dev/project/data.txt

// 경로 정규화
Path messy = Path.of("/Users/dev/../dev/./project");
System.out.println(messy.normalize()); // /Users/dev/project

Files — 파일 조작 유틸리티

Files 클래스는 static 메서드로 파일 읽기, 쓰기, 복사, 삭제 등을 한 줄로 처리할 수 있게 해줍니다. 실무에서 가장 많이 쓰는 API예요.

파일 읽기

JAVA
// 파일 전체를 문자열로 읽기 (Java 11+)
String content = Files.readString(Path.of("memo.txt"));
System.out.println(content);

// 파일 전체를 줄 단위 리스트로 읽기
List<String> lines = Files.readAllLines(Path.of("memo.txt"), StandardCharsets.UTF_8);
for (String line : lines) {
    System.out.println(line);
}

// 파일 전체를 바이트 배열로 읽기
byte[] bytes = Files.readAllBytes(Path.of("image.png"));

파일 쓰기

JAVA
// 문자열을 파일에 쓰기 (Java 11+)
Files.writeString(Path.of("output.txt"), "Hello, NIO!");

// 줄 단위 리스트를 파일에 쓰기
List<String> lines = List.of("첫 번째 줄", "두 번째 줄", "세 번째 줄");
Files.write(Path.of("output.txt"), lines, StandardCharsets.UTF_8);

// 기존 파일에 추가하기 (APPEND 옵션)
Files.writeString(Path.of("log.txt"), "새 로그 항목\n",
        StandardOpenOption.CREATE, StandardOpenOption.APPEND);

Files.lines() — 스트림으로 대용량 파일 처리

readAllLines()는 파일 전체를 메모리에 올려요. 대용량 파일이라면 lines()를 쓰는 것이 낫습니다.

JAVA
// Files.lines()는 Stream을 반환한다 — 지연 로딩(lazy loading)
try (Stream<String> lines = Files.lines(Path.of("huge-log.txt"))) {
    long errorCount = lines
        .filter(line -> line.contains("ERROR")) // ERROR가 포함된 줄만
        .count();
    System.out.println("에러 수: " + errorCount);
}
// Stream도 AutoCloseable이므로 try-with-resources로 닫아야 한다

Files.lines()는 파일 전체를 한 번에 읽지 않고, 필요할 때 한 줄씩 읽어요. 이전 글에서 배운 스트림의 지연 평가가 여기서도 동작하는 거예요.

파일/디렉토리 조작

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

// 파일 복사
Files.copy(Path.of("source.txt"), Path.of("backup.txt"),
        StandardCopyOption.REPLACE_EXISTING); // 이미 있으면 덮어쓰기

// 파일 이동 (이름 변경에도 사용)
Files.move(Path.of("old.txt"), Path.of("new.txt"));

// 파일 삭제
Files.delete(Path.of("temp.txt"));          // 없으면 예외 발생
Files.deleteIfExists(Path.of("temp.txt"));  // 없어도 예외 없음

// 디렉토리 생성
Files.createDirectory(Path.of("newDir"));          // 상위 디렉토리 없으면 예외
Files.createDirectories(Path.of("a/b/c/newDir"));  // 상위 디렉토리까지 한 번에

// 파일 정보
System.out.println(Files.size(Path.of("data.txt")));          // 파일 크기 (바이트)
System.out.println(Files.getLastModifiedTime(Path.of("data.txt"))); // 수정 시간
System.out.println(Files.isDirectory(Path.of("src")));         // 디렉토리인지

IO vs NIO 비교표

구분IO (java.io)NIO (java.nio)
** 데이터 흐름**스트림 (Stream) — 단방향채널 (Channel) — 양방향
** 데이터 단위**바이트 / 문자버퍼 (Buffer)
** 블로킹**항상 블로킹블로킹 + 논블로킹 선택 가능
** 방향**입력 따로, 출력 따로하나의 채널로 읽기/쓰기
** 멀티플렉싱**지원 안 함Selector로 여러 채널 관리 가능
** 파일 조작**File 클래스Path + Files 클래스
** 등장 시점**JDK 1.0JDK 1.4 (파일은 JDK 7)

** 그래서 뭘 쓰면 될까요?**

  • ** 파일 읽기/쓰기 **: Files 클래스를 우선 사용하세요. 가장 간결하고 현대적이에요
  • ** 대용량 파일 **: Files.lines() + 스트림, 또는 BufferedReader
  • ** 바이너리 파일 **: FileChannel + ByteBuffer
  • ** 네트워크 고성능 서버 **: NIO의 SocketChannel + Selector (또는 Netty 같은 프레임워크)
  • ** 간단한 네트워크 **: Socket의 IO 스트림으로도 충분합니다

실전 예제

파일 복사 -- 세 가지 방식 비교

IO 스트림 방식은 버퍼를 만들어 반복문으로 읽고 씁니다. NIO의 FileChannel.transferTo()는 OS 수준의 최적화된 복사를 해요.

JAVA
// 방법 1: IO 스트림
try (InputStream in = new FileInputStream(src);
     OutputStream out = new FileOutputStream(dst)) {
    byte[] buffer = new byte[8192];
    int bytesRead;
    while ((bytesRead = in.read(buffer)) != -1)
        out.write(buffer, 0, bytesRead);
}
JAVA
// 방법 2: NIO FileChannel — OS 수준 최적화
try (FileChannel inCh = FileChannel.open(Path.of(src), StandardOpenOption.READ);
     FileChannel outCh = FileChannel.open(Path.of(dst),
             StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
    inCh.transferTo(0, inCh.size(), outCh);
}

가장 간결한 방법은 Files.copy()예요. 내부적으로도 최적화되어 있으므로 특별한 이유가 없다면 이걸 쓰면 됩니다.

JAVA
// 방법 3: Files.copy() — 가장 간결
Files.copy(Path.of(src), Path.of(dst), StandardCopyOption.REPLACE_EXISTING);

디렉토리 탐색 — Files.walk()

JAVA
// 디렉토리 내 모든 .java 파일 찾기
try (Stream<Path> paths = Files.walk(Path.of("src"))) {
    List<Path> javaFiles = paths
        .filter(Files::isRegularFile)                    // 파일만 (디렉토리 제외)
        .filter(p -> p.toString().endsWith(".java"))     // .java 확장자만
        .sorted()
        .toList();

    javaFiles.forEach(System.out::println);
    System.out.println("총 " + javaFiles.size() + "개의 Java 파일");
}

로그 파일 분석 — Files.lines() 활용

JAVA
// 로그 파일에서 특정 패턴 분석
try (Stream<String> lines = Files.lines(Path.of("app.log"))) {
    // 시간대별 에러 건수 집계
    Map<String, Long> errorsByHour = lines
        .filter(line -> line.contains("ERROR"))
        .map(line -> line.substring(11, 13)) // HH 부분 추출 (시간)
        .collect(Collectors.groupingBy(
            hour -> hour,
            Collectors.counting()
        ));

    errorsByHour.entrySet().stream()
        .sorted(Map.Entry.comparingByKey())
        .forEach(e -> System.out.println(e.getKey() + "시: " + e.getValue() + "건"));
}

Files.lines()와 스트림의 조합은 실무에서 자주 쓰이는 패턴입니다.

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

주의할 점

flip() 빼먹기

NIO Buffer에서 쓰기 → 읽기 전환 시 flip()을 호출하지 않으면 데이터를 읽을 수 없어요. position이 쓰기 끝 위치에 있기 때문입니다. clear()compact()의 차이도 알아야 해요. clear()는 버퍼 전체를 초기화하고, compact()는 읽지 않은 데이터를 앞으로 당긴 뒤 나머지를 초기화합니다.

Files.readAllLines()로 대용량 파일 읽기

readAllLines()는 파일 전체를 메모리에 올립니다. GB 단위 파일에 사용하면 OutOfMemoryError가 발생해요. 대용량 파일은 Files.lines()(지연 로딩) 또는 BufferedReader를 사용하세요.

리소스 누수

I/O 리소스는 반드시 try-with-resources로 관리해야 합니다. Files.lines()가 반환하는 Stream도 AutoCloseable이므로 try-with-resources로 감싸야 해요. 감싸지 않으면 파일 핸들이 해제되지 않습니다.

정리

항목핵심
InputStream / OutputStream바이트 단위 I/O. 바이너리 데이터 처리용
Reader / Writer문자 단위 I/O. 멀티바이트 인코딩 지원
BufferedReader/Writer버퍼링으로 디스크 접근 횟수 감소. readLine() 제공
try-with-resourcesAutoCloseable 리소스를 자동으로 닫음. I/O 필수 패턴
Channel + BufferNIO 핵심. 양방향, 논블로킹 지원. flip() 필수
Path + Files현대적 파일 조작 API. 대부분의 파일 작업이 한 줄
IO vs NIO 선택일반 파일은 Files, 대용량은 Files.lines(), 네트워크 고성능은 NIO

다음 글에서는 ** 날짜와 시간 **을 다뤄볼게요. DateCalendar가 왜 그렇게 쓰기 불편했는지, java.time 패키지가 이 문제를 어떻게 해결했는지 궁금하다면 이어서 봐주세요.

댓글 로딩 중...