본문 바로가기

C#/이것이 C#이다

[ 이것이 C#이다 ] Chapter 18. 파일 다루기

 [ 학습 흐름 ]
  1. 파일 정보와 디렉토리 정보 다루기
  2. 파일을 읽고 쓰기 위해 알아야 할 것들
  3. 이진 데이터 처리를 위한 Binary Writer / Binary Reader
  4. 텍스트 파일 처리를 위한 Stream Writer / Stream Reader
  5. 객체 직렬화하기

[ 파일 정보와 디렉토리 정보 다루기 ]

[ 파일과 디렉토리 ]

- 파일은 컴퓨터 저장 매체에 기록되는 데이터의 묶음 .

- 디렉토리는 파일이 위치하는 주소로 파일을 담는다는 의미에서 폴더라고도 부른다 .

- .Net은 파일과 디렉토리 정보를 손쉽게 다룰 수 있도록 System.IO 네임스페이스에 다음 클래스를 제공한다 .


[ 파일 관련 클래스 ]

클래스 설명
File 파일의 생성,복사,삭제,이동,조회를 처리하는 정적 메소드를 제공 .
FileInfo File 클래스와 하는 일은 동일하지마나 정적 메소드 대신 인스턴스 메소드를 제공한다 .
Directory 디렉토리의 생성,삭제,이동,조회를 처리하는 정적 메소드를 제공한다 .
DirectoryInfo Directory 클래스와 하는 일은 동일하지만 정적 메소드 대신 인스턴스 메소드를 제공한다 .

- 하나의 파일에 대해 한두가지 작업 => File 정적 메소드를 이용

- 하나의 파일에 대해 여러작업 수행 => FileInfo 인스턴스 메소드를 이용


[ File / FileInfo 메서드와 프로퍼티 ]

기능 File FileInfo
생성 FileStream fs = File.Create("a.dat"); FileInfo file=new FileInfo("a.dat");
FileStram fs = file.Create();
복사 File.Copy("a.dat","b.dat"); FileInfo src = new FileInfo("a.dat");
FileInfo dst = src.CopyTo("d.dst");
삭제 File.Delete("a.dat"); FileInfo file = new FileInfo("a.dat");
file.Delete();
이동 File.Move("a.dat","b.dat"); FileInfo file=new FileInfo("a.dat");
file.MoveTo("b.dat");
존재 여부 확인 if(File.Exsits("a.dat"))
// ...
FileInfo file = new FileInfo("a.dat");
if(file.Exsits)
// ...
속성조회 Console.Write(File.GetAttributes("a.dat")); FileInfo file = new FileInfo("a.dat");
Console.Write(file.Attributes);

[ Directory / DirectoryInfo 메서드와 프로퍼티 ]

기능 Directory DirectoryInfo
생성 DirectoryInfo dir=Directory.CreateDirectory("a"); DirectoryInfo dir = new DirectoryInfo("a");
dir.Create();
삭제 Directory.Delete("a"); DirectoryInfo dir = new DirectoryInfo("a");
dir.Delete();
이동 Directory.Move("a","b"); DirecoryInfo dir = new DirectoryInfo("a");
dir.MoveTo("b");
존재 여부 확인 if(Directory.Exsits("a.dat"))
// ...
DirectoryInfo dir = new DirectoryInfo("a")
if(dir.Exsits)
// ...
속성조회 Console.WriteLine(Dircetory.GetAttributes("a")) DirectoryInfo dir = new DirectoryInfo("a")
Console.WriteLine(dir.Attributes);
하위 디렉토리 조회 string[]dirs = Directory.GetDirectories("a"); DirectoryInfo dir = new DirectoryInfo("a");
string[]dirs = dir.GetDirectories();
하위 파일 조회 string[]files = Directory.GetFiles("a"); DirectoryInfo dir = new DirectoryInfo("a");
dir.GetFiles();

[ 예제 프로그램 : 디렉토리 / 파일 정보 조회하기 ]

using System;
using System.Linq;
using System.IO;

namespace ConsoleApp;

class MainApp
{ 
    static void Main(string[] args)
    {
        string directory = args.Length < 1 ? "." : args[0];

        Console.WriteLine($"{directory} directory Info");
        Console.WriteLine("- Directories :");
        var directories = (from dir in Directory.GetDirectories(directory)
                           let info = new DirectoryInfo(dir)
                           select new
                           {
                               Name = info.Name,
                               Attributes = info.Attributes
                           }).ToList();

        foreach (var d in directories)
            Console.WriteLine($"{d.Name} : {d.Attributes}");

        Console.WriteLine("- Files : ");
        var files = (from file in Directory.GetFiles(directory)
                     //let은 LINQ안에서 변수를 만든다 . var 와 같은 역할
                     let info = new FileInfo(file)
                     select new
                     {
                         Name=info.Name,
                         FileSize=info.Length,
                         Attributes = info.Attributes
                     }).ToList();

        foreach (var f in files)
            Console.WriteLine($"{f.Name} : {f.FileSize} , {f.Attributes}");



    }
}

 


[ 예제 프로그램 : 디렉토리 / 파일 생성하기 ]

using System;
using System.Linq;
using System.IO;

namespace ConsoleApp;

class MainApp
{ 
    static void OnWrongPathType(string type)
    {
        Console.WriteLine($"{type}is wrong type");
        return;
    }

    static void Main(string[] args)
    {
       if(args.Length == 0)
        {
            Console.WriteLine(
                "Usage : Test.exe <Path> [Type : File/Direcoty]");
            return;
        }
       
        string path = args[0];
        string type = "File";

        if(args.Length > 1)
            type = args[1];

        //이미 존재한다면 최종수정시간만 갱신
        if(File.Exists(path)||Directory.Exists(path)) 
        {
            if (type == "File")
                File.SetLastWriteTime(path, DateTime.Now);
            else if(type=="Directory")
                Directory.SetLastWriteTime(path,DateTime.Now);
            else
            {
                OnWrongPathType(path);
                return;
            }
            Console.WriteLine($"Updated {path} {type}");
        }
        //생성
        else
        {
            if (type == "File")
                File.Create(path).Close();
            else if(type=="Directory")
                Directory.CreateDirectory(path);
            else
            {
                OnWrongPathType(path);
                return;
            }
            Console.WriteLine($"Create {path} {type}");
        }
    }
}

[ 파일을 읽고 쓰기 위해 알아야 할 것들 ]

[ 스트림 ]

- 파일을 다룰때 스트림은 데이터가 흐르는 통로 .

- 메모리에서 하드디스크로 데이터를 옮길 때 먼저 스트림을 만들어 둘 사이를 연결, 메모리에 있는 데이터를

바이트 단위로 하드디스크로 옮긴다 .


[ 순차 접근 방식 ]

- 스트림(데이터의 흐름)을 이용하여 파일을 다룰 때는 처음부터 끝까지 순서대로 읽고 쓰는것이 보통 (순차접근방식)

- 스트림의 구조는 네트워크/데이터 백업 장치의 입/출력 구조와도 통한다 .

- 스트림 이용시 파일이 아닌 네트워크를 향해 데이터를 흘려보내거나 읽을수 있고 , 테이프 백업 장치를 통해 데이터를 기록하거나 읽을 수 도 있다. 

- 즉 , 첫번째 바이트를 읽어야 두번째를 읽을 수 있다 .


[ 임의 접근 방식 ]

- 하드디스크는 데이터의 흐림이 단방향성을 가진 네트워크/자기 테이프 장치와 다르다 .

- 암과 헤드를 움직여 디스크의 어떤 위치에 기록된 데이터라도 즉시 찾을 수 있다 . (임의 접근 방식)

- 1MB 크기의 파일에서 786 byte 번째에 위치한 데이터를 읽을때 , 앞을 다 읽지 않아도 원하는 위치에 접근 가능하다 .


[ System.IO.Stream 클래스 ]

[ Stream 클래스 ]

- 그 자체로 입력 스트림/출력 스트림의 역할을 모두 할 수 있다 .

- 파일을 읽고 쓰는 방식인 순차접근 / 임의접근 방식 모두 지원한다 .

- Stream은 추상 클래스이기에 파생 클래스를 사용해야 한다 .

- 이는 다양한 매체 / 장치들에 대한 파일 입출력을 스트림 모델 하나로 다루기 위함이다 . ex)File Stream / Network Stream


[ FileStream 인스턴스 생성하기 ]

Stream stream1 = new FileStream("a.dat",FileMode.Create);	//새파일 생성
Stream stream2 = new FileStream("b.dat",FileMode.Open);		//파일 열기
Stream stream3 = new FileStream("c.dat",FileMode.OpenOrCreate);	//새파일 열거나 없으면 생성

Stream stream4 = new FileStream("d.dat",FileMode.Truncate);	//파일 비워서 열기
Stream stream5 = new FileStream("e.dat",FileMode.Append);	//덧붙이기 모드로 열기

[ 파일 쓰기 ]

public override void Write(
	byte[]array,	//쓸 데이터가 담겨 있는 byte 배열
    int offset,		//byte 배열 내 시작 오프셋
    int count		//기록할 데이터의 총 길이 (단위는 바이트)
);

public override void WriteByte(byte value);

 

long someValue = 0x123456789ABCDEF0;

// 1)파일 스트림 생성
Stream outStream = new FileStream("a.dat",FileMode.Create);

// 2)SomeValue(long 형식)을 byte 배열로 변환
byte[] wBytes = BitConverter.GetBytes(someValue);

// 3)변환한 byte 배열을 파일 스트림을 통해 파일에 기록
outStream.Write(wBytes,0,wBytes.Length);

// 4)파일 스트림 닫기
outStream.Close();

- FileStream 클래스는 파일에 데이터를 기록하기 위해 Stream 클래스로부터 물려받은 두가지를 오버라이딩 한다 .

- 위의 Write 메서드는 각종 데이터 형식을 byte로 변환해주는 BitConverter클래스를 통해 변환후 기록한다 .

- 임의 형식의 데이터를 byte 배열로 변환 / byte 배열에 담겨 있는 데이터를 다시 임의 형식의 데이터로 변환해준다 .


[ 파일 읽기 ]

public override int Read(
	byte[]array,	//읽을 데이터를 담을 byte 배열
    int offset,		//byte 배열 내 시작 오프셋
    int count		//읽을 데이터의 최대 바이트 수
);

public override int ReadByte();

 

byte[] rbytes = new Byte[8];

// 1)파일 스트림 생성
Stream inStream = new FileStream("a.dat",FileMode.Open);

// 2)rBytes의 길이만큼 (8바이트)데이터를 읽어 rbytes에 저장
inStream.Read(rBytes,0,rBytes.Length);

[ 예제 프로그램 - 하나의 데이터 기록하기 ]

using System;
using System.Linq;
using System.IO;
using System.Security.Cryptography;

namespace ConsoleApp;

class MainApp
{ 

    static void Main(string[] args)
    {
        long SomeValue = 0x123456789ABCDEF0;
        Console.WriteLine("{0,-1} : 0x{1:X16}", "Original Data", SomeValue);

        //쓰기
        Stream outStream = new FileStream("a.dat", FileMode.Create);
        byte[]wBytes = BitConverter.GetBytes(SomeValue);
        Console.Write("{0,-13} : ", "Byte Array");

        foreach (byte b in wBytes)
            Console.Write("{0:x2}", b);
        Console.WriteLine();

        outStream.Write(wBytes,0,wBytes.Length);
        outStream.Close();

        //읽기
        Stream inStream = new FileStream("a.dat", FileMode.Open);
        byte[] rbytes = new byte[8];

        int i = 0;
        while(inStream.Position<inStream.Length)
            rbytes[i++]=(byte)inStream.ReadByte();
        long readValue = BitConverter.ToInt64(rbytes, 0);

        Console.WriteLine("{0,-13} : 0x{1:x16}", "Read Data", readValue);
        inStream.Close();


    }
}

 

- long 형식으로부터 변환된 ByteArray의 순서가 역순임을 볼 수 있다 .

- 이는 CLR이 지원하는 바이트 오더가 데이터의 낮은 주소부터 기록하는 리틀 엔디안 방식이기에 나타난 현상이다 .

- 자바의 가상머신은 빅 엔디안 바이트 오더를 지원한다 .

- ARM과 x86 계열의 CPU들은 리틀 엔디안 방식으로 동작하지만 PowerCPU / Sparc 계열의 CPU는 빅엔디안으로 동작

- 즉 , 서로 다른 바이트 오더 방식을 사용하는 시스템 투성이기에 C#의 프로그램을 다른 시스템에서 읽도록 하려면 바이트 오더의 차이를 반드시 고려해야 한다 .


[ 순차접근 ]

- Stream 클래스에는 Positioin이라는 프로퍼티가 존재한다 .

- 이는 현재 스트림의 읽는 위치 / 쓰는 위치를 나타낸다 .

- 여러개의 데이터 여러개를 기록하는 일은 Write() / WriteByte()메소드를 차례차례 호출하는 것으로 충분하다 .

- 이렇게 파일을 순차적으로 쓰거나 읽는 방식을 순차접근이라고 한다 .


[ 임의접근 ]

- 파일 내의 임의의 위치에 Positioin이 위치하게 할 수 있다 .

- 임의 접근은 Seek()메소드 호출 / Position 프로퍼티에 직접 원하는 갑 대입시 지정한 위치로 점프 , 읽기/쓰기가 가능하다


[ 예제 프로그램 - 순차접근 / 임의접근 ]

using System;
using System.Linq;
using System.IO;
using System.Security.Cryptography;

namespace ConsoleApp;

class MainApp
{ 

    static void Main(string[] args)
    {
        Stream outStream = new FileStream("a.dat", FileMode.Create);
        Console.WriteLine($"Position : {outStream.Position}");

        outStream.WriteByte(0x01);
        Console.WriteLine($"Position : {outStream.Position}");

        outStream.WriteByte(0x02);
        Console.WriteLine($"Position : {outStream.Position}");

        outStream.WriteByte(0x03);
        Console.WriteLine($"Position : {outStream.Position}");

        outStream.Seek(5, SeekOrigin.Current);
        Console.WriteLine($"Position : {outStream.Position}");

        outStream.WriteByte(0x04);
        Console.WriteLine($"Position : {outStream.Position}");
        outStream.Close();
    }
}

 

- 0 ,1, 2 번지까지 순차적으로 기록하다 3,4,5,6,7 5개의 번지를 건너뛰어 8번지에 기록하였다 .


[ 힘든 파일 입출력 ]

- 메모리의 구조와 데이터를 저장할 장치의 구조가 상이하기에 생긴 문제이다 .

- 그나마 C# 클래스가 디스크 / 네트워크 등에 데이터를 기록하고 읽는 방법을 Stream 클래스로 추상화했기에

같은 프로그래밍 모델을 이용 할 수 있다 .

- FileStream을 통해 Stream 파일 처리 모델을 익혀두면 네트워크 프로그래밍에 도움이 될 것이다 .


[ 실수를 줄여주는 using 선언 ]

[ 대표적인 실수 ]

//파일 스트림 닫기 - 프로그래머가 주로 빼먹는 부분
outStream.Close();

//1 . using 선언
{
	using Stream outStream = new FileStream("a.dat",FileMode.Create);
    // 나머지 동작들
}
//using 선언을 통해 생성된 객체는 코드블록이 끝나면서 outStream.Dispose() 호출


//2 . using 선언문 아래에 코드 블록을 만들어 자원의 수명을 세부적으로 조절 .
using (Stream outStream = new FileStream("a.dat",FileMode.Create))
{
	//나머지 동작들
}
//using 선언을 통해 생성된 객체는 코드블록이 끝나면서 outStream.Dispose() 호출

- 코드 블록의 마지막에서 Dispose()메소드가 호출되도록 하는 using 선언은 Close()메소드 호출과 사실상 동일한 코드

- Stream.Close()메소드가 IDisposable 인터페이스에서 상속받은 Dispose()메소드를 호출하기 때문 .

- using 선언은 Stream 객체뿐 아니라 IDispose를 상속해서 Dispose()메소드를 구현하는 모든 객체에 대해 사용가능하다 .

using FS = System.IO.FireStream;

using Stream outStream = new FS("a.dat",FileMode.Create))
{
	//세부 동작
}

- using 별칭 지시문을 통해 긴 이름의 클래스를 간단한 별칭으로 사용 가능하다 .


[ 이진 데이터 처리를 위한 BinaryWriter / BinaryReader ]

[ BinaryWriter / Reader ]

- FileStream 클래스는 데이터 저장시 byte / byte 배열 형식으로 변환이 필요한 불편함이 있다 .

- .Net은 불편함을 해소하기 위해 BinaryWriter / BinaryReader 클래스를 제공한다 .

//생성자를 호출하며 FileStream의 인스턴스를 인수로 넘긴다 . 
//BinaryWriter 객체는 FileStream 인스턴스가 생성한 스트림에 대해 이진 데이터 기록을 수행
BinaryWriter bw = new BinaryWriter(new FileStream("a.dat",FileMode.Create));

//Write()메소드는 모든 데이터 형식에 대해 오버로딩 됨
bw.Write(32);
bw.Write("Good");
bw.Write(3.15);

bw.Close();

 

BinaryReader br = new BinaryReader(new FileStream("a.dat",FileMode.Open));

int a= br.ReadInt32();
string b = br.ReadString();
double c = br.ReadDouble();

br.Close();

[ 예제 ]

using System;
using System.Linq;
using System.IO;
using System.Security.Cryptography;

namespace ConsoleApp;

class MainApp
{ 

    static void Main(string[] args)
    {
        using (BinaryWriter bw = new BinaryWriter(new FileStream("a.dat",FileMode.Create)))
        {
            bw.Write(int.MaxValue);
            bw.Write("Good Morning");
            bw.Write(uint.MaxValue);
            bw.Write("안녕하세요");
            bw.Write(double.MaxValue);
        }

        //만약 위와 같은 스타일의 using 선언이 아니었다면 열려 있는 파일을 다시 여는것과 같음
        using BinaryReader br = new BinaryReader(new FileStream("a.dat", FileMode.Open));

        Console.WriteLine($"File Size : {br.BaseStream.Length} bytes");
        Console.WriteLine($"{br.ReadInt32()}");
        Console.WriteLine($"{br.ReadString()}");
        Console.WriteLine($"{br.ReadUInt32()}");
        Console.WriteLine($"{br.ReadString()}");
        Console.WriteLine($"{br.ReadDouble()}");

    }
}

- FileStream 만으로 데이터를 기록시 BitConverter를 이용 , 각 데이터를 바이트 단위로 나눠 저장했음 .

- BinaryWriter는 각 데이터 타입을 알아서 바이트 단위로 저장해준다 .

- 문자열 저장시 문자열의 길이를 저장할 데이터의 가장 첫번째 바이트에 바이트에 저장한후 그 뒤에 데이터를 저장한다 .


[ 텍스트 파일 처리를 위한 StreamWriter / StreamReader ]

[ 텍스트 파일 ]

- 텍스트 파일은 구조는 간단하지만 활용도가 높은 파일 형식이다 .

- .Net은 텍스트 파일 을 쓰고 읽을수 있게 StreamWriter / StreamReader를 제공한다 .

- Stream이 NetworkStream 이라면 네트워크를 통해 텍스트 데이터를 내보내거나 읽어들인다 .

- Stream이 FileStream 이라면 파일로 텍스트 데이터를 내보내거나 읽어들인다 .

using System;
using System.Linq;
using System.IO;
using System.Security.Cryptography;

namespace ConsoleApp;

class MainApp
{ 

    static void Main(string[] args)
    {
        using (StreamWriter sw = new StreamWriter(new FileStream("a.txt",FileMode.Create)))
        {
            sw.WriteLine(int.MaxValue);
            sw.WriteLine("Good Morning");
            sw.WriteLine(uint.MaxValue);
            sw.WriteLine("안녕하세요");
            sw.WriteLine(double.MaxValue);
        }

        //만약 위와 같은 스타일의 using 선언이 아니었다면 열려 있는 파일을 다시 여는것과 같음
        using (StreamReader sr = new StreamReader(new FileStream("a.txt", FileMode.Open)))
        {
            Console.WriteLine($"File Size : {sr.BaseStream.Length} bytes");

            //EndOfStream 프로퍼티는 스트림의 끝에 도달했는지 알려준다 .
            while(sr.EndOfStream==false)
            {
                Console.WriteLine(sr.ReadLine());
            }
        }        
    }
}

 


[ 객체 직렬화 하기 ]

[ 직렬화 ]

- 프로그래머가 직접 정의한 클래스나 구조체 같은 복합 데이터 형식은 스트림에 읽고 쓰는 것을 지원하지 않는다 .

- 읽고 쓰려면 그 형식이 가진 필드의 값을 저장할 순서를 정한후 , 순서대로 저장하고 읽는 코드를 작성해야 한다 .

- 위 작업은 C#에서 직렬화라는 메커니즘으로 간편하게 제공한다 .

- 직렬화는 객체의 상태(필드에 저장된 값)를 메모리/영구 저장 장치에 저장이 가능한 0과 1의 순서로 바꾸는 것을 말한다 . 


[ 직렬화 하기 ]

Stream ws = new FileStream(fileName,FileMode.Create);
NameCard nc = new NameCard();

string jsonString = JsonSerializer.Serialzie<NameCard>(nc);//직렬화
byte[] jsonBytes = System.Text.Encoding.UTF8.GetBytes(jsonString);
ws.Write(jsonBytes,0,jsonBytes.Length);

- 직렬화 할 프로퍼티를 public 으로 한정한다 .

- JsonSerializer는 System.Text.Json 네임스페이스에 소속 , 객체를 JSON 형태로 직렬화 / 역직렬화 한다 .


[ 역직렬화 하기 ]

Stream rs = new FileStream(fileName,FileMode.Open)
byte[]jsonBytes = new Byte[rs.Length];
rs.Read(jsonBytes,0,jsonBytes.Length);
string jsonString = System.Text.Encoding.UTF8.GetString(jsonBytes);

NameCard nc2 = JsonSerialzier.Desirializer<NameCard>(jsonString)

[ 직렬화 무시하기 ]

class NameCard
{
	public string Name{get;set;}
    [JsonIgnore]
    public int Age{get;set;}
}

- JsonIngore 어트리뷰트로 수식하면 직렬화 될 때 저장되지 않고 , 역직렬화시 복원되지 않는다 .


[ 직렬화 예제 ]

using System;
using System.Linq;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
using System.Text.Json;

namespace ConsoleApp;
class NameCard
{
    public string Name { get; set; }
    public string Phone { get; set; }
    public int Age { get; set; }
}
class MainApp
{ 

    static void Main(string[] args)
    {
        var fileName = "a.json";

        using(Stream ws = new FileStream(fileName,FileMode.Create))
        {
            NameCard nc = new NameCard()
            {
                Name="박상현",
                Phone="010-123-4567",
                Age=33
            };

            string jsonString = JsonSerializer.Serialize<NameCard>(nc);
            byte[]jsonBytes = System.Text.Encoding.UTF8.GetBytes(jsonString);
            ws.Write(jsonBytes,0,jsonBytes.Length);
        }

        using (Stream rs = new FileStream(fileName, FileMode.Open))
        {
            byte[]jsonBytes = new byte[rs.Length];
            rs.Read(jsonBytes,0,jsonBytes.Length);
            string jsonString = System.Text.Encoding.UTF8.GetString(jsonBytes);

            NameCard nc2 = JsonSerializer.Deserialize<NameCard>(jsonString);

            Console.WriteLine($"Name : {nc2.Name}");
            Console.WriteLine($"Phone : {nc2.Phone}");
            Console.WriteLine($"Age : {nc2.Age}");
        }
    }
}

[ 컬렉션 직렬화 예제 ]

using System;
using System.Linq;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
using System.Text.Json;
using System.Collections.Generic;

namespace ConsoleApp;
class NameCard
{
    public string Name { get; set; }
    public string Phone { get; set; }
    public int Age { get; set; }
}
class MainApp
{ 

    static void Main(string[] args)
    {
        var fileName = "a.json";

        using(Stream ws = new FileStream(fileName,FileMode.Create))
        {
            var list = new List<NameCard>();
            list.Add(new NameCard() { Name="박상현",Phone="010-123-4567",Age=33});
            list.Add(new NameCard() { Name = "김연아", Phone = "010-3333-4567", Age = 32 });
            list.Add(new NameCard() { Name = "장미란", Phone = "010-443-67", Age = 39 });


            string jsonString = JsonSerializer.Serialize<List<NameCard>>(list);
            byte[]jsonBytes = System.Text.Encoding.UTF8.GetBytes(jsonString);
            ws.Write(jsonBytes,0,jsonBytes.Length);
        }

        using (Stream rs = new FileStream(fileName, FileMode.Open))
        {
            byte[]jsonBytes = new byte[rs.Length];
            rs.Read(jsonBytes,0,jsonBytes.Length);
            string jsonString = System.Text.Encoding.UTF8.GetString(jsonBytes);

            var list2 = JsonSerializer.Deserialize<List<NameCard>>(jsonString);

            foreach(NameCard nc in list2)
            {
                Console.WriteLine($"Name : {nc.Name} , Phone : {nc.Phone} , Age : {nc.Age}");

            }
        }
    }
}

- List를 비롯한 컬렉션들도 직렬화를 지원한다 .