CodeGym /행동 /C# SELF /성능과 수동 버퍼 관리

성능과 수동 버퍼 관리

C# SELF
레벨 41 , 레슨 3
사용 가능

1. 소개

왜 버퍼링이 필요한가와 성능 비교

버퍼링은 데이터를 큰 '묶음'으로 모아서 디스크에 수십만 번씩 접근하지 않고 한 번에 많이 전송하게 해주는 전략이라는 걸 이미 이해했을 거야. 대량의 데이터에서 특히 성능이 크게 향상돼.

언제 I/O 성능을 고민해야 하나

  • 큰 파일을 다룰 때(기가바이트, 테라바이트 — 예: 회사 전체 로그 모음 같은 경우).
  • 응답 시간이 극히 중요할 때(예: 실시간 로그 처리).
  • 파일 접근 횟수가 매우 많을 때(예: 사진 아카이브 대량 이름 변경/복사).
  • 면접용 데모로 최적화 접근법을 이해하고 있다는 걸 보여주고 싶을 때(그리고 속도를 측정하는 걸 좋아할 때).

.NET에서는 기본적으로 대부분의 파일 스트림에 버퍼링이 적용되어 있지만, 때로는 세밀한 조정이나 특별한 전략이 필요해.

주요 '플레이어' — 어떤 버퍼들이 있는가

클래스 기본 버퍼 크기 변경 가능? 적용처
FileStream
있음 (4096 바이트) 예 (생성자 통해) 기본 파일 스트림
BufferedStream
있음 (4096 바이트) 예 (생성자 통해) 스트림 위의 '래퍼'
StreamReader/Writer
있음 (1024/1024 바이트) 예 (생성자) 텍스트 작업
  • BufferedStream은 다른 스트림을 '포장'해서 성능을 올릴 수 있어(예: 기본 스트림이 버퍼링을 잘 안 하거나 더 큰 버퍼가 필요할 때).
  • 버퍼 크기는 속도와 메모리 사용량 사이의 타협점이야.

2. 예제:

큰 파일을 복사할 때 세 가지 접근법을 비교해보자:

  1. 버퍼 없이 — 한 바이트씩 (안좋은 예제지만 이해하기 쉬움)
  2. 기본 버퍼 사용 — 표준 FileStreamCopyTo
  3. 수동 버퍼 관리 — 우리가 직접 버퍼를 전달하고 크기를 최적화

실험을 위해 파일 복사용 간단한 유틸리티를 만들어보자. 파일 이름은 BigFile.bin이라고 하자.

class FileCopyBenchmarks
{
    // 한 바이트씩 복사 (안티패턴 — 이렇게 하지 마세요!)
    public static void CopyOneByte(string source, string dest)
    {
        using var input = new FileStream(source, FileMode.Open, FileAccess.Read);
        using var output = new FileStream(dest, FileMode.Create, FileAccess.Write);

        int b;
        while ((b = input.ReadByte()) != -1)
        {
            output.WriteByte((byte)b);
        }
    }

    // FileStream의 기본 버퍼로 복사
    public static void CopyWithDefaultBuffer(string source, string dest)
    {
        using var input = new FileStream(source, FileMode.Open, FileAccess.Read);
        using var output = new FileStream(dest, FileMode.Create, FileAccess.Write);

        input.CopyTo(output); // 내부 버퍼 사용 (보통 81920 바이트)
    }

    // 수동 버퍼 제어로 복사
    public static void CopyWithCustomBuffer(string source, string dest, int bufferSize = 1024 * 1024)
    {
        using var input = new FileStream(source, FileMode.Open, FileAccess.Read);
        using var output = new FileStream(dest, FileMode.Create, FileAccess.Write);

        byte[] buffer = new byte[bufferSize];
        int bytesRead;
        while ((bytesRead = input.Read(buffer, 0, buffer.Length)) > 0)
        {
            output.Write(buffer, 0, bytesRead);
        }
    }
}

어떤 함수가 더 빠를까? 실행 시간을 측정해보자.

성능을 올바르게 측정하는 방법

.NET에서는 성능 측정에 Stopwatch를 쓰는 게 가장 쉬워:

static void Measure(Action action, string description)
{
    var sw = Stopwatch.StartNew();
    action();
    sw.Stop();
    Console.WriteLine($"{description}: {sw.ElapsedMilliseconds} ms");
}

이제 같은 파일을 여러 방식으로 복사해보자:

string source = "BigFile.bin";
string dest1 = "copy1.bin";
string dest2 = "copy2.bin";
string dest3 = "copy3.bin";

// 미리 BigFile.bin 파일을 만드세요 (예: 100-500 MB) 또는 아무 큰 파일을 사용하세요.

Measure(() => FileCopyBenchmarks.CopyOneByte(source, dest1), "CopyOneByte (한 바이트씩)");
Measure(() => FileCopyBenchmarks.CopyWithDefaultBuffer(source, dest2), "CopyWithDefaultBuffer (기본)");
Measure(() => FileCopyBenchmarks.CopyWithCustomBuffer(source, dest3, 1024 * 1024), "CopyWithCustomBuffer (1 MB)");

주의할 점과 함정

  • 여러 번 연속 실행하면 OS 캐시가 '워밍업'되어 다음 측정이 더 빨라질 수 있어 — 실제 평가를 위해선 프로그램을 재시작하거나 캐시를 비우는 게 좋아.
  • 파일이 작으면(10–20 KB) 버퍼링의 이점이 거의 보이지 않아 — 파일이 클수록 차이가 커져.
  • 버퍼 크기를 너무 크게(예: 100 MB) 지정하면 메모리 사용량이 급증해서 시스템에 악영향을 줄 수 있어.

결과 시각화: 표

방법 500 MB 파일에서 시간 (ms)
한 바이트씩 100 000+
기본 FileStream / CopyTo 1 000 — 5 000
수동 버퍼 1 MB 700 — 1 200

숫자는 예시지만 경향은 분명해: 버퍼가 클수록 디스크 접근이 적어지고 속도가 빨라진다.

3. 수동 버퍼 관리의 해부

왜 가끔 버퍼 크기를 직접 설정하고 싶을까? 간단한 비유: 이사할 때 컵 하나씩 옮길 수도 있고 큰 박스를 한 번에 옮길 수도 있어. 하지만 박스가 너무 크면 못 들겠지!

수동 버퍼로 읽기가 어떻게 동작하는가

// 수동 버퍼 크기 제어 예제
int bufferSize = 1024 * 1024; // 1 MB
byte[] buffer = new byte[bufferSize];
int read;
while ((read = inputStream.Read(buffer, 0, buffer.Length)) > 0)
{
    outputStream.Write(buffer, 0, read);
}
  • Read는 버퍼 전체를 채우려 시도하지만, 파일이 끝나면 더 적은 바이트를 반환할 수 있어.
  • 버퍼 크기는 보통 32 KB에서 48 MB 사이로 선택해 — 그 이상은 거의 성능 향상이 없음.
  • 동시에 많은 작업이나 스레드가 있다면 사용 메모리를 잊지 말아야 해.

버퍼 크기 실험하기

샘플에서 버퍼 크기를 바꿔보자 (32 KB, 128 KB, 1 MB, 4 MB) 그리고 어디에서 성능이 최고인지 확인해. 보통 '황금중간'은 약 1 MB 정도야.

수동 버퍼가 유리한 시나리오

  • 사용 메모리 양을 제어해야 할 때(예: 약한 서버에서 프로그램을 돌릴 때).
  • 스트림이 자동으로 버퍼링되지 않을 때(NetworkStream, 커스텀 스트림).
  • 많은 병렬 작업을 할 때 — 각 작업에 최적 크기의 버퍼를 할당할 수 있어.
  • 매우 큰 파일(예: 대용량 CSV 변환)을 최대한 빨리 처리하고 싶을 때.

4. 모범 사례와 흔한 실수

버퍼를 무한대로 크게 만들 수 있을까? 메모리가 남아돈다 해도 그렇게 할 필요는 없어. 너무 큰 버퍼는 오히려 성능을 떨어뜨릴 수 있어: 메모리가 놀고 있거나 캐시 문제로 시스템이 느려질 수 있어.

수동 버퍼링이 모든 것을 가속화하는 건 아니야. 작은 파일이나 이미 최적화된 스트림(예: 내부 버퍼가 큰 FileStream)에서는 이득이 거의 없고 코드만 복잡해질 뿐이야.

전형적인 함정: 스트림을 닫지 않거나 예외 처리를 빼먹으면 파일이 잠긴 상태로 남아. 파일 작업할 땐 using과 예외 처리(try-catch)를 꼭 써라.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION