.NET/C#

[C# 11 과 .NET 7] 9. File, Streams, Serialization

클리엘 2023. 7. 27. 12:20
728x90

Data에 대한 입출력의 대상은 file이나 stream이 될 수 있으며 때로는 text를 encoding 하거나 직렬화할 수 있습니다.

1. File System 관리

Application에서는 종종 다른 환경에서 file이나 directory등으로 입출력 동작을 수행해야 할 경우가 있으며 System 및 System.IO namespace에서는 이러한 목적의 class들을 포함하고 있습니다.

 

(1) cross-platform 환경및 filesystem

 

우선 cross-platform환경을 처리하는 방법과 Windows와 Linux 또는 macOS사이의 차이점에 대해 알아보고자 합니다. Windows와 macOS 그리고  Linux에서 경로는 다르게 취급되고 있으므로 .NET이 이를 어떻게 처리하는지를 알아둘 필요가 있습니다.

 

csStudy08 Solution에서 WorkingWithFileSystems이름의 Console App project를 생성합니다.  그리고 project에 Helpers.cs 이름의 class file을  추가한뒤 아래와 같이 SectionTitle method를 가진 partial Program class를 정의합니다.

static void SectionTitle(string title)
{
	ConsoleColor previousColor = System.Console.ForegroundColor;
	System.Console.ForegroundColor = ConsoleColor.Yellow;
	Console.WriteLine("*");
	Console.WriteLine($"* {title}");
	Console.WriteLine("*");
	System.Console.ForegroundColor = previousColor;
}

Program.cs에서는 기존의 문을 모두 삭제하고 System.IO.Directory, System.Environment, System.IO.Path namespace를 정적 import한뒤

using static System.IO.Directory; 
using static System.IO.Path; 
using static System.Environment;

여기에 경로 및 directory분리문자를 출력하고, 현재 directory에 대한 경로와 system file, 임시 file, document 등 일부 특별한 용도의 directory에 대한 경로를 출력하는 문을 아래와 같이 작성합니다.

Console.WriteLine("{0,-33} {1}", arg0: "Path.PathSeparator", arg1: PathSeparator);
Console.WriteLine("{0,-33} {1}", arg0: "Path.DirectorySeparatorChar", arg1: DirectorySeparatorChar);
Console.WriteLine("{0,-33} {1}", arg0: "Directory.GetCurrentDirectory()", arg1: GetCurrentDirectory());
Console.WriteLine("{0,-33} {1}", arg0: "Environment.CurrentDirectory", arg1: CurrentDirectory);
Console.WriteLine("{0,-33} {1}", arg0: "Environment.SystemDirectory", arg1: SystemDirectory);
Console.WriteLine("{0,-33} {1}", arg0: "Path.GetTempPath()", arg1: GetTempPath());

Console.WriteLine("GetFolderPath(SpecialFolder)");
Console.WriteLine("{0,-33} {1}", arg0: " .System)", arg1: GetFolderPath(SpecialFolder.System));
Console.WriteLine("{0,-33} {1}", arg0: " .ApplicationData)", arg1: GetFolderPath(SpecialFolder.ApplicationData));
Console.WriteLine("{0,-33} {1}", arg0: " .MyDocuments)", arg1: GetFolderPath(SpecialFolder.MyDocuments));
Console.WriteLine("{0,-33} {1}", arg0: " .Personal)", arg1: GetFolderPath(SpecialFolder.Personal));
Environment type에서는 위의 예제에서 사용된 것 이외 GetEnvironmentVariables method와 OSVersion, ProcessorCount속성등 여러 가지 유용한 member들을 가지고 있습니다.

예제를 실행하면 다음과 같은 결과를 표시할 것입니다.

Visual Studio Code에서 dotnet run을 사용해 console app을 실행하는 경우 CurrentDirectory는 bin folder 내부가 아닌  project folder가 될 것입니다.

Windows에서는 directory에 대한 구분문자로 backslash(\)문자를 사용하지만 macOS와 Linux는 slash문자를 사용합니다. 경로를 결합할 때 code에서 어떤 문자가 사용되는지를 미리 가정해서는 안됩니다.

(2) Drive

 

Drive에 관한 것으로는 computer에 연결된 모든 drive에 관한 정보를 반환하는 정적 method를 가진 DriveInfo type을 사용합니다. 이때 각각의 drive는 drive type을 가집니다.

 

Program.cs에서 모든 drive를 가져와 사용가능한 것들에 대해서만 drive의 이름, type, size, 사용 가능한 용량 그리고 format을 출력하는 문을 작성합니다.

SectionTitle("My drives");
Console.WriteLine("{0,-30} | {1,-10} | {2,-7} | {3,18} | {4,18}", "NAME", "TYPE", "FORMAT", "SIZE (BYTES)", "FREE SPACE");
foreach (DriveInfo drive in DriveInfo.GetDrives())
{
	if (drive.IsReady)
	{
		Console.WriteLine("{0,-30} | {1,-10} | {2,-7} | {3,18:N0} | {4,18:N0}", drive.Name, drive.DriveType, drive.DriveFormat, drive.TotalSize, drive.AvailableFreeSpace);
	}
	else
	{
		Console.WriteLine("{0,-30} | {1,-10}", drive.Name, drive.DriveType);
	}
}
TotalSize와 같은 속성을 읽기 전에 drive가 준비상태인지를 확인해야 합니다. 그렇지 않으면 이동식 drive에 관한 예외를 보게 될 것입니다.

예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.

(3) Directory

 

Directory와 관련해서는 Directory, Path 그리고 Environment 적정 class를 사용합니다. 이들 type에는 filesystem에 관한 다수의 member들을 가지고 있습니다.

 

사용자지정 경로를 구성할 때는 예를 들어 directory분리문자를 사용할 때 platform에 대한 가정을 하지 않도록 code작성에 주의해야 합니다.

 

Program.cs에서 directory 이름에 대한 문자열을 생성하고 Path type의 Combine method를 통해 적절히 이들을 결합하여 사용자의 home directory 아래에 사용자지정 경로를 정의하도록 하는 문을 아래와 같이 작성합니다. 이때 사용자지정 directory에 대한 경로는 Directory class의 Exists method를 통해 존재여부를 확인하고 directory를 생성한 뒤 Directory class의 CreateDirectory method와 Delete method를 사용해 이곳에 포함된 모든 file과 하위 directory를 포함해 삭제하도록 합니다.

SectionTitle("Custom Directory");
			
string newFolder = Combine(GetFolderPath(SpecialFolder.Personal), "NewFolder");

Console.WriteLine($"Working with: {newFolder}");
Console.WriteLine($"Does it exist? {Path.Exists(newFolder)}");
Console.WriteLine("Creating it...");
			
CreateDirectory(newFolder);

Console.WriteLine($"Does it exist? {Path.Exists(newFolder)}");
Console.Write("Confirm the directory exists, and then press ENTER: "); Console.ReadLine();
Console.WriteLine("Deleting it...");
			
Delete(newFolder, recursive: true);

Console.WriteLine($"Does it exist? {Path.Exists(newFolder)}");

예제를 실행한 뒤 결과를 확인합니다. directory를 삭제하기 위해 Enter key를 누르기 전 해당 directory가 정상적으로 만들어졌는지 확인합니다.

(4) File

 

File과 관련해서는 Directory에서 했던 것처럼 file type을 정적으로 가져올 수 있습니다. 다만 아래 예제에서는 directory type에서와 이름이 같은 method를 일부 가지고 있으며 따라서 서로 충돌할 수 있기 때문에 그런 방식을 사용하지 않았습니다. 또한 이 경우 큰 문제가 되지 않을 만큼 짧은 이름을 사용할 수 있습니다.

 

Program.cs에서 파일존재를 확인한 뒤 text file을 생성하고 해당 file에 text를 기록하는 문을 작성합니다.

SectionTitle("files");

string dir = Combine(GetFolderPath(SpecialFolder.Personal), "OutputFiles");
CreateDirectory(dir);

string textFile = Combine(dir, "Dummy.txt");
string backupFile = Combine(dir, "Dummy.bak");

Console.WriteLine($"Working with: {textFile}");
Console.WriteLine($"Does it exist? {File.Exists(textFile)}");

StreamWriter textWriter = File.CreateText(textFile);
textWriter.WriteLine("Hello, C#!");
textWriter.Close();

Console.WriteLine($"Does it exist? {File.Exists(textFile)}");

File.Copy(sourceFileName: textFile, destFileName: backupFile, overwrite: true);
Console.WriteLine($"Does {backupFile} exist? {File.Exists(backupFile)}");

Console.Write("Confirm the files exist, and then press ENTER: ");
Console.ReadLine();
			
File.Delete(textFile);
Console.WriteLine($"Does it exist? {File.Exists(textFile)}");
			
Console.WriteLine($"Reading contents of {backupFile}:");
StreamReader textReader = File.OpenText(backupFile);
Console.WriteLine(textReader.ReadToEnd());
textReader.Close();

예제에서는 file을 기록하고 나면 system resource와 잠금을 해제하기 위해 file을 닫아줍니다.(이것은 일반적으로 file작성 시 예외가 발생하더라도 file이 닫히는 경우를 확실히 하기 위해 try-finally문에서 이루어집니다.) 그리고 backup을 위해 file을 복사한 뒤 기존의 file은 삭제하고 backup file로부터 내용을 읽어 표시합니다.

 

예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.

(5) Path

 

때로는 경로의 일부만을 확인해야 하는 경우도 있을 것입니다. 예를 들어 경로에서 folder이름이나 file이름, 혹은 확장자와 같은 것만을 추출해야 하는 경우입니다. 또는 임시 folder나 file이름을 생성해야 하는 경우도 있습니다. 이와 같은 작업은 Path class의 정적 method를 사용하면 가능합니다.

 

Program.cs에서 아래와 같은 구문을 추가합니다.

SectionTitle("Paths");
string dir = Combine(GetFolderPath(SpecialFolder.Personal), "OutputFiles");
string textFile = Combine(dir, "Dummy.txt");

Console.WriteLine($"Folder Name: {GetDirectoryName(textFile)}");
Console.WriteLine($"File Name: {GetFileName(textFile)}");
Console.WriteLine("File Name without Extension: {0}",

GetFileNameWithoutExtension(textFile));

Console.WriteLine($"File Extension: {GetExtension(textFile)}");
Console.WriteLine($"Random File Name: {GetRandomFileName()}");
Console.WriteLine($"Temporary File Name: {GetTempFileName()}");

예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다.

GetTempFileName은 zero-byte file을 생성하여 사용할 수 있도록 준비한 뒤 해당 file의 이름을 반환합니다. 하지만 GetRandomFileName은 file의 이름만 반환할 뿐 실제 생성하지는 않습니다.

(6) File 정보 확인

 

File이나 directory에 관한 정보, 예를 들어 size나 마지막 접근일자와 같은 정보를 를 확인하려면 FileInfo 혹은 DirectoryInfo class의 instance를 생성해 사용할 수 있습니다.

 

FileInfo와 DirectoryInfo는 모두 FileSystemInfo class로부터 상속받은 것으로 따라서 둘 다 자신만의 속성과 더불어  LastAccessTime나 Delete와 같은 공통된 member를 가지고 있습니다.

Class Members
FileSystemInfo Field : FullPath, OriginalPath
Property : Attributes, CreationTime, CreationTimeUtc, Exists, Extension, FullName, LastAccessTime, LastAccessTimeUtc, LastWriteTime, LastWriteTimeUtc, Name Methods: Delete, GetObjectData, Refresh
DirectoryInfo Property : Parent, Root
Method : Create, CreateSubdirectory, EnumerateDirectories, EnumerateFiles, EnumerateFileSystemInfos, GetAccessControl, GetDirectories, GetFiles, GetFileSystemInfos, MoveTo, SetAccessControl
FileInfo Property : Directory, DirectoryName, IsReadOnly, Length
Methods: AppendText, CopyTo, Create, CreateText, Decrypt, Encrypt, GetAccessControl, MoveTo, Open, OpenRead, OpenText, OpenWrite, Replace, SetAccessControl

아래 예제는 하나의 File에 대해 FileInfo instance를 통해 여러 동작을 어떻게 효휼적으로 수행할 수 있는지를 알려주기 위한 것으로 Program.cs에서 backup file에 대한 FileInfo instance를 생성하고 console에 이에 대한 정보를 출력하도록 합니다.

SectionTitle("File information");

string dir = Combine(GetFolderPath(SpecialFolder.Personal), "OutputFiles");
string backupFile = Combine(dir, "Dummy.bak");

FileInfo info = new(backupFile);
Console.WriteLine($"{backupFile}:");
Console.WriteLine($"Contains {info.Length} bytes");
Console.WriteLine($"Last accessed {info.LastAccessTime}");
Console.WriteLine($"Has readonly set to {info.IsReadOnly}");

예제를 실행하면 다음과 같은 결과를 표시하게 됩니다.

byte에 대한 숫자는 OS마다 다른 line ending을 사용할 수 있으므로 다르게 표시될 수 있습니다.

 

(7) File에 대한 처리방식 제어

 

File에 대한 어떤 처리를 진행하고자 할 때 경우에 따라 어떤 형태로 file을 open 해야 할지 지정하는 경우가 있으며 이런 경우 File.Open method를 사용할 수 있습니다. Open method는 enum값을 통해 추가적인 option을 지정할 수 있는 overload를 가지고 있습니다.

 

사용가능한 enum값으로는 다음과 같은 것이 있습니다.

  • FileMode : CreateNew, OpenOrCreate, Truncate 등 해당 file에 무엇을 할지 지정합니다.
  • FileAccess : ReadWrite와 같이 필요한 접근 수준을 지정합니다.
  • FileShare : 다른 process에서 Read와 같이 접근가능한 수준을 지정함으로써 file에 대한 잠금을 조정합니다.

따라서 만약 file을 열고 해당 file로부터 읽기를 수행하면서 다른 process에서도 역시 읽기를 허용하려면 다음과 같이 지정합니다.

FileStream file = File.Open(textFile, FileMode.Open, FileAccess.Read, FileShare.Read);

또한 file에 대한 속성을 위해 사용가능한 아래와 enum값도 존재합니다.

  • FileAttributes : FileSystemInfo에서 파생된 type의 attrubute 속성에서 Archive와 Encrypted와 같은 값을 확인합니다.

이를 통해 file이나 directory의 속성은 아래와 같이 확인할 수 있습니다.

FileInfo info = new(backupFile);
WriteLine("Is the backup file compressed? {0}", info.Attributes.HasFlag(FileAttributes.Compressed));
2. Stream 읽기/쓰기

Stream은 읽고 쓸 수 있는 일련의 byte입니다. File을 array처럼 처리할 수 있지만 file내 byte의 위치를 알면 random access가 제공되므로 순차적으로 접근할 수 있는 stream으로서 file을 처리하는 것이 유용할 수 있습니다.

 

Stream은 또한 terminal 입출력 및 random access를 제공하지 않으며 위치를 탐색(즉, 이동)할 수 없는 socket과 port 같은 networking resource를 처리하는 데에도 사용할 수 있습니다. 이에 따라 data stream이 어디서 온 것인지 신경 쓸 필요 없이 임의의 byte를 처리할 수 있는 code를 작성할 수 있으며 이때 code는 단순히 stream을 읽고 쓰기만 하면 다른 한편에서는 실제 byte가 저장된 곳을 처리할 수 있게 됩니다.

 

(1) Stream의 추상화 및 구체화

 

Stream이라는 추상화 class는 stream에 대한 모든 type을 표현합니다. 추상화 class자체는 new를 사용해 instance를 생성할 수 없으며 상속만이 가능합니다.

 

때문에 해당 기반 class로부터 상속된 다수의 구체화 class가 존재하는데 여기에는 FileStream, MemoryStream, BufferedStream, GZipStream 그리고 SslStream과 같은 것이 존재하며 이들은 모두 같은 방식으로 작동합니다. 모든 stream은 IDisposable interface를 구현하고 있으므로 비관리 resource를 해제하기 위한 Dispose method를 가집니다.

 

Stream class에서 가지고 있는 일부 공통 member에 관해서는 아래 표에서 확인할 수 있습니다.

Member Description
CanRead, CanWrite 해당 속성은 stream을 읽거나 쓸수 있는지의 여부를 나타냅니다.
Length, Position 해당 속성은 총 byte길이와 stream에서의 현재 위치를 나타냅니다.
Dispose 해당 method는 stream을 닫고 resource를 해제합니다.
Flush Stream이 bufer를 가지고 있다면 해당 method는 buffer의 byte를 stream에 기록하고 buffer를 비웁니다.
CanSeek 해당 속성은 Seek method가 사용가능한지를 나타냅니다.
Seek 해당 method는 현재 위치를 매개변수로 지정된 곳으로 이동합니다.
Read, ReadAsync 해당 method는 stream으로 부터 지정한 수의 byte를 byte배열로 읽고 위치를 앞당깁니다.
ReadByte 해당 method는 stream으로 부터 다음 byte를 읽고 위치를 앞당깁니다.
Write, WriteAsync 해당 method는 byte array의 내용을 stream으로 작성합니다.
WriteByte 해당 method는 byte를 stream으로 작성합니다.

● Storage stream

 

아래 표에서는 byte가 저장될 위치를 나타내는 일부 storage stream을 설명하고 있습니다.

Namespace Class Description
System.IO FileStream filesystem에 저장된 byte
System.IO MemoryStream 현재 process의 memory에 저장된 byte
System.Net.Sockets NetworkStream network 위치에 저장된 byte

FileStream은 WIndows상에서 더 높은 성능과 신뢰성을 가진 .NET 6에서 재작성되었습니다. 자세한 사항은 아래 link를 참고하시기 바랍니다.

File IO improvements in .NET 6 - .NET Blog (microsoft.com)

 

● Function stream

 

아래 표는 자체적으로는 존재하지 않지만 다른 stream에 연결되어 기능을 추가시키는 function stream을 설명하고 있습니다.

Namespace Class Description
System.Security.Cryptography CryptoStream Stream을 암호화/복호화 합니다.
System.IO.Compression GZipStream, DeflateStream Stream을 압축하고 해제합니다.
System.Net.Security AuthenticatedStream Stream전역에 자격증명을 보냅니다.

● Stream helper

 

비록 저수준에서 stream을 사용해야 하는 경우가 있겠지만 대부분은 helper class를 사용하는 것만으로 손쉽게 구현할 수 있습니다. Stream에 대한 모든 helper type은 IDisposable을 구현하고 있으므로 비관리 resource를 해제하기 위한 Dispose method를 가집니다.

 

일반적인 상황에서 사용가능한 몇몇 helper class는 아래 표에 안내되어 있습니다.

Namespace Class Description
System.IO StreamReader 기본 stream으로 부터 일반 text를 읽습니다.
System.IO StreamWriter 기본 stream으로 일반 text를 작성합니다.
System.IO BinaryReader Stream으로 부터 .NET type을 읽습니다. 예를 들어 ReadDecimal method는 기본 stream에서 decimal 값을 다음 16 byte를 읽고 ReadInt32 method는 int값으로 다음 4 byte를 읽습니다.
System.IO BinaryWriter Stream으로 .NET type을 작성합니다. 예를 들어 decimal 매개변수를 가진 Write method는 기본 stream으로 16byte를 작성하며 int 매개변수를 가진 Write method는 4 byte를 작성합니다.
System.Xml XmlReader 기본 stream으로 부터 XML format을 사용해 읽습니다.
System.Xml XmlWrite 기본 stream으로 XML format을 사용해 작성합니다.

(2) Text stream

 

Stream으로 text를 작성하는 예제를 만들기 위해 csStudy09 solution을 생성하고 이어서 WorkingWithStreams이름의 Console app project를 추가합니다.

 

Project가 생성되면 Viper.cs라는 이름의 class file을 추가하고 Callsigns이름의 문자열배열값을 가진 Viper이름의 정적 class를 아래와 같이 작성합니다.

 

Program.cs에서는 기존의 문을 모두 삭제하고 System.Xml namespace와 System.Environment, System.IO.path namespace를 정적 import 합니다. 그리고 Viper call signs를 열거하면서 각각 한 줄씩 text file에 기록하도록 하는 문을 아래와 같이 작성합니다.

string textFile = Combine(CurrentDirectory, "streams.txt");
            
// text file을 생성한 뒤 helper writer를 반환
StreamWriter text = File.CreateText(textFile);
            

foreach (string item in Viper.Callsigns)
{
    text.WriteLine(item);
}

text.Close(); // release resources

// file의 크기와 내용 출력
Console.WriteLine("{0} contains {1:N0} bytes.", arg0: GetFileName(textFile), arg1: new FileInfo(textFile).Length);
Console.WriteLine(File.ReadAllText(textFile));

예제를 실행하면 다음과 같은 결과를 표시할 것입니다.

(3) XML stream

 

XML 요소를 작성하는 데는 2가지 방식이 존재합니다.

  • WriteStartElement와 WriteEndElement : 요소가 자식요소를 가지는 경우 같이 사용합니다.
  • WriteElementString : 요소가 자식요소를 가지지 않는 경우 사용합니다.

읽거나 쓰기 위해 file을 열면 .NET의 외부 resource를 사용하는 것으로 이를 비관리 resouce라고 하며 대상 file에 대한 작업이 종료되면 instance는 반드시 소멸되어야 하며 대게는 resource를 소멸할 때 try ~ finally block안에서 Dispose method를 호출합니다.

 

아래 예제는 Viper pilot 호출 sign에 대한 문자열 배열값을 XML file로 저장하도록 하는 것으로 Program.cs에서 호출 sign을 열거하고 각각을 단일 XML file의 요소로 작성하도록 합니다.

//작성을 위한 file경로 정의
string xmlFile = Combine(CurrentDirectory, "streams.xml");

// filestream과 XML writer에 대한 변수 선언
FileStream? xmlFileStream = null;
XmlWriter? xml = null;

try
{
    // file stream 생성
    xmlFileStream = File.Create(xmlFile);
    // file stream을 XML writer helper로 wrapping하고 자동적으로 중첩된 요소간 들여쓰기를 적용합니다.
    xml = XmlWriter.Create(xmlFileStream, new XmlWriterSettings { Indent = true });

    xml.WriteStartDocument();
    // root 요소 작성
    xml.WriteStartElement("callsigns");

    //string을 열거하고 각각을 stream에 작성합니다.
    foreach (string item in Viper.Callsigns)
    {
        xml.WriteElementString("callsign", item);
    }

    // 종료 root 요소 작성
    xml.WriteEndElement();
    // helper와 stream닫기
    xml.Close();
    xmlFileStream.Close();
}
catch (Exception ex) {
    //해당 경로가 존재하지 않으면 예외발생
    Console.WriteLine($"{ex.GetType()} says {ex.Message}");
}
finally {
    if (xml != null)
    {
        xml.Dispose();
        Console.WriteLine("비관리 resource 해제");
    }

    if (xmlFileStream != null)
    {
        xmlFileStream.Dispose();
        Console.WriteLine("File stream의 비관리 resource 해제");
    }
}

//File 내용 출력
Console.WriteLine("{0} contains {1:N0} bytes.", arg0: Path.GetFileName(xmlFile), arg1: new FileInfo(xmlFile).Length);
Console.WriteLine(File.ReadAllText(xmlFile));

예제를 실행하면 아래와 같은 결과를 표시합니다.

Dispose method를 호출하기 전 개체가 null인지를 확인해야 합니다.

(4) using문을 사용해 소멸을 단순화하기

 

위 예제에서는 null 개체에 대한 확인 code를 좀 더 단순화한 다음 using 문을 통해 Dispose method를 호출하도록 바꿀 수 있습니다. 일반적으로는 더 높은 수준에서의 제어가 필요하지 않은 이상에는 수동적으로 Dispose method를 호출하기보다는 using문의 사용을 권장합니다.

 

using keyword자체가 namespace를 import 하고 IDisposable를 구현하는 개체에 Dispose를 호출할 finally문을 생성하는 2가지로 사용된다는 측면에서 약간 혼란스러울 수 있지만 실제 사용해 보면 이 둘은 아주 명확히 구분된다는 것을 알 수 있을 것입니다.

 

아래 예제에서 compiler는 using 문 block을 catch문이 없는 try-finally문으로 변경합니다. 물론 여기에서도 try문을 충첩적으로 사용할 수 있으므로 어떤 예외사항을 잡아내야 한다면 아래와 같이 구현할 수 있습니다.

using (FileStream file = File.OpenWrite(Path.Combine(CurrentDirectory, "test.txt")))
{
    using (StreamWriter writer = new StreamWriter(file))
    {
        try
        {
            writer.WriteLine(".NET!");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"{ex.GetType()} says {ex.Message}");
        }
    } // 개체가 null이 아니면 자동적으로 Dispose를 호출
} // 개체가 null이 아니면 자동적으로 Dispose를 호출

게다가 위 예제는 using문을 위한 괄호와 들여 쓰기를 명시하지 않음으로써 아래와 같이 더욱 간소화할 수 있습니다.

using FileStream file = File.OpenWrite(Path.Combine(CurrentDirectory, "test.txt"));
using StreamWriter writer = new(file);

try
{
    writer.WriteLine(".NET!");
}
catch (Exception ex)
{
    Console.WriteLine($"{ex.GetType()} says {ex.Message}");
}

(5) Stream 압축

 

XML은 상대적으로 장황한 편이라 일반적인 text보다 더 많은 byte공간을 차지하게 됩니다. 이제 GZIP이라는 일반적인 압축 algorithm을 사용하여 XML을 얼마나 압축할 수 있는지를 확인해 볼 것입니다.

 

.NET Core 2.1에서 Microsoft는 Brotli이라는 압축 algorithm의 구현을 도입하였습니다. 성능면에서 Brotli는 DEFLATE와 GZIP에서 사용된 algorithm과 비슷하지만 20% 정도 더 압축력이 높다고 평가되고 있습니다.

 

예제를 통해 GZIP과 Brotli 2개의 압축 algorithm을 비교해 보고자 Project에 Compress.cs라는 class file을 추가합니다. Compress.cs에서는 GZipStream과 BrotliStream에 대한 instance를 사용하여 위와 동일한 XML 요소를 포함하는 압축 file을 생성한 다음 읽는 동안 압축을 풀고 console에 해당 내용을 출력하는 문을 아래와 같이 작성합니다.

using System.IO.Compression; // BrotliStream, GZipStream, CompressionMode
using System.Xml; // XmlWriter, XmlReader
using static System.Environment; // CurrentDirectory
using static System.IO.Path; // Combine

namespace WorkingWithStreams
{
    partial class Program
    {
        static void Compress(string algorithm = "gzip")
        {
            //file의 확장자를 통해 algorithm을 사용하는 file 경로 정의
            string filePath = Combine(CurrentDirectory, $"streams.{algorithm}");

            FileStream file = File.Create(filePath);
            Stream compressor;

            if (algorithm == "gzip")
            {
                compressor = new GZipStream(file, CompressionMode.Compress);
            }
            else
            {
                compressor = new BrotliStream(file, CompressionMode.Compress);
            }

            using (compressor)
            {
                using (XmlWriter xml = XmlWriter.Create(compressor))
                {
                    xml.WriteStartDocument();
                    xml.WriteStartElement("callsigns");
                    foreach (string item in Viper.Callsigns)
                    {
                        xml.WriteElementString("callsign", item);
                    }
                }
            }

            Console.WriteLine("{0} contains {1:N0} bytes.", Path.GetFileName(filePath), new FileInfo(filePath).Length);
            Console.WriteLine($"The compressed contents:");
            Console.WriteLine(File.ReadAllText(filePath));
            
            Console.WriteLine("Reading the compressed XML file:");
            file = File.Open(filePath, FileMode.Open);
            Stream decompressor;

            if (algorithm == "gzip")
            {
                decompressor = new GZipStream(file, CompressionMode.Decompress);
            }
            else
            {
                decompressor = new BrotliStream(file, CompressionMode.Decompress);
            }

            using (decompressor)
            {

                using (XmlReader reader = XmlReader.Create(decompressor))
                {

                    while (reader.Read())
                    {
                        //callsign 요소가 있는지 확인
                        if ((reader.NodeType == XmlNodeType.Element) && (reader.Name == "callsign"))
                        {
                            reader.Read(); //요소 내부 text로 이동
                            Console.WriteLine($"{reader.Value}"); //해당 값 읽기
                        }
                    }
                }
            }
        }
    }
}

Program.cs에서는 gzip과 brotli algorithm을 사용하기 위한 매개변수와 함께 Compress method를 호출하는 문을 아래와 같이 작성합니다.

Compress(algorithm: "gzip");
Compress(algorithm: "brotli");

예제를 실행하면 다음과 같이 XML file과 gzip 및 brotli algorithm을 통해 압축된 XML file의 size를 비교하는 결과를 표시하게 됩니다.

당연한 이야기지만 압축된 file은 압축되지 않은 동일한 file size의 size보다 훨씬 적음을 알 수 있습니다.

 

(6) tar 압축

 

확장자가 .tar인 file은 Unix기반의 압축 application인 tar에 의해 생성된 것입니다. 확장자가 .tag.gz인 file도 존재하는데 이는 tar로 file이 만들어지고 GZIP 압축 algorithm을 사용해 압축되어 생성된 것임을 의미합니다.

 

.NET 7에서는 System.Formats.Tar assembly를 통해 tar 압축 file을 읽고, 생성하며 압축하거나 풀 수 있습니다.

 

이와 관련해 TarFile class에서는 아래 표에서의 static public member를 사용할 수 있습니다.

Member Description
CreateFromDirectory
CreateFromDirectoryAsync
지정된 directory로 부터 지정된 모든 filesystem 항목을 포함하는 stream을 생성합니다.
ExtractToDirectory
ExtractToDirectoryAsync
지정한 directory에서 tar 압축을 나타내는 stream의 content를 추출합니다.
DefaultCapacity Windows의 MAX_PATH (260)이 임의 기본 용량으로 사용됩니다.

관련한 예제 작성을 위해 csStudy09 solution에서 WorkingWithTarArchives이름의 Console app project를 생성합니다. 그리고 Helpers.cs라는 class file을 추가하여 error, 경고, 정보 message를 console에 적절한 색상으로 출력하는 3개의 method를 가진 partial Program class를 아래와 같이 작성합니다.

using static System.Console;

namespace WorkingWithTarArchives
{
    partial class Program
    {
        static void WriteError(string message)
        {
            ConsoleColor previousColor = ForegroundColor;
            ForegroundColor = ConsoleColor.Red;
            WriteLine($"FAIL: {message}");
            ForegroundColor = previousColor;
        }

        static void WriteWarning(string message)
        {
            ConsoleColor previousColor = ForegroundColor;
            ForegroundColor = ConsoleColor.DarkYellow;
            WriteLine($"WARN: {message}");
            ForegroundColor = previousColor;
        }
        static void WriteInformation(string message)
        {
            ConsoleColor previousColor = ForegroundColor;
            ForegroundColor = ConsoleColor.Blue;
            WriteLine($"INFO: {message}");
            ForegroundColor = previousColor;
        }
    }
}

이어서 project의 실행 file이 동작하는 bin folder에 images라는 folder를 생성하고 아래 image들을 내려받은 뒤 해당 folder로 복사해 놓습니다.

images.zip
2.48MB

Program.cs에서는 기존의 문을 모두 삭제하고 지정한 folder의 content를 tar로 압축한 다음 이것을 다시 새로운 folder로 추출하는 구문을 아래와 같이 작성합니다.

try
{
    string current = Environment.CurrentDirectory;
    WriteInformation($"Current directory: {current.Split(Path.DirectorySeparatorChar).Last()}");

    string sourceDirectory = Path.Combine(current, "images");
    string destinationDirectory = Path.Combine(current, "extracted");
    string tarFile = Path.Combine(current, "images-archive.tar");

    if (!Directory.Exists(sourceDirectory))
    {
        WriteError($"The {sourceDirectory.Split(Path.DirectorySeparatorChar).Last()} directory must exist. Please create it and add some files to it.");
        return;
    }

    //tar file이 존재한다면 삭제
    if (File.Exists(tarFile))
    {
        File.Delete(tarFile);
        WriteWarning($"{Path.GetFileName(tarFile)} already existed so it was deleted.");
    }

    WriteInformation($"Archiving directory: {sourceDirectory.Split(Path.DirectorySeparatorChar).Last()}\n To .tar file: {Path.GetFileName(tarFile)}");

    TarFile.CreateFromDirectory(sourceDirectoryName: sourceDirectory, destinationFileName: tarFile, includeBaseDirectory: true);

    WriteInformation($"Does {Path.GetFileName(tarFile)} exist? {File.Exists(tarFile)}.");

    //destinationDirectory folder가 존재하지 않으면 새로 생성
    if (!Directory.Exists(destinationDirectory))
    {
        Directory.CreateDirectory(destinationDirectory);
        WriteWarning($"{destinationDirectory.Split(Path.DirectorySeparatorChar).Last()} did not exist so it was created.");
    }

    WriteInformation($"Extracting archive: {Path.GetFileName(tarFile)}\n To directory: {destinationDirectory.Split(Path.DirectorySeparatorChar).Last()}");

    TarFile.ExtractToDirectory(sourceFileName: tarFile, destinationDirectoryName: destinationDirectory, overwriteFiles: true);

    if (Directory.Exists(destinationDirectory))
    {
        foreach (string dir in Directory.GetDirectories(destinationDirectory))
        {
            WriteInformation($"Extracted directory {dir.Split(Path.DirectorySeparatorChar).Last()} containing these files: " + string.Join(',', Directory.EnumerateFiles(dir).Select(file => Path.GetFileName(file))));
        }
    }
}
catch (Exception ex)
{
    WriteError(ex.Message);
}

예제를 실행하면 다음과 같은 결과를 확인할 수 있습니다

만약 반디집과 같이 tar archive의 content를 볼 수 있는 software가 있다면 이 것을 사용해 images-archive.tar file의 content를 다음과 같이 확인합니다.

 

● tar 항목 읽기 및 쓰기

 

TarFile class 외에도 tar 압축 file에서 개별적인 항목을 읽고 쓰기 위한 TarEntry, TarEntryFormat, TarReader, TarWriter 등의 class가 존재합니다. 이들은 GzipStream과 결합하여 쓰거나 읽을 때 항목을 압축하거나 해제할 수 있습니다.

.NET에서 지원하는 tar에 관한 더 자세한 사항은 아래 link를 참고하시기 바랍니다.

System.Formats.Tar Namespace | Microsoft Learn

 

System.Formats.Tar Namespace

Contains types used in reading and writing data in the Tape Archive (TAR) file archiving format.

learn.microsoft.com

3. Text의 encoding과 decoding

Text 문자는 다양한 방식으로 표현될 수 있습니다. 예를 들어 alphabet은 전신을 통해 전송할 수 있는 일련의 점과 선으로 구성되는 Morse 부호로 encode 될 수 있습니다.

 

비슷하게 computer에서의 text도 code의 공간 안에서 code point를 나타내는 bit(1과 0)로 저장됩니다. 대부분의 code point는 단일 문자로 표현되지만 형식지정과 같은 다른 의미를 가질 수도 있습니다.

 

예를 들어 ASCII는 128 code point를 가진 code공간을 가지고 있습니다. .NET은 Unicode라고 하는 표준을 통해 text를 내부적으로 endcode 합니다. Unicode는 백만 code point이상을 가지고 있습니다.

 

때로는 text를 Unicode를 사용하지 않거나 Unicode의 변형을 사용하는 system에서 사용하기 위해 .NET 외부로 보내야 하는 경우가 있는데 이 때문에 encoding사이에 변환방법을 아는 것은 중요합니다.

 

아래 표에서는 computer에서 일반적으로 사용되는 text ecoding을 나열하고 있습니다.

Encoding Description
ASCII byte중 하위 7bit만을 사용함으로서 문자의 범위가 제한적입니다.
UTF-8 각 Unicode code point를 1~4byte의 sequence로 나타냅니다.
UTF-7 UTF-8보다 7bit channel에서 더 효휼적으로 설계되었지만 보안과 견고함에서 문제점을 가지고 있으므로 UTF-7보다는 UTF-8을 더 권장합니다.
UTF-16 각 Unicode code point를 1~2개의 16bit integer로 표현합니다.
UTF-32 각 Unicode code point를 32bit integer로 표현하므로 가변 길이 encoding의 다른 Unicode encoding과는 달리 고정된 길이의 encoding입니다.
ANSI/ISO encodings 특정 언어 또는 언어의 group을 지원하기 위해 사용되는 다양한 code page에 대한 지원을 제공합니다.

현시점에 대부분의 system은 UTF-8을 기본으로 사용하고 있으며 .NET에서는 Encoding.Default로 나타낼 수 있습니다. 상술하였듯 Encoding.UTF7은 보안에 취약하므로 사용을 피해야 하며 이러한 이유로 C# compiler는 UTF-7 사용을 시도할 때 경고를 띄우고 있습니다. UTF-7은 다른 system과의 호환성을 위해 어쩔 수 없이 text를 encoding 해야 하는 경우를 위해서 선택적으로 사용할 수 있도록 남아있을 뿐입니다.

 

(1) Byte array로 문자열 encoding 하기

 

csStudy09 solution에서 WorkingWithEncodings이름의 project를 생성하고 Program.cs에서는 System.Text namespace를 import 한 뒤 사용자로 부터 선택된 encoding을 사용해 문자열을 encode 하는 문을 아래와 같이 작성합니다. 또한 각 byte는 loop를 통해 문자열로 decode 한 다음 이를 출력하도록 합니다.

WriteLine("Encodings");
WriteLine("[1] ASCII");
WriteLine("[2] UTF-7");
WriteLine("[3] UTF-8");
WriteLine("[4] UTF-16 (Unicode)");
WriteLine("[5] UTF-32");
WriteLine("[6] Latin1");
WriteLine("[any other key] Default encoding");
WriteLine();
            
Write("Press a number to choose an encoding.");
ConsoleKey number = ReadKey(intercept: true).Key;
WriteLine(); WriteLine();

Encoding encoder = number switch
{
    ConsoleKey.D1 or ConsoleKey.NumPad1 => Encoding.ASCII,
    ConsoleKey.D2 or ConsoleKey.NumPad2 => Encoding.UTF7,
    ConsoleKey.D3 or ConsoleKey.NumPad3 => Encoding.UTF8,
    ConsoleKey.D4 or ConsoleKey.NumPad4 => Encoding.Unicode,
    ConsoleKey.D5 or ConsoleKey.NumPㄴad5 => Encoding.UTF32,
    ConsoleKey.D6 or ConsoleKey.NumPad6 => Encoding.Latin1,
    _ => Encoding.Default
};

// encode할 문자열
string message = "Café £4.39";
WriteLine($"Text to encode: {message} Characters: {message.Length}");

// 문자열을 byte array로 encode
byte[] encoded = encoder.GetBytes(message);

// encode후 byte 확인
WriteLine("{0} used {1:N0} bytes.", encoder.GetType().Name,
encoded.Length);
WriteLine();

// 각 byte 열거
WriteLine($"BYTE | HEX | CHAR");
foreach (byte b in encoded)
{
    WriteLine($"{b,4} | {b.ToString("X"),3} | {(char)b,4}");
}

string decoded = encoder.GetString(encoded);
WriteLine(decoded);

예제를 실행하고 ASCII선택을 위해 1을 누르면 아래와 같이 결과를 확인할 수 있습니다. 표시된 byte결과를 보면 pound 기호 (£)와 accent e (é)는 ASCII로 표현되지 않았음을 확인할 수 있습니다. 따라서 이때는 ?문자가 대신 사용되었습니다.

다시 예제를 실행하고 이번에는 UTF-8인 3을 입력합니다. UTF-8에서는 위 두 문자를 위해 추가로 2 byte를 더 필요로 하지만(총 12byte)  é 과 £ 문자에 대한 encode와 decode가 가능합니다.

UTF-16은 모든 문자에 대해 2byte를 필요로 하므로 총 20byte가 사용되며 é 와 £문자 역시 encode와 decode가 가능합니다. 해당 encoding은 .NET에서 내부적으로 char와 string값을 저장하기 위해 사용됩니다.

 

(2) File로 text를 encoding 하고 decoding 하기

 

StreamReader와 StreamWriter 같은 stream helper class를 사용할 때 사용하고자 하는 encoding을 지정할 수 있습니다. helper로 작성하면 text는 자동적으로 encoded 되며 helper로부터 읽으면 byte는 자동적으로 decode 됩니다.

 

Encoding을 지정하려면 helper type의 생성자로 두 번째 매개변수를 통해 encoding을 다음과 같이 전달합니다.

StreamReader reader = new(stream, Encoding.UTF8); 
StreamWriter writer = new(stream, Encoding.UTF8);

때로는 다른 system에서 사용할 file을 생성하기 때문에 어떤 encoding을 사용할지 선택할 수 없는 경우가 있습니다. 만약 이런 상황이라면 가장 적은 수의 byte를 사용하는 것으로 고르는 것이 도움이 될 수 있습니다. 다만 필요한 모든 문자를 저장하지 못할 수 있습니다.

4. Random access handle을 가지는 읽기 및 쓰기

.NET 6부터는 file stream이 없이도 file을 대상으로 작업이 가능한 새로운 API를 도입하였습니다.

 

이를 위해 우선 아래와 같이 file에 대한 handle을 가져와야 합니다.

using Microsoft.Win32.SafeHandles;
using System.Text;

using SafeFileHandle handle = File.OpenHandle(path: "test.txt", mode: FileMode.OpenOrCreate, access: FileAccess.ReadWrite);

이렇게 하고 나면 byte array로 encode 된 text를 읽기 전용 memory buffer에 아래와 같이 작성하고 이를 file로 저장할 수 있습니다.

ReadOnlyMemory<byte> buffer = new(Encoding.UTF8.GetBytes("test123123"));
await RandomAccess.WriteAsync(handle, buffer, fileOffset: 0);

file에서 읽기 위해서는 file의 길이(크기)를 가져와 해당 길이만큼의 공간을 memory buffer에 할당하여 file을 읽어 들입니다.

long length = RandomAccess.GetLength(handle);
Memory<byte> contentBytes = new(new byte[length]);
await RandomAccess.ReadAsync(handle, contentBytes, fileOffset: 0);

string content = Encoding.UTF8.GetString(contentBytes.ToArray());
WriteLine($"Content of file: {content}");

5. Object graph 직렬화

Object graph는 직접 참조 혹은 참조 chain을 통해 간접적으로 서로 연결된 다수의 개체입니다.

 

직렬화는 지정한 형식을 사용해 실제 object graph를 일련의 byte로 변환하는 처리를 말합니다.(역직렬화는 이런 처리를 반대로 수행합니다.) 직렬화를 통해 우리는 실제 개체의 현재 상태를 저장할 수 있으며 이후에 그 상태 그대로 개체를 다시 재생할 수 있습니다. 예를 들어 대표적으로 game을 들 수 있는데 현재 game의 진행상태를 저장함으로서 우리는 이후에 마지막상태에서 game을 계속 진행할 수 있습니다. 직렬화된 개체는 database나 file로 저장됩니다.

 

지정할 수 있는 format(형식)에는 수십 가지가 있지만 대부분의 경우 XML(eXtensible Markup Language)나 JSON(JavaScript Object Notation)이 둘 중 하나를 사용합니다.

JSON은 data를 상당히 소형화할 수 있는 것으로 web이나 mobile application에서 최적화되어 있습니다. XML은 JSON보다 훨씬 장황하며 주로 legacy system에서 지원되는 경우가 많습니다. 따라서 직렬화된 개체 graph의 크기를 최소화하려면 JSON을 사용하는 것이 좋습니다. JSON은 태생적으로 JavaScript를 위한 직렬화형식이므로 개체 graph를 web이나 mobile application으로 전송하기에도 적합한 형식이라 할 수 있습니다. mobile app은 대게 제한된 대역폭환경에서 동작하는 경우가 많으므로 byte의 수는 중요한 요소로 작용합니다.

.NET은 XML과 JSON으로 직렬화하고 그 반대를 수행할 수 있는 다수의 class를 가지고 있는데, 그중 XmlSerializer와 JsonSerializer가 가장 대표적인 class에 해당합니다.

 

(1) XML 직렬화

 

일반적으로 가장 많이 사용되는 직렬화 형식 중 하나가 바로 XML입니다. 일반적인 예제를 만들어 보기 위해 csStudy09 solution에서 WorkingWithSerialization이름의 project를 생성하고 자체 혹은 파생된 class에서만 접근할 수 있는 protected속성인 Group으로 Student.cs이름의 class file를 아래와 같이 생성합니다.

public class Student
{
    public Student(int group)
    {
        Group = group;
    }

    public string? Name { get; set; }
    public DateTime DateOfBirth { get; set; }
    public HashSet<Student>? Friends { get; set; }
    protected int Group { get; set; }
}

예제에서는 초기 salary값을 설정하기 위해 단일 매개변수를 가진 생성자를 사용하고 있습니다. Program.cs에서는 기존의 문을 모두 삭제하고 XML 직렬화에 필요한 정적 Environment와 Path namespace를 import 합니다.

using static System.Environment;
using static System.IO.Path;

그리고 Student instance의 개체 graph를 생성하는 문을 아래와 같이 추가해 줍니다.

List<Student> students = new()
{
    new(1)
    {
        Name = "홍길동",
        DateOfBirth = new(year: 2014, month: 9, day: 14)
    },
    new(2)
    {
        Name = "홍길영",
        DateOfBirth = new(year: 2014, month: 6, day: 29)
    },
    new(3)
    {
        Name = "장진구",
        DateOfBirth = new(year: 2014, month: 3, day: 7),
        Friends = new()
        {
            new(4)
            {
                Name = "김말순",
                DateOfBirth = new(year: 2014, month: 7, day: 7)
            }
        }
    }
};

// Person의 List를 XML로 형식화할 개체 생성
XmlSerializer xs = new(type: students.GetType());
            
// 작성할 file생성
string path = Combine(CurrentDirectory, "students.xml");
using (FileStream stream = File.Create(path))
{
    // 개체 graph를 stream으로 직렬화
    xs.Serialize(stream, students);
}

Console.WriteLine("Written {0:N0} bytes of XML to {1}", arg0: new FileInfo(path).Length, arg1: path);
Console.WriteLine();
            
Console.WriteLine(File.ReadAllText(path));

예제를 실행하면 아마도 다음과 같은 결과를 표시할 것입니다.

위 오류를 해결하기 위해서는 Student.cs에서 매개변수 없는 생성자를 아래와 같이 추가해줘야 합니다.

public Student() { }
사실 위 생성자는 어디에서도 사용되지 않지만 반드시 존재해야 XmlSerializer가 새로운 Student instance를 역직렬화 처리 중에 호출할 수 있습니다.

예제를 실행하고 결과를 확인해 봅니다.

결과에서는 '<Name>홍길동</Name>'과 같이 XML요소로 개체 graph가 직렬화되었으며 Group속성은 public속성이 아니기에 포함되지 않았습니다.

 

(2) XML 경량화하기

 

예제에서 일부 field는 요소대신 attribute를 사용해 더욱 경량화할 수 있습니다.

 

Student.cs에서 System.Xml.Serialization namespace를 import한뒤

using System.Xml.Serialization;

Name, DateOfBirth속성에 해당 [XmlAttribute] attribute를 적용합니다. 이때 각 attribute에 아래와 같이 줄임말을 적용합니다.

[XmlAttribute("nm")]
public string? Name { get; set; }

[XmlAttribute("dob")]
public DateTime DateOfBirth { get; set; }

위와 같이 적용 후 다시 예제를 실행하면 설정한 줄임말이 사용됨으로써 file공간이 절약됨을 알 수 있습니다. 따라서 생성된 file의 용량이 이전보다 더 줄어들게 됩니다.

(3) XML file 역직렬화

 

Memory에 실제 개체로 XML file을 역직렬화하기 위해 Program.cs에서 XML file을 열고 역직렬화하는 문을 아래와 같이 작성합니다.

Console.WriteLine();
Console.WriteLine("* Deserializing XML files");

string path = Combine(CurrentDirectory, "students.xml");

List<Student> students = new();
XmlSerializer xs = new(type: students.GetType());

using (FileStream xmlLoad = File.Open(path, FileMode.Open))
{
    List<Student>? loadedStudent = xs.Deserialize(xmlLoad) as List<Student>;
    if (loadedStudent is not null)
    {
        foreach (Student p in loadedStudent)
        {
            Console.WriteLine("{0} has {1} children.", p.Name, p.Friends?.Count ?? 0);
        }
    }
}

위 예제를 실행하면 XML로부터 student개체를 load 하여 아래와 같이 열거할 것입니다.

이 외에도 XML을 생성하는 데 사용할 수 있는 다른 많은 attribute들도 존재합니다.

 

만약 어떠한 annotation도 사용하지 않는다면 XmlSerializer는 역직렬화에서 속성의 이름에 대소문자를 구분하지 않고 수행합니다.

XmlSerializer를 사용할 때 오로지 public field와 속성만을 포함하며 type은 반드시 매개변수 없는 생성자를 가져야 합니다. 또한 attribute를 사용해 출력을 사용자정의할 수 있습니다.

(4) JSON 직렬화

 

Newtonsoft.Json는 Json.NET으로 알려져 있으며 JSON 직렬화에 사용할 수 있는 가장 인기 있고 강력한 .NET library 중 하나입니다.

 

NuGet package manager에서 download를 counter 하는 데 사용되는 단위는 32bit integer인데 Newtonsoft.Json의 인기가 너무 높은 나머지 여기서 수용가능한 숫자의 범위를 넘어설 정도의 download횟수가 기록된 적도 있을 정도입니다.

 

Json.NET을 사용하기 위해 우선 Nuget Package에서 아래와 같이 Newtonsoft.Json을 검색해 최신 version을 내려받습니다.

그리고 Program.cs에서 text file을 생성하고 file에 JSON으로 student를 직렬화하는 구문을 아래와 같이 추가합니다.

List<Student> students = new()
{
    new(1)
    {
        Name = "홍길동",
        DateOfBirth = new(year: 2014, month: 9, day: 14)
    },
    new(2)
    {
        Name = "홍길영",
        DateOfBirth = new(year: 2014, month: 6, day: 29)
    },
    new(3)
    {
        Name = "장진구",
        DateOfBirth = new(year: 2014, month: 3, day: 7),
        Friends = new()
        {
            new(4)
            {
                Name = "김말순",
                DateOfBirth = new(year: 2014, month: 7, day: 7)
            }
        }
    }
};

string jsonPath = Combine(CurrentDirectory, "student.json");
using (StreamWriter jsonStream = File.CreateText(jsonPath))
{
    Newtonsoft.Json.JsonSerializer jss = new();
    jss.Serialize(jsonStream, students);
}

Console.WriteLine();
Console.WriteLine("Written {0:N0} bytes of JSON to: {1}", arg0: new FileInfo(jsonPath).Length, arg1: jsonPath);

Console.WriteLine(File.ReadAllText(jsonPath));

예제를 실행해 보면 JSON은 요소로 이루어진 XML에 비해 byte의 수가 절반도 안 되는 훨씬 적은 용량만이 필요함을 알 수 있습니다. 심지어 attribute를 사용한 XML에 비해서도 더 작은 용량입니다.

(5) 고성능 JSON 처리

 

.NET Core 3.0은 JSON처리에 필요한 새로운 namespace인 System.Text.Json를 추가하였으며 Span와 같은 API를 사용하여 성능에 최적화하였습니다.

 

Json.NET과 같은 이전 library는 UTF-16을 읽음으로써 구현되는데 HTTP를 포함한 대부분의 network protocol이 UTF-8을 사용하고 Json.NET의 Unicode 문자열 값으로 부터 UTF-8로 변환하는 것을 피할 수 있기 때문에 UTF-8을 사용하여 JSON문서를 읽고 쓰는 것이 더 성능이 좋다고 할 수 있습니다.

 

Micorsoft는 이 새로운 API를 통해 상황에 따라 1.3 ~ 5배까지 성능향상을 달성했습니다.

 

본래 Json.NET의 제작자인 James Newton-King은 그가 JSON API에 관한 comment에서 'Json.NET은 사라지지 않는다.'라고 언급한 것처럼 Microsoft와 협업하여 새로운 JSON type을 개발하였습니다.

 

JSON type을 역직렬화하는데 새로운 JSON API를 사용하기 위해 WorkingWithSerialization project의 Program.cs상단에서 직렬화를 수행하기 위한 새로운 JSON class를 별칭을 사용해 Import 합니다. 별칭은 위에서 사용한 Json.NET의 이름과 충돌하는 것을 피하기 위한 것입니다.

using FastJson = System.Text.Json.JsonSerializer;

그리고 JSON file을 열어 역직렬화를 수행한 뒤 Student에 대한 frends의 이름과 수를 출력하도록 하는 문을 아래와 같이 작성합니다.

string jsonPath = Combine(CurrentDirectory, "student.json");

using (FileStream jsonLoad = File.Open(jsonPath, FileMode.Open))
{
	// deserialize object graph into a List of Person
	List<Student>? loadedStudent = await FastJson.DeserializeAsync(utf8Json: jsonLoad, returnType: typeof(List<Student>)) as List<Student>;

	if (loadedStudent is not null)
	{
		foreach (Student s in loadedStudent)
		{
			Console.WriteLine("{0} has {1} children.", s.Name, s.Friends?.Count ?? 0);
		}
	}
}

위 예제를 실행하면 다음과 같은 결과를 표시할 것입니다.

개발 생산성을 위해서는 Json.NET을 다양한 기능 또는 성능을 위해서는 System.Text.Json을 사용합니다. 아래 link를 통해 이 둘에 대한 차이목록을 확인해 볼 수 있습니다.

Migrate from Newtonsoft.Json to System.Text.Json - .NET | Microsoft Learn

 

Migrate from Newtonsoft.Json to System.Text.Json - .NET

Learn about the differences between Newtonsoft.Json and System.Text.Json and how to migrate to System.Text.Json.

learn.microsoft.com

(6) JSON 처리 제어

 

JSON처리 방식에 관해서는 아래와 같이 사용가능한 많은 option들이 존재합니다.

  • Field의 포함 또는 제외
  • Casing 정책 설정
  • 대소문자 구분 정책 설정
  • 공백의 처리방식 설정

예제를 위해 csStudy09 solution에서 WorkingWithJson이름의 project를 생성한 뒤 Program.cs에서 기존의 문을 모두 삭제하고 고성능 JSON을 위한 주요 namespace와 System. Environment, System.IO.Path namespace를 정적 import 합니다.

using System.Text.Json; 

using static System.Environment;
using static System.IO.Path;

그다음 Book.cs이라는 새로운 class file을 추가하고 그 안에 Book class를 아래와 같이 정의합니다.

using System.Text.Json.Serialization;

namespace WorkingWithJson
{
	public class Book
	{
		public Book(string title)
		{
			Title = title;
		}

		public string Title { get; set; }

		public string? Author { get; set; }
		
		[JsonInclude]
		public DateTime PublishDate;

		[JsonInclude]
		public DateTimeOffset Created;

		public ushort Pages;
	}
}

다시 Program.cs로 돌아와 Book class의 instance를 생성하고 이를 JSON으로 직렬화하는 문을 아래와 같이 작성합니다.

Book mybook = new(title: "어린왕자")
{
	Author = "생텍쥐페리",
	PublishDate = new(year: 1943, month: 4, day: 6),
	Pages = 250,
	Created = DateTimeOffset.UtcNow,
};

JsonSerializerOptions options = new()
{
	IncludeFields = true, // 모든 field를 포함
	PropertyNameCaseInsensitive = true,
	WriteIndented = true,
	PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
	Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
};

string filePath = Combine(CurrentDirectory, "mybook.json");

using (Stream fileStream = File.Create(filePath))
{
	JsonSerializer.Serialize<Book>(utf8Json: fileStream, value: mybook, options);
}

Console.WriteLine("{0:N0} byte의 JSON file {1}", arg0: new FileInfo(filePath).Length, arg1: Path.GetFileName(filePath));
Console.WriteLine();
			
Console.WriteLine(File.ReadAllText(filePath));

예제를 실행하면 다음과 같은 결과를 표시할 것입니다.

생성된 JSON file은 총 171byte로 member의 이름은 publishDate와 같이 camelCase가 적용되었습니다. 이는 Javascript를 사용하는 browser를 위한 가장 적합한 방식입니다. 또한 option설정에서 pages를 포함하여 모든 field를 포함하도록 했고 결과를 보면 사람이 읽기 쉽도록 하기 위해 JSON자체에 들여 쓰기 등의 서식이 적용되어 있음을 확인할 수 있습니다. DateTime와 DateTimeOffset값은 단일 표준 문자열 값으로 저장됩니다.

 

Program.cs에서 JsonSerializerOptions을 설정할 때 PropertNameCaseInsensitive, WriteIndented와 IncludeFields부분을 주서처리하고 예제를 다시 실행해 보면 결과를 다음과 같이 바뀌게 됩니다.

결과를 보면 JSON은 133byte가 되어 이전 171byte보다 용량이 작아졌으며 member의 이름은 normal casing가 사용되어 PublishDate와 같이 되었습니다. Pages field는 포함되지 않았으며 [JsonInclude]와 같은 attribute가 포함된 field는 정상적으로 포함되었습니다.

 

(7) HTTP응답을 위한 새로운 JSON 확장 method

 

.NET 5에서 Microsoft는 HttpResponse의 확장 method와 같이 System.Text.Json namespace type을 개선하였습니다.

 

(8) Newtonsoft에서 신규 JSON으로의 변환

 

만약 Newtonsoft Json.NET library를 사용하는 code를 신규 System.Text.Json namespace로 변환해야 한다면 아래 link에서 Microsoft가 이를 위해 마련해 둔 문서를 찾아볼 수 있습니다.

Migrate from Newtonsoft.Json to System.Text.Json - .NET | Microsoft Learn

 

Migrate from Newtonsoft.Json to System.Text.Json - .NET

Learn about the differences between Newtonsoft.Json and System.Text.Json and how to migrate to System.Text.Json.

learn.microsoft.com

728x90