I/O – 성능 최적화

실험 환경 공통 상수 정의

성능 비교 예제에서 공통으로 사용할 상수를 정의

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쓰기 시간
114,368ms
27,474ms
101,692ms
100180ms
1.00028ms
8.00013ms
80.00012ms

버퍼 크기가 커질수록 빨라지지만, 일정 크기 이상에는 효과가 거의 없다. 이유는 디스크와 파일 시스템이 데이터를 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를 먼저 직접 닫으면 bosflush()가 호출되지 않아 버퍼에 남아있던 데이터가 파일에 저장되지 않는 심각한 버그가 발생한다

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,000ms5,000ms시스템 콜 1,000만번
버퍼 직접 (예제2)14ms5ms가장 빠름, 코드 복잡
BufferedXxx (예제3)102ms94ms단순한 코드, 멀티스레드 안전
한 번에 쓰기 (예제4)15ms3ms가장 단순, 소용량 파일 전용

소용량 파일은 예제4, 일반적인 경우에는 예제3(BufferedXxx), 초고성능이 필요하거나 대용량 파일을 스트리밍 처리해야 한다면 예제2 방식을 선택하는 것이 좋다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션