1. 소개
왜 버퍼링이 필요한가와 성능 비교
버퍼링은 데이터를 큰 '묶음'으로 모아서 디스크에 수십만 번씩 접근하지 않고 한 번에 많이 전송하게 해주는 전략이라는 걸 이미 이해했을 거야. 대량의 데이터에서 특히 성능이 크게 향상돼.
언제 I/O 성능을 고민해야 하나
- 큰 파일을 다룰 때(기가바이트, 테라바이트 — 예: 회사 전체 로그 모음 같은 경우).
- 응답 시간이 극히 중요할 때(예: 실시간 로그 처리).
- 파일 접근 횟수가 매우 많을 때(예: 사진 아카이브 대량 이름 변경/복사).
- 면접용 데모로 최적화 접근법을 이해하고 있다는 걸 보여주고 싶을 때(그리고 속도를 측정하는 걸 좋아할 때).
.NET에서는 기본적으로 대부분의 파일 스트림에 버퍼링이 적용되어 있지만, 때로는 세밀한 조정이나 특별한 전략이 필요해.
주요 '플레이어' — 어떤 버퍼들이 있는가
| 클래스 | 기본 버퍼 | 크기 변경 가능? | 적용처 |
|---|---|---|---|
|
있음 (4096 바이트) | 예 (생성자 통해) | 기본 파일 스트림 |
|
있음 (4096 바이트) | 예 (생성자 통해) | 스트림 위의 '래퍼' |
|
있음 (1024/1024 바이트) | 예 (생성자) | 텍스트 작업 |
- BufferedStream은 다른 스트림을 '포장'해서 성능을 올릴 수 있어(예: 기본 스트림이 버퍼링을 잘 안 하거나 더 큰 버퍼가 필요할 때).
- 버퍼 크기는 속도와 메모리 사용량 사이의 타협점이야.
2. 예제:
큰 파일을 복사할 때 세 가지 접근법을 비교해보자:
- 버퍼 없이 — 한 바이트씩 (안좋은 예제지만 이해하기 쉬움)
- 기본 버퍼 사용 — 표준 FileStream과 CopyTo
- 수동 버퍼 관리 — 우리가 직접 버퍼를 전달하고 크기를 최적화
실험을 위해 파일 복사용 간단한 유틸리티를 만들어보자. 파일 이름은 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에서 4–8 MB 사이로 선택해 — 그 이상은 거의 성능 향상이 없음.
- 동시에 많은 작업이나 스레드가 있다면 사용 메모리를 잊지 말아야 해.
버퍼 크기 실험하기
샘플에서 버퍼 크기를 바꿔보자 (32 KB, 128 KB, 1 MB, 4 MB) 그리고 어디에서 성능이 최고인지 확인해. 보통 '황금중간'은 약 1 MB 정도야.
수동 버퍼가 유리한 시나리오
- 사용 메모리 양을 제어해야 할 때(예: 약한 서버에서 프로그램을 돌릴 때).
- 스트림이 자동으로 버퍼링되지 않을 때(NetworkStream, 커스텀 스트림).
- 많은 병렬 작업을 할 때 — 각 작업에 최적 크기의 버퍼를 할당할 수 있어.
- 매우 큰 파일(예: 대용량 CSV 변환)을 최대한 빨리 처리하고 싶을 때.
4. 모범 사례와 흔한 실수
버퍼를 무한대로 크게 만들 수 있을까? 메모리가 남아돈다 해도 그렇게 할 필요는 없어. 너무 큰 버퍼는 오히려 성능을 떨어뜨릴 수 있어: 메모리가 놀고 있거나 캐시 문제로 시스템이 느려질 수 있어.
수동 버퍼링이 모든 것을 가속화하는 건 아니야. 작은 파일이나 이미 최적화된 스트림(예: 내부 버퍼가 큰 FileStream)에서는 이득이 거의 없고 코드만 복잡해질 뿐이야.
전형적인 함정: 스트림을 닫지 않거나 예외 처리를 빼먹으면 파일이 잠긴 상태로 남아. 파일 작업할 땐 using과 예외 처리(try-catch)를 꼭 써라.
GO TO FULL VERSION