2025. 3. 9. 23:59ㆍ개발/C#
이 글은 박상현 님의 '이것이 C#이다 개정판'을 참고하여 공부한 내용입니다.
System.IO는. NET에서 제공하는 파일 및 데이터 스트림 등을 지원하는 네임스페이스입니다. 저희는 여기에 선언된 객체들을 통해 C# 코드에서 파일 및 디렉터리 작업을 수행할 수 있습니다.
File, FileInfo, Directory, DirectoryInfo
C#에서 파일과 디렉터리를 다룰 때 사용하는 주요 클래스들입니다. 이름에서 알 수 있듯이 File과 FileInfo는 파일을 다룰 수 있는 클래스이며, Directory와 DirectoryInfo는 디렉터리를 다루는 클래스입니다.
그리고 File과 Directory는 정적 메서드를 통해 파일과 디렉터리 작업을 지원하여 간단하게 사용할 수 있습니다. 반대로 FileInfo와 DirectoryInfo는 인스턴스기반으로 파일과 디렉터리를 작업을 지원하며, 동일한 개체에 여러 번 접근할 경우 상태를 저장하기에 더 효율적으로 사용할 수 있습니다.
File/Directory
두 클래스는 정적 메서드를 통해 생성, 삭제, 이동, 복사, 읽기/쓰기 등과 같은 작업을 수행할 수 있습니다.
using System;
using System.IO;
class Program
{
static void Main()
{
string dirPath = "TestFolder";
string filePath = dirPath + "/test.txt";
Directory.CreateDirectory(dirPath);
if (Directory.Exists(dirPath))
{
Console.WriteLine("Directory is already exist.");
}
File.WriteAllText(filePath, "Hello, File!");
if (File.Exists(filePath))
{
Console.WriteLine("File is already exist.");
}
string content = File.ReadAllText(filePath);
Console.WriteLine("File content: " + content);
File.Delete(filePath);
Directory.Delete(dirPath);
}
}
Directory is already exist.
File is already exist.
File content: Hello, File!
위 예시는 File과 Directory의 정적 메서드를 통해 파일과 디렉터리를 생성, 조회, 쓰기, 읽기, 삭제 작업을 수행한 코드입니다. 마지막 Delete메서드를 주석처리하여 실제로 파일과 디렉터리가 생성 됐는지 탐색기를 열어 확인할 수 있습니다.
FileInfo/DirectoryInfo
인스턴스 기반으로 사용하는 파일, 디렉터리 클래스입니다. 인스턴스이기 때문에 상태값으로 생성시간과 같은 정보를 가지고 있으며, 파일과 같은 경우 바로 읽고 쓰는 것이 아닌 Stream을 반환하여 사용합니다. 디렉터리의 경우 하위와 상위 디렉터리에 대한 DirectoryInfo 목록을 배열로 조회할 수 있으며 현재 디렉터리에 있는 파일에 대한 FileInfo 목록도 배열로 반환받을 수 있습니다.
using System;
using System.IO;
class Program
{
static void Main()
{
string dirPath = "TestFolder";
string filePath = dirPath + "/test.txt";
DirectoryInfo dirInfo = new DirectoryInfo(dirPath);
FileInfo fileInfo = new FileInfo(filePath);
dirInfo.Create();
if (dirInfo.Exists)
{
Console.WriteLine("Directory is already exist.");
}
using (StreamWriter writer = fileInfo.CreateText())
{
writer.WriteLine("Hello, FileInfo!");
}
if (fileInfo.Exists)
{
Console.WriteLine("File is already exist.");
}
string content = File.ReadAllText(filePath);
Console.WriteLine("File content: " + content);
fileInfo.Delete();
dirInfo.Delete();
}
}
Directory is already exist.
File is already exist.
File content: Hello, FileInfo!
위 예시는 FileInfo와 DirectoryInfo를 사용하여 전과 동일한 작업을 수행하는 코드입니다. 중간에 보이는 using은 전에 C# 종료자, Finalize, IDisposable 에서 설명한 IDisposable을 사용한 방법으로, using 문이 끝나면 사용된 Stream이 Dispose를 호출하여 자동으로 해제되는 방식입니다.
System.IO.Stream
Stream는 데이터를 읽고 쓸 수 있게 해주는. NET의 기본 추상 클래스입니다. Stream은 이름 그래도 연속적인 데이터의 흐름을 뜻하며 다양한 데이터 입출력 매체들의 데이터를 다룰 수 있도록 설계되었습니다. 처음에 말했던 데로 Stream은 추상 클래스이기 때문에 직접 인스턴스를 생성할 수 없고 다양한 파생 클래스로 사용해야 합니다. 그리고 Stream은 데이터를 바이트 단위로 처리하기에, 기본적으로 byte [] 배열 형식을 통해 데이터를 읽고 씁니다.
아래는 주로 사용되는 클래스들입니다.
Stream 파생 클래스 | 비고 |
FileStream | 파일에 대한 Stream 제공, 동기/비동기 읽기/쓰기 지원 |
MemoryStream | RAM(메모리)에 대한 Stream 제공, 데이터 임시저장소로 주로 사용 |
NetworkStream | 네트워크에 대한 Stream 제공, TCP/IP 소켓통신, 클라이언트-서버 데이터 전송 |
BufferedStream | 버퍼 계층을 지원, IO 성능을 높이기 위해 사용 |
이 중 FileStream을 사용하여 입출력 코드를 작성해 보겠습니다.
using System;
using System.IO;
class Program
{
static void Main()
{
ulong value = 0xFEDCBA9876543210;
Console.WriteLine("{0,-13} : 0x{1:X16}", "Original Data", value);
using (Stream outStream = new FileStream("a.dat", FileMode.Create))
{
byte[] bytes = BitConverter.GetBytes(value);
Console.Write("{0,-13} : ", "Byte array");
foreach (byte b in bytes)
{
Console.Write("{0:X2} ",b);
}
Console.WriteLine();
outStream.Write(bytes, 0, bytes.Length);
}
using (Stream inStream = new FileStream("a.dat", FileMode.Open))
{
byte[] bytes = new byte[8];
int pos = 0;
while (inStream.Position < inStream.Length)
bytes[pos++] = (byte)inStream.ReadByte();
ulong readValue = BitConverter.ToUInt64(bytes, 0);
Console.WriteLine("{0,-13} : 0x{1:X16} ", "Read Data", readValue);
}
}
}
Original Data : 0xFEDCBA9876543210
Byte array : 10 32 54 76 98 BA DC FE
Read Data : 0xFEDCBA9876543210
실행하면 아래와 같은 결과가 나옵니다. 처음 using문은 파일을 생성하여 쓰기 작업을 하였고 두 번째 using 문은 생성된 파일을 열어 파일의 내용을 읽었습니다.
읽을 때 사용한 while문을 보면 Position과 Length 속성을 사용한 것을 확인할 수 있습니다. 여기서 Position은 Stream에서 현재 위치를 나타냅니다. 스트림에서 1 Byte 읽으면 Position은 1 증가합니다. 그리고 Position을 사용하여 스트림 내부의 임의 접근도 가능합니다. Position을 변경하기 위해서는 Seek메서드를 사용하여 변경할 수 있습니다. 마지막 Length는 스트림의 바이트 길이를 뜻합니다. 즉 스트림의 바이트를 모두 읽으면 반복문이 종료된다는 의미입니다.
그리고 출력 부분을 확인하면 BitConverter로 변환한 byte [] 배열의 순서가 작은 바이트부터 시작된 것을 확인할 수 있습니다. 이는 CLR의 호스트 바이트 오더가 리틀 엔디안 방식이기 때문입니다. 리틀 엔디안은 이름 대로 바이트를 작은 번호부터 저장하는 방식을 뜻합니다. 반대로 빅 엔디안은 큰 번호부터 저장하는 방식입니다.
Little Endian(리틀 엔디안) | |||||||
0x100 | 0x101 | 0x102 | 0x103 | 0x104 | 0x105 | 0x106 | 0x107 |
10 | 32 | 54 | 76 | 98 | BA | DC | FD |
Big Endian(빅 엔디안) | |||||||
0x100 | 0x101 | 0x102 | 0x103 | 0x104 | 0x105 | 0x106 | 0x107 |
FD | DC | BA | 98 | 76 | 54 | 32 | 10 |
데이터를 읽거나 쓰는 방식이 하나의 엔디안으로 통일되었다면 문제가 없지만, 리틀 엔디안으로 저장한 데이터를 빅 엔디안으로 읽고 저장할 경우 다른 값이 전달되는 치명적인 문제가 발생합니다. 그렇기에 네트워크 간 데이터를 전송할 때 네트워크 바이트 오더는 빅 엔디안으로 사용하기로 하였습니다. 그렇기에 호스트 바이트 오더가 리틀 엔디안이라면 데이터를 빅 엔디안 순서로 변환해서 전송해야 합니다.
BinaryWriter/BinaryReader
이 두 클래스는 스트림을 사용하여 데이터를 바이너리 형식으로 쓰고 읽는 기능을 합니다. 텍스트 형식이 아닌 바이너리 형식을 사용하기에 문자형이 아닌 숫자형 자료형을 사용하는 것이 더 적합합니다. 사용법은 아래와 같습니다.
using System;
using System.IO;
class Program
{
static void Main()
{
string filename = "data.bin";
using (BinaryWriter writer = new BinaryWriter(File.Open(filename, FileMode.Create)))
{
writer.Write(4);
writer.Write(3.14f);
writer.Write("Hello");
writer.Write(true);
}
}
}
우선 BinaryWriter는 스트림을 인자로 받아 생성됩니다. 그래서 File.Open 메서드를 사용하여 FileStream을 생성하여 BinaryWriter의 인자로 쓰인 모습을 확인할 수 있습니다. 그리고 BinaryWriter의 Write는 여러 가지 형식으로 오버로드 되어있기 때문에 간편히 사용할 수 있습니다.
저장된 파일을 읽어보면 리틀 엔디안 방식으로 저장하여 쓰기 때문에 첫번째 쓸 값인 int의 4가 00 바이트에 04가 쓰인 것을 확인할 수 있습니다. 그리고 int의 나머지 바이트를 채우고 다음 float인 3.14 f를 바이너리로 변형하여 동일한 리틀 엔디안 방식으로 4바이트 쓰인 걸 볼 수 있습니다.
다음 문자열은 길이를 먼저 쓰기 때문에 08번 바이트에 "Hello"의 길이인 5가 쓰인것을 확인할 수 있고, 쓸려는 문자열인 "Hello"의 문자 0x48 = 'H', 0x65 = 'e'와 같이 0D번 바이트까지 쓰인 것을 알 수 있습니다. 그리고 마지막으로 bool을 저장하기 위해 1바이트를 사용하여 0E번 바이트에 쓰였습니다.
만약 문자열 길이가 255가 넘어가면 어떻게 될까요? 그러면 BinaryWirter는 내부적으로 Write7BitEncodedInt 메서드를 사용하여 문자열 길이를 7bit 인코딩하여 쓰게 됩니다. 7bit 인코딩은 1바이트 중 제일 큰 최상위 비트를 플래그로 사용합니다. 이 플래그가 있다면 뒤에 읽을 숫자가 더 있다는 뜻이 됩니다. 그래서 표현하려는 숫자를 바이너리 형식으로 변환한 뒤 7bit씩 잘라서 플래그와 같이 기입하는 것입니다. 예시를 들어보겠습니다.
문자열 길이 500을 이진법으로 나타내면 다음과 같습니다. 0b111110100 이 숫자를 7비트씩 나눕니다. 그럼 0b11과 0b1110100 나뉘게 될 것입니다. 여기서 리틀 엔디안 방식을 사용하기에 작은 바이트부터 쓰게 됩니다. 0b1110100, 0b11 여기서 최상위 비트에 플래그를 기입합니다. 0b11110100, 0b11 이것을 16진법으로 표현하면 위 그림과 같은 0xF4, 0x03이 됩니다.
이제 바이너리 형식을 다시 읽어 오겠습니다.
using System;
using System.IO;
class Program
{
static void Main()
{
string filename = "data.bin";
using (BinaryReader reader = new BinaryReader(File.Open(filename, FileMode.Open)))
{
int number = reader.ReadInt32();
float pi = reader.ReadSingle();
string str = reader.ReadString();
bool flag = reader.ReadBoolean();
Console.WriteLine($"int: {number}, float: {pi}, string: {str}, bool: {flag}");
}
}
}
int: 4, float: 3.14, string: Hello, bool: True
BinaryWriter와 동일하게 스트림을 받아 사용하게 됩니다. 이때 주의해야 할 것은 BinaryWriter가 쓴 순서대로 읽어와야 한다는 것입니다. 마지막으로 엔디안 오더가 다른 환경에서 읽게 된다면 예상치 못한 값을 반환받을 수도 있습니다.
StreamWriter/StreamReder
위 BinaryWriter와 BinaryReader가 바이너리 형식을 사용했다면 StreamWriter와 StreamReader는 문자열 데이터를 처리합니다. 즉 텍스트 문서를 작성할 수 있는 클래스입니다. 사용법을 확인해 보겠습니다.
using System;
using System.IO;
class Program
{
static void Main()
{
string filename = "example.txt";
using (StreamWriter writer = new StreamWriter(filename))
{
writer.WriteLine("Hello, World!");
writer.WriteLine(42);
writer.WriteLine(3.14f);
writer.WriteLine(true);
}
}
}
StreamWriter는 스트림을 받기도 하지만 문자열로 받을 경우 해당 경로의 파일에 대한 StreamWriter를 반환합니다. 그리고 Write/WriteLine 메서드도 다양한 형식으로 오버로드 되어있기 때문에 간편히 사용할 수 있습니다. 마지막으로 입력된 값들이 모두 문자열로 변환되어 텍스트 문서에 쓰인 것을 확인할 수 있습니다.
using System;
using System.IO;
class Program
{
static void Main()
{
string filename = "example.txt";
using (StreamReader reader = new StreamReader(filename))
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
}
}
}
Hello, World!
42
3.14
True
StreamReder는 Read/ReadLine과 같은 메서드를 사용하게 해당 스트림 혹은 파일에서 데이터를 문자형으로 읽어옵니다. 그리고 EOF에 도달하면 Read는 -1을 반환하고 ReadLine은 null을 반환합니다. 그 밖에도 ReadToEnd와 같은 스트림 안의 모든 문자를 읽는 메서드도 있습니다.
Serialize
직렬화는 클래스와 같은 복합 데이터 형식을 스트림에서 간편하게 읽고 쓸 수 있게 지원합니다. HTTP 서버와 클라이언트에서 JSON을 사용해서 데이터를 교환한다고 하면, 직렬화를 사용해 간편하게 데이터를 변환하여 교환할 수 있을 것입니다.
주로 사용했던 형식은 바이너리, JSON, XML이 있습니다. 그런데 여기서 바이너리 형식은 보안취약점이 발견되어 사용이 권장되지 않습니다. 보안취약점을 요약하자면 역직렬화 과정에서 공격자에 의한 악의적인 코드가 실행되기 때문입니다. 그래서 아래와 같은 코드를 컴파일하려고 하면 경고가 발생합니다.
using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
[Serializable]
class Person
{
public string Name;
public int Age;
}
class Program
{
static void Main()
{
Person person = new Person { Name = "Alice", Age = 25 };
string filename = "person.dat";
using (FileStream fs = new FileStream(filename, FileMode.Create))
{
BinaryFormatter formatter = new BinaryFormatter();
formatter.Serialize(fs, person);
}
using (FileStream fs = new FileStream(filename, FileMode.Open))
{
BinaryFormatter formatter = new BinaryFormatter();
Person loadedPerson = (Person)formatter.Deserialize(fs);
Console.WriteLine($"Name: {loadedPerson.Name}, Age: {loadedPerson.Age}");
}
}
}
SYSLIB0011 'BinaryFormatter' is obsolete: 'BinaryFormatter serialization is obsolete and should not be used. See https://aka.ms/binaryformatter for more information.'
사용하면 안 된다고 강력히 권고하고 있습니다. 그렇다면 경고를 무시하면 어떻게 될까요?
#pragma warning disable SYSLIB0011
해당 전처리문을 사용하면 경고 없이 컴파일이 가능합니다. 하지만 실행하면 formatter.Serialize()에서 예외가 발생합니다.
Unhandled exception. System.NotSupportedException: BinaryFormatter serialization and deserialization are disabled within this application. See https://aka.ms/binaryformatter for more information.
at System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Serialize(Stream serializationStream, Object graph)
at Program.Main() in C:\Users\leebo\source\repos\TestApp\TestApp\Program.cs:line 24
그래서 가장 권장되는 직렬화는 텍스트 기반의 JSON 형식입니다.
using System;
using System.Text.Json;
using System.IO;
class Program
{
class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
static void Main()
{
Person person = new Person { Name = "Alice", Age = 25 };
string json = JsonSerializer.Serialize(person);
File.WriteAllText("person.json", json);
string loadedJson = File.ReadAllText("person.json");
Person loadedPerson = JsonSerializer.Deserialize<Person>(loadedJson);
Console.WriteLine($"Name: {loadedPerson.Name}, Age: {loadedPerson.Age}");
}
}
Name: Alice, Age: 25
위 코드는 JSON Serializer를 통해 Person 객체 정보를 JSON으로 직렬화하여 문자열로 만들었습니다. 그리고 직렬화된 문자열을 파일에 쓴 다음 다시 파일을 읽고 문자열을 역직렬화를 통해 Person 객체를 생성한 것입니다. 만약 클래스 내부에 부분적으로 직렬화를 하고 싶지 않다면 JsonIgnore 애트리뷰트를 사용하면 됩니다.
using System;
using System.Text.Json;
using System.IO;
using System.Text.Json.Serialization;
class Program
{
class Person
{
public string Name { get; set; }
[JsonIgnore]
public int Age { get; set; }
}
static void Main()
{
Person person = new Person { Name = "Alice", Age = 25 };
string json = JsonSerializer.Serialize(person);
File.WriteAllText("person.json", json);
string loadedJson = File.ReadAllText("person.json");
Person loadedPerson = JsonSerializer.Deserialize<Person>(loadedJson);
Console.WriteLine($"Name: {loadedPerson.Name}, Age: {loadedPerson.Age}");
}
}
Name: Alice, Age: 0
'개발 > C#' 카테고리의 다른 글
C# 네트워크 프로그래밍 (0) | 2025.03.28 |
---|---|
C# 스레드와 태스크 (0) | 2025.03.19 |
C# dynamic (0) | 2025.03.05 |
C# 애트리뷰트 (0) | 2025.02.25 |
C# 리플렉션 (0) | 2025.02.23 |