가계부 만들기
수입과 지출을 담고 달마다 셈합니다.
배운 것을 묶습니다
5부는 새 문법을 배우는 자리가 아닙니다. 앞에서 하나씩 본 것을 묶어 돌아가는 프로그램을 만듭니다. 단원마다 따로 완결되므로 어느 것부터 보셔도 됩니다.
가계부가 하는 일은 넷입니다.
| 할 일 | 어디서 배운 것 |
|---|---|
| 거래 하나를 담기 | 4.5 record · 2.8 열거형 |
| 여러 건을 모으기 | 3.2 컬렉션 |
| 달마다·분류마다 셈하기 | 3.3 LINQ |
| 파일로 남기고 다시 읽기 | 3.8 파일 입출력 |
담는 것부터 정합니다. 무엇을 담을지가 정해지면 나머지는 그 위에 얹힙니다. 반대로 하면 담는 모양을 몇 번씩 고치게 됩니다.
돌아가는 가계부
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
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); }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
여든 줄이 되지 않습니다. 아래에서 이것을 한 조각씩 뜯어보고, 왜 그렇게 적었는지를 봅니다.
담을 것을 먼저
거래 하나에 필요한 것은 날짜·종류·분류·금액이고, 메모는 있을 수도 없을 수도 있습니다. 그대로 적으면 됩니다.
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')
_entries 를 public 으로
열어 두면 이 검사를 건너뛰는 길이 생깁니다. 밖에 내줄 때도
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.8의 File 로
한 줄에 한 건씩 적습니다. 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 | 모으고, 검사하고, 셈하고, 파일을 다룹니다 |
Summary 의 Balance 를
담지 않고 계산한 것을 보십시오. 담아 두면 수입과 지출을 고칠 때마다
함께 고쳐야 하고, 한 번 빠뜨리면 세 값이 서로 맞지 않는 상태가
됩니다. 다른 값에서 나오는 것은 담지 마십시오.
Ledger 가 파일까지 다루는 것은 이 크기에서는 괜찮습니다.
더 커지면 나눌 자리가 여기입니다. 셈하는 일과 파일을 다루는 일은
바뀌는 까닭이 다릅니다.
직접 해보기
어떤 달의 지출이 지난달보다 얼마나 늘었는지 알려 주는
Diff 를 추가해보세요. 지난달 자료가 없으면 어떻게 할지도
정해야 합니다.
같은 거래를 두 번 담은 것을 찾아 돌려주는 기능을 추가해보세요. 같은 거래란 무엇인지부터 정해야 합니다.
- 담을 것부터 정합니다. 그 위에 셈하기와 파일이 얹힙니다.
- 돈에
double을 사용하지 않습니다. 정수이거나decimal입니다. - 입구를 하나로 두면 검사를 한 곳에만 적으면 됩니다.
- 다른 값에서 나오는 것은 담지 않습니다. 담으면 서로 어긋납니다.
- 파일에 적는 날짜와 숫자는
InvariantCulture입니다. - CSV 는 값에 쉼표가 들어가는 순간 규칙이 필요해집니다.
- 없는 것과 0은 다릅니다. 구분해야 하면 물음표를 붙이십시오.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.