본문 바로가기
Old Posts/Java

Java NIO - 파일 읽기 쓰기 (FileChannel, Path)

by A6K 2021. 1. 2.

Java NIO를 이용해서 파일을 다룰 수 있는 클래스와 예제 코드를 소개하겠다.

1. FileChannel

파일에서 데이터를 읽고 쓸 수 있는 채널이다. 파일 채널을 생성하기 위해서는 IO의 FileInputStream, FileOutputStream의 getChannel()메소드를 호출하거나 RandomAccessFile의 getChannel() 메소드로 열 수도 있다.

// RandomAccessFile 이용
RandomAccessFile file = new RandomAccessFile("testFile.txt", "rw");
FileChannel channel1 = file.getChannel();

// FileInputStream 이용
FileInputStream fis = new FileInputStream("testFile.txt");
FileChannel channel2 = fis.getChannel();

// FileOutputStream 이용
FileInputStream fos = new FileInputStream("testFile.txt");
FileChannel channel3 = fos.getChannel();

아니면 FileChannel 클래스에 정적 메소드로 제공되는 open()을 이용하는 방법도 있다.

FileChannel channel = FileChannel.open(Paths.get("testFile.txt"), StandardOpenOption.READ, StandardOpenOption.WRITE);

잠시후에 설명하겠지만 NIO에서 파일 시스템을 이용하기 쉽게 만들어주는 Path객체를 첫 번째 인자로 준다. 두 번째 인자는 open() 메소드의 옵션으로 줄 수 있는 값으로 다음과 같은 값을 명시할 수 있다.

Enum 설명
StandardOpenOption.READ 읽기전용 파일 열기
StandardOpenOption.WRITE 쓰기전용 파일 열기
StandardOpenOption.CREATE 파일이 없으면 생성
StandardOpenOption.CREATE_NEW 파일이 없으면 생성, 이미 존재하면 예외 발생
StandardOpenOption.APPEND EOF(End-Of-File)부터 데이터를 추가, (WRITE, CREATE와 함께 사용가능)
StandardOpenOption.DLETE_ON_CLOSE 채널이 닫히면 파일도 삭제 (임시파일)
StandardOpenOption.TRUNCATE_EXISTING 파일의 크기를 0으로 잘라냄

파일 채널은 논블로킹(Non-blocking)으로 변경할 수 없다. 파일에 대한 연산은 항상 블로킹 모드로 동작한다.

1.1 파일 읽기(read)

파일의 데이터를 읽기 위해서는 FileChannel의 read() 메소드를 이용하면 된다.

ByteBuffer buffer = ByteBuffer.allocate(64);

RandomAccessFile file = new RandomAccessFile("testFile.txt", "r");
FileChannel channel = file.getChannel();

int bytesRead = channel.read(buffer);
while (bytesRead != -1) {

	System.out.println("Read " + bytes read);
	buffer.flip();

	while(buffer.hasRemaining()){
		System.out.print((char) buffer.get());
	}

	buffer.clear();
	bytesRead = channel.read(buffer);
}
file.close();

파일의 데이터를 읽기 위한 버퍼를 할당받고, read() 메소드로 버퍼에 데이터를 읽어 들인다. 파일에서 읽은 데이터는 버퍼의 position부터 저장된다.

파일에서 더 이상 읽을 데이터가 없다면 -1 이 리턴된다.

1.2 파일 쓰기(write)

파일의 데이터를 읽기 위해서는 FileChannel의 write() 메소드를 이용하면 된다.

RandomAccessFile file = new RandomAccessFile("/tmp/test.txt", "rw");
FileChannel channel = file.getChannel();
String newData = "New String data";

ByteBuffer buffer = ByteBuffer.allocate(64);
buffer.clear();
buffer.put(newData.getBytes());

buffer.flip();

while(buffer.hasRemaining()) {
	channel.write(buffer);
}
		
file.close();

파일에 데이터를 쓰기 위해서 write() 메소드로 버퍼의 데이터를 파일로 쓴다. 이 때, 얼마만큼의 데이터가 파일채널에 쓰여질지는 매번 다르기 때문에 버퍼에 있는 데이터가 모두 사용될 때까지 반복적으로 write()를 수행해주면 된다.

1.3 파일 포지션(position)

파일 연산을 할 때, 파일의 어떤 부분에 읽기 및 쓰기가 진행되는지 알고 있어야 한다. 파일채널에서는 position() 메소드를 이용해서 현재 파일 오프셋을 가져올 수 있고, position(long pos) 메소드를 이용해서 특정 오프셋으로 seek 할 수 있다.

long pos = channel.position();

channel.position(pos + 100);

position 값이 EOF인 상황에서 read()를 수행하면 -1 이 리턴된다. 즉, 파일의 마지막 부분에 position이 있는 상황에서 read() 메소드를 호출하면 리턴 값으로 -1을 받게 된다.

파일의 사이즈보다 큰 position 값을 설정하고 write() 할 경우, 그 만큼 파일의 사이즈가 확장된다. 만약 파일의 크기가 10이고, position 값을 20으로 설정한 다음 write() 호출을 한다면 10~20 사이에는 null 값(0x00)이 채워진다. 이 공간을 파일홀(File Hole)이라고 한다.

1.4 파일의 크기(size)

size() 메소드를 이용해서 채널이 연결되어 있는 파일의 크기를 알아올 수도 있다.

long size = channel.size();

1.5 파일 자르기(trucate)

truncate() 메소드를 이용해서 파일을 특정 사이즈로 잘라버릴 수도 있다.

channel.truncate(1024);

1.6 force()

파일 입출력에서 항상 생각해야 할 것이 파일 시스템 캐시다. 파일에 데이터를 썼다고 해서 무조건 하드디스크나 SSD 같은 저장장치에 데이터가 쓰여진 것은 아니다. 파일 시스템은 성능상 이슈로 캐시 영역을 가지고 있다. 파일에 데이터를 쓰면 우선 파일 시스템 캐시까지만 데이터가 쓰여지고 운영체제가 적당한 시기에 저장장치로 쓰게 된다.

문제는 파일에 데이터를 쓰는 행위가 데이터의 영속적인 보관을 위해서는 디스크로 쓰여짐을 보장해야한다. DBMS 같은 소프트웨어의 경우 데이터가 저장장치로 쓰여짐을 보장하고 클라이언트 리턴을 줘야하는 경우다.

channel.force(true);

FileChannel의 force() 메소드를 이용해서 데이터가 디스크로 내려감을 보장할 수 있다. 이 때, 파라미터로 주는 값은 파일의 컨텐츠 뿐만 아니라 메타 정보까지도 디스크로 내릴 것인지 여부다.

2. Path

파일 시스템에 저장되어 있는 파일은 최상위 디렉토리인 '루트(/)'부터 찾아 내려갈 수 있는 경로(Path)를 갖는다. 파일의 경로는 절대경로일 수도 있고 상대경로일 수도 있다. 파일의 경로는 문자열로 저장되는데, Java NIO에서는 파일의 경로를 좀 더 편하게 다룰 수 있도록 Paths, Path 같은 클래스, 인터페이스를 제공해준다.

// 절대경로
Path path = Paths.get("/tmp/test.txt");

// 상대경로
Path path = Paths.get("/tmp", "test.txt");

Path 인터페이스를 얻기 위해서는 Paths 클래스의 정적 메소드인 get()을 사용할 수 있다. 이 때, 파일의 절대경로를 이용할 수도 있고, 상대경로로 이용할 수도 있다. 상대경로로 이용할 때에는 Paths.get(basePath, relativePath) 형태로 호출하면 된다.

리눅스 사용자들에게는 익숙하겠지만 dot(.)과 dot-dot(..) 문자를 경로에 사용할 수도 있다. dot(.)은 현재 디렉토리를 의미하고, dot-dot(..)은 부모 디렉토리를 의미한다.

Path currentDir = Paths.get(".");
System.out.println(currentDir.normalize().toAbsolutePath());

이런 코드를 이용해서 현재 작업 디렉토리를 가져오는 예제를 작성할 수도 있다.

2.1 상대경로 얻어오기 - relativize()

"../../../subdir1"이 된다. 이 경로를 relativize() 메소드를 이용해서 얻어보면

Path path = Paths.get("/tmp/subdir1");
Path basePath     = Paths.get("/tmp/subdir2/subsubdir/test.txt");

Path basePathToPath = basePath.relativize(path);

System.out.println(basePathToPath);

이런 코드를 작성할 수 있다.

2.2 normalize()

파일의 경로를 구성하는 요소 중에 dot(.)과 dot-dot(..)이 있다고 했다. 파일의 상대 경로를 따라갈 때 사용하는 특수 엘리먼트이다. 문제는 불필요하게 dot(.)과 dot-dot(..)이 늘어날 수 있다는 점이다.

"/tmp/dir/../dir/../dir/../dir/.."라는 경로르 생각해보자. 중간에 들어간 dot-dot과 dir로 내려가는 엘리먼트들은 불필요하다. 이 경로는 결국 "/tmp" 디렉토리를 의미한다.

normalize() 메소드는 경로에 있는 dot(.)과 dot-dot(..)을 풀어주는 역할을 한다.

이 밖에도 많은 메소드들이 있다. 자세한 것은 javadoc을...

댓글