데이터 통신을 다루다 보면 버퍼라는 개념이 나온다. 버퍼는 왜 쓰이는 것일까?
버퍼에 목적에 대해 알아보자!
파일 입출력과 성능 최적화 1 - 하나씩 쓰기
파일을 효과적으로 더 빨리 쓰러 읽고 쓰는 방법에 대해서 알아보자.
먼저 예제에서 공통으로 사용할 상수들을 정의하자.
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이다.
예제1-쓰기
먼저 가장 단순한 FileOutputStream의 write()를 사용해서 1byte씩 파일에 저장해 보자.
그리고 10MB 파일을 걸리는 시간을 확인해 보자.
package io.buffered;
import java.io.FileOutputStream;
import java.io.IOException;
import static io.buffered.BufferedConst.FILE_NAME;
import static io.buffered.BufferedConst.FILE_SIZE;
public class CreateFileV1_1 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
// 10M byte 파일 생성
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");
}
}
fos.write(1) : 파일의 내용은 중요하지 않기 때문에 여기서는 단순히 1이라는 값을 반복하며 계속 저장한다.
한 번의 호출에 1byte가 만들어진다.
이 메서드를 약 1000 (10 * 1024 * 1024) 만 번 호출하면 10MB의 파일이 만들어진다.
실행을 하면 결과를 보는데 상당히 오랜 시간이 걸린다.
약 36초 정도 소요되었다.
예제 1 - 읽기
package io.buffered;
import java.io.FileInputStream;
import java.io.IOException;
import static io.buffered.BufferedConst.FILE_NAME;
import static io.buffered.BufferedConst.FILE_SIZE;
public class ReadFileV1_1 {
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");
}
}
- fis.read()를 사용해서 앞서 만든 파일에서 1byte씩 데이터를 읽는다.
- 파일의 크기가 10MB 이므로 fis.read() 메서드를 약 10000 (10 * 1024 * 1024) 만 번 호출한다.
실행을 하면 결과를 보는데 상당히 오랜 시간이 걸린다.
총 실행시간은 약 20초가 걸렸다.
정리
10MB 파일 하나를 쓰는데 14초, 읽는데 5초라는 매우 오랜 시간이 걸렸다.
이렇게 오랜 걸린 이유는 자바에서 1byte씩 디스크에 전달하기 때문이다. 디스크는 1byte의 데이터를 받아서 1byte 의 데이터를 쓴다. 이 과정에서 무려 1000만 번 반복하는 것이다.
더 자세히 설명하면 다음 2가지 이유로 느려진다.
write() 나 read()를 호출할 때마다 OS의 시스템 콜(자바가 운영체제를 통해 명령어, 파일 전달)을 통해 파일을 읽거나 쓰는 명령어는 전달한다. 이러한 시스템 콜은 상대적으로 무거운 작업이다.
HDD, SDD 같은 장치들도 하나의 데이터를 읽고 쓸 때마다 필요한 시간이 있다. HDD의 경우 더욱 느린데, 믈리적으로 디스크의 회전이 필요하다.
이러한 무거운 작업을 무려 1000만 번 반복한다
비유를 하자면 창고에서 마트까지 상품을 전달해야 하는데, 화물차에 한 번에 하나의 물건만 가지고 이동하는 것이다.
화물차가 무려 1000만 번 이동을 반복해야 10MB의 파일이 만들어진다.
물론 반대로 데이터를 읽어 들일 때도 마찬가지이다.
이런 문제를 해결하려면 화물차에 더 많은 상품을 담아서 보내면 된다
참고
이렇게 자바에서 운영 체제를 통해 디스크를 1byte씩 전달하면, 운영체제나 하드레어 레벨에서 여러 가지 최적화가 발생한다. 따라서 디스크에 1byte씩 계속 쓰는 것은 아니다. 그렇다면 훨씬 더 느렸을 것이다. 하지만, 자바에서 1바이트씩 write() 나 read()를 호출할 때마다 운영 체제로의 시스템 콜이 발생하고, 이 시스템 콜 자체가 상단 한 오버헤드를 유발한다. 운영 체제와 하드웨어가 어느 정도 최적화를 제공하더라도, 자주 발생하는 시스템 콜로 인한 성능 저하는 피할 수 없다.
결국 자바에서 read(), write() 호출 횟수를 줄여서 시스템 콜 횟수도 줄여야 한다.
파일 입출력과 성능 최적화 2 - 버퍼 활용
이번에는 1byte씩 데이터를 전달하는 것이 아니라 byte []을 통해 배열에 담아서 한번에 여러 byte를 전달해보자
예제 2 - 쓰기
package io.buffered;
import java.io.FileOutputStream;
import java.io.IOException;
import static io.buffered.BufferedConst.*;
public class CreateFileV2_1 {
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");
}
}
- 데이터를 먼저 buffer라는 byte []에 담아둔다.
- 이렇게 데이터를 모아서 전달하거나 모아서 전달받는 용도로 사용하는 것을 버퍼라 한다.
- 여기서 BUFFER_SIZE 만큼 데이터를 모아서 write()를 호출한다.
- 예를 들어 BUFFER_SIZE 가 10이라면 10 만큼 모이면 wirte()를 호출해서 10byte를 한 번에 스트림에 전달한다.
버퍼의 크기에 따른 쓰기 성능
` BUFFER_SIZE` 에 따른 쓰기 성능
1 : 14368ms
2 : 7474ms
3 : 4829ms
10 : 1692ms
100 : 180ms
1000 : 28ms
2000 : 23ms
4000 : 16ms
8000 : 13ms
80000 : 12ms
많은 데이터를 한 번에 전달하면 성능을 최적화할 수 있다. 이렇게 되면 시스템 콜도 줄어들고 , HDD, SDD 같은 장치들의 작동 횟수도 줄어든다. 예를 들어 버퍼틔 크기를 1-> 3로 변경하면 콜 회수는 절반으로 줄어든다.
그런데 버퍼의 크기가 커진다 해서 속도가 계속 줄어들지는 않는다.
왜냐하면 디스크나 파일 시스템에서 데이터를 읽고 쓰는 기본 단위가 보통 4KB 또는 8KB 이기 때문이다.
- 4KB (4096 byte)
- 8KB ( 8192 byte)
결국 버퍼에 많은 데이터를 담아서 보내도 디스크나 파일 시스템에서 해당 단위로 나누어 저장하기 때문에 효율에는 한계가 있다.
따라서 버퍼의 크기는 보통 4KB , 8 KB로 잡는 것이 효율적이다.
예제 2 - 읽기
package io.buffered;
import java.io.FileInputStream;
import java.io.IOException;
import static io.buffered.BufferedConst.BUFFER_SIZE;
import static io.buffered.BufferedConst.FILE_NAME;
public class ReadFileV2_1 {
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");
}
}
읽기의 경우 이전 결과는 20초였고 현재는 8ms이다.
버퍼를 사용하면 큰 성능 향상이 있다. 하지만 직접 버퍼를 만들고 관리해야 하는 번거로운 다점이 있다.
예제 1과 같이 버퍼를 사용하지 않는 단순한 코드를 유지하면서, 버퍼를 사용할 때와 같은 성능의 이점을 누리는 방법은 없을까?
파일 입출력과 성능 최적화 3 - Buffered 스트림 쓰기
BufferedOutputStream 은 버퍼 기능을 내부에서 대신 처리해 준다. 따라서 단순한 코드를 유지하면서 버퍼를 사용하는 이점도 누릴 수 있다.
BufferedOutputStream을 사용해서 코드를 작성해 보자.
package io.buffered;
import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import static io.buffered.BufferedConst.*;
public class CreateFileV3_1 {
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);
}
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");
}
}
- BufferedOutputStream 은 내부에서 단순히 버퍼 기능만 제공한다. 따라서 반드시 대상 OutputStream 이 있어야 한다.
- 여기서 FileOutputStream 객체를 생성자에 전달한다.
- 추가로 사용할 버퍼의 크기도 함께 전달할 수 있다.
- 코드를 보면 버퍼의 byte []를 직접 다루지 않고, 마치 예제 1과 같이 단순하게 코드를 작성할 수 있다.
BufferedOutputStream 분석
BufferdOutputStream 은 OutputStream을 상속받는다. 따라서 개발자 입장에서 보면 OutputStream과 같은 기능을 그대로 사용할 수 있다. 예제에서는 write()를 사용했다.
BufferedOutputStream 실행 순서
- BufferedOutputStream 은 내부 byte [] buf라는 버퍼를 가지고 있다.
- 여기서 버퍼의 크기는 3이라고 가정하겠다.
- BufferedOutputStream에 write(byte)를 통해 byte 하나는 전달하면 byte [] buf에 보관된다.
- 참고로 실제로는 write(int) 타입이지만 쉽게 설명하기 위해 write(byte)로 그려두었다.
write(byte)를 2번 호출했다. 아직 버퍼가 가득차지 않았다
- write(byte) 를 3번 호출하면 버퍼가 가득한다.
- 버퍼가 가득 차면 FileOutputStream에 있는 wirte(byte []) 메서드를 호출한다.
- 참고로 BufferedOutputStream의 생성자에서 FileOutputStream , fos를 전달해 두었다.
- FileOutputStream의 write(byte []) 을 호출하면, 전달된 모든 byte []을 시스템 콜로 OS에 전달한다.
버퍼의 데이터를 모두 전달했기 때문에 버퍼의 내용을 지운다.
이후에 write(byte)가 호출되면 다시 버퍼를 채우는 식으로 반복한다.
flush()
버퍼가 다 차지 않아도 버퍼에 남아있는 데이터를 전달하려면 flush()라는 메서드를 호출하면 된다.
버퍼에 2개의 데이터가 남아있음
flush() 호출
버퍼에 남은 데이터를 전달함
데이터를 전달하고 버퍼를 비움
close()
만약 버퍼에 데이터가 남아있는 상태로 close()를 호출하면 어떻게 될까?
- BufferedOutputStream을 close()로 닫으면 먼저 내부에서 flush()을 호출한다. 따라서 버퍼에 남아있는 데이터를 모두 전달하고 비운다.
- 따라서 close()를 호출해도 남은 데이터를 안전하게 저장할 수 있다.
- 버퍼가 비워지고 나면 close()로 BufferedOutputStream의 자원을 정리한다.
- 그리고 다음 연결된 스트림의 close()를 호출한다. 여기서는 FileOutputStream의 자원이 정리된다.
- 여기서 핵심은 close()를 호출하면 close()가 연쇄적으로 호출된다는 점이다. 따라서 마지막에 연결한 BufferedOutputStream 만 닫아주면 된다.
- 만약 BufferedOutputStream을 닫지 않고, FileOutputStream 만 닫으면 어떻게 될까?
- 이 경우 BufferdOutputStream의 flush() 도 호출되지 않고 , 자원도 정리되지 않는다. 따라서 남은 byte 가 버퍼에 남아있게 되고 파일에 저장되지 않는 심각한 문제가 발생한다.
- 따라서 지금과 같이 스트림을 연결해서 사용하는 경우에는 마지막에 연결한 스트림은 반드시 닫아주어야 한다.
- 마지막에 연결한 스트림만 닫아주면 연쇄적으로 close()가 호출된다.
기본 스트림, 보조 스트림
FileOutputStream과 같이 단독으로 사용할 수 있는 스트림은 기본 스트림이라 한다.
BufferedOutputStream과 같이 단독으로 사용할 수 없고, 보조 기능을 제공하는 스트림을 보조 스트림이라 한다.
public BufferedOutputStream(OutputStream out) { ... }
public BufferedOutputStream(OutputStream out, int size) { ... }
BufferedOutputStream 은 버퍼라는 보조 기능을 제공한다. 그렇다면 누구에게 보조 기능을 제공할지 대상을 반드시 전달해야 한다.
정리
- BufferedOutputStream 은 버퍼 기능을 제공하는 보조 스트림이다.
- BufferedOutputStream 도 OutputStream의 자식이기 때문에 OutputStream 의 기능을 그대로 사용할 수 있다.
- 물론 대부분의 기능은 재정의 된다. write()의 경우 먼저 버퍼에 쌓도록 정재정의된다.
- 버퍼의 크기만큼 데이터를 모아서 전달하기 때문에 빠른 속도로 데이터를 처리할 수 있다.
파일 입출력과 성능 최적화 4 - Buffered 스트림 읽기
예제 3 - 읽기
package io.buffered;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import static io.buffered.BufferedConst.BUFFER_SIZE;
import static io.buffered.BufferedConst.FILE_NAME;
public class ReadFileV3_1 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
BufferedInputStream bis = new BufferedInputStream(fis, BUFFER_SIZE);
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 name: " + FILE_NAME);
System.out.println("File size: " + fileSize / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
- BufferdInputStream 은 InputStream을 상속받는다. 따라서 개발자 입장에서 InputStream과 같은 기능을 그대로 사용할 수 있다. 예제에서는 read()를 사용했다.
BufferedInputStream 실행 순서
read() 호출 전
버퍼의 크기는 3이라고 가정하겠다.
- read()는 1byte 만 조회한다.
- BufferedInputStream 은 먼저 버퍼를 확인한다. 버퍼에 데이터가 없으므로 데이터를 불러온다.
- BufferedInputStream 은 FileInputStream에서 read(byte []) 을 사용해서 버퍼의 크기인 3byte의 데이터를 불러온다.
- 불러온 데이터를 버퍼에 보관한다.
버퍼에 있는 데이터 중에 1byte를 반환한다.
read()를 또 호출하면 버퍼에 있는 데이터 중 1byte 를 반환한다.
read() 를 또 호출하면 버퍼에 있는 데이터 중에 1byte를 반환한다.
- read() 를 호출하는데, 이번에는 버퍼가 비어있다.
- FileInputStream에서 버퍼 크기만큼 조회하고 버퍼에 담아둔다.
- 버퍼에 있는 데이터를 하나 반환한다
- 이런 방식을 반복한다.
정리
BufferedInputStream 은 버퍼의 크기만큼 데이터를 미리 읽어서 버퍼에 보관해 둔다. 따라서 read()를 통해 1byte씩 데이터를 조회해도, 성능이 최적화된다.
버퍼를 직접 다루는 것보다 BufferedXxx의 성능이 떨어지는 이유
- 예제 1 쓰기: 36179ms (36초)
- 예제 2 쓰기: 27ms (버퍼 직접 다룸)
- 예제 3 쓰기: 190ms (BufferedXxx)
예제 2는 버퍼를 직접 다루는 것이고, 예제 3은 BufferedXxx라는 클래스가 대신 버퍼를 처리해 준다. 버퍼를 사용하는 것은 같기 때문에 결과적으로 예제 2와 예제 3은 비슷한 성능이 나와야 한다. 그런데 왜 예제 2가 더 빠른 것일까?
이유는 바로 동기화 때문이다.
BufferedOutputStream.write()
@Override
public void write(int b) throws IOException {
if (lock != null) {
lock.lock();
try {
implWrite(b);
} finally {
lock.unlock();
}
} else {
synchronized (this) {
implWrite(b);
}
}
}
BufferedOutputStream을 포함한 BufferedXxx 클래스는 모두 동기화 처리가 되어있다.
이번 예제의 문제는 1byte씩 저장해서 총 10MB를 저장해야 하는데 이렇게 하려면 write()를 약 1000만 번 호출해야 한다.(10 * 1024 * 1024)
결과적으로 락을 걸고 푸는 코드도 1000만 번 호출된다는 뜻이다.
BufferedXxx 클래스의 특징
BufferedXxx 클래스는 자바 초창기에 만들어진 클래스인데, 처음부터 멀티 스레드를 고려해서 만든 클래스이다. 따라서 멀티 스레드에 안전하지만 락을 걸고 푸는 동기화 코드로 인해 성능이 약간 저하될 수 있다.
하지만 싱글 스레드 상황에서는 동기화 락이 필요하지 않기 때문에 직접 버퍼를 다룰 때와 비교해서 성능이 떨어진다.
일반적인 상황이라면 이 정도 성능은 크게 문제가 되지는 않기 때문에 싱글 스레드여도 BufferedXxx를 사용하면 충분하다.
물론 매우 큰 데이터를 다루어야 하고, 성능 최적화가 중요하다면 예제 2와 같이 직접 버퍼를 다루는 방법을 고려하자.
아쉽게도 동기화 락이 없는 ` BufferedXxx ` 클래스는 없다. 꼭 필요한 상황이라면 BufferedXxx 를 참고해서 동기화 락 코드를 제거한 클래스를 직접 만들어 사용하며 된다.
파일 입출력과 성능 최적화 5 - 한 번에 쓰기
파일의 크기가 크지 않다면 간단하게 한 번에 쓰고 읽는 것도 좋은 방법이다.
이 방법은 성능은 가장 빠르지만, 결과적으로 메모리를 한 번에 많이 사용하기 때문에 파일의 크기가 작아야 한다.
예제 4 - 쓰기
package io.buffered;
import java.io.FileOutputStream;
import java.io.IOException;
import static io.buffered.BufferedConst.FILE_NAME;
import static io.buffered.BufferedConst.FILE_SIZE;
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]; // 10MB
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");
}
}
실행시간은 8KB의 버퍼를 직접 사용한 예제 2와 오차 범위 정도로 거의 비슷하다.
디스크나 파일 시스템에서 데이터를 읽고 쓰고 기본 단위가 보통 4KB 또는 8KB 이기 때문에 , 한 번에 쓴다고 해서 무작정 빠른 것은 아니다.
예제 4 - 읽기
package io.buffered;
import java.io.FileInputStream;
import java.io.IOException;
import static io.buffered.BufferedConst.FILE_NAME;
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 name: " + FILE_NAME);
System.out.println("File size: " + bytes.length / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
- readAllBytes()를 사용하면 한 번에 데이터를 다 읽을 수 있다.
readAllBytes()는 자바 구현에 따라 다르지만 보통 4KB, 8KB, 16KB 단위로 데이터를 읽어 들인다
정리
- 파일의 크기가 크지 않아서, 메모리 사용에 큰 영향을 주지 않는다면 쉽고 빠르게 한 번에 처리하자
- 성능이 중요하고 큰 파일을 나누어 처리해야 한다면, 버퍼를 직접 다루자.
- 성능이 크게 중요하지 않고, 버퍼 기능이 필요하면 BufferedXxx를 사용하자
- BufferedXxx는 동기화 코드가 들어 있어서 스레드 안전하지만, 약간의 성능 저하가 있다.
'JAVA' 카테고리의 다른 글
네트워크 - 프로그램1 (1) | 2025.01.29 |
---|---|
자바 - IO 기본 (InputStream, OutputStream) (1) | 2025.01.28 |
지연 연산 vs 즉시 연산: Kotlin과 Java Stream을 통한 최적화 전략 이해하기 (0) | 2025.01.17 |
Runnable과 Callable (0) | 2025.01.12 |
ReentrantLock (0) | 2025.01.07 |