Java 7 에는 비동기 방식의 File I/O를 지원하는 AsynchronousFileChannel이 추가되었다.
비동기 방식이므로 File I/O에 소요되는 시간 동안 다른 처리를 할 수 있다는 장점이 있다.
특히 용량이 큰 파일일 수록 File I/O에 소요되는 시간이 클 수 있으므로, 비동기 방식의 장점을 더 살릴 수 있다.
AsynchronousFileChannel을 사용하는 일반적인 방법
AsynchronousFileChannel로 파일을 읽으려면 아래의 메서드를 사용하면 된다.
1 2 3 4
publicabstract <A> voidread(ByteBuffer dst, long position, A attachment, CompletionHandler<Integer,? super A> handler)
CompletionHandler를 사용하는 대신 Future를 반환하는 read() 메서드도 있는데, while (!result.isDone()) { … } 와 같은 식으로 계속 완료 여부를 polling하는 Future 방식보다는(Future도 Future.get()을 사용하면 굳이 polling 하지 않아도 된다.) CompletionHandler 방식이 더 간지나므로 Future 방식은 여기서는 다루지 않는다.
암튼 API를 보니 파일의 position 위치에서 부터 읽은 데이터를 dst 라는 ByteBuffer에 담고, 성공/실패 시 CompletionHandler에 구현된 callback 메서드를 호출하는구나.. 라고 이해되는데, attachment는 뭘까? API문서에서도 그냥 아래와 같이 뜬구름 잡는 소리만 있다.
attachment - The object to attach to the I/O operation; can be null
attachment가 뭔지 궁금하지만, null 일 수도 있다고 하니 일단 그냥 null로 둬보자.
일반적인 코드
nio의 Channel은 데이터의 I/O에 byte[] 대신 Buffer를 사용한다. AsynchronousFileChannel도 마찬가지로 ByteBuffer를 사용하며, AsynchronousFileChannel를 이용해서 파일을 읽어들이는 코드는 다음과 같다.
privatevoidasyncFileChannelTest(Path sourceFilePath, boolean isWrite)throws IOException { try ( AsynchronousFileChannel asyncFileChannel = AsynchronousFileChannel.open( sourceFilePath, StandardOpenOption.READ ); ) { System.err.println("AsynchronousFileChannel 테스트 시작"); long startTime = System.nanoTime(); long fileSize = asyncFileChannel.size(); ByteBuffer byteBuffer = ByteBuffer.allocate((int)fileSize); System.err.println("AsynchronousFileChannel.read() 호출"); asyncFileChannel.read( byteBuffer, 0, null, new CompletionHandler<Integer, Object>() { @Override publicvoidcompleted(Integer result, Object object){ if (result == -1) { long endTime = System.nanoTime(); System.err.println("비정상 종료 : " + (endTime - startTime) + " ns elapsed."); return; } byteBuffer.flip(); byteBuffer.mark(); if (isWrite) System.out.write(byteBuffer.array(), 0, result); byteBuffer.reset(); long endTime = System.nanoTime(); System.err.println("AsynchronousFileChannel.read() 완료 : " + (endTime - startTime) + " ns elapsed."); } @Override publicvoidfailed(Throwable exc, Object object){ exc.printStackTrace(); } } ); System.err.println("AsyncFileChannel I/O 진행 중에는 다른 작업도 할 수 있지롱"); System.err.println("그동안 그리스에도 다녀오고"); System.err.println("크로아티아에도 갔다오자"); } }
실행하면 다음과 같은 결과가 나온다.
1 2 3 4 5 6
AsynchronousFileChannel 테스트 시작 AsynchronousFileChannel.read() 호출 AsyncFileChannel I/O 진행 중에는 다른 작업도 할 수 있지롱 그동안 그리스에도 다녀오고 크로아티아에도 갔다오자 AsynchronousFileChannel.read() 완료 : 320668276 ns elapsed.
위에서 보는 것처럼 File I/O가 처리되는 시간 동안에도 메시지를 콘솔에 출력하는 것처럼 다른 작업을 수행할 수 있다.
한 가지 마음에 걸리는 것은 ByteBuffer의 크기를 파일 사이즈와 같게 한다는 점이다. 용량이 클수록 비동기 방식의 장점이 더 드러난다고는 하지만, 용량이 기가 단위로 정말 거대한 파일을 통으로 메모리에 모두 담아 처리하면 OutOfMemoryError를 유발할 수도 있다.
위의 그래프는 Java SDK에 번들로 제공되는 VisualVM을 사용해서 메모리 사용을 모니터링한 것인데, 160메가 정도의 파일로 테스트 해본 결과 위와 같이 메모리 사용이 쭈욱~ 올라간 것을 알 수 있다. 기가 단위의 파일이라면 역시나 OutOfMemoryError가 발생할 것 같다.
참고로 일반적인 main()으로 실행하면 금방 종료되어 위처럼 캡쳐 등이 불편해서, 편의상 간단하게 SpringBoot로 만든 웹 애플리케이션에서 테스트를 진행했다.
암튼 메모리 사용량을 적게 할 수 있도록 작은 크기의 ByteBuffer를 생성해서 이를 재활용하는 것이 좋겠다.
작은 크기의 ByteBuffer을 재사용해서 OutOfMemoryError 를 막는 방법
파일 크기 만큼의 ByteBuffer를 사용하는 대신 작은 크기의 ByteBuffer에 데이터를 담는 일을 반복하면 OutOfMemoryError 걱정없이도 파일 내용을 읽을 수 있다. 그런데 그냥 쉽게 되는 것은 아니고 손 봐줘야 할 곳이 몇 군데 있다.
반복 문제
비동기 방식이라 Handler를 통해서 구현하므로 통상적인 방법처럼 단순하게 while 문으로는 해결할 수 없다.
그렇다고 엄청난 고도의 방법이 필요한 것은 아니다. while을 쓰는 대신 CompletionHander내에서 다시 asynchronousFileChannel.read()를 호출하게 하면 될 것 같다.
읽을 위치 지정 문제
asynchronousFileChannel.read()를 다시 호출할 때 주의해야할 점이 있다. 바로 asynchronousFileChannel.read()의 두번째 파라미터인 position 값의 적절한 설정이다. ByteBuffer 사이즈만큼 읽은 후에 다시 읽을 때는, 앞에서 읽고난 위치에서부터 다시 읽기 시작해야 중복 또는 누락 없이 정확하게 파일 내용을 읽을 수 있다.
이 역시도 어려운 문제는 아니다. ByteBuffer 크기에 반복회수를 곱해준 값을 두번째 파라미터로 넘겨주면 되겠다. long 타입의 iterations라는 변수로 반복회수를 관리하자.
그런데, 한가지 문제가 있다. 반복회수는 익명 내부 클래스인CompletionHandler 내의 completed() 메서드 내에서 증가시켜야 하는데, 내부 클래스의 메서드에서는 바깥 클래스의 변수의 값을 직접 변경할 수 없다. 그래서 단순히 primitive 타입의 iterations을 사용하면 내부 클래스의 메서드에서 증가시킬 수 없으므로, primitive 타입인 long 대신에 객체인 Long 타입을 쓰면 값을 변경할 수 있다. 하지만 이 Long 타입의 변수를 CompletionHandler에게 어떻게 넘겨주지?
아하 attachment
그렇다. 우리에겐 미지의 파라미터인 attachment가 있었다.
Java API Doc에 뜬구름 잡는 설명만 있었던 attachment는 이럴 때 쓰라는 넘이었구나.. asynchronousFileChannel.read()의 세번째 파라미터에 null 대신 Long 타입의 iterations라는 변수를 넘겨주자.
privatevoidasyncFileChannelTest(Path sourceFilePath, boolean isWrite)throws IOException { try ( AsynchronousFileChannel asyncFileChannel = AsynchronousFileChannel.open( sourceFilePath, StandardOpenOption.READ ); ) { System.err.println("AsynchronousFileChannel 테스트 시작"); long startTime = System.nanoTime(); long fileSize = asyncFileChannel.size(); // ByteBuffer 크기를 8k로 축소 ByteBuffer byteBuffer = ByteBuffer.allocate(8 * 1024); // 반복 회수 관리용 변수 Long iterations = 0L; System.err.println("AsynchronousFileChannel.read() 호출"); asyncFileChannel.read( byteBuffer, 0, iterations, // null 대신 iterations 전달 new CompletionHandler<Integer, Long>() { // 타입 파라미터에 Object 대신 Long 전달 @Override publicvoidcompleted(Integer result, Long iterations){ // 타입 파라미터에 Object 대신 Long if (result == -1) { long endTime = System.nanoTime(); System.err.println("비정상 종료 : " + (endTime - startTime) + " ns elapsed."); return; } // 반복 회수 확인 System.err.println((iterations + 1) + "회차 반복"); byteBuffer.flip(); byteBuffer.mark(); if (isWrite) System.out.write(byteBuffer.array(), 0, result); byteBuffer.reset(); // 읽어들인 바이트수가 // 파일사이즈와 같거나(버퍼 크기와 파일 크기가 같은 경우) // 버퍼 사이즈보다 작다면 파일의 끝까지 읽은 것이므로 종료 처리 if (result == fileSize || result < byteBuffer.capacity()) { long endTime = System.nanoTime(); System.err.println("AsynchronousFileChannel.read() 완료 : " + (endTime - startTime) + " ns elapsed."); return; } // 읽을 내용이 남아있으므로 반복 회수를 증가 시키고 다시 읽는다. iterations++; asyncFileChannel.read(byteBuffer, result * iterations, iterations, this); } @Override publicvoidfailed(Throwable exc, Long iterations){ // 타입 파라미터에 Object 대신 Long exc.printStackTrace(); } } ); System.err.println("AsyncFileChannel I/O 진행 중에는 다른 작업도 할 수 있지롱"); System.err.println("그동안 그리스에도 다녀오고"); System.err.println("크로아티아에도 갔다오자"); } }
자 이제 실행해보면 메모리를 적게 쓰면서도 비동기 방식으로 파일 내용을 읽어올 것이다. 생각만해도 흐뭇하다. 얼른 실행해보자.
1 2 3 4 5 6 7 8 9 10
AsynchronousFileChannel 테스트 시작 AsynchronousFileChannel.read() 호출 AsyncFileChannel I/O 진행 중에는 다른 작업도 할 수 있지롱 그동안 그리스에도 다녀오고 크로아티아에도 갔다오자 1회차 반복 java.nio.channels.ClosedChannelException at sun.nio.ch.SimpleAsynchronousFileChannelImpl.implRead(SimpleAsynchronousFileChannelImpl.java:302) at sun.nio.ch.AsynchronousFileChannelImpl.read(AsynchronousFileChannelImpl.java:229) ... 이하 생략 ...
ㅋㅋㅋ 한 방에 될리가.. 에러 메시지를 보니 채널이 이미 닫혀있다고 한다. 응? 나 채널 닫은 적 없는데..
채널이 닫힌 이유
어디서 닫혔을까 하고 코드를 보니 AsynchronousFileChannel을 가져올 때, 자원 해제의 편의를 위해 Java 7에 도입된 try-with-resources 구문을 사용했다. 채널이 닫힌 이유를 알 것 같다.
파일 내용을 ByteBuffer에 성공적으로 한 번 읽어들인 후에 CompletionHandler내에서 다시 호출하는 read()는 System.err.println("크로아티아에도 갔다오자");가 실행되고 난 시점에 호출되는데, 이 시점에는 이미 try-with-resources를 빠져나온 후가 된다. 따라서 try-with-resources에 의해 자동으로 자원이 해제되면서 AsynchronousFileChannel가 닫히고, 그 다음에 이미 닫혀있는 AsynchronousFileChannel의 read()를 다시 호출하니까 채널이 이미 닫혀있다는 에러가 발생한 것이다.
원인은 알겠는데 해결은 또 어떻게 해야하나.
try-catch 적용
일단 AsynchronousFileChannel를 닫는 처리를 프로그래머가 직접 제어할 수 있도록 try-with-resources 부터 걷어내는 것이 순서일 것 같다. 그대신 적절한 위치에서 명시적으로 AsynchronousFileChannel.close()를 호출해서 자원 해제 처리를 확실히 해주면, 채널이 닫히지 않은 채로 비동기 방식의 반복을 수행하고, 적절한 위치에서 채널을 닫는 처리가 가능할 것 같다.
코드는 아래와 같이 바뀐다. 이번에 바뀐 내용은 //// 2차 변경와 같이 슬래쉬 4개로 주석처리했다.
privatevoidasyncFileChannelTest(Path sourceFilePath, boolean isWrite)throws IOException { //// try-with-resource 대신 try-catch-finally 적용 try { System.err.println("AsynchronousFileChannel 테스트 시작"); AsynchronousFileChannel asyncFileChannel = AsynchronousFileChannel.open( sourceFilePath, StandardOpenOption.READ ); long startTime = System.nanoTime(); long fileSize = asyncFileChannel.size(); // ByteBuffer 크기를 8k로 축소 ByteBuffer byteBuffer = ByteBuffer.allocate(8 * 1024); // 반복 회수 관리용 변수 Long iterations = 0L; System.err.println("AsynchronousFileChannel.read() 호출"); asyncFileChannel.read( byteBuffer, 0, iterations, // null 대신 iterations 전달 new CompletionHandler<Integer, Long>() { // 타입 파라미터에 Object 대신 Long 전달 @Override publicvoidcompleted(Integer result, Long iterations){ // 타입 파라미터에 Object 대신 Long if (result == -1) { long endTime = System.nanoTime(); System.err.println("비정상 종료 : " + (endTime - startTime) + " ns elapsed."); //// asyncFileChannel 닫기 closeAsyncFileChannel(asyncFileChannel); return; } // 반복 회수 확인 System.err.println((iterations + 1) + "회차 반복"); byteBuffer.flip(); byteBuffer.mark(); if (isWrite) System.out.write(byteBuffer.array(), 0, result); byteBuffer.reset(); // 읽어들인 바이트수가 // 파일사이즈와 같거나(버퍼 크기와 파일 크기가 같은 경우) // 버퍼 사이즈보다 작다면 파일의 끝까지 읽은 것이므로 종료 처리 if (result == fileSize || result < byteBuffer.capacity()) { long endTime = System.nanoTime(); System.err.println("AsynchronousFileChannel.read() 완료 : " + (endTime - startTime) + " ns elapsed."); //// asyncFileChannel 닫기 closeAsyncFileChannel(asyncFileChannel); return; } // 읽을 내용이 남아있으므로 반복 회수를 증가 시키고 다시 읽는다. iterations++; asyncFileChannel.read(byteBuffer, result * iterations, iterations, this); } @Override publicvoidfailed(Throwable exc, Long iterations){ // 타입 파라미터에 Object 대신 Long exc.printStackTrace(); //// asyncFileChannel 닫기 closeAsyncFileChannel(asyncFileChannel); } } ); System.err.println("AsyncFileChannel I/O 진행 중에는 다른 작업도 할 수 있지롱"); System.err.println("그동안 그리스에도 다녀오고"); System.err.println("크로아티아에도 갔다오자"); } catch (Exception e) { e.printStackTrace(); thrownew RuntimeException(e); //// 상황에 맞는 예외 처리 필요 } } //// asyncFileChannel 닫기 privatevoidcloseAsyncFileChannel(AsynchronousFileChannel asyncFileChannel){ if (asyncFileChannel != null && asyncFileChannel.isOpen()) { try { asyncFileChannel.close(); } catch (IOException e) { e.printStackTrace(); thrownew RuntimeException(e); //// 상황에 맞는 예외 처리 필요 } } }
실행하면 드디어!! 다음과 같이 정상적으로 표시된다.
1 2 3 4 5 6 7 8 9 10 11 12
AsynchronousFileChannel 테스트 시작 AsynchronousFileChannel.read() 호출 AsyncFileChannel I/O 진행 중에는 다른 작업도 할 수 있지롱 그동안 그리스에도 다녀오고 크로아티아에도 갔다오자 1회차 반복 2회차 반복 3회차 반복 ... 19496회차 반복 19497회차 반복 AsynchronousFileChannel.read() 완료 : 844209043 ns elapsed.
메모리 사용량을 확인해보면 아래와 같이 거의 미미한 수준의 변화만 있을 뿐이다.
attachment의 활용
앞에서 단순히 Long 타입의 변수를 attachment로 활용해서 반복 회수를 관리했는데, attachment에 다른 객체를 사용하면 더 많은 정보를 관리할 수 있다. 예를 들어, 읽어들인 바이트수를 attachment에 사용된 객체에 계속 누적하면, 파일 읽기 완료 후 읽어들인 바이트수와 실제 파일 크기를 비교할 수도 있다.
privatevoidasyncFileChannelTest(Path sourceFilePath, boolean isWrite)throws IOException { //// try-with-resource 대신 try-catch-finally 적용 try { System.err.println("AsynchronousFileChannel 테스트 시작"); AsynchronousFileChannel asyncFileChannel = AsynchronousFileChannel.open( sourceFilePath, StandardOpenOption.READ ); long startTime = System.nanoTime(); long fileSize = asyncFileChannel.size(); // ByteBuffer 크기를 8k로 축소 ByteBuffer byteBuffer = ByteBuffer.allocate(8 * 1024); ////// attachment 용 객체 classAsyncIOResultInfo{ long iterations = 0L; long totalBytesRead = 0L; } AsyncIOResultInfo asyncIOResultInfo = new AsyncIOResultInfo(); System.err.println("AsynchronousFileChannel.read() 호출"); asyncFileChannel.read( byteBuffer, 0, asyncIOResultInfo, ////// iterations 대신 asyncIOResultInfo 전달 new CompletionHandler<Integer, AsyncIOResultInfo>() { ////// 타입 파라미터에 Long 대신 AsyncIOResultInfo 전달 @Override publicvoidcompleted(Integer result, AsyncIOResultInfo asyncIOResultInfo){ ////// Long 대신 AsyncIOResultInfo 전달 if (result == -1) { long endTime = System.nanoTime(); System.err.println("비정상 종료 : " + (endTime - startTime) + " ns elapsed."); //// asyncFileChannel 닫기 closeAsyncFileChannel(asyncFileChannel); return; } // 반복 회수 확인 System.err.println((asyncIOResultInfo.iterations + 1) + "회차 반복"); ////// iterations 대신 asyncIOResultInfo.iterations ////// 읽어들인 바이트수 누적 asyncIOResultInfo.totalBytesRead += result; byteBuffer.flip(); byteBuffer.mark(); if (isWrite) System.out.write(byteBuffer.array(), 0, result); byteBuffer.reset(); // 읽어들인 바이트수가 // 파일사이즈와 같거나(버퍼 크기와 파일 크기가 같은 경우) // 버퍼 사이즈보다 작다면 파일의 끝까지 읽은 것이므로 종료 처리 if (result == fileSize || result < byteBuffer.capacity()) { long endTime = System.nanoTime(); System.err.println("AsynchronousFileChannel.read() 완료 : " + (endTime - startTime) + " ns elapsed."); ////// 총 읽어들인 바이트수 비교 System.err.println("fileSize : " + fileSize); System.err.println("totalBytesRead : " + asyncIOResultInfo.totalBytesRead); //// asyncFileChannel 닫기 closeAsyncFileChannel(asyncFileChannel); return; } // 읽을 내용이 남아있으므로 반복 회수를 증가 시키고 다시 읽는다. ////// iterations 대신 asyncIOResultInfo.iterations asyncIOResultInfo.iterations++; asyncFileChannel.read(byteBuffer, result * asyncIOResultInfo.iterations, asyncIOResultInfo, this); } @Override publicvoidfailed(Throwable exc, AsyncIOResultInfo iterations){ ////// Long 대신 AsyncIOResultInfo 전달 exc.printStackTrace(); //// asyncFileChannel 닫기 closeAsyncFileChannel(asyncFileChannel); } } ); System.err.println("AsyncFileChannel I/O 진행 중에는 다른 작업도 할 수 있지롱"); System.err.println("그동안 그리스에도 다녀오고"); System.err.println("크로아티아에도 갔다오자"); } catch (Exception e) { e.printStackTrace(); thrownew RuntimeException(e); //// 상황에 맞는 예외 처리 필요 } } //// asyncFileChannel 닫기 privatevoidcloseAsyncFileChannel(AsynchronousFileChannel asyncFileChannel){ if (asyncFileChannel != null && asyncFileChannel.isOpen()) { try { asyncFileChannel.close(); } catch (IOException e) { e.printStackTrace(); thrownew RuntimeException(e); //// 상황에 맞는 예외 처리 필요 } } }
실행해보면 아래와 같이 전체 읽어들인 바이트수와 파일 크기도 함께 표시할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
AsynchronousFileChannel 테스트 시작 AsynchronousFileChannel.read() 호출 AsyncFileChannel I/O 진행 중에는 다른 작업도 할 수 있지롱 그동안 그리스에도 다녀오고 크로아티아에도 갔다오자 1회차 반복 2회차 반복 3회차 반복 ... 19496회차 반복 19497회차 반복 AsynchronousFileChannel.read() 완료 : 683431718 ns elapsed. fileSize : 159718093 totalBytesRead : 159718093
기존 방식과의 비교
속도
동기와 비동기는 그 작업 자체의 속도 비교보다는 대기 시간 동안 다른 작업 처리가 가능하냐 마냐가 중요하므로, 처리 속도가 중요한 것은 아니지만 그래도 궁금하니까 기존 방식이랑 한 번 비교해보자.
980메가 정도의 파일로 비교해봤다. 버퍼를 사용할 수 있는 부분에서는 8k 를 적용했다. 참고로 테스트용 대용량 텍스트 파일은 여기에서 내려받을 수 있다.
AsynchronousFileChannel은 약 2.75초 정도 소요되었고, 가장 빠른 FileChannel과 BufferedInputStream은 0.5초 이내, BufferedReader와 InputStreamReader는 4~5초 정도 소요되었다.
텍스트를 읽을 때 간편해서 가장 많이 사용하는 BufferedReader.readLine()이 아무래도 8k 보다는 현저히 바이트수가 작을 행 단위로 반복을 하므로 반복 회수가 가장 많을테고, 따라서 가장 느릴 거라고 예상은 했지만 이 정도 차이라면 상당히 놀랍다. 작은 파일은 관계없겠지만 대용량 파일에서는 BufferedReader.readLine()는 피하는 것이 좋을 것 같다.
자원 사용
아래 그림은 다음과 같이 6번의 테스트 수행 시 CPU와 메모리 변화 추이를 나타내고 있다.
AsynchronousFileChannel.read()
FileChannel.read()
BufferedInputStream.read()
BufferedReader.read()
InputStreamReader.read()
BufferedReader.readLine()
BufferedReader.readLine()를 실행할 때만 메모리 사용량이 급격히 증가한다. 처리 속도 뿐아니라 메모리 사용 측면에서도 BufferedReader.readLine()는 대용량 파일에서는 사용하지 않는 것이 좋을 것 같다.
정리
대용량 파일도 AsynchronousFileChannel을 활용해서 비동기 방식으로 I/O를 처리할 수 있다.
하지만 OutOfMemoryError가 발생하지 않도록 적당한 크기의 ByteBuffer로 쪼개서 반복 처리하는 것이 좋다.
반복 처리 방식, 파일 읽을 위치 지정, 채널 자원의 해제, attachment의 사용 등 신경써야 할 부분이 있다.
대용량 파일에서는 BufferedReader.readLine()는 사용하지 않는 것이 좋다.