Backend/Java

[Java] 파일 입출력의 진화: 전통 I/O에서 현대적 비동기·리액티브 방식까지

제이동 개발자 2025. 5. 24. 16:08
728x90

[Java] 파일 입출력의 진화: 전통 I/O에서 현대적 비동기·리액티브 방식까지

Java는 출시 초기부터 지금까지 다양한 파일 입출력(File I/O) API를 제공해 왔습니다. 각 버전마다 성능, 사용 편의성, 비동기 처리 능력 등을 개선해 왔는데요, 이 글에서는 시간의 흐름에 따라 주요 변화를 짚어보고, 현재 어떤 방식으로 파일을 다루는지 살펴보겠습니다.

 

아래 코드들은 깃허브에 테스트 코드까지 구현해 놓았습니다. 필요하신분들은 참고하시길 바랍니다.

 

1. Java 1.x: java.io 전통 스트림 방식

  • 시기: Java 1.0~1.4
  • 주요 클래스: FileInputStream/FileOutputStream, FileReader/FileWriter, BufferedInputStream/BufferedOutputStream
  • 사용법: 바이트·문자 단위로 직접 읽고 쓰며, Buffered* 클래스로 버퍼링

예시코드

// 파일 존재 여부 확인 및 기본 스트림 사용 예시
import java.io.*;

public class LegacyIOExample {
    public void basicIO() {
        File file = new File("data.txt");
        System.out.println("경로: " + file.getAbsolutePath());
        
        try (FileInputStream fis = new FileInputStream(file);
             FileOutputStream fos = new FileOutputStream("out.txt")) {
            int b;
            while ((b = fis.read()) != -1) {
                fos.write(b); // 1바이트씩 복사
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

단점

  • 장황한 리소스 해제: finally 블록에서 close() 코드 중복
  • 예외 처리 복잡: 예외가 발생 시 리소스 누수 위험
  • 문자셋 지정 불편: 기본 플랫폼 인코딩 의존
  • 원자적 파일 조작 부재: 복사·이동·삭제 시 일관성 보장 어려움

이러한 리소스 관리 번거로움원자적 조작 부재를 보완하기 위해 Java 7에서 NIO.2 (java.nio.file) 가 도입되었습니다.

 

 

2. Java 7+: NIO.2 (java.nio.file) - New I/O

  • File API 통합: Path, Files, StandardCopyOption
  • 간편 메서드: Files.copy, Files.move, Files.delete
  • Files.copy, Files.write, Files.move 사용 시 리소스 자동 해제
❗️ Files.newInputStream · Files.newOutputStream를 이용하여
직접 스트림 제어를 하는 경우는 반드시 try-with-resources 를 사용해야 한다

 

예시코드

public class NioFileExample {

  /**
   * 파일을 대상 경로로 복사합니다.
   */
  public void copyFile(Path file, Path targetFile) throws IOException {
    Files.createDirectories(targetFile.getParent());
    Files.copy(file, targetFile, StandardCopyOption.REPLACE_EXISTING);

    // 아래와 같이 직접 파일 스트림을 사용하는 경우 try-with-resources를 사용해야 한다.
    // 직접 연 스트림은 try-with-resources로 관리
//    try (InputStream in = Files.newInputStream(file, StandardOpenOption.READ);
//        OutputStream out = Files.newOutputStream(targetFile,
//            StandardOpenOption.CREATE,
//            StandardOpenOption.TRUNCATE_EXISTING)) {
//      // JDK 9+ 메서드: in.transferTo(out);
//      byte[] buffer = new byte[8 * 1024];
//      int read;
//      while ((read = in.read(buffer)) != -1) {
//        out.write(buffer, 0, read);
//      }
//    }
  }


  /**
   * 문자열 콘텐츠를 파일에 작성합니다.
   */
  public void writeFile(Path path, String content, StandardOpenOption... openOptions)
      throws IOException {
    Files.createDirectories(path.getParent());
    Files.writeString(path, content, openOptions);
  }

  /**
   * 파일을 삭제합니다 (존재하지 않을 경우 예외는 발생하지 않습니다).
   */
  public void deleteFile(Path path) throws IOException {
    Files.deleteIfExists(path);
  }

  /**
   * 파일을 다른 디렉토리로 이동하거나 이름을 변경합니다.
   */
  public void moveFile(Path file, Path targetFile) throws IOException {
    Files.createDirectories(targetFile.getParent());
    Files.move(file, targetFile, StandardCopyOption.REPLACE_EXISTING);
  }
}

 

단점

  • 여전히 블로킹 I/O: 대용량 처리 시 스레드 대기
  • 텍스트 vs. 바이너리: 인코딩·바이너리 구분 필요
  • 파일 속성 분리 조회: Files.readAttributes 호출 필요

 

 

3. Java 8+: Stream API 활용

Java 8의 Stream API 등장으로 Files.lines()로 함수형 스타일 텍스트 처리가 가능해졌습니다.

  • Files.lines(path)로 한 줄씩 스트림 처리
  • 함수형 연산 (map, filter, collect)
  • parallel()로 손쉬운 병렬화

 

예시코드

public class StreamFileExample {

  /**
   * 파일의 모든 줄을 읽어 반환합니다.
   */
  public List<String> readLines(Path path) throws IOException {
    // StandardCharsets default - UTF_8
    try (Stream<String> lines = Files.lines(path)) {
      return lines.collect(Collectors.toList());
    }
  }

  /**
   * 문자열 리스트를 파일에 라인 단위로 씁니다.
   */
  public void writeLines(Path path, List<String> lines, StandardOpenOption... openOptions) throws IOException {
    Files.createDirectories(path.getParent());
    // StandardCharsets default - UTF_8
    Files.write(path, lines, openOptions);
  }

  /**
   * 지정된 파일을 삭제합니다.
   */
  public void deleteFile(Path path) throws IOException {
    Files.deleteIfExists(path);
  }

  /**
   * 파일을 지정된 경로로 이동합니다.
   */
  public void moveFile(Path src, Path dest) throws IOException {
    Files.createDirectories(dest.getParent());
    Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
  }
}

단점

  • 텍스트 전용: 바이너리 처리 불가
  • 메모리 과다 사용: 전체 라인 수집 시
  • 여전히 블로킹 I/O

 

 

4. Java 7+: 비동기 I/O (AsynchronousFileChannel)

대용량·네트워크 마운트 환경에서 논블로킹 I/O를 위해 AsynchronousFileChannel을 활용 할 수 있게 되었습니다.

  • 코드 복잡도 증가: Future/CompletionHandler 관리
  • 채널 닫기 누수 위험
  • 버퍼·스레드풀 직접 관리 필요

예시코드

// StringIOExample.java
public class StringIOExample {

    /**
     * 파일을 읽어 문자열로 반환
     */
    public String readText(Path path) throws IOException {
        return Files.readString(path, StandardCharsets.UTF_8);
    }

    /**
     * 문자열을 파일에 기록 (덮어쓰기)
     */
    public void writeText(Path path, String content) throws IOException {
        Files.createDirectories(path.getParent());
        Files.writeString(path, content,
            StandardCharsets.UTF_8,
            StandardOpenOption.CREATE,
            StandardOpenOption.TRUNCATE_EXISTING);
    }

    /**
     * 파일 삭제
     */
    public void deleteFile(Path path) throws IOException {
        Files.deleteIfExists(path);
    }

    /**
     * 파일 이동
     */
    public void moveFile(Path src, Path dest) throws IOException {
        Files.createDirectories(dest.getParent());
        Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
    }
}

단점

  • 텍스트 전용: 바이너리 처리 불가
  • 메모리 과다 사용: 전체 라인 수집 시
  • 여전히 블로킹 I/O

 

 

4. Java 7+: 비동기 I/O (AsynchronousFileChannel)

  • Future 또는 CompletionHandler 기반 비동기 읽기/쓰기
  • 논블로킹 I/O
  • 높은 동시성

예시코드

public class AsyncFileExample {

    /**
     * 비동기 읽기 → Future<Integer> 반환
     */
    public Future<Integer> readAsync(Path path, ByteBuffer buffer) throws IOException {
        AsynchronousFileChannel ch = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
        return ch.read(buffer, 0);
    }

    /**
     * 비동기 쓰기 → Future<Integer> 반환
     */
    public Future<Integer> writeAsync(Path path, ByteBuffer buffer) throws IOException {
        AsynchronousFileChannel ch = AsynchronousFileChannel.open(
            path, StandardOpenOption.WRITE, StandardOpenOption.CREATE);
        return ch.write(buffer, 0);
    }

    /**
     * 동기 파일 삭제
     */
    public void deleteFile(Path path) throws IOException {
        Files.deleteIfExists(path);
    }

    /**
     * 동기 파일 이동
     */
    public void moveFile(Path src, Path dest) throws IOException {
        Files.createDirectories(dest.getParent());
        Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
    }
}

단점

  • 코드 복잡도 증가: Future/CompletionHandler 관리
  • 채널 닫기 누수 위험
  • 버퍼·스레드풀 직접 관리 필요

 

 

5. Java 11+: 편의 메서드 (Files.readString, Files.writeString)

  • 편의 메서드 제공 : 텍스트 읽기·쓰기 (readString/writeString)
  • 내부 버퍼링·인코딩 지정 지원
  • 코드 간결

 

public class ConvenienceFileExample {

  /**
   * 파일을 읽어 문자열로 반환
   */
  public String readText(Path path) throws IOException {
    // default : StandardCharsets.UTF_8
    return Files.readString(path);
  }

  /**
   * 문자열을 파일에 기록 (덮어쓰기)
   */
  public void writeText(Path path, String content, StandardOpenOption... openOptions) throws IOException {
    Files.createDirectories(path.getParent());
    Files.writeString(path, content, StandardCharsets.UTF_8, openOptions);
  }

  /**
   * 파일 삭제
   */
  public void deleteFile(Path path) throws IOException {
    Files.deleteIfExists(path);
  }

  /**
   * 파일 이동
   */
  public void moveFile(Path src, Path dest) throws IOException {
    Files.createDirectories(dest.getParent());
    Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
  }
}

단점

  • 텍스트 전용: 바이너리 처리 불가
  • 대용량 비효율: 스트림 제어 불가
  • 비동기 미지원

 

이번 글에서는 Java 버전에 따라 파일을 읽고 쓰기는 전체적인 흐름을 다루었고, 다음 글에서는 위에서 정리한 이론을 바탕으로, 직접 구현한 파일 입출력 코드를 단계별로 작성해 보려고 합니다.

 

728x90