흔한 덕후의 잡동사니
C# 파일 입출력에 대하여 본문
1. 텍스트 파일 입출력 (StreamWriter, StreamReader)
텍스트 파일을 다룰 때 사용된다. 사람이 읽을 수 있는 형식으로 데이터를 처리한다.
StreamWriter : 텍스트 파일에 데이터를 쓸 때 사용
StreamReader : 텍스트 파일에서 데이터를 읽을 때 사용
using (StreamWriter writer = new StreamWriter("example.txt"))
{
writer.WriteLine("Hello, world!");
}
using (StreamReader reader = new StreamReader("example.txt"))
{
string content = reader.ReadToEnd();
Console.WriteLine(content);
}
문자 단위로 데이터를 처리한다. UTF-8, UTF-16 등 다양한 인코딩 형식을 지원해 상대적으로 단순하고 가독성이 높은 데이터 처리에 유리한 방식이다.
2. 바이너리 파일 입출력 (BinaryWriter, BinaryReader)
텍스트 파일이 아닌 바이너리 데이터를 다룰 때 사용된다. 큰 데이터나 구조체, 이미지, 오디오 파일 등을 처리할 때 유리하다. 이는 이진 모드 특성상 정해진 크기의 데이터를 가져와 메모리에 그대로 올려서 사용하기 때문인데, 추가적인 변환과정 없이 원하는 자료형으로 바로 casting해서 사용하기에 저장된 데이터의 크기가 클수록 더 유리한 것이다.
BinaryWriter : 바이너리 파일에 데이터를 쓸 때 사용
BinaryReader : 바이너리 파일에서 데이터를 읽을 때 사용
using (BinaryWriter writer = new BinaryWriter(File.Open("example.bin", FileMode.Create)))
{
writer.Write(123);
writer.Write("Hello, world!");
}
using (BinaryReader reader = new BinaryReader(File.Open("example.bin", FileMode.Open)))
{
int number = reader.ReadInt32();
string text = reader.ReadString();
Console.WriteLine($"{number} - {text}");
}
바이너리 방식을 사용하여 텍스트 형식으로 변환 없이 구조체나 객체를 직접 읽고 쓸 수 있다. 텍스트 형식에 비해 더 빠르고, 메모리 사용이 효율적이며, bit 단위로 저장하는 방식 특성상 직렬화 및 역직렬화와 함께 사용될 수 있다.
3. 파일 스트림 입출력 (FileStream)
파일을 바이트 단위로 읽고 쓸 때 사용한다. 바이너리 파일을 다룰 때 유용하며, 텍스트가 아닌 데이터를 다룰 때 사용한다.
FileStream : 파일에 데이터를 바이트 단위로 읽거나 쓸 때 사용
using (FileStream fs = new FileStream("example.dat", FileMode.Create))
{
byte[] data = new byte[] { 1, 2, 3, 4, 5 };
fs.Write(data, 0, data.Length);
}
using (FileStream fs = new FileStream("example.dat", FileMode.Open))
{
byte[] data = new byte[5];
fs.Read(data, 0, data.Length);
Console.WriteLine(string.Join(",", data));
}
바이트 단위로 데이터를 처리. byte 단위이다! 이진 형식이 아니다. byte는 binary와 다르다!
byte 단위로 데이터를 관리하기에 파일 크기나 포맷에 관계없이 데이터를 처리할 수 있다.
텍스트 파일(StreamWriter, StreamReader)보다 성능상 이점이 있음. 왜? 텍스트 파일 입출력은 텍스트 형식을 위해 내부적으로 인코딩 과정을 거친다. 이로인해 성능이 상대적으로 떨어진다.
임시 파일, 큰 파일 등의 처리에 유리한데, 이는 인코딩과 관련이 있다. 텍스트 방식은 인코딩 된 크기(ex UTF-8, 16)를 읽은 후 문자 단위로 데이터를 변환 후 저장한다. 그에 반해 바이트 단위로 데이터를 처리하면 변환과정없이 계속 데이터의 처리가 가능하다. 그래서 상대적으로 바이트 방식이 텍스트 방식보다 유리한 것이다.
4. 메모리 맵핑 파일 (MemoryMappedFile, MemoryMappedViewAccessor)
파일의 일부를 메모리 공간에 매핑하여 성능을 최적화하고, 여러 프로세스 간 데이터 공유에 유리한 방식
MemoryMappedFile : 파일을 메모리에 매핑하는 클래스
MemoryMappedViewAccessor : 메모리 맵핑된 파일에 데이터를 읽고 쓸 수 있는 클래스
using (var mmf = MemoryMappedFile.CreateFromFile("example.dat", FileMode.OpenOrCreate, "exampleMap", 1000))
{
using (var accessor = mmf.CreateViewAccessor())
{
accessor.Write(0, 12345);
int value = accessor.ReadInt32(0);
Console.WriteLine($"Value: {value}");
}
}
파일을 메모리처럼 다룰 수 있어 I/O 성능이 뛰어나다.
여러 프로세스가 동일한 메모리 맵핑 파일을 공유할 수 있다.
큰 파일을 효율적으로 처리할 수 있음. 그에 반해 작은 파일의 경우 1,2,3번 방식이 유리하다. 어느정도 큰 파일부터 유리한지는 CPU 성능에 따라 달라지기에 따로 테스트가 요구된다. 또한 최대 크기에 제한이 있기에 CreateFileMapping 함수로 반환된 핸들로 최대 크기를 확인해야한다. C#에서는 CreateFileMapping 함수가 존재하지 않기에 kernel32.dll 에서 함수를 가져와야한다.
또한 큰 파일을 효율적으로 처리할 수 있는 이유 중 하나가 말 그대로 메모리를 매핑하여 사용되어 디스크 I/O 작업을 최소화하고, 원하는 위치를 알고 있다면 일반적인 파일 입출력이 처음부터 탐색하여 찾아야하는 반면, 메모리 맵 파일은 해당 위치를 가상 메모리 주소를 통해 접근하기에 바로 접근이 가능하기 때문이다.
그런데 문제가 하나 있다. 메모리 맵 파일과 파일 입출력 API를 동시에 사용할 때 문제가 되는데, 메모리 맵 파일을 통해 변경된 자료는 즉시 반영되지 않으므로 그 지점에 해당하는 자료를 파일 입출력 API를 사용하여 읽어오려 할 때 동기화 문제가 발생하게 된다. 따라서 메모리 맵 파일을 사용할 때에는 읽기 전용으로만 사용하는 목적이 아닌 이상 파일 입출력 API를 사용하지 않는 것이 좋다.
(메모리 맵 파일은 작업 중인 데이터가 실제 파일로 바로 반영되지는 않으며, 페이지 교체 알고리즘에 따라 해당 페이지가 메모리에서 내려갈 경우, 또는 메모리 맵 파일을 닫는 과정에서 반영된다.)
5. 비동기 파일 입출력 (StreamWriter, StreamReader, FileStream의 비동기 메서드)
파일 입출력 작업이 비동기적으로 이루어질 때, 특히 UI 애플리케이션에서 멀티스레딩 및 비동기 처리를 통해 UI가 멈추지 않도록 해야 할 때 사용한다.
WriteAsync, ReadAsync : 비동기적으로 파일을 읽거나 쓸 때 사용
using (StreamWriter writer = new StreamWriter("example.txt"))
{
await writer.WriteLineAsync("Hello, async world!");
}
using (StreamReader reader = new StreamReader("example.txt"))
{
string content = await reader.ReadToEndAsync();
Console.WriteLine(content);
}
비동기 I/O 처리를 통해 반응성 관련 성능과 충분한 스레드가 있을 경우 처리 성능 향상이 있다. 꼭 충분한 스레드가 있어야한다. 물론 내부적으로 IOCP 방식을 사용하기에 보통은 문제가 없다. 단, 정말 주의해야할 것이 있는데, C# 내부의 TreadPool에서 IOCP의 병렬 스레드 갯수를 조작할 수 있다. 만약 ThreadPool.SetMaxThreads()를 사용해 최대 크기를 줄이는 등 스레드 갯수에 제한을 걸어버리면, 이를 사용하는 모든 작업들(ex) Task, async/await 등등 내부적으로 IOCP를 사용하는 모든 비동기 작업들) 에도 영향을 미쳐버린다! 그렇다고 최대 크기를 마구 늘려버리면 무분별한 컨텍스트 스위칭으로 인해 성능이 더 떨어진다! 그러니 cpu가 바뀔 때 마다 적절한 스레드 갯수를 테스트하여 최적의 갯수를 찾아내는 것이 중요하다.
다른 스레드에게 I/O 작업을 맡기기에 바로 다른 작업을 이어서 할 수 있다.
이 또한 문제가 있는데, 생성되는 스레드가 포그라운드 스레드가 아닌 백그라운드 스레드이다. 이로 인해 작업 도중 프로세스의 모든 포그라운드 스레드가 종료될 경우 작업 내용이 소실되는 일이 발생할 수 있다.
결론
C#에서 파일 입출력 방식은 크게 텍스트 기반, 바이너리 기반, 메모리 기반으로 나눌 수 있다. 각 방식은 사용 목적과 성능에 따라 선택되어야 하며, 대용량 데이터나 빠른 성능이 요구되는 경우에는 바이너리 방식이나 메모리 맵핑 파일을 사용하는 것이 유리하다. 반면 텍스트 데이터를 다룰 때는 StreamWriter/StreamReader 방식이 간단하고 직관적이다. 비동기 입출력도 UI가 멈추지 않도록 하는등, 유저에게 보이는 반응성에 있어 중요하다.
가능하면 [ 4. 메모리 맵 파일] 이나 [ 5. 비동기 파일 입출력 ]을 사용할 시 테스트를 통해 최적의 성능을 낼 수 있는 조건을 찾아내는 것이 좋다.
'언어 관련 > C#' 카테고리의 다른 글
C++ 템플릿과 C# 제네릭의 차이점 (0) | 2025.02.11 |
---|---|
new 키워드, 그리고 boxing/unboxing에 대하여 (0) | 2025.01.31 |
List<T>와 LinkedList<T> 의 차이점(ps. 트리 구조) (0) | 2025.01.27 |
C#의 스레드에 관하여 (0) | 2025.01.14 |