실험 환경 공통 상수 정의
성능 비교 예제에서 공통으로 사용할 상수를 정의
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
}
예제 1 – 1바이트씩 읽고 쓰기 (기준점)
쓰기
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("Time taken: " + (endTime - startTime) + "ms");
}
}
File size: 10MB
Time taken: 14092ms
읽기
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("Time taken: " + (endTime - startTime) + "ms");
}
}
File size: 10MB
Time taken: 5003ms
왜 이렇게 느릴까?
- 10MB 파일을 쓰는 데 14초(PC에 따라 다를 수 있음) 읽는데 5초. 체감상 너무 오래 걸리는 수치이다., 이유는 두 가지이다
- 시스템 콜 오버헤드:
write()나read()를 호출할 때마다 OS의 시스템 콜이 발생한다. 자바는 파일에 직접 접근할 수 없고 OS를 통해 명령을 전달해야 하는데, 이 시스템 콜 자체가 무거운 작업이다. 그것을 무려 약 1,000만 번 (10 x 1024 x 1024) 반복한다 - 디스크 I/O 비용: HDD/SSD 같은 저장 장치도 데이터를 읽고 쓸 때마다 고유한 처리 비용이 있다. 특히 HDD는 물리적인 디스크 회전이 수반된다
- 물론 OS와 하드웨어가 내부적으로 어느 정도 최적화(버퍼링)를 수행하기 때문에 실제로 디스크에 1바이트씩 직접 기록하는 건 아니다. 그럼에도 자바에서 1바이트씩 호출할 때마다 발생하는 시스템 콜 횟수 자체를 줄이지 않으면 이 성능 저하를 피할 수 없다
- 비유하자면, 창고에서 마트로 상품을 옮길 때 화물차에 물건을 하나씩 실어 1,000만 번 왕복하는 것과 같다. 해결책은 명확하다. 화물차를 최대한 꽉 채워서 왕복 횟수를 줄이는 것. 즉
read()/write()호출 횟수를 줄이면 된다
예제 2 – 버퍼를 직접 활용한 읽고 쓰기
쓰기
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("Time taken: " + (endTime - startTime) + "ms");
}
}
File size: 10MB
Time taken: 14ms
읽기
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("Time taken: " + (endTime - startTime) + "ms");
}
}
File size: 10MB
Time taken: 5ms
예제1 대비 약 1,000배 정도 성능 향상이다. 핵심은 단순하다. 바이트를 바로 전달하는 대신 byte[] 배열에 모아두었다가, 배열이 가득 차면 그때 한 번에 전달한다. 이것을 버퍼(Buffer)라고 한다
버퍼 크기에 따른 성능 변화
| BUFFER_SIZE | 쓰기 시간 |
| 1 | 14,368ms |
| 2 | 7,474ms |
| 10 | 1,692ms |
| 100 | 180ms |
| 1.000 | 28ms |
| 8.000 | 13ms |
| 80.000 | 12ms |
버퍼 크기가 커질수록 빨라지지만, 일정 크기 이상에는 효과가 거의 없다. 이유는 디스크와 파일 시스템이 데이터를 4KB 또는 8KB 단위로 처리하기 때문이다. 버퍼에 아무리 많은 데이터를 담아 보내도 결국 그 단위로 나뉘어 처리된다. 따라서 버퍼 크기는 4KB(4096) ~ 8KB(8192) 정도가 가장 효율적이며, 이것이 BUFFER_SIZE= 8192로 설정한 이유이다
예제3 – BufferedXxx 스트림 활용
버퍼를 직접 다루면 성능은 좋지만 코드가 복잡해진다. 끝부분의 잔여 데이터 처리 같은 엣지 케이스까지 직접 신경 써야 한다. 자바는 이 불편함을 해결하는 BufferedOutputStream / BufferedInputStream을 제공한다
쓰기
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(); // BufferedOutputStream을 닫으면 내부적으로 flush() 후 fos도 함께 닫힌다
long endTime = System.currentTimeMillis();
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
File size: 10MB
Time taken: 102ms
읽기
public class ReadFileV3 {
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("Time taken: " + (endTime - startTime) + "ms");
}
}
File size: 10MB
Time taken: 94ms
예제1 대비 읽기는 약 50배, 쓰기는 약 140배 빨라졌다. 코드는 예제1만큼 단순하면서 버퍼 효과까지 누릴 수 있다
BufferedOutputStream 동작 원리
BufferedOutputStream은 내부에 byte[] buf를 가지고 있다. 버퍼 크기를 3이라고 가정하면 다음과 같이 동작한다
개발자 → bos.write(byte) → [buf: ■□□] → (미전달)
개발자 → bos.write(byte) → [buf: ■■□] → (미전달)
개발자 → bos.write(byte) → [buf: ■■■] → 버퍼 가득 참
→ fos.write(byte[]) 호출
→ OS 시스템 콜 1회 발생
→ [buf: □□□] 버퍼 초기화
BufferedInputStream은 반대로 동작한다. read() 호출 시 버퍼가 비어 있으면 FileInputStream으로부터 버퍼 크기 만큼 한 번에 읽어 담아두고, 이후 read() 호출 시에는 파일 접근 없이 버퍼에서 1바이트씩 반환한다
flush()와 close()의 관계
- 버퍼가 가득 차지 않은 상태에서도 즉시 전달하고 싶다면
flush()를 명시적으로 호출한다 close()를 호출하면 내부에서 자동으로flush()→ 버퍼 비우기 → 자원 정리가 순서대로 실행된다. 그리고 연결된 스트림의close()까지 연쇄적으로 호출된다- 중요: 스트림을 연결해서 사용할 때는 반드시 마지막에 연결한 스트림(여기서는
bos)만 닫아야 한다. 만약fos를 먼저 직접 닫으면bos의flush()가 호출되지 않아 버퍼에 남아있던 데이터가 파일에 저장되지 않는 심각한 버그가 발생한다
BufferedXxx가 예제2보다 느린 이유 – 동기화
- 예제2(버퍼 직접)가 14ms, 예제3(BufferedXxx)이 102ms로 약 7배 차이가 난다. 버퍼를 사용하는 건 같은데 이유는 무엇일까?
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);
}
}
}
BufferedXxx 클래스는 자바 초장기 멀티 스레드 환경을 고려해 설계된 클래스이다. 때문에 모든 메서드에 동기화 락이 걸려 있다. 10MB를 1바이트씩 쓰면 write()가 약 1,000만 번 호출되고, 그 횟수만큼 락을 걸고 푸는 비용이 발생한다
싱글 스레드 환경에서는 이 동기화 비용이 불필요한 오버헤드이다. 동기화 락이 제거된 BufferedXxx 클래스는 자바 표준 라이브러리에 존재하지 않으므로, 극한의 성능이 필요하다면 직접 구현하거나 예제2 방식을 선택해야 한다
결론: 일반적인 상황에서는 BufferedXxx로 충분하다. 매우 큰 데이터를 다루거나 성능이 임계치인 상황에서만 직접 버퍼를 다루는 방식을 고려하면 된다
기본 스트림과 보조 스트림
| 구분 | 예시 | 특징 |
| 기본 스트림 | BufferedXxx, FileInputStream | 단독 사용 가능, 실제 I/O 수행 |
| 보조 스트림 | BufferedOutputStream, BufferedInputStream | 단독 사용 불가, 기본 스트림에 부가 기능 제공 |
BufferedOutputStream의 생성자가 반드시 OutputStream을 요구하는 것도 이 설계 때문이다
public BufferedOutputStream(OutputStream out) { ... }
public BufferedOutputStream(OutputStream out, int size) { ... }
예제 4 – 한 번에 읽고 쓰기
파일 크기가 크지 않다면 가장 간단한 방법을 선택할 수 있다
쓰기
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("Time taken: " + (endTime - startTime) + "ms");
}
}
File size: 10MB
Time taken: 15ms
읽기
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("Time taken: " + (endTime - startTime) + "ms");
}
}
File size: 10MB
Time taken: 3ms
성능은 예제2와 오차 범위 수준으로 거의 동일하다. 디스크와 파일 시스템이 결국 4KB/8KB 단위로 처리하기 때문에, 10MB를 한 번에 보내든 8KB를 나눠 보내든 디스크 접근 횟수에는 큰 차이가 없다. readAllBytes() 역시 내부적으로 4KB ~ 16KB 단위로 읽어들이도록 최적화되어 있다.
단, 이 방식은 파일 크기가 메모리에 올라오기 때문에 대용량 파일에는 절대 사용하면 안 된다. 수 GB짜리 파일에 readAllBytes()를 호출하는 순간 OutOfMemoryError가 발생한다
성능 비교 정리
| 방식 | 쓰기 | 읽기 | 특징 |
| 1바이트씩 (예제1) | 14,000ms | 5,000ms | 시스템 콜 1,000만번 |
| 버퍼 직접 (예제2) | 14ms | 5ms | 가장 빠름, 코드 복잡 |
| BufferedXxx (예제3) | 102ms | 94ms | 단순한 코드, 멀티스레드 안전 |
| 한 번에 쓰기 (예제4) | 15ms | 3ms | 가장 단순, 소용량 파일 전용 |
소용량 파일은 예제4, 일반적인 경우에는 예제3(BufferedXxx), 초고성능이 필요하거나 대용량 파일을 스트리밍 처리해야 한다면 예제2 방식을 선택하는 것이 좋다