파일 입출력
파일을 읽고 씁니다.
대개는 한 줄로 끝납니다
파일을 다루는 방법은 두 가지입니다. 통째로 한 번에 다루는 것과 조금씩 나누어 다루는 것입니다.
// 통째로 — 파일 하나가 문자열 하나가 됩니다 string text = File.ReadAllText(path); File.WriteAllText(path, text); // 조금씩 — 열어 두고 필요한 만큼 읽고 씁니다 using var reader = new StreamReader(path); string? line = reader.ReadLine();
설정 파일이나 목록처럼 크지 않은 것은 앞엣것으로 끝납니다. 뒤엣것은 파일이 아주 커서 통째로 담을 수 없거나, 다 읽기 전에 처리를 시작해야 할 때 필요합니다.
다만 뒤엣것에는 따라오는 것이 하나 있습니다. 열었으면 닫아야 합니다. 닫지 않으면 쓴 내용이 파일에 남지 않고, 다른 곳에서 그 파일을 열 수도 없습니다. 이 단원의 절반이 그 이야기입니다.
쓰고, 덧붙이고, 읽기
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
var path = Path.Combine(Path.GetTempPath(), "장보기.txt"); // 줄 단위로 씁니다. 파일이 없으면 만들고, 있으면 덮어씁니다. File.WriteAllLines(path, ["우유 2개", "달걀 10개"]); // 뒤에 덧붙입니다 File.AppendAllLines(path, ["빵 1개"]); // 한 줄씩 읽습니다 var no = 1; foreach (var line in File.ReadLines(path)) Console.WriteLine($"{no++}. {line}"); // 통째로 읽을 수도 있습니다 Console.WriteLine(); Console.WriteLine(File.ReadAllText(path)); Console.WriteLine($"{Path.GetFileName(path)} · {File.ReadAllLines(path).Length}줄");
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
여는 코드도 닫는 코드도 없습니다.
File 의 메서드들이 열고 읽고 닫는 것까지 안에서 다 하기
때문입니다. 파일이 크지 않다면 이 방법으로 충분합니다.
한 번에 다루는 메서드들
| 메서드 | 하는 일 |
|---|---|
| File.ReadAllText | 파일 전체를 문자열 하나로 읽습니다 |
| File.ReadAllLines | 파일 전체를 문자열 배열로 읽습니다 |
| File.ReadLines | 한 줄씩 읽습니다. 전체를 담아 두지 않습니다 |
| File.WriteAllText | 문자열을 씁니다. 있던 내용은 사라집니다 |
| File.WriteAllLines | 줄 목록을 씁니다. 있던 내용은 사라집니다 |
| File.AppendAllText | 뒤에 덧붙입니다 |
| File.Exists | 있는지 확인합니다 |
| File.Copy · Move · Delete | 복사·이동·삭제합니다 |
WriteAllText 는 덮어씁니다. 있던 내용에
이어 붙이려던 것이면 AppendAllText 입니다. 이름이 비슷해
자주 헷갈리는 자리입니다.
폴더 쪽은 Directory 가 맡습니다.
Directory.CreateDirectory 는 중간 폴더가 없으면
함께 만들고, 이미 있으면 아무 일도 하지 않습니다. 그래서 미리 확인하지
않고 그냥 불러도 됩니다.
경로를 직접 이어 붙이지 마세요
경로 구분자는 운영 체제마다 다릅니다. 직접 적으면 한쪽에서만
동작합니다. Path.Combine 이 알맞은 것을 넣어 줍니다.
Path.Combine("data", "2026", "메모.txt"); // Windows : data\2026\메모.txt // Linux : data/2026/메모.txt // 경로에서 필요한 부분만 꺼냅니다 var path = @"C:\logs\app.2026.txt"; Path.GetFileName(path); // app.2026.txt Path.GetFileNameWithoutExtension(path); // app.2026 Path.GetExtension(path); // .txt Path.GetDirectoryName(path); // C:\logs
줄바꿈도 마찬가지입니다. Windows 는 \r\n, 그 밖은
\n 을 사용합니다.
WriteAllLines 는
Environment.NewLine 을 넣으므로 같은 코드가
플랫폼마다 다른 바이트를 만듭니다. 파일 크기를 세어 비교하는 코드를 적을
때 걸리는 자리입니다.
통째로 읽을 것과 한 줄씩 읽을 것
ReadAllLines 와 ReadLines 는
이름이 한 글자 차이지만 메모리를 사용하는 방식이 다릅니다.
string[] lines = File.ReadAllLines(path); // 배열이라 Length 를 알 수 있고 // 여러 번 탐색할 수 있습니다. // 1GB 파일이면 1GB 를 담습니다.
foreach (var line in File.ReadLines(path)) { } // 한 줄만 메모리에 있습니다. // 1GB 파일도 견딥니다. // 다시 탐색하려면 또 읽습니다.
ReadLines 는 3.3의
LINQ 와 그대로 이어집니다. 거르고 세는 동안에도 파일 전체를 담지
않습니다.
var count = File.ReadLines(path) .Where(line => line.StartsWith("ERROR")) .Count();
열었으면 닫아야 합니다
StreamWriter 로 직접 열었다면 이야기가 달라집니다.
쓴 내용은 곧바로 파일에 가지 않습니다. 어느 만큼 모았다가 한꺼번에
내보내기 때문입니다.
var w = new StreamWriter(path); w.WriteLine("아직 버퍼에 있습니다"); Console.WriteLine(new FileInfo(path).Length); // 0 w.Dispose(); Console.WriteLine(new FileInfo(path).Length); // 이제 들어 있습니다
닫기 전에는 0 바이트입니다. 여기서 프로그램이 예외로 멈추면 쓴 내용이 사라집니다. 게다가 열어 둔 동안에는 다른 곳에서 그 파일을 열 수 없습니다.
// IOException : The process cannot access the file '...' // because it is being used by another process.
파일을 잠그는 것은 운영 체제가 하는 일입니다. 그래서 브라우저 안에서 실행할 때처럼 잠금이 없는 환경도 있습니다. 그런 곳에서도 위의 버퍼 문제는 그대로 남으므로, 닫는 습관은 어디서나 같습니다.
그래서 using 을 사용합니다.
어떤 경우에도 닫힙니다. 예외가 나가는 길에도 닫히므로
1.10의 finally 에
닫는 코드를 손수 적지 않아도 됩니다.
using (var w = new StreamWriter(path)) { w.WriteLine("한 줄"); } // 여기서 이미 닫혔습니다
using var w = new StreamWriter(path); w.WriteLine("한 줄"); // 메서드가 끝날 때 닫힙니다
둘은 닫는 시점만 다릅니다. 여는 것이 여럿이면 왼쪽은 중괄호가 계단처럼 깊어지는데, 오른쪽은 그렇지 않습니다. 다만 스코프가 끝날 때까지 열려 있으므로 일찍 닫아야 하는 것은 왼쪽으로 적거나 메서드를 나눕니다.
using 을 붙일 수 있는 것은
IDisposable 을 구현한 형식입니다. 파일뿐 아니라 연결,
잠금처럼 다 사용한 뒤 놓아 주어야 하는 것이 모두 여기에 해당합니다.
한글이 깨질 때
.NET 은 UTF-8 로 읽고 씁니다. 따로 지정하지 않으면 BOM 없이 씁니다.
// "한글" 을 쓴 결과 File.WriteAllText(path, "한글"); // ED 95 9C EA B8 80 File.WriteAllText(path, "한글", new UTF8Encoding(true)); // EF BB BF ED 95 9C EA B8 80 ← 앞의 셋이 BOM
글자가 깨져 보인다면 쓴 쪽과 읽는 쪽의 인코딩이 다른 것입니다. 오래된 프로그램이 만든 한국어 파일은 CP949(EUC-KR)인 경우가 있는데, .NET Core 이후로는 그 표가 기본으로 들어 있지 않습니다.
Encoding.GetEncoding(949); // NotSupportedException : No data is available for encoding 949. // For information on defining a custom encoding, see the // documentation for the Encoding.RegisterProvider method.
예외가 알려 주는 대로 System.Text.Encoding.CodePages
패키지를 추가하고 시작할 때 한 번 등록하면 사용할 수 있습니다. 새로 만드는
파일이라면 UTF-8 로 두는 편이 낫습니다.
없는 파일과 잠긴 파일
파일을 다루면 3.7이 곧바로 필요해집니다.
| 예외 | 언제 |
|---|---|
| FileNotFoundException | 읽으려는 파일이 없을 때 |
| DirectoryNotFoundException | 경로 중간의 폴더가 없을 때 |
| UnauthorizedAccessException | 권한이 없거나 읽기 전용일 때 |
| IOException | 다른 프로그램이 잡고 있을 때(위 셋의 부모이기도 합니다) |
File.Exists 로 미리 확인하면 되지 않을까 싶지만,
확인한 다음 여는 사이에 파일이 사라질 수 있습니다. 다른 프로그램이
지우거나 옮기면 그렇습니다. 그래서 확인은 확인대로 하되 예외 처리를 빼지
않습니다.
// 없는 것이 흔한 일이면 — 확인하고 기본값으로 if (!File.Exists(path)) return Settings.Default; // 없으면 안 되는 것이면 — 그대로 예외를 올립니다 var text = File.ReadAllText(path);
1.10에서 본 기준이 여기서도 같습니다. 늘 있는 일이면 미리 확인하고, 있어서는 안 되는 일이면 예외로 둡니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 1.0File · StreamReader · StreamWriter · using 문
- 8.0using 선언 — 중괄호 없이 스코프 끝까지
- 12.0컬렉션 식 — WriteAllLines 에 [ … ] 로 넘기기
파일을 다루는 형식들은 처음부터 있었고 지금까지 이름이 바뀌지 않았습니다.
using 문도 1.0부터입니다. C# 8.0이 추가한
using 선언은 기능이 아니라 모양을 고친
것입니다. 파일 셋을 동시에 여는 코드는 중괄호가 세 겹으로 깊어졌는데,
선언으로 적으면 평평해집니다. 읽고 쓰는 일을 기다리지 않고 처리하는
ReadAllTextAsync 같은 것들은
async 와 함께 4부에서 다룹니다.
직접 해보기
키 = 값 형식의 설정 파일을 읽어
Dictionary 로 만들어보세요.
빈 줄과 # 로 시작하는 줄은 건너뛰고,
키와 값 앞뒤의 공백은 없앱니다. 파일이 없으면 빈 사전을
돌려줍니다.
# 설정 파일입니다 host = localhost port=8080 # 주석은 건너뜁니다 timeout = 30
로그 파일에서 ERROR 로 시작하는 줄만 뽑아 다른 파일로
옮겨보세요. 파일이 아주 클 수 있으니 통째로 읽지 않습니다.
몇 줄을 옮겼는지도 알려 줍니다.
- 크지 않은 파일은
File의 메서드 한 줄로 끝납니다. 여닫는 일까지 안에서 처리합니다. WriteAllText는 덮어씁니다. 이어 붙이려면AppendAllText입니다.- 경로는
Path.Combine으로 조합합니다. 구분자와 줄바꿈은 플랫폼마다 다릅니다. ReadAllLines는 전부 담고ReadLines는 한 줄씩 꺼냅니다. 큰 파일에는 뒤엣것입니다.- 직접 열었으면
using으로 반드시 닫습니다. 닫기 전에는 쓴 내용이 파일에 없고 다른 곳에서 열 수도 없습니다. - 인코딩은 UTF-8, BOM 없음이 기본입니다. CP949 는 패키지를 추가해 등록해야 합니다.
File.Exists로 확인해도 여는 사이에 사라질 수 있습니다. 예외 처리를 함께 둡니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.