C# 5.1 · 5부. 실전 프로젝트

가계부 만들기

수입과 지출을 담고 달마다 셈합니다.

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

배운 것을 묶습니다

5부는 새 문법을 배우는 자리가 아닙니다. 앞에서 하나씩 본 것을 묶어 돌아가는 프로그램을 만듭니다. 단원마다 따로 완결되므로 어느 것부터 보셔도 됩니다.

가계부가 하는 일은 넷입니다.

할 일어디서 배운 것
거래 하나를 담기4.5 record · 2.8 열거형
여러 건을 모으기3.2 컬렉션
달마다·분류마다 셈하기3.3 LINQ
파일로 남기고 다시 읽기3.8 파일 입출력

담는 것부터 정합니다. 무엇을 담을지가 정해지면 나머지는 그 위에 얹힙니다. 반대로 하면 담는 모양을 몇 번씩 고치게 됩니다.

최소 예제 · 편집 가능

돌아가는 가계부

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

Program.cs
var book = new Ledger();

book.Add(new(new(2026, 8, 1),  Kind.Income,  "월급", 3_200_000));
book.Add(new(new(2026, 8, 3),  Kind.Expense, "식비", 42_000, "점심"));
book.Add(new(new(2026, 8, 7),  Kind.Expense, "교통", 58_000));
book.Add(new(new(2026, 8, 15), Kind.Expense, "식비", 120_000, "장보기"));
book.Add(new(new(2026, 9, 1),  Kind.Income,  "월급", 3_200_000));
book.Add(new(new(2026, 9, 4),  Kind.Expense, "식비", 35_000));

foreach (var month in book.Months()) {
    var s = book.Summary(month);
    Console.WriteLine($"{month:yyyy년 M월}  수입 {s.Income,10:N0}  지출 {s.Expense,9:N0}  남은 것 {s.Balance,10:N0}");

    foreach (var (category, amount) in book.ByCategory(month, Kind.Expense))
        Console.WriteLine($"    {category,-4} {amount,9:N0}");
}

// 0원짜리 거래는 담기지 않습니다
try { book.Add(new(new(2026, 9, 5), Kind.Expense, "식비", 0)); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine(ex.Message.Split('\n')[0]); }

enum Kind { Income, Expense }

record Entry(DateOnly Date, Kind Kind, string Category, int Amount, string? Memo = null);

record Summary(int Income, int Expense) { public int Balance => Income - Expense; }

class Ledger {
    private readonly List<Entry> _entries = [];

    public void Add(Entry entry) {
        if (entry.Amount <= 0)
            throw new ArgumentOutOfRangeException(
                nameof(entry), entry.Amount, "금액은 0보다 커야 합니다.");

        _entries.Add(entry);
    }

    public IEnumerable<DateOnly> Months()
        => _entries.Select(e => new DateOnly(e.Date.Year, e.Date.Month, 1)).Distinct().Order();

    public Summary Summary(DateOnly month) {
        var m = _entries.Where(e => e.Date.Year == month.Year && e.Date.Month == month.Month);

        return new(m.Where(e => e.Kind == Kind.Income).Sum(e => e.Amount),
                   m.Where(e => e.Kind == Kind.Expense).Sum(e => e.Amount));
    }

    public IEnumerable<(string Category, int Amount)> ByCategory(DateOnly month, Kind kind)
        => _entries
            .Where(e => e.Date.Year == month.Year && e.Date.Month == month.Month && e.Kind == kind)
            .GroupBy(e => e.Category)
            .Select(g => (g.Key, g.Sum(e => e.Amount)))
            .OrderByDescending(x => x.Item2);
}
출력
2026년 8월 수입 3,200,000 지출 220,000 남은 것 2,980,000 식비 162,000 교통 58,000 2026년 9월 수입 3,200,000 지출 35,000 남은 것 3,165,000 식비 35,000 금액은 0보다 커야 합니다. (Parameter 'entry')

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

여든 줄이 되지 않습니다. 아래에서 이것을 한 조각씩 뜯어보고, 왜 그렇게 적었는지를 봅니다.

상세 사용법

담을 것을 먼저

거래 하나에 필요한 것은 날짜·종류·분류·금액이고, 메모는 있을 수도 없을 수도 있습니다. 그대로 적으면 됩니다.

enum Kind { Income, Expense }

record Entry(DateOnly Date, Kind Kind, string Category, int Amount, string? Memo = null);
선택까닭
record담고 나면 고치지 않습니다(4.5). 값으로 비교되어 겹친 입력을 찾기도 쉽습니다
DateOnly시각이 필요 없습니다. DateTime 을 사용하면 0시 0분이 값에 섞입니다
enum Kind수입인지 지출인지가 bool 이면 true 가 어느 쪽인지 읽는 사람이 짐작해야 합니다
int Amount원 단위라 소수가 없습니다. 소수가 필요하면 decimal 이고 double 은 아닙니다
string? Memo없을 수 있는 것에 물음표를 적습니다(4.9)

금액에 double 을 사용하지 마십시오. 2진 소수라 0.1을 정확히 담지 못해, 더할수록 어긋납니다. 돈은 decimal 이거나, 여기처럼 가장 작은 단위의 정수입니다.

담는 자리에서 막기

Add유일한 입구입니다. 목록을 private 로 두었으므로 밖에서 몰래 넣을 수 없고, 검사를 한 곳에만 적으면 됩니다.

private readonly List<Entry> _entries = [];

public void Add(Entry entry) {
    if (entry.Amount <= 0)
        throw new ArgumentOutOfRangeException(
            nameof(entry), entry.Amount, "금액은 0보다 커야 합니다.");

    _entries.Add(entry);
}

무엇이 잘못됐는지를 예외에 담습니다. 이름과 실제 값을 함께 넘기면 메시지에 그대로 붙습니다(3.7).

// 금액은 0보다 커야 합니다. (Parameter 'entry')

_entriespublic 으로 열어 두면 이 검사를 건너뛰는 길이 생깁니다. 밖에 내줄 때도 IEnumerable<Entry> 로 내주면 넣거나 지우지 못합니다.

달마다 셈하기

3.3의 LINQ 를 그대로 사용합니다. 걸러서 더하는 것이 전부입니다.

public Summary Summary(DateOnly month) {
    var m = _entries.Where(e => e.Date.Year == month.Year && e.Date.Month == month.Month);

    return new(m.Where(e => e.Kind == Kind.Income).Sum(e => e.Amount),
               m.Where(e => e.Kind == Kind.Expense).Sum(e => e.Amount));
}

m 을 두 번 사용하는 것이 눈에 걸릴 수 있습니다. LINQ 는 물어볼 때 도는 것이라 걸러 내는 일이 두 번 일어납니다. 건수가 적으면 이대로가 읽기 좋고, 많아지면 한 번만 돌게 고칩니다.

// 한 번만 돌리려면
var m = _entries
    .Where(e => e.Date.Year == month.Year && e.Date.Month == month.Month)
    .ToArray();

분류별 합계는 GroupBy 하나입니다. 큰 것부터 보이도록 내림차순으로 정렬했습니다.

_entries
    .Where(e => … && e.Kind == kind)
    .GroupBy(e => e.Category)
    .Select(g => (g.Key, g.Sum(e => e.Amount)))
    .OrderByDescending(x => x.Item2);

달의 목록은 날짜에서 1일로 잘라 만듭니다. 같은 달의 여러 날이 하나로 모입니다.

_entries.Select(e => new DateOnly(e.Date.Year, e.Date.Month, 1)).Distinct().Order();

파일로 남기기

3.8File 로 한 줄에 한 건씩 적습니다. CSV 는 눈으로 읽히고 표 계산기에서도 열립니다.

public void Save(string path)
    => File.WriteAllLines(path, _entries.Select(e => string.Join(',', [
        e.Date.ToString("yyyy-MM-dd"),
        e.Kind.ToString(),
        Quote(e.Category),
        e.Amount.ToString(CultureInfo.InvariantCulture),
        Quote(e.Memo ?? "")
    ])));

InvariantCulture 를 잊지 마십시오. 날짜와 숫자를 문화권에 맡기면 적은 장비와 읽는 장비가 다를 때 깨집니다. 파일에 남기는 값은 언제나 고정된 형식으로 적습니다.

여기서 진짜 문제는 메모에 쉼표가 들어가는 것입니다. 그대로 적으면 읽을 때 항목이 하나 늘어납니다.

// 감싸지 않으면
2026-08-15,Expense,식비,120000,장보기, 쌀
//                                    ↑ 여기서 항목이 6개가 됩니다

// 감싸면
2026-08-15,Expense,식비,120000,"장보기, 쌀"

규칙은 둘입니다. 쉼표나 따옴표가 있으면 따옴표로 감싸고, 안의 따옴표는 두 번 적습니다.

private static string Quote(string s)
    => s.Contains(',') || s.Contains('"')
        ? '"' + s.Replace("\"", "\"\"") + '"'
        : s;

읽는 쪽은 Split(',') 로는 안 됩니다. 따옴표 안인지 밖인지를 보면서 한 글자씩 지나가야 합니다.

private static string[] Split(string line) {
    var rst = new List<string>();
    var sb = new StringBuilder();
    var quoted = false;

    for (var i = 0; i < line.Length; i++) {
        var c = line[i];

        if (quoted) {
            if (c != '"') sb.Append(c);
            else if (i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; }
            else quoted = false;
        }
        else if (c == '"') quoted = true;
        else if (c == ',') { rst.Add(sb.ToString()); sb.Clear(); }
        else sb.Append(c);
    }

    rst.Add(sb.ToString());
    return [.. rst];
}

실제로 넣어 보고 확인했습니다. 메모를 "장보기, 쌀" 로 두고 저장한 뒤 다시 읽으면 그대로 돌아옵니다.

이 정도가 손으로 적을 선입니다. 줄바꿈이 든 값이나 다른 구분자까지 다루어야 한다면 CSV 라이브러리를 가져오는 편이 낫습니다. 규칙이 생각보다 많습니다.

왜 이렇게 나누었나

조각맡은 일
Entry거래 하나. 담기만 하고 아무것도 하지 않습니다
Summary셈한 결과. 남은 것은 두 값에서 나오므로 담지 않고 계산합니다
Ledger모으고, 검사하고, 셈하고, 파일을 다룹니다

SummaryBalance담지 않고 계산한 것을 보십시오. 담아 두면 수입과 지출을 고칠 때마다 함께 고쳐야 하고, 한 번 빠뜨리면 세 값이 서로 맞지 않는 상태가 됩니다. 다른 값에서 나오는 것은 담지 마십시오.

Ledger 가 파일까지 다루는 것은 이 크기에서는 괜찮습니다. 더 커지면 나눌 자리가 여기입니다. 셈하는 일과 파일을 다루는 일은 바뀌는 까닭이 다릅니다.

실습 문제

직접 해보기

1. 지난달과 비교하기 난이도 하

어떤 달의 지출이 지난달보다 얼마나 늘었는지 알려 주는 Diff 를 추가해보세요. 지난달 자료가 없으면 어떻게 할지도 정해야 합니다.

DateOnly 에는 AddMonths 가 있습니다. 지난달 자료가 없으면 Summary 가 0을 돌려주는데, 그것이 "지출이 0이었다" 와 같은 뜻인지 생각해보세요.
// 자료가 없는 달과 지출이 0인 달은 다릅니다. 그래서 물음표를 붙입니다. public int? Diff(DateOnly month) { var last = month.AddMonths(-1); // 지난달에 아무것도 없으면 비교할 것이 없습니다 if (!_entries.Any(e => e.Date.Year == last.Year && e.Date.Month == last.Month)) return null; return Summary(month).Expense - Summary(last).Expense; } // 사용하는 쪽 Console.WriteLine(book.Diff(new(2026, 9, 1)) switch { null => "지난달 자료가 없습니다", 0 => "지난달과 같습니다", > 0 and var n => $"{n:N0}원 늘었습니다", var n => $"{-n:N0}원 줄었습니다" }); // 9월은 35,000, 8월은 220,000 이므로 "185,000원 줄었습니다" // int 를 돌려주고 0 을 "자료 없음" 으로 쓰면 안 됩니다. 지출이 // 정말 같았을 때와 구분되지 않습니다.
2. 겹쳐 들어간 거래 찾기 난이도 중

같은 거래를 두 번 담은 것을 찾아 돌려주는 기능을 추가해보세요. 같은 거래란 무엇인지부터 정해야 합니다.

Entryrecord 이므로 값이 같으면 같은 것으로 비교됩니다. 다만 메모까지 같아야 같은 것인지는 정하기 나름입니다.
// 방법 1 — record 의 비교를 그대로 사용합니다. // 메모까지 같아야 겹친 것으로 봅니다. public IEnumerable<(Entry Entry, int Count)> Duplicates() => _entries .GroupBy(e => e) .Where(g => g.Count() > 1) .Select(g => (g.Key, g.Count())); // 방법 2 — 메모는 빼고 봅니다. 같은 날 같은 곳에 같은 금액이면 // 메모가 달라도 두 번 담은 것일 가능성이 큽니다. public IEnumerable<IGrouping<(DateOnly, Kind, string, int), Entry>> Duplicates2() => _entries .GroupBy(e => (e.Date, e.Kind, e.Category, e.Amount)) .Where(g => g.Count() > 1); // 어느 쪽이 맞는지는 코드가 정하지 못합니다. 쓰는 사람이 정할 일이고, // 방법 2 라면 "겹친 것 같습니다" 로 보여 주고 지우는 것은 사람이 // 고르게 하는 편이 안전합니다. // 4.5에서 본 함정을 조심하세요. Entry 에 배열이나 목록을 담으면 // GroupBy(e => e) 가 내용이 같아도 다른 것으로 봅니다.
요약
  • 담을 것부터 정합니다. 그 위에 셈하기와 파일이 얹힙니다.
  • 돈에 double 을 사용하지 않습니다. 정수이거나 decimal 입니다.
  • 입구를 하나로 두면 검사를 한 곳에만 적으면 됩니다.
  • 다른 값에서 나오는 것은 담지 않습니다. 담으면 서로 어긋납니다.
  • 파일에 적는 날짜와 숫자는 InvariantCulture 입니다.
  • CSV 는 값에 쉼표가 들어가는 순간 규칙이 필요해집니다.
  • 없는 것과 0은 다릅니다. 구분해야 하면 물음표를 붙이십시오.

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