1. 소개
작은 파일을 다룰 땐 별거 아니지만, 파일 크기가 적어도 100–500MB를 넘어가거나 기가바이트 단위가 되면 흥미로운 현상이 나타납니다:
- 연산이 느려짐 — 예를 들어 File.ReadAllBytes()나 File.WriteAllText()를 그대로 쓰면 프로그램 전체가 느려질 수 있음.
- 램이 부족해져서 OutOfMemoryException이 발생할 수 있음.
- 시스템이 스왑을 사용하면 전체 시스템 성능이 저하될 수 있음.
- 병렬 작업이 디스크에 과부하를 줄 수 있음.
현실에서 자주 마주치는 사례:
- 서버 로그(하루에 기가바이트 단위).
- 큰 CSV나 XML 파일의 수출입 처리.
- 비디오, 오디오, 아카이브, 바이너리 파일 작업.
- 큰 어셈블리 복사나 백업.
2. 큰 파일 작업 시 최적화 전략
최적화를 시작하기 전에 무엇을 빠르게/안정적으로 하고 싶은지 정해야 합니다. 흔한 목적은 다음과 같습니다:
- “파일을 가능한 빠르게 읽고 쓰되 시스템을 망치지 않기”.
- “메모리를 과다 사용하지 않도록 부분적으로 파일 처리하기”.
- “메모리에 불필요한 데이터 복사 방지”.
- “가능하면 병렬로 처리(가능한 경우)”.
일반 접근법:
- 스트리밍 읽기/쓰기: 스트림과 버퍼를 사용해 청크 단위로 처리 (FileStream, BufferedStream).
- 디스크에서 직접 작업: 메모리에 임시 복사본을 만들지 않음.
- 버퍼와 메모리 신중 관리: 파일 전체를 메모리에 올리지 않음.
- 비동기 연산: 메인 스레드를 블로킹하지 않으려면(다음 강의에서 상세).
3. 스트리밍 읽기/쓰기: 기본 패턴
핵심 원칙: 파일을 조각으로 다뤄라! 최신 C#에선 FileStream, BufferedStream 등 스트림 클래스로 쉽게 구현할 수 있습니다.
// 읽기용 스트림 열기:
using FileStream fs = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[1024 * 1024]; // 1MB
int bytesRead;
// 파일 끝까지 읽기
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
// 여기서 읽은 데이터를 처리!
// 예: 모든 바이트의 합을 계산해보자 (연습용)
long sum = 0;
for (int i = 0; i < bytesRead; i++)
sum += buffer[i];
Console.WriteLine($"읽은 바이트: {bytesRead}, 합계: {sum}");
}
팁: 버퍼 크기(예: 64KB, 128KB, 1MB)는 실험으로 최적값을 찾아라. 너무 작으면 디스크 접근이 잦아지고, 너무 크면 메모리만 낭비한다.
이유: File.ReadAllBytes()나 File.ReadAllText()처럼 한 번에 전체를 읽는 메서드는 파일 전체를 메모리에 올리려 하기 때문에, 파일이 크면 당연히 OutOfMemoryException이 발생한다.
4. BufferedStream: 언제 왜 쓰는가
이전 강의에서 봤듯이 BufferedStream은 다른 스트림 위에 얹어 블록 단위로 읽기/쓰기하게 해줍니다.
사용 예:
using var fileStream = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
using var bufferedStream = new BufferedStream(fileStream, 1024 * 128);
byte[] buffer = new byte[1024 * 128];
int bytesRead;
while ((bytesRead = bufferedStream.Read(buffer, 0, buffer.Length)) > 0)
{
// 데이터 처리
}
특히 바이트를 한 개씩 읽는 경우 파일 시스템이 블록 접근을 선호하므로 성능 향상을 제공합니다.
재미있는 사실: 최신 .NET의 FileStream은 자체적으로 버퍼링을 제공해서, 네이티브 스트림이나 특수 장치(예: 네트워크) 같은 "로우" 스트림을 쓸 때 BufferedStream을 더 고려해볼 만합니다.
5. 큰 텍스트 파일 읽기/쓰기
바이너리는 블록 단위로 읽으면 되지만, 텍스트(특히 큰 CSV, 로그, JSON)는 어떻게 할까?
여기서 StreamReader(읽기)와 StreamWriter(쓰기)가 도움됩니다.
줄 단위 읽기:
using var reader = new StreamReader("biglog.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
// 한 줄 처리
if (line.Contains("ERROR"))
Console.WriteLine("ERROR 발견: " + line);
}
좋은 점:
- 파일 전체를 메모리에 올리지 않음.
- StreamReader 내부 버퍼링이 이미 최적화되어 있음.
줄 단위 쓰기:
using var writer = new StreamWriter("output.txt");
for (int i = 0; i < 1000000; i++)
writer.WriteLine($"이것은 줄 번호 {i} 입니다");
스트리밍 읽기 아키텍처
[디스크의 파일]
|
[FileStream]
|
[BufferedStream (선택)]
|
[StreamReader/StreamWriter (텍스트용)]
|
[사용자 코드: 데이터 처리]
6. 실무 적용
로그 파일 앱을 확장해 오래된 로그(7일 이상)를 압축해서 하나의 아카이브로 옮긴다고 하자. 파일이 크면 메모리가 부족해지지 않도록 청크 단위로 읽고 써야 합니다.
간단한 파일 복사 예:
void CopyLargeFile(string sourcePath, string destPath)
{
using var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read);
using var destStream = new FileStream(destPath, FileMode.Create, FileAccess.Write);
byte[] buffer = new byte[1024 * 256]; // 256KB
int bytesRead;
while ((bytesRead = sourceStream.Read(buffer, 0, buffer.Length)) > 0)
{
destStream.Write(buffer, 0, bytesRead);
// 여기서 프로그레스 바 업데이트 가능
}
}
사용 사례:
- 백업 파일 복사
- 일별/월별 로그 병합
- 파일 전처리(예: 줄 필터링)
7. 버퍼 효율성 평가 방법
“얼마나 빨라졌나?”를 알고 싶으면 실행 시간을 측정하세요:
var watch = System.Diagnostics.Stopwatch.StartNew();
CopyLargeFile("source.bin", "dest.bin");
watch.Stop();
Console.WriteLine($"복사 시간: {watch.Elapsed.TotalSeconds} 초");
일반적인 버퍼 크기:
- 4KB — 파일 시스템의 최소 블록(참고용).
- 64KB/128KB — 실제로 대부분 상황에서 잘 동작함.
- 1MB 이상 — 매우 빠른 SSD와 큰 파일에서만 의미가 있음.
직접 여러 크기를 시도해 보세요.
8. 큰 파일에서 데이터 검색 및 처리
단순 복사가 아니라 특정 문자열, 숫자, 구문을 찾아야 할 때는 부분 단위 접근이 가장 효율적입니다.
예: 거대한 로그에서 ERROR가 포함된 모든 줄 찾기
using var reader = new StreamReader("server.log");
using var writer = new StreamWriter("errors.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
if (line.Contains("ERROR"))
writer.WriteLine(line); // 필요한 줄만 결과로 보냄
}
이 방식이면 기가바이트 단위 로그도 메모리를 거의 쓰지 않고 처리할 수 있습니다.
9. 매우 큰 파일(>2GB) 작업의 특징
스트리밍을 사용하면 .NET과 Windows는 (수십 TB도) 잘 처리하지만 몇 가지 주의사항이 있습니다.
- 32비트 애플리케이션는 주소 공간이 2GB로 제한되므로 x64를 사용하라!
- 큰 파일에는 항상 64비트 플랫폼(AnyCPU 또는 x64) 권장.
- 4GB 이상의 파일은 FAT32에서 지원되지 않으니 NTFS나 exFAT 사용.
큰 파일의 반복적 처리 흐름
+------------------+
| Start |
+------------------+
|
v
+------------------------------+
| 읽기용 스트림 열기 |
+------------------------------+
|
v
+------------------------------+
| 파일 끝까지 반복 |
+------------------------------+
|
v
+---------------------------+
| 데이터 블록 읽기 |
+---------------------------+
|
v
+---------------------------+
| 블록 처리 |
+---------------------------+
|
v
+--------------------------+
| 다음 블록으로 이동 |
+--------------------------+
|
v
+--------------------+
| 스트림 닫기 |
+--------------------+
10. 유용한 팁
FileStream 주요 메서드(파일을 스트림처럼 다루기)
| 메서드 | 설명 |
|---|---|
|
버퍼에 파일 일부를 읽음 |
|
버퍼의 일부를 파일에 씀 |
|
파일 내 원하는 위치로 이동 |
|
파일 크기(바이트) |
|
현재 파일 위치 |
Seek() 사용 예:
using var stream = new FileStream("bigfile.bin", FileMode.Open);
// 1GB 앞으로 이동!
stream.Seek(1024L * 1024 * 1024, SeekOrigin.Begin);
byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
// 이제 파일 중간에서 데이터 읽기!
사용처:
- 파일 인덱싱
- 특정 블록에 빠르게 접근(예: 대형 DB의 블록)
멀티스레드로 파일 작업하기
가능하면 큰 파일을 블록으로 나눠 병렬 처리할 수 있습니다. 다만 HDD는 랜덤 접근에서 느리니 주의하고, SSD에서는 이점이 더 큽니다.
일반 상황에서는 확실하지 않다면 순차 처리로 시작하라. 병렬성은 여러 파일을 동시에 처리할 때 더 실용적이다(한 파일을 동시에 여러 영역에서 읽고 쓰는 것은 복잡성과 오버헤드가 큼).
11. 큰 파일 작업 시 흔한 실수
실수 #1: 파일 전체를 메모리에 읽음.
초보자들이 File.ReadAllBytes()나 File.ReadAllText()를 큰 파일에 쓰면 앱이 메모리 부족으로 종료됩니다. 스트리밍을 사용하세요.
실수 #2: 너무 작은 버퍼 사용.
작은 버퍼로 자주 디스크를 호출하면 성능이 엉망이 됩니다. 적절한 버퍼 크기를 선택하세요.
실수 #3: 스트림을 닫지 않음.
스트림을 닫지 않으면 파일 디스크립터가 점유된 채로 남아 다른 프로그램에서 접근할 수 없고 OS 오류가 발생할 수 있습니다. 항상 using을 사용하세요.
실수 #4: 같은 파일을 동시에 여러 곳에서 접근.
한 파일을 여러 프로세스/스레드에서 동시에 읽고 쓰면 IOException이 발생할 수 있습니다. 때론 동작하더라도 안정성이 떨어집니다.
GO TO FULL VERSION