재고 관리 만들기
물건과 수량을 다루고 파일로 남깁니다.
여럿이 함께 건드립니다
5.1의 가계부는 혼자 적는 것이었습니다. 재고는 다릅니다. 여러 사람이 같은 물건을 동시에 가져갑니다.
그래서 이 단원의 중심은 셈이 아니라 지키는 것입니다. 아래 코드는 읽기에 아무 문제가 없어 보입니다.
public bool Ship() { if (Stock <= 0) return false; // 있는지 보고 Stock--; // 하나 뺍니다 return true; }
보는 것과 빼는 것 사이에 다른 스레드가 끼어듭니다(4.2). 둘이 같은 재고를 보고 둘 다 가져가면, 하나뿐인 물건이 두 사람에게 팔립니다.
이 단원이 묶는 것은 넷입니다.
| 할 일 | 어디서 배운 것 |
|---|---|
| 품목을 코드로 찾기 | 3.2 Dictionary |
| 여럿이 함께 건드려도 지키기 | 4.2 lock |
| 안 되는 까닭을 말해 주기 | 3.7 예외 |
| 파일로 남기기 | 3.8 파일 · 4.7 소스 제너레이터 |
돌아가는 재고 관리
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
var inv = new Inventory(); inv.Receive("A-100", "볼펜", 50); inv.Receive("B-200", "노트", 30); inv.Receive("A-100", "볼펜", 20); inv.Ship("A-100", 45); try { inv.Ship("B-200", 100); } catch (InvalidOperationException ex) { Console.WriteLine($"! {ex.Message}"); } try { inv.Ship("C-300", 1); } catch (KeyNotFoundException ex) { Console.WriteLine($"! {ex.Message}"); } Console.WriteLine(); foreach (var (code, name, qty) in inv.Stocks()) Console.WriteLine($"{code,-6} {name,-4} {qty,4}개"); Console.WriteLine($"\n이력 {inv.History.Count}건"); foreach (var m in inv.History) Console.WriteLine($" {m.Code,-6} {m.Delta,+4} {m.Reason}"); record Item(string Code, string Name, int Quantity); record Movement(DateTime At, string Code, int Delta, string Reason); class Inventory { private readonly Lock _gate = new(); private readonly Dictionary<string, Item> _items = []; private readonly List<Movement> _history = []; public IReadOnlyList<Movement> History { get { lock (_gate) return [.. _history]; } } public void Receive(string code, string name, int qty) { if (qty <= 0) throw new ArgumentOutOfRangeException(nameof(qty), qty, "수량은 0보다 커야 합니다."); lock (_gate) { var now = _items.TryGetValue(code, out var it) ? it.Quantity : 0; _items[code] = new(code, name, now + qty); _history.Add(new(DateTime.Now, code, qty, "입고")); } } public void Ship(string code, int qty) { if (qty <= 0) throw new ArgumentOutOfRangeException(nameof(qty), qty, "수량은 0보다 커야 합니다."); lock (_gate) { if (!_items.TryGetValue(code, out var it)) throw new KeyNotFoundException($"등록되지 않은 품목입니다({code})."); if (it.Quantity < qty) throw new InvalidOperationException( $"재고가 모자랍니다({code} 남은 수량 {it.Quantity}개, 요청 {qty}개)."); _items[code] = it with { Quantity = it.Quantity - qty }; _history.Add(new(DateTime.Now, code, -qty, "출고")); } } public IEnumerable<(string Code, string Name, int Quantity)> Stocks() { lock (_gate) return [.. _items.Values.OrderBy(i => i.Code).Select(i => (i.Code, i.Name, i.Quantity))]; } }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
모든 lock 이 같은 _gate
를 잠급니다. 읽는 Stocks 까지 포함해서입니다.
왜 그래야 하는지가 아래의 이야기입니다.
락이 없으면 어떻게 되는가
재고 1600에서 여덟 스레드가 200번씩 출고하면, 성공한 건수와 남은 재고를 더해 1600이어야 합니다. 실제로 재 보았습니다.
1회 락 없이 1600 + 475 = 2075 lock 1600 + 0 = 1600 2회 락 없이 1600 + 420 = 2020 lock 1600 + 0 = 1600 3회 락 없이 1600 + 311 = 1911 lock 1600 + 0 = 1600 4회 락 없이 1600 + 211 = 1811 lock 1600 + 0 = 1600 5회 락 없이 1600 + 472 = 2072 lock 1600 + 0 = 1600
1600개를 다 내보냈는데 창고에 475개가 남아 있습니다.
Stock-- 가 겹쳐 빼는 일이 사라진 것입니다.
장부와 실물이 갈라지는 자리가 이렇게 생깁니다.
여기서 겪은 일을 그대로 적어 둡니다. 처음에는 이 어긋남이 다섯 번에 한 번만 나왔습니다. 스레드가 하나씩 시작되어 서로 겹치는 순간이 짧았기 때문입니다.
// 출발선을 두어 같은 순간에 시작하게 하자 매번 어긋났습니다. using var gate = new ManualResetEventSlim(false); var threads = Enumerable.Range(0, 8).Select(_ => new Thread(() => { gate.Wait(); for (var i = 0; i < 200; i++) if (ship()) Interlocked.Increment(ref ok); })).ToArray(); foreach (var t in threads) t.Start(); gate.Set(); // 한꺼번에 출발
이것이 이 오류의 성질입니다. 개발 장비에서는 잘 돌고 사람이 몰리는 시각에만 어긋납니다. 재현되지 않으니 찾기 어렵고, 찾았을 때는 이미 장부가 틀어져 있습니다. 그래서 나중에 고치는 것이 아니라 처음부터 잠그고 시작합니다.
읽는 것도 잠급니다
담는 쪽만 잠그면 읽는 쪽이 중간 상태를 봅니다.
Stocks 와 History 도 같은
_gate 를 잠급니다.
public IReadOnlyList<Movement> History { get { lock (_gate) return [.. _history]; } }
[.. _history] 로 사본을 내주는 것이
중요합니다. 목록을 그대로 내주면 부르는 쪽이 그것을 탐색하는 동안 다른 스레드가 항목을
추가해 InvalidOperationException 이 납니다. 잠근 안에서
사본을 만들면 내준 뒤에는 아무도 건드리지 못합니다.
Stocks 도 같은 까닭으로 목록으로 만들어
돌려줍니다. LINQ 를 그대로 돌려주면 실제로 도는 것은 잠금 밖이라
잠근 뜻이 없습니다.
// 이렇게 적으면 잠근 뜻이 없습니다 public IEnumerable<Item> Stocks() { lock (_gate) return _items.Values.OrderBy(i => i.Code); // 아직 돌지 않았습니다 } // 잠근 안에서 다 돌려 목록으로 만듭니다 lock (_gate) return [.. _items.Values.OrderBy(i => i.Code)];
안을 짧게 두기
4.2에서 잠근 안을 짧게 두라고 했습니다. 그래서 수량 검사는 잠금 밖에 있습니다.
public void Ship(string code, int qty) { if (qty <= 0) throw new ArgumentOutOfRangeException(…); // 밖에서 lock (_gate) { … } // 안은 짧게 }
넘어온 값만 보는 검사는 밖에 두어도 됩니다. 재고를 보는 검사는 다릅니다. 그것은 안에 있어야 하고, 밖으로 빼면 처음의 그 오류로 돌아갑니다.
// 이렇게 하면 안 됩니다 — 보는 것과 빼는 것이 갈라집니다 if (QuantityOf(code) < qty) throw …; // 여기서 잠갔다 풀고 lock (_gate) { … } // 여기서 다시 잠급니다
수량을 고치지 않고 새로 만들기
Item 이 record 이므로
수량을 고칠 수 없습니다. with 로 새로 만들어 담습니다
(4.5).
_items[code] = it with { Quantity = it.Quantity - qty };
번거로워 보이지만 그것이 값입니다. 내준 Item
을 밖에서 고칠 수 없으므로, 사본을 내주는 것만으로 안전해집니다. 고칠 수 있는
형식이라면 사본을 내주어도 그 안의 것을 건드릴 수 있습니다.
수량과 이력을 함께 남기기
지금 수량만 담으면 어쩌다 그 수량이 되었는지 알 수 없습니다.
입고와 출고를 Movement 로 함께 남깁니다.
record Movement(DateTime At, string Code, int Delta, string Reason); // A-100 50 입고 // A-100 -45 출고
부호로 방향을 담았습니다. 입고·출고를 따로 두지 않아 합계가
그대로 셈됩니다. 실제 창고에서는 조정·반품·폐기가 더 붙는데, 그것도
Reason 하나로 늘어납니다.
더 나아가면 수량을 담지 않고 이력만 남기는 방식도 있습니다. 지금
수량은 이력을 다 더하면 나오기 때문입니다. 5.1에서 Balance
를 담지 않았던 것과 같은 판단입니다. 다만 건수가 늘면 매번 더하는 것이 비싸지므로,
둘 다 담고 서로 맞는지 가끔 확인하는 방식을 많이 사용합니다.
JSON 으로 남기기
5.1은 CSV 였습니다. 여기서는 담을 것이 둘 (품목과 이력)이라 구조가 있는 JSON 이 맞습니다.
record Snapshot(Dictionary<string, Item> Items, List<Movement> History); public void Save(string path) { lock (_gate) { var snap = new Snapshot(new(_items), [.. _history]); File.WriteAllText(path, JsonSerializer.Serialize(snap, AppJson.Default.Snapshot)); } } [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(Snapshot))] partial class AppJson : JsonSerializerContext { }
AppJson.Default.Snapshot 을 넘기는 것이
4.7의 소스 제너레이터입니다. 반사로
탐색하지 않으므로 트리밍해서 배포해도 그대로 돕니다.
저장도 잠근 안에서 합니다. 저장하는 도중에 출고가 일어나면 품목과 이력이 어긋난 파일이 남습니다.
읽을 때는 null 을 막습니다
(4.9). 파일이 비어 있거나
"null" 한 줄이면 그대로 null 이
돌아옵니다.
var snap = JsonSerializer.Deserialize(File.ReadAllText(path), AppJson.Default.Snapshot) ?? throw new InvalidDataException("내용이 비어 있습니다.");
안 되는 까닭을 나누어 말하기
| 어긋난 것 | 예외 | 메시지 |
|---|---|---|
| 수량이 0 이하 | ArgumentOutOfRangeException | 넘어온 값이 잘못되었습니다 |
| 없는 품목 | KeyNotFoundException | 등록되지 않은 품목입니다(C-300) |
| 재고 부족 | InvalidOperationException | 재고가 모자랍니다(B-200 남은 수량 30개, 요청 100개) |
셋을 한 가지로 뭉치지 마십시오. 받는 쪽이 다르게 다뤄야 합니다. 없는 품목은 등록하면 되고, 재고 부족은 기다리거나 수량을 줄이면 됩니다. 메시지에 실제 수량을 담는 것도 그래서입니다. 얼마나 모자라는지 알아야 다음에 무엇을 할지 정할 수 있습니다.
직접 해보기
주문 하나에 품목이 여럿일 때 전부 되거나 전부 안 되도록
ShipAll 을 만들어보세요. 하나라도 모자라면 아무것도
나가지 않아야 합니다.
이력만 가지고 어느 시점의 수량을 셈하는
QuantityAt 을 만들어보세요. 그리고 그 값이 지금 담아
둔 수량과 맞는지 확인하는 방법도 적어보세요.
- 보는 것과 바꾸는 것이 갈라지면 그 사이에 끼어듭니다. 둘을 같은
lock안에 두십시오. - 읽는 쪽도 잠급니다. 담는 쪽만 잠그면 중간 상태가 보입니다.
- 내줄 때는 잠근 안에서 사본을 만듭니다. LINQ 를 그대로 돌려주면 잠근 뜻이 없습니다.
- 넘어온 값만 보는 검사는 밖에, 재고를 보는 검사는 안에 둡니다.
- 수량만 담으면 어쩌다 그렇게 되었는지 알 수 없습니다. 이력을 함께 남깁니다.
- 안 되는 까닭은 나누어 말합니다. 메시지에 실제 수량을 담으십시오.
- 이 오류는 재현되지 않습니다. 나중에 고치는 것이 아니라 처음부터 잠급니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.