공부/C#

이것이 C#이다 Chapter 18 파일 다루기

bokob 2024. 3. 17. 20:46

18.1 파일 정보와 디렉터리 정보 다루기

파일(File)

  • 컴퓨터 저장 매체에 기록되는 데이터의 묶음

디렉터리(Directory)

파일이 위치하는 주소로, 파일(서류)를 담는다는 의미에서 폴더(Folder, 서류철)라고 부르기도 함

 

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

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

File 클래스와 FileInfo 클래스는 거의 같은 기능을 제공한다. 메소드를 제공하는 방식의 차이가 다르긴 하다.

보통 하나의 파일에 대해 한두 가지 정도의 작업 시 File 클래스의 정적 메소드를 이용하고, 하나의 파일에 여러 작업을 수행할 때는 FileInfo 클래스의 인스턴스 메소드를 이용한다.

Directory 클래스와 DirectoryInfo 클래스에 대해서도 마찬가지다.

 

File, FileInfo, Directory, DirectoryInfo 클래스에서 제공하는 주요 메소드와 프로퍼티

기능 File FileInfo Directory DirectoryInfo
생성 Create() Create() CreateDirectory() Create()
복사 Copy() CopyTo() - -
삭제 Delete() Delete() Delete() Delete()
이동 Move() MoveTo() Move() MoveTo()
존재 여부 확인 Exists() Exists Exists() Exists
속성 조회 GetAttributes() Attributes GetAttributes() Attributes
하위 디렉터리 조회 - - GetDirectories() GetDirectories()
하위 파일 조회 - - GetFiles() GetFiles()

 

File, FileInfo 사용법

기능 File FileInfo
생성 FileStream fs = File.Create("a.dat"); FileInfo file = new FileInfo("a.dat");
FileStream fs = file.Create();
복사 File.Copy("a.dat", "b.dat"); FileInfo src = new FileInfo("a.dat");
FileInfo dst = src.CopyTo("b.dat");
삭제 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.Exists("a.dat"))
// ...
FileInfo file = new FileInfo("a.dat");
if(file.Exists)
// ...
속성 조회 Console.WriteLine(File.GetAttributes("a.dat")); FileInfo file = new FileInfo("a.dat");
Console.WriteLine(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"); DirectoryInfo dir = new DirectoryInfo("a");
dir.MoveTo("b");
존재 여부 확인 if(Directory.Exists("a.dat"))
// ...
DirectoryInfo dir = new DirectoryInfo("a");
if(dir.Exists)
// ...
속성 조회 Console.WriteLine(Directory.GetAttributes("a")); DirectoryInfo dir = new DirectoryInfo("a");
Console.WriteLine(dir.Attributes);
하위 디렉터리 조회 string[] dirs = Directory.GetDirectories("a"); DirectoryInfo dir = new DirectoryInfo("a");
DirectoryInfo[] dirs = dir.GetDirectories();
하위 파일 조회 string[] files = Directory.GetFiles("a"); DirectoryInfo dir = new DirectoryInfo("a");
FileInfo[] files = dir.GetFiles();

 

 

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

스트림(stream)

  • 시내, 강, 또는 도로의 차선을 뜻하는 영단어지만 파일을 다룰 때는 '데이터가 흐르는 통로'를 뜻함
  • 데이터를 옮길 때는 이 스트림을 먼저 만들어 둘 사이를 연결한 후, 데이터를 바이트 단위로 옮긴다.

데이터 접근 방식

1. 순차 접근(Sequenctial Access)

  • 처음부터 끝까지 순서대로 접근하는 방식

2. 임의 접근(Random Access)

  • 임의의 주소에 있는 데이터에 곧바로 접근하는 방식

 

18.2.1 System.IO.Stream 클래스

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);       // 덧붙이기 모드로 열기

 

FileStream으로 파일 쓰기

FileStream 클래스는 파일을 쓰기 위해 Stream 클래스로부터 물려받은 다음 두 가지 메소드를 오버라이딩하고 있다.

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

public override void WriteByte(byte value);

 

BitConverter 클래스를 이용해 임의 형식의 데이터를 byte의 배열로 변환하거나, byte의 배열에 담겨 있는 데이터를 다시 임의 형식으로 변환해서 사용한다.

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으로 데이터 읽어오기

FileStream 클래스는 파일에서 데이터를 읽어오기 위해 Stream 클래스로부터 물려받은 다음 두 가지 메소드를 오버라이딩하고 있다.

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);

바이트 오더(Byte Order)

16진수 123456789ABCDEF0을 바이트 단위로 쪼개면 12, 34, 56, 78, 9A, BC, DE, F0의 순서로 배열에 들어가야 한다.

이 배열을 a.dat 파일로 만들고 바이너리 파일 뷰어로 열어보면 다음과 같다.

offset (h) 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000   F0 DE BC 9A 78 56 45 12

 

이 현상은 CLR이 지원하는 바이트 오더(Byte Order)가 데이터의 낮은 주소부터 기록하는 리틀 엔디안(Little Endian) 방식이기 때문이다.

반면에 자바의 JVM은 빅 엔디안(Big Endian) 바이트 오더를 지원한다.

 

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

C# 프로그램에서 만든 파일을 다른 시스템에서 읽도록 하려면 바이트 오더의 차이를 반드시 고려해야 한다. 그 반대도 마찬가지

네트워크를 통해 전송되는 데이터에 대해서도 같은 고려가 필요


Stream 클래스에는 Position이라는 프로퍼티가 존재한다. FileStream은 Stream 클래스의 파생 클래스이기에 역시 존재

Position 프로퍼티는 현재 스트림의 읽는 위치 또는 쓰는 위치를 나타낸다.

ex) Position이 3이면 3번째 바이트에서 쓰거나 읽을 준비가 되어 있는 상태

 

WriteByte(), ReadByte() 메소드는 Position이 1씩 증가하고, Write(), Read() 메소드는 쓰거나 읽은 바이트 수만큼 Position이 증가한다.

따라서 여러 개의 데이터를 여러 개를 기록하는 일은 Write()나 WriteByte() 메소드를 차례로 호출하면 된다.

이를 '순차 접근'이라고 한다.

 

한편, 파일 내의 임의의 위치에 Position이 위치하도록 할 수 있는 방식을 '임의 접근'이라고 한다.

Seek() 메소드를 호출하거나 Position 프로퍼티에 원하는 값을 대입하면 지정한 위치로 점프해 읽기/쓰기를 위한 준비를 할 수 있다.

Stream outStream = new FileStream("a.dat", FileMode.Create);

// ...

// SeekOrigin.Current는 스트림의 현재 위치를 기준으로 이동한다는 뜻
outStream.Seek(5, SeekOrigin.Current); // 현재 위치에서 5바이트 뒤로 이동
outStream.WriteByte(0x04);

 

18.3 실수를 줄여주는 using 선언

using 키워드

  • 네임스페이스 참조
  • 파일이나 소켓 등 할당받은 자원을 유효한 범위를 벗어나면 자동으로 해제
  • 별칭 지시문 ex) using FS = System.IO.FileStream;

using 선언문

{
    // 1) 파일 스트림 열기
    using 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);
} // using 선언을 통해 생성된 객체는 코드 블록이 끝나면서 outStream.Dispose() 호출

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

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

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

 

using 선언문의 다른 형태

// 1) 파일 스트림 열기
using (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);
} // using 선언을 통해 생성된 객체는 코드 블록이 끝나면서 outStream.Dispose() 호출

 

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

.NET은 FileStream의 불편함(byte 또는 byte 배열로 변환하는 점)을 해소하기 위해 다음과 같은 도우미 클래스 이외에도 많은 클래스들을 제공한다.

 

BinaryWriter

  • 스트림에 이진 데이터를 기록하기 위한 목적으로 만들어진 클래스

BinaryReader

  • 이진 데이터를 읽어들이기 위한 목적으로 만들어진 클래스

위 두 클래스는 파일 처리의 도우미 역할을 할 뿐이기 때문에 이를 사용하기 위해서는 Stream으로부터 파생된 클래스의 인스턴스가 있어야 한다.

BinaryWriter bw = new BinaryWriter(new FileStream("a.dat", FileMode.Create));

// Write 메소드는 C#이 제공하는 모든 기본 데이터 형식에 대해 오버로딩되어 있음
bw.Write(32);
bw.Write("Good Morning!");
bw.Write(3.14);

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

// BinaryReader는 읽을 데이터 형식별로 ReadInt32처럼 'Read데이터_형식()'꼴의 메소드를 제공함
int a = br.ReadInt32();
string b = br.ReadString();
double c = br.ReadDouble();

br.Close();

 

using System;
using System.IO;

namespace BinaryFile
{
    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 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}");
        }
    }
}

위 예제 코드에서의 결과 파일을 바이터리 데이터 뷰어로 보았을 때

BinaryWriter가 각 데이터 타입을 알아서 바이트 단위로 저장한다.

문자열을 저장할 때는 문자열의 길이를 저장할 데이터의 가장 첫 번째 바이트에 저장해두고 그 뒤부터 문자열 데이터를 저장

 

 

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

.NET은 텍스트 파일을 쓰고 읽을 수 있는 StreamWriter/StreamReader 라는 도우미 클래스를 제공한다.

StreamWriter sw = new StreamWriter(new FileSstream("a.dat", FileMode.Create));

// Write, WriteLine() 메소드는 C#이 제공하는 모든 기본 데이터 형식에 대해 오버로딩되어 있음
sw.Write(32);
sw.WriteLine("Good Morning!");
sw.WriteLine(3.14);

sw.Close();
StreamReader sr = new StreamReader(new FileStream("a.dat", FileMode.Open));

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

sr.Close();

 

18.6 객체 직렬화하기

프로그래머가 정의한 클래스나 구조체 같은 복합 데이터 형식을 스트림에 쉽게 읽고 쓰기 위해 C#은 직렬화를 제공

 

직렬화(Serialization)

  • 객체의 상태(객체의 필드에 저장된 값들)를 메모리나 영구 저장 장치에 저장이 가능한 0과 1의 순서로 바꾸는 것


.NET의 직렬화가 지원하는 형식

.NET은 이진(Binary) 형식에서 직렬화에 치명적인 보안 취약점을 발견하여 BinaryFormatter의 사용을 금지하고 있다(SYSLIB0011 컴파일러 경고를 해제하면 사용할 수 있음).

이외에도 JSON(JavaScript Object Notation)이나 XML 형식으로의 직렬화를 사용할 수 있다.


직렬화할 프로퍼티를 pubblic으로 한정하면 해당 클래스는 메모리나 영구 저장 장치에 저장할 수 있는 형식이 된다.

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

이를 Stream 클래스와 JsonSerializer를 이용해서 간단히 저장할 수 있다. (책에서는 JSON만 다룸)

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

// ~ nc에 프로퍼티 값 저장 ~

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

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 = JsonSerializer.Deserialize<NameCard>(jsonString); // 역직렬화

 

상태를 저장하고 싶지 않은 프로퍼티는 그 프로퍼티만 System.Text.Json.Serialization 네임스페이스의 [JsonIgnore] 애트리뷰트로 수식해주면 된다. 이렇게 하면 해당 프로퍼티는 직렬화 시에 저장되지 않고, 역직렬화 시에는 복원되지 않는다.

class NameCard
{
    public string Name {get; set;}
    public string Phone {get; set;}
    
    [JsonIgnore]
    public int Age {get; set;} // 직렬화 및 역직렬화 안 됨
}