1. 소개
새 클래스를 사용하기 전에, 이 클래스가 왜 필요한지 먼저 이해하는 게 좋아. FileStream으로 파일을 “직접” 다룰 때 무슨 일이 일어나는지 같이 살펴보자.
Read나 Write를 FileStream으로 만든 스트림에 호출하면 실제로는 컴퓨터의 디스크 서브시스템에 접근해. 이 과정 자체는(특히 오래된 HDD에서는, 물론 최신 SSD라도) RAM으로 작업하는 것보다 훨씬 느려. 맥도날드에서 감자튀김을 시킬 때마다 카운터 직원이 창고에 가서 새 봉투를 꺼내 오는 상황을 상상해봐. 줄이 얼마나 길어질지 알겠지?
작은 조각의 데이터를 자주 다루면 디스크나 네트워크에 대한 빈번한 접근 때문에 성능이 떨어져. 데이터 양이 클수록 효과가 더 뚜렷해.
간단한 비유
버퍼 없는 스트림은 마치 요거트 하나 사러 마트에 10번 가는 것과 같아. 버퍼링된 스트림은 한 번에 카트 가득 담아서 가는 거지 — 왕복 횟수를 줄여주는 셈.
2. 클래스 BufferedStream: 첫인상
무엇을 위해 쓰나
BufferedStream은 어떤 Stream이든 감싸는 래퍼야. 메모리에 중간 버퍼를 유지해. 데이터를 쓸 때 먼저 버퍼에 넣고, 버퍼가 찼을 때 한 번에 디스크로 밀어내. 읽을 때도 비슷해서, 첫 읽기 시 어느 정도 블록을 메모리로 로드하고 그다음엔 메모리에서 조금씩 반환해.
코드 예제: BufferedStream 생성
간단한 예제를 만들어보자. 100,000줄을 파일에 쓰는 과제라고 하자:
string filePath = "big_output.txt";
using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
using var bufferedStream = new BufferedStream(fileStream);
using var writer = new StreamWriter(bufferedStream);
for (int i = 0; i < 100_000; i++)
{
writer.WriteLine($"라인 번호 {i}");
}
Console.WriteLine("쓰기 완료!");
설명:
- FileStream으로 파일을 쓰기용으로 연다.
- 그걸 BufferedStream으로 감싸고, 그 다음에 StreamWriter로 감싼다(텍스트 라인을 스트림에 쓰는 역할).
- 버퍼가 차면 데이터가 한 번에 디스크로 밀려나간다.
3. 내부적으로 버퍼링은 어떻게 동작하나
다이어그램으로 보면:
[네 코드] → [StreamWriter] → [BufferedStream] → [FileStream] → [디스크의 파일]
StreamWriter의 WriteLine()을 호출하면 텍스트가 먼저 내부 버퍼에 들어가고, 그 다음 BufferedStream을 통해 또 다른 버퍼로 가고, 버퍼가 찰 때나 스트림이 닫힐 때 디스크로 밀려나.
한 바구니에 몇 바이트?
기본 버퍼 크기는 4096 바이트(4 KB)지만, 명시적으로 지정할 수 있어:
int myBufferSize = 16 * 1024; // 16 KB
using var fileStream = new FileStream(filePath, FileMode.Create);
using var bufferedStream = new BufferedStream(fileStream, myBufferSize);
// ...
실전 팁: 요즘 시스템에선 보통 8–64 KB 버퍼를 쓰는 게 합리적이야. 아주 큰 파일 작업이면 더 크게 써도 되고. 근데 무턱대고 키우면 안 돼: 예를 들어 RAM이 128 KB인 마이크로컨트롤러에서 64 KB 버퍼는 별로야 :)
4. 실험: 버퍼 유무에 따른 속도 비교
얼마나 차이가 나는지 보려면, FileStream으로 버퍼 없이 쓸 때와 버퍼를 썼을 때를 비교하는 테스트를 해보자:
using System.Diagnostics;
using System.Text;
string data = new string('X', 1000); // 1,000 문자
void WriteWithoutBuffer()
{
using var fs = new FileStream("no_buffer.txt", FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: false);
for (int i = 0; i < 10_000; i++)
{
byte[] bytes = Encoding.UTF8.GetBytes(data);
fs.Write(bytes, 0, bytes.Length); // 파일에 직접 쓰기 – 매번 디스크 접근
}
}
void WriteWithBuffer()
{
using var fs = new FileStream("with_buffer.txt", FileMode.Create, FileAccess.Write, FileShare.None);
using var bs = new BufferedStream(fs, 16 * 1024);
for (int i = 0; i < 10_000; i++)
{
byte[] bytes = Encoding.UTF8.GetBytes(data);
bs.Write(bytes, 0, bytes.Length);
}
}
// 시간 측정
Stopwatch sw = Stopwatch.StartNew();
WriteWithoutBuffer();
sw.Stop();
Console.WriteLine("버퍼 없음: " + sw.ElapsedMilliseconds + " ms");
sw.Restart();
WriteWithBuffer();
sw.Stop();
Console.WriteLine("버퍼 있음: " + sw.ElapsedMilliseconds + " ms");
예상 결과:
대부분의 경우 버퍼를 쓰면 속도 향상이 눈에 띄게 나타날 거야. 특히 HDD에서는 더 그렇고, SSD여도 효과는 있지만 덜 극적일 수 있어.
5. 어떤 버퍼를 골라야 하나? 비교와 실전
.NET에는 버퍼링 관련 클래스가 많아. 정리해보자:
| 클래스 | 용도 | 버퍼 내장? | BufferedStream을 써야 하나? |
|---|---|---|---|
|
파일 작업 | 있음 (기본 4 KB) | 거의 필요 없음(하지만 가능) |
|
네트워크 작업 | 아니오 | 매우 권장 |
|
텍스트 읽기/쓰기 | 있음 (약 1 KB) | 보통 불필요 |
|
압축/해제 | 아니오 | 속도 향상을 위해 사용 가능/권장 |
중요:
생성자 파라미터로 bufferSize를 지정한 FileStream은 이미 버퍼링된 스트림이야. 충분히 큰 버퍼를 지정했다면 추가로 BufferedStream을 덧붙여도 큰 이득이 없을 수 있어. 하지만 네트워크 스트림 같은 다른 종류의 스트림을 다루면 BufferedStream이 큰 도움이 돼.
6. 예제: BufferedStream로 파일 복사하기
string source = "big_input.dat";
string dest = "big_output.dat";
int bufferSize = 64 * 1024; // 64 KB
using var inputStream = new FileStream(source, FileMode.Open, FileAccess.Read);
using var outputStream = new FileStream(dest, FileMode.Create, FileAccess.Write);
using var bufferedInput = new BufferedStream(inputStream, bufferSize);
using var bufferedOutput = new BufferedStream(outputStream, bufferSize);
byte[] buffer = new byte[bufferSize];
int bytesRead;
while ((bytesRead = bufferedInput.Read(buffer, 0, buffer.Length)) > 0)
{
bufferedOutput.Write(buffer, 0, bytesRead);
}
// flush를 잊지 마세요 – 마지막 바이트들이 디스크로 가지 않을 수 있어!
bufferedOutput.Flush();
Console.WriteLine("복사 완료!");
설명:
- 64 KB 같은 큰 블록 단위로 BufferedStream을 통해 원본 파일에서 읽는다.
- 같은 크기의 버퍼를 사용해서 다른 파일에 쓴다.
- 루프가 끝난 뒤 마지막 데이터를 위해 반드시 Flush()를 호출하자.
7. 유용한 팁들
언제 진짜로 BufferedStream이 필요한가
- 버퍼가 없는 스트림을 다룰 때(예: NetworkStream이나 당신이 만든 custom Stream);
- 대용량 바이너리 데이터를 처리할 때(예: 파일 복사, 포맷 변환, 백업);
- 기존 코드 최적화를 할 때, 작은 Read/Write 호출이 병목이라면.
비동기와 버퍼링에 대해 조금
ReadAsync/WriteAsync 같은 비동기 작업이 생기면서도 버퍼링은 여전히 유용해. 다만 버퍼 위에서 비동기 메서드를 쓰면 처리 대부분이 메모리 안에서 이뤄지고, 실제 디스크와의 물리적 상호작용은 더 줄어들어.
.NET 8+와 .NET 9에서는 버퍼링이 더 깊게 통합되어 있고 대부분 클래스가 기본적으로 버퍼를 갖고 있어. 그래도 네트워크 스트림이나 직접 구현한 스트림과의 호환성을 위해 수동으로 BufferedStream을 사용하는 경우가 여전히 있어.
비동기에 대한 자세한 내용은 레벨 58에서 배울 거야 :P
버퍼화된 스트림 동작 시각적 도식
flowchart LR
A[너의 코드] --> B[StreamReader/Writer]
B --> C[BufferedStream]
C --> D[FileStream]
D --> E[파일/장치]
- A — 너의 코드가 Write/Read를 호출함.
- B — 텍스트나 데이터를 다루는 상위 레벨 스트림.
- C — 버퍼링(속도 향상을 위해 데이터 묶음 처리).
- D — 구체적인 스트림 구현(파일, 네트워크 등).
- E — 물리적 장치(하드디스크, SSD, 네트워크 등).
실전에서 쓰는 요령들
- 파일에 한 줄씩 쓰는(예: 로깅) 경우라면, 버퍼 크기를 한 줄 크기보다 크게 명시적으로 설정하는 게 좋아. 그러면 한 번에 큰 덩어리로 더 빨리 플러시된다.
- 각 동작을 즉시 디스크에 남겨야 한다면(예: 중요한 로그), 각 쓰기 후에 Flush()를 호출해. 단, 이러면 버퍼링의 이점이 줄어든다!
- 임시 파일을 만들고 바로 삭제할 거라면 버퍼에 남아있어도 상관없을 수 있지만, 파일이 반드시 온전히 기록되어야 한다면 주의하자.
- 수십 기가바이트 같은 아주 큰 파일을 다룰 때는 버퍼 크기를 1_048_576 바이트(1 MB) 이상으로 올려도 괜찮아 — 다만 RAM이 충분해야 한다.
8. 흔한 실수와 사용 시 주의점
지금 당장 모든 곳에 무작정 버퍼를 박아 넣고 싶은 유혹이 들 수도 있지만, 과유불급이야!
자주 하는 실수 중 하나는 Flush()를 호출하거나 스트림을 닫는 것을 잊는 거야. 프로그램이 비정상적으로 종료되면 마지막 바이트들이 메모리 버퍼에 남아 디스크에 기록되지 않을 수 있어. 예를 들어 로그를 파일에 쓰다가 프로세스가 죽으면 마지막 로그가 날아갈 수 있어.
BufferedStream은 네가 보낸 논리적 메시지의 경계(끝)를 “보지” 못해 — 단지 특정 크기의 데이터가 쌓일 때까지 기다려. 그래서 중요한 데이터(로그, 백업 등)는 주기적으로 강제로 Flush()해 주는 게 좋아:
bufferedStream.Flush(); // 버퍼를 강제로 디스크로 밀어냄
또 한 가지: StreamWriter도 자체 버퍼가 있어! 즉, 중첩해서 쓰면 버퍼가 두 번 존재하게 되고 항상 좋은 건 아니야. 보통은 하나의 버퍼 레이어면 충분하고, StreamWriter를 사용하면 추가로 BufferedStream을 덧붙일 필요가 없을 때가 많아.
GO TO FULL VERSION