13장. 파일 입출력¶
파일 입출력(File I/O)은 컴퓨터 과학의 가장 근본적이고도 보편적인 작업 중 하나로, 프로그램이 외부 영속 저장 장치(Persistent Storage)와 데이터를 주고받는 모든 활동을 포괄합니다. 폰 노이만 아키텍처(Von Neumann Architecture)에서 정의한 컴퓨터의 기본 구성 요소인 중앙처리장치(CPU), 메모리(Memory), 그리고 입출력 장치(I/O Devices) 중에서, 파일 시스템은 입출력 장치의 가장 핵심적인 추상화 계층입니다. 휘발성 메모리(Volatile Memory)인 RAM에 저장된 데이터는 프로그램 종료나 시스템 재부팅 시 모두 소실되지만, 비휘발성 저장 장치(Non-Volatile Storage)인 하드 디스크, SSD, 또는 네트워크 스토리지에 파일로 저장된 데이터는 영구적으로 보존됩니다.
현대 소프트웨어 시스템에서 파일 입출력은 단순한 데이터 저장을 넘어서, 프로세스 간 통신(Inter-Process Communication, IPC), 설정 관리(Configuration Management), 로깅(Logging), 캐싱(Caching), 데이터 직렬화(Serialization), 그리고 분산 시스템에서의 상태 공유 등 다양한 고수준 추상화의 기반이 됩니다. 사용자 설정 파일, 게임 세이브 데이터, 애플리케이션 로그, 데이터베이스 백업, 문서 파일, 미디어 콘텐츠 등 거의 모든 실용적인 애플리케이션은 파일 시스템과의 복잡한 상호작용을 필요로 합니다.
파일 시스템의 역사적 진화와 컴퓨터 과학적 토대:
파일 시스템의 개념은 1960년대 초기 메인프레임 시대에 자기 테이프(Magnetic Tape)와 디스크 드라이브가 보급되면서 태동했습니다. 초기 컴퓨터는 천공 카드(Punched Cards)나 종이 테이프(Paper Tape)를 입출력 매체로 사용했으나, 이들은 순차 접근(Sequential Access)만 가능했고 검색과 수정이 극도로 비효율적이었습니다. 1956년 IBM의 RAMAC(Random Access Method of Accounting and Control)이 최초의 하드 디스크 드라이브를 도입하면서 임의 접근(Random Access) 저장 장치의 시대가 열렸습니다.
1964년 IBM의 OS/360 운영체제는 계층적 파일 시스템(Hierarchical File System)**의 개념을 정립했으며, 이는 디렉터리(Directory) 구조를 통해 파일을 체계적으로 조직하는 현대 파일 시스템의 근간이 되었습니다. 1969년 켄 톰프슨(Ken Thompson)과 데니스 리치(Dennis Ritchie)가 개발한 Unix 운영체제는 "**모든 것은 파일이다(Everything is a file)"라는 획기적인 추상화 철학을 도입했습니다. 이 철학에서 일반 파일뿐만 아니라 디렉터리, 디바이스, 파이프, 소켓 등 모든 시스템 리소스를 통일된 파일 인터페이스로 접근할 수 있게 함으로써, 단순하면서도 강력한 프로그래밍 모델을 제공했습니다.
이후 FAT(File Allocation Table, 1977), ext(Extended File System, 1992), NTFS(New Technology File System, 1993), HFS+(Hierarchical File System Plus, 1998), ext4(2008), Btrfs(B-tree File System, 2009) 등 다양한 파일 시스템이 발전해 왔으며, 각각은 저널링(Journaling), 스냅샷(Snapshot), 압축(Compression), 암호화(Encryption), 중복 제거(Deduplication) 등의 고급 기능을 제공하게 되었습니다. 현대의 분산 파일 시스템(Distributed File Systems)인 HDFS(Hadoop Distributed File System), GFS(Google File System), Amazon S3 등은 페타바이트(Petabyte) 규모의 데이터를 수천 대의 서버에 분산하여 저장하고 처리할 수 있는 능력을 제공합니다.
C#과 .NET의 파일 시스템 추상화:
C#의 파일 입출력 API는 .NET Framework의 System.IO 네임스페이스에 정의되어 있으며, 이는 Common Language Infrastructure(CLI) 명세의 일부로 표준화되어 있습니다. .NET의 파일 시스템 추상화는 Windows의 Win32 API, Linux의 POSIX(Portable Operating System Interface) 시스템 콜, 그리고 macOS의 BSD 계열 인터페이스를 모두 통합하여, 플랫폼 독립적인(Platform-Agnostic) 일관된 프로그래밍 모델을 제공합니다. 이는 **추상화(Abstraction)**의 힘을 극대화한 사례로, 개발자는 운영체제별 세부 구현을 의식하지 않고도 동일한 코드로 Windows, Linux, macOS에서 파일을 다룰 수 있습니다.
.NET의 파일 I/O 아키텍처는 다층 구조(Layered Architecture)로 설계되어 있습니다. 최하위 계층은 운영체제의 네이티브 API(Native API)를 래핑(Wrapping)하는 Platform Abstraction Layer(PAL)이며, 그 위에 스트림(Stream) 기반의 I/O 추상화, 그리고 최상위에 편의 메서드(Convenience Methods)를 제공하는 고수준 API가 위치합니다. 이러한 계층화는 관심사의 분리(Separation of Concerns) 원칙을 실현하며, 각 계층은 독립적으로 최적화되고 테스트될 수 있습니다.
텍스트 파일 vs 바이너리 파일의 이론적 구분:
파일은 내용의 구조와 의미론(Semantics)에 따라 크게 텍스트 파일(Text File)과 바이너리 파일(Binary File)로 구분됩니다. 이 구분은 단순히 파일 확장자나 MIME 타입으로 결정되는 것이 아니라, 데이터의 인코딩 방식과 해석 의미론에 기반합니다.
텍스트 파일**은 문자 인코딩 표준(ASCII, UTF-8, UTF-16, ISO-8859 등)에 따라 인코딩된 문자 시퀀스로 구성되며, 텍스트 에디터나 터미널에서 직접 읽고 편집할 수 있는 **사람이 읽을 수 있는(Human-Readable) 형식입니다. .txt, .csv, .json, .xml, .html, .md, .log, .ini, .yaml 등이 대표적인 텍스트 파일 형식입니다. 텍스트 파일의 핵심 특징은 **플랫폼 간 상호 운용성(Interoperability)**입니다. 표준 인코딩을 사용하므로 서로 다른 운영체제, 프로그래밍 언어, 애플리케이션 간에 데이터를 교환할 수 있으며, 버전 관리 시스템(Git, SVN 등)에서 변경 내역을 추적하기 용이합니다.
반면 바이너리 파일**은 특정 애플리케이션이나 포맷 명세에 따라 구조화된 이진 데이터(Raw Binary Data)로 구성되며, 전용 프로그램 없이는 해석할 수 없는 **기계가 읽는(Machine-Readable) 형식입니다. .exe, .dll, .so, .jpg, .png, .mp3, .mp4, .pdf, .docx, .db, .dat 등이 바이너리 파일에 해당합니다. 바이너리 파일의 장점은 **공간 효율성(Space Efficiency)**과 **처리 성능(Processing Performance)**입니다. 텍스트로 "1234567890"을 저장하면 10바이트가 필요하지만, 32비트 정수로 저장하면 4바이트만 필요합니다. 또한 파싱(Parsing) 오버헤드 없이 메모리에 직접 로드하여 사용할 수 있어, 대용량 데이터 처리에 유리합니다.
이 장에서는 주로 텍스트 파일 처리를 다루며, 특히 설정 파일(Configuration Files), 로그 파일(Log Files), 데이터 교환 형식(Data Interchange Formats)인 CSV와 JSON 같은 실용적인 예제를 중심으로 학습합니다. 바이너리 파일 처리는 BinaryReader와 BinaryWriter 클래스를 통해 수행할 수 있으며, 고급 주제로 다룰 수 있습니다.
이 장에서 배울 내용¶
이 장을 통해 독자 여러분은 C#에서 파일 시스템을 다루는 이론적 토대와 실무적 기법을 종합적으로 습득하게 됩니다:
-
텍스트 파일 읽기의 이론과 실제:
File.ReadAllText(),File.ReadAllLines(),StreamReader등 다양한 읽기 메커니즘의 내부 동작 원리, 메모리 관리 전략, 그리고 버퍼링(Buffering)과 스트리밍(Streaming)의 성능 특성을 이해합니다. 작은 파일의 원자적(Atomic) 읽기부터 대용량 파일의 메모리 효율적 스트림 처리까지, 각 시나리오에 최적화된 접근 방법을 학습합니다. -
텍스트 파일 쓰기의 다양한 패러다임:
File.WriteAllText(),File.WriteAllLines(),StreamWriter를 통한 파일 쓰기의 원자성(Atomicity), 내구성(Durability), 그리고 동시성(Concurrency) 이슈를 탐구합니다. 덮어쓰기(Overwrite)와 추가(Append) 모드의 차이, 버퍼 플러싱(Buffer Flushing) 전략, 그리고 트랜잭션 파일 쓰기(Transactional File Writing) 패턴을 이해합니다. -
파일 시스템 메타데이터 조작과 경로 처리:
File,Directory,Path클래스를 활용한 파일 시스템 메타데이터 관리, 경로 정규화(Path Normalization), 플랫폼 독립적 경로 처리, 그리고 파일 속성(File Attributes) 및 권한(Permissions) 관리를 배웁니다. 파일 복사, 이동, 삭제의 원자성 보장과 예외 처리 전략을 학습합니다. -
JSON 직렬화의 현대적 접근:
System.Text.Json을 사용한 고성능 JSON 직렬화와 역직렬화의 내부 메커니즘, 타입 안전성(Type Safety), 그리고 스키마 진화(Schema Evolution) 전략을 이해합니다. 객체-관계 매핑(Object-Relational Mapping)과 유사한 객체-JSON 매핑의 원리, 커스텀 컨버터(Custom Converter), 그리고 성능 최적화 기법을 습득합니다.
13.1 텍스트 파일 읽기¶
파일 읽기는 파일 입출력의 가장 기본적이면서도 가장 빈번하게 수행되는 작업으로, 프로그램이 외부 데이터를 메모리로 로드하여 처리할 수 있게 하는 핵심 메커니즘입니다. C#과 .NET은 파일 읽기를 위한 계층화된 API를 제공하며, 각 계층은 서로 다른 추상화 수준과 성능 특성을 가집니다. 최상위의 편의 메서드(Convenience Methods)인 File.ReadAllText()와 File.ReadAllLines()는 간결한 코드로 빠른 개발을 가능하게 하지만, 파일 전체를 메모리에 로드하므로 대용량 파일에는 적합하지 않습니다. 반면 중간 계층의 StreamReader는 스트림 기반 읽기를 통해 메모리 효율성을 제공하며, 최하위 계층의 FileStream과 BufferedStream은 버퍼 크기와 읽기 전략에 대한 세밀한 제어를 가능하게 합니다.
파일 읽기의 시스템 레벨 동작 원리:
운영체제 수준에서 파일 읽기는 여러 복잡한 단계를 거칩니다. 먼저 애플리케이션이 파일 열기 시스템 콜(예: Windows의 CreateFile, Unix의 open)을 호출하면, 운영체제는 파일 경로를 파싱하고 파일 시스템 메타데이터를 조회하여 파일의 물리적 위치(inode, MFT 엔트리 등)를 확인합니다. 파일이 존재하고 권한이 허용되면, 커널은 파일 디스크립터(File Descriptor) 또는 핸들(Handle)을 할당하고 애플리케이션에 반환합니다. 이 디스크립터는 파일의 현재 읽기 위치(File Pointer)와 메타데이터를 관리하는 커널 내부 구조체를 가리킵니다.
실제 데이터 읽기는 여러 단계의 캐싱과 버퍼링을 통해 최적화됩니다. 먼저 운영체제의 페이지 캐시(Page Cache) 또는 **버퍼 캐시(Buffer Cache)**를 확인하여, 요청된 데이터가 이미 메모리에 있는지 검사합니다. 캐시 히트(Cache Hit)인 경우 디스크 I/O 없이 메모리에서 직접 데이터를 복사하므로 극도로 빠릅니다(수백 나노초). 캐시 미스(Cache Miss)인 경우 디스크 컨트롤러에 읽기 명령을 보내고, 디스크는 기계적 헤드 이동(HDD의 경우)이나 전자적 셀 접근(SSD의 경우) 후 데이터를 읽어 DMA(Direct Memory Access)를 통해 시스템 메모리로 전송합니다(수 밀리초). 읽어진 데이터는 페이지 캐시에 저장되어 후속 읽기 요청에 재사용됩니다.
.NET의 파일 읽기 클래스들은 이러한 운영체제 메커니즘 위에 추가적인 추상화와 최적화를 제공합니다. StreamReader는 내부적으로 고정 크기 버퍼(기본 1KB)를 유지하며, 애플리케이션이 한 줄을 요청하더라도 한 번에 여러 줄을 미리 읽어(Prefetch) 버퍼에 저장합니다. 이는 시스템 콜 오버헤드를 줄이고(Context Switching 최소화), 디스크의 순차 읽기(Sequential Read) 패턴을 활용하여 전체적인 처리량(Throughput)을 향상시킵니다.
인코딩(Encoding)의 이론적 토대와 실무적 함의:
텍스트 파일을 읽을 때 가장 중요한 개념이 **문자 인코딩(Character Encoding)**입니다. 컴퓨터는 본질적으로 이진 데이터(Binary Data)만 처리할 수 있으므로, 문자를 숫자로 매핑(Mapping)하는 표준화된 규칙이 필요합니다. 이러한 매핑 체계를 인코딩 또는 문자 집합(Character Set)이라고 합니다.
역사적으로 가장 초기의 표준은 1963년 제정된 ASCII(American Standard Code for Information Interchange)**입니다. ASCII는 7비트를 사용하여 128개의 문자(영문 대소문자, 숫자, 기본 기호, 제어 문자)를 표현합니다. 그러나 ASCII는 영어권 문자만 지원하므로, 다국어 지원을 위해 ISO-8859 시리즈(Latin-1, Latin-2 등), Windows-1252, EUC-KR(한국어), Shift-JIS(일본어), GB2312(중국어) 등 다양한 8비트 확장 인코딩이 등장했습니다. 이러한 레거시 인코딩들은 각국의 문자를 지원했지만, 서로 호환되지 않아 데이터 교환 시 **문자 깨짐(Mojibake) 문제를 야기했습니다.
1991년 유니코드 컨소시엄(Unicode Consortium)은 전 세계 모든 문자 체계를 단일 표준으로 통합하는 유니코드(Unicode) 표준을 발표했습니다. 유니코드는 현재 약 150,000개 이상의 문자를 정의하며, 각 문자에 고유한 코드 포인트(Code Point, 예: U+AC00은 '가')를 할당합니다. 하지만 유니코드 자체는 추상적인 문자 집합일 뿐이며, 실제로 바이트 시퀀스로 인코딩하기 위해서는 UTF(Unicode Transformation Format) 인코딩 방식이 필요합니다.
UTF-8**은 현대 인터넷과 소프트웨어 개발의 사실상 표준(De Facto Standard) 인코딩으로, 가변 길이(Variable-Length) 인코딩 방식입니다. ASCII 문자(U+0000~U+007F)는 1바이트로 표현되어 ASCII와 완전히 호환되며, 한글과 한자는 3바이트, 이모지와 고대 문자는 4바이트로 표현됩니다. UTF-8의 장점은 **공간 효율성(영어 중심 텍스트에서 최소 메모리 사용), 견고성(중간에 바이트가 손실되어도 자가 동기화 가능), 그리고 바이트 순서 독립성(Byte Order Independence, BOM 불필요)입니다.
UTF-16**은 .NET 내부에서 문자열을 표현하는 인코딩으로, 대부분의 문자를 2바이트로 표현하지만 일부 문자(Supplementary Characters, U+10000 이상)는 4바이트(서로게이트 쌍, Surrogate Pair)로 표현합니다. UTF-16의 장점은 대부분의 현대 언어 문자에 대해 **고정 너비(Fixed-Width) 처리가 가능하여 인덱싱이 빠르다는 점이지만, ASCII 호환성이 없고 바이트 순서(Byte Order, Endianness)에 따라 UTF-16LE(Little-Endian)와 UTF-16BE(Big-Endian)로 구분되어 BOM(Byte Order Mark)이 필요할 수 있습니다.
.NET의 파일 읽기 메서드는 기본적으로 UTF-8 인코딩을 사용하지만, Encoding 클래스를 통해 다양한 인코딩을 명시적으로 지정할 수 있습니다. 레거시 시스템이나 외부 데이터와의 통합 시, 올바른 인코딩을 지정하지 않으면 **문자 손실(Character Loss)**이나 **잘못된 문자 표시(Character Corruption)**가 발생할 수 있으므로, 인코딩에 대한 정확한 이해가 필수적입니다.
13.1.1 File.ReadAllText()¶
File.ReadAllText() 메서드는 .NET의 파일 읽기 API 중 가장 높은 추상화 수준을 제공하는 정적 메서드로, 파일의 전체 내용을 단일 문자열(Single String)로 원자적(Atomically)으로 읽어옵니다. 이 메서드는 내부적으로 파일을 열고, 모든 바이트를 읽고, 지정된 인코딩으로 디코딩하고, 파일을 닫는 일련의 과정을 캡슐화하여, 단 한 줄의 코드로 완전한 파일 읽기 작업을 수행할 수 있게 합니다.
내부 동작 메커니즘과 메모리 할당 전략:
File.ReadAllText()의 내부 구현은 효율성과 안전성을 모두 고려하여 설계되었습니다. 메서드는 먼저 FileInfo를 통해 파일의 크기를 조회한 후, 정확한 크기의 문자 배열(Character Array)을 미리 할당합니다. 이는 사전 할당(Pre-Allocation) 전략으로, 동적 배열 확장(Dynamic Array Expansion)의 오버헤드를 회피하고 메모리 단편화(Memory Fragmentation)를 최소화합니다. 파일 크기가 2GB(Int32.MaxValue) 이하인 경우, 단일 연속 메모리 블록에 전체 내용을 로드할 수 있습니다.
읽기 작업은 FileStream을 사용하며, 운영체제의 파일 캐시를 최대한 활용하기 위해 순차 접근(Sequential Access) 힌트를 제공합니다. 데이터는 내부 버퍼를 통해 효율적으로 읽히며, Encoding.GetString() 메서드를 통해 바이트 배열이 문자열로 변환됩니다. 이 과정에서 UTF-8의 경우 SIMD(Single Instruction Multiple Data) 명령어를 활용한 벡터화 디코딩(Vectorized Decoding)이 수행되어 성능이 극대화됩니다.
중요한 점은 File.ReadAllText()가 비버퍼링(Unbuffered) 방식이 아니라는 것입니다. 내부적으로는 여전히 버퍼를 사용하지만, 이는 메서드 호출자에게 투명(Transparent)하게 처리됩니다. 또한 파일은 읽기가 완료되면 자동으로 닫히며, 예외가 발생하더라도 finally 블록을 통해 리소스가 안전하게 해제됩니다. 이는 RAII(Resource Acquisition Is Initialization) 패턴의 C# 구현으로, 리소스 누수(Resource Leak)를 방지합니다.
성능 특성과 적용 시나리오:
File.ReadAllText()의 시간 복잡도는 O(n)으로, 여기서 n은 파일의 바이트 크기입니다. 공간 복잡도도 O(n)으로, 파일 크기와 동일한 메모리를 사용합니다. 이는 작은 파일(일반적으로 수 MB 이하)에 대해서는 이상적이지만, 대용량 파일의 경우 메모리 부족(Out of Memory) 예외를 발생시킬 수 있습니다. .NET의 힙(Heap)은 단일 객체 크기를 2GB로 제한하므로, 이를 초과하는 파일은 이 메서드로 읽을 수 없습니다.
또 다른 고려사항은 가비지 컬렉션(Garbage Collection) 압력입니다. 큰 문자열은 LOH(Large Object Heap, 85,000바이트 이상의 객체가 할당되는 특수한 힙 영역)에 할당되며, LOH는 일반 힙과 달리 압축(Compaction)되지 않아 메모리 단편화를 야기할 수 있습니다. 빈번한 대용량 파일 읽기는 Gen 2 가비지 컬렉션을 유발하여 애플리케이션 일시 정지(Application Pause)를 초래할 수 있습니다.
따라서 File.ReadAllText()는 다음 시나리오에 가장 적합합니다:
- 설정 파일(Configuration Files): JSON, XML, INI, YAML 등 일반적으로 수 KB ~ 수백 KB 크기
- 작은 데이터 파일: 캐시 데이터, 세션 정보, 임시 데이터 등
- 전체 내용 분석: 정규 표현식 매칭, 전체 텍스트 검색 등 파일 전체를 메모리에 로드해야 하는 경우
- 원자적 읽기가 필요한 경우: 파일 내용의 일관성(Consistency)이 중요한 경우
기본 사용법:
파일이 존재하는지 확인:
파일을 읽기 전에 파일이 존재하는지 확인하는 것이 좋은 습관입니다. File.Exists() 메서드를 사용하여 파일 존재 여부를 체크할 수 있습니다.
string filePath = "data.txt";
if (File.Exists(filePath))
{
string content = File.ReadAllText(filePath);
Console.WriteLine($"파일 내용:\n{content}");
}
else
{
Console.WriteLine("파일을 찾을 수 없습니다.");
}
인코딩 지정:
기본적으로 UTF-8 인코딩을 사용하지만, 다른 인코딩이 필요한 경우 명시적으로 지정할 수 있습니다.
using System.Text;
// EUC-KR 인코딩으로 읽기 (한국어 레거시 파일)
string content = File.ReadAllText("legacy.txt", Encoding.GetEncoding("euc-kr"));
Console.WriteLine(content);
실용 예제 - 설정 파일 읽기:
string configPath = "config.txt";
if (File.Exists(configPath))
{
string config = File.ReadAllText(configPath);
Console.WriteLine("=== 설정 정보 ===");
Console.WriteLine(config);
}
else
{
Console.WriteLine("설정 파일이 없습니다. 기본 설정을 사용합니다.");
}
장점과 한계:
- 장점: 코드가 매우 간단하고 직관적입니다.
- 한계: 파일 전체를 메모리에 로드하므로, 큰 파일(수백 MB 이상)은 메모리 부족을 초래할 수 있습니다.
13.1.2 File.ReadAllLines()¶
File.ReadAllLines() 메서드는 파일을 **줄 단위(Line-Oriented)**로 분할하여 문자열 배열로 반환하는 고수준 편의 메서드입니다. 이 메서드는 텍스트 파일의 구조적 특성, 즉 줄바꿈 문자(Line Separator)로 구분된 레코드의 시퀀스라는 개념을 직접 지원하여, CSV 파일, 로그 파일, 설정 파일 등 줄 기반 데이터 포맷을 처리하는 애플리케이션에 이상적입니다.
줄바꿈 처리의 플랫폼 간 복잡성:
텍스트 파일에서 "줄(Line)"의 개념은 겉보기보다 복잡합니다. 역사적으로 서로 다른 운영체제는 서로 다른 줄바꿈 규약(Line Ending Convention)을 채택했습니다. Unix와 Linux는 LF(Line Feed, \n, ASCII 0x0A)를 사용하고, macOS 9 이전은 CR(Carriage Return, \r, ASCII 0x0D)을 사용했으며, Windows와 DOS는 CRLF(\r\n)의 조합을 사용합니다. 현대 macOS(OS X 이후)는 Unix 스타일의 LF를 채택했습니다.
File.ReadAllLines()는 이러한 플랫폼 간 차이를 투명하게 처리합니다. 내부적으로 StreamReader.ReadLine() 메서드를 사용하여 CR, LF, CRLF 세 가지 모두를 줄 구분자로 인식하고, 반환된 문자열에서는 이들 문자를 제거합니다. 이는 플랫폼 독립적(Platform-Agnostic) 코드 작성을 가능하게 하는 중요한 추상화입니다.
메모리 관리와 배열 할당 전략:
File.ReadAllLines()는 파일의 모든 줄을 메모리의 문자열 배열로 로드합니다. 내부 구현은 먼저 파일을 한 번 스캔하여 줄 수를 계산하거나(일부 구현), 동적 리스트(List<string>)를 사용하여 줄을 수집한 후 배열로 변환합니다(현재 .NET 구현). 후자의 접근 방식은 파일을 두 번 읽을 필요가 없지만, 리스트의 내부 배열이 여러 번 재할당될 수 있어 메모리 단편화를 야기할 수 있습니다.
각 줄은 독립적인 문자열 객체로 할당되므로, 천 줄짜리 파일은 최소 천 개의 힙 할당을 발생시킵니다. 줄 수가 많고 각 줄이 짧은 파일의 경우, 배열 자체와 각 문자열의 오버헤드(객체 헤더, 길이 필드 등)가 실제 텍스트 데이터보다 더 많은 메모리를 소비할 수 있습니다. 이는 File.ReadAllText()에 비해 메모리 효율성이 낮을 수 있음을 의미하지만, 줄 단위 인덱싱과 처리의 편의성을 제공합니다.
기본 사용법:
string[] lines = File.ReadAllLines("data.txt");
Console.WriteLine($"총 {lines.Length}줄");
foreach (string line in lines)
{
Console.WriteLine(line);
}
실용 예제 - CSV 파일 읽기:
string csvPath = "students.csv";
if (File.Exists(csvPath))
{
string[] lines = File.ReadAllLines(csvPath);
// 첫 줄은 헤더로 처리
Console.WriteLine("=== 학생 목록 ===");
for (int i = 1; i < lines.Length; i++)
{
string[] fields = lines[i].Split(',');
string name = fields[0];
string age = fields[1];
string grade = fields[2];
Console.WriteLine($"{name} ({age}세, {grade}학년)");
}
}
줄 번호와 함께 출력:
string[] lines = File.ReadAllLines("source.cs");
for (int i = 0; i < lines.Length; i++)
{
Console.WriteLine($"{i + 1,4}: {lines[i]}");
}
빈 줄 제거:
string[] lines = File.ReadAllLines("data.txt");
// 빈 줄이 아닌 것만 필터링
string[] nonEmptyLines = lines
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToArray();
Console.WriteLine($"유효한 줄: {nonEmptyLines.Length}개");
13.1.3 StreamReader 사용¶
StreamReader는 .NET의 스트림 기반 I/O 아키텍처의 핵심 클래스로, 텍스트 파일을 증분적(Incremental) 으로, 지연 평가(Lazy Evaluation) 방식으로 읽을 수 있게 합니다. File.ReadAllText()와 File.ReadAllLines()가 파일 전체를 한 번에 메모리에 로드하는 즉시 로딩(Eager Loading) 전략을 사용하는 반면, StreamReader는 필요한 만큼만 읽는 스트리밍(Streaming) 패러다임을 구현하여, 대용량 파일이나 무한 스트림(예: 네트워크 소켓, 파이프)을 효율적으로 처리할 수 있습니다.
스트리밍 I/O의 이론적 토대:
스트리밍은 데이터를 전체 집합이 아닌 일련의 작은 청크(Chunk)로 처리하는 프로그래밍 패러다임입니다. 이는 1970년대 Unix의 파이프(Pipe) 메커니즘에서 유래한 개념으로, Doug McIlroy가 제안한 "작은 도구를 파이프로 연결(Chain Small Tools)"하는 Unix 철학의 핵심입니다. 스트리밍의 주요 장점은:
- 상수 메모리 사용(Constant Memory Usage): 파일 크기에 관계없이 고정된 버퍼 크기만 사용하므로, 기가바이트 파일도 수 MB 메모리로 처리 가능
- 조기 종료(Early Termination): 특정 조건을 만족하는 데이터를 찾으면 나머지를 읽지 않고 종료 가능
- 파이프라인 처리(Pipeline Processing): 읽기-변환-쓰기를 동시에 수행하여 전체 지연 시간(Latency) 감소
- 실시간 처리(Real-Time Processing): 데이터가 생성되는 즉시 처리 가능 (로그 tail, 네트워크 스트림 등)
StreamReader의 내부 아키텍처:
StreamReader는 여러 계층의 추상화로 구성된 복잡한 시스템입니다. 최하위 계층은 FileStream으로, 이는 운영체제의 파일 핸들을 래핑하고 바이트 단위 읽기를 제공합니다. StreamReader는 이 바이트 스트림 위에 **인코딩 계층(Encoding Layer)**과 **버퍼링 계층(Buffering Layer)**을 추가합니다.
내부적으로 StreamReader는 기본 4KB 크기의 바이트 버퍼와 문자 버퍼를 유지합니다. ReadLine() 호출 시, 먼저 문자 버퍼에서 줄바꿈 문자를 검색합니다. 없으면 바이트 버퍼에서 더 많은 바이트를 읽어 디코딩하고, 이를 반복하여 완전한 줄을 구성합니다. 이 과정에서 UTF-8 디코더는 멀티바이트 문자의 경계를 정확히 추적하여, 문자가 버퍼 경계에서 분할되는 경우에도 올바르게 처리합니다.
리소스 관리와 Dispose 패턴:
StreamReader는 관리되지 않는 리소스(Unmanaged Resource), 즉 운영체제 파일 핸들을 캡슐화하므로, 명시적 해제가 필수입니다. .NET의 IDisposable 패턴**을 구현하여, Dispose() 메서드 호출 시 내부 버퍼를 플러시하고 파일 핸들을 닫습니다. using 문은 C#의 **RAII(Resource Acquisition Is Initialization) 구현으로, 예외 발생 시에도 finally 블록에서 Dispose()가 호출되도록 보장하여 리소스 누수를 방지합니다.
C# 8.0의 **using 선언(Using Declaration)**은 더욱 간결한 구문을 제공합니다. using var reader = new StreamReader(...)로 선언하면, 변수가 선언된 스코프를 벗어날 때 자동으로 Dispose()가 호출됩니다. 이는 중첩된 using 블록의 인덴테이션 증가를 방지하고 코드 가독성을 향상시킵니다.
기본 사용법 - using 문:
StreamReader는 IDisposable 인터페이스를 구현하므로, using 문을 사용하여 자동으로 리소스를 해제해야 합니다.
using (StreamReader reader = new StreamReader("large.txt"))
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
}
C# 8.0 이후의 간결한 using 선언:
using StreamReader reader = new StreamReader("data.txt");
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
// 메서드 종료 시 자동으로 Dispose 호출
파일 전체를 한 번에 읽기:
using StreamReader reader = new StreamReader("file.txt");
string content = reader.ReadToEnd();
Console.WriteLine(content);
실용 예제 - 로그 파일 분석:
string logPath = "application.log";
int errorCount = 0;
int warningCount = 0;
if (File.Exists(logPath))
{
using StreamReader reader = new StreamReader(logPath);
string line;
while ((line = reader.ReadLine()) != null)
{
if (line.Contains("[ERROR]"))
{
errorCount++;
Console.WriteLine($"에러: {line}");
}
else if (line.Contains("[WARNING]"))
{
warningCount++;
}
}
Console.WriteLine($"\n총 에러: {errorCount}개");
Console.WriteLine($"총 경고: {warningCount}개");
}
조건에 맞는 줄만 읽기:
using StreamReader reader = new StreamReader("data.txt");
string line;
int lineNumber = 0;
while ((line = reader.ReadLine()) != null)
{
lineNumber++;
// 특정 키워드가 포함된 줄만 출력
if (line.Contains("중요"))
{
Console.WriteLine($"줄 {lineNumber}: {line}");
}
}
StreamReader의 장점:
- 메모리 효율: 파일을 한 번에 로드하지 않으므로 큰 파일도 처리 가능
- 유연성: 줄 단위, 문자 단위, 블록 단위로 다양하게 읽을 수 있음
- 제어 가능: 읽기 위치를 제어하고 조건부로 읽기를 중단할 수 있음
13.2 텍스트 파일 쓰기¶
파일 쓰기는 프로그램의 휘발성 상태(Volatile State)를 비휘발성 저장 장치에 영속화(Persist)하는 작업으로, 현대 애플리케이션의 필수 기능입니다. 메모리상의 데이터 구조를 텍스트 형식으로 직렬화(Serialize)하여 파일 시스템에 저장함으로써, 프로그램 재시작 후에도 상태를 복원하거나, 다른 시스템과 데이터를 교환하거나, 감사 추적(Audit Trail)을 위한 로그를 기록할 수 있습니다.
파일 쓰기의 원자성과 내구성 문제:
파일 쓰기는 읽기보다 훨씬 복잡한 컴퓨터 과학적 문제를 내포합니다. 주요 관심사는 원자성(Atomicity), 일관성(Consistency), **내구성(Durability)**입니다. 원자성은 쓰기 작업이 완전히 성공하거나 완전히 실패해야 함을 의미합니다(부분적 쓰기로 인한 손상 방지). 일관성은 파일 시스템 메타데이터와 실제 데이터가 동기화되어야 함을 의미합니다. 내구성은 쓰기 완료 후 시스템 장애가 발생해도 데이터가 보존되어야 함을 의미합니다.
운영체제는 일반적으로 버퍼 캐시(Buffer Cache)**를 사용하여 파일 쓰기를 최적화합니다. 애플리케이션이 데이터를 쓰면, 먼저 커널 메모리의 페이지 캐시에 저장되고, 나중에 **백그라운드 플러싱(Background Flushing) 프로세스(Linux의 pdflush, Windows의 Lazy Writer)가 디스크에 실제 기록합니다. 이는 성능을 극대화하지만, 플러싱 전에 전원 장애나 시스템 크래시가 발생하면 데이터가 손실될 수 있습니다.
.NET의 파일 쓰기 API는 이러한 저수준 복잡성을 추상화하지만, fsync/FlushFileBuffers 같은 명시적 동기화 메커니즘은 제공하지 않아, 완전한 내구성 보장이 필요한 경우(예: 금융 트랜잭션 로그) 추가 조치가 필요할 수 있습니다. 일반적인 해결책은 write-ahead logging(WAL) 이나 atomic file replacement 패턴(임시 파일에 쓰고 원자적으로 rename)을 사용하는 것입니다.
덮어쓰기(Overwrite) vs 추가(Append) 모드:
파일 쓰기에는 두 가지 기본 모드가 있습니다. **덮어쓰기 모드**는 파일이 존재하면 기존 내용을 완전히 삭제하고 새 내용으로 교체합니다. 이는 **멱등성(Idempotency)**을 제공하여, 같은 작업을 여러 번 수행해도 결과가 동일합니다. 설정 파일 저장이나 데이터 내보내기에 적합합니다.
추가 모드**는 파일 끝(EOF, End-Of-File)에 새 데이터를 덧붙입니다. 이는 로그 파일, 트랜잭션 저널, 증분 백업 등에 필수적입니다. 추가 모드는 여러 프로세스나 스레드가 동시에 같은 파일에 쓸 때 특별한 주의가 필요합니다. 적절한 **파일 잠금(File Locking) 메커니즘 없이는 데이터 손상이나 경쟁 조건(Race Condition)이 발생할 수 있습니다.
디렉터리 경로와 예외 처리:
파일 쓰기 전에 디렉터리의 존재를 확인하고 필요시 생성하는 것이 중요합니다. FileNotFoundException이 아니라 DirectoryNotFoundException이 발생할 수 있으며, 이는 코드의 견고성(Robustness)을 해칠 수 있습니다. Directory.CreateDirectory()는 **멱등적**으로 동작하여, 디렉터리가 이미 존재해도 예외를 던지지 않으므로 안전하게 호출할 수 있습니다.
13.2.1 File.WriteAllText()¶
File.WriteAllText() 메서드는 문자열 전체를 파일에 원자적으로 쓰는 최상위 추상화로, 파일 열기, 인코딩, 쓰기, 닫기의 전체 과정을 단일 호출로 캡슐화합니다. 이 메서드는 **덮어쓰기 의미론(Overwrite Semantics)**을 가지며, 파일이 존재하면 기존 내용을 삭제하고, 없으면 새로 생성합니다.
내부 동작과 안전성 보장:
메서드 내부에서는 FileMode.Create 플래그로 FileStream을 열어, 파일이 존재하면 길이를 0으로 자르고(Truncate), 없으면 생성합니다. 그 다음 StreamWriter를 통해 문자열을 인코딩(기본 UTF-8)하여 바이트로 변환하고, 이를 스트림에 씁니다. 마지막으로 버퍼를 플러시하고 파일을 닫습니다.
중요한 점은 쓰기가 **단일 트랜잭션이 아니**라는 것입니다. 쓰기 중간에 예외나 시스템 장애가 발생하면 파일이 부분적으로 쓰인 상태로 남을 수 있습니다. 이는 애플리케이션 논리에서 처리해야 하며, 일반적인 패턴은 임시 파일에 쓴 후 File.Move()로 원자적으로 교체하는 것입니다.
기본 사용법:
string content = "안녕하세요, C# 파일 입출력입니다.";
File.WriteAllText("output.txt", content);
Console.WriteLine("파일이 저장되었습니다.");
여러 줄 쓰기:
실용 예제 - 사용자 설정 저장:
string username = "홍길동";
string theme = "다크 모드";
int fontSize = 14;
string config = $@"사용자명: {username}
테마: {theme}
폰트 크기: {fontSize}";
File.WriteAllText("config.txt", config);
Console.WriteLine("설정이 저장되었습니다.");
기존 파일 덮어쓰기:
인코딩 지정:
using System.Text;
string content = "한글 내용입니다.";
// UTF-8 (BOM 없음) 인코딩으로 저장
File.WriteAllText("output.txt", content, new UTF8Encoding(false));
13.2.2 File.WriteAllLines()¶
File.WriteAllLines() 메서드는 문자열 컬렉션을 줄 단위 텍스트 파일로 변환하는 고수준 API로, 각 문자열 요소를 별도의 줄로 쓰고 플랫폼에 적합한 줄바꿈 문자를 자동으로 삽입합니다. 이는 CSV 생성, 로그 파일 작성, 리포트 생성 등 줄 기반 데이터 구조를 파일로 출력하는 일반적인 패턴을 직접 지원합니다.
줄바꿈 문자의 플랫폼 적응:
File.WriteAllLines()는 Environment.NewLine 속성을 사용하여 현재 운영체제에 적합한 줄바꿈 시퀀스를 삽입합니다. Windows에서는 CRLF(\r\n), Unix/Linux/macOS에서는 LF(\n)가 사용됩니다. 이는 생성된 파일이 각 플랫폼의 텍스트 도구(vi, notepad, less 등)에서 올바르게 표시되도록 보장합니다.
흥미로운 점은 이 메서드가 순수 출력(Write-Only) 작업임에도 불구하고 플랫폼 특정 동작을 한다는 것입니다. 이는 크로스 플랫폼 호환성과 플랫폼 네이티브 규약 준수 사이의 **트레이드오프**를 보여줍니다. 만약 특정 줄바꿈 형식이 필요하다면(예: Git 리포지토리에서 LF로 통일), StreamWriter를 사용하여 명시적으로 \n을 쓰는 것이 낫습니다.
성능 고려사항과 메모리 패턴:
File.WriteAllLines()는 IEnumerable<string>을 받으므로, LINQ 쿼리나 제너레이터(yield return)를 직접 전달할 수 있습니다. 이는 **지연 평가(Lazy Evaluation)**를 허용하여, 모든 줄을 먼저 메모리에 구성하지 않고도 파일을 생성할 수 있음을 의미합니다. 예를 들어, 백만 개의 로그 항목을 생성하는 제너레이터를 전달하면, 한 번에 하나씩 평가되고 파일에 쓰여집니다.
하지만 내부 구현은 여전히 버퍼링을 사용하므로, 각 줄이 즉시 디스크에 플러시되지는 않습니다. 실시간 로깅이나 진행 상황 추적이 필요한 경우, StreamWriter의 AutoFlush 속성을 활성화하거나 명시적으로 Flush()를 호출해야 합니다.
13.2.3 StreamWriter 사용¶
StreamWriter는 .NET의 텍스트 쓰기를 위한 핵심 클래스로, 문자열을 인코딩된 바이트 스트림으로 변환하여 파일에 쓰는 완전한 제어를 제공합니다. File.WriteAllText()와 File.WriteAllLines()가 내부적으로 사용하는 저수준 API이기도 하며, 버퍼링 전략(Buffering Strategy), 플러싱 정책(Flushing Policy), 추가 모드(Append Mode) 등을 세밀하게 제어할 수 있습니다.
버퍼링 메커니즘의 심층 분석:
StreamWriter는 기본적으로 1KB 크기의 내부 문자 버퍼를 유지합니다. Write() 또는 WriteLine() 호출 시, 데이터는 먼저 이 버퍼에 축적되고, 버퍼가 가득 차거나 명시적 Flush() 호출 시에만 인코딩되어 하위 Stream(일반적으로 FileStream)에 전달됩니다. FileStream 자체도 운영체제 버퍼 캐시를 사용하므로, 데이터가 실제 디스크에 기록되기까지 여러 단계의 버퍼링이 있습니다.
이러한 다층 버퍼링은 **쓰기 증폭(Write Amplification)**을 크게 줄입니다. 각 WriteLine() 호출이 개별 시스템 콜과 디스크 I/O를 유발한다면, 짧은 줄을 만 번 쓰는 것은 극도로 느릴 것입니다. 버퍼링을 통해 여러 줄을 모아 한 번의 큰 쓰기로 처리하여, 시스템 콜 오버헤드와 디스크 탐색 시간(HDD의 경우)을 최소화합니다.
AutoFlush vs 명시적 Flush:
StreamWriter의 AutoFlush 속성을 true로 설정하면, 각 Write() 또는 WriteLine() 후 자동으로 버퍼를 플러시합니다. 이는 실시간성이 중요한 경우(예: 충돌 시 로그 손실 최소화)에 유용하지만, 성능을 크게 저하시킵니다. 일반적으로 AutoFlush는 비활성화하고, 중요한 이벤트 후에만 명시적으로 Flush()를 호출하는 것이 균형 잡힌 접근입니다.
파일 추가 모드의 동시성 고려사항:
StreamWriter를 추가 모드(append: true)로 열면, 파일 포인터가 파일 끝으로 설정됩니다. 하지만 이것만으로는 다중 프로세스 환경에서 안전한 추가를 보장하지 못합니다. Unix 시스템의 O_APPEND 플래그처럼, .NET의 FileShare.ReadWrite 모드와 결합하여 사용해도, Windows와 Linux의 파일 잠금 의미론이 다르기 때문에 경쟁 조건이 발생할 수 있습니다.
안전한 다중 작성자 로깅을 위해서는 로그 로테이션(Log Rotation), 프로세스별 로그 파일, 또는 전용 로깅 프레임워크(Serilog, NLog)를 사용하는 것이 권장됩니다. 이들은 파일 잠금, 비동기 쓰기, 백그라운드 플러싱 등을 통해 성능과 안전성을 최적화합니다.
기본 사용법 - 새 파일 생성:
using (StreamWriter writer = new StreamWriter("output.txt"))
{
writer.WriteLine("첫 번째 줄");
writer.WriteLine("두 번째 줄");
writer.WriteLine("세 번째 줄");
}
Console.WriteLine("파일이 저장되었습니다.");
C# 8.0 이후의 간결한 using 선언:
using StreamWriter writer = new StreamWriter("data.txt");
writer.WriteLine("Hello, World!");
writer.WriteLine("C# 파일 쓰기");
// 메서드 종료 시 자동으로 Dispose 호출
파일에 내용 추가 (Append):
두 번째 매개변수를 true로 설정하면 기존 파일 끝에 내용을 추가합니다.
using (StreamWriter writer = new StreamWriter("log.txt", append: true))
{
writer.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 새 로그 항목");
}
Console.WriteLine("로그가 추가되었습니다.");
실용 예제 - 로그 파일 작성:
void WriteLog(string message, string level = "INFO")
{
string logPath = "application.log";
using StreamWriter writer = new StreamWriter(logPath, append: true);
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
writer.WriteLine($"[{timestamp}] [{level}] {message}");
}
// 로그 작성
WriteLog("애플리케이션 시작");
WriteLog("데이터베이스 연결 성공");
WriteLog("파일 처리 완료");
WriteLog("메모리 부족", "WARNING");
WriteLog("치명적 오류 발생", "ERROR");
실용 예제 - 보고서 생성:
string reportPath = "report.txt";
using StreamWriter writer = new StreamWriter(reportPath);
writer.WriteLine("=== 월간 보고서 ===");
writer.WriteLine($"생성 날짜: {DateTime.Now:yyyy년 MM월 dd일}");
writer.WriteLine();
writer.WriteLine("주요 지표:");
writer.WriteLine($" - 사용자 수: 1,234명");
writer.WriteLine($" - 매출액: ₩5,678,000");
writer.WriteLine($" - 성장률: 12.5%");
writer.WriteLine();
writer.WriteLine("=== 보고서 끝 ===");
Console.WriteLine("보고서가 생성되었습니다.");
대용량 데이터 쓰기:
using StreamWriter writer = new StreamWriter("large.txt");
// 반복문으로 대량의 데이터 쓰기
for (int i = 1; i <= 100000; i++)
{
writer.WriteLine($"줄 번호: {i}");
}
Console.WriteLine("대용량 파일이 생성되었습니다.");
Write vs WriteLine:
using StreamWriter writer = new StreamWriter("test.txt");
writer.Write("줄바꿈 없음 ");
writer.Write("계속 같은 줄 ");
writer.WriteLine("이제 줄바꿈");
writer.WriteLine("새로운 줄");
// 결과:
// 줄바꿈 없음 계속 같은 줄 이제 줄바꿈
// 새로운 줄
13.3 파일과 디렉터리 관리¶
파일 내용의 읽기와 쓰기를 넘어서, 현대 애플리케이션은 파일 시스템 자체를 **메타데이터 수준(Metadata Level)**에서 조작해야 합니다. 파일 복사, 이동, 삭제, 이름 변경, 디렉터리 구조 탐색, 경로 정규화 등의 작업은 설치 프로그램, 백업 도구, 개발 도구, 미디어 관리자 등 다양한 애플리케이션의 핵심 기능입니다.
.NET의 System.IO 네임스페이스는 이러한 파일 시스템 관리를 위한 세 가지 주요 클래스를 제공합니다. File 클래스는 파일 자체에 대한 작업(복사, 이동, 삭제 등), Directory 클래스는 디렉터리 계층 관리, Path 클래스는 경로 문자열의 파싱과 조합을 담당합니다. 이들은 모두 **정적 메서드(Static Methods)**만 제공하는 유틸리티 클래스로, 상태를 유지하지 않고 순수 함수(Pure Functions)처럼 동작합니다.
파일 시스템 메타데이터의 POSIX 모델:
현대 파일 시스템의 개념적 모델은 대부분 Unix의 POSIX 표준에서 유래했습니다. 파일은 단순한 바이트 시퀀스가 아니라, **inode(index node)**라는 메타데이터 구조와 연결된 추상적 개체입니다. inode는 파일 크기, 타입, 권한, 소유자, 타임스탬프(생성 시간, 수정 시간, 접근 시간), 그리고 데이터 블록 위치를 포함합니다. 파일명은 실제로 inode에 저장되지 않고, 디렉터리 엔트리(directory entry)가 이름을 inode 번호에 매핑합니다.
이러한 아키텍처는 **하드 링크(Hard Link)**와 **심볼릭 링크(Symbolic Link)**를 가능하게 합니다. 하드 링크는 같은 inode를 가리키는 여러 디렉터리 엔트리로, 파일을 "이동"하는 것은 실제로 디렉터리 엔트리를 재배치하는 것이며, 데이터 복사는 발생하지 않습니다. Windows의 NTFS는 유사한 개념인 MFT(Master File Table) 엔트리를 사용하지만, .NET API는 이러한 플랫폼 차이를 추상화합니다.
원자성과 트랜잭션 보장:
파일 시스템 작업의 원자성은 보장되지 않는 경우가 많습니다. File.Copy()는 대상 파일을 먼저 생성하고 데이터를 복사하므로, 중간에 실패하면 부분적으로 복사된 파일이 남을 수 있습니다. File.Move()는 같은 파일 시스템 내에서는 원자적(inode 재배치만 필요)이지만, 다른 파일 시스템 간에는 복사 후 삭제로 구현되어 원자성을 잃습니다.
안전한 파일 갱신을 위한 일반적인 패턴은 **write-replace 패턴**입니다: 새 데이터를 임시 파일에 쓰고, 성공 시 File.Replace()를 사용하여 원자적으로 기존 파일을 교체하며, 백업도 생성합니다. 이는 설정 파일 갱신이나 데이터베이스 스냅샷 작성에 권장되는 방법입니다.
13.3.1 File 클래스¶
File 클래스는 개별 파일에 대한 메타데이터 작업을 캡슐화하는 정적 유틸리티로, 파일의 생명주기 관리(생성, 복사, 이동, 삭제)와 속성 조회를 담당합니다. 이 클래스는 **편의성(Convenience)**을 위해 설계되어, 한 줄의 코드로 복잡한 파일 작업을 수행할 수 있습니다.
File.Exists()의 TOCTOU 문제:
File.Exists()는 파일의 존재 여부를 확인하는 가장 직관적인 방법이지만, TOCTOU(Time-Of-Check-Time-Of-Use) 경쟁 조건에 취약합니다. 파일 존재를 확인한 후 실제 파일을 열기 전에, 다른 프로세스가 파일을 삭제하거나 수정할 수 있습니다. 따라서 Exists() 확인 후 파일 작업을 하는 것보다, 직접 파일을 열고 FileNotFoundException을 처리하는 것이 더 견고한 패턴입니다.
File.Copy()의 내부 최적화:
File.Copy()는 운영체제의 네이티브 파일 복사 API를 사용하여 최적화됩니다. Windows에서는 CopyFileEx() API를 호출하여, 파일 시스템 드라이버가 효율적인 복사 전략(예: Copy-on-Write, 리플렉션 복사)을 선택할 수 있게 합니다. Linux에서는 sendfile() 시스템 콜을 사용하여 커널 공간에서 직접 복사하므로, 사용자 공간 버퍼를 거치지 않아 매우 빠릅니다.
File.Move()의 원자성 특성:
File.Move()의 동작은 원본과 대상의 파일 시스템에 따라 다릅니다. 같은 파일 시스템 내에서는 메타데이터만 갱신하는 원자적 작업이지만(rename 시스템 콜), 다른 파일 시스템 간에는 복사 후 삭제로 구현되어 원자성을 잃습니다. 또한 대상 파일이 이미 존재하면 기본적으로 예외가 발생합니다.
13.3.2 Directory 클래스¶
Directory 클래스는 디렉터리 계층 구조의 생성, 탐색, 조작을 담당하는 정적 유틸리티로, 파일 시스템의 트리 구조(Tree Structure)를 프로그래밍 방식으로 관리할 수 있게 합니다. 디렉터리는 Unix 철학에서 "파일의 특수한 타입"으로, 다른 파일의 이름과 inode 번호 매핑을 저장하는 메타데이터 컨테이너입니다.
Directory.CreateDirectory()의 멱등성:
Directory.CreateDirectory()는 멱등적(Idempotent) 메서드로, 디렉터리가 이미 존재해도 예외를 던지지 않고 성공적으로 반환합니다. 또한 부모 디렉터리가 없으면 자동으로 생성하는 **재귀적 생성(Recursive Creation)**을 수행합니다. 이는 mkdir -p Unix 명령과 유사하며, 복잡한 디렉터리 구조를 안전하게 초기화할 수 있게 합니다.
Directory.GetFiles()의 검색 패턴:
Directory.GetFiles()는 와일드카드 패턴을 사용한 파일 필터링을 지원합니다. *는 임의의 문자 시퀀스, ?는 단일 문자를 매칭합니다. 하지만 정규 표현식만큼 강력하지 않으므로, 복잡한 필터링은 LINQ의 Where() 절과 결합하는 것이 일반적입니다. 또한 SearchOption.AllDirectories를 사용하면 재귀적 검색을 수행하지만, 심볼릭 링크 순환을 감지하지 못해 무한 루프에 빠질 수 있으므로 주의가 필요합니다.
파일 존재 확인:
string filePath = "data.txt";
if (File.Exists(filePath))
{
Console.WriteLine("파일이 존재합니다.");
}
else
{
Console.WriteLine("파일이 없습니다.");
}
파일 복사:
string source = "original.txt";
string destination = "backup.txt";
if (File.Exists(source))
{
File.Copy(source, destination, overwrite: true);
Console.WriteLine("파일이 복사되었습니다.");
}
파일 이동 (이름 변경):
string oldPath = "old_name.txt";
string newPath = "new_name.txt";
if (File.Exists(oldPath))
{
File.Move(oldPath, newPath);
Console.WriteLine("파일이 이동되었습니다.");
}
파일 삭제:
string filePath = "temp.txt";
if (File.Exists(filePath))
{
File.Delete(filePath);
Console.WriteLine("파일이 삭제되었습니다.");
}
파일 정보 가져오기:
string filePath = "data.txt";
if (File.Exists(filePath))
{
FileInfo info = new FileInfo(filePath);
Console.WriteLine($"파일명: {info.Name}");
Console.WriteLine($"전체 경로: {info.FullName}");
Console.WriteLine($"크기: {info.Length} 바이트");
Console.WriteLine($"생성 날짜: {info.CreationTime}");
Console.WriteLine($"마지막 수정: {info.LastWriteTime}");
}
파일 속성 확인:
string filePath = "data.txt";
if (File.Exists(filePath))
{
FileAttributes attrs = File.GetAttributes(filePath);
bool isReadOnly = (attrs & FileAttributes.ReadOnly) == FileAttributes.ReadOnly;
bool isHidden = (attrs & FileAttributes.Hidden) == FileAttributes.Hidden;
Console.WriteLine($"읽기 전용: {isReadOnly}");
Console.WriteLine($"숨김: {isHidden}");
}
13.3.2 Directory 클래스¶
Directory 클래스는 디렉터리에 대한 작업을 수행하는 정적 메서드를 제공합니다.
디렉터리 존재 확인:
string dirPath = "data";
if (Directory.Exists(dirPath))
{
Console.WriteLine("디렉터리가 존재합니다.");
}
else
{
Console.WriteLine("디렉터리가 없습니다.");
}
디렉터리 생성:
string dirPath = "output";
if (!Directory.Exists(dirPath))
{
Directory.CreateDirectory(dirPath);
Console.WriteLine("디렉터리가 생성되었습니다.");
}
디렉터리 삭제:
string dirPath = "temp";
if (Directory.Exists(dirPath))
{
// recursive: true는 하위 파일/디렉터리도 모두 삭제
Directory.Delete(dirPath, recursive: true);
Console.WriteLine("디렉터리가 삭제되었습니다.");
}
디렉터리 내 파일 목록:
string dirPath = "."; // 현재 디렉터리
string[] files = Directory.GetFiles(dirPath);
Console.WriteLine("=== 파일 목록 ===");
foreach (string file in files)
{
Console.WriteLine(Path.GetFileName(file));
}
특정 패턴의 파일 검색:
// .txt 파일만 검색
string[] textFiles = Directory.GetFiles(".", "*.txt");
Console.WriteLine("=== 텍스트 파일 ===");
foreach (string file in textFiles)
{
Console.WriteLine(Path.GetFileName(file));
}
하위 디렉터리 목록:
string dirPath = ".";
string[] subdirs = Directory.GetDirectories(dirPath);
Console.WriteLine("=== 하위 디렉터리 ===");
foreach (string dir in subdirs)
{
Console.WriteLine(Path.GetFileName(dir));
}
재귀적으로 모든 파일 검색:
string dirPath = ".";
// 모든 하위 디렉터리를 포함하여 검색
string[] allFiles = Directory.GetFiles(dirPath, "*.*", SearchOption.AllDirectories);
Console.WriteLine($"총 {allFiles.Length}개의 파일");
실용 예제 - 백업 디렉터리 생성:
string backupDir = $"backup_{DateTime.Now:yyyyMMdd_HHmmss}";
Directory.CreateDirectory(backupDir);
// 현재 디렉터리의 .txt 파일을 백업 디렉터리로 복사
string[] files = Directory.GetFiles(".", "*.txt");
foreach (string file in files)
{
string fileName = Path.GetFileName(file);
string destPath = Path.Combine(backupDir, fileName);
File.Copy(file, destPath);
}
Console.WriteLine($"{files.Length}개의 파일이 백업되었습니다.");
13.3.3 Path 클래스¶
Path 클래스는 파일 경로 문자열의 순수 텍스트 조작(Pure String Manipulation)**을 담당하는 정적 유틸리티로, 실제 파일 시스템 접근 없이 경로의 파싱(Parsing), 정규화(Normalization), 조합(Composition)을 수행합니다. 이는 **관심사의 분리(Separation of Concerns) 원칙을 구현하여, 경로 논리와 I/O 작업을 명확히 분리합니다.
경로 표현의 플랫폼 간 이식성 문제:
파일 경로는 운영체제마다 다른 규약을 따릅니다. Windows는 백슬래시(\) 를 디렉터리 구분자로 사용하고, 드라이브 문자(C:, D:) 개념이 있으며, UNC 경로(\\server\share)를 지원합니다. Unix/Linux/macOS는 슬래시(/) 를 구분자로 사용하고, 단일 루트(/)에서 시작하는 계층 구조를 가지며, 대소문자를 구분합니다.
Path 클래스는 이러한 차이를 추상화하는 핵심 메커니즘입니다. Path.DirectorySeparatorChar와 Path.AltDirectorySeparatorChar 상수는 현재 플랫폼의 구분자를 제공하며, Path.Combine()은 올바른 구분자를 자동으로 삽입합니다. 흥미롭게도, Windows의 .NET은 슬래시(/)도 유효한 경로 구분자로 인식하여, 대부분의 Windows API가 혼용을 허용합니다.
Path.Combine()의 지능형 병합 알고리즘:
Path.Combine()은 단순한 문자열 연결이 아니라, **의미론적 경로 결합(Semantic Path Joining)**을 수행합니다. 두 번째 인수가 절대 경로(예: /home/user 또는 /tmp)로 시작하면, 첫 번째 인수를 무시하고 두 번째 인수만 반환합니다. 이는 사용자가 절대 경로를 입력할 때 기본 디렉터리를 오버라이드하는 직관적인 동작을 제공합니다.
또한 중복 구분자를 제거하고, 상대 경로 요소(.와 ..)를 정규화하지는 않지만, 구분자의 일관성은 보장합니다. Path.GetFullPath()는 한 단계 더 나아가 상대 경로를 절대 경로로 변환하고, .와 ..를 해석하여 **정규 경로(Canonical Path)**를 생성합니다.
경로 파싱의 Edge Cases와 보안 고려사항:
경로 파싱은 예상보다 복잡한 보안 함의를 가집니다. 경로 순회 공격(Path Traversal Attack) 은 ../../../etc/passwd 같은 입력으로 제한된 디렉터리 밖의 파일에 접근하려는 시도입니다. Path.GetFullPath()와 Path.GetPathRoot()를 사용하여 입력 경로를 정규화하고, 허용된 기본 디렉터리 내에 있는지 검증하는 것이 필수적입니다.
또한 Windows의 레거시 8.3 형식 이름(Short File Names) 은 PROGRA~1처럼 공백을 제거하고 이름을 단축하여, 같은 파일을 여러 경로로 참조할 수 있게 합니다. Path.GetFullPath()는 이를 해석하지 않으므로, 보안 검증을 우회할 수 있습니다. .NET 5+의 Path.GetRealPath()는 심볼릭 링크와 8.3 이름을 모두 해석하여 **진짜 경로(Real Path)**를 반환합니다.
경로 결합:
string dir = "data";
string file = "config.txt";
// 플랫폼에 맞는 경로 구분자를 자동으로 사용
string fullPath = Path.Combine(dir, file);
Console.WriteLine(fullPath); // data/config.txt (Linux) 또는 data\config.txt (Windows)
파일명 추출:
string path = "/home/user/documents/file.txt";
string fileName = Path.GetFileName(path);
Console.WriteLine(fileName); // file.txt
확장자 추출:
확장자 제거:
string path = "photo.jpg";
string nameWithoutExt = Path.GetFileNameWithoutExtension(path);
Console.WriteLine(nameWithoutExt); // photo
디렉터리 경로 추출:
string path = "/home/user/documents/file.txt";
string dir = Path.GetDirectoryName(path);
Console.WriteLine(dir); // /home/user/documents
확장자 변경:
string path = "document.txt";
string newPath = Path.ChangeExtension(path, ".pdf");
Console.WriteLine(newPath); // document.pdf
임시 파일 경로 생성:
// 시스템의 임시 디렉터리 경로
string tempDir = Path.GetTempPath();
Console.WriteLine($"임시 디렉터리: {tempDir}");
// 고유한 임시 파일명 생성
string tempFile = Path.GetTempFileName();
Console.WriteLine($"임시 파일: {tempFile}");
경로 유효성 검사:
string path = "data/file.txt";
// 경로가 절대 경로인지 확인
bool isAbsolute = Path.IsPathRooted(path);
Console.WriteLine($"절대 경로: {isAbsolute}"); // False
실용 예제 - 안전한 파일 경로 생성:
string CreateSafeFilePath(string directory, string fileName)
{
// 디렉터리가 없으면 생성
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// 경로 결합
string filePath = Path.Combine(directory, fileName);
// 파일이 이미 존재하면 번호 추가
if (File.Exists(filePath))
{
string nameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
string ext = Path.GetExtension(fileName);
int counter = 1;
do
{
fileName = $"{nameWithoutExt}_{counter}{ext}";
filePath = Path.Combine(directory, fileName);
counter++;
} while (File.Exists(filePath));
}
return filePath;
}
// 사용
string safePath = CreateSafeFilePath("output", "data.txt");
File.WriteAllText(safePath, "테스트 데이터");
Console.WriteLine($"파일 저장됨: {safePath}");
13.4 JSON 데이터 처리¶
JSON(JavaScript Object Notation)은 현대 소프트웨어 생태계에서 사실상의 표준(De Facto Standard) 데이터 교환 형식으로 자리잡은 경량 텍스트 기반 직렬화 포맷입니다. 웹 API, 설정 파일, 데이터 저장, 마이크로서비스 통신, NoSQL 데이터베이스 등 거의 모든 현대 애플리케이션이 JSON을 사용하여 구조화된 데이터를 표현하고 전송합니다.
JSON의 역사적 진화와 설계 철학:
JSON은 2001년 더글러스 크록포드(Douglas Crockford)가 JavaScript의 객체 리터럴 표기법에서 착안하여 제안한 데이터 형식입니다. 당시 지배적이었던 XML의 장황함(Verbosity)과 복잡성에 대한 반발로 등장했으며, **단순성(Simplicity)**과 **가독성(Readability)**을 핵심 설계 원칙으로 삼았습니다. JSON은 단 6가지 데이터 타입(객체, 배열, 문자열, 숫자, 불린, null)만 지원하며, 이러한 제약이 오히려 보편성과 상호운용성을 높였습니다.
2006년 RFC 4627로 표준화되었고, 2013년 ECMA-404와 RFC 7159로 갱신되었으며, 2017년 RFC 8259가 현재 표준입니다. JSON의 성공은 JavaScript 생태계의 폭발적 성장, RESTful API의 대중화, 그리고 **XML 대비 20-30% 작은 페이로드 크기**에서 비롯되었습니다. 현재 거의 모든 프로그래밍 언어가 JSON 파서와 직렬화 라이브러리를 표준 또는 준표준 라이브러리로 제공합니다.
직렬화(Serialization)의 이론적 토대:
직렬화는 메모리 내 객체 그래프(Object Graph)를 선형 바이트 시퀀스로 변환하는 과정으로, 반대 과정인 역직렬화(Deserialization)와 함께 **객체 영속성(Object Persistence)**과 **분산 시스템(Distributed Systems)**의 핵심 메커니즘입니다. 직렬화의 주요 도전 과제는:
- 타입 보존(Type Preservation): 객체의 타입 정보를 어떻게 인코딩할 것인가? JSON은 타입 정보를 직접 포함하지 않아, 역직렬화 시 타입을 명시적으로 지정해야 합니다.
- 참조 순환(Circular References): A가 B를 참조하고 B가 A를 참조하는 경우 무한 루프 방지
- 버전 호환성(Version Compatibility): 클래스 정의가 변경되어도 이전 직렬화 데이터를 읽을 수 있어야 함
- 성능(Performance): 리플렉션(Reflection) 사용 최소화와 메모리 할당 최적화
JSON은 자기 기술적(Self-Describing) 포맷으로, 각 필드명이 데이터와 함께 저장되어 버전 호환성을 자연스럽게 제공합니다. 새 필드는 무시되고, 없는 필드는 기본값으로 채워집니다. 하지만 타입 정보 부재로 인해, 역직렬화는 컴파일 타임에 타입을 알아야 하는 **정적 타입 언어(Statically-Typed Languages)**에서 도전적입니다.
13.4.1 System.Text.Json 소개¶
.NET Core 3.0에서 도입된 System.Text.Json은 Microsoft가 개발한 고성능 JSON 라이브러리로, 이전의 사실상 표준이었던 Newtonsoft.Json(Json.NET)을 대체하기 위해 설계되었습니다. 주요 설계 목표는 성능(Performance), 보안(Security), 그리고 **.NET 플랫폼 통합(Platform Integration)**이었습니다.
고성능 아키텍처와 제로 할당 파싱:
System.Text.Json은 .NET의 최신 성능 기능을 적극 활용합니다. Span<T>와 Memory<T>를 사용하여 제로 복사(Zero-Copy) 파싱을 구현하고, ArrayPool<T>로 버퍼를 재사용하여 가비지 컬렉션 압력을 최소화합니다. Utf8JsonReader는 UTF-8 바이트를 직접 파싱하여 문자열로의 변환(UTF-8 → UTF-16 → String)을 지연시키며, SIMD 명령어를 활용한 벡터화 검색으로 토큰 스캐닝을 가속화합니다.
벤치마크에 따르면, System.Text.Json은 Newtonsoft.Json 대비 직렬화 2배, 역직렬화 1.5배 빠르며, 메모리 할당은 **60-70% 감소**합니다. 이는 고처리량(High-Throughput) 웹 서비스나 대용량 데이터 처리 파이프라인에서 중요한 차이를 만듭니다.
보안 우선 설계(Security-First Design):
System.Text.Json은 기본적으로 안전한 설정을 사용합니다. 깊은 중첩 제한(기본 64레벨)으로 스택 오버플로우(Stack Overflow) 공격을 방지하고, 최대 문자열 길이 제한으로 메모리 고갈(Memory Exhaustion) 공격을 차단합니다. 주석(Comments)과 후행 쉼표(Trailing Commas) 같은 비표준 JSON을 기본적으로 거부하여, **파서 불일치 공격(Parser Differential Attack)**을 예방합니다.
타입 재정의나 임의 코드 실행을 허용하는 TypeNameHandling 같은 위험한 기능을 제공하지 않아, 원격 코드 실행(Remote Code Execution, RCE) 취약점 표면을 제거했습니다. Newtonsoft.Json의 과거 RCE 취약점들은 주로 이 기능에서 비롯되었습니다.
.NET 생태계 통합:
System.Text.Json은 C# 9.0의 레코드(Records), nullable 참조 타입(Nullable Reference Types), 그리고 init-only 속성과 원활하게 작동합니다. 또한 ASP.NET Core의 기본 JSON 직렬화 엔진으로 통합되어, 추가 의존성 없이 웹 API를 구축할 수 있습니다.
13.4.2 JSON 직렬화¶
직렬화(Serialization)는 런타임 객체를 JSON 텍스트로 변환하는 과정으로, 객체의 **공개 속성(Public Properties)**을 JSON 객체의 키-값 쌍으로 매핑합니다. .NET의 타입 시스템과 JSON의 타입 시스템 간 변환은 다음과 같이 이루어집니다:
- C# 클래스/구조체 → JSON 객체
{} - C# 배열/리스트 → JSON 배열
[] - C# 문자열 → JSON 문자열
"..." - C# 정수/실수 → JSON 숫자
- C# bool → JSON true/false
- C# null → JSON null
리플렉션 기반 vs 소스 제너레이터:
기본적으로 JsonSerializer는 **리플렉션(Reflection)**을 사용하여 객체의 속성을 런타임에 검사합니다. 이는 유연하지만, 리플렉션 호출의 오버헤드와 AOT(Ahead-Of-Time) 컴파일 비호환성 문제가 있습니다. .NET 6+의 **소스 제너레이터(Source Generators)**는 컴파일 시점에 직렬화 코드를 생성하여, 런타임 리플렉션을 제거하고 성능을 10-30% 향상시키며, Native AOT를 지원합니다.
명명 정책(Naming Policy)과 규약:
C#은 PascalCase 명명 규약을 사용하지만, JavaScript/JSON은 camelCase를 선호합니다. JsonSerializerOptions.PropertyNamingPolicy를 JsonNamingPolicy.CamelCase로 설정하면, PersonName 속성이 personName JSON 키로 변환됩니다. 이는 프런트엔드 JavaScript 코드와의 상호운용성을 높입니다.
기본 클래스 정의:
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string Email { get; set; }
}
객체를 JSON 문자열로 변환:
using System.Text.Json;
Person person = new Person
{
Name = "홍길동",
Age = 30,
Email = "hong@example.com"
};
// 직렬화
string json = JsonSerializer.Serialize(person);
Console.WriteLine(json);
// 출력: {"Name":"홍길동","Age":30,"Email":"hong@example.com"}
가독성 있는 JSON 생성 (들여쓰기):
var options = new JsonSerializerOptions
{
WriteIndented = true // 들여쓰기 활성화
};
string json = JsonSerializer.Serialize(person, options);
Console.WriteLine(json);
// 출력:
// {
// "Name": "홍길동",
// "Age": 30,
// "Email": "hong@example.com"
// }
컬렉션 직렬화:
List<Person> people = new List<Person>
{
new Person { Name = "홍길동", Age = 30, Email = "hong@example.com" },
new Person { Name = "김철수", Age = 25, Email = "kim@example.com" },
new Person { Name = "이영희", Age = 28, Email = "lee@example.com" }
};
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(people, options);
Console.WriteLine(json);
JSON 파일로 저장:
Person person = new Person
{
Name = "홍길동",
Age = 30,
Email = "hong@example.com"
};
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(person, options);
File.WriteAllText("person.json", json);
Console.WriteLine("JSON 파일이 저장되었습니다.");
실용 예제 - 게임 설정 저장:
class GameSettings
{
public string PlayerName { get; set; }
public int Level { get; set; }
public int Score { get; set; }
public bool SoundEnabled { get; set; }
}
void SaveGameSettings(GameSettings settings)
{
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(settings, options);
File.WriteAllText("game_settings.json", json);
Console.WriteLine("게임 설정이 저장되었습니다.");
}
// 사용
GameSettings settings = new GameSettings
{
PlayerName = "플레이어1",
Level = 5,
Score = 1250,
SoundEnabled = true
};
SaveGameSettings(settings);
13.4.3 JSON 역직렬화¶
역직렬화(Deserialization)는 JSON 텍스트를 런타임 객체로 재구성하는 과정으로, 직렬화의 역변환입니다. 이는 문자열 파싱(String Parsing), 타입 매핑(Type Mapping), 그리고 **객체 구성(Object Construction)**의 세 단계로 구성됩니다.
역직렬화의 타입 안전성과 검증:
정적 타입 언어인 C#에서 역직렬화는 컴파일 타임에 타입을 알아야 합니다. JsonSerializer.Deserialize<Person>(json)에서 Person은 **타입 매개변수(Type Parameter)**로, 컴파일러에게 어떤 타입의 객체를 생성할지 알려줍니다. 이는 동적 타입 언어(Python, JavaScript)와 대조되며, **타입 안전성(Type Safety)**을 제공하지만 유연성은 떨어집니다.
JSON 스키마와 C# 클래스 정의가 불일치하는 경우의 동작은 JsonSerializerOptions로 제어할 수 있습니다. 기본적으로:
- JSON에 있지만 클래스에 없는 속성은 **무시(Ignore)**됩니다
- 클래스에 있지만 JSON에 없는 속성은 **기본값(Default Value)**으로 초기화됩니다
- 타입이 호환되지 않으면 JsonException이 발생합니다
JsonSerializerOptions.PropertyNameCaseInsensitive = true를 설정하면, 대소문자 불일치를 허용하여 상호운용성을 높일 수 있습니다.
불변 객체와 생성자 기반 역직렬화:
C# 9.0의 레코드(Records)와 init-only 속성은 불변 객체(Immutable Objects) 를 선호하는 현대 프로그래밍 패러다임을 반영합니다. System.Text.Json은 **매개변수화된 생성자(Parameterized Constructor)**를 통한 역직렬화를 지원하여, 속성이 읽기 전용인 경우에도 JSON에서 객체를 구성할 수 있습니다.
생성자 매개변수 이름은 JSON 키와 매칭되며(대소문자 불일치 허용 가능), 모든 매개변수가 JSON에 제공되지 않으면 역직렬화가 실패합니다. 이는 **완전성 검증(Completeness Validation)**을 자동으로 제공하는 강력한 메커니즘입니다.
null 처리와 Nullable Reference Types:
C# 8.0의 nullable reference types는 컴파일 타임에 null 안전성을 강화하지만, 런타임에는 여전히 null이 가능합니다. JSON에서 명시적 null 값이나 누락된 속성은 C# 객체의 null로 변환됩니다. Nullable reference types를 활성화한 경우, Person?처럼 nullable 타입을 사용하여 역직렬화 결과가 null일 수 있음을 명시해야 합니다.
예외 처리와 견고한 역직렬화:
JSON 파싱 중 발생할 수 있는 예외는:
- JsonException: JSON 구문 오류, 타입 불일치, 중첩 깊이 초과 등
- NotSupportedException: 지원되지 않는 타입(예: 순환 참조, 읽기 전용 컬렉션)
- ArgumentNullException: null JSON 문자열
프로덕션 코드에서는 역직렬화를 항상 try-catch로 감싸고, 적절한 폴백(Fallback) 전략을 구현해야 합니다. 일반적인 패턴은 역직렬화 실패 시 기본 설정 객체를 반환하거나, 로그를 남기고 사용자에게 알리는 것입니다.
JSON 문자열을 객체로 변환:
string json = @"{
""Name"": ""홍길동"",
""Age"": 30,
""Email"": ""hong@example.com""
}";
// 역직렬화
Person person = JsonSerializer.Deserialize<Person>(json);
Console.WriteLine($"이름: {person.Name}");
Console.WriteLine($"나이: {person.Age}");
Console.WriteLine($"이메일: {person.Email}");
JSON 파일에서 읽기:
if (File.Exists("person.json"))
{
string json = File.ReadAllText("person.json");
Person person = JsonSerializer.Deserialize<Person>(json);
Console.WriteLine($"이름: {person.Name}");
Console.WriteLine($"나이: {person.Age}");
}
컬렉션 역직렬화:
string json = @"[
{""Name"":""홍길동"",""Age"":30,""Email"":""hong@example.com""},
{""Name"":""김철수"",""Age"":25,""Email"":""kim@example.com""}
]";
List<Person> people = JsonSerializer.Deserialize<List<Person>>(json);
foreach (var person in people)
{
Console.WriteLine($"{person.Name} ({person.Age}세)");
}
실용 예제 - 게임 설정 불러오기:
GameSettings LoadGameSettings()
{
string settingsPath = "game_settings.json";
if (File.Exists(settingsPath))
{
string json = File.ReadAllText(settingsPath);
GameSettings settings = JsonSerializer.Deserialize<GameSettings>(json);
Console.WriteLine("게임 설정을 불러왔습니다.");
return settings;
}
else
{
Console.WriteLine("설정 파일이 없습니다. 기본 설정을 사용합니다.");
return new GameSettings
{
PlayerName = "플레이어",
Level = 1,
Score = 0,
SoundEnabled = true
};
}
}
// 사용
GameSettings settings = LoadGameSettings();
Console.WriteLine($"플레이어: {settings.PlayerName}");
Console.WriteLine($"레벨: {settings.Level}");
Console.WriteLine($"점수: {settings.Score}");
null 안전성 처리:
string json = @"{""Name"":""홍길동""}"; // Age와 Email 누락
Person? person = JsonSerializer.Deserialize<Person>(json);
if (person != null)
{
Console.WriteLine($"이름: {person.Name}");
Console.WriteLine($"나이: {person.Age}"); // 0 (기본값)
Console.WriteLine($"이메일: {person.Email ?? "(없음)"}");
}
예외 처리:
string json = @"{잘못된 JSON}";
try
{
Person person = JsonSerializer.Deserialize<Person>(json);
}
catch (JsonException ex)
{
Console.WriteLine($"JSON 파싱 오류: {ex.Message}");
}
실용 예제 - 완전한 설정 관리 시스템:
class AppConfig
{
public string AppName { get; set; }
public string Version { get; set; }
public Dictionary<string, string> Settings { get; set; }
}
class ConfigManager
{
private const string ConfigPath = "app_config.json";
public static void SaveConfig(AppConfig config)
{
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(config, options);
File.WriteAllText(ConfigPath, json);
Console.WriteLine("설정이 저장되었습니다.");
}
public static AppConfig LoadConfig()
{
if (File.Exists(ConfigPath))
{
string json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<AppConfig>(json);
}
else
{
// 기본 설정 생성
return new AppConfig
{
AppName = "MyApp",
Version = "1.0.0",
Settings = new Dictionary<string, string>
{
{ "Theme", "Light" },
{ "Language", "ko-KR" }
}
};
}
}
}
// 사용 예제
AppConfig config = ConfigManager.LoadConfig();
Console.WriteLine($"앱: {config.AppName} v{config.Version}");
// 설정 수정
config.Settings["Theme"] = "Dark";
ConfigManager.SaveConfig(config);
13장 정리 및 요약¶
이 장에서는 C#의 파일 입출력 시스템을 이론적 토대부터 실무 적용까지 종합적으로 탐구했습니다. 파일 I/O는 단순한 데이터 저장을 넘어서, 운영체제 커널, 파일 시스템 드라이버, 메모리 관리, 인코딩 변환, 그리고 직렬화 메커니즘이 복잡하게 상호작용하는 다층적 시스템임을 확인했습니다.
핵심 개념 정리¶
1. 텍스트 파일 읽기의 계층적 추상화
-
File.ReadAllText(): 최상위 편의 API로, 파일 전체를 단일 문자열로 원자적 로딩. O(n) 시간/공간 복잡도, 작은 파일(<수 MB)에 이상적. 내부적으로 사전 할당 전략과 UTF-8 벡터화 디코딩 사용. -
File.ReadAllLines(): 줄 기반 추상화로, 플랫폼 독립적 줄바꿈 처리(CR/LF/CRLF). 각 줄이 독립 문자열 객체로 할당되어 메모리 오버헤드 증가. CSV, 로그, 설정 파일 등 줄 단위 레코드에 적합. -
StreamReader: 스트림 기반 증분 읽기로, 상수 메모리 사용(O(1) 공간). 4KB 내부 버퍼와 지연 평가를 통해 대용량 파일 처리. RAII 패턴(using 문)으로 리소스 누수 방지.
2. 텍스트 파일 쓰기의 원자성과 내구성
-
File.WriteAllText(): 덮어쓰기 의미론, 단일 호출로 파일 생성/갱신. 원자성 미보장으로, 중요 데이터는 write-replace 패턴(임시 파일 → File.Replace) 권장. -
File.WriteAllLines(): IEnumerable지연 평가 지원, 플랫폼 적응 줄바꿈(Environment.NewLine). LINQ 제너레이터와 결합하여 메모리 효율적 파일 생성. -
StreamWriter: 버퍼링 제어(AutoFlush vs 명시적 Flush), 추가 모드(append) 지원. 다층 버퍼링(StreamWriter → FileStream → OS 캐시)으로 쓰기 증폭 최소화. 다중 작성자 동시성 문제는 로그 프레임워크로 해결 권장.
3. 파일 시스템 메타데이터 조작의 POSIX 모델
-
File클래스: 파일 생명주기 관리.File.Exists()의 TOCTOU 문제 인식,File.Copy()의 네이티브 최적화(sendfile, CopyFileEx),File.Move()의 조건부 원자성(같은 FS 내). -
Directory클래스: 계층 구조 관리.CreateDirectory()의 멱등성과 재귀 생성,GetFiles()의 와일드카드 패턴과 심볼릭 링크 순환 주의. -
Path클래스: 순수 문자열 조작. 플랫폼 독립적 경로 결합(Path.Combine()), 경로 순회 공격 방지(GetFullPath 정규화), 8.3 이름과 심볼릭 링크 해석(GetRealPath).
4. JSON 직렬화의 현대적 구현
-
System.Text.Json: 고성능(Newtonsoft.Json 대비 2배 빠른 직렬화), 제로 복사 파싱(Span/Memory ), 보안 우선 설계(깊이 제한, RCE 공격 표면 제거). -
직렬화: 리플렉션 기반 vs 소스 제너레이터(AOT 호환, 10-30% 성능 향상), 명명 정책(PascalCase ↔ camelCase), 타입 시스템 매핑(C# 타입 → JSON 타입).
-
역직렬화: 컴파일 타임 타입 지정(타입 안전성), 생성자 기반 불변 객체 지원, nullable reference types 통합, 스키마 불일치 처리(무시/기본값/예외).
소프트웨어 공학적 통찰¶
성능 최적화 전략:
-
읽기: 파일 크기 < 10MB →
ReadAllText, 줄 단위 처리 필요 →ReadAllLines, 대용량 →StreamReader. 버퍼 크기 조정은 일반적으로 기본값(4KB)이 최적. -
쓰기: 빈번한 작은 쓰기 → 버퍼링 활성화(AutoFlush=false), 대용량 데이터 →
StreamWriter수동 버퍼 제어, 안전한 갱신 → write-replace 패턴. -
JSON: AOT 환경이나 핫 패스(Hot Path) → 소스 제너레이터, 대용량 JSON →
Utf8JsonReader스트리밍, 부분 역직렬화 → JsonDocument DOM.
보안 및 견고성:
- 경로 검증:
Path.GetFullPath()정규화 후 허용 기본 디렉터리 검사, Windows 8.3 이름 고려. - 원자성 보장: 중요 데이터는
File.Replace()사용, 트랜잭션 로그나 WAL 패턴 검토. - 예외 처리: 모든 I/O는 try-catch 필수, 구체적 예외(FileNotFoundException, UnauthorizedAccessException 등) 처리.
- 인코딩 명시: 레거시 시스템 통합 시 인코딩 명시적 지정, BOM 처리 고려.
크로스 플랫폼 고려사항:
- 경로 구분자: 하드코딩 금지,
Path.Combine()또는Path.DirectorySeparatorChar사용. - 줄바꿈: 플랫폼 독립적 처리를 위해
Environment.NewLine또는StreamReader.ReadLine()활용. - 대소문자: Windows는 대소문자 불구분, Unix는 구분. 가능하면 소문자로 통일.
- 파일 잠금: Windows와 Unix의 파일 잠금 의미론 차이 인식, 크로스 플랫폼 잠금은 별도 라이브러리 고려.
다음 단계와 고급 주제¶
14장 "LINQ 기초"에서는 파일에서 읽은 데이터를 쿼리하고 변환하는 선언적 방법을 배웁니다. File.ReadLines() (지연 평가)와 LINQ를 결합하면, 대용량 파일을 메모리 효율적으로 필터링하고 프로젝션할 수 있습니다.
더 나아가기:
- 비동기 I/O:
FileStream.ReadAsync()와async/await로 UI 블로킹 방지 (17장 참조) - 메모리 매핑 파일:
MemoryMappedFile로 초대용량 파일 고성능 처리 - 감시 및 반응:
FileSystemWatcher로 파일 변경 이벤트 감지 - 압축:
GZipStream,DeflateStream으로 투명한 압축 I/O - 바이너리 직렬화: Protocol Buffers, MessagePack으로 공간 효율적 직렬화
실습 문제¶
문제 1: 할 일 목록 관리¶
할 일을 파일에 저장하고 불러오는 프로그램을 작성하세요.
void SaveTodoList(List<string> todos)
{
File.WriteAllLines("todos.txt", todos);
Console.WriteLine("할 일이 저장되었습니다.");
}
List<string> LoadTodoList()
{
if (File.Exists("todos.txt"))
{
return new List<string>(File.ReadAllLines("todos.txt"));
}
return new List<string>();
}
// 사용
List<string> todos = LoadTodoList();
todos.Add("C# 공부하기");
todos.Add("파일 입출력 연습");
todos.Add("프로젝트 완성");
SaveTodoList(todos);
// 불러와서 출력
List<string> loadedTodos = LoadTodoList();
Console.WriteLine("=== 할 일 목록 ===");
for (int i = 0; i < loadedTodos.Count; i++)
{
Console.WriteLine($"{i + 1}. {loadedTodos[i]}");
}
문제 2: 점수 기록 시스템¶
학생들의 점수를 JSON 파일로 저장하고 불러오는 시스템을 작성하세요.
using System.Text.Json;
class Student
{
public string Name { get; set; }
public int Score { get; set; }
}
void SaveScores(List<Student> students)
{
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(students, options);
File.WriteAllText("scores.json", json);
Console.WriteLine("점수가 저장되었습니다.");
}
List<Student> LoadScores()
{
if (File.Exists("scores.json"))
{
string json = File.ReadAllText("scores.json");
return JsonSerializer.Deserialize<List<Student>>(json);
}
return new List<Student>();
}
// 사용
List<Student> students = new List<Student>
{
new Student { Name = "홍길동", Score = 85 },
new Student { Name = "김철수", Score = 92 },
new Student { Name = "이영희", Score = 78 }
};
SaveScores(students);
// 불러와서 평균 계산
List<Student> loadedStudents = LoadScores();
double average = loadedStudents.Average(s => s.Score);
Console.WriteLine("=== 학생 점수 ===");
foreach (var student in loadedStudents)
{
Console.WriteLine($"{student.Name}: {student.Score}점");
}
Console.WriteLine($"평균: {average:F2}점");
문제 3: 로그 시스템¶
타임스탬프가 포함된 로그를 파일에 추가하는 시스템을 작성하세요.
class Logger
{
private const string LogPath = "app.log";
public static void Log(string message, string level = "INFO")
{
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
string logEntry = $"[{timestamp}] [{level}] {message}";
// 파일에 추가
using StreamWriter writer = new StreamWriter(LogPath, append: true);
writer.WriteLine(logEntry);
// 콘솔에도 출력
Console.WriteLine(logEntry);
}
public static void ShowLogs()
{
if (File.Exists(LogPath))
{
Console.WriteLine("=== 로그 기록 ===");
string[] lines = File.ReadAllLines(LogPath);
foreach (string line in lines)
{
Console.WriteLine(line);
}
}
}
}
// 사용
Logger.Log("프로그램 시작");
Logger.Log("데이터 로딩 완료");
Logger.Log("경고: 메모리 사용량 높음", "WARNING");
Logger.Log("에러 발생: 파일 없음", "ERROR");
Console.WriteLine();
Logger.ShowLogs();
다음 장 예고¶
14장 "LINQ 기초"에서는 데이터 컬렉션을 효율적으로 쿼리하고 변환하는 강력한 기능을 학습합니다: - LINQ란 무엇인가? - Where, Select, OrderBy 등 기본 연산자 - 컬렉션과 파일 데이터를 우아하게 처리하는 방법
파일에서 읽은 데이터를 LINQ로 필터링하고 가공하는 등, 이번 장에서 배운 파일 입출력과 결합하면 더욱 강력한 프로그램을 만들 수 있습니다!