[PART11.입출력 기본(4/7)] System.IO 파일 입출력 기초 — File 클래스 한 번에 배우기
File.ReadAllText/WriteAllText/Exists/Delete의 동작 원리 / ReadAllLines vs ReadLines의 메모리 차이 (LOH) / UTF-8 BOM 함정과 인코딩 명시 / Unity 모바일에서 persistentDataPath와 StreamingAssets의 결정적 차이
목차
1. 문제 제기 — 텍스트 파일 한 줄 저장이 왜 이렇게 어려운가
게임 세이브 파일 한 줄을 저장하는 가장 단순한 코드는 다음과 같습니다.
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 클래스의 텍스트 읽기 메서드는 크게 두 부류로 나뉩니다.

| 메서드 | 반환 타입 | 동작 | 권장 사용 |
|---|---|---|---|
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 메서드
// 텍스트 읽기/쓰기 (인코딩 명시 가능)
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 인스턴스
// 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
// 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;
}
.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
}
.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. ReadAllLines → string[] 한 번에 반환
IL은 짧고 단순합니다. 그러나 이 한 줄 뒤에서 .NET은 파일 전체 바이트를 디스크에서 읽고, UTF-8 디코더로 문자열을 만들고, 줄바꿈을 기준으로 나눠 string[] 배열에 차곡차곡 쌓습니다. 100MB 파일이면 100MB짜리 메타데이터 + 그만큼의 string 인스턴스가 한 번에 GC heap에 올라갑니다.
2. ReadLines → IEnumerable<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의 정체
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() 자동 호출
.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 함정
public static void WriteUtf8NoBom(string path, string content)
{
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
File.WriteAllText(path, content, utf8NoBom);
}
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.UTF8은 BOM 포함 UTF-8을 반환합니다. 다른 시스템(특히 게임 서버 JSON 파서, 외부 빌드 도구)이 BOM을 유효한 데이터로 인식해 파싱이 실패하는 경우가 있습니다. BOM 없는 UTF-8을 강제하려면 new UTF8Encoding(false) 또는 인코딩 인자를 생략(WriteAllText 기본 동작은 BOM 없는 UTF-8)합니다.
4. 실전 적용 — Unity 모바일에서 어디에 무엇을 저장할까
4.1 경로 결정 — persistentDataPath / StreamingAssets / Resources

| 경로 | 읽기/쓰기 | File.* 직접 사용 | 용도 |
|---|---|---|---|
Application.persistentDataPath |
둘 다 | ✅ | 유저 세이브, 다운로드 캐시 |
Application.streamingAssetsPath |
읽기 전용 | Android는 ❌ (압축 안에 있음) | 출시 시 함께 배포되는 데이터 |
Application.dataPath/Resources/ |
Resources.Load만 |
❌ | 에셋 (Asset Bundle 권장) |
// ✅ 세이브 데이터는 항상 persistentDataPath
string savePath = Path.Combine(Application.persistentDataPath, "save.json");
File.WriteAllText(savePath, json);
4.2 큰 로그 파일 처리 — Lazy 패턴
// ❌ 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;
}
// ✅ 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와 결합도 가능합니다.
int errorCount = File.ReadLines(path)
.Count(l => l.Contains("[ERROR]"));
4.3 비동기 — UI 멈춤 방지
// ❌ 스레드 블로킹 — 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 핸들 누수 방지
// ❌ 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()가 호출 안 됨 → 파일 잠김
}
// ✅ 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 사용
// ❌ 사용자 OS 코드 페이지에 따라 한글이 깨질 수 있음
File.WriteAllText("save.json", json, Encoding.Default);
// ✅ UTF-8 명시 (기본 BOM 없음)
File.WriteAllText("save.json", json); // 인자 생략 = UTF-8 (BOM 없음)
// ✅ BOM 명시 회피
File.WriteAllText("save.json", json, new UTF8Encoding(false));
5.2 ❌ 절대 경로 하드코딩
// ❌ Windows 개발 PC에서만 동작
File.WriteAllText("C:\\Users\\me\\save.json", json);
// ❌ Unity Editor에서는 되지만 모바일에서 권한 오류
File.WriteAllText("/sdcard/save.json", json);
// ✅ 플랫폼이 알려주는 경로 + Path.Combine
string savePath = Path.Combine(Application.persistentDataPath, "save.json");
File.WriteAllText(savePath, json);
5.3 ❌ 큰 파일을 ReadAllBytes로 통째 로드
// ❌ 200MB 비디오를 한 번에 byte[]로 → OutOfMemoryException
byte[] all = File.ReadAllBytes(videoPath);
// ✅ 스트리밍으로 청크 단위 읽기
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 미지정으로 잠금 충돌
// ❌ 읽는 도중 다른 프로세스(또는 같은 앱의 다른 코드)가 못 읽음
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
// ✅ 다른 읽기 프로세스 허용
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
로그 파일을 모니터링하는 도구처럼 여러 프로세스가 동시에 같은 파일을 읽어야 한다면 FileShare.Read를 명시합니다.
5.5 ❌ Unity StreamingAssets를 File.ReadAllText로 접근
// ❌ Android에서 동작 안 함 — StreamingAssets은 압축된 APK 안에 있음
string path = Path.Combine(Application.streamingAssetsPath, "data.json");
string json = File.ReadAllText(path); // FileNotFoundException
// ✅ 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 함정
// ❌ Exists 검사와 Delete 사이에 다른 프로세스가 파일을 지우면 예외
if (File.Exists(path))
{
File.Delete(path); // 그 사이에 사라지면 IOException
}
// ✅ 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.ReadLinesAsync — IAsyncEnumerable<string> 반환 |
.NET 7 — ReadLinesAsync (대용량 + 비동기)
// .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.Exists후File.Delete는 TOCTOU 함정.Delete는 없는 파일에 예외를 던지지 않으므로 그냥 호출 + 권한 예외만 처리한다.- 비동기 변종(
*Async) 은 디스크 대기 동안 스레드를 양보한다. 서버·UI 코드라면async를 우선 선택한다.
