Skip to main content
Summary

데이터가 남아야 프로그램이 된다

지금까지의 예제는 대부분 실행이 끝나면 값이 사라졌습니다. 콘솔에 입력한 이름, 계산한 합계, 만든 객체 목록은 프로그램이 종료되는 순간 메모리에서 사라집니다.

실제 프로그램은 다릅니다. 메모장 앱은 글을 파일에 저장합니다. 가계부 앱은 지출 내역을 다시 불러옵니다. 게임은 저장 파일을 만들고, 서버 프로그램은 외부에서 받은 데이터를 처리합니다.

이번 장에서는 가장 기본적인 외부 데이터 저장 방법을 다룹니다.

  • 파일과 폴더 경로 만들기

  • 텍스트 파일 읽고 쓰기

  • CSV 형식으로 표 형태 데이터 저장하기

  • 객체를 JSON으로 저장하고 다시 객체로 복원하기

  • 파일 작업 중 생길 수 있는 오류 처리하기

여기서 말하는 파일 입출력은 파일에 데이터를 쓰고, 파일에서 데이터를 읽는 작업입니다. 프로그램 안에서만 존재하던 값을 바깥 파일로 내보내고, 다음 실행에서 다시 불러오는 출발점이라고 생각하면 됩니다.

파일, JSON, 외부 데이터 다루기AI 생성 이미지

File, Directory, Path로 경로 다루기

C#에서는 System.IO 네임스페이스의 클래스들을 사용해 파일과 폴더를 다룹니다.

  • File: 파일 읽기, 쓰기, 존재 확인

  • Directory: 폴더 생성, 존재 확인, 파일 목록 확인

  • Path: 운영체제에 맞게 경로 조합

먼저 간단한 텍스트 파일을 만들어 보겠습니다.

using System.IO;

string folder = "data";
string filePath = Path.Combine(folder, "memo.txt");

Directory.CreateDirectory(folder);
File.WriteAllText(filePath, "C# 파일 저장 연습");

string text = File.ReadAllText(filePath);
Console.WriteLine(text);

실행 결과:

C# 파일 저장 연습

Path.Combine(folder, "memo.txt")는 data/memo.txt 또는 datamemo.txt처럼 운영체제에 맞는 경로를 만들어 줍니다. 직접 "data/memo.txt"처럼 쓰는 것보다 안전합니다.

Directory.CreateDirectory(folder)는 폴더가 없으면 만들고, 이미 있으면 그대로 둡니다. 파일을 쓰기 전에 폴더를 준비하는 습관을 들이면 DirectoryNotFoundException 같은 오류를 줄일 수 있습니다.

여기서 사용한 "data"는 상대 경로입니다. 보통 현재 프로그램이 실행되는 위치를 기준으로 data 폴더가 만들어집니다. 처음에는 상대 경로로 연습해도 충분하지만, 실제 프로그램에서는 실행 위치가 어디인지도 함께 확인하는 습관이 필요합니다.

텍스트 파일 읽고 쓰기

작은 텍스트 파일은 File.WriteAllText, File.ReadAllText로 쉽게 처리할 수 있습니다.

string path = Path.Combine("data", "todo.txt");

Directory.CreateDirectory("data");

File.WriteAllText(path, "책 읽기n코드 연습하기n");

string content = File.ReadAllText(path);
Console.WriteLine(content);

실행 결과:

책 읽기
코드 연습하기

여러 줄을 목록으로 다루고 싶다면 WriteAllLines, ReadAllLines가 더 편합니다.

string path = Path.Combine("data", "tasks.txt");

string[] tasks =
{
    "문법 복습",
    "예제 실행",
    "오류 메시지 읽기"
};

Directory.CreateDirectory("data");
File.WriteAllLines(path, tasks);

string[] loadedTasks = File.ReadAllLines(path);

foreach (string task in loadedTasks)
{
    Console.WriteLine($"- {task}");
}

실행 결과:

- 문법 복습
- 예제 실행
- 오류 메시지 읽기

WriteAllText와 WriteAllLines는 기존 파일 내용을 덮어씁니다. 뒤에 내용을 추가하려면 AppendAllText나 AppendAllLines를 사용합니다.

File.AppendAllLines(path, new[] { "작은 프로젝트 만들기" });

이 코드는 path가 가리키는 파일의 끝에 새 줄을 추가합니다. 파일이 없으면 새로 만들지만, 폴더 자체가 없다면 실패할 수 있으므로 앞에서처럼 Directory.CreateDirectory로 폴더를 준비해 두는 편이 좋습니다.

using 문과 IDisposable

파일은 메모리 안의 변수와 다릅니다. 운영체제가 관리하는 외부 자원입니다. 파일을 열어 놓은 채 닫지 않으면 다른 프로그램이 그 파일을 쓰지 못하거나, 아직 데이터가 완전히 저장되지 않을 수 있습니다.

이처럼 사용 후 정리가 필요한 객체는 보통 IDisposable을 구현합니다. IDisposable은 "다 썼으면 정리할 수 있다"는 약속을 가진 인터페이스입니다.

C#의 using 문은 사용이 끝난 객체를 자동으로 정리합니다.

string path = Path.Combine("data", "log.txt");
Directory.CreateDirectory("data");

using StreamWriter writer = new StreamWriter(path);

writer.WriteLine("프로그램 시작");
writer.WriteLine(DateTime.Now);

위 코드는 using 선언 형태입니다. writer는 현재 스코프가 끝날 때 자동으로 정리되고, 그 과정에서 파일도 닫힙니다. 예외가 발생해도 정리 코드가 실행되므로 파일 작업에 자주 사용합니다.

정리되는 범위를 더 눈에 보이게 쓰고 싶다면 다음처럼 블록 형태로 작성할 수도 있습니다.

string path = Path.Combine("data", "log.txt");
Directory.CreateDirectory("data");

using (StreamWriter writer = new StreamWriter(path))
{
    writer.WriteLine("프로그램 시작");
    writer.WriteLine(DateTime.Now);
}

이 경우에는 중괄호 블록이 끝나는 순간 writer가 정리됩니다.

읽을 때도 같은 방식으로 사용할 수 있습니다.

string path = Path.Combine("data", "log.txt");

using StreamReader reader = new StreamReader(path);

string allText = reader.ReadToEnd();
Console.WriteLine(allText);

작은 파일은 File.ReadAllText가 간단합니다. 하지만 파일을 한 줄씩 처리하거나, 스트림을 직접 다뤄야 할 때는 StreamReader, StreamWriter와 using을 함께 사용합니다.

CSV 파일로 표 형태 데이터 저장하기

CSV는 Comma-Separated Values의 줄임말입니다. 값을 쉼표로 구분한 텍스트 형식입니다.

예를 들어 판매 내역은 다음처럼 저장할 수 있습니다.

Name,Price,Quantity
Keyboard,45000,2
Mouse,25000,3

간단한 CSV 파일을 만들어 읽어 보겠습니다.

string path = Path.Combine("data", "sales.csv");
Directory.CreateDirectory("data");

string[] lines =
{
    "Name,Price,Quantity",
    "Keyboard,45000,2",
    "Mouse,25000,3"
};

File.WriteAllLines(path, lines);

string[] loadedLines = File.ReadAllLines(path);

for (int i = 1; i < loadedLines.Length; i++)
{
    string[] parts = loadedLines[i].Split(',');

    string name = parts[0];
    int price = int.Parse(parts[1]);
    int quantity = int.Parse(parts[2]);

    Console.WriteLine($"{name}: {price * quantity:N0}원");
}

실행 결과:

Keyboard: 90,000원
Mouse: 75,000원

첫 줄인 Name,Price,Quantity는 헤더입니다. 각 열이 무엇을 의미하는지 설명합니다. 그래서 반복문을 i = 1부터 시작했습니다.

단, Split(',') 방식은 단순한 CSV에만 적합합니다. 값 안에 쉼표가 들어가거나 줄바꿈이 포함되면 더 복잡한 처리가 필요합니다. 예를 들어 상품명이 "Monitor, 27 inch"라면 단순히 쉼표로 나누었을 때 잘못 분리됩니다. 실무에서는 CSV 전용 라이브러리를 쓰는 경우도 많습니다.

JSON은 객체를 저장하기 좋은 형식이다

CSV는 표 형태 데이터에 적합합니다. 하지만 객체 안에 목록이 있거나, 여러 속성을 가진 데이터를 저장하려면 JSON이 더 자연스럽습니다.

JSON은 JavaScript Object Notation의 줄임말입니다. 이름은 JavaScript에서 왔지만, 지금은 다양한 언어에서 사용하는 데이터 교환 형식입니다.

C#에서는 System.Text.Json의 JsonSerializer를 사용해 객체를 JSON 문자열로 바꾸고, JSON 문자열을 다시 객체로 바꿀 수 있습니다. 객체를 저장 가능한 형식으로 바꾸는 일을 직렬화, 저장된 형식에서 다시 객체를 만드는 일을 역직렬화라고 합니다.1

다음 예제는 할 일 목록을 JSON 파일로 저장하고 다시 읽습니다.

using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;

string path = Path.Combine("data", "tasks.json");
Directory.CreateDirectory("data");

List<TodoItem> tasks = new()
{
    new TodoItem { Title = "파일 입출력 공부", IsDone = true },
    new TodoItem { Title = "JSON 예제 작성", IsDone = false }
};

var options = new JsonSerializerOptions
{
    WriteIndented = true
};

string json = JsonSerializer.Serialize(tasks, options);
File.WriteAllText(path, json);

string loadedJson = File.ReadAllText(path);
List<TodoItem>? loadedTasks = JsonSerializer.Deserialize<List<TodoItem>>(loadedJson);

if (loadedTasks is not null)
{
    foreach (TodoItem task in loadedTasks)
    {
        string status = task.IsDone ? "완료" : "진행 중";
        Console.WriteLine($"[{status}] {task.Title}");
    }
}

public class TodoItem
{
    public string Title { get; set; } = "";
    public bool IsDone { get; set; }
}

실행 결과:

[완료] 파일 입출력 공부
[진행 중] JSON 예제 작성

저장된 tasks.json 파일은 다음과 비슷합니다.

[
  {
    "Title": "파일 입출력 공부",
    "IsDone": true
  },
  {
    "Title": "JSON 예제 작성",
    "IsDone": false
  }
]

WriteIndented = true는 사람이 읽기 좋게 들여쓰기해서 JSON을 만듭니다. 이 옵션을 주지 않으면 기본적으로 더 짧은 한 줄 형태로 저장됩니다. System.Text.Json은 기본적으로 public 프로퍼티를 직렬화하고, 필드는 기본 설정에서 제외됩니다.2

그래서 TodoItem의 Title, IsDone은 public 프로퍼티로 작성했습니다.

역직렬화 결과는 null일 수 있다

다음 코드에 주목하세요.

List<TodoItem>? loadedTasks = JsonSerializer.Deserialize<List<TodoItem>>(loadedJson);

?는 결과가 null일 수 있음을 나타냅니다. 예를 들어 JSON 내용 자체가 null이면 역직렬화 결과도 null이 될 수 있습니다. JSON 형식이 완전히 잘못되었거나 예상한 타입과 맞지 않으면 JsonException이 발생할 수도 있습니다. 그래서 파일에서 읽은 값을 바로 믿기보다는, 먼저 확인하고 사용하는 습관이 좋습니다.

if (loadedTasks is not null)
{
    // 안전하게 사용
}

공식 예제에서도 파일 내용을 문자열로 읽은 뒤 JsonSerializer.Deserialize<T>()로 원하는 타입의 객체를 만드는 패턴을 사용합니다.3

JSON의 모양과 C# 타입의 모양이 맞아야 역직렬화가 자연스럽게 됩니다. 예를 들어 JSON에는 Title이 있는데 C# 클래스에는 Name만 있다면 값이 원하는 프로퍼티에 들어가지 않습니다. 기본 설정에서는 프로퍼티 이름의 대소문자도 구분합니다.

파일 입출력 오류 처리하기

파일 작업은 실패할 가능성이 높습니다. 코드가 맞아도 환경 때문에 실패할 수 있습니다.

대표적인 경우는 다음과 같습니다.

  • 경로가 잘못됨

  • 폴더가 없음

  • 파일이 없음

  • 권한이 없음

  • 다른 프로그램이 파일을 사용 중임

  • JSON 형식이 깨짐

이럴 때는 try-catch로 오류를 처리합니다.

using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;

string path = Path.Combine("data", "tasks.json");

try
{
    string json = File.ReadAllText(path);
    List<TodoItem>? tasks = JsonSerializer.Deserialize<List<TodoItem>>(json);

    if (tasks is null)
    {
        Console.WriteLine("저장된 할 일이 없습니다.");
        return;
    }

    Console.WriteLine($"할 일 {tasks.Count}개를 불러왔습니다.");
}
catch (FileNotFoundException)
{
    Console.WriteLine("저장 파일이 아직 없습니다.");
}
catch (UnauthorizedAccessException)
{
    Console.WriteLine("파일을 읽을 권한이 없습니다.");
}
catch (JsonException)
{
    Console.WriteLine("JSON 파일 형식이 올바르지 않습니다.");
}
catch (IOException ex)
{
    Console.WriteLine($"파일을 읽는 중 문제가 발생했습니다: {ex.Message}");
}

public class TodoItem
{
    public string Title { get; set; } = "";
    public bool IsDone { get; set; }
}

FileNotFoundException은 파일이 없을 때 발생합니다. 처음 실행하는 프로그램이라면 파일이 없는 것이 자연스러운 상황일 수도 있습니다. 이 경우에는 "저장 파일이 없습니다"라고 안내하고 빈 목록으로 시작하면 됩니다.

UnauthorizedAccessException은 파일이나 폴더에 접근할 권한이 없을 때 발생할 수 있습니다. 예를 들어 읽을 수 없는 위치에 접근하거나, 파일 대신 폴더 경로를 파일처럼 읽으려고 할 때도 만날 수 있습니다.

JsonException은 JSON 형식이 잘못되었을 때 처리합니다. 사용자가 파일을 직접 수정했거나, 저장 도중 파일이 깨졌을 수 있습니다.

모든 예외를 무조건 catch (Exception)으로 감싸면 원인을 구분하기 어렵습니다. 먼저 예상 가능한 예외를 구체적으로 처리하고, 정말 필요한 경우에만 마지막에 넓은 범위의 예외를 처리하는 편이 좋습니다.

흔한 실수

경로를 문자열로 직접 이어 붙인다

string path = "data" + "\\" + "memo.txt";

이 코드는 운영체제에 따라 어색해질 수 있습니다. 다음처럼 작성하세요.

string path = Path.Combine("data", "memo.txt");

폴더를 만들지 않고 파일부터 쓴다

File.WriteAllText("data/memo.txt", "hello");

data 폴더가 없으면 실패할 수 있습니다.

Directory.CreateDirectory("data");
File.WriteAllText(Path.Combine("data", "memo.txt"), "hello");

JSON으로 저장할 클래스를 필드만으로 만든다

public class User
{
    public string name = "";
}

기본 설정에서는 public 필드가 아니라 public 프로퍼티를 사용하는 편이 안전합니다.

public class User
{
    public string Name { get; set; } = "";
}

역직렬화 결과를 바로 사용한다

var users = JsonSerializer.Deserialize<List<User>>(json);
Console.WriteLine(users.Count); // users가 null이면 오류

null 가능성을 확인하세요.

var users = JsonSerializer.Deserialize<List<User>>(json);

if (users is not null)
{
    Console.WriteLine(users.Count);
}

작은 연습 문제

  1. data/profile.txt 파일에 이름과 이메일을 저장하고 다시 읽어 출력해 보세요.

  2. scores.csv 파일에 이름, 과목, 점수를 저장한 뒤 각 줄을 읽어 홍길동 - C# - 90점 형식으로 출력해 보세요.

  3. Book 클래스를 만들고 Title, Author, Price 프로퍼티를 추가하세요. List<Book>을 JSON 파일로 저장하고 다시 읽어 출력해 보세요.

  4. 존재하지 않는 파일을 읽을 때 FileNotFoundException을 잡아 "파일이 없습니다"라고 출력해 보세요.

  5. JSON 파일 내용을 일부러 잘못 수정한 뒤 JsonException이 처리되는지 확인해 보세요.

파일 저장은 프로그램을 한 단계 현실에 가깝게 만듭니다. 메모리 안에서만 움직이던 값이 파일에 남고, 다음 실행에서 다시 이어집니다. 이 기본기를 익히면 설정 파일, 저장 데이터, 외부 API 응답 처리 같은 주제로 자연스럽게 확장할 수 있습니다.

참고

1JSON serialization and deserialization in .NET - overview

2How to write .NET objects as JSON (serialize)

3How to read JSON as .NET objects (deserialize)

책 목록

C# 학습: 기초 문법부터 작은 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기