자바 프로세스가 외부와 데이터를 주고받기 위해서는 Stream이라는 데이터 이동 통로가 필요하다.
자바 프로세스가 외부와 데이터를 주고받기 위해 Stream이 필요한 이유는 데이터의 입출력을 효과적으로 처리하기 위해서다. 외부 장치나 파일, 네트워크는 자바 내부와는 다른 데이터 표현 방식을 사용하므로, 데이터를 주고받으려면 이를 일관된 형식으로 변환하고 전달하는 통로가 필요하다. Stream은 이러한 데이터를 바이트 단위로 처리하며, 자바가 외부 리소스와 상호작용할 수 있도록 한다. 또한, 문자 데이터를 바이트로 변환하거나 바이트 데이터를 문자로 파싱하는 작업을 통해 개발자가 간편하게 데이터를 다룰 수 있도록 지원한다.
콘솔, 파일, 네트워크 소켓 등 외부 데이터 소스와 연결되어 데이터를 입출력한다. 자바에서 개발자가 다루는 문자 데이터는 내부적으로 바이트 데이터로 변환되거나, 외부에서 들어온 바이트 데이터를 문자로 파싱하여 처리된다. 이를 통해 콘솔, .dat 파일, 네트워크 등 다양한 외부 자원과의 데이터 교환이 가능하다.
파일에 데이터를 출력하는 스트림이다. 클래스 이름만 보아도 이 스트림의 목적은 파일과의 데이터 이동 통로이다. 다만 스트림은 단방향이기에 Output이라는 개념이 붙어 출력방향의 데이터통로라고 이해하는 것이 옳다.
생성자로 파일명을 기본으로 받는다. 이를 통해 특정한 파일과 자바 사이에 이동통로가 구축되는 것이다. 파일명이 없으면 파일을 자동으로 생성하고 데이터(자바)를 해당 파일(외부)에 저장한다.
자바 프로세스에서 생성된 데이터를 파일에 작성하기 위해 FileOutputStream은 바이트 데이터로 데이터를 변환하고 이를 dat파일에 전달하면 dat파일 프로그램은 이를 다시 인코딩하여 볼 수 있다.
배열과 단순한 int 숫자를 입력가능하며 바이트 데이터로 Stream에 쓰여진다.
FileInputStream의 기능으로, 파일에서 데이터를 바이트 단위로 읽어온다. 더는 읽을 내용이 없다면 -1을 반환한다(EOF)
외부 자원을 사용하는 것이므로 외부자원을 닫는 기능은 필수적이다. 사용 후 반드시 닫아주어야 한다.
데이터를 주고받는 작업을 Input/Output(I/O)라고 한다. 자바에서 프로세스 내부의 데이터를 외부 파일에 저장하거나, 네트워크로 전송하거나, 콘솔에 출력하는 작업은 모두 자바 프로세스 외부와의 데이터 주고받기로, 바이트 단위의 데이터를 처리하는 행위이다.
예를 들어, 네트워크로 데이터를 보내는 작업, 콘솔에 데이터를 출력하는 작업, 파일에 데이터를 저장하는 작업은 모두 바이트 단위 전송이라는 공통점을 가진다. 그러나, 이 작업들 각각은 기능이나 사용 방식에서 차이가 있다.
이러한 차이와 복잡성을 관리하기 위해, 자바는 InputStream과 OutputStream이라는 기본 추상 클래스를 제공한다. 이들은 데이터 입력과 출력의 공통된 동작을 정의하며, 구체적인 스트림 클래스들이 이를 확장하여 다양한 입출력 작업을 수행할 수 있도록 한다. 이를 통해 스트림 작업이 통일된 방식으로 처리되면서도 각 작업의 특성에 맞는 기능을 구현할 수 있다.
public class ByteArrayStreamMain {
public static void main(String[] args) throws IOException {
byte[] input = {1,2,3};
// 메모리에 쓰기
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write(input);
// 메모리에서 읽기
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
byte[] bytes = bais.readAllBytes();
System.out.println("Arrays.toString(bytes) = " + Arrays.toString(bytes));
}
}
ByteArrayOutputStream , ByteArrayInputStream 을 사용하면 메모리에 스트림을 쓰고 읽을 수 있다.
이 클래스들은 OutputStream , InputStream 을 상속받았기 때문에 부모의 기능을 모두 사용할 수 있다.
보통 메모리에 데이터를 저장하는 것은 컬렉션 시리즈를 사용하면 되기에 이 기능은 사용할 일은 거의 없을 것이다.
우리가 자주 사용하는 sout에 해당하는 System.out.println()에서 System.out은 PrintStream 타입이며, 이 스트림은 OutputStream을 상속받는다.
PrintStream은 쓰기 기능을 지원하는 write()와 println() 메서드를 제공한다.
write(): 바이트 배열을 입력받아 출력한다. println(): 문자열을 입력받아 처리한다.println()에 문자열을 넘기면, 내부적으로 이를 바이트로 변환한 뒤 콘솔에 출력하는 과정을 수행한다. 이 과정 덕분에 개발자는 바이트 변환을 신경 쓰지 않고도 문자열을 간단히 출력할 수 있다.
따라서, 우리는 더 직관적이고 편리한 System.out.println()을 주로 사용하며, 직접 바이트 배열을 다루는 System.out.write()는 거의 사용하지 않는다.
파일에 입력을 위한 스트림으로 이를 활용하여 10MB에 해당하는 데이터를 파일에 입력해보는 코드가 아래와 같다.
이 코드에서 비효율성을 관찰해보자.
public class BufferedConst {
public static final String FILE_NAME = "temp/buffered.dat";
public static final int FILE_SIZE = 10 * 1024 * 1024; // 10MB
public static final int BUFFER_SIZE = 8192; // 8KB
}
/////////////
public class CreateFileV1 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(BufferedConst.FILE_NAME);
long startTime = System.currentTimeMillis();
for (int i = 0; i < FILE_SIZE; i++) {
fos.write(1);
}
fos.close();
long endTime = System.currentTimeMillis();
System.out.println("File create: " + FILE_NAME);
System.out.println("File size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
위에 상수필드를 몇 개 초기화했다. BUFFER_SIZE는 일단 무시한 채 FILE_SIZE, FILE_NAME만 활용하니 이에 집중하자.
FILE_SIZE(10MB) = 10 1024 1024 만큼의 for문을 돌린다. 한번의 for 마다 1을 파일에 작성한다.
이때 로직 수행시간을 보기위해 시간경과를 찍어 보면 다음과 같다.
수행시간이 이렇게 많이 걸린 이유는 FILE_SIZE 만큼 1이라는 데이터를 반복 전송했기 때문이다. 아래 코드로 하여금 이 문제에 대해 더 깊숙히 조사해보자.
public class CreateFileV2 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
byte[] buffer = new byte[BUFFER_SIZE];
int bufferIndex = 0;
for (int i = 0; i < FILE_SIZE; i++) {
buffer[bufferIndex] = 1;
bufferIndex++;
// 버퍼가 가득 차면 쓰고, 버퍼를 비운다.
if (bufferIndex == BUFFER_SIZE) {
fos.write(buffer);
bufferIndex = 0;
}
}
if (bufferIndex > 0) {
fos.write(buffer, 0, bufferIndex);
}
fos.close();
long endTime = System.currentTimeMillis();
System.out.println("File create: " + FILE_NAME);
System.out.println("File size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
V2 코드는 BUFFER_SIZE 상수를 활용하여 데이터를 효율적으로 처리하는 방식으로, 데이터를 파일에 바로 쓰는 대신 일정 크기의 배열(버퍼)에 저장한 뒤 버퍼가 가득 차면 한 번에 파일에 기록한다. 이러한 방식은 매번 파일에 접근하는 대신 데이터를 쌓아둔 뒤 처리하므로 디스크 I/O 작업을 줄이고 성능을 최적화할 수 있다. 마지막으로 모든 데이터를 처리한 뒤 남아 있는 버퍼의 데이터를 파일에 기록하며 작업을 마무리한다. 이 접근법은 대용량 데이터를 다루거나 파일 쓰기 작업이 빈번한 경우에 유리하며, 입출력 성능을 크게 향상시킨다.
15초 걸리던 작업이 0.02초로 줄어들었다. 분명 for문은 동일하게 10 1024 1024번 돌아갔으나 V1에 비해 버퍼를 활용한 V2가 압도적으로 빨랐다.
Stream을 통해 파일에 무언가를 작성하는 작업이 자바 배열에 담는 것보다 압도적으로 느리다는 것을 증명하게 된 것이다.
또한, 정확하지는 않지만 파일에 배열 자체를 한 번에 작성하는 것이 단건씩 작성하는 것과 크게 속도 차이가 나지 않음을 알 수 있다. 결론적으로, 파일에 스트림을 통해 데이터를 접근하는 과정 자체가 시간적 비용이 크다는 점을 알 수 있다.
이를 현실세계에 비유하자면, V1 방식은 배달해야 할 택배가 1000개인데 500개를 실을 수 있는 트럭으로 택배를 1개씩 배달하고, 다시 택배센터로 와서 1개를 싣고 배달하는 반복을 하는 것과 같다. 반면, V2 방식은 500개를 한 번에 실어 나르기 때문에 두 번의 왕복만으로 작업을 완료할 수 있다.
참고
1을 작성하는 것은 1byte 전송에 해당한다. 실제 운영체제나 하드웨어 레벨의 여러가지 최적화 덕에 1byte를10 * 1024 * 1024번 전송하는 속도로 15초의 결과가 나타나지는 않는다.(아마 최적화가 없다면 훨씬 더 느릴 것이다.)
그럼에도 write(), read()를 매우 많이 수행할 경우 시스템 콜이 비례하여 많이 발생하기 때문에 성능 저하를 피할 수 없기에15s vs 20ms와 같은 차이가 발생하는 것이다.
BufferSize
버퍼 사이즈를 8KB정도로 맞추었는데 이보다 더 높게 값을 둔다고 하더라도 8kb이후에는 크게 향상되지 않는다. 디스크나 파일 시스템이 데이터를 읽고 쓰는 기본 단위가 보통 4KB or 8KB이기 때문에 어차피 더 많은 양의 데이터를 한번에 보내도 해당 단위로 나누어 저장하기에 효율에는 한계가 있다.
위의 V2 코드가 V1에 비해 꽤 복잡하다. 버퍼 배열을 활용하기 위해 직접 코드를 짰기 때문이다. 자바는 이 과정을 편리하게 처리할 수 있는 추상화된 클래스를 제공한다.
버퍼를 쉽게 사용하기 위해 BufferedInputStream을 자바가 지원한다.
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(BufferedConst.FILE_NAME);
BufferedOutputStream bos = new BufferedOutputStream(fos, BUFFER_SIZE);
long startTime = System.currentTimeMillis();
for (int i = 0; i < FILE_SIZE; i++) {
bos.write(1);
}
bos.close();
long endTime = System.currentTimeMillis();
System.out.println("File create: " + FILE_NAME);
System.out.println("File size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
FileOutputStream과 BufferedOutputStream를 함께 생성하고 FileOutputStream을 BufferedOutputStream 생성자로 주입하게 되면 자동적으로 버퍼를 사용할 수 있다.
bos는 fos를 인자로 받는다. 우리의 목적은 파일에 무언가를 작성할 것인데 데이터 전송 방식의 효율성을 위해 버퍼를 사용하는 것이다. bos는 이러한 버퍼기능을 담당하며, 피전송체에 관련한 스트림인 FileOutputStream을 인자로 주는 것이다.
이를 통해 나타낸 시간경과는 다음과 같다.
우리가 직접 만들었던 Buffer활용 스트림 방식보다 시간이 생각보다 더 걸리는 느낌이다. 이는 멀티스레드를 위한 동기화 문제를 해결하기 위한 코드가 함께되서 그러하다. 보통 이정도 속도도 매우 빠른 것으로 간주하기에 스트림에서 버퍼를 이용할 때에는 자바에서 제공하는 이 BufferedOutputStream를 사용하게 된다.
BufferedOutputStream을 보조 스트림, FileOutputStream을 기본 스트림이라 한다. BufferedOutputStream은 단독으로 사용할 수 없고 위 코드처럼 FileOutputStream을 끼워 사용한다. BufferedOutputStream는 기본 스트림에 버퍼를 제공한다고 생각하면 옳다.