반응형

[PART11.입출력 기본(4/7)] System.IO 파일 입출력 기초 — File 클래스 한 번에 배우기

File.ReadAllText/WriteAllText/Exists/Delete의 동작 원리 / ReadAllLines vs ReadLines의 메모리 차이 (LOH) / UTF-8 BOM 함정과 인코딩 명시 / Unity 모바일에서 persistentDataPath와 StreamingAssets의 결정적 차이


1. 문제 제기 — 텍스트 파일 한 줄 저장이 왜 이렇게 어려운가

게임 세이브 파일 한 줄을 저장하는 가장 단순한 코드는 다음과 같습니다.

C#
File.WriteAllText("save.json", playerData);
string back = File.ReadAllText("save.json");

겉으로 보이는 코드는 두 줄이지만, 그 뒤에는 다음과 같은 결정이 숨어 있습니다.

  • 인코딩 — UTF-8을 쓰는데 BOM(Byte Order Mark)이 붙는다? 안 붙는다? 다른 언어 글자가 깨지는 이유가 여기 있습니다.
  • 메모리File.ReadAllLines는 100MB 로그 파일을 통째로 메모리에 올립니다. 모바일에서 OutOfMemoryException을 만나는 가장 흔한 경로입니다.
  • 파일 핸들File.WriteAllText는 자동 닫지만, FileStream을 직접 열면 using이 없으면 핸들이 누수됩니다.
  • 플랫폼 권한 — Android scoped storage, iOS sandbox에서는 절대 경로를 쓸 수 없습니다.
  • 잠금 — 한 프로세스가 파일을 쓰는 도중 다른 코드가 같은 파일을 읽으려 하면 IOException 폭주.

이 글에서는 System.IO.File 정적 메서드들이 내부적으로 어떻게 동작하는지, 메모리·인코딩·핸들 측면에서 어떤 트레이드오프가 있는지, 그리고 Unity 모바일에서 어떤 경로·방식이 안전한지를 정리합니다.


2. 개념 정의 — File 정적 클래스의 두 얼굴

2.1 한 번에 vs 한 줄씩

File 클래스의 텍스트 읽기 메서드는 크게 두 부류로 나뉩니다.

Eager 로딩 vs Lazy 로딩 — 메모리 사용량 비교
메서드 반환 타입 동작 권장 사용
File.ReadAllText string 파일 전체를 한 번에 메모리 적재 작은 설정 파일·세이브
File.ReadAllLines string[] 파일을 줄로 분리해 한 번에 적재 중간 크기까지
File.ReadAllBytes byte[] 바이너리 전체를 한 번에 적재 작은 이미지·바이너리
File.ReadLines IEnumerable<string> 한 줄씩 yield, 메모리 부담 적음 대용량 로그·CSV
IEnumerable<T> — 지연 평가 컬렉션 (Lazy enumerable) 모든 원소가 메모리에 미리 들어 있는 게 아니라, foreach가 한 원소씩 요청할 때마다 만들어 내는 인터페이스. 처리한 원소는 GC가 회수할 수 있어 메모리 절약이 가능하다.
예시: foreach (var line in File.ReadLines(path)) — 100MB 파일도 한 줄씩만 메모리에 머문다

2.2 자주 쓰는 File 메서드

C#
// 텍스트 읽기/쓰기 (인코딩 명시 가능)
string content = File.ReadAllText("config.json");
File.WriteAllText("config.json", json, Encoding.UTF8);

// 끝에 이어 쓰기
File.AppendAllText("log.txt", $"[{DateTime.Now}] {message}\n");

// 존재 확인 / 삭제 / 복사 / 이동
if (File.Exists(path))   File.Delete(path);
File.Copy("a.txt", "b.txt", overwrite: true);
File.Move("temp.dat", "final.dat");

// 바이너리
byte[] data = File.ReadAllBytes("save.bin");
File.WriteAllBytes("save.bin", data);

이 메서드들은 모두 내부적으로 FileStream을 열고, 읽거나 쓰고, 자동으로 닫는 일을 한 줄로 묶어 준 것입니다. using 블록이 따로 필요 없습니다.

2.3 File vs FileInfo — 정적 메서드 vs 인스턴스

C#
// File — 정적 메서드, 매 호출마다 보안 검사
long size = new FileInfo("save.json").Length;
DateTime when = File.GetCreationTime("save.json");
File.Copy("save.json", "backup.json");

// FileInfo — 인스턴스, 한 번의 보안 검사로 여러 작업
FileInfo fi = new FileInfo("save.json");
long size2 = fi.Length;
DateTime when2 = fi.CreationTime;
fi.CopyTo("backup.json");

같은 파일에 대해 두 개 이상의 정보를 조회해야 하면 FileInfo를 한 번 만들고 재사용합니다. File 정적 메서드는 매 호출마다 경로 검증과 보안 검사를 다시 합니다.


3. 내부 동작 — Eager vs Lazy의 IL 차이

3.1 ReadAllLines와 ReadLines의 IL

C#
// Eager — string[] 통째로
public static int CountLinesEager(string path)
{
    string[] lines = File.ReadAllLines(path);
    return lines.Length;
}

// Lazy — IEnumerable<string>를 foreach
public static int CountLinesLazy(string path)
{
    int count = 0;
    foreach (string line in File.ReadLines(path))
    {
        count++;
    }
    return count;
}
IL
.method public hidebysig static int32 CountLinesEager (string path) cil managed
{
    .maxstack 1
    .locals init (
        [0] string[],                                             // string[] 배열 (LOH 후보)
        [1] int32
    )

    IL_0001: ldarg.0
    IL_0002: call string[] [System.Runtime]System.IO.File::ReadAllLines(string)  // ★ 한 번에 string[] 반환
    IL_0007: stloc.0
    IL_0008: ldloc.0
    IL_0009: ldlen
    IL_000a: conv.i4
    IL_000b: stloc.1

    IL_000e: ldloc.1
    IL_000f: ret
}
IL
.method public hidebysig static int32 CountLinesLazy (string path) cil managed
{
    .maxstack 2
    .locals init (
        [0] int32,                                                // count
        [1] class IEnumerator`1<string>,                          // 열거자
        [2] string,                                               // 현재 line
        [3] int32
    )

    IL_0001: ldc.i4.0
    IL_0002: stloc.0
    IL_0004: ldarg.0
    IL_0005: call class IEnumerable`1<string> File::ReadLines(string)  // ★ IEnumerable<string> 반환 (지연)
    IL_000a: callvirt instance class IEnumerator`1<!0> IEnumerable`1<string>::GetEnumerator()
    IL_000f: stloc.1
    .try
    {
        // loop start
            IL_0012: ldloc.1
            IL_0013: callvirt instance !0 IEnumerator`1<string>::get_Current()  // 한 줄 가져오기
            IL_0018: stloc.2
            IL_001a: ldloc.0
            IL_001b: ldc.i4.1
            IL_001c: add
            IL_001d: stloc.0

            IL_001f: ldloc.1
            IL_0020: callvirt instance bool IEnumerator::MoveNext()             // ★ 다음 줄 요청
            IL_0025: brtrue.s IL_0012
        // end loop
        IL_0027: leave.s IL_0034
    }
    finally
    {
        IL_0029: ldloc.1
        IL_002a: brfalse.s IL_0033
        IL_002c: ldloc.1
        IL_002d: callvirt instance void IDisposable::Dispose()                  // ★ 열거자 자동 해제
        IL_0033: endfinally
    }

    IL_0034: ldloc.0
    IL_0035: stloc.3
    IL_0038: ldloc.3
    IL_0039: ret
}

3.2 IL 분석 포인트

1. ReadAllLinesstring[] 한 번에 반환

IL은 짧고 단순합니다. 그러나 이 한 줄 뒤에서 .NET은 파일 전체 바이트를 디스크에서 읽고, UTF-8 디코더로 문자열을 만들고, 줄바꿈을 기준으로 나눠 string[] 배열에 차곡차곡 쌓습니다. 100MB 파일이면 100MB짜리 메타데이터 + 그만큼의 string 인스턴스가 한 번에 GC heap에 올라갑니다.

2. ReadLinesIEnumerable<string> + MoveNext 루프

컴파일러가 foreach를 펼쳐 GetEnumerator → MoveNext → get_Current 패턴으로 변환하고, .try { } finally { Dispose } 블록을 자동 생성합니다. 핵심은 각 줄이 get_Current 호출 시점에만 메모리에 잡히고, 다음 줄로 넘어가면 GC가 회수할 수 있다는 점입니다.

3. LOH(Large Object Heap, 대형 객체 힙)와 85KB 경계

.NET GC는 85,000바이트 이상의 객체를 별도의 LOH에 올립니다. LOH는 압축(compaction)이 거의 일어나지 않아 한 번 쌓이면 단편화로 메모리를 낭비합니다. ReadAllLines로 만들어진 string[] 배열이 85KB를 넘으면 곧장 LOH 직행입니다. Unity의 Boehm GC도 비슷한 단편화 문제가 있어, 모바일 핫패스에서는 가능한 한 큰 배열 할당을 피합니다.

3.3 using FileStream — 자동 try-finally의 정체

C#
public static void OpenAndWrite(string path, byte[] data)
{
    using FileStream fs = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None);
    fs.Write(data, 0, data.Length);
}
using 선언 — 변수 선언에 붙이는 자동 dispose (C# 8 using declaration) 변수가 선언된 스코프(메서드·블록)가 끝나는 시점에 컴파일러가 자동으로 Dispose()를 호출하는 코드를 삽입한다. 들여쓰기를 한 단계 줄여 준다.
예시: using var fs = File.OpenWrite(path); — 메서드가 끝나는 순간 fs.Dispose() 자동 호출
IL
.method public hidebysig static void OpenAndWrite (string path, uint8[] data) cil managed
{
    .maxstack 4
    .locals init ([0] class FileStream)

    IL_0001: ldarg.0
    IL_0002: ldc.i4.2                                          // FileMode.Create == 2
    IL_0003: ldc.i4.2                                          // FileAccess.Write == 2
    IL_0004: ldc.i4.0                                          // FileShare.None == 0
    IL_0005: newobj instance void FileStream::.ctor(string, FileMode, FileAccess, FileShare)
    IL_000a: stloc.0
    .try
    {
        IL_000b: ldloc.0
        IL_000c: ldarg.1
        IL_000d: ldc.i4.0
        IL_000e: ldarg.1
        IL_000f: ldlen
        IL_0010: conv.i4
        IL_0011: callvirt instance void Stream::Write(uint8[], int32, int32)
        IL_0017: leave.s IL_0024
    }
    finally
    {
        IL_0019: ldloc.0
        IL_001a: brfalse.s IL_0023
        IL_001c: ldloc.0
        IL_001d: callvirt instance void IDisposable::Dispose()  // ★ 자동 핸들 해제
        IL_0023: endfinally
    }
    IL_0024: ret
}

4. using은 컴파일러가 try-finally로 펼친 코드

using FileStream fs = ... 한 줄은 IL에서 .try { 작업 } finally { fs.Dispose() } 블록으로 펼쳐집니다. 예외가 던져져도 핸들은 반드시 해제됩니다. 명시적 using을 빼면 GC가 finalizer를 돌릴 때까지 파일이 잠긴 채로 남아 다른 프로세스가 못 열게 됩니다.

5. FileMode.Create == 2, FileAccess.Write == 2, FileShare.None == 0

열거형이 IL에서 정수 상수로 펼쳐집니다. FileMode.Create는 "있으면 덮어쓰고 없으면 만든다", FileShare.None은 "내가 닫을 때까지 다른 프로세스 접근 차단"을 의미합니다.

3.4 UTF-8 BOM 함정

C#
public static void WriteUtf8NoBom(string path, string content)
{
    var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
    File.WriteAllText(path, content, utf8NoBom);
}
IL
IL_0001: ldc.i4.0                                                    // false
IL_0002: newobj instance void UTF8Encoding::.ctor(bool)              // ★ BOM 없는 UTF-8 객체
IL_0007: stloc.0
IL_0008: ldarg.0
IL_0009: ldarg.1
IL_000a: ldloc.0
IL_000b: call void File::WriteAllText(string, string, Encoding)

6. UTF8Encoding(bool encoderShouldEmitUTF8Identifier)

정적 프로퍼티 Encoding.UTF8BOM 포함 UTF-8을 반환합니다. 다른 시스템(특히 게임 서버 JSON 파서, 외부 빌드 도구)이 BOM을 유효한 데이터로 인식해 파싱이 실패하는 경우가 있습니다. BOM 없는 UTF-8을 강제하려면 new UTF8Encoding(false) 또는 인코딩 인자를 생략(WriteAllText 기본 동작은 BOM 없는 UTF-8)합니다.


4. 실전 적용 — Unity 모바일에서 어디에 무엇을 저장할까

4.1 경로 결정 — persistentDataPath / StreamingAssets / Resources

Unity 파일 경로 3가지 비교
경로 읽기/쓰기 File.* 직접 사용 용도
Application.persistentDataPath 둘 다 유저 세이브, 다운로드 캐시
Application.streamingAssetsPath 읽기 전용 Android는 ❌ (압축 안에 있음) 출시 시 함께 배포되는 데이터
Application.dataPath/Resources/ Resources.Load 에셋 (Asset Bundle 권장)
C#
// ✅ 세이브 데이터는 항상 persistentDataPath
string savePath = Path.Combine(Application.persistentDataPath, "save.json");
File.WriteAllText(savePath, json);

4.2 큰 로그 파일 처리 — Lazy 패턴

C#
// ❌ Before — 100MB 로그 파일이 LOH에 통째로
public static int CountErrors(string path)
{
    string[] lines = File.ReadAllLines(path);
    int n = 0;
    foreach (var l in lines) if (l.Contains("[ERROR]")) n++;
    return n;
}
C#
// ✅ After — 한 줄씩 읽고 버린다
public static int CountErrors(string path)
{
    int n = 0;
    foreach (var l in File.ReadLines(path))
    {
        if (l.Contains("[ERROR]")) n++;
    }
    return n;
}

After는 메모리 사용량이 한 줄 분량(수 KB)에 머물러 모바일에서도 안전합니다. LINQ와 결합도 가능합니다.

C#
int errorCount = File.ReadLines(path)
                     .Count(l => l.Contains("[ERROR]"));

4.3 비동기 — UI 멈춤 방지

C#
// ❌ 스레드 블로킹 — UI가 멈춤
string content = File.ReadAllText(path);

// ✅ async 버전 — 디스크 I/O 동안 스레드 양보
string content = await File.ReadAllTextAsync(path);

Unity 에디터·서버 도구처럼 async/await가 가능한 환경에서는 비동기 버전을 우선 선택합니다. Unity Mono/IL2CPP에서도 .NET Standard 2.1 호환 모드라면 *Async 메서드가 그대로 동작합니다.

4.4 핸들 누수 방지

C#
// ❌ Before — using 없이 FileStream 직접 열기
public static void Save(string path, byte[] data)
{
    var fs = new FileStream(path, FileMode.Create);
    fs.Write(data, 0, data.Length);
    // 예외 발생 시 fs.Dispose()가 호출 안 됨 → 파일 잠김
}
C#
// ✅ After — using 선언으로 자동 해제
public static void Save(string path, byte[] data)
{
    using var fs = new FileStream(path, FileMode.Create);
    fs.Write(data, 0, data.Length);
}

File.WriteAllBytes처럼 정적 메서드로 충분하면 FileStream을 직접 다루지 않는 편이 더 안전합니다. 핸들을 직접 잡는 코드는 using을 빠뜨리는 순간 잠금이 풀리지 않아 다른 프로세스에서 IOException: 다른 프로세스가 파일을 사용 중...이 발생합니다.


5. 함정과 주의사항

5.1 ❌ 인코딩을 명시하지 않고 시스템 Default 사용

C#
// ❌ 사용자 OS 코드 페이지에 따라 한글이 깨질 수 있음
File.WriteAllText("save.json", json, Encoding.Default);
C#
// ✅ UTF-8 명시 (기본 BOM 없음)
File.WriteAllText("save.json", json);   // 인자 생략 = UTF-8 (BOM 없음)

// ✅ BOM 명시 회피
File.WriteAllText("save.json", json, new UTF8Encoding(false));

5.2 ❌ 절대 경로 하드코딩

C#
// ❌ Windows 개발 PC에서만 동작
File.WriteAllText("C:\\Users\\me\\save.json", json);

// ❌ Unity Editor에서는 되지만 모바일에서 권한 오류
File.WriteAllText("/sdcard/save.json", json);
C#
// ✅ 플랫폼이 알려주는 경로 + Path.Combine
string savePath = Path.Combine(Application.persistentDataPath, "save.json");
File.WriteAllText(savePath, json);

5.3 ❌ 큰 파일을 ReadAllBytes로 통째 로드

C#
// ❌ 200MB 비디오를 한 번에 byte[]로 → OutOfMemoryException
byte[] all = File.ReadAllBytes(videoPath);
C#
// ✅ 스트리밍으로 청크 단위 읽기
using var fs = File.OpenRead(videoPath);
byte[] buf = new byte[64 * 1024];
int read;
while ((read = fs.Read(buf, 0, buf.Length)) > 0)
{
    ProcessChunk(buf, read);
}

5.4 ❌ FileShare 미지정으로 잠금 충돌

C#
// ❌ 읽는 도중 다른 프로세스(또는 같은 앱의 다른 코드)가 못 읽음
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
C#
// ✅ 다른 읽기 프로세스 허용
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);

로그 파일을 모니터링하는 도구처럼 여러 프로세스가 동시에 같은 파일을 읽어야 한다면 FileShare.Read를 명시합니다.

5.5 ❌ Unity StreamingAssets를 File.ReadAllText로 접근

C#
// ❌ Android에서 동작 안 함 — StreamingAssets은 압축된 APK 안에 있음
string path = Path.Combine(Application.streamingAssetsPath, "data.json");
string json = File.ReadAllText(path);   // FileNotFoundException
C#
// ✅ UnityWebRequest로 접근
using var req = UnityWebRequest.Get(Path.Combine(Application.streamingAssetsPath, "data.json"));
await req.SendWebRequest();
string json = req.downloadHandler.text;

5.6 ❌ Exists 후 Delete의 TOCTOU 함정

C#
// ❌ Exists 검사와 Delete 사이에 다른 프로세스가 파일을 지우면 예외
if (File.Exists(path))
{
    File.Delete(path);   // 그 사이에 사라지면 IOException
}
C#
// ✅ Delete 자체가 존재하지 않는 파일에 대해 예외 없음 (UnauthorizedAccessException 등 별도 예외 체크)
try
{
    File.Delete(path);   // 파일이 없으면 그냥 아무 일도 안 함
}
catch (UnauthorizedAccessException)
{
    // 권한 문제만 따로 처리
}
TOCTOU (Time-of-Check to Time-of-Use) "확인한 시점"과 "실제 사용한 시점" 사이에 외부 변화가 일어나 의도와 다르게 동작하는 경합 상태. 파일 시스템·권한 코드에서 가장 자주 만난다.

6. C# / .NET 버전별 변화

.NET 버전 변경점
.NET Core 1.0+ ReadAllTextAsync, WriteAllTextAsync 등 비동기 API 도입
.NET Core 2.1+ ReadOnlySpan<char> Path/File 오버로드, 성능 개선
.NET 6 RandomAccess — 멀티 스레드 안전 읽기/쓰기, ReadOnlySpan<byte> 직접 처리
.NET 7 File.ReadLinesAsyncIAsyncEnumerable<string> 반환

.NET 7 — ReadLinesAsync (대용량 + 비동기)

C#
// .NET 7+ — 메모리 + UI 모두 지킨다
await foreach (string line in File.ReadLinesAsync(path))
{
    // 한 줄씩 비동기로 처리, UI 안 멈춤
}
IAsyncEnumerable<T> — 비동기 지연 컬렉션 (Asynchronous enumerable) IEnumerable<T>의 비동기 버전. await foreach 로 순회하며, 각 원소가 준비될 때마다 await 한다. 디스크·네트워크 I/O를 한 줄씩 비동기로 처리할 때 적합.

File.ReadAllText 시리즈 자체는 .NET 1.0부터 거의 변화가 없지만, 주변 인프라(Span, async, IAsyncEnumerable) 가 발전하면서 큰 파일 처리 패턴이 점점 안전해지고 있습니다.


7. 정리 — 이것만 기억하라

  • File.ReadAllText/ReadAllLines/ReadAllBytes는 파일 전체를 메모리에 한 번에 올린다. 85KB 초과면 LOH 직행이라 모바일에서 위험하다.
  • 대용량은 File.ReadLines (IEnumerable) 또는 .NET 7+의 ReadLinesAsync (IAsyncEnumerable) 로 한 줄씩 처리한다.
  • File.WriteAllText 인자 생략 = BOM 없는 UTF-8. 명시하려면 new UTF8Encoding(false). Encoding.UTF8은 BOM 포함이라 외부 파서와 호환 문제가 생기기 쉽다.
  • FileStream을 직접 열면 반드시 using 선언 으로 감싼다. IL이 try-finally + Dispose로 펼쳐져 핸들 누수를 막는다.
  • Unity 모바일은 Application.persistentDataPath 가 표준 저장 경로. Path.Combine으로 안전한 경로를 만든다.
  • StreamingAssets는 Android에서 File.*로 접근 불가. UnityWebRequest로 읽는다.
  • File.ExistsFile.Delete는 TOCTOU 함정. Delete는 없는 파일에 예외를 던지지 않으므로 그냥 호출 + 권한 예외만 처리한다.
  • 비동기 변종(*Async) 은 디스크 대기 동안 스레드를 양보한다. 서버·UI 코드라면 async를 우선 선택한다.
반응형

+ Recent posts