종합 실습 - 카페 주문 관리 콘솔 앱
이 장에서 배우는 것
동네 카페에서 판매 수량을 메모하고 하루 매출을 계산하는 일은 간단해 보인다. 그러나 주문을 받으면서 재고를 줄이고, 프로그램을 종료한 뒤에도 주문 내역을 유지하려면 여러 기능이 같은 규칙을 따라야 한다. 주문 목록에는 두 잔이 기록되었는데 재고는 한 잔만 줄어드는 식의 어긋남을 막아야 한다.
이 장에서는 앞서 익힌 문법을 묶어 작은 주문 관리 프로그램을 완성한다. 판매 품목은 미리 준비한 병입 커피와 쿠키다. 재고 한 개를 판매 한 개로 계산하므로 원재료 배합 문제 없이 주문 처리의 흐름에 집중할 수 있다. 실행할 때마다 같은 주문을 처리하는 시연 방식으로 만들고, 입력 메뉴를 붙이는 작업은 연습 문제로 남긴다.
- 주문을 기록하는 동작과 재고를 차감하는 동작을 하나의 메서드로 묶는다.
- 주문 당시의 단가를 보관하고 주문 내역에서 품목별 매출을 계산한다.
- JSON으로 상태를 저장하고 비동기로 다시 읽어 같은 집계를 얻는다.
- xUnit 테스트 프로젝트에서 정상 주문과 실패한 주문의 결과를 검증한다.
문제 상황
카페는 영업을 시작하며 병입 커피 여덟 개와 쿠키 여섯 개를 준비했다. 병입 커피는 3,500원이고 쿠키는 2,000원이다. 첫 손님은 커피 두 개를, 다음 손님은 쿠키 세 개를 주문했다. 이어서 쿠키 네 개를 주문받으려 했지만 남은 쿠키는 세 개뿐이다. 세 번째 요청은 거절해야 하며, 실패한 요청 때문에 주문 번호가 소비되거나 매출이 늘어나서는 안 된다.
기존에는 재고표와 매출표를 따로 수정했다. 주문을 취소하면서 매출표만 지우거나, 프로그램을 다시 실행하면서 재고표를 초기화하는 일이 생겼다. 새 프로그램은 주문과 재고를 함께 저장하고, 매출표는 주문 내역으로부터 만든다. 같은 사실을 여러 곳에서 따로 고치는 작업을 줄이는 것이다.
이번 완성 코드는 실행을 재현하기 위해 매번 새 영업 상태를 만든다. 정해진 주문을 처리한 다음 전용 시연 파일을 덮어쓰고, 그 파일을 다시 읽어 결과를 출력한다. 기존 파일을 이어서 사용하는 실제 영업 모드와는 시작 방식이 다르다. 시연 파일 이름은 cafe-demo.json이며 실제 영업 자료를 이 이름으로 보관하지 않는다.
| 항목 | 규칙 | 시연 결과 |
|---|---|---|
| 주문 단위 | 한 주문에 한 품목을 담는다 | 커피 주문과 쿠키 주문을 따로 기록한다 |
| 판매 가능 수량 | 양수이며 남은 재고 이하여야 한다 | 쿠키 네 개 요청은 거절한다 |
| 매출 기준 | 수량과 주문 당시 단가를 곱한다 | 커피 7,000원, 쿠키 6,000원이다 |
| 저장 범위 | 현재 재고와 성공한 주문을 저장한다 | 실패한 요청은 파일에 기록하지 않는다 |
주문과 재고를 함께 바꾸는 경계
주문 처리 메서드는 먼저 품목 코드, 수량, 재고를 확인한다. 이 검사가 모두 끝난 뒤 주문을 추가하고 재고를 차감한다. 수량이 잘못되었거나 재고가 부족한 경우에는 상태를 바꾸기 전에 예외가 발생한다. 호출하는 쪽은 두 작업을 따로 호출할 필요가 없다.
이런 설계에서 중요한 것은 메서드 이름보다 상태를 변경할 수 있는 통로다. 주문 목록과 재고 사전을 외부에 그대로 공개하면 호출자가 목록에만 주문을 추가할 수 있다. 완성 코드의 Cafe는 두 자료를 비공개로 보관하고, 주문 처리 메서드와 조회 메서드만 공개한다. 저장용 객체도 읽어 들인 뒤 내부 자료로 복사한다.
한 번의 호출에서 순서대로 실행된다고 해서 데이터베이스의 트랜잭션(transaction)과 같은 보장을 얻는 것은 아니다. 여기서는 한 실행 흐름이 요청을 순차 처리한다. 여러 계산대가 동시에 같은 재고를 판매하거나, 상태를 변경하는 도중 프로세스가 종료되는 경우까지 해결하지는 않는다. 현재 코드가 보장하는 범위는 입력 검사에 실패한 주문이 주문 목록과 재고를 변경하지 않는다는 것이다.
금액에는 decimal을 사용한다. 주문에는 단가를 함께 기록한다. 나중에 메뉴 가격을 바꾸더라도 과거 매출은 그 주문이 발생했을 때의 단가로 계산해야 하기 때문이다. 메뉴에서 현재 가격을 다시 찾아 과거 주문에 곱하면 가격 변경이 이전 매출까지 바꾸게 된다.
주문 번호는 성공한 주문 개수에 1을 더해 만든다. 이 방식은 주문을 삭제하지 않는 이번 프로그램에 맞는다. 주문 취소를 추가한다면 기록을 지우기보다 취소 여부를 별도로 남기거나 번호 생성 규칙을 바꾸어야 한다. 번호가 어떤 조건에서 유일한지 설명할 수 있어야 자료 구조도 그 조건에 맞게 유지할 수 있다.
저장할 자료와 다시 계산할 자료
저장 파일에는 재고와 주문을 넣고 매출 합계는 넣지 않는다. 매출은 주문 목록에서 다시 계산할 수 있기 때문이다. 합계까지 따로 저장하면 주문은 수정했는데 합계는 고치지 않는 문제가 생길 수 있다. 재고는 이번 설계에서 현재 상태로 보관하며, 입고 내역이나 폐기 내역을 추적하는 기능은 포함하지 않는다.
직렬화(serialization)는 객체의 자료를 저장 가능한 표현으로 바꾸는 작업이다. JSON은 이 표현을 사람이 읽기에도 비교적 수월한 형식으로 만든다. 반대로 역직렬화는 파일의 표현을 객체로 읽어 들이는 작업이다. System.Text.Json을 사용하면 속성 이름과 값을 직접 문자열로 이어 붙일 필요가 없다.
JSON을 객체로 읽었다는 사실만으로 카페 규칙까지 검증되지는 않는다. 예를 들어 재고가 음수인 파일도 JSON 문법 자체는 올바를 수 있다. 완성 코드는 루트 자료의 존재, 필요한 품목, 재고의 범위, 주문 번호의 순서와 주문 값의 범위를 검사한다. 저장 형식에 맞지 않는 상태가 내부로 들어오는 일을 줄이기 위한 검사다.
SaveAsync와 LoadAsync는 파일 작업이 끝날 때까지 await로 기다린다. 앞 장에서 살펴본 비동기 흐름을 그대로 사용한 것이다. 저장을 시작한 직후 파일을 다시 읽으면 아직 쓰는 중인 자료를 읽을 수 있으므로, 저장 호출의 완료를 기다린 다음 복원을 시작한다. 비동기 메서드를 사용한다고 주문 자료를 동시에 수정해도 된다는 뜻은 아니다.
파일은 현재 작업 디렉터리를 기준으로 만든다. 예제에서는 경로를 출력하지 않는다. 실행 위치에 따라 달라지는 정보를 출력에서 제외하고, 품목 순서를 명시하며, 숫자 형식을 고정해서 같은 실행 결과를 얻는다. 이런 결정성은 결과를 비교하거나 테스트가 실패한 원인을 찾을 때 도움이 된다.
xUnit으로 업무 규칙 확인하기
화면에서 총매출 13,000원을 확인하는 것과 실패한 주문이 재고를 보존하는지 검증하는 것은 다른 작업이다. 화면 확인은 프로그램 전체를 이해하는 데 유용하지만, 코드가 바뀔 때마다 사람이 모든 조건을 반복해서 확인하기는 어렵다. 테스트는 기대하는 규칙을 실행 가능한 코드로 남긴다.
xUnit에서는 Fact 특성을 붙인 메서드를 개별 테스트로 실행할 수 있다. 테스트는 준비, 실행, 검증의 순서로 작성한다. 새 카페를 준비하고 주문을 넣은 뒤 재고와 매출이 기대값과 같은지 비교한다. Assert.Equal은 두 값이 같은지 확인하고, Assert.Throws는 지정한 예외가 발생하는지 확인한다.
여기서는 테스트마다 새 Cafe를 만든다. 이전 테스트에서 사용한 재고나 주문을 공유하지 않으므로 테스트 실행 순서에 기대지 않는다. 콘솔 출력 문자열을 비교하는 대신 주문 처리 결과를 검사한다. 안내 문구를 조금 고쳤다고 업무 규칙 테스트까지 실패하도록 만들 필요는 없다.
테스트 프로젝트는 앱 프로젝트를 참조한다. 이 참조는 Cafe 같은 공개 타입을 테스트에서 사용할 수 있게 하지만, 참조만으로 앱의 최상위 문을 실행하지는 않는다. 테스트가 Cafe를 생성하더라도 시연 주문을 자동으로 처리하거나 JSON 파일을 생성하지 않는다.
| 확인 방법 | 대상 | 확인하는 규칙 |
|---|---|---|
| 정상 주문 테스트 | Cafe.PlaceOrder | 주문 수량만큼 재고가 줄고 금액이 집계된다 |
| 재고 부족 테스트 | 실패 전후 상태 | 거절된 요청이 주문과 재고를 변경하지 않는다 |
| 수량 검사 테스트 | 0개 주문 | 양수가 아닌 수량은 주문으로 기록되지 않는다 |
| 콘솔 시연 | 저장과 복원 흐름 | 복원한 자료로 기대한 집계를 출력한다 |
이 테스트만으로 파일 장애나 모든 잘못된 JSON을 검증했다고 볼 수는 없다. 우선 상태 변경 규칙을 확인하고, 저장과 복원을 자동 검증하는 테스트는 연습 문제에서 추가한다. 사실 확인이 필요하면 System.Text.Json 안내와 xUnit 안내를 참고할 수 있다.
완성 코드
.NET 10 SDK가 설치된 macOS 또는 Linux에서 다음 명령으로 두 프로젝트를 만든다. 테스트 프로젝트를 만드는 명령은 필요한 테스트 패키지도 설정한다. 패키지를 처음 복원할 때는 네트워크 연결이 필요하다. 프로젝트를 만든 뒤 아래 내용으로 앱의 Program.cs를 교체하고, 기본 테스트 파일을 지운 자리에 CafeTests.cs를 추가한다.
mkdir cafe-final
cd cafe-final
dotnet new console --name Cafe.App --framework net10.0
dotnet new xunit --name Cafe.Tests --framework net10.0
dotnet add Cafe.Tests/Cafe.Tests.csproj reference Cafe.App/Cafe.App.csproj
rm Cafe.Tests/UnitTest1.cs
앱의 완성 코드는 Program.cs 한 파일이다. 테스트 파일은 별도 프로젝트에 둔다. 앱 폴더 안에 테스트 프로젝트를 만들면 앱이 테스트 소스까지 수집할 수 있으므로 두 폴더를 나란히 배치한다.
Cafe.App/Program.cs
using System.Globalization;
using System.Text.Json;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var cafe = new Cafe();
cafe.PlaceOrder("COFFEE", 2);
cafe.PlaceOrder("COOKIE", 3);
try
{
cafe.PlaceOrder("COOKIE", 4);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"주문 거절: {ex.Message}");
}
const string path = "cafe-demo.json";
await cafe.SaveAsync(path);
var restored = await Cafe.LoadAsync(path);
Console.WriteLine("저장 및 복원 완료");
Console.WriteLine($"주문 수: {restored.OrderCount}");
Console.WriteLine("[재고]");
foreach (string code in Cafe.ProductCodes)
{
Console.WriteLine(
$"{Cafe.GetName(code)}: {restored.GetStock(code)}개");
}
Console.WriteLine("[매출]");
foreach (SalesRow row in restored.GetSales())
{
Console.WriteLine(
$"{Cafe.GetName(row.Code)}: {row.Quantity}개 / {row.Amount:0}원");
}
Console.WriteLine($"총매출: {restored.TotalSales:0}원");
public sealed record Order(
int Number, string Code, int Quantity, decimal UnitPrice);
public sealed record SalesRow(
string Code, int Quantity, decimal Amount);
public sealed class CafeData
{
public Dictionary<string, int> Stock { get; set; } = new();
public List<Order> Orders { get; set; } = new();
}
public sealed class Cafe
{
private readonly Dictionary<string, int> stock;
private readonly List<Order> orders;
public static IEnumerable<string> ProductCodes
=> new[] { "COFFEE", "COOKIE" };
public Cafe()
{
stock = new Dictionary<string, int>(StringComparer.Ordinal)
{
["COFFEE"] = 8,
["COOKIE"] = 6
};
orders = new List<Order>();
}
private Cafe(CafeData data)
{
Validate(data);
stock = new Dictionary<string, int>(
data.Stock, StringComparer.Ordinal);
orders = new List<Order>(data.Orders);
}
public int OrderCount => orders.Count;
public decimal TotalSales
=> orders.Sum(order => order.Quantity * order.UnitPrice);
public static string GetName(string code) => code switch
{
"COFFEE" => "병입 커피",
"COOKIE" => "쿠키",
_ => throw new ArgumentException("알 수 없는 품목이다.", nameof(code))
};
private static decimal GetPrice(string code) => code switch
{
"COFFEE" => 3500m,
"COOKIE" => 2000m,
_ => throw new ArgumentException("알 수 없는 품목이다.", nameof(code))
};
public int GetStock(string code)
{
_ = GetName(code);
return stock[code];
}
public Order PlaceOrder(string code, int quantity)
{
decimal price = GetPrice(code);
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(quantity), "수량은 양수여야 한다.");
}
if (quantity > stock[code])
{
throw new InvalidOperationException("재고가 부족하다.");
}
var order = new Order(
checked(orders.Count + 1), code, quantity, price);
orders.Add(order);
stock[code] -= quantity;
return order;
}
public SalesRow[] GetSales()
{
return orders
.GroupBy(order => order.Code)
.OrderBy(group => group.Key, StringComparer.Ordinal)
.Select(group => new SalesRow(
group.Key,
group.Sum(order => order.Quantity),
group.Sum(order => order.Quantity * order.UnitPrice)))
.ToArray();
}
public async Task SaveAsync(string path)
{
var data = new CafeData
{
Stock = new Dictionary<string, int>(
stock, StringComparer.Ordinal),
Orders = new List<Order>(orders)
};
await using FileStream stream = File.Create(path);
await JsonSerializer.SerializeAsync(
stream,
data,
new JsonSerializerOptions { WriteIndented = true });
}
public static async Task<Cafe> LoadAsync(string path)
{
await using FileStream stream = File.OpenRead(path);
CafeData data =
await JsonSerializer.DeserializeAsync<CafeData>(stream)
?? throw new InvalidDataException("저장 자료가 없다.");
return new Cafe(data);
}
private static void Validate(CafeData data)
{
if (data.Stock is null || data.Orders is null)
{
throw new InvalidDataException("저장 항목이 비어 있다.");
}
if (data.Stock.Count != 2)
{
throw new InvalidDataException("재고 품목 구성이 다르다.");
}
foreach (string code in ProductCodes)
{
if (!data.Stock.TryGetValue(code, out int count) || count < 0)
{
throw new InvalidDataException("재고 값이 올바르지 않다.");
}
}
for (int i = 0; i < data.Orders.Count; i++)
{
Order order = data.Orders[i];
if (order is null ||
order.Number != i + 1 ||
(order.Code != "COFFEE" && order.Code != "COOKIE") ||
order.Quantity <= 0 ||
order.UnitPrice <= 0m)
{
throw new InvalidDataException("주문 값이 올바르지 않다.");
}
}
}
}
Cafe.Tests/CafeTests.cs
using System;
using Xunit;
public sealed class CafeTests
{
[Fact]
public void SuccessfulOrderReducesStockAndAddsSales()
{
var cafe = new Cafe();
Order order = cafe.PlaceOrder("COFFEE", 2);
Assert.Equal(1, order.Number);
Assert.Equal(3500m, order.UnitPrice);
Assert.Equal(6, cafe.GetStock("COFFEE"));
Assert.Equal(1, cafe.OrderCount);
Assert.Equal(7000m, cafe.TotalSales);
SalesRow row = Assert.Single(cafe.GetSales());
Assert.Equal("COFFEE", row.Code);
Assert.Equal(2, row.Quantity);
Assert.Equal(7000m, row.Amount);
}
[Fact]
public void RejectedOrderPreservesStateAndNextNumber()
{
var cafe = new Cafe();
cafe.PlaceOrder("COOKIE", 3);
Assert.Throws<InvalidOperationException>(() =>
{
cafe.PlaceOrder("COOKIE", 4);
});
Assert.Equal(3, cafe.GetStock("COOKIE"));
Assert.Equal(1, cafe.OrderCount);
Assert.Equal(6000m, cafe.TotalSales);
Order next = cafe.PlaceOrder("COOKIE", 1);
Assert.Equal(2, next.Number);
}
[Fact]
public void ZeroQuantityDoesNotCreateOrder()
{
var cafe = new Cafe();
Assert.Throws<ArgumentOutOfRangeException>(() =>
{
cafe.PlaceOrder("COFFEE", 0);
});
Assert.Equal(8, cafe.GetStock("COFFEE"));
Assert.Equal(0, cafe.OrderCount);
Assert.Equal(0m, cafe.TotalSales);
}
}
줄별 해설
첫 두 using 문은 숫자 형식과 JSON 처리에 사용할 이름을 가져온다. 콘솔 템플릿이 설정한 암시적 using 덕분에 컬렉션, 파일, LINQ, 작업 관련 이름을 별도로 모두 나열하지 않아도 된다. CurrentCulture를 고정하는 줄은 실행하는 컴퓨터의 지역 설정이 숫자 표시에 영향을 주지 않게 한다.
new Cafe()는 시작 재고와 빈 주문 목록을 만든다. 이어지는 두 PlaceOrder 호출은 성공한 주문 두 건을 만든다. 세 번째 호출은 쿠키 재고보다 많은 수량을 요구한다. 이곳의 catch는 재고 부족 예외만 처리하며, 거절 메시지를 출력한 뒤 저장 단계로 진행한다.
SaveAsync를 기다린 다음 LoadAsync를 호출하므로 출력에 사용하는 restored는 파일에서 복원한 객체다. 메모리의 원래 cafe를 그대로 출력했다면 저장 파일에 필요한 속성이 누락되어도 눈치채기 어려웠을 것이다. 복원한 객체를 사용하는 구성은 저장 흐름을 시연 결과에 연결한다.
재고 출력은 ProductCodes가 돌려주는 순서를 따른다. 사전의 열거 순서에 출력 규칙을 맡기지 않은 것이다. 매출 출력은 GetSales에서 품목 코드를 정렬한 순서를 따른다. 두 품목만 있는 지금도 순서를 명시해 두면 품목이 늘어났을 때 결과의 기준이 분명하다.
Order는 주문 번호, 품목 코드, 수량, 단가를 보관하는 record다. SalesRow는 집계 결과 한 줄을 표현한다. 둘은 역할이 다르다. Order는 저장해야 하는 거래 사실이고, SalesRow는 주문 목록으로부터 언제든 다시 만들 수 있는 결과다. CafeData는 JSON에 담을 두 컬렉션을 한 객체로 묶는다.
Cafe의 두 필드는 readonly이지만 컬렉션 안의 내용은 바뀔 수 있다. readonly는 필드가 다른 컬렉션을 가리키도록 다시 대입하는 일을 제한한다. 재고를 줄이거나 주문을 추가하는 동작까지 금지하지 않는다. 자료를 외부에 공개하지 않는 설계와 함께 읽어야 한다.
복원용 생성자는 Validate를 호출한 뒤 사전과 목록을 새로 만든다. 전달된 저장용 컬렉션을 그대로 필드에 넣지 않으므로 나중에 저장용 객체의 목록을 바꾸어도 카페 내부 목록은 바뀌지 않는다. 목록 안의 Order는 일반적인 사용에서 속성을 변경하지 않는 record이므로 여기서는 원소까지 다시 생성하지 않는다.
PlaceOrder의 GetPrice 호출은 품목이 알려진 코드인지 먼저 확인한다. 그다음 수량과 재고를 검사한다. checked는 주문 번호 덧셈이 정수 범위를 넘을 때 잘못된 번호로 돌아가지 않게 한다. 성공한 경우에만 목록에 추가하므로 재고 부족 요청은 번호 계산에 영향을 주지 않는다.
GetSales는 품목별로 주문을 묶고, 코드순으로 정렬하고, 수량과 금액을 각각 더한다. 마지막 ToArray는 호출 시점의 집계 결과를 배열로 확정한다. 반환된 배열을 호출자가 수정하더라도 내부 주문 목록은 바뀌지 않는다. 주문이 없는 품목은 이 집계에 나타나지 않으며, 재고 목록에는 계속 표시된다.
SaveAsync의 File.Create는 대상 파일을 새로 만들거나 기존 내용을 비운다. 따라서 이 메서드는 누적 기록을 덧붙이는 방식이 아니다. await using은 메서드가 끝날 때 파일 스트림을 비동기로 정리한다. 메서드가 반환된 뒤에야 다음 복원 단계가 시작된다.
LoadAsync에서 ?? 뒤의 예외는 파일 내용이 JSON의 null인 경우를 처리한다. JSON 문법 오류는 역직렬화 과정에서 별도 예외가 된다. Validate는 알려진 품목과 기본 값 범위를 확인하지만 주문 누적 수량이 시작 재고와 정확히 맞는지, 누군가 금액을 바꾸었는지까지 증명하지는 않는다. 또한 지나치게 큰 수치의 집계 범위도 따로 제한하지 않는다. 외부 파일을 받아들이는 기능으로 확대한다면 검증 범위를 추가해야 한다.
테스트의 세 메서드는 서로 독립적이다. 첫 번째는 정상 주문의 결과를, 두 번째는 실패 전후의 상태와 다음 번호를, 세 번째는 수량의 경계값을 검사한다. 특히 두 번째 테스트는 예외가 발생했다는 사실만 확인하지 않고 상태가 보존되었는지도 확인한다.
실행 결과
cafe-final 디렉터리에서 다음 명령을 실행한다. 현재 디렉터리에 cafe-demo.json이 생성된다. 아래는 정상적인 파일 읽기와 쓰기가 가능한 환경에서 앱이 출력하는 전체 내용이다.
dotnet run --project Cafe.App
주문 거절: 재고가 부족하다.
저장 및 복원 완료
주문 수: 2
[재고]
병입 커피: 6개
쿠키: 3개
[매출]
병입 커피: 2개 / 7000원
쿠키: 3개 / 6000원
총매출: 13000원
같은 명령을 다시 실행해도 주문은 두 건이고 총매출은 13,000원이다. 실행할 때 새 Cafe를 만들기 때문이다. 이 결과가 같다는 것은 기존 파일에서 영업을 이어갔다는 뜻이 아니다. 매번 동일한 자료를 저장하고 복원했다는 뜻이다.
테스트 프로젝트는 콘솔 앱처럼 dotnet run으로 실행하지 않는다. 다음 명령을 사용한다.
dotnet test Cafe.Tests
예상 결과는 테스트 세 개 성공, 실패 0개다. 테스트 도구의 진행 메시지, 소요 시간, 결과 표시 형식은 설치된 패키지와 SDK에 따라 달라지므로 고정 출력으로 제시하지 않는다. 테스트 실행은 시연 파일을 만들지 않는다.
실무에서 자주 틀리는 것
검사 전에 재고를 차감한다
다음은 PlaceOrder 안에 넣었을 때 문제가 되는 부분 코드다. 재고 부족을 발견한 시점에는 이미 값이 음수가 되었다. 예외는 이전 대입을 되돌려 주지 않는다.
stock[code] -= quantity;
if (stock[code] < 0)
{
throw new InvalidOperationException("재고가 부족하다.");
}
수량이 양수인지 확인한 다음 남은 재고와 비교하고, 모든 검사가 끝난 뒤 차감한다. 실패 경로에서 상태를 건드리지 않으면 복구 코드도 줄어든다.
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(quantity));
}
if (quantity > stock[code])
{
throw new InvalidOperationException("재고가 부족하다.");
}
stock[code] -= quantity;
현재 가격으로 과거 매출을 계산한다
다음 집계는 가격표가 바뀌면 과거 주문의 매출도 바뀐다. 저장된 주문에 단가가 있어도 사용하지 않으면 의미가 없다.
decimal total = orders.Sum(
order => order.Quantity * GetPrice(order.Code));
주문을 받을 때 확정한 단가를 사용한다. 가격표는 새 주문을 만들 때 사용하고, 이전 주문의 금액은 주문 자료로 계산한다.
decimal total = orders.Sum(
order => order.Quantity * order.UnitPrice);
저장 완료를 기다리지 않고 복원한다
다음 코드는 저장 작업을 시작하고 바로 읽기를 시도한다. 폐기 대입을 사용하면 기다리지 않았다는 컴파일러 경고는 피할 수 있지만 실행 순서 문제는 남는다.
_ = cafe.SaveAsync(path);
var restored = await Cafe.LoadAsync(path);
앞 작업의 결과가 필요한 경우에는 완료를 기다린다. 이 예제에서 저장과 복원은 독립적인 작업이 아니다.
await cafe.SaveAsync(path);
var restored = await Cafe.LoadAsync(path);
기다린다고 파일 저장 자체가 장애에 안전해지는 것은 아니다. 현재 코드는 기존 파일을 먼저 비우므로 저장 도중 실패하면 불완전한 파일이 남을 수 있다. 실제 영업 자료를 보존하려면 임시 파일에 쓰기, 파일 교체, 백업과 복구 정책을 함께 설계해야 한다.
읽기 실패를 새 영업 상태로 숨긴다
다음 코드는 파일이 손상되었거나 읽기 권한이 없어도 새 카페를 반환한다. 사용자는 저장 자료가 사라졌는데도 정상적으로 시작했다고 오해할 수 있다.
try
{
return await Cafe.LoadAsync(path);
}
catch
{
return new Cafe();
}
기존 상태를 이어가는 기능에서는 파일이 없는 경우와 파일을 읽지 못한 경우를 구분한다. 다음은 그 기능을 위한 메서드의 부분 코드다. 파일 없음만 새 상태로 처리하며, 손상이나 권한 문제는 호출자에게 전달한다.
try
{
return await Cafe.LoadAsync(path);
}
catch (FileNotFoundException)
{
return new Cafe();
}
화면을 담당하는 쪽은 전달받은 오류를 설명하고 시작을 중단하거나 복구 절차를 안내할 수 있다. 실패를 성공처럼 바꾸는 대신 어떤 상태로 계속할 수 있는지 명시적으로 결정해야 한다.
한눈에 보기
| 구성 요소 | 책임 | 확인할 기준 |
|---|---|---|
| PlaceOrder | 검사 후 주문 추가와 재고 차감 | 검사 실패 시 상태를 보존한다 |
| Order | 주문 당시의 품목, 수량, 단가 보관 | 현재 메뉴 가격에 영향을 받지 않는다 |
| GetSales | 품목별 수량과 금액 계산 | 저장된 주문을 사용하고 순서를 고정한다 |
| SaveAsync와 LoadAsync | 재고와 주문 저장 및 복원 | 완료를 기다리고 복원 값을 검사한다 |
| xUnit 테스트 | 업무 규칙의 반복 검증 | 성공과 실패 후 상태를 함께 확인한다 |
콘솔 출력, 업무 규칙, 파일 저장의 책임을 구분하면 변경할 위치를 찾기 쉽다. 입력 방식이 달라져도 주문 규칙을 다시 작성할 필요가 없고, 테스트는 화면 없이 그 규칙을 호출할 수 있다. 다만 이번 파일 검증은 기본 구조 검사이며, 동시 판매나 파일 손상 복구를 포함한 운영 기능과는 구분해야 한다.
연습 문제
- 수량 검사 테스트를 Theory와 InlineData를 사용하는 하나의 테스트로 바꾸어 0과 -1을 각각 검증하라. 두 경우 모두 주문 수와 매출이 0이고 커피 재고가 8인지 확인하라.
- 임시 파일을 사용하는 비동기 테스트를 추가하라. 커피 두 개와 쿠키 세 개를 주문한 상태를 저장하고 복원한 뒤, 주문 수, 품목별 재고, 총매출을 확인하라. 테스트 성공 여부와 관계없이 파일을 삭제하라.
- 주문이 없는 품목도 매출 목록에 수량 0개와 금액 0원으로 나타나게 GetSales를 수정하라. 새 카페의 집계에는 두 품목이 나오고 둘 다 0이어야 한다.
- 정해진 주문 대신 품목 코드와 수량을 한 번 입력받도록 시작 부분을 바꾸려 한다. 수량에 문자가 들어온 경우와 숫자 0이 들어온 경우를 어디서 처리할지 설명하고 수량 입력 처리 부분을 작성하라.
정답과 해설
1. 여러 수량을 같은 규칙으로 검사한다
기존 ZeroQuantityDoesNotCreateOrder 메서드를 다음 메서드로 교체한다. Theory는 전달되는 자료마다 테스트를 실행한다. InlineData가 두 개이므로 수량 검사 사례도 두 개가 된다.
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void NonPositiveQuantityDoesNotCreateOrder(int quantity)
{
var cafe = new Cafe();
Assert.Throws<ArgumentOutOfRangeException>(() =>
{
cafe.PlaceOrder("COFFEE", quantity);
});
Assert.Equal(8, cafe.GetStock("COFFEE"));
Assert.Equal(0, cafe.OrderCount);
Assert.Equal(0m, cafe.TotalSales);
}
입력값만 달라지고 기대하는 규칙은 같으므로 테스트 본문을 공유한다. 품목 오류처럼 실패 이유와 기대 결과가 달라지는 경우까지 한 메서드에 무리하게 합칠 필요는 없다.
2. 저장과 복원을 함께 검사한다
CafeTests에 다음 메서드를 추가한다. 파일 경로와 Task 이름을 완전히 적었으므로 추가 using 없이 사용할 수 있다. 임시 이름은 다른 테스트나 앱의 시연 파일과 충돌하는 일을 피한다.
[Fact]
public async System.Threading.Tasks.Task SavedStateCanBeRestored()
{
string path = System.IO.Path.GetTempFileName();
try
{
var cafe = new Cafe();
cafe.PlaceOrder("COFFEE", 2);
cafe.PlaceOrder("COOKIE", 3);
await cafe.SaveAsync(path);
Cafe restored = await Cafe.LoadAsync(path);
Assert.Equal(2, restored.OrderCount);
Assert.Equal(6, restored.GetStock("COFFEE"));
Assert.Equal(3, restored.GetStock("COOKIE"));
Assert.Equal(13000m, restored.TotalSales);
}
finally
{
System.IO.File.Delete(path);
}
}
이 테스트는 실제 파일 시스템까지 사용하는 통합 테스트(integration test)다. 객체를 JSON으로 바꾸고 다시 읽는 연결을 확인한다. 임시 파일 이름은 실행마다 달라도 검증하는 주문과 금액은 같다. finally에서 정리하므로 검증에 실패한 경우에도 파일 삭제를 시도한다.
3. 품목 목록을 집계의 출발점으로 삼는다
기존 코드는 주문에 등장한 품목을 출발점으로 삼았다. 모든 품목을 표시하려면 ProductCodes에서 시작하고 각 품목의 주문을 찾아 합산한다. GetSales를 다음 메서드로 교체한다.
public SalesRow[] GetSales()
{
return ProductCodes
.Select(code => new SalesRow(
code,
orders
.Where(order => order.Code == code)
.Sum(order => order.Quantity),
orders
.Where(order => order.Code == code)
.Sum(order => order.Quantity * order.UnitPrice)))
.ToArray();
}
해당 주문이 없으면 두 Sum 결과는 0이다. 표시 순서는 ProductCodes의 순서를 따른다. 품목마다 주문 목록을 반복해서 검색하므로 품목과 주문이 많아지면 한 번 집계한 사전을 조회하는 방식도 고려할 수 있다.
이 변경 이후에는 정상 주문 테스트의 Assert.Single이 더 이상 맞지 않는다. 그 부분을 다음과 같이 바꾼다. 제품 요구사항이 달라졌으므로 테스트의 기대값도 함께 조정해야 한다.
SalesRow[] rows = cafe.GetSales();
Assert.Equal(2, rows.Length);
Assert.Equal(new SalesRow("COFFEE", 2, 7000m), rows[0]);
Assert.Equal(new SalesRow("COOKIE", 0, 0m), rows[1]);
4. 입력 형식과 업무 규칙을 나누어 처리한다
문자를 정수로 바꿀 수 없는 문제는 입력을 담당하는 코드에서 처리한다. 0은 정수로 읽을 수 있지만 주문 규칙에 맞지 않으므로 PlaceOrder가 거절한다. 이렇게 하면 이후에 다른 입력 방식을 붙여도 양수 수량 규칙을 공유한다.
Console.Write("수량: ");
string? input = Console.ReadLine();
if (!int.TryParse(input, out int quantity))
{
Console.WriteLine("정수를 입력해야 한다.");
}
else
{
try
{
cafe.PlaceOrder("COFFEE", quantity);
Console.WriteLine("주문을 기록했다.");
}
catch (ArgumentOutOfRangeException)
{
Console.WriteLine("수량은 양수여야 한다.");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"주문 거절: {ex.Message}");
}
}
위 부분 코드는 품목 선택을 커피로 고정하고 수량 처리에 집중했다. 품목 코드도 입력받는다면 알려진 코드인지 확인하고 잘못된 입력을 안내하는 단계를 추가한다. 입력을 붙인 뒤에는 같은 입력을 주었을 때 같은 결과를 얻는지 확인하고, 업무 규칙 테스트는 그대로 유지한다.
카페 프로그램의 다음 변경은 새 문법을 더하는 일일 수도 있지만, 기존 규칙을 더 분명하게 표현하는 일일 수도 있다. 취소, 입고, 가격 변경 가운데 하나를 선택하면 먼저 저장해야 할 사실과 다시 계산할 결과를 구분하고, 바뀌어서는 안 되는 상태를 테스트로 적는다. 이 기준을 유지하면 작은 콘솔 앱도 기능을 늘려 가며 이해할 수 있는 구조로 남는다.