Java 재활 훈련 19일차 - Java NIO

java

목록 보기
20/20

File Handling and I/O

Classic Java I/O

기존의 file관련 I/O 동작은 File이라는 클래스를 중심으로 파일과 디렉터리를 모두 표현할 수 있었다. 아래는 configuration file을 읽는 가장 기본적인 동작을 설명한 것이다.

// Get a file object to represent the user's home directory
var homedir = new File(System.getProperty("user.home"));

// Create an object to represent a config file (should
// already be present in the home directory)
var f = new File(homedir, "app.conf");

// Check the file exists, really is a file, and is readable
if (f.exists() && f.isFile() && f.canRead()) {

  // Create a file object for a new configuration directory
  var configdir = new File(homedir, ".configdir");
  // And create it
  configdir.mkdir();

  // Finally, move the config file to its new home
  f.renameTo(new File(configdir, ".config"));
}

File 클래스에는 많은 메서드들이 있는데, file content를 읽는 일부 기본 기능은 직접 제공되지 않았다. 다음은 File 메서드에 대한 간략한 요역이다.

// Permissions management
boolean canX = f.canExecute();
boolean canR = f.canRead();
boolean canW = f.canWrite();

boolean ok;
ok = f.setReadOnly();
ok = f.setExecutable(true);
ok = f.setReadable(true);
ok = f.setWritable(false);

// Different views of the file's name
File absF = f.getAbsoluteFile();
File canF = f.getCanonicalFile();
String absName = f.getAbsolutePath();
String canName = f.getCanonicalPath();
String name = f.getName();
String pName = f.getParent();
URI fileURI = f.toURI(); // Create URI for File path

// File metadata
boolean exists = f.exists();
boolean isAbs = f.isAbsolute();
boolean isDir = f.isDirectory();
boolean isFile = f.isFile();
boolean isHidden = f.isHidden();
long modTime = f.lastModified(); // milliseconds since epoch
boolean updateOK = f.setLastModified(updateTime); // milliseconds
long fileLen = f.length();

// File management operations
boolean renamed = f.renameTo(destFile);
boolean deleted = f.delete();

// Create won't overwrite existing file
boolean createdOK = f.createNewFile();

// Temporary file handling
var tmp = File.createTempFile("my-tmp", ".tmp");
tmp.deleteOnExit();

// Directory handling
boolean createdDir = dir.mkdir(); // Non-recursive create only
String[] fileNames = dir.list();
File[] files = dir.listFiles();

I/O stream 추상화는 디스크나 다른 소스에서 오는 순차적 byte stream을 처리하는 방법으로 java 1.0에 존재했다. 이 API의 핵심은 추상 클래스 쌍 InputStreamOutputStream이다.

file을 byte stream 수준으로 다루는 방법은 FileInputStreamFileOutputStream이다. 아래는 ASCII 97에 해당하는 문자를 찾는 방법이다.

try (var is = new FileInputStream("/Users/ben/cluster.txt")) {
  byte[] buf = new byte[4096];
  int len, count = 0;
  while ((len = is.read(buf)) > 0) {
    for (int i = 0; i < len; i = i + 1) {
      if (buf[i] == 97) {
        count = count + 1;
      }
    }
  }
  System.out.println("'a's seen: "+ count);
} catch (IOException e) {
  e.printStackTrace();
}

이는 data를 저수준인 byte stream으로 다루기 때문에, 사람이 읽기 편하고 다루기 쉽도록 string으로 다루도록 하는 방법이 있어야 한다. 이를 위해 고수준의 ReaderWriter class이 있다.

ReaderWriter class는 byte stream class를 overlay하고 저수준 I/O stream 처리의 필요성을 없애기 위해 설계되었다. 다음과 같이 서로 겹쳐서 사용하는 여러 하위 클래스를 가지고 있다.

  • FileReader
  • BufferedReader
  • InputStreamReader
  • FileWriter
  • PrintWriter
  • BufferedWriter

file에서 라인마다 읽어서 출력하기 위해서는 FileReader로 file을 읽고, BufferedReader로 line별 read를 할 수 있다. BufferedReader는 버퍼링을 지원하기 때문에 성능이 더 우수하다.

try (var in = new BufferedReader(new FileReader(filename))) {
  String line;

  while((line = in.readLine()) != null) {
    System.out.println(line);
  }
} catch (IOException e) {
  // Handle FileNotFoundException, etc. here
}

데이터를 file에 쓸 때는 FileWriter를 사용하면 되는데, 성능 상의 이유로 버퍼링을 지원하기 위해서 BufferedWriter를 같이 쓴다. BufferedWriterFileWriter는 문자만 지원하고 데이터를 쓸 때 \n을 써주는 과정과 같이 귀찮은 작업들을 해주어야 한다. 이를 해결하기 위해서 PrintWriter를 사용하면 된다.

var f = new File(System.getProperty("user.home")
 + File.separator + ".bashrc");
try (var out = new PrintWriter(new BufferedWriter(new FileWriter(f)))) {
  out.println("## Automatically generated config file. DO NOT EDIT");
  // ...
} catch (IOException iox) {
  // Handle exceptions
}

참고로 지금까지 file과 관련된 stream, Reader/Writer는 모두 close를 해주어야 하기 때문에 try-with-resource 문법을 사용하였다. try-with-resource를 사용하는 방법은 AutoCloseable을 구현한 객체이면 자동으로 close 메서드를 호출시켜준다.

이렇게 classic I/O의 대표 주자인 File 객체와 InputStream/OutputStream, Reader/Writer 각각의 기능들이 너무 분산되어 있고, 통일되지 않았으며 누락된 기능들이 존재했다. 또한, 플랫폼 별 OS 기능도 없고 파일 시스템에 대한 통합 모델이 없다는 문제점이 있었다.

Modern Java I/O

Java7부터 new I/O API가 나왔고 이를 NIO.2라고 부른다. java.nio.file package에 있으며 두 가지 중요한 부분들이 있다. 하나는 Path로 file location을 표현하기 위한 클래스이다. 두번째는 Files class로 file과 file system을 다루기 위한 static method를 제공해준다.

Files를 사용하여 file을 copy하는 코드를 다음과 같이 간단하게 만들 수 있다.

var inputFile = new File("input.txt");
try (var in = new FileInputStream(inputFile)) {
  Files.copy(in, Path.of("output.txt"));
} catch(IOException ex) {
  ex.printStackTrace();
}

다음은 Files와 관련된 자주 사용되는 매서드들을 정리한 것이다.

Path source, target;
Attributes attr;
Charset cs = StandardCharsets.UTF_8;

// Creating files
//
// Example of path --> /home/ben/.profile
// Example of attributes --> rw-rw-rw-
Files.createFile(target, attr);

// Deleting files
Files.delete(target);
boolean deleted = Files.deleteIfExists(target);

// Copying/moving files
Files.copy(source, target);
Files.move(source, target);

// Utility methods to retrieve information
long size = Files.size(target);

FileTime fTime = Files.getLastModifiedTime(target);
System.out.println(fTime.to(TimeUnit.SECONDS));

Map<String, ?> attrs = Files.readAttributes(target, "*");
System.out.println(attrs);

// Methods to deal with file types
boolean isDir = Files.isDirectory(target);
boolean isSym = Files.isSymbolicLink(target);

// Methods to deal with reading and writing
List<String> lines = Files.readAllLines(target, cs);
byte[] b = Files.readAllBytes(target);

var br = Files.newBufferedReader(target, cs);
var bwr = Files.newBufferedWriter(target, cs);

var is = Files.newInputStream(target);
var os = Files.newOutputStream(target);

일부 메서들은 세밀한 조작을 위해 옵션으로 파라미터를 받아 두었는데, Files.copy의 경우 file이 실제로 존재하면 복사를 하지 않는 문제가 있다. 따라서, 다음과 같이 StandardCopyOption을 사용하여 옵션을 주어야 한다.

Files.copy(Path.of("input.txt"), Path.of("output.txt"),
           StandardCopyOption.REPLACE_EXISTING);

REPLACE_EXISTING을 해야 이미 파일이 있는 경우 덮어쓰기가 가능하다. LinkOption도 있는데, 이 값을 사용하면 symbolic link에 대해서 어떻게 처리할 지 지정할 수 있다.

Path

Path는 filesystem에 있는 file을 지정하기 위해서 사용하는데, 다음을 제공한다.

  1. System 의존성
  2. 계층
  3. path 요소의 일련의 구성
  4. exist, deleted 등의 확인 메서드

참고로 Paths라는 클래스도 있는데, 이는 Java7 당시에는 interface에 static class를 담지 못해서 만들어진 클래스이다. java 17부터는 Path interface method가 권장되며 Paths는 향후 지원이 중단될 수 있다.

Path 클래스를 생성하는 방법은 of라는 간단한 방법으로 생성이 가능하다. string으로 path를 직접 받아서 쓸 수도 있으며 URI 객체를 만들어 사용할 수도 있다.

var p = Path.of("/Users/ben/cluster.txt");
var p2 = Path.of(new URI("file:///Users/ben/cluster.txt"));
System.out.println(p2.equals(p));

File f = p.toFile();
System.out.println(f.isDirectory());

Path p3 = f.toPath();
System.out.println(p3.equals(p));

위의 예제는 PathFile이 서로 간단하게 교환 가능하고 리팩토링이 가능하다는 것을 보여준다.

이처럼 Path는 기존의 file system 로직과 호환 가능하도록 설계 되었는데, 기존의 ReaderWriter 클래스와 연결하기 위해서는 Files에서 제공하는 bridge method들을 사용하면 된다.

var logFile = Path.of("/tmp/app.log");
try (var writer =
       Files.newBufferedWriter(logFile, StandardCharsets.UTF_8,
                               StandardOpenOption.WRITE,
                               StandardOpenOption.CREATE)) {
  writer.write("Hello World!");
  // ...
} catch (IOException e) {
  // ...
}

여기서도 옵션으로 StandardOpenOption을 준 것을 볼 수 있다. 파일이 없다면 새로 생성하고, 있다면 기존의 file에 데이터를 추가해준다.

다음은 jar 파일을 기반으로 분석을 하는 코드이다. jar 파일을 Path로 열고 file system을 새로 만든 다음에 해당 file system에서 path를 가져오는 방식이다.

var tempJar = Path.of("sample.jar");
try (var workingFS =
      FileSystems.newFileSystem(tempJar)) {

  Path pathForFile = workingFS.getPath("/hello.txt");
  Files.write(pathForFile,
              List.of("Hello World!"),
              Charset.defaultCharset(),
              StandardOpenOption.WRITE, StandardOpenOption.CREATE);
}

classic I/O 방식의 가장 큰 문제점 중 하나는 native 지원과 고성능 I/O의 부족이라는 것이다. Java 1.4에 해당 solution이 추가되었고, Java New I/O(NIO) API에 추가되었다.

NIO Channels and Buffers

NIO buffer는 고성능 I/O를 위해서 저수준의 추상화를 제공한다. NIO buffer는 특정 primitive type을 가진 linear한 sequence에 대한 container를 제공한다. 가장 많이 사용되는 예제가 바로 ByteBuffer이다.

ByteBuffer는 byte 시퀸스로 성능을 위해 byte[]을 사용하는 방식의 대안으로 여겨질 수 있다. ByteBuffer는 최선의 성능을 위해서 JVM이 동작하는 platform의 native 능력을 사용한다.

이러한 방식을 direct buffers case라고 하는데, direct buffer는 java heap memory를 사용하지 않고 native memory에 직접 할당된다. 따라서, 이들은 java의 다른 object와 달리 garbage collection 대상이 되지 않는다.

ByteBuffer를 직접 만드는 방법은 3가지가 있다.

  1. ByteBuffer.allocateDirect: direct buffer를 사용하는 Byte buffer를 만들어준다.
  2. ByteBuffer.allocate: heap버전의 byte buffer를 만들어주지만 사용되진 않는다.
  3. ByteBuffer.wrap: 기존의 있던 byte[]를 감싸서 heap버전의 byte buffer를 만들어준다.
var b = ByteBuffer.allocateDirect(65536);
var b2 = ByteBuffer.allocate(4096);

byte[] data = {1, 2, 3};
ByteBuffer b3 = ByteBuffer.wrap(data);

byte buffer는 byte에 대한 저수준 동작을 위한 메서드들을 제공해준다.

b.order(ByteOrder.BIG_ENDIAN);

int capacity = b.capacity();
int position = b.position();
int limit = b.limit();
int remaining = b.remaining();
boolean more = b.hasRemaining();

byte buffer에 데이터를 넣는 것은 put을 사용하면 되고, 얻어오는 것은 get을 사용하면 된다.

b.put((byte)42);
b.putChar('x');
b.putInt(0xc001c0de);

b.put(data);
b.put(b2);

double d = b.getDouble();
b.get(data, 0, data.length);

datab2처럼 bulk로 데이터를 넘기는 것이 성능적으로 더 좋다.

buffer들은 in-memory를 사용하기 때문에 file이나 network와 같은 작업을 하기 위해서는 Channel이 필요하다. Channels는 connection을 표현하는 데 read, write 연산을 지원할 수 있어야 한다. Files와 Sockets은 channel의 대표적인 예제이다.

Channel의 핵심 이해는 다음과 같다.

  1. channel에서 byte를 읽어서 buffer로 옮긴다.
  2. buffer로부터 받은 byte를 channel로 써준다.

아래는 큰 사이즈의 file을 16M 청크 단위로 읽는 코드이다.

FileInputStream fis = getSomeStream();
boolean fileOK = true;

try (FileChannel fchan = fis.getChannel()) {
  var buffy = ByteBuffer.allocateDirect(16 * 1024 * 1024);
  while(fchan.read(buffy) != -1 || buffy.position() > 0 || fileOK) {
    fileOK = computeChecksum(buffy);
    buffy.compact();
  }
} catch (IOException e) {
  System.out.println("Exception in I/O");
}

이는 heap을 최소화하여 사용하기 때문에 복사 동작에 있어서 효율이 굉장히 빠르다.

비동기 I/O

AsynchronousFileChannel을 사용하면 Futute 스타일이나 callback 스타일로 비동기 I/O를 제공할 수 있다.

  • AsynchronousFileChannel: file I/O를 위해 존재
  • AsynchronousScoketChannel: client socket I/O를 존재
  • AsynchronousServerSocketChannel: 비동기적인 socket으로 들어오는 connection들을 받아낸다.

futute 기반의 비동기 I/O는 다음과 같이 두 가지 메서드들을 제공한다.
1. isDone: task가 끝났는 지 아닌 지 boolean을 반환한다.
2. get: result를 반환한다. 만약 작업이 안 끝났다면 block된다.

다음은 100MB 정도 되는 file 데이터를 비동기적으로 처리하는 방식이다.

try (var channel =
         AsynchronousFileChannel.open(Path.of("input.txt"))) {
  var buffer = ByteBuffer.allocateDirect(1024 * 1024 * 100);
  Future<Integer> result = channel.read(buffer, 0);

  while(!result.isDone()) {
    // Do some other useful work....
  }

  System.out.println("Bytes read: " + result.get());
}

callback style의 비동기 I/O는 CompletionHandler를 기반으로 동작한다. 이는 이 두가지 메서드를 정의하는데, complatedfailed으로 작업 이후 callback으로 작업이 성공했는 지 실패했는 지를 알려주기 위해 사용된다.

byte[] data = {2, 3, 5, 7, 11, 13, 17, 19, 23};
ByteBuffer buffy = ByteBuffer.wrap(data);

CompletionHandler<Integer,Object> h =
  new CompletionHandler<>() {
    public void completed(Integer written, Object o) {
      System.out.println("Bytes written: " + written);
    }

    public void failed(Throwable x, Object o) {
      System.out.println("Asynch write failed: "+ x.getMessage());
    }
  };

try (var channel =
       AsynchronousFileChannel.open(Path.of("primes.txt"),
          StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

  channel.write(buffy, 0, null, h);

  // Give the CompletionHandler time to run before foreground exit
  Thread.sleep(1000);
}

AsynchronousFileChannel object는 I/O 작업을 계속하기 위해서 background thread pool을 사용하므로, 원래의 thread는 다른 task들을 작업할 수 있다. 기본적으로 background thread pool은 런타임에서 제공하고 관리하는 thread pool을 사용하고 필요한 경우 application에서 관리하는 thread pool을 사용하도록 할 수 있다. 하지만 이는 거의 필요하지 않다.

java.nio.channels 패키지의 SelectableChannelSelecto를 사용하면 단일 thread에서 여러 channel을 관리하고 어떤 channel이 읽기 또는 쓰기를 위해 준비되었는지 확인할 수 있게 한다.

Watch server와 Directory searching

nio는 WatchService를 제공하여 특정 디렉터리에서 발생하는 file관련 event를 모니터링을 할 수 있다.

try {
  var watcher = FileSystems.getDefault().newWatchService();

  var dir = FileSystems.getDefault().getPath("/home/ben");
  dir.register(watcher,
                StandardWatchEventKinds.ENTRY_CREATE,
                StandardWatchEventKinds.ENTRY_MODIFY,
                StandardWatchEventKinds.ENTRY_DELETE);

  while(!shutdown) {
    WatchKey key = watcher.take();
    for (WatchEvent<?> event: key.pollEvents()) {
      Object o = event.context();
      if (o instanceof Path) {
        System.out.println("Path altered: "+ o);
      }
    }
    key.reset();
  }
}

direcory stream을 사용하면 현재 directory의 모든 file들을 볼 수 있다. 다음은 특정 directory에 있는 java 파일의 정보들을 읽어오는 코드이다.

try(DirectoryStream<Path> stream =
    Files.newDirectoryStream(Path.of("/opt/projects"), "*.java")) {
  for (Path p : stream) {
    System.out.println(p +": "+ Files.size(p));
  }
}

위 API의 문제점 중 하나는 glbo syntax에 맞는 요소들을 반환한다는 것이다. 만약 directory 안에 있는 모든 file들을 가져오고 싶다면 Files.findFiles.walk 메서드를 사용하

var homeDir = Path.of("/Users/ben/projects/");
Files.find(homeDir, 255,
  (p, attrs) -> p.toString().endsWith(".java"))
     .forEach(q -> {System.out.println(q.normalize());});

0개의 댓글