상황
통합테스트 시점에 파일을 copy하는 기능에서 오류가 나서 수정 요청이 들어왔다.
단위테스트 당시에는 문제가 없었는데, 디버깅을 걸어보니 파일의 용량이 큰 경우에 생기는 이슈라는 것을 알게 되었다.
InputStream, OutputStream을 사용하는 기존 코드에서 BufferedStream 사용으로 변경하여 해결하였다.
이 이슈를 해결하는 과정에서 알게된 것을 정리해보려고 한다.
본문
JAVA에서 자료를 읽거나 쓰기 위해 Stream을 사용한다.
입출력과 관련된 것은 java.io 패키지에서 제공하고 있다.
✔️1. Stream
InputStream과 OutputStream에 대해 이해해보기 전 Stream의 개념에 대해 먼저 정리가 필요하다.
📌Stream이란?
- 스트림이란 데이터, 패킷, 비트 등의 일련의 연속성을 갖는 흐름을 의미한다.
스트리밍이라는 단어를 떠올리면 이해하기 좀 더 쉬운데, 우리는 유튜브에서 '동영상 스트리밍' 형태로 보게 된다. 대용량의 유튜브 영상을 바로 재생하고 끊김없이 시청이 가능하다. 어떻게 이런 일이 가능할까?
서버에서 영상파일을 작은 단위로 쪼개어 우리에게 전달하기 때문이다. 이처럼 데이터를 잘라 연속적인 데이터의 흐름 형태로 전달하는 것을 스트림이라고 한다.
불안정한 네트워크 환경에서 종종 영상이 끊기는 경험이 있을 것이다. 이것은 스트림 형태로 전달받는 도중에 네트워크 문제가 생겨 다음 데이터를 전달을 하지 못해 발생하는 현상인 것이다. 만약 작은 단위로 쪼개어 전달해주는 스트림 방식이 아닌 영상 데이터를 한번에 전달받는 방식이었다면 대용량 크기의 영상을 모두 전달받기 전까지 기다렸다가 재생할 수 있을 것이다.
✔️2. InputStream과 OutputStream
기존에 문제가 되었던 코드는 파일을 InputStream 으로 읽어들이고, OutputStream 으로 전송하여 복사하는 로직이었다.
기존 코드를 간략하게 먼저 살펴보자.
@Test
void fileCopyTest1() {
long start = System.currentTimeMillis();
String orgFile = "C:/dev/test.jpg";
String copyFile = "C:/dev/copy.jpg";
try {
FileInputStream fis = new FileInputStream(orgFile);
FileOutputStream fos = new FileOutputStream(copyFile);
int data = 0;
while (( data = fis.read() ) != -1) { // 더 이상 읽을 데이터가 없는 시점 -1이 출력된다. 스트림이 비어있는 것
fos.write(data);
}
fis.close();
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("소요 시간 :"+(end - start) +"ms");
}
*참고로 위 코드에서 사용한 FileInputStream은 추상 클래스인 InputStream을 상속받은 클래스이다. (output도 마찬가지)
위 코드는 1byte씩 파일크기(스트림의 길이)만큼 while문을 돌면서 끊임없이 읽고 쓰기를 반복하고 있기에 리소스를 많이 소모하게 된다. 당연히 성능 이슈를 피할 수 없는 것이다. (특히 파일의 크기가 클수록..)
✏️테스트 결과 : 135KB의 파일 기준 3125ms 소요
✔️3. BufferedInputStream 과 BufferedOutputStream
위 코드가 1byte씩 파일을 읽기 때문에 성능상 문제가 생겼다면, 1byte보다 큰 단위씩 읽어들여 API의 호출 횟수를 줄이면 해결되지 않을까?
그래서 JAVA는 파일 입출력시 자원 소모량 감소와 성능 향상을 위해 Buffered 계열의 보조 스트림을 제공하고 있다.
(참고 : buffer는 데이터를 일정량 모아두었다가 한 번에 전송함으로써 데이터 처리의 효율성을 높인다. 그렇다고 해서 단순히 모았다가 보내서 성능이 높아지는 것은 아니다. OS 레벨에 있는 시스템 콜의 횟수 자체를 줄이기 때문에 성능이 빨라지는 것이다.)
위 코드에서의 문제를 해결하기 위해 buffer를 사용하면 어떨까?
코드로 살펴보자.
@Test
void fileCopyTest2() {
long start = System.currentTimeMillis();
String orgFile = "C:/dev/test.jpg";
String copyFile = "C:/dev/copy.jpg";
InputStream in = null;
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(orgFile));
out = new BufferedOutputStream(new FileOutputStream(copyFile));
while (true) {
int data = in.read();
if (data == -1)
break;
out.write(data);
}
in.close();
out.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
long end = System.currentTimeMillis();
System.out.println("소요 시간 :"+(end - start) +"ms");
}
✏️테스트 결과 : 135KB의 파일 기준 14ms 소요
input, outputStream으로 테스트 해본 것과 비교하면 현저히 빨라진 속도이다!
✔️4. NIO (New Input Output)
❓그럼 위와 같은 방법을 사용하면 충분히 개선이 되는걸까?
Java I/O의 큰 단점은 커널 버퍼에 직접 접근할 수 없다는 것과 I/O 프로세스를 거치는 동안 작업 요청한 쓰레드는 블록킹된다는 것이다. I/O는 기본적으로 버퍼를 지원하지 않기 때문에, 2번째 테스트 예시와 같이 BufferedInputStream과 BufferedOutputStream을 이용한다. 이런 고질적인 문제를 해결하기 위해 Java 4부터 새로운 입출력(New Input Output)이라는 뜻에서 java.nio 패키지를 추가한다.
👇
Java 7로 버전업하면서 자바 IO 와 NIO 사이의 일관성 없는 클래스 설계를 바로 잡고 비동기 채널 등의
네트워크 지원을 대폭 강화한 NIO.2 API가 추가되었다.
NIO.2는 java.nio의 하위 패키지(java.nio.channels, java.nio.charset, java.nio.file)에 통합되어 있다.
아래는 NIO를 사용한 파일 copy 예시 코드이다.
@Test
void fileCopyTest4() throws IOException {
long start = System.currentTimeMillis();
String orgFile = "C:/dev/test.jpg";
String copyFile = "C:/dev/copy.jpg";
// 1. 원본 File, 복사할 File 준비
RandomAccessFile file = new RandomAccessFile(orgFile, "r");
RandomAccessFile newFile = new RandomAccessFile(copyFile, "rw");
// 2. FileChannel 생성
FileChannel source = file.getChannel();
FileChannel target = newFile.getChannel();
// 3. File 복사
source.transferTo(0, source.size(), target);
long end = System.currentTimeMillis();
System.out.println("소요 시간 :"+(end - start) +"ms"); //4ms (135KB)
}
✏️테스트 결과 : 135KB의 파일 기준 3ms 소요
✔️번외 ) Apache의 commons-io 라이브러리 사용하여 파일 복사
위의 방법 외에도 잘 구현되어있는 라이브러리를 사용하는 간단한 방법도 존재한다.
사용하기 위해서는 아래와 같이 라이브러리를 추가해주어야 한다.
implementation 'commons-io:commons-io:2.11.0'
@Test
void fileCopyTest3() throws IOException {
long start = System.currentTimeMillis();
String orgFile = "C:/dev/test.jpg";
String copyFile = "C:/dev/copy.jpg";
File file = new File(orgFile);
File newFile = new File(copyFile);
FileUtils.copyFile(file, newFile); // Apache Commons IO
long end = System.currentTimeMillis();
System.out.println("소요 시간 :"+(end - start) +"ms"); //17ms (135KB)
}
✏️테스트 결과 : 135KB의 파일 기준 29ms 소요
결론
속도 측면에서 정리해보면 (왼쪽이 느림, 오른쪽이 빠름)
InputStream & OutputStream ➡️ BufferedInputStream & BufferedOutputStream ➡️ NIO 이다.
❓NIO가 가장 빠르니깐 무조건 이 방식을 채택하는 것이 성능면에서 유리할까?
이것 또한 작업 성격에 따라 달리 선택해야 하는 문제이다.
✏️NIO는 통신하는 클라이언트 수가 많고 하나의 입출력 처리 작업이 오래걸리지 않는 작업에 적합.
NIO는 버퍼 할당 크기가 문제가 되고 모든 입출력 작업에 버퍼를 기반으로 사용하기 때문에 즉시 처리하는 IO보다 조금 복잡하기 때문이다.
✏️IO는 대용량 데이터를 처리하는 경우에 적합.
통신 클라이언트 수가 적고 전송되는 데이터가 대용량이면서 순차적으로 처리될 필요성이 있다면 IO로 구현하는 것이 적합하다.
해결
개발 중인 프로그램 성격이 API를 호출하는 클라이언트의 수가 많지 않고, 데이터의 크기가 작지 않기 때문에
BufferedStream (IO 방식)으로 구현하여 성능을 개선하였다.
'Back > Java' 카테고리의 다른 글
[JAVA][시큐어코딩] 적절하지 않은 난수 값 사용 (0) | 2024.08.01 |
---|---|
[JAVA][시큐어코딩] Null Pointer 역참조에 대응하기 (0) | 2024.08.01 |
[JAVA] Stream Collectors.groupingBy 널(null) 사용하기 (0) | 2023.04.07 |
[Java] Stream 스트림으로 중복 값 찾기 (0) | 2023.02.22 |
[Java] Optional 개념, 사용법 (1) | 2023.02.21 |
댓글