C# · 심화
제네릭·비동기·성능 설계
패턴 매칭과 nullable 로 도메인 모델링
record 계층, 목록 패턴·속성 패턴, nullable 흐름 분석, 불가능한 상태 없애기
개발자KR · 원고 갱신
이 장에서 배우는 것
배송 추적 서비스에서 가장 자주 나오는 버그는 계산 실수가 아니다. 이미 배송이 끝난 택배에 분류 이벤트가 들어오는 일, 배송 완료 상태인데 수령인이 비어 있는 일처럼 있어서는 안 되는 상태가 데이터에 나타나는 일이다. 이 장에서는 그런 상태를 검사 코드로 막지 않고, 타입으로 표현할 수 없게 만드는 방법을 다룬다. 도구는 세 가지다. 상태를 나누는 record 계층, 그 계층을 읽는 패턴 매칭(pattern matching), 값이 비어 있을 가능성을 컴파일러가 추적하게 하는 nullable 흐름 분석이다.
이 장의 코드는 접수, 분류, 이동, 배송 완료, 배송 실패 다섯 상태를 가진 택배 하나의 생애를 모델링한다. 스캐너가 보내는 문자열 한 줄을 이벤트로 읽고, 현재 상태와 이벤트를 짝지어 다음 상태를 계산한다.
- 상태마다 record 타입을 두어 필요한 값만 갖게 하고, 불가능한 조합을 만들 수 없게 설계한다.
- 속성 패턴, 확장 속성 패턴, 목록 패턴, 튜플 패턴으로 상태를 읽고 전이 규칙을 표로 쓰듯 작성한다.
- nullable 참조 형식의 경고가 어떤 흐름 분석에서 나오는지 이해하고,
!없이NotNullWhen특성과 패턴으로 경고를 해소한다. - 값 객체의 생성 지점을 하나로 좁혀 검증을 우회할 수 없게 만든다.
문제 상황
택배 추적 서비스를 처음 만들 때 흔히 아래와 같은 클래스 하나로 시작한다. 상태는 문자열이고, 상태에 따라 필요한 값은 모두 nullable 속성으로 둔다.
class LegacyParcel
{
public string Status { get; set; } = "";
public DateOnly? DeliveredAt { get; set; }
public string? ReceiverName { get; set; }
public string? FailReason { get; set; }
}
이 모델은 만들기 쉽지만 세 가지 문제가 생긴다. 첫째, Status 가 문자열이라 "Delivred" 같은 오타가 컴파일 단계에서 걸리지 않는다. 둘째, nullable 속성 세 개는 값이 있거나 없는 경우가 각각 두 가지이므로 조합이 여덟 가지인데, 그중 의미 있는 것은 몇 개뿐이다. Status 가 "Delivered" 인데 DeliveredAt 이 null 인 객체도, FailReason 과 ReceiverName 이 함께 채워진 객체도 타입 검사를 통과한다. 셋째, 이 조합을 방어하려는 검사가 화면 코드, 저장 코드, 알림 코드에 흩어진다. 한 곳에서 검사를 빠뜨리면 NullReferenceException 이나 잘못된 알림이 운영 중에 나타난다.
해결 방향은 검사를 늘리는 것이 아니라 모델을 바꾸는 것이다. 배송 완료 상태는 수령인과 날짜를 반드시 가진 타입이고, 배송 실패 상태는 사유를 반드시 가진 타입이면, 앞의 조합은 만들 방법 자체가 없다. 아래 그림은 두 모델을 비교한다.
record 계층으로 상태 나누기
상태마다 타입을 둔다
기본서에서 다룬 record 와 상속을 그대로 쓴다. 추상 record 하나를 뿌리로 두고, 상태마다 봉인된(sealed) record 를 파생한다. 각 record 는 그 상태에서 의미가 있는 값만 위치 매개변수로 받는다.
abstract record ParcelState(TrackingNumber Number);
sealed record Received(TrackingNumber Number, string SenderName) : ParcelState(Number);
sealed record Sorted(TrackingNumber Number, string HubCode, int Bin) : ParcelState(Number);
sealed record InTransit(TrackingNumber Number, IReadOnlyList<string> Route) : ParcelState(Number);
sealed record Delivered(TrackingNumber Number, string ReceiverName, DateOnly Date) : ParcelState(Number);
sealed record Failed(TrackingNumber Number, FailReason Reason) : ParcelState(Number);
nullable 속성은 하나도 없다. Delivered 를 만들려면 수령인과 날짜를 반드시 넘겨야 하고, Failed 에는 날짜 속성이 없으므로 실패한 택배에 배송일을 적는 코드는 컴파일되지 않는다. 상태의 종류도 문자열이 아니라 타입이므로 오타가 나올 자리가 없다. 이벤트도 같은 방식으로 Scanned, Departed, Handed, Rejected 네 record 로 나눈다.
생성 지점을 하나로 좁힌다
운송장 번호를 그냥 string 으로 두면 빈 문자열이나 형식이 틀린 값이 모델 깊숙이 들어온다. 값 객체(value object)를 두고 생성자를 비공개로 하면 검증을 통과한 값만 존재하게 된다.
sealed record TrackingNumber
{
private TrackingNumber(string value) => Value = value;
public string Value { get; }
...
public static bool TryCreate(string? raw, [NotNullWhen(true)] out TrackingNumber? result) { ... }
}
Value 를 init 이 아닌 읽기 전용 속성으로 두는 데 이유가 있다. init 이면 with 식으로 검증을 거치지 않고 값을 바꿀 수 있다. 읽기 전용이면 with 로도 바꿀 수 없다. 한계도 분명히 해 둔다. 타입은 "경로 목록이 두 개 이상이다" 같은 조건을 표현하지 못한다. InTransit 은 Route 를 빈 목록으로도 만들 수 있으므로, 이 조건은 InTransit 을 만드는 곳을 전이 함수 한 곳으로 제한해서 지킨다. 타입으로 막을 수 있는 것은 타입으로, 나머지는 생성 지점의 수를 줄여서 관리하는 것이 실용적인 선이다.
패턴으로 상태 읽기
계층을 나눴으면 읽는 쪽은 switch 식이 맡는다. 패턴 종류마다 쓰임이 다르다. 이 장의 코드에서 쓰는 것을 표로 정리한다.
| 패턴 | 예 | 읽는 법 | 코드에서의 쓰임 |
|---|---|---|---|
| 타입 패턴 | Received r | Received 이면 r 로 받는다 | 상태 분기 |
| 속성 패턴 | Sorted { Bin: >= 90 } | 속성이 조건을 만족하면 | 대형 칸 구분 |
| 확장 속성 패턴 | Delivered { Date.DayOfWeek: ... } | 중첩 속성을 점으로 잇는다 | 주말 배송 구분 |
| 목록 패턴 | [var first, .., var last] | 처음과 끝 원소를 꺼낸다 | 경로 요약, 스캔 줄 해석 |
| 튜플 패턴 | (Sorted s, Departed d) | 두 값을 함께 검사한다 | 상태 전이 규칙 |
속성 패턴
속성 패턴은 if 로 속성을 꺼내 비교하던 코드를 한 줄 조건으로 바꾼다. Sorted { Bin: >= 90 } s 는 "Sorted 이면서 Bin 이 90 이상이면 s 로 받는다"는 뜻이다. 비교에는 관계 연산자, or, and, not 을 쓸 수 있다. 중첩 속성은 Delivered { Date.DayOfWeek: DayOfWeek.Saturday or DayOfWeek.Sunday } 처럼 점으로 이어 쓴다.
목록 패턴
목록 패턴은 Length 또는 Count 와 인덱서를 가진 형식에 쓸 수 있다. 배열은 물론 IReadOnlyList<T> 도 된다. [] 는 빈 목록, [var only] 는 원소 하나, [var first, .., var last] 는 두 개 이상이며 처음과 끝을 꺼낸다. .. 는 나머지 원소를 뜻하고, 이름을 붙이지 않으면 아무것도 만들지 않으므로 비용이 없다. 스캐너 한 줄을 공백으로 잘라 ["SCAN", var hub, var bin] 처럼 읽으면, 토큰 수 검사와 첫 토큰 비교와 변수 추출이 한 번에 끝난다.
튜플 패턴으로 전이 규칙 쓰기
상태 전이는 "현재 상태와 들어온 이벤트의 조합"으로 결정된다. 두 값을 튜플로 묶어 switch 식에 넘기면 규칙이 표처럼 한 줄에 하나씩 놓인다.
그림의 화살표가 곧 switch 식의 한 줄이다. 규칙에 없는 조합은 마지막 _ => null 로 떨어지고, 호출한 쪽은 null 을 "전이 불가"로 받는다. 종료 상태를 먼저 걸러 내는 (Delivered or Failed, _) 줄이 Rejected 줄보다 앞에 있어야 한다는 점도 그림과 같다. 패턴은 위에서 아래로 검사되기 때문이다.
nullable 흐름 분석
nullable 참조 형식을 켜면 컴파일러는 모든 참조 변수에 "null 일 수 있음"과 "null 이 아님" 두 상태 중 하나를 붙여 코드 흐름을 따라간다. string? 변수를 그대로 역참조하면 경고가 나오고, is not null 검사나 null 패턴 뒤에는 상태가 바뀌어 경고가 사라진다. 새 콘솔 프로젝트 템플릿은 이 기능을 기본으로 켜 둔다.
| 표기 | 의미 | 예 | 주의 |
|---|---|---|---|
string? | null 일 수 있다고 선언 | 입력 문자열 | 런타임 타입은 같다 |
is null, is not null | 검사 뒤 상태가 바뀐다 | if (line is null) return null; | 분기 안에서만 유효 |
[NotNullWhen(true)] | true 를 돌려주면 out 값은 null 이 아니다 | TryCreate | 구현이 약속을 지켜야 한다 |
! | 컴파일러에게 null 이 아니라고 단언 | value! | 경고만 끄고 검사는 하지 않는다 |
가장 중요한 것은 Try... 패턴이다. 메서드가 bool 을 돌려주고 결과를 out 매개변수로 내보낼 때, [NotNullWhen(true)] 를 붙이면 호출한 쪽은 true 분기에서 결과를 null 검사 없이 쓸 수 있다. 컴파일러는 구현 쪽에서도 약속을 확인한다. return next is not null; 처럼 반환 조건이 null 검사와 연결되어 있으면 통과하고, 그렇지 않으면 경고를 낸다. 즉 이 특성은 문서가 아니라 검증되는 계약이다.
한 가지 기억할 점이 있다. nullable 분석은 컴파일 시점의 도구일 뿐, 런타임에 null 을 막지 않는다. 외부에서 들어오는 값(파일, 네트워크, 리플렉션으로 채운 객체)은 경계에서 한 번 검증해야 한다. 이 장에서는 그 경계를 TrackingNumber.TryCreate 와 ParseScan 두 곳으로 두었다. 자세한 규칙은 nullable 참조 형식 문서와 패턴 문서에서 확인할 수 있다.
완성 코드
새 콘솔 프로젝트를 만들고 Program.cs 를 아래 내용으로 바꾼다. 최상위 문장이 먼저 오고, 형식 선언이 뒤따른다.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
Console.WriteLine("=== 1. 운송장 번호 검증 ===");
foreach (var raw in new string?[] { "KR000101", "KR12", null, "KR00A104", "KR000102" })
{
var verdict = TrackingNumber.TryCreate(raw, out var parsed)
? $"유효 ({parsed.Value})"
: "형식 오류";
Console.WriteLine($"입력 [{raw ?? "null"}] -> {verdict}");
}
Console.WriteLine("=== 2. 상태 전이 재생 ===");
var jobs = new (string Raw, string Sender, string?[] Lines)[]
{
("KR000101", "박서준", new string?[]
{
"SCAN HUB-SEL 12", "DEPART", null, "DEPART HUB-DJN", "DEPART HUB-BSN",
"HANDOVER 김하나 2026-09-30", "HANDOVER 김하나 2026-09-30",
}),
("KR000102", "정우", new string?[] { "SCAN HUB-SEL 95", "REJECT DAMAGED", "SCAN HUB-SEL 3" }),
("KR000103", "최민", new string?[] { "SCAN HUB-ICN 40", "DEPART HUB-SEL" }),
("KR00X104", "한서", Array.Empty<string?>()),
};
var finals = new List<ParcelState>();
foreach (var (raw, sender, lines) in jobs)
{
if (!TrackingNumber.TryCreate(raw, out var number))
{
Console.WriteLine($"건너뜀: {raw}");
continue;
}
ParcelState state = new Received(number, sender);
Console.WriteLine($"[{number}] {Rules.Describe(state)}");
finals.Add(Rules.Replay(state, lines));
}
Console.WriteLine("=== 3. 목록 패턴과 속성 패턴 ===");
if (!TrackingNumber.TryCreate("KR000199", out var demo))
{
return;
}
Console.WriteLine(Rules.Describe(new InTransit(demo, [])));
Console.WriteLine(Rules.Describe(new InTransit(demo, ["HUB-ULS"])));
Console.WriteLine(Rules.Describe(new Delivered(demo, "이든", new DateOnly(2026, 10, 3))));
Console.WriteLine("=== 4. nullable 흐름 ===");
var phones = new Dictionary<string, string?>
{
["김하나"] = "010-1111-2222",
["이든"] = null,
["정우"] = "",
};
foreach (var name in new[] { "김하나", "이든", "정우", "박서준" })
{
var text = phones.TryGetValue(name, out var phone) ? Rules.MaskPhone(phone) : "등록 안 됨";
Console.WriteLine($"{name}: {text}");
}
Console.WriteLine("=== 5. 요약 ===");
var ended = finals.Count(s => s is Delivered or Failed);
Console.WriteLine($"종료 {ended}건, 진행 중 {finals.Count - ended}건");
enum FailReason { ReceiverAbsent, Damaged }
sealed record TrackingNumber
{
private TrackingNumber(string value) => Value = value;
public string Value { get; }
public static bool TryCreate(string? raw, [NotNullWhen(true)] out TrackingNumber? result)
{
if (raw is ['K', 'R', ..] && raw.Length == 8 && raw[2..].All(char.IsAsciiDigit))
{
result = new TrackingNumber(raw);
return true;
}
result = null;
return false;
}
public override string ToString() => Value;
}
abstract record ParcelState(TrackingNumber Number);
sealed record Received(TrackingNumber Number, string SenderName) : ParcelState(Number);
sealed record Sorted(TrackingNumber Number, string HubCode, int Bin) : ParcelState(Number);
sealed record InTransit(TrackingNumber Number, IReadOnlyList<string> Route) : ParcelState(Number);
sealed record Delivered(TrackingNumber Number, string ReceiverName, DateOnly Date) : ParcelState(Number);
sealed record Failed(TrackingNumber Number, FailReason Reason) : ParcelState(Number);
abstract record ParcelEvent;
sealed record Scanned(string HubCode, int Bin) : ParcelEvent;
sealed record Departed(string NextHub) : ParcelEvent;
sealed record Handed(string ReceiverName, DateOnly Date) : ParcelEvent;
sealed record Rejected(FailReason Reason) : ParcelEvent;
static class Rules
{
public static ParcelEvent? ParseScan(string? line)
{
if (line is null)
{
return null;
}
return line.Split(' ', StringSplitOptions.RemoveEmptyEntries) switch
{
["SCAN", var hub, var bin] when int.TryParse(bin, out var binNo) => new Scanned(hub, binNo),
["DEPART", var next] => new Departed(next),
["HANDOVER", var name, var date] when DateOnly.TryParse(date, CultureInfo.InvariantCulture, out var day)
=> new Handed(name, day),
["REJECT", "ABSENT"] => new Rejected(FailReason.ReceiverAbsent),
["REJECT", "DAMAGED"] => new Rejected(FailReason.Damaged),
_ => null,
};
}
public static bool TryAdvance(ParcelState state, ParcelEvent ev, [NotNullWhen(true)] out ParcelState? next)
{
next = (state, ev) switch
{
(Received r, Scanned s) => new Sorted(r.Number, s.HubCode, s.Bin),
(Sorted s, Departed d) => new InTransit(s.Number, [s.HubCode, d.NextHub]),
(InTransit t, Departed d) => t with { Route = [.. t.Route, d.NextHub] },
(InTransit t, Handed h) => new Delivered(t.Number, h.ReceiverName, h.Date),
(Delivered or Failed, _) => null,
(_, Rejected x) => new Failed(state.Number, x.Reason),
_ => null,
};
return next is not null;
}
public static string Describe(ParcelState state) => state switch
{
Received r => $"접수됨 - 보내는 사람 {r.SenderName}",
Sorted { Bin: >= 90 } s => $"분류됨 {s.HubCode} 허브 - 대형 칸 {s.Bin}",
Sorted s => $"분류됨 {s.HubCode} 허브 - 칸 {s.Bin}",
InTransit { Route: [] } => "이동 중 - 경로 정보 없음",
InTransit { Route: [var only] } => $"이동 중 - {only} 도착",
InTransit { Route: [var first, .., var last] } t => $"이동 중 - {first}에서 {last}까지 {t.Route.Count}개 지점",
Delivered { Date.DayOfWeek: DayOfWeek.Saturday or DayOfWeek.Sunday } d
=> $"주말 배송 완료 - {d.ReceiverName} ({Iso(d.Date)})",
Delivered d => $"배송 완료 - {d.ReceiverName} ({Iso(d.Date)})",
Failed { Reason: FailReason.ReceiverAbsent } => "배송 실패 - 수취인 부재",
Failed f => $"배송 실패 - 사유 {f.Reason}",
_ => throw new UnreachableException($"알 수 없는 상태 {state.GetType().Name}"),
};
public static ParcelState Replay(ParcelState state, IEnumerable<string?> lines)
{
foreach (var line in lines)
{
var ev = ParseScan(line);
if (ev is null)
{
Console.WriteLine($" 무시: 해석할 수 없는 줄 [{line ?? "null"}]");
continue;
}
if (TryAdvance(state, ev, out var next))
{
state = next;
Console.WriteLine($" {Describe(state)}");
}
else
{
Console.WriteLine($" 거부: {Describe(state)} / {ev.GetType().Name} 이벤트는 받을 수 없다");
}
}
return state;
}
public static string MaskPhone(string? phone) => phone switch
{
null or "" => "번호 없음",
{ Length: >= 4 } => new string('*', phone.Length - 4) + phone[^4..],
_ => phone,
};
static string Iso(DateOnly date) => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
줄별 해설
운송장 번호 검증. raw is ['K', 'R', ..] 는 문자열을 문자 목록으로 보고 앞 두 글자를 검사한다. 이 패턴은 null 이면 false 이므로 뒤의 raw.Length 에서 null 경고가 나오지 않는다. raw[2..].All(char.IsAsciiDigit) 는 나머지가 모두 숫자인지 확인한다. 길이가 8이 아니면 두 번째 조건에서 끝난다. 통과한 값만 비공개 생성자로 감싸 true 와 함께 돌려주고, 실패하면 result = null 과 false 를 돌려준다. 호출부의 parsed.Value 는 삼항 식의 참 분기에 있으므로 컴파일러가 parsed 를 null 이 아닌 것으로 본다.
작업 목록과 재생 루프. jobs 는 이름 붙은 튜플 배열이다. foreach (var (raw, sender, lines) in jobs) 는 각 튜플을 분해한다. 번호가 잘못된 KR00X104 는 TryCreate 에서 걸러져 continue 로 넘어간다. 이 if 뒤에서 number 는 null 이 아니므로 new Received(number, sender) 가 경고 없이 컴파일된다.
스캔 줄 해석. ParseScan 은 줄을 공백으로 잘라 나온 배열을 목록 패턴으로 분기한다. ["SCAN", var hub, var bin] 은 정확히 세 토큰이고 첫 토큰이 SCAN 일 때만 맞는다. when 절의 int.TryParse 가 실패하면 그 줄은 다음 줄로 넘어가 결국 _ => null 에 닿는다. 토큰 하나뿐인 DEPART 도 같은 길로 null 이 된다. 반환 형식이 ParcelEvent? 이므로 서로 다른 record 를 돌려주는 팔들이 이 형식으로 변환된다.
전이 함수. TryAdvance 는 (state, ev) 튜플에 패턴을 적용한다. 세 번째 팔의 t with { Route = [.. t.Route, d.NextHub] } 는 기존 경로를 펼치고 허브를 하나 더한 새 record 를 만든다. 기존 객체는 바뀌지 않는다. (Delivered or Failed, _) 가 Rejected 팔보다 앞에 있어야 종료된 택배가 다시 실패로 바뀌지 않는다. 마지막 줄 return next is not null; 이 [NotNullWhen(true)] 계약을 만족시킨다. Replay 에서 state = next; 가 경고 없이 통과하는 이유가 이것이다.
상태 설명. Describe 의 팔 순서가 곧 우선순위다. Sorted { Bin: >= 90 } s 는 일반 Sorted s 보다 먼저 놓았다. InTransit 은 [], [var only], [var first, .., var last] 세 팔이 원소 수 0, 1, 2 이상을 나눠 맡는다. Delivered 의 확장 속성 패턴 Date.DayOfWeek 는 날짜의 요일이 토요일 또는 일요일인 경우를 고른다. 컴파일러는 추상 record 의 파생 형식을 모두 나열했는지 판단하지 못하므로 마지막 _ 팔이 필요하다. 여기서는 조용히 넘어가는 대신 UnreachableException 을 던진다.
전화번호 가리기. MaskPhone 의 첫 팔 null or "" 에서 null 과 빈 문자열이 함께 처리된다. 이후 팔에서는 phone 이 null 이 아닌 상태로 분석되어 phone.Length 가 경고 없이 컴파일된다. phone[^4..] 는 뒤 네 글자를 잘라 낸다. 호출부의 TryGetValue 는 값 형식 인수가 string? 이므로 phone 도 string? 이고, 그래서 MaskPhone 이 string? 을 받도록 선언한 것이다.
날짜 출력. DateOnly.ToString() 의 기본 형식은 문화권에 따라 달라진다. 출력이 실행 환경에 따라 바뀌지 않도록 Iso 에서 형식과 InvariantCulture 를 명시했다.
실행 결과
$ dotnet new console -n ParcelModeling
$ cd ParcelModeling
$ dotnet run
=== 1. 운송장 번호 검증 ===
입력 [KR000101] -> 유효 (KR000101)
입력 [KR12] -> 형식 오류
입력 [null] -> 형식 오류
입력 [KR00A104] -> 형식 오류
입력 [KR000102] -> 유효 (KR000102)
=== 2. 상태 전이 재생 ===
[KR000101] 접수됨 - 보내는 사람 박서준
분류됨 HUB-SEL 허브 - 칸 12
무시: 해석할 수 없는 줄 [DEPART]
무시: 해석할 수 없는 줄 [null]
이동 중 - HUB-SEL에서 HUB-DJN까지 2개 지점
이동 중 - HUB-SEL에서 HUB-BSN까지 3개 지점
배송 완료 - 김하나 (2026-09-30)
거부: 배송 완료 - 김하나 (2026-09-30) / Handed 이벤트는 받을 수 없다
[KR000102] 접수됨 - 보내는 사람 정우
분류됨 HUB-SEL 허브 - 대형 칸 95
배송 실패 - 사유 Damaged
거부: 배송 실패 - 사유 Damaged / Scanned 이벤트는 받을 수 없다
[KR000103] 접수됨 - 보내는 사람 최민
분류됨 HUB-ICN 허브 - 칸 40
이동 중 - HUB-ICN에서 HUB-SEL까지 2개 지점
건너뜀: KR00X104
=== 3. 목록 패턴과 속성 패턴 ===
이동 중 - 경로 정보 없음
이동 중 - HUB-ULS 도착
주말 배송 완료 - 이든 (2026-10-03)
=== 4. nullable 흐름 ===
김하나: *********2222
이든: 번호 없음
정우: 번호 없음
박서준: 등록 안 됨
=== 5. 요약 ===
종료 2건, 진행 중 1건
실무에서 자주 틀리는 것
경고가 귀찮아서 !를 붙인다
null 허용 경고를 ! 로 지우면 컴파일러가 하던 추적이 멈추고, 문제는 운영 중 예외로 되돌아온다.
// 틀림: 검증 결과를 확인하지 않고 단언한다
TrackingNumber.TryCreate(input, out var number);
Console.WriteLine(number!.Value); // input 이 잘못되면 NullReferenceException
// 고침: 반환값으로 분기하면 number 는 참 분기에서 null 이 아니다
if (TrackingNumber.TryCreate(input, out var number))
{
Console.WriteLine(number.Value);
}
일반 패턴을 구체적인 패턴보다 앞에 둔다
팔은 위에서 아래로 검사된다. 넓은 패턴이 앞에 있으면 뒤의 좁은 패턴은 도달할 수 없다.
// 틀림: 아래 팔은 이미 처리된 패턴이라 컴파일 오류(CS8510)가 난다
Sorted s => $"칸 {s.Bin}",
Sorted { Bin: >= 90 } s => $"대형 칸 {s.Bin}",
// 고침: 좁은 패턴부터 쓴다
Sorted { Bin: >= 90 } s => $"대형 칸 {s.Bin}",
Sorted s => $"칸 {s.Bin}",
기본 팔이 새 상태를 조용히 삼킨다
추상 record 를 읽는 switch 에는 기본 팔이 필요하다. 그 팔에 그럴듯한 문구를 돌려주면 상태를 새로 추가했을 때 화면에 "알 수 없음"이 조용히 나가고, 아무도 눈치채지 못한다.
// 틀림
_ => "알 수 없음",
// 고침: 실패를 드러내고, 모든 상태를 한 번씩 통과시키는 테스트를 둔다
_ => throw new UnreachableException($"알 수 없는 상태 {state.GetType().Name}"),
검증을 생성자 밖에 둔다
공개 생성자를 열어 두고 "쓰기 전에 검증 함수를 부르자"고 약속하면, 약속을 지키지 않은 호출부가 반드시 생긴다.
// 틀림: 아무 문자열이나 운송장 번호가 된다
record TrackingNumber(string Value);
var bad = new TrackingNumber("hello");
// 고침: 생성자를 비공개로 하고 TryCreate 로만 만든다 (완성 코드의 TrackingNumber 참고)
if (TrackingNumber.TryCreate("hello", out var number)) { /* 도달하지 않는다 */ }
한눈에 보기
| 수단 | 막아 주는 것 | 막지 못하는 것 | 보완 |
|---|---|---|---|
| 상태별 record | 상태와 필드의 불일치 | 컬렉션이 비었는지 여부 | 생성 지점을 함수 하나로 |
| 비공개 생성자 + TryCreate | 형식이 틀린 값 객체 | default 로 만든 구조체 | 클래스 사용 |
| 튜플 패턴 전이 | 규칙에 없는 이벤트 순서 | 새 상태 추가 시 누락된 팔 | 기본 팔에서 예외, 테스트 |
| nullable 분석 | null 역참조 경로 | 경계 밖에서 들어온 null | 경계에서 한 번 검증 |
| 하고 싶은 일 | 패턴 | 결과 변수 | 비고 |
|---|---|---|---|
| 비었거나 null | null or "" | 없음 | 이후 팔은 null 아님 |
| 범위 조건 | { Bin: >= 90 } | 뒤에 이름 가능 | 넓은 조건은 뒤에 |
| 처음과 끝 | [var a, .., var b] | a, b | 원소 2개 이상 |
| 나머지 수집 | ["MOVE", .. var rest] | rest | 배열이면 새 배열 |
| 두 값 동시 검사 | (state, ev) switch | 각 요소 이름 | 종료 상태를 먼저 |
다음 장에서는 이렇게 설계한 코드를 추측이 아니라 측정으로 평가하는 방법을 다룬다.
연습 문제
- 세관 보류 상태를 추가한다.
InTransit상태에서CustomsHold(string Reason)이벤트를 받으면Held상태가 되고, 다른 상태에서는 전이되지 않는다. 필요한 선언, 전이 팔,Describe팔을 쓰라. "MOVE HUB-A HUB-B HUB-C"형태의 줄을 받아 허브가 두 개 이상이면HUB-A > HUB-B > HUB-C로 이어 붙인 문자열을, 그렇지 않으면 null 을 돌려주는 함수를 목록 패턴의 슬라이스로 작성하라.- 아래 함수는 nullable 경고를 낸다. 어떤 경고인지 설명하고,
!없이 두 가지 방법으로 고치라.
static int NoteLength(string? note) => note.Length; Delivered가 수령인 이름 없이 만들어지는 일은 왜 불가능한지, 그리고 이 장의 모델이 여전히 막지 못하는 잘못된 값의 예를 하나 들라.
정답과 해설
1. 선언과 전이 팔, 설명 팔을 아래처럼 더한다.
sealed record Held(TrackingNumber Number, string Reason) : ParcelState(Number);
sealed record CustomsHold(string Reason) : ParcelEvent;
// TryAdvance 의 switch 에서 (Delivered or Failed, _) 보다 앞에 추가
(InTransit t, CustomsHold c) => new Held(t.Number, c.Reason),
// Describe 의 switch 에 추가
Held h => $"세관 보류 - {h.Reason}",
다른 상태에서 CustomsHold 를 받으면 어떤 팔에도 맞지 않아 마지막 _ => null 로 떨어진다. ParseScan 에도 ["HOLD", var reason] 팔을 더해야 문자열에서 이벤트가 만들어진다. Describe 팔을 빠뜨리면 기본 팔의 UnreachableException 이 실행 중에 드러나므로, 모든 상태를 통과시키는 테스트가 필요하다는 앞의 설명이 여기서 확인된다.
2. 배열 슬라이스에 이름을 붙이면 나머지 원소가 새 배열로 담긴다.
static string? ParseRoute(string line) => line.Split(' ') switch
{
["MOVE", .. var hubs] when hubs.Length >= 2 => string.Join(" > ", hubs),
_ => null,
};
"MOVE HUB-A HUB-B HUB-C" 는 HUB-A > HUB-B > HUB-C 를 돌려주고, "MOVE HUB-A" 는 허브가 하나라 null 이다.
3. note 가 string? 인데 검사 없이 Length 를 읽었으므로 null 가능 참조를 역참조한다는 경고(CS8602)가 나온다. 고치는 방법은 두 가지다.
static int NoteLength(string? note) => note?.Length ?? 0;
static int NoteLength2(string? note) => note is null ? 0 : note.Length;
첫 번째는 null 조건 연산자와 null 병합 연산자를 쓰고, 두 번째는 검사 뒤 분기에서 컴파일러가 null 이 아님을 알게 한다. 어느 쪽이든 null 일 때의 값을 코드가 직접 정한다는 점이 ! 와 다르다.
4. Delivered 의 위치 매개변수 ReceiverName 이 string (nullable 아님)이고 다른 생성 경로가 없기 때문이다. 값을 넘기지 않으면 컴파일 오류이고, null 리터럴을 넘기면 경고가 난다. 막지 못하는 값의 예로는 빈 문자열 수령인, 또는 앞서 설명한 빈 Route 가 있다. 타입은 "값이 있다"까지만 보장하고 "값이 의미 있다"는 별도의 검증이 필요하므로, 빈 문자열은 TrackingNumber 처럼 값 객체를 두거나 생성 지점에서 검사해야 한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.