PHpullh

C# · 심층 가이드

C# 파일/IO 완전 정리

텍스트 파일 읽기와 쓰기부터 JSON 직렬화, CSV 파싱, 경로 조합, 디렉터리 순회, 설정 로드, 로그 회전까지 C# 파일 입출력 열다섯 가지를 정리했습니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

.NET의 파일 API는 층이 나뉘어 있습니다. File.ReadAllText처럼 한 번에 끝내는 편의 메서드가 있고, 그 아래에 스트림과 리더가 있으며, 각각 비동기 짝을 갖습니다. 작은 설정 파일이라면 위층이 맞고, 수백 메가짜리 로그를 한 줄씩 훑는다면 통째로 읽는 순간 메모리에서 문제가 생깁니다. 어느 층을 쓸지는 파일 크기와 호출 빈도로 정합니다. 서버 코드라면 여기에 비동기 여부까지 더해지는데, 디스크 대기 동안 스레드를 붙잡고 있을 이유가 없기 때문입니다. 예제를 볼 때 이 세 축을 함께 보면 선택이 훨씬 단순해집니다.

텍스트 파일 읽기와 텍스트 파일 쓰기, 텍스트 파일 추가 쓰기가 기본 삼종 세트입니다. 여기에 구조를 얹은 것이 JSON 저장과 직렬화이고, 반대로 구조가 있는 척하지만 실은 규칙이 지저분한 쪽이 CSV 파싱입니다. 경로 합치기와 디렉터리 목록은 파일을 다루기 전에 위치를 정하는 단계라 앞쪽에 두고 읽어도 좋고, 설정 로드는 파일과 환경 변수 읽기를 합쳐 쓰는 실전형 예제입니다. 끝의 로그 파일 회전 흉내는 앞에서 익힌 쓰기, 존재 확인, 경로 조작을 한 번에 엮은 마무리에 해당합니다.

Path.Combine에는 잘 알려지지 않은 규칙이 있습니다. 뒤에 오는 인자가 루트로 시작하면 앞의 경로를 전부 버리고 그 절대 경로만 남깁니다. 사용자 입력으로 받은 하위 경로를 그대로 이어 붙이면 엉뚱한 위치에 쓰게 되는 원인입니다. CSV 파싱도 마찬가지로, 쉼표로 Split하는 코드는 따옴표로 감싼 필드 안에 쉼표나 줄바꿈이 들어오는 순간 깨집니다. 형식이 우리 손을 벗어나 있다면 검증된 파서를 쓰는 편이 낫습니다.

01텍스트 파일 읽기

코드는 File.WriteAllText로 sample-1.txt를 만든 뒤 File.Exists로 존재 여부를 출력합니다. 읽기 코드를 시험하기 전에 대상 파일을 미리 깔아 두는 준비 단계로 씁니다. WriteAllText는 내부에서 스트림을 열고 닫아 주므로 using 블록을 따로 감쌀 필요가 없습니다.

C# code

// 텍스트 파일 읽기
using System;
using System.IO;

File.WriteAllText("sample-1.txt", "hello");
Console.WriteLine(File.Exists("sample-1.txt"));
알아두면 좋은 점

File.ReadAllText는 BOM이 있으면 그 인코딩을 따르고 없으면 UTF-8로 읽으므로, 인코딩을 확실히 못 박으려면 두 번째 인자로 직접 넘겨야 합니다.

자주 하는 실수

상대 경로는 소스 파일 위치가 아니라 프로세스의 현재 작업 디렉터리를 기준으로 해석됩니다. IDE에서 실행할 때와 배포 후 경로가 달라 파일을 못 찾는 일이 흔합니다.

02텍스트 파일 쓰기

실제로 하는 일은 Path.Combine이 조립한 로그 경로 문자열을 화면에 찍는 것뿐이며, 파일도 폴더도 생기지 않습니다. 저장 위치를 먼저 확정한 다음 쓰기 호출을 붙이는 순서로 작업할 때 이런 형태가 나옵니다. 구분자는 실행 중인 OS에 맞춰 역슬래시나 슬래시로 자동으로 달라집니다.

C# code

// 텍스트 파일 쓰기
using System;
using System.IO;

Console.WriteLine(Path.Combine("logs", "2026", "app-2.log"));
알아두면 좋은 점

Path.Combine은 앞 조각 끝에 구분자가 이미 있으면 중복해서 넣지 않으므로, 문자열을 직접 이어 붙일 때 생기는 이중 슬래시를 걱정하지 않아도 됩니다.

자주 하는 실수

쓰는 도중 프로세스가 죽으면 파일이 반쯤 쓰인 상태로 남습니다. 중요한 파일은 임시 이름으로 먼저 쓰고 File.Move로 바꿔치기해야 깨진 내용이 남지 않습니다.

03텍스트 파일 추가 쓰기

WriteAllText는 이름과 달리 뒤에 붙이는 것이 아니라 기존 내용을 통째로 덮어씁니다. 이 코드도 sample-3.txt를 새로 쓰고 결과를 확인할 뿐입니다. 이어쓰기가 목적이라면 File.AppendAllText를 써야 하고, 그쪽은 파일이 없을 때 알아서 새로 만들어 줍니다.

C# code

// 텍스트 파일 추가 쓰기
using System;
using System.IO;

File.WriteAllText("sample-3.txt", "hello");
Console.WriteLine(File.Exists("sample-3.txt"));
알아두면 좋은 점

File.AppendAllLines는 줄마다 개행을 붙여 주므로, 로그처럼 한 줄씩 쌓는 용도에서는 문자열 끝에 개행을 직접 넣을 필요가 없습니다.

자주 하는 실수

여러 프로세스가 같은 파일에 동시에 이어 쓰면 줄이 뒤섞이거나 파일이 잠겨 IOException이 납니다. 공유 로그는 프로세스마다 파일을 나누는 편이 안전합니다.

04JSON 저장

JSON 직렬화는 등장하지 않고 저장할 파일 경로를 계산해 출력하는 부분만 담겨 있습니다. Path.Combine은 문자열만 이어 붙일 뿐 중간 폴더를 만들지 않아서, 이대로 쓰기를 시도하면 DirectoryNotFoundException이 납니다. Directory.CreateDirectory를 먼저 불러야 합니다.

C# code

// JSON 저장
using System;
using System.IO;

Console.WriteLine(Path.Combine("logs", "2026", "app-4.log"));
알아두면 좋은 점

Directory.CreateDirectory는 이미 있는 폴더에 대해 예외를 내지 않고 중간 단계까지 한 번에 만들어 주므로, 존재 여부를 먼저 확인할 필요가 없습니다.

자주 하는 실수

JsonSerializer는 기본적으로 public 속성만 직렬화하므로 필드나 비공개 속성에 담은 값은 조용히 빠집니다. 저장 후 파일을 한 번 열어 확인해야 합니다.

05CSV 파싱

파싱할 대상 파일을 하나 만들어 두고 생성됐는지 확인하는 정도의 코드입니다. CSV 실습 전에 샘플 데이터를 심는 용도로 어울립니다. 읽을 때 File.ReadAllLines는 모든 줄을 배열에 담지만 File.ReadLines는 한 줄씩 흘려보내므로, 큰 파일에서는 후자가 메모리를 훨씬 덜 씁니다.

C# code

// CSV 파싱
using System;
using System.IO;

File.WriteAllText("sample-5.txt", "hello");
Console.WriteLine(File.Exists("sample-5.txt"));
알아두면 좋은 점

File.ReadLines는 열거하는 동안 파일 핸들을 잡고 있으므로, 중간에 순회를 멈추더라도 열거자가 정리되도록 foreach로 감싸 두는 편이 안전합니다.

자주 하는 실수

CSV를 쉼표로 Split하면 따옴표 안의 쉼표와 줄바꿈에서 무너집니다. 직접 만든 데이터가 아니라면 규격을 지키는 파서를 쓰는 편이 안전합니다.

06환경 변수 읽기

환경 변수 조회는 어디에도 없고, 경로 세 조각을 합친 결과를 출력하는 줄 하나가 전부입니다. 로그 폴더를 환경 변수로 받아 조립할 때 그 값이 들어갈 자리가 여기입니다. 주의할 점은 중간 인자가 절대 경로면 Path.Combine이 앞 조각을 전부 버리고 그 경로부터 다시 시작한다는 것입니다.

C# code

// 환경 변수 읽기
using System;
using System.IO;

Console.WriteLine(Path.Combine("logs", "2026", "app-6.log"));
알아두면 좋은 점

Path.Combine은 조각에 잘못된 문자가 있어도 검사하지 않으므로, 환경 변수로 받은 값을 그대로 넣으면 존재할 수 없는 경로가 조용히 만들어집니다.

자주 하는 실수

사용자 입력을 경로 조각으로 넣으면 상위 폴더 표기가 섞였을 때 의도한 폴더 밖으로 나갈 수 있습니다. 조립 후 Path.GetFullPath로 정규화해 기준 폴더 안인지 확인해야 합니다.

07명령행 인자

인자 처리 대신 파일을 하나 쓰고 존재 여부를 찍는 흐름만 남아 있습니다. 실제로 인자를 받도록 고치려면 파일명 자리에 값을 끼워 넣으면 됩니다. 최상위 문으로 작성한 프로그램에서는 args 변수가 이미 제공되므로 Main 시그니처를 직접 선언하지 않아도 됩니다.

C# code

// 명령행 인자
using System;
using System.IO;

File.WriteAllText("sample-7.txt", "hello");
Console.WriteLine(File.Exists("sample-7.txt"));
알아두면 좋은 점

args에는 셸이 따옴표를 처리한 뒤의 값이 들어오므로 공백이 포함된 경로도 원소 하나로 도착합니다. 받은 값을 다시 공백으로 나눌 필요가 없습니다.

자주 하는 실수

인자를 args[0]으로 바로 읽으면 인자 없이 실행했을 때 IndexOutOfRangeException이 납니다. 길이를 먼저 확인하고 사용법을 안내하는 편이 낫습니다.

08경로 합치기

제목 그대로 Path.Combine이 세 조각을 OS 구분자로 이어 붙인 결과를 보여 줍니다. 구분자를 문자열에 하드코딩하는 대신 이 메서드를 쓰면 윈도우와 리눅스에서 같은 코드가 동작합니다. 다만 상위 폴더 표기는 정리되지 않으므로, 정규화된 경로가 필요하면 Path.GetFullPath를 거쳐야 합니다.

C# code

// 경로 합치기
using System;
using System.IO;

Console.WriteLine(Path.Combine("logs", "2026", "app-8.log"));
알아두면 좋은 점

확장자를 바꿀 때는 문자열을 잘라내지 말고 Path.ChangeExtension을, 파일명만 얻을 때는 Path.GetFileName을 쓰면 구분자 처리를 신경 쓰지 않아도 됩니다.

자주 하는 실수

윈도우에서 만든 경로 문자열을 그대로 리눅스로 옮기면 역슬래시가 구분자가 아니라 파일 이름의 일부가 됩니다. 저장은 조각으로 하고 조립은 실행 시점에 해야 합니다.

09디렉터리 목록

디렉터리를 훑는 코드는 없고 파일 하나를 만든 뒤 File.Exists 결과를 출력합니다. 목록 조회 예제를 돌리기 전에 폴더를 채워 두는 데 쓸 수 있습니다. File.Exists는 권한이 없거나 경로가 잘못돼도 예외 없이 false를 돌려주므로, 실패 원인까지 알려면 직접 열어 보고 예외를 잡아야 합니다.

C# code

// 디렉터리 목록
using System;
using System.IO;

File.WriteAllText("sample-9.txt", "hello");
Console.WriteLine(File.Exists("sample-9.txt"));
알아두면 좋은 점

Directory.EnumerateFiles는 목록 전체를 배열로 만들지 않고 하나씩 흘려주므로, 파일이 아주 많은 폴더에서도 첫 결과를 훨씬 빨리 얻을 수 있습니다.

자주 하는 실수

하위 폴더까지 훑을 때 접근 권한이 없는 폴더를 만나면 열거가 통째로 중단될 수 있습니다. EnumerationOptions로 접근 불가 항목의 처리 방식을 명시해 두어야 합니다.

10임시 파일

임시 파일 API는 쓰이지 않고 고정된 로그 경로 문자열만 만들어 출력합니다. 진짜 임시 파일이 필요하다면 Path.GetTempFileName이 임시 폴더에 빈 파일까지 만들어 주는데, 만든 쪽에서 지우지 않으면 계속 남습니다. 이름만 필요할 때는 Path.GetTempPath와 조합하는 편이 가볍습니다.

C# code

// 임시 파일
using System;
using System.IO;

Console.WriteLine(Path.Combine("logs", "2026", "app-10.log"));
알아두면 좋은 점

Path.GetTempFileName은 임시 폴더의 파일 수가 한계에 이르면 더 만들지 못하고 실패하므로, 대량으로 쓸 때는 Path.GetRandomFileName으로 이름만 받는 편이 낫습니다.

자주 하는 실수

임시 파일을 만든 뒤 try/finally로 삭제를 보장하지 않으면 예외가 난 실행마다 파일이 쌓여 결국 디스크를 채웁니다.

11존재 여부 확인

쓰기와 확인이 한 쌍으로 붙어 있어 File.Exists가 True를 출력합니다. 파일 유무로 분기하는 코드의 가장 단순한 형태입니다. 확인한 순간과 실제로 여는 순간 사이에 다른 프로세스가 파일을 지울 수 있으니, 경합이 있는 환경에서는 존재 확인보다 열기 시도와 예외 처리가 안전합니다.

C# code

// 존재 여부 확인
using System;
using System.IO;

File.WriteAllText("sample-11.txt", "hello");
Console.WriteLine(File.Exists("sample-11.txt"));
알아두면 좋은 점

File.Exists는 대상이 폴더면 false를 돌려주므로, 같은 이름의 폴더가 있는 경우를 구분하려면 Directory.Exists도 함께 확인해야 합니다.

자주 하는 실수

네트워크 드라이브나 마운트된 경로에서는 존재 확인 호출 자체가 타임아웃까지 멈춰 있을 수 있습니다. 반복문 안에서 매번 부르면 응답이 눈에 띄게 느려집니다.

12표준 입력 읽기

표준 입력을 읽는 호출은 없고 경로 조립 결과 출력만 남아 있으며, 입력 코드를 붙일 자리는 이 줄 앞입니다. Console.ReadLine은 파이프 입력이 끝나면 빈 문자열이 아니라 null을 반환하므로, 반복 종료 조건은 반드시 null 검사로 잡아야 합니다.

C# code

// 표준 입력 읽기
using System;
using System.IO;

Console.WriteLine(Path.Combine("logs", "2026", "app-12.log"));
알아두면 좋은 점

파이프로 들어오는 대량 입력은 Console.OpenStandardInput을 감싼 StreamReader로 읽으면 줄마다 콘솔 계층을 거치지 않아 처리량이 크게 올라갑니다.

자주 하는 실수

윈도우에서 만든 입력을 리눅스에서 파이프로 받으면 줄 끝에 캐리지 리턴이 남아 문자열 비교가 어긋납니다. 읽은 줄에 Trim을 걸어 두는 편이 안전합니다.

13설정 로드

설정 파서 대신 sample-13.txt를 쓰고 존재 여부를 찍는 코드가 들어 있습니다. 설정 파일이 없을 때 기본값 파일을 깔아 두는 초기화 루틴이 이런 모양입니다. WriteAllText는 기본적으로 BOM 없는 UTF-8로 저장하니, BOM을 요구하는 도구와 맞추려면 인코딩을 받는 오버로드를 써야 합니다.

C# code

// 설정 로드
using System;
using System.IO;

File.WriteAllText("sample-13.txt", "hello");
Console.WriteLine(File.Exists("sample-13.txt"));
알아두면 좋은 점

설정은 파일과 환경 변수, 명령행 인자가 겹칠 수 있으므로 어느 쪽이 우선인지 미리 정하고, 파일이 없을 때 쓸 기본값 경로도 함께 정해 두어야 합니다.

자주 하는 실수

설정 파일에 연결 문자열이나 키를 그대로 적어 두고 소스 저장소에 올리는 사고가 잦습니다. 비밀 값은 환경 변수나 별도 보관소로 빼고 파일은 제외 목록에 넣어야 합니다.

14직렬화

직렬화 로직은 빠져 있고 결과를 담을 파일 경로만 계산해 출력합니다. 실제 저장 단계에서 System.Text.Json을 쓴다면, 기본 인코더가 한글과 여러 기호를 유니코드 이스케이프로 바꾼다는 점을 알아 두어야 합니다. 사람이 열어 볼 파일이면 JsonSerializerOptions에서 인코더를 바꾸면 됩니다.

C# code

// 직렬화
using System;
using System.IO;

Console.WriteLine(Path.Combine("logs", "2026", "app-14.log"));
알아두면 좋은 점

JsonSerializerOptions는 만들 때마다 내부 캐시가 새로 구성되므로, 인스턴스를 한 번 만들어 static readonly로 재사용해야 직렬화 성능이 떨어지지 않습니다.

자주 하는 실수

역직렬화 대상 타입에 대응되는 속성이나 생성자가 없으면 값이 채워지지 않은 객체가 만들어져, 예외 없이 데이터만 사라진 채 흐름이 이어집니다.

15로그 파일 회전 흉내

회전 로직은 없고 파일을 새로 쓴 뒤 존재 여부를 출력하는 데서 끝납니다. 로그 회전은 보통 기존 파일을 File.Move로 옮겨 놓고 새 파일을 여는 방식으로 구현합니다. File.Move는 대상 이름이 이미 있으면 예외를 던지므로, 덮어써야 한다면 overwrite 인자를 true로 넘겨야 합니다.

C# code

// 로그 파일 회전 흉내
using System;
using System.IO;

File.WriteAllText("sample-15.txt", "hello");
Console.WriteLine(File.Exists("sample-15.txt"));
알아두면 좋은 점

회전 기준을 크기로 잡는다면 new FileInfo(path).Length로 확인할 수 있고, 날짜 기준이라면 파일명에 날짜를 넣어 자연스럽게 새 파일이 열리도록 만드는 방법도 있습니다.

자주 하는 실수

다른 프로세스가 로그 파일을 열어 둔 상태에서 이름을 바꾸려 하면 윈도우에서는 잠금 때문에 실패합니다. 회전 전에 쓰기 스트림을 먼저 닫아야 합니다.

정리하며

  • 파일 크기와 호출 빈도로 편의 메서드와 스트림 중 어느 층을 쓸지 먼저 정합니다
  • Path.Combine은 뒤 인자가 루트로 시작하면 앞 경로를 통째로 버립니다
  • 쉼표 Split 기반 CSV 파싱은 따옴표 필드가 등장하는 순간 무너집니다
  • 설정은 파일과 환경 변수를 겹쳐 읽어 배포 환경마다 값을 갈아 끼울 수 있게 만듭니다

더 깊이 들어가고 싶다면 C# 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.