일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch2
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch12
- 자바 고급2편 - io
- 자바의 정석 기초편 ch4
- 스프링 mvc2 - 타임리프
- 코드로 시작하는 자바 첫걸음
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch13
- 자바의 정석 기초편 ch1
- 스프링 mvc1 - 스프링 mvc
- 자바의 정석 기초편 ch5
- 스프링 db2 - 데이터 접근 기술
- 스프링 db1 - 스프링과 문제 해결
- @Aspect
- 자바의 정석 기초편 ch7
- 자바 중급2편 - 컬렉션 프레임워크
- 2024 정보처리기사 수제비 실기
- jpa - 객체지향 쿼리 언어
- 스프링 입문(무료)
- 자바의 정석 기초편 ch6
- 자바 중급1편 - 날짜와 시간
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch9
- jpa 활용2 - api 개발 고급
- 자바의 정석 기초편 ch14
- 자바 기본편 - 다형성
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch11
- Today
- Total
나구리의 개발공부기록
I/O 기본, 스트림 시작, InputStream, OutputStream, 파일 입출력과 성능 최적화(하나씩 쓰기, 버퍼 활용, Buffered 스트림 쓰기, Buffered 스트림 읽기, 한 번에 쓰기) 본문
I/O 기본, 스트림 시작, InputStream, OutputStream, 파일 입출력과 성능 최적화(하나씩 쓰기, 버퍼 활용, Buffered 스트림 쓰기, Buffered 스트림 읽기, 한 번에 쓰기)
소소한나구리 2025. 2. 20. 19:30출처 : 인프런 - 김영한의 실전 자바 - 고급2편 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
1. 스트림 시작
위 그림처럼 자바가 가진 데이터를 hello.dat이라는 파일에 저장하기 위해 데이터를 밖으로 보내려면 출력 스트림을 사용하면 되고, 반대로 외부 데이터를 자바 프로세스 안으로 가져오려면 입력 스트림을 사용하면 됨
** 주의!
- 실행 전에 프로젝트 하위(src 하위가 아님)에 temp라는 폴더를 만들고 진행해야 함
- 해당 폴더가 없으면 예제 진행 시 java.io.FileNotFoundException 예외가 발생할 수 있음
StreamStartMain1
package io.start;
public class StreamStartMain1 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/hello.dat");
// byte로 저장
fos.write(65);
fos.write(66);
fos.write(67);
fos.close();
FileInputStream fis = new FileInputStream("temp/hello.dat");
System.out.println(fis.read());
System.out.println(fis.read());
System.out.println(fis.read());
System.out.println(fis.read());
fis.close();
}
}
/* 실행 결과
65
66
67
-1
*/
new FileOutputStream("temp/hello.dat");
- 파일에 데이터를 출력하는 스트림
- 파일이 없으면 파일을 자동으로 만들고, 데이터를 해당 파일에 저장함
- 폴더를 만들지는 않기 때문에 폴더는 미리 만들어두어야 함
- 두 번째 매개변수에 new FileOutputStream("temp/hello.dat", true); 처럼 true를 추가하게 되면 기존 파일의 끝에 이어서 쓰게 됨
- 기본값은 false이며 기본값으로 동작하면 기존 파일의 데이터를 지우고 처음부터 다시 씀
write()
- byte 단위로 값을 출력함
- 65, 66, 67을 출력했으므로 ASCII 코드로 디코딩되면 A, B, C가 됨
new FileInputStream("temp/hello.dat");
- 파일에서 데이터를 읽어오는 스트림
read()
- 파일에서 데이터를 byte단위로 하나씩 읽어옴
- 예제에서는 65, 66, 67을 순서대로 읽어오고 파일의 끝(EOF, End of File)에 도달해서 더는 읽을 내용이 없다면 -1을 반환함
close()
- 파일에 접근하는 것은 자바 입장에서 외부 자원을 사용하는 것이므로 자바에서 내부 객체는 자동으로 GC가 되지만 외부 자원은 사용 후 반드시 닫아 주어야 함
실행 결과 - 콘솔
- 입력한 순서대로 byte 값이 잘 출력되며 마지막은 파일의 끝에 도달해서 -1이 출력되는 것을 확인할 수 있음
실행 결과 - temp/hello.dat
- 위의 코드로 temp 디렉토리에 생성된 hello.dat에 들어가 보면 hello.dat에 분명히 byte로 65, 66, 67을 저장했지만 개발툴이나 테스트 편집기 등에서 열어보면 ABC라고 보임
- 자바에서 read()로 읽어서 출력한 경우에는 65, 66, 67로 출력되었지만 우리가 사용하는 개발툴이나 텍스트 편집기 등은 UTF-8 또는 MS949 문자 집합을 사용하여 byte 단위의 데이터를 문자로 디코딩해서 보여줌
- 즉 65, 66, 67을 ASCII 문자인 A, B, C로 인식해서 출력한 것임
StreamStartMain2
package io.start;
public class StreamStartMain2 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/hello.dat");
// byte로 저장
fos.write(65);
fos.write(66);
fos.write(67);
fos.close();
FileInputStream fis = new FileInputStream("temp/hello.dat");
int data;
while ((data = fis.read()) != -1) {
System.out.println(data);
}
fis.close();
}
}
/* 실행 결과
65
66
67
*/
입력 스트림의 read() 메서드가 파일의 끝에 도달하면 -1을 반환하는 것을 이용하여 반복문 사용해 읽어 들인 값이 -1이 아니면 계속 데이터를 읽도록 코드를 실행할 수 있음
** 참고 - read()가 byte가 아닌 int를 반환하는 이유
이 부분은 크게 중요하지 않으므로 참고만 하고 넘어가도 됨
- 부호 없는 바이트 표현
- 자바에서 byte는 부호 있는 8비트 값(-128 ~ 127)인데 int로 반환함으로써 0 ~ 255까지의 모든 가능한 바이트 값을 부호 없이 표현할 수 있음
- EOF(End of File) 표시
- byte를 표현하려면 256 종류의 값을 모두 사용해야 하는데 자바의 byte는 -128 ~ 127까지 256 종류의 값만 가질 수 있어 EOF를 위한 특별한 값을 할당하기 어려움
- int의 범위는 굉장히 넓기 때문에 0-255까지 모든 바이트 값을 표현할 수 있고 추가로 -1을 반환하여 스트림의 끝(EOF)을 나타낼 수 있음
- write()의 경우도 비슷한 이유로 int 타입을 입력받음
StreamStartMain3
byte를 하나씩 다루지 않고 byte[]을 사용하여 데이터를 원하는 크기만큼 더 편리하게 저장하고 읽을 수 있음
package io.start;
public class StreamStartMain3 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/hello.dat");
byte[] input = {65, 66, 67, 68, 69, 70};
fos.write(input); // 바이트 배열을 넘길 수 있음
FileInputStream fis = new FileInputStream("temp/hello.dat");
byte[] buffer1 = new byte[10];
int readCount1 = fis.read(buffer1, 0, 3);
System.out.println("readCount1 = " + readCount1);
System.out.println(Arrays.toString(buffer1));
byte[] buffer2 = new byte[10];
int readCount2 = fis.read(buffer2, 3, 5);
System.out.println("readCount2 = " + readCount2);
System.out.println(Arrays.toString(buffer2));
byte[] input2 = {65, 66, 67, 68, 69, 70};
fos.write(input2);
fos.close();
byte[] buffer3 = new byte[10];
int readCount3 = fis.read(buffer3);
System.out.println("readCount3 = " + readCount3);
System.out.println(Arrays.toString(buffer3));
fis.close();
}
}
/* 실행 결과
readCount1 = 3
[65, 66, 67, 0, 0, 0, 0, 0, 0, 0]
readCount2 = 3
[0, 0, 0, 68, 69, 70, 0, 0, 0, 0]
readCount3 = 6
[65, 66, 67, 68, 69, 70, 0, 0, 0, 0]
*/
출력 스트림
- write(byte[]): byte[]에 원하는 데이터를 담고 write()에 전달하면 해당 데이터를 한 번에 출력할 수 있음
입력 스트림
- read(byte[], offset, length): byte[]을 미리 만들어 두고 만들어둔 byte[]에 한 번에 데이터를 읽어올 수 있음
- byte[]: 데이터가 읽혀지는 버퍼
- offset: 데이터가 기록되는 byte[]의 인덱스 시작 위치
- length: 읽어올 byte의 길이, buffer의 길이가 넘어가면 IndexOutOfBoundsException 예외가 발생함
- 예를 들어 byte[10]인 배열인 버퍼에 offset이 1이고 length를 10으로 입력하면 버퍼의 index 1부터 10byte를 읽게 되는 코드가 되어 버퍼의 범위를 벗어나게 되고 예외가 발생하게 됨
- 반환 값: 버퍼에 읽은 총 바이트 수, 여기서는 3byte를 읽었으므로 3이 반환되었으며 스트림의 끝에 도달하여 더 이상 데이터가 없는 경우 -1을 반환함
read(byte[])
- offset, length를 생략하고 read() 메서드에 byte[]만 입력해도 동작하며 이렇게 하면 offset은 0, length는 byte[].length 값을 가짐
StreamStartMain4
package io.start;
public class StreamStartMain4 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/hello.dat");
byte[] input = {65, 66, 67, 68, 69, 70};
fos.write(input); // 바이트 배열을 넘길 수 있음
fos.close();
FileInputStream fis = new FileInputStream("temp/hello.dat");
byte[] readBytes = fis.readAllBytes();
System.out.println(Arrays.toString(readBytes));
fis.close();
}
}
/* 실행 결과
[65, 66, 67, 68, 69, 70]
*/
readAllBytes()를 사용하면 스트림이 끝날 때까지(파일의 끝에 도달할 때까지) 모든 데이터를 한 번에 읽어올 수 있음
부분으로 나누어 읽기 vs 전체 읽기
read(byte[], offset, length)
- 스트림의 내용을 부분적으로 읽거나 읽은 내용을 처리하면서 스트림을 계속해야 읽어야 할 경우에 적합함
- 메모리 사용량을 제어할 수 있음
- 예시)
- 대용량 파일을 처리할 때 한 번에 메모리에 로드하기보다는 이 메서드를 사용하면 일정한 크기의 데이터를 반복적으로 읽을 수 있으므로 파일을 조각조각 읽어 들일 수 있음
- 100M의 파일을 1M 단위로 나누어 읽고 처리하게 되면 한 번에 최대 1M의 메모리만 사용함
readAllBytes()
- 한 번의 호출로 모든 데이터를 읽을 수 있어 편리하며 작은 파일이나 메모리에 모든 내용을 올려서 처리해야 하는 경우에 적합한
- 메모리 사용량을 제어할 수 없기 때문에 큰 파일의 경우 OutOfMemoryError가 발생할 수 있음
2. InputStream, OutputStream
현대의 컴퓨터는 대부분 byte 단위로 데이터를 주고받는데, 이렇게 데이터를 주고 받는 것을 Input/Output(I/O)라 함
(참고로 bit 단위는 너무 작기 때문에 byte 단위를 기본으로 사용함)
자바 내부에 있는 데이터를 외부에 있는 파일에 저장하거나 네트워크를 통해 전송하거나 콘솔에 출력할 때 모두 byte단위로 데이터를 주고받음
만약, 파일, 네트워크, 콘솔 각각 데이터를 주고받는 방식이 다르다면 각 사용법을 따로따로 익혀야 하므로 상당히 불편할 수 있고, 각각의 코드가 변경이 될 때 너무 많은 코드를 변경해야 할 수 있음
자바는 이런 문제를 해결하기 위해 InputStream, OutputStream이라는 기본 추상 클래스를 제공함
InputStream과 상속 클래스는 read(), read(byte[]), readAllbytes() 등을 제공하며, OutputStream과 상속 클래스는 write(int), write(byte[]) 등을 제공함
스트림을 사용하면 파일을 사용하든, 소켓을 통해 네트워크를 사용하든 모두 일관된 방식으로 데이터를 주고받을 수 있으며 수많은 기본 구현체들을 제공함
각각의 구현 클래스들은 자신에게 맞는 추가 기능도 함께 제공됨
파일에 사용하는 FileInputStream, FileOutputStream은 앞서 알아보았고 네트워크 관련 스트림은 이후에 네트워크를 다룰 때 자세히 다룸
메모리 스트림
ByteArrayStreamMain
package io.start;
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));
}
}
/* 실행 결과
[1, 2, 3]
*/
ByteArrayOutputStream, ByteArrayInputStream을 사용하면 메모리에 스트림을 쓰고 읽을 수 있으며 이 클래스들은 OutputStream, InputStream을 상속받았기 때문에 부모의 기능을 모두 사용할 수 있음
참고로 메모리에 어떤 데이터를 저장하고 읽을 때는 컬렉션이나 배열을 사용하면 되기 때문에 이 기능은 잘 사용하지 않으며 스트림을 간단하게 테스트하거나 스트림의 데이터를 확인하는 용도로 사용함
콘솔 스트림
PrintStreamMain
package io.start;
public class PrintStreamMain {
public static void main(String[] args) throws IOException {
PrintStream printStream = System.out;
byte[] bytes = "Hello!\n".getBytes(UTF_8);
printStream.write(bytes);
printStream.println("Print!");
}
}
/* 실행 결과
Hello!
Print!
*/
우리가 자주 사용했던 System.out이 사실은 PrintStream이며 이 스트림은 OutputStream을 상속받음
이 스트림은 자바가 시작될 때 자동으로 만들어지기 때문에 직접 생성하지 않아도 됨
- write(byte[]): OutputStream 부모 클래스가 제공하는 기능
- println(String): PrintStream이 자체적으로 제공하는 추가 기능
정리
InputStream과 OutputStream이 다양한 스트림들을 추상화하고 기본 기능에 대한 표준을 잡아둔 덕분에 개발자는 편리하게 입출력 작업을 수행할 수 있으며 다음과 같은 장점이 있음
일관성
모든 종류의 입출력 작업에 대해 동일한 인터페이스(부모의 메서드)를 사용할 수 있어 코드의 일관성이 유지됨
유연성
실제 데이터 소스나 목적지가 무엇인지에 관계없이 동일한 방식으로 코드를 작성할 수 있음
파일, 네트워크, 메모리 등 다양한 소스에 대해 동일한 메서드를 사용할 수 있음
확장성
새로운 유형의 입출력 스트림을 쉽게 추가할 수 있음
재사용성
다양한 스트림 클래스들을 조합하여 복잡한 입출력 작업을 수행할 수 있음
BufferedInputStream을 사용하여 성능을 향상시키거나, DataInputStream을 사용하여 기본 데이터 타입을 쉽게 읽을 수 있음
에러 처리
표준화된 예외 처리 메커니즘을 통해 일관된 방식으로 오류를 처리할 수 있음
** 참고
InputStream, OutputStream은 자바 1.0부터 제공이 되고 일부 작동하는 코드도 들어있기 때문에 추상 클래스로 제공되고 있음
3. 파일 입출력과 성능 최적화
하나씩 쓰기
BufferedConst
package io.buffered;
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
}
FILE_NAME: temp/buffered.dat 파일을 만들 예정
FILE_SIZE: 파일의 크기는 10MB
BUFFER_SIZE는 뒤에서 설명함
CreateFileV1 - 쓰기
package io.buffered;
public class CreateFileV1 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(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 created: " + FILE_NAME);
System.out.println("File Size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
/* 실행 결과
File created: temp/buffered.dat
File Size: 10MB
Time taken: 15737ms
*/
fos.write(1): 파일의 내용은 중요하지 않기 때문에 단순히 1이라는 값을 반복하여 저장함
한 번 호출에 1byte가 만들어지고, 이 메서드를 약 1000만 번 호출하면 10MB의 파일이 만들어짐
M1 Pro 기분 약 16초 정도 소요 되었음
시스템에 따라 실행 시간이 1분 이상 걸리는 경우도 있음
ReadFileV1 - 읽기
package io.buffered;
public class ReadFileV1 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
int fileSize = 0;
int data;
while ((data = fis.read()) != -1) {
fileSize++;
}
fis.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File Size: " + (fileSize / 1024 / 1024) + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
/* 실행 결과
File created: temp/buffered.dat
File Size: 10MB
Time taken: 5427ms
*/
fis.read(): 앞서 만든 파일에서 1byte씩 데이터를 읽음
파일의 크기가 10MB이므로 fis.read() 메서드를 약 1000만 번(10 * 1024 * 1024) 호출함
M1 Pro 기준 약 5초 정도 소요되었음
정리
10MB 파일 하나 쓰는데, 15초 읽는데 5초라는 매우 오랜 시간이 걸렸음
이렇게 오래 걸린 이유는 자바에서 1byte씩 디스크에 데이터를 전달하기 때문에 디스크는 1byte의 데이터를 받아 쓰는 과정을 1000만 번 반복하는 것임
write(), read()를 호출할 때마다 OS의 시스템 콜을 통해 파일을 읽거나 쓰는 명령어를 전달하는데 이러한 시스템 콜은 상대적으로 무거운 작업임
HDD, SSD 같은 장치들도 하나의 데이터를 읽고 쓸 때마다 필요한 시간이 있으며 HDD는 물리적으로 디스크의 회전이 필요하기 때문에 더욱 느림
이런 무거운 작업을 1000만 번 반복하기 때문에 오래 걸린 것이므로 이런 문제를 해결하려면 더 많은 용량을 한꺼번에 담아서 보내면 됨
** 참고
이렇게 자바에서 운영 체제를 통해 디스크에 1byte씩 전달하면 운영 체제나 하드웨어 레벨에서 여러 가지 최적화가 발생하여 실제로 디스크에 1byte씩 계속 쓰지는 않음
만약 실제로 1byte씩 계속 읽고 쓰고 했다면 더욱 느렸을 것임
그러나 자바에서 1byte씩 write()나 read()를 호출할 때마다 발생하는 시스템 콜 자체가 상당한 오버헤드를 유발하기 때문에 운영체제와 하드웨어가 어느 정도 최적화를 제공하더라도 자주 발생하는 시스템 콜로 인한 성능 저하는 피할 수 없음
결국 자바에서 read(), write() 호출 횟수를 줄여서 시스템 콜 횟수도 줄여야 함
버퍼 활용
CreateFileV2 - 쓰기
1byte씩 데이터를 전달하는 것이 아니라 byte[]를 통해 배열에 담아서 한 번에 여러 byte를 전달
package io.buffered;
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;
// 버퍼가 가득 차면 쓰고, 버퍼를 비움
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 created: " + FILE_NAME);
System.out.println("File Size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
/* 실행 결과
File created: temp/buffered.dat
File Size: 10MB
Time taken: 15ms
*/
데이터를 먼저 buffer라는 byte[]에 담아두고 BUFFER_SIZE (8KB) 만큼 데이터를 모아서 write()를 호출
이렇게 데이터를 모아서 전달하거나 전달받는 용도로 사용하는 것을 버퍼라고 함
실행 결과를 보면 기존 약 16초 대비 15ms로 1000배 정도 빠른 것을 확인할 수 있음
버퍼의 크기에 따른 쓰기 성능
public static final int BUFFER_SIZE = 1; // Time taken: 17368ms
public static final int BUFFER_SIZE = 2; // Time taken: 8647ms
public static final int BUFFER_SIZE = 3; // Time taken: 5836ms
public static final int BUFFER_SIZE = 10; // Time taken: 1797ms
public static final int BUFFER_SIZE = 100; // Time taken: 192ms
public static final int BUFFER_SIZE = 1000; // Time taken: 31ms
public static final int BUFFER_SIZE = 2000; // Time taken: 21ms
public static final int BUFFER_SIZE = 4000; // Time taken: 18ms
public static final int BUFFER_SIZE = 8000; // Time taken: 15ms
public static final int BUFFER_SIZE = 80000; // Time taken: 12ms
많은 데이터를 한 번에 전달하면 시스템 콜도 줄어들고 HDD, SSD 같은 장치들의 작동 횟수도 줄어들게 되기 때문에 성능을 최적화할 수 있음
버퍼의 크기를 1 -> 2로 변경하면 시스템 콜 횟수가 절반으로 줄어들기 때문에 약 2배의 속도가 감소되었고 10으로 변경하면 약 10배의 속도가 감소된 것을 확인할 수 있음
그러나 버퍼의 크기가 커진다고 해서 속도가 계속 줄어들지 않는데 그 이유는 디스크나 파일 시스템에서 데이터를 읽고 쓰는 기본 단위가 보통 4KB or 8KB 이기 때문임
물론 조금은 더 빨라질 수 있지만 크게 효과가 미비하기 때문에 결국 버퍼에 많은 데이터를 담아 보내도 디스크나 파일 시스템에서 해당 단위로 나누어 저장하기 때문에 효율에는 한계가 있음
따라서 버퍼의 크기는 보통 4KB(4096 byte), 8KB(8192 byte)로 잡는 것이 효율적임
ReadFileV2 - 읽기
package io.buffered;
public class ReadFileV2 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
byte[] buffer = new byte[BUFFER_SIZE];
int fileSize = 0;
int size;
while ((size = fis.read(buffer)) != -1) {
fileSize += size;
}
fis.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File Size: " + (fileSize / 1024 / 1024) + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
/* 실행 결과
File created: temp/buffered.dat
File Size: 10MB
Time taken: 2ms
*/
버퍼 사이즈가 8192인 버퍼를 활용하여 읽기를 수행하면 마찬가지로 1000배 이상의 성능 향상이 발생한 것을 확인할 수 있음
읽기/쓰기 모두 시스템 환경에 따라 그리고 반복 실행함에 따라 실행 시간은 달라질 수 있음
버퍼를 사용하면 큰 성능향상이 있기 때문에 무조건 버퍼를 사용해야 함
그러나 버퍼를 직접 만들고 관리해야 하는 번거로운 단점이 있는데, 자바는 이를 해결하는 방법을 제공함
Buffered 스트림 쓰기
자바가 제공하는 BufferedOutputStream은 버퍼 기능을 내부에서 대신 처리해 주기 때문에 단순한 코드를 유지하면서 버퍼를 사용하는 이점도 함께 누릴 수 있음
CreateFileV3
package io.buffered;
public class CreateFileV3 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(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 created: " + FILE_NAME);
System.out.println("File Size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
/* 실행 결과
File created: temp/buffered.dat
File Size: 10MB
Time taken: 146ms
*/
BufferedOutputStream은 내부에서 단순히 버퍼 기능만 제공하기 때문에 반드시 대상 OutputStream이 있어야 함
여기서는 FileOutputStream 객체를 생성자에 전달하였으며 추가로 사용할 버퍼의 크기도 함께 전달할 수 있음
코드를 보면 버퍼를 위한 byte[]을 직접 다루지 않고 예제 1과 같이 단순하게 코드를 작성할 수 있음
실행 결과를 보면 예제 1보다 약 100배 빠른 속도로 처리되어 성능 향상이 이루어진 것을 확인할 수 있음
성능이 예제 2보다는 다소 떨어지는 이유는 동기화 관련 코드들이 내부에 있어서 그런데 자세한 설명은 뒤에서 이어서 함
BufferedOutputStream 분석
BufferedOutputStream은 OutputStream을 상속받으므로 개발자 입장에서는 OutputStream과 같은 기능을 그대로 사용할 수 있음
BufferedOutputStream은 내부에 byte[] buf라는 버퍼를 가지고 있으며 write(int)를 통해 데이터를 전달하면 byte[] buf에 보관됨
BufferedOutputStream의 생성자에서 FileOutputStream fos를 전달해 두었기 때문에 write(int)를 버퍼가 가득 찰 때까지 호출하면 FileOutputStream에 있는 write(byte[] )메서드를 호출함
FileOutputStream의 write(byte[])을 호출하면 전달된 모든 byte[]을 시스템 콜로 OS에 전달함
버퍼의 데이터를 모두 전달했기 때문에 버퍼의 내용을 비운 후 BufferedOutputStream의 write(int)가 호출되면 다시 버퍼를 채우는 식으로 반복함
flush()
버퍼가 다 차지 않아도 버퍼에 남아있는 데이터를 전달하려면 flush()라는 메서드를 호출하면 됨
해당 메서드를 호출하면 버퍼가 가득 차지 않아도 데이터를 전달된 OutputStream에게 전달하고 버퍼를 비움
close()
버퍼에 데이터가 남아있는 상태로 BufferedOutputStream의 close()로 닫으면 내부에서 먼저 flush()를 호출하기 때문에 버퍼에 남아있는 데이터를 모두 전달하고 비움
그래서 마지막에 버퍼가 가득 차지 않은 상태에서 BufferedOutputStream의 close() 메서드를 호출하면 남아있는 데이터를 안전하게 저장할 수 있음
버퍼가 비워지고 나면 close()로 BufferedOutputStream의 자원을 정리하고 나서 다음 연결된 스트림의 close()를 호출하므로 생성자의 인자로 전달받은 FileOutputStream의 자원이 정리됨
여기서 핵심은 BufferedOutputStream의 close()를 호출하면 close()가 연쇄적으로 호출되어 모든 자원을 닫을 수 있으므로 BufferedOutputStream의 close()만 호출해 주면 된다는 것임
**주의 - 반드시 마지막에 연결한 스트림을 닫아야 함
만약 BufferedOutputStream을 닫지 않고 FileOutputStream만 직접 닫게 되면 BufferedOutputStream의 flush()도 호출되지 않고 자원도 정리되지 않음
마지막 데이터가 버퍼에 남아있게 되고 파일에 저장되지 않는 심각한 문제가 발생함
지금처럼 스트림을 연결해서 사용하는 경우에는 반드시 마지막에 연결한 스트림을 닫아주어야 연쇄적으로 close() 호출되어 자원을 전부 닫을 수 있음
기본 스트림, 보조 스트림
FileOutputStream과 같이 단독으로 사용할 수 있는 스트림을 기본 스트림이라고 함
BufferedOutputStream과 같이 단독으로 사용할 수 없고 보조 기능을 제공하는 스트림을 보조 스트림이라고 함
public BufferedOutputStream(OutputStream out) { ... }
public BufferedOutputStream(OutputStream out, int size) { ... }
private BufferedOutputStream(OutputStream out, int initialSize, int maxSize) { ... }
BufferedOutputStream의 생성자를 보면 반드시 FileOutputStream 같은 OutputStream이 있어야 함
즉, BufferOutputStream은 버퍼라는 보조 기능을 제공하는 것이므로 누구에게 보조 기능을 제공할지 대상을 반드시 전달해야 함
참고로 BufferOutputStream은 상수로 size값을 가지고 있어 버퍼 사이즈를 전달하지 않으면 기본 상수 값 버퍼가 만들어짐
정리
- BufferedOutputStream은 버퍼 기능을 제공하는 보조 스트림임
- BufferedOutputStream도 OutputStream의 자식이기 때문에 OutputStream의 기능을 그대로 사용할 수 있으며 대부분의 기능은 재정의 되어 있음
- write()의 경우 먼저 버퍼에 쌓도록 재정의 됨
- 버퍼의 크기만큼 데이터를 모아서 전달하기 때문에 빠른 속도로 데이터를 처리할 수 있음
Buffered 스트림 읽기
ReadFileV3
package io.buffered;
public class ReadFileV3 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
BufferedInputStream bis = new BufferedInputStream(fis);
long startTime = System.currentTimeMillis();
int fileSize = 0;
int data;
while ((data = bis.read()) != -1) {
fileSize++;
}
bis.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File Size: " + (fileSize / 1024 / 1024) + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
/* 실행 결과
File created: temp/buffered.dat
File Size: 10MB
Time taken: 148ms
*/
읽기도 버퍼를 적용한 BufferedInputStream을 사용하면 V1 버전과 비교했을 때 약 50배 정도 빨라진 것을 확인할 수 있음
마찬가지로 V2 버전보다는 느린 것도 확인이 됨
BufferedInputStream 분석
BufferedInputStream도 InputStream을 상속받기 때문에 InputStream과 같은 기능을 그대로 사용할 수 있음
BufferedInputStream은 먼저 버퍼를 확인하고 버퍼에 데이터가 없으면 FileInputStream의 read(byte[]) 메서드를 사용하여 버퍼의 크기만큼 데이터를 불러온 후 데이터를 버퍼에 보관함
BufferedInputStream의 read() 메서드는 1byte만 조회하기 때문에 read()를 반복 호출해서 버퍼에 담긴 데이터를 1byte씩 반환함
이때는 FileInputStream에는 접근하지 않고 BufferedInputStream에 있는 버퍼에서만 데이터를 반환함
버퍼에 담긴 데이터를 모두 반환한 후 read()를 호출하면 버퍼가 비어져있는 것을 확인하고 FileInputStream에서 버퍼 크기만큼 조회하고 다시 버퍼에 담아두고 다시 버퍼에 있는 데이터를 read()를 통해 하나씩 반환함
이런 식으로 FileInputStream의 데이터와 버퍼의 데이터가 모두 반환될 때까지 반복함
정리
BufferedInputStream은 버퍼의 크기만큼 데이터를 미리 읽어서 버퍼에 보관해 두기 때문에 read()로 1byte씩 데이터를 조회해도 성능이 최적화가 됨
버퍼를 직접 다루는 것보다 BufferedXxx의 성능이 떨어지는 이유
- V1 쓰기 속도: 15737ms (버퍼 없음)
- V2 쓰기 속도: 15ms (버퍼 직접 다룸)
- V3 쓰기 속도: 146ms (BufferedXxx 클래스 사용)
V2는 버퍼를 직접 다루는 것이고 예제 3은 BufferedXxx라는 클래스가 대신 버퍼를 처리해 주는데 버퍼를 사용하는 것은 같기 때문에 결과적으로 V2, V3은 비슷한 성능이 나와야 하지만 예제 2가 더 빠른 성능을 보여 주었음
그 이유는 BufferedXxx의 메서드에는 동기화가 적용되어 있기 때문임
// BufferedOutputStream.write()
public void write(int b) throws IOException {
if (lock != null) {
lock.lock();
try {
implWrite(b);
} finally {
lock.unlock();
}
} else {
synchronized (this) {
implWrite(b);
}
}
}
// BufferedInputStream.read()
public int read() throws IOException {
if (lock != null) {
lock.lock();
try {
return implRead();
} finally {
lock.unlock();
}
} else {
synchronized (this) {
return implRead();
}
}
}
BufferedOutputStream을 포함한 BufferedXxx 클래스는 모두 동기화 처리가 되어 있음
이번 예제에서는 1byte씩 저장해서 10MB를 저장해야 하는데 이렇게 하려면 write()를 약 1000만 번 호출해야 하고 결과적으로 락을 푸는 코드도 1000만 번 호출된다는 뜻임
BufferedXxx 클래스의 특징
BufferedXxx 클래스는 자바 초창기에 만들어진 클래스이므로 처음부터 멀티 스레드를 고려해서 만든 클래스임
따라서 멀티 스레드에 안전하지만 락을 걸고 푸는 동기화 코드로 인해 성능이 약간 저하될 수 있으며 동기화가 필요 없는 싱글 스레드 상황에서는 동기화 락이 필요하지 않기 때문에 직접 버퍼를 다룰 때와 비교하면 불필요한 성능 저하가 발생됨
일반적인 상황이라면 이 정도 성능 저하는 크게 문제 되지 않기 때문에 싱글 스레드여도 BufferedXxx를 사용하면 충분하고 매우 큰 데이터를 다루어야 하거나 성능 최적화가 중요하다면 V2 버전과 같이 직접 버퍼를 다루는 방법을 고려하면 됨
아쉽게도 동기화 락이 없는 BufferedXxx 클래스는 없기 때문에 꼭 필요한 상황이라면 BufferedXxx 코드를 참고해서 동기화 락 코드를 제거한 클래스를 직접 만들어서 사용하면 됨
한 번에 쓰기
파일의 크기가 크지 않다면 간단하게 한 번에 쓰고 읽는 것도 좋은 방법이 될 수 있음
성능은 가장 빠르지만 결과적으로 메모리를 한 번에 많이 사용하기 때문에 파일의 크기가 작아야 함
CreateFileV4 - 쓰기
package io.buffered;
public class CreateFileV4 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
byte[] buffer = new byte[FILE_SIZE];
for (int i = 0; i < FILE_SIZE; i++) {
buffer[i] = 1;
}
fos.write(buffer);
fos.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File Size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
/* 실행 결과
File created: temp/buffered.dat
File Size: 10MB
Time taken: 8ms
*/
버퍼의 사이즈를 FILE_SIZE(10MB)만큼 만들어서 버퍼에 데이터를 모두 저장하고 한 번에 넘기도록 코드를 작성하고 실행해 보면 8KB의 버퍼를 직접 사용한 V2버전과 오차 범위 정도로 거의 비슷한 성능이 나오는 것을 확인할 수 있음
디스크나 파일 시스템에서 읽고 쓰는 기본 단위가 4KB, 8KB이기 때문에 한 번에 쓴다고 해서 무작정 빠른 것은 아님
ReadFileV4 - 읽기
package io.buffered;
public class ReadFileV4 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
byte[] bytes = fis.readAllBytes();
fis.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File Size: " + bytes.length / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
/* 실행 결과
File created: temp/buffered.dat
File Size: 10MB
Time taken: 3ms
*/
readAllBytes() 메서드를 활용하여 한번에 데이터를 읽어오면 8KB의 버퍼를 사용한 V2와 거의 비슷한 성능을 보여주는 것을 확인할 수 있음
readAllBytes()는 자바 구현에 따라 다르지만 보통 4KB, 8KB, 16KB 단위로 데이터를 읽음
정리
- 파일의 크기가 크지 않아서 메모리 사용에 큰 영향을 주지 않는다면 쉽고 빠르게 한 번에 처리하면 됨
- 성능이 중요하고 큰 파일을 나누어 처리해야 한다면 버퍼를 직접 다루면 됨
- 성능이 크게 중요하지 않고 버퍼 기능이 필요하면 동기화 코드가 들어있어 스레드 안전하지만 편리하게 버퍼를 사용할 수 있는 BufferedXxx를 사용하면 됨
'인프런 - 실전 자바 로드맵 > 실전 자바 - 고급 2편, 입출력, 네트워크, 리플렉션' 카테고리의 다른 글
I/O 기본, 문자 다루기(시작, 스트림을 문자로, Reader, Writer, BufferedReader), 기타 스트림 (0) | 2025.02.21 |
---|---|
문자 인코딩, 프로젝트 환경 구성, 컴퓨터와 데이터, 컴퓨터와 문자 인코딩, 문자 집합 조회, 문자 인코딩 예제 (1) | 2025.02.20 |