C# 3.8 · 3부. 중급 문법

파일 입출력

파일을 읽고 씁니다.

예상 학습 시간 18분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 중급
개념 설명

대개는 한 줄로 끝납니다

파일을 다루는 방법은 두 가지입니다. 통째로 한 번에 다루는 것조금씩 나누어 다루는 것입니다.

// 통째로 — 파일 하나가 문자열 하나가 됩니다
string text = File.ReadAllText(path);
File.WriteAllText(path, text);

// 조금씩 — 열어 두고 필요한 만큼 읽고 씁니다
using var reader = new StreamReader(path);
string? line = reader.ReadLine();

설정 파일이나 목록처럼 크지 않은 것은 앞엣것으로 끝납니다. 뒤엣것은 파일이 아주 커서 통째로 담을 수 없거나, 다 읽기 전에 처리를 시작해야 할 때 필요합니다.

다만 뒤엣것에는 따라오는 것이 하나 있습니다. 열었으면 닫아야 합니다. 닫지 않으면 쓴 내용이 파일에 남지 않고, 다른 곳에서 그 파일을 열 수도 없습니다. 이 단원의 절반이 그 이야기입니다.

최소 예제 · 편집 가능

쓰고, 덧붙이고, 읽기

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
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}줄");
출력
1. 우유 2개 2. 달걀 10개 3. 빵 1개 우유 2개 달걀 10개 빵 1개 장보기.txt · 3줄

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

여는 코드도 닫는 코드도 없습니다. 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 을 사용합니다. WriteAllLinesEnvironment.NewLine 을 넣으므로 같은 코드가 플랫폼마다 다른 바이트를 만듭니다. 파일 크기를 세어 비교하는 코드를 적을 때 걸리는 자리입니다.

통째로 읽을 것과 한 줄씩 읽을 것

ReadAllLinesReadLines 는 이름이 한 글자 차이지만 메모리를 사용하는 방식이 다릅니다.

ReadAllLines — 전부 담습니다
string[] lines =
    File.ReadAllLines(path);

// 배열이라 Length 를 알 수 있고
// 여러 번 탐색할 수 있습니다.
// 1GB 파일이면 1GB 를 담습니다.
ReadLines — 한 줄씩 꺼냅니다
foreach (var line
    in File.ReadLines(path)) { }

// 한 줄만 메모리에 있습니다.
// 1GB 파일도 견딥니다.
// 다시 탐색하려면 또 읽습니다.

ReadLines3.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.10finally 에 닫는 코드를 손수 적지 않아도 됩니다.

using 문 — 중괄호 안에서만
using (var w = new StreamWriter(path)) {
    w.WriteLine("한 줄");
}
// 여기서 이미 닫혔습니다
using 선언 — 스코프 끝까지(C# 8.0)
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.0
2.0
3.0
5.0
6.0
8.0
9.0
12.0
14.0
  • 1.0File · StreamReader · StreamWriter · using 문
  • 8.0using 선언 — 중괄호 없이 스코프 끝까지
  • 12.0컬렉션 식 — WriteAllLines 에 [ … ] 로 넘기기
C# 1.0+

파일을 다루는 형식들은 처음부터 있었고 지금까지 이름이 바뀌지 않았습니다. using 문도 1.0부터입니다. C# 8.0이 추가한 using 선언은 기능이 아니라 모양을 고친 것입니다. 파일 셋을 동시에 여는 코드는 중괄호가 세 겹으로 깊어졌는데, 선언으로 적으면 평평해집니다. 읽고 쓰는 일을 기다리지 않고 처리하는 ReadAllTextAsync 같은 것들은 async 와 함께 4부에서 다룹니다.

실습 문제

직접 해보기

1. 설정 파일 읽기 난이도 하

키 = 값 형식의 설정 파일을 읽어 Dictionary 로 만들어보세요. 빈 줄과 # 로 시작하는 줄은 건너뛰고, 키와 값 앞뒤의 공백은 없앱니다. 파일이 없으면 빈 사전을 돌려줍니다.

# 설정 파일입니다

host = localhost
port=8080
  # 주석은 건너뜁니다
timeout = 30
한 줄씩 필요하니 ReadLines 입니다. = 가 값 안에도 있을 수 있으니 Split('=', 2) 로 앞의 하나만 구분합니다.
static Dictionary<string, string> Load(string path) { var result = new Dictionary<string, string>(); if (!File.Exists(path)) return result; foreach (var line in File.ReadLines(path)) { var text = line.Trim(); if (text.Length == 0 || text.StartsWith('#')) continue; var parts = text.Split('=', 2); if (parts.Length < 2) continue; result[parts[0].Trim()] = parts[1].Trim(); } return result; } foreach (var (key, value) in Load(path)) Console.WriteLine($"{key} = {value}"); // host = localhost // port = 8080 // timeout = 30 // 없는 파일이면 0개입니다. Exists 로 확인했으니 예외가 나지 않습니다.
2. 큰 로그에서 골라 옮기기 난이도 중

로그 파일에서 ERROR 로 시작하는 줄만 뽑아 다른 파일로 옮겨보세요. 파일이 아주 클 수 있으니 통째로 읽지 않습니다. 몇 줄을 옮겼는지도 알려 줍니다.

ReadLines 가 돌려주는 것을 그대로 WriteAllLines 에 넘길 수 있습니다. 그 사이에 Where 를 끼우면 한 줄씩 걸러지며 흘러갑니다.
// ReadLines 는 한 줄씩 꺼내므로 파일이 커도 메모리를 그만큼 사용하지 않습니다 File.WriteAllLines(dst, File.ReadLines(src).Where(line => line.StartsWith("ERROR"))); foreach (var line in File.ReadLines(dst)) Console.WriteLine(line); Console.WriteLine($"{File.ReadAllLines(dst).Length}줄을 옮겼습니다"); // ERROR 파일을 찾지 못했습니다 // ERROR 연결이 끊겼습니다 // 2줄을 옮겼습니다 // 읽는 파일과 쓰는 파일이 같으면 안 됩니다. 아직 읽는 중인 파일을 // 열어 덮어쓰는 것이라, 잠금이 있는 환경에서는 IOException 이 발생하고 // 잠금이 없는 환경에서는 파일이 빈 채로 남습니다.
요약
  • 크지 않은 파일은 File 의 메서드 한 줄로 끝납니다. 여닫는 일까지 안에서 처리합니다.
  • WriteAllText덮어씁니다. 이어 붙이려면 AppendAllText 입니다.
  • 경로는 Path.Combine 으로 조합합니다. 구분자와 줄바꿈은 플랫폼마다 다릅니다.
  • ReadAllLines 는 전부 담고 ReadLines한 줄씩 꺼냅니다. 큰 파일에는 뒤엣것입니다.
  • 직접 열었으면 using 으로 반드시 닫습니다. 닫기 전에는 쓴 내용이 파일에 없고 다른 곳에서 열 수도 없습니다.
  • 인코딩은 UTF-8, BOM 없음이 기본입니다. CP949 는 패키지를 추가해 등록해야 합니다.
  • File.Exists 로 확인해도 여는 사이에 사라질 수 있습니다. 예외 처리를 함께 둡니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.