모던 자바의 흔한 I/O 작업들

모던 자바의 흔한 I/O 작업들

애플리케이션을 만들 때, 특히 웹 애플리케이션을 만들 때 개발자가 실제로 맞닥뜨리는 I/O 작업은 그렇게 많지 않아요. 대략 이런 것들이죠.

  • 텍스트 파일 읽고 쓰기
  • 웹에서 텍스트·이미지·JSON 읽기
  • 디렉터리의 파일 둘러보기
  • ZIP 파일 읽기
  • 임시 파일이나 임시 디렉터리 만들기

물론 Java API는 그보다 훨씬 많은 작업을 지원해요. 그 상세한 내용은 Java I/O API 튜토리얼에서 다루니, 여기서는 위 목록처럼 자주 쓰는 작업에 집중할게요. 그리고 이 글은 Java 8 이후의 API 개선에 초점을 맞춰요. 특히 이런 변화를 알아두면 좋아요.

  • Java 18부터 (JEP 400: UTF-8 by Default 덕분에) I/O의 기본 인코딩이 UTF-8이 됐어요.
  • java.nio.file.Files 클래스는 Java 7에 처음 등장했는데, Java 8·11·12에서 유용한 메서드들이 추가됐어요.
  • java.io.InputStream은 Java 9·11·12에서 유용한 메서드들을 얻었어요.
  • java.io.Filejava.io.BufferedReader는 이제 사실상 구식이 됐어요. 웹 검색이나 AI 채팅에는 아직 자주 등장하지만, 새 코드에서 쓸 이유는 거의 없어요.

출처: dev.java - Common I/O Tasks in Modern Java

본문

텍스트 파일 읽기

텍스트 파일을 통째로 문자열로 읽는 건 한 줄이면 끝나요.

String content = Files.readString(path);

여기서 pathjava.nio.file.Path의 인스턴스인데, 이렇게 만들 수 있어요.

var path = Path.of("/usr/share/dict/words");

Java 18 이전에는 문자열을 읽거나 쓰는 파일 작업에서 문자 인코딩을 반드시 지정하라고 권장했어요. 그 이유는 이렇습니다. 지금은 압도적으로 많은 경우 UTF-8을 쓰지만, 하위 호환성을 위해 Java는 "플랫폼 인코딩"을 기본값으로 사용했거든요. Windows에서는 그게 예전 방식의 인코딩일 수도 있어요. 그래서 이식성을 보장하려면 텍스트 I/O마다 StandardCharsets.UTF_8 파라미터를 일일이 넘겨야 했죠. 이제는 그럴 필요가 없어요.

파일을 한 줄씩의 연속으로 가져오고 싶다면 이렇게 해요.

List<String> lines = Files.readAllLines(path);

파일이 크다면, Stream<String>으로 줄을 지연(lazily) 처리할 수 있어요.

try (Stream<String> lines = Files.lines(path)) {
    . . .
}

스트림 연산(map, filter 같은 것들)으로 줄을 자연스럽게 처리할 수 있다면 Files.lines를 써도 좋아요. 다만 Files.lines가 돌려주는 스트림은 닫아야 한다는 점을 꼭 기억하세요. 그걸 보장하기 위해 위 코드처럼 try-with-resources 문을 사용하는 거예요.

그리고 java.io.BufferedReaderreadLine 메서드를 쓸 이유는 이제 거의 없어요.

줄 단위가 아니라 다른 단위로 입력을 나누고 싶다면 java.util.Scanner를 쓰면 돼요. 예를 들어 문자(글자가 아닌 것을 구분자로) 단위로 단어를 읽는 코드는 이렇습니다.

Stream<String> tokens = new Scanner(path).useDelimiter("\\PL+").tokens();

Scanner에는 숫자를 읽는 메서드도 있지만, 보통은 입력을 한 줄짜리 문자열 또는 하나의 문자열로 읽은 다음 파싱하는 편이 더 단순해요.

텍스트 파일에서 숫자를 파싱할 때는 조심하세요. 숫자 형식이 로케일(locale)에 따라 달라질 수 있기 때문이에요. 예를 들어 100.000이라는 입력은 미국 로케일에서는 100.0이지만 독일 로케일에서는 100000.0이에요. 로케일별 파싱이 필요하면 java.text.NumberFormat을 쓰고, 그게 아니라면 Integer.parseInt/Double.parseDouble을 쓰면 돼요.

텍스트 파일 쓰기

문자열을 텍스트 파일에 쓰는 것도 한 번의 호출로 끝나요.

String content = . . .;
Files.writeString(path, content);

한 문자열이 아니라 줄 목록이 있다면 이렇게 해요.

List<String> lines = . . .;
Files.write(path, lines);

보다 일반적인 출력이 필요하고 printf 메서드를 쓰고 싶다면 PrintWriter를 사용할 수 있어요.

var writer = new PrintWriter(path.toFile());
writer.printf(locale, "Hello, %s, next year you'll be %d years old!%n", name, age + 1);

printf는 로케일별로 동작한다는 점에 유의하세요. 숫자를 쓸 때는 그에 맞는 형식으로 써야 해요. printf 대신 java.text.NumberFormat이나 Integer.toString/Double.toString을 고려해볼 수도 있어요.

그런데 이상하게도 Java 21 기준으로는 Path를 받는 PrintWriter 생성자가 없어요. printf를 쓰지 않을 거라면 BufferedWriter 클래스를 쓰고 write 메서드로 문자열을 쓰면 돼요.

var writer = Files.newBufferedWriter(path);
writer.write(line); // 줄바꿈 문자를 쓰지는 않음
writer.newLine();

다 쓴 뒤에는 writer를 꼭 닫는 걸 잊지 마세요.

입력 스트림에서 읽기

스트림을 쓰는 가장 흔한 이유는 웹사이트에서 무언가를 읽는 경우일 거예요.

요청 헤더를 설정하거나 응답 헤더를 읽어야 한다면 HttpClient를 사용해요.

HttpClient client = HttpClient.newBuilder().build();
HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://horstmann.com/index.html"))
        .GET()
        .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
String result = response.body();

그런데 데이터만 필요하다면 이건 과한 방법이에요. 그럴 땐 그냥 이렇게 하면 됩니다.

InputStream in = new URI("https://horstmann.com/index.html").toURL().openStream();

그다음 데이터를 바이트 배열로 읽고, 필요하면 문자열로 바꿔요.

byte[] bytes = in.readAllBytes();
String result = new String(bytes);

혹은 데이터를 출력 스트림으로 옮길 수도 있어요.

try (OutputStream out = Files.newOutputStream(path)) {
    in.transferTo(out);
}

입력 스트림의 바이트를 전부 읽으려는 거라면 반복문이 필요 없다는 점이 포인트예요.

그런데 진짜 입력 스트림이 필요한가부터 생각해볼게요. 많은 API가 파일이나 URL에서 직접 읽는 옵션을 줍니다. 좋아하는 JSON 라이브러리에도 파일이나 URL에서 읽는 메서드가 있을 가능성이 높아요. 예를 들어 Jackson jr를 쓰면:

URL url = new URI("https://dog.ceo/api/breeds/image/random").toURL();
Map<String, Object> result = JSON.std.mapFrom(url);

앞선 호출로 받은 강아지 이미지를 읽는 코드는 이렇습니다.

URL url = new URI(result.get("message").toString()).toURL();
BufferedImage img = javax.imageio.ImageIO.read(url);

이게 read 메서드에 입력 스트림을 넘기는 것보다 나은 이유는, 라이브러리가 URL의 추가 정보를 이용해 이미지 타입을 결정할 수 있기 때문이에요.

The Files API

java.nio.file.Files 클래스는 파일·디렉터리를 만들고, 복사하고, 옮기고, 삭제하는 등 포괄적인 파일 작업을 제공해요. 그 전체 설명은 File System Basics 튜토리얼에 자세히 나오니, 여기서는 자주 쓰는 몇 가지를 짚어볼게요.

디렉터리와 하위 디렉터리의 항목 둘러보기

대부분의 상황에서는 두 가지 메서드 중 하나를 쓰면 돼요. Files.list는 디렉터리의 모든 항목(파일, 하위 디렉터리, 심볼릭 링크)을 방문해요.

try (Stream<Path> entries = Files.list(pathToDirectory)) {
    . . .
}

반복 상태를 추적하는 스트림 객체가 닫히도록 try-with-resources 문을 사용해요. 하위 디렉터리의 항목까지 방문하고 싶다면 대신 Files.walk를 써요.

Stream<Path> entries = Files.walk(pathToDirectory);

그다음 스트림 메서드로 관심 있는 항목을 골라내고 결과를 모으면 돼요.

try (Stream<Path> entries = Files.walk(pathToDirectory)) {
    List<Path> htmlFiles = entries.filter(p -> p.toString().endsWith("html")).toList();
    . . .
}

디렉터리 항목을 둘러보는 다른 메서드들도 소개할게요.

  • 오버로드된 Files.walk 버전으로 탐색하는 트리의 깊이를 제한할 수 있어요.
  • Files.walkFileTree 메서드 두 개는 디렉터리를 처음·마지막으로 방문할 때 FileVisitor에 알려주는 방식으로 반복 과정을 더 세밀하게 제어해요. 특히 디렉터리 트리를 비우고 삭제할 때 유용할 때가 있어요. 자세한 내용은 Walking the File Tree 튜토리얼을 참고하세요. 그런 제어가 필요 없다면 더 단순한 Files.walk를 쓰는 편이 좋아요.
  • Files.findFiles.walk와 같지만 각 경로와 그 BasicFileAttributes를 살피는 필터를 직접 지정해요. 파일별로 속성을 따로 읽는 것보다 약간 더 효율적이에요.
  • Files.newDirectoryStream(Path) 메서드 두 개는 향상된 for 루프에서 쓸 수 있는 DirectoryStream 인스턴스를 돌려줘요. Files.list를 쓰는 것보다 이점은 없어요.
  • 예전 File.listFile.listFiles 메서드는 파일 이름이나 File 객체를 돌려주는데, 이제 구식이 됐어요.

ZIP 파일 다루기

Java 1.1부터 ZipInputStreamZipOutputStream 클래스로 ZIP 파일을 처리하는 API가 있었죠. 그런데 그 API는 좀 다루기 까다로워요. Java 8에서 훨씬 편한 ZIP 파일 시스템이 도입됐습니다.

try (FileSystem fs = FileSystems.newFileSystem(pathToZipFile)) {
    . . .
}

try-with-resources 문 덕분에 ZIP 파일 작업이 끝나면 close 메서드가 호출돼요. 그 메서드가 파일 시스템의 변경 사항을 반영해 ZIP 파일을 갱신해요.

그러면 Files 클래스의 메서드를 그대로 쓸 수 있어요. ZIP 파일 안의 모든 파일 목록을 얻으려면:

try (Stream<Path> entries = Files.walk(fs.getPath("/"))) {
    List<Path> filesInZip = entries.filter(Files::isRegularFile).toList();
}

파일 내용을 읽으려면 그냥 Files.readString이나 Files.readAllBytes를 쓰면 됩니다.

String contents = Files.readString(fs.getPath("/LICENSE"));

Files.delete로 파일을 지울 수 있고, 파일을 추가하거나 교체하려면 Files.writeString이나 Files.write를 쓰면 돼요.

임시 파일과 임시 디렉터리 만들기

사용자 입력을 모으고, 파일을 만들고, 외부 프로세스를 실행해야 할 때가 꽤 있어요. 그럴 때 재부팅하면 사라지는 임시 파일이나, 프로세스가 끝난 뒤 지워버릴 임시 디렉터리를 사용하죠. 저는 그럴 때 Files.createTempFileFiles.createTempDirectory 메서드를 써요.

Path filePath = Files.createTempFile("myapp", ".txt");
Path dirPath = Files.createTempDirectory("myapp");

이러면 적절한 위치(Linux에서는 /tmp)에 주어진 접두사와, 파일이라면 접미사까지 붙여서 임시 파일이나 디렉터리를 만들어줘요.

결론

웹 검색이나 AI 채팅은 흔한 I/O 작업에 대해 불필요하게 복잡한 코드를 제안하곤 해요. 실제로는 더 나은 선택지가 있어요.

  1. 문자열이나 바이트 배열을 읽거나 쓸 때 반복문은 필요 없어요.
  2. 스트림·reader·writer조차 필요 없을 수 있어요.
  3. 파일·디렉터리를 만들고·복사하고·옮기고·삭제하는 Files 메서드에 익숙해지세요.
  4. 디렉터리 항목을 둘러볼 때는 Files.listFiles.walk를 쓰세요.
  5. ZIP 파일을 처리할 때는 ZIP 파일 시스템을 쓰세요.
  6. 예전 File 클래스에는 손대지 마세요.

더 알아보기