Skip to main content
Summary

마지막 단계는 작은 프로그램을 완성하는 일이다

문법을 많이 아는 것과 프로그램을 완성하는 것은 다릅니다. 프로그램을 완성하려면 요구사항을 정리하고, 코드를 나누고, 오류를 처리하고, 저장 기능을 붙이고, 나중에 고쳐도 망가지지 않았는지 확인해야 합니다.

이 장에서는 지금까지 배운 내용을 하나의 작은 프로젝트로 묶습니다. 만들 프로그램은 할 일 관리 콘솔 앱입니다.

요구사항은 단순하게 잡겠습니다.

  • 할 일을 추가한다.

  • 전체 할 일 목록을 본다.

  • 완료 처리한다.

  • 제목으로 검색한다.

  • 프로그램을 종료해도 파일에 저장된 데이터가 남는다.

  • 핵심 동작은 단위 테스트로 확인한다.

여기서 중요한 것은 기능의 크기가 아닙니다. 작게 만들고, 실행하고, 테스트하고, 개선하는 흐름입니다.

테스트, 설계 원칙, 작은 프로젝트 완성하기AI 생성 이미지

프로젝트 준비하기

솔루션 하나에 실행 프로젝트와 테스트 프로젝트를 함께 둡니다.

dotnet new sln -n TodoConsole
dotnet new console -n TodoApp
dotnet new xunit -n TodoApp.Tests

dotnet sln TodoConsole.sln add TodoApp/TodoApp.csproj
dotnet sln TodoConsole.sln add TodoApp.Tests/TodoApp.Tests.csproj

dotnet add TodoApp.Tests/TodoApp.Tests.csproj reference TodoApp/TodoApp.csproj

구조는 다음처럼 생각하면 됩니다.

TodoConsole/
  TodoApp/
    Program.cs
    TodoItem.cs
    ITodoRepository.cs
    JsonTodoRepository.cs
    TodoService.cs
  TodoApp.Tests/
    TodoServiceTests.cs

테스트 프로젝트는 실행 프로그램을 참조합니다. 그래서 TodoApp 안의 핵심 클래스는 테스트할 수 있도록 public으로 만듭니다.

단일 책임 원칙으로 역할 나누기

단일 책임 원칙은 어렵게 들리지만 핵심은 간단합니다. 하나의 클래스가 너무 많은 일을 하지 않게 하자는 뜻입니다.

나쁜 구조는 이런 모습입니다.

// Program.cs 안에서 입력, 출력, 저장, 검색, 완료 처리까지 모두 처리

처음에는 빠르게 만들 수 있지만 기능이 늘어나면 수정하기 어려워집니다. 그래서 역할을 나눕니다.

  • TodoItem: 할 일 데이터

  • TodoService: 추가, 완료, 검색 같은 핵심 규칙

  • ITodoRepository: 저장소가 지켜야 할 약속

  • JsonTodoRepository: JSON 파일 저장과 읽기

  • Program: 메뉴 출력과 사용자 입력

먼저 모델을 만듭니다.

namespace TodoApp;

public record TodoItem(
    int Id,
    string Title,
    bool IsDone,
    DateTime CreatedAt
);

record는 값 중심 데이터를 표현하기 좋습니다. 할 일은 번호, 제목, 완료 여부, 생성 시간이 핵심 값이므로 record로 만들기 적합합니다.

의존성 역전으로 저장 방식을 분리하기

의존성 역전 원칙은 중요한 규칙 코드가 구체적인 저장 방식에 너무 강하게 묶이지 않게 하자는 생각입니다.

TodoService가 File.WriteAllText를 직접 호출하면 나중에 저장 방식을 바꾸기 어렵습니다. 테스트할 때도 실제 파일을 만들어야 하므로 불편합니다.

그래서 먼저 저장소 인터페이스를 만듭니다.

namespace TodoApp;

public interface ITodoRepository
{
    List<TodoItem> Load();
    void Save(List<TodoItem> items);
}

인터페이스는 "무엇을 할 수 있는가"를 정합니다. 실제로 JSON 파일에 저장할지, 메모리에만 저장할지, 데이터베이스에 저장할지는 구현 클래스가 결정합니다.

JSON 파일 저장소는 다음처럼 작성할 수 있습니다.

using System.Text.Json;

namespace TodoApp;

public class JsonTodoRepository : ITodoRepository
{
    private readonly string _path;

    public JsonTodoRepository(string path)
    {
        _path = path;
    }

    public List<TodoItem> Load()
    {
        if (!File.Exists(_path))
        {
            return new List<TodoItem>();
        }

        string json = File.ReadAllText(_path);

        if (string.IsNullOrWhiteSpace(json))
        {
            return new List<TodoItem>();
        }

        return JsonSerializer.Deserialize<List<TodoItem>>(json)
            ?? new List<TodoItem>();
    }

    public void Save(List<TodoItem> items)
    {
        string? folder = Path.GetDirectoryName(_path);

        if (!string.IsNullOrWhiteSpace(folder))
        {
            Directory.CreateDirectory(folder);
        }

        var options = new JsonSerializerOptions
        {
            WriteIndented = true
        };

        string json = JsonSerializer.Serialize(items, options);
        File.WriteAllText(_path, json);
    }
}

파일이 없으면 빈 목록을 반환합니다. 파일이 비어 있거나 JSON을 읽은 결과가 null이어도 역시 빈 목록을 반환합니다. 외부 데이터는 항상 예상과 다를 수 있으므로 안전한 기본값을 준비하는 습관이 좋습니다. 다만 JSON 형식 자체가 깨진 경우까지 복구하는 코드는 여기서는 생략했습니다. 실제 프로그램에서는 그런 경우에도 사용자에게 안내할 방법을 따로 마련하는 편이 좋습니다.

핵심 규칙을 서비스로 모으기

이제 할 일 추가, 완료, 검색을 담당하는 TodoService를 만듭니다.

namespace TodoApp;

public class TodoService
{
    private readonly ITodoRepository _repository;
    private readonly List<TodoItem> _items;

    public TodoService(ITodoRepository repository)
    {
        _repository = repository;
        _items = _repository.Load();
    }

    public IReadOnlyList<TodoItem> GetAll()
    {
        return _items;
    }

    public TodoItem Add(string title)
    {
        if (string.IsNullOrWhiteSpace(title))
        {
            throw new ArgumentException("할 일 제목은 비어 있을 수 없습니다.");
        }

        int nextId = _items.Count == 0 ? 1 : _items.Max(item => item.Id) + 1;

        var item = new TodoItem(
            nextId,
            title.Trim(),
            false,
            DateTime.Now
        );

        _items.Add(item);
        _repository.Save(_items);
        return item;
    }

    public bool MarkDone(int id)
    {
        int index = _items.FindIndex(item => item.Id == id);

        if (index == -1)
        {
            return false;
        }

        TodoItem current = _items[index];
        _items[index] = current with { IsDone = true };
        _repository.Save(_items);
        return true;
    }

    public List<TodoItem> Search(string keyword)
    {
        if (string.IsNullOrWhiteSpace(keyword))
        {
            return new List<TodoItem>();
        }

        return _items
            .Where(item => item.Title.Contains(keyword, StringComparison.OrdinalIgnoreCase))
            .OrderBy(item => item.Id)
            .ToList();
    }
}

여기에는 여러 장에서 배운 내용이 들어 있습니다.

  • 예외 처리: 제목이 비어 있으면 ArgumentException을 던집니다.

  • 컬렉션: List<TodoItem>으로 할 일 목록을 관리합니다.

  • LINQ: Max, Where, OrderBy, ToList를 사용합니다.

  • 현대 C# 문법: record와 with로 완료 상태를 바꾼 새 값을 만듭니다.

  • 설계 원칙: 저장 방식은 ITodoRepository 뒤로 숨깁니다.

콘솔 메뉴 붙이기

Program.cs는 사용자와 대화하는 역할만 맡깁니다.

using System.Text.Json;
using TodoApp;

var repository = new JsonTodoRepository("data/todos.json");
TodoService service;

try
{
    service = new TodoService(repository);
}
catch (IOException)
{
    Console.WriteLine("파일을 읽는 중 문제가 발생했습니다.");
    return;
}
catch (JsonException)
{
    Console.WriteLine("저장된 할 일 파일의 JSON 형식이 올바르지 않습니다.");
    return;
}

while (true)
{
    Console.WriteLine();
    Console.WriteLine("1. 목록 보기");
    Console.WriteLine("2. 할 일 추가");
    Console.WriteLine("3. 완료 처리");
    Console.WriteLine("4. 검색");
    Console.WriteLine("0. 종료");
    Console.Write("선택: ");

    string? input = Console.ReadLine();

    try
    {
        if (input == "1")
        {
            PrintItems(service.GetAll());
        }
        else if (input == "2")
        {
            Console.Write("제목: ");
            string title = Console.ReadLine() ?? "";
            TodoItem item = service.Add(title);
            Console.WriteLine($"추가됨: {item.Id}. {item.Title}");
        }
        else if (input == "3")
        {
            Console.Write("완료할 번호: ");
            bool parsed = int.TryParse(Console.ReadLine(), out int id);

            if (!parsed)
            {
                Console.WriteLine("번호를 입력하세요.");
                continue;
            }

            bool changed = service.MarkDone(id);
            Console.WriteLine(changed ? "완료 처리했습니다." : "해당 번호가 없습니다.");
        }
        else if (input == "4")
        {
            Console.Write("검색어: ");
            string keyword = Console.ReadLine() ?? "";
            PrintItems(service.Search(keyword));
        }
        else if (input == "0")
        {
            break;
        }
        else
        {
            Console.WriteLine("알 수 없는 메뉴입니다.");
        }
    }
    catch (ArgumentException ex)
    {
        Console.WriteLine($"입력 오류: {ex.Message}");
    }
    catch (IOException)
    {
        Console.WriteLine("파일을 쓰는 중 문제가 발생했습니다.");
    }
}

static void PrintItems(IEnumerable<TodoItem> items)
{
    foreach (TodoItem item in items)
    {
        string mark = item.IsDone ? "[x]" : "[ ]";
        Console.WriteLine($"{mark} {item.Id}. {item.Title}");
    }
}

실행하면 다음처럼 사용할 수 있습니다.

1. 목록 보기
2. 할 일 추가
3. 완료 처리
4. 검색
0. 종료
선택: 2
제목: C# 복습하기
추가됨: 1. C# 복습하기

선택: 1
[ ] 1. C# 복습하기

메뉴 코드는 여전히 길어질 수 있습니다. 실제 프로젝트라면 메뉴 처리도 별도 클래스로 나눌 수 있습니다. 다만 지금 단계에서는 Program이 입력과 출력만 담당하고, 핵심 규칙은 TodoService에 있다는 점이 중요합니다.

단위 테스트 작성하기

단위 테스트는 프로그램의 작은 기능이 예상대로 동작하는지 자동으로 확인하는 코드입니다. 여기서는 xUnit을 사용합니다. MSTest를 사용해도 원리는 같습니다.

테스트에서는 실제 JSON 파일을 쓰지 않겠습니다. 대신 메모리에만 저장하는 가짜 저장소를 만듭니다.

using TodoApp;

public class FakeTodoRepository : ITodoRepository
{
    public List<TodoItem> Items { get; set; } = new List<TodoItem>();

    public List<TodoItem> Load()
    {
        return Items;
    }

    public void Save(List<TodoItem> items)
    {
        Items = items;
    }
}

이제 TodoServiceTests.cs에 테스트를 작성합니다.

using TodoApp;
using Xunit;

public class TodoServiceTests
{
    [Fact]
    public void Add_정상적인_제목이면_할일을_추가한다()
    {
        var repository = new FakeTodoRepository();
        var service = new TodoService(repository);

        TodoItem item = service.Add("테스트 작성하기");

        Assert.Equal(1, item.Id);
        Assert.Equal("테스트 작성하기", item.Title);
        Assert.False(item.IsDone);
        Assert.Single(service.GetAll());
    }

    [Fact]
    public void Add_빈_제목이면_예외가_발생한다()
    {
        var repository = new FakeTodoRepository();
        var service = new TodoService(repository);

        Assert.Throws<ArgumentException>(() => service.Add("   "));
    }

    [Fact]
    public void MarkDone_존재하는_번호이면_완료된다()
    {
        var repository = new FakeTodoRepository();
        var service = new TodoService(repository);
        TodoItem item = service.Add("LINQ 복습");

        bool result = service.MarkDone(item.Id);

        Assert.True(result);
        Assert.True(service.GetAll()[0].IsDone);
    }

    [Fact]
    public void Search_제목에_검색어가_포함된_항목을_찾는다()
    {
        var repository = new FakeTodoRepository();
        var service = new TodoService(repository);
        service.Add("C# 문법 정리");
        service.Add("운동하기");

        List<TodoItem> result = service.Search("c#");

        Assert.Single(result);
        Assert.Equal("C# 문법 정리", result[0].Title);
    }
}

테스트를 실행합니다.

dotnet test

예상 결과는 다음과 비슷합니다.

Passed!  - Failed: 0, Passed: 4, Skipped: 0

테스트의 목적은 모든 코드를 검사하는 것이 아닙니다. 먼저 핵심 규칙을 검사하면 됩니다. 이 프로젝트에서는 추가, 빈 제목 검증, 완료 처리, 검색이 핵심입니다.

흔한 실수

첫째, 모든 코드를 Program.cs에 넣는 실수입니다. 작은 예제에서는 괜찮아 보이지만 테스트하기 어렵습니다. 입력과 출력은 Program, 규칙은 TodoService, 저장은 Repository로 나누는 편이 좋습니다.

둘째, Console.ReadLine()이 들어간 코드를 바로 테스트하려는 실수입니다. 콘솔 입력은 자동 테스트와 잘 맞지 않습니다. 테스트하고 싶은 계산과 판단 로직을 메서드나 클래스로 분리하세요.

셋째, 단위 테스트에서 실제 파일에 계속 저장하는 실수입니다. 파일 시스템은 느리고 환경에 따라 실패할 수 있습니다. 핵심 규칙 테스트에는 가짜 저장소를 쓰고, 파일 저장 자체는 별도의 작은 테스트나 수동 확인으로 다루는 편이 단순합니다.

넷째, 모든 예외를 catch (Exception)으로 잡고 아무 메시지도 남기지 않는 실수입니다. 오류를 숨기면 프로그램은 조용히 잘못됩니다. 사용자가 이해할 수 있는 메시지를 출력하되, 개발 중에는 원인을 확인할 수 있어야 합니다.

다섯째, 테스트가 없는 상태에서 구조를 크게 바꾸는 실수입니다. 리팩터링은 기능을 바꾸지 않고 내부 구조를 개선하는 작업입니다. 테스트가 있으면 바꾼 뒤에도 기존 동작이 유지되는지 빠르게 확인할 수 있습니다.

Git과 README로 프로젝트 정리하기

코드를 완성했다면 Git으로 변경 이력을 관리합니다.

git init
dotnet new gitignore
git add .
git commit -m "Create todo console app"

기능을 추가할 때마다 작게 커밋하는 습관을 들이세요.

git add .
git commit -m "Add todo search feature"

README에는 거창한 설명보다 실행에 필요한 정보를 적으면 됩니다.

# Todo Console App

C#으로 만든 간단한 할 일 관리 콘솔 앱입니다.

## 기능

- 할 일 추가
- 목록 보기
- 완료 처리
- 제목 검색
- JSON 파일 저장

## 실행 방법

```bash
dotnet run --project TodoApp
```

## 테스트 실행

```bash
dotnet test
```

README는 미래의 자신과 다른 개발자를 위한 안내문입니다. 실행 방법, 테스트 방법, 주요 기능만 있어도 충분히 도움이 됩니다.

작은 연습 문제

  1. 완료된 할 일만 보는 메뉴를 추가해 보세요.

  2. TodoService에 삭제 기능을 추가하고 테스트도 작성해 보세요.

  3. 검색 결과를 생성일 기준 내림차순으로 정렬해 보세요.

  4. 파일 저장 중 예외가 발생했을 때 사용자에게 어떤 메시지를 보여 줄지 개선해 보세요.

  5. README에 프로젝트 구조 설명을 추가해 보세요.

마무리

C#을 배운다는 것은 문법을 외우는 일이 아닙니다. 값을 저장하고, 조건을 판단하고, 반복하고, 객체로 나누고, 파일에 저장하고, 비동기로 기다리고, 테스트로 확인하는 경험을 쌓는 일입니다.

처음부터 완벽한 구조를 만들 필요는 없습니다. 작게 만들고 실행하세요. 중복이 보이면 메서드로 나누고, 역할이 섞이면 클래스로 나누고, 바꾸기 어려우면 인터페이스를 생각하세요. 그리고 중요한 동작에는 테스트를 붙이세요.

이 과정을 반복하면 C# 문법은 따로 떨어진 지식이 아니라 실제 프로그램을 완성하는 도구가 됩니다.

책 목록

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

전체 목차 보기

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

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

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

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

참여하기