C# · 심화
제네릭·비동기·성능 설계
리플렉션과 특성 - 메타데이터로 동작 바꾸기
Type·PropertyInfo, 사용자 정의 특성, 간단한 검증기, 비용과 소스 생성기 소개
개발자KR · 원고 갱신
이 장에서 배우는 것
기본서에서 클래스와 record 를 만들 때 우리는 타입의 모양을 코드로 적기만 했다. 프로그램은 그 모양을 실행 중에 다시 읽을 수도 있다. 어떤 타입에 어떤 속성이 있는지, 속성에 어떤 표식이 붙어 있는지를 코드가 스스로 알아내는 능력을 리플렉션(reflection)이라 부른다. 이 장에서는 리플렉션으로 타입 정보를 읽고, 속성에 붙이는 표식인 특성(attribute)을 직접 만들고, 두 가지를 합쳐 작은 검증기를 만든다. 마지막에는 이 방식의 비용과, 비용을 컴파일 시점으로 옮기는 소스 생성기(source generator)의 발상을 살펴본다.
Type과PropertyInfo로 타입의 속성 목록을 읽고 값을 읽고 쓸 수 있다.- 사용자 정의 특성을 만들고, 속성에 붙은 특성을 실행 중에 읽어 낼 수 있다.
- 특성을 규칙으로 삼아 임의의 객체를 검사하는 검증기를 작성할 수 있다.
- 리플렉션이 왜 비용을 치르는지 설명하고, 결과를 캐시하는 구조를 적용할 수 있다.
- 소스 생성기가 어떤 문제를 해결하는지 말할 수 있고, 리플렉션과 생성기 중 무엇을 고를지 판단할 수 있다.
문제 상황
택배 물류 센터의 주문 접수 서비스에 ParcelOrder 가 있다. 수취인, 주소, 무게, 배송 메모를 담는 클래스다. 접수 창구에서 들어온 주문은 분류 단계로 넘기기 전에 검사해야 한다. 수취인은 비어 있으면 안 되고, 주소는 30자를 넘으면 안 되고, 무게는 1kg 이상 30kg 이하여야 한다.
가장 먼저 떠오르는 방법은 접수 메서드 안에 if 문을 나열하는 것이다. 처음에는 문제가 없다. 그런데 주문 종류가 늘어난다. 반품 주문, 방문 수거 주문, 추적 조회 요청이 각각 비슷한 규칙을 갖는다. 검사 코드가 클래스마다 복사되고, 필드가 하나 추가될 때마다 클래스 정의, 검사 코드, 오류 메시지 세 곳을 함께 고쳐야 한다. 규칙이 속성에서 멀리 떨어져 있으니 한 곳을 빠뜨려도 컴파일러는 알려 주지 않는다.
규칙을 속성 바로 옆에 선언하고, 검사하는 코드는 한 벌만 두면 어떨까. 속성 위에 [MaxLen(30)] 처럼 적어 두고, 검증기는 객체의 타입을 들여다보며 붙어 있는 규칙을 찾아 실행한다. 이 구조가 가능하려면 코드가 실행 중에 타입과 속성, 그리고 속성에 붙은 표식을 읽을 수 있어야 한다. 그 도구가 이 장의 주제다.
앞 장에서 다룬 테스트하기 좋은 설계와도 연결된다. 검증기는 객체를 받아 오류 목록을 돌려주는 함수이고 외부 자원에 기대지 않는다. 파일이나 시계를 가짜로 바꿀 필요가 없으므로 테스트가 단순하다. 이 장의 예제도 그 성질을 유지한다.
Type 과 PropertyInfo 로 타입 들여다보기
Type 객체를 얻는 세 가지 길
리플렉션의 출발점은 System.Type 이다. 타입 하나에 대한 설명서 객체라고 생각하면 된다. 얻는 방법은 세 가지다.
typeof(ParcelOrder): 컴파일 시점에 타입 이름을 아는 경우.order.GetType(): 변수에 담긴 객체의 실제 타입을 알고 싶은 경우. 변수가object로 선언되어 있어도 실제 타입이 나온다.Type.GetType("이름"): 문자열로 타입을 찾는 경우. 어셈블리 이름까지 정확히 적어야 해서 이 장에서는 쓰지 않는다.
검증기는 어떤 타입이 들어올지 미리 알 수 없으므로 두 번째 방법을 쓴다. Validate(object target) 는 target.GetType() 으로 실제 타입을 얻는다.
PropertyInfo 로 속성 읽고 쓰기
type.GetProperties() 는 공개 인스턴스 속성을 PropertyInfo 배열로 돌려준다. 비공개 속성이나 정적 속성까지 보려면 BindingFlags 를 함께 넘겨야 한다. 이 배열의 순서는 보장되지 않는다. 실제로는 선언 순서와 비슷하게 나오는 경우가 많지만, 그것에 기대는 코드는 런타임 버전이나 상속 구조에 따라 흔들릴 수 있다. 출력이 항상 같아야 하는 코드라면 이름으로 정렬한다.
PropertyInfo 는 속성 하나에 대한 설명서다. 자주 쓰는 멤버는 다음과 같다.
| 대상 | 멤버 | 돌려주는 것 | 이 장에서의 쓰임 |
|---|---|---|---|
| Type | Name | 타입 이름 문자열 | 목록 출력 |
| Type | GetProperties() | PropertyInfo 배열 | 검사 대상 속성 수집 |
| Type | GetProperty(name) | 이름이 맞는 PropertyInfo 또는 null | 문자열 키로 속성 찾기 |
| PropertyInfo | PropertyType | 속성의 타입 | 값 변환 대상 결정 |
| PropertyInfo | GetValue(obj) | obj 에서 읽은 값(object) | 규칙에 넘길 값 |
| PropertyInfo | SetValue(obj, v) | 없음(값을 씀) | 문자열 필드로 객체 조립 |
GetValue 는 값을 object 로 돌려준다. int 속성이라면 박싱이 일어난다. 기본서에서 값 형식을 object 에 담으면 힙에 상자가 생긴다고 배웠는데, 여기서도 같은 일이 벌어진다. 리플렉션이 느린 이유 중 하나다.
SetValue 는 init 접근자만 가진 속성에도 동작한다. 컴파일러는 객체 초기화 구문 바깥에서 init 속성을 쓰지 못하게 막지만, 런타임에는 그냥 쓰기 접근자가 있는 속성으로 보인다. 그래서 문자열 딕셔너리로 객체를 조립하는 코드를 리플렉션으로 쓸 수 있다. 다만 컴파일러가 지켜 주던 규칙을 우회하는 것이므로 신뢰할 수 있는 경계 안에서만 쓰는 편이 좋다.
사용자 정의 특성과 검증기
특성은 메타데이터를 붙이는 표식이다
특성은 타입이나 속성 같은 선언에 붙는 메타데이터(metadata)다. 컴파일러는 대괄호 안의 내용을 어셈블리에 기록해 둘 뿐, 그 내용으로 동작을 바꾸지 않는다. 특성이 의미를 갖는 것은 누군가 그것을 읽고 행동할 때다. 앞서 본 [Obsolete] 는 컴파일러가 읽는 특성이고, 우리가 만들 [MaxLen] 은 우리의 검증기가 읽는 특성이다.
사용자 정의 특성은 System.Attribute 를 상속한 클래스다. 클래스 이름이 Attribute 로 끝나면 사용할 때 접미사를 생략할 수 있다. MaxLenAttribute 는 [MaxLen(30)] 으로 적는다. 적용 대상은 [AttributeUsage] 로 제한한다. 이 장의 특성은 속성에만 붙이고, 한 속성에 규칙을 여러 개 붙일 수 있도록 AllowMultiple 을 켠다.
생성자 인자에는 제한이 있다. 컴파일 시점에 값이 정해지는 상수, typeof 식, 열거형 값, 그리고 이들의 배열만 넘길 수 있다. 실행 중에 계산한 값이나 static readonly 필드는 넘길 수 없다. 특성 정보가 어셈블리 파일에 그대로 기록되기 때문이다.
규칙 특성의 뼈대
규칙마다 검사 방법이 다르므로 공통 기반 클래스 RuleAttribute 를 두고 Check(object? value) 를 추상 메서드로 선언한다. 문제가 없으면 null, 문제가 있으면 오류 메시지를 돌려준다. 검증기는 구체적인 규칙 종류를 알 필요가 없다. 규칙을 새로 추가할 때 검증기를 고치지 않아도 되는 이유다. 기본서에서 배운 상속과 다형성을 특성에 적용한 셈이다.
규칙 옆에 사람이 읽는 이름도 필요하다. 오류 메시지에 WeightKg 라고 쓰는 것보다 무게(kg) 라고 쓰는 편이 접수 담당자에게 친절하다. 그래서 규칙이 아닌 LabelAttribute 를 따로 만든다. 표시 이름과 검사 규칙은 목적이 다르므로 클래스도 분리한다.
검증기의 흐름
검증기가 하는 일은 네 단계다.
- 대상 객체의
Type을 얻는다. - 공개 속성 중
RuleAttribute가 붙은 것을 모은다. - 각 속성의 값을
GetValue로 읽는다. - 붙어 있는 규칙마다
Check를 호출하고, 메시지가 있으면 오류 목록에 담는다.
이 중 1, 2단계는 타입이 같으면 매번 같은 결과가 나온다. 3, 4단계만 객체마다 달라진다. 이 관찰이 다음 절의 캐시로 이어진다.
비용, 캐시, 소스 생성기
리플렉션의 비용
리플렉션은 공짜가 아니다. 비용은 크게 세 곳에서 나온다. 첫째, 메타데이터를 읽어 PropertyInfo 같은 객체를 만드는 일이 반복된다. 둘째, GetCustomAttributes 는 호출할 때마다 특성 객체를 새로 생성한다. 특성 클래스의 생성자가 실행된다는 뜻이다. 셋째, GetValue 와 SetValue 는 인자와 반환값이 object 이므로 박싱과 형식 확인이 따른다. 직접 order.WeightKg 를 읽는 코드와 비교하면 훨씬 많은 일을 한다.
얼마나 느린지는 추측하지 않고 재야 한다. 재는 방법은 이 책 뒤에서 성능 측정을 따로 다룬다. 지금은 비용의 구조만 알면 충분하다. 반복되는 부분과 반복되지 않는 부분을 나누고, 반복되는 부분을 한 번만 하도록 바꾸는 것이 기본 전략이다.
결과를 캐시한다
타입별 규칙표는 프로그램이 실행되는 동안 바뀌지 않는다. 그래서 Type 을 키로, 속성과 표시 이름과 규칙 배열을 묶은 표를 값으로 하는 딕셔너리에 저장한다. 첫 검증에서만 메타데이터를 훑고, 이후에는 딕셔너리에서 꺼내 쓴다. 남는 비용은 GetValue 와 Check 호출뿐이다. 검증 서비스는 여러 요청을 동시에 처리할 수 있으므로 ConcurrentDictionary 의 GetOrAdd 를 쓴다. 동시에 두 스레드가 같은 타입을 처음 만나면 스캔이 두 번 일어날 수 있다. 결과는 같으므로 정확성에는 문제가 없고, 이 점은 알고 쓰면 된다.
소스 생성기 소개
캐시를 써도 GetValue 의 박싱과 느린 호출은 남는다. 더 근본적인 해법은 리플렉션이 할 일을 컴파일 시점에 미리 해 두는 것이다. 소스 생성기는 컴파일러가 소스를 분석하는 도중에 실행되어 새 C# 코드를 만들어 내는 도구다. 예를 들어 [MaxLen] 이 붙은 속성을 찾아 다음과 같은 코드를 자동으로 작성해 프로젝트에 끼워 넣는다.
static List<string> ValidateParcelOrder(ParcelOrder o)
{
var errors = new List<string>();
if (o.Address is string a && a.Length > 30)
errors.Add("주소: 30자를 넘는다");
if (o.WeightKg < 1 || o.WeightKg > 30)
errors.Add("무게(kg): 1~30 범위를 벗어난다");
return errors;
}
이 코드는 속성을 직접 읽으므로 박싱도 없고 메타데이터 조회도 없다. 타입 정보가 코드에 박혀 있어서 사용하지 않는 코드를 잘라내는 트리머나 네이티브 AOT 배포와도 잘 맞는다. 리플렉션으로 이름을 찾는 방식은 트리머가 그 멤버를 지워 버릴 수 있어서 별도 설정이 필요하다. BCL 에도 이 발상을 쓴 기능이 있다. 정규식의 [GeneratedRegex] 와 System.Text.Json 의 소스 생성 모드가 대표적이다. 자세한 내용은 공식 문서의 소스 생성기 개요에서 확인할 수 있다.
생성기를 직접 만들려면 Roslyn 관련 패키지가 필요하다. 이 책의 예제는 외부 패키지 없이 BCL 만 쓰기로 했으므로 직접 만들지 않고 개념만 소개한다. 두 방식의 선택 기준은 다음과 같다.
| 기준 | 리플렉션 | 소스 생성기 |
|---|---|---|
| 동작 시점 | 실행 중 | 컴파일 중 |
| 실행 비용 | 메타데이터 조회와 박싱이 있다 | 직접 호출과 같다 |
| 구현 난이도 | BCL 만으로 짧게 작성 | 별도 프로젝트와 Roslyn 지식이 필요 |
| 처음 보는 타입 | 플러그인처럼 실행 중 로드한 타입도 처리 | 컴파일 시점에 보이는 타입만 처리 |
| AOT·트리밍 | 멤버 보존을 따로 신경 써야 한다 | 영향이 적다 |
완성 코드
아래 프로그램은 세 부분으로 이뤄진다. 먼저 ParcelOrder 의 속성과 규칙 개수를 출력하고, 다음으로 두 주문을 검증하고, 마지막으로 문자열 딕셔너리에서 주문을 조립해 검증한다. 마지막에 타입 스캔이 몇 번 일어났는지 출력해 캐시가 동작함을 확인한다.
using System.Collections.Concurrent;
using System.Globalization;
using System.Reflection;
Type type = typeof(ParcelOrder);
Console.WriteLine($"[타입] {type.Name}");
foreach (PropertyInfo p in type.GetProperties().OrderBy(x => x.Name, StringComparer.Ordinal))
{
int ruleCount = p.GetCustomAttributes<RuleAttribute>().Count();
Console.WriteLine($" {p.Name} : {p.PropertyType.Name}, 규칙 {ruleCount}개");
}
PropertyInfo receiver = type.GetProperty(nameof(ParcelOrder.Receiver))
?? throw new InvalidOperationException("Receiver 속성이 없다");
MaxLenAttribute maxLen = receiver.GetCustomAttribute<MaxLenAttribute>()
?? throw new InvalidOperationException("MaxLen 특성이 없다");
Console.WriteLine($"Receiver 최대 길이: {maxLen.Limit}");
var good = new ParcelOrder { Receiver = "김하늘", Address = "서울시 중구 세종대로 1", WeightKg = 3 };
var bad = new ParcelOrder
{
Receiver = " ",
Address = new string('가', 31),
WeightKg = 40,
Memo = "문 앞에 두고 벨을 누르지 말아 주세요"
};
Report("good", good);
Report("bad", bad);
var built = Mapper.Build<ParcelOrder>(new Dictionary<string, string>
{
["Receiver"] = "박서준",
["Address"] = "부산시 해운대구 해변로 2",
["WeightKg"] = "7",
["Coupon"] = "무시되는 값"
});
Console.WriteLine($"조립: {built.Receiver} / {built.Address} / {built.WeightKg}kg");
Report("built", built);
Console.WriteLine($"스캔 횟수: {ObjectValidator.ScanCount}");
static void Report(string name, object target)
{
Console.WriteLine($"[검증] {name}");
List<string> errors = ObjectValidator.Validate(target);
if (errors.Count == 0)
{
Console.WriteLine(" 통과");
}
foreach (string error in errors)
{
Console.WriteLine($" {error}");
}
}
[AttributeUsage(AttributeTargets.Property)]
sealed class LabelAttribute : Attribute
{
public string Text { get; }
public LabelAttribute(string text) { Text = text; }
}
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
abstract class RuleAttribute : Attribute
{
public abstract string? Check(object? value);
}
sealed class NotBlankAttribute : RuleAttribute
{
public override string? Check(object? value) =>
value is string s && s.Trim().Length > 0 ? null : "값이 비어 있다";
}
sealed class MaxLenAttribute : RuleAttribute
{
public int Limit { get; }
public MaxLenAttribute(int limit) { Limit = limit; }
public override string? Check(object? value) =>
value is string s && s.Length > Limit ? $"{Limit}자를 넘는다" : null;
}
sealed class RangeIntAttribute : RuleAttribute
{
public int Min { get; }
public int Max { get; }
public RangeIntAttribute(int min, int max) { Min = min; Max = max; }
public override string? Check(object? value) =>
value is int n && (n < Min || n > Max) ? $"{Min}~{Max} 범위를 벗어난다" : null;
}
sealed class ParcelOrder
{
[Label("수취인")] [NotBlank] [MaxLen(10)]
public string? Receiver { get; init; }
[Label("주소")] [NotBlank] [MaxLen(30)]
public string? Address { get; init; }
[Label("무게(kg)")] [RangeInt(1, 30)]
public int WeightKg { get; init; }
[Label("메모")] [MaxLen(20)]
public string? Memo { get; init; }
public string InternalCode { get; init; } = "X";
}
sealed record PropertyRules(PropertyInfo Property, string Label, RuleAttribute[] Rules);
static class ObjectValidator
{
private static readonly ConcurrentDictionary<Type, PropertyRules[]> Cache = new();
public static int ScanCount;
public static List<string> Validate(object target)
{
var errors = new List<string>();
foreach (PropertyRules item in Cache.GetOrAdd(target.GetType(), Scan))
{
object? value = item.Property.GetValue(target);
foreach (RuleAttribute rule in item.Rules)
{
string? message = rule.Check(value);
if (message is not null)
{
errors.Add($"{item.Label}: {message}");
}
}
}
return errors;
}
private static PropertyRules[] Scan(Type type)
{
ScanCount++;
var list = new List<PropertyRules>();
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.OrderBy(x => x.Name, StringComparer.Ordinal);
foreach (PropertyInfo p in properties)
{
RuleAttribute[] rules = p.GetCustomAttributes<RuleAttribute>().ToArray();
if (rules.Length == 0)
{
continue;
}
string label = p.GetCustomAttribute<LabelAttribute>()?.Text ?? p.Name;
list.Add(new PropertyRules(p, label, rules));
}
return list.ToArray();
}
}
static class Mapper
{
public static T Build<T>(Dictionary<string, string> fields) where T : new()
{
var obj = new T();
foreach (var (name, text) in fields)
{
PropertyInfo? p = typeof(T).GetProperty(name);
if (p is null)
{
continue;
}
Type target = Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType;
p.SetValue(obj, Convert.ChangeType(text, target, CultureInfo.InvariantCulture));
}
return obj;
}
}
줄별 해설
최상위 문장의 첫 블록. typeof(ParcelOrder) 로 Type 을 얻고, GetProperties() 결과를 OrderBy(..., StringComparer.Ordinal) 로 정렬한다. 배열 순서가 보장되지 않으므로 출력을 고정하려는 조치다. 각 속성에서 GetCustomAttributes<RuleAttribute>() 로 규칙 특성만 세는 이유가 있다. 인자 없는 GetCustomAttributes() 는 컴파일러가 붙인 내부 특성까지 돌려줄 수 있어서 개수가 예상과 달라질 수 있다. 형식 인자로 원하는 종류를 지정하면 이런 잡음이 없다. InternalCode 는 규칙이 없으므로 0개로 나온다.
특성 값 읽기. GetProperty(nameof(ParcelOrder.Receiver)) 는 이름이 틀리면 null 을 돌려준다. nameof 를 쓰면 속성 이름을 바꿀 때 컴파일러가 따라온다. ?? throw 로 null 을 예외로 바꿔 이후 코드에서 널 경고가 나지 않게 한다. GetCustomAttribute<MaxLenAttribute>() 는 특성 하나를 인스턴스로 돌려주므로 maxLen.Limit 처럼 생성자 인자로 넘긴 값을 읽을 수 있다.
주문 두 개. good 은 모든 규칙을 통과한다. bad 는 수취인이 공백 한 칸이고, 주소가 31자이고, 무게가 40이고, 메모가 21자다. 규칙 네 개가 각각 하나씩 어긴다. 속성마다 어기는 규칙이 하나뿐이라서 같은 속성 안의 규칙 순서에 출력이 좌우되지 않는다.
LabelAttribute. 속성에 하나만 붙으므로 AllowMultiple 을 켜지 않는다. 검증기는 ?.Text ?? p.Name 으로, 라벨이 없으면 속성 이름을 대신 쓴다.
RuleAttribute 와 파생 클래스. 기반 클래스의 [AttributeUsage] 설정은 파생 특성에도 적용된다. NotBlank 는 문자열이 아니거나 공백뿐이면 실패한다. MaxLen 은 null 이면 통과시킨다. 값이 없다는 검사는 NotBlank 의 몫이고, 규칙 하나가 한 가지 일만 하게 하려는 선택이다. RangeInt 는 int 값만 검사하므로 다른 형식의 속성에 붙이면 조용히 통과한다. 이 약점은 연습 문제에서 다시 다룬다.
ParcelOrder. 특성은 속성 선언 바로 위에 나란히 적는다. 속성이 init 접근자를 쓰므로 객체 초기화 구문으로만 값을 채울 수 있다. 규칙은 속성 옆에 있고 검증 코드는 어디에도 없다.
PropertyRules 와 ObjectValidator. PropertyRules 는 속성 정보, 라벨, 규칙 배열을 묶는 record 다. Cache.GetOrAdd(target.GetType(), Scan) 은 캐시에 없으면 Scan 을 호출해 저장하고, 있으면 저장된 값을 돌려준다. Scan 은 ScanCount 를 올려 스캔 횟수를 기록한다. 이 카운터는 예제의 관찰용이며, 여러 스레드가 동시에 올릴 가능성은 다루지 않는다. Validate 안에서는 속성마다 값을 한 번 읽고, 그 값을 모든 규칙에 넘긴다.
Mapper.Build. where T : new() 제약으로 new T() 를 쓸 수 있다. 딕셔너리의 키로 GetProperty 를 호출하고, 없는 키(Coupon)는 건너뛴다. 속성 타입이 int? 같은 널 허용 값 형식이면 Convert.ChangeType 이 실패하므로 Nullable.GetUnderlyingType 으로 기저 타입을 꺼낸다. 문화권에 따라 숫자 표기가 달라지지 않도록 CultureInfo.InvariantCulture 를 넘긴다.
마지막 출력. 검증을 세 번 호출했지만 타입은 ParcelOrder 하나이므로 스캔은 한 번이다. 앞쪽의 속성 목록 출력은 ObjectValidator 를 거치지 않으므로 세지 않는다.
실행 결과
$ dotnet run
[타입] ParcelOrder
Address : String, 규칙 2개
InternalCode : String, 규칙 0개
Memo : String, 규칙 1개
Receiver : String, 규칙 2개
WeightKg : Int32, 규칙 1개
Receiver 최대 길이: 10
[검증] good
통과
[검증] bad
주소: 30자를 넘는다
메모: 20자를 넘는다
수취인: 값이 비어 있다
무게(kg): 1~30 범위를 벗어난다
조립: 박서준 / 부산시 해운대구 해변로 2 / 7kg
[검증] built
통과
스캔 횟수: 1
오류 목록이 주소, 메모, 수취인, 무게 순서인 것은 속성 이름의 순서(Address, Memo, Receiver, WeightKg)를 따랐기 때문이다. 클래스에 선언한 순서와 다르다는 점을 눈여겨보자.
실무에서 자주 틀리는 것
검증할 때마다 타입을 다시 훑는다
규칙표를 캐시하지 않고 호출마다 만드는 코드다. 동작은 맞지만 주문이 쏟아지는 시간대에 같은 메타데이터 조회와 특성 객체 생성이 요청 수만큼 반복된다.
// 틀린 코드
public static List<string> Validate(object target)
{
foreach (PropertyInfo p in target.GetType().GetProperties())
{
foreach (RuleAttribute rule in p.GetCustomAttributes<RuleAttribute>())
{
// 호출할 때마다 PropertyInfo 와 특성 객체를 새로 만든다
}
}
return new List<string>();
}
// 고친 코드: 타입별 결과를 한 번만 만든다
foreach (PropertyRules item in Cache.GetOrAdd(target.GetType(), Scan))
{
object? value = item.Property.GetValue(target);
// 저장된 규칙 배열만 순회한다
}
GetProperties 의 순서를 믿는다
선언 순서대로 나온다고 가정하고 오류 메시지나 화면의 열 순서를 만들면, 다른 환경이나 상속 구조에서 순서가 달라질 수 있다. 테스트가 어느 날 이유 없이 깨지는 원인이 되기도 한다.
// 틀린 코드
foreach (PropertyInfo p in type.GetProperties())
{
Console.WriteLine(p.Name);
}
// 고친 코드: 순서가 의미를 갖는다면 직접 정한다
foreach (PropertyInfo p in type.GetProperties().OrderBy(x => x.Name, StringComparer.Ordinal))
{
Console.WriteLine(p.Name);
}
특성 인자에 실행 중 값을 넣는다
특성 인자는 컴파일 시점 상수여야 한다. 변수나 static readonly 필드를 넣으면 컴파일 오류(CS0182)가 난다. 상한을 여러 곳에서 공유하고 싶으면 const 로 선언한다.
// 틀린 코드
static readonly int ReceiverLimit = 10;
[MaxLen(ReceiverLimit)] // 컴파일 오류
public string? Receiver { get; init; }
// 고친 코드
const int ReceiverLimit = 10;
[MaxLen(ReceiverLimit)]
public string? Receiver { get; init; }
GetProperty 의 null 을 확인하지 않는다
이름이 틀렸거나 비공개 속성이면 GetProperty 는 예외 없이 null 을 돌려준다. 그 뒤에 바로 점을 찍으면 NullReferenceException 이 난다. 외부에서 들어온 문자열 키를 쓸 때는 특히 조심한다.
// 틀린 코드
PropertyInfo p = typeof(ParcelOrder).GetProperty(key);
p.SetValue(order, value); // key 가 없으면 예외
// 고친 코드
PropertyInfo? p = typeof(ParcelOrder).GetProperty(key);
if (p is null)
{
return; // 모르는 키는 건너뛰거나 오류로 보고한다
}
p.SetValue(order, value);
한눈에 보기
| 도구 | 하는 일 | 주의점 |
|---|---|---|
typeof / GetType() | Type 객체를 얻는다 | GetType() 은 실제 타입을 돌려준다 |
GetProperties() | 공개 인스턴스 속성 목록 | 순서가 보장되지 않는다 |
GetValue / SetValue | 속성 값을 읽고 쓴다 | object 로 오가며 박싱이 생긴다 |
| 사용자 정의 특성 | 선언에 메타데이터를 붙인다 | 인자는 상수만 가능하고, 읽는 코드가 있어야 의미가 생긴다 |
GetCustomAttributes<T>() | 붙은 특성을 인스턴스로 읽는다 | 호출마다 새 객체를 만든다 |
캐시(ConcurrentDictionary) | 타입별 규칙표를 한 번만 만든다 | 동시에 처음 만나면 스캔이 겹칠 수 있다 |
| 소스 생성기 | 컴파일 시점에 코드를 만든다 | 별도 프로젝트와 Roslyn 지식이 필요하다 |
검증 규칙을 메타데이터로 선언하는 방식은 도메인 모델을 덜 장황하게 만든다. 다음 장에서는 패턴 매칭과 nullable 을 활용해 잘못된 상태를 타입으로 표현하는 방법을 다룬다. 규칙을 특성으로 검사하는 방법과 타입으로 아예 막는 방법이 어떻게 다른지 비교해 보면 좋다.
연습 문제
- 우편번호가 숫자 5자리인지 검사하는
ZipCodeAttribute를 작성하라.RuleAttribute를 상속하고, 값이null이거나 형식이 다르면 오류 메시지를 돌려주게 하라. - 완성 코드에
TrackingQuery라는 클래스를 추가하고, 속성 하나에[NotBlank]를 붙였다.ParcelOrder를 세 번,TrackingQuery를 두 번 검증한 뒤ScanCount는 얼마인가. 이유도 설명하라. - 다음 세 상황에서 리플렉션(캐시 포함)과 소스 생성기 중 무엇이 더 알맞은지 고르고 이유를 한 문장씩 써라. (가) 서비스 시작 시 수십 개 타입의 규칙을 한 번 읽어 화면에 목록으로 보여 준다. (나) 초당 수만 건의 주문을 검증하고, 네이티브 AOT 로 배포한다. (다) 배송사별 플러그인 DLL 을 실행 중에 로드해 그 안의 타입을 검증한다.
[RangeInt(1, 30)]을 실수로string속성에 붙이면 지금의 검증기는 아무 오류도 내지 않고 통과시킨다. 이 실수를 조용히 넘기지 않고 알아채도록 하려면 검증기의 어느 부분을 어떻게 바꿔야 하는지 설명하라.
정답과 해설
1번. 한 가지 예는 다음과 같다.
sealed class ZipCodeAttribute : RuleAttribute
{
public override string? Check(object? value) =>
value is string s && s.Length == 5 && s.All(char.IsAsciiDigit)
? null
: "우편번호는 숫자 5자리여야 한다";
}
value is string s 는 null 과 문자열이 아닌 값을 함께 걸러 낸다. char.IsAsciiDigit 은 0부터 9까지의 ASCII 숫자만 참으로 판정하므로 다른 문자 체계의 숫자가 통과하지 않는다. 검증기는 수정하지 않아도 되고, 속성에 [ZipCode] 만 붙이면 된다.
2번. 2다. 캐시의 키가 Type 이므로 타입마다 스캔이 한 번씩 일어난다. ParcelOrder 의 두 번째와 세 번째 검증, TrackingQuery 의 두 번째 검증은 저장된 규칙표를 그대로 쓴다. 검증 호출 횟수(5회)는 스캔 횟수와 관계가 없다.
3번. (가) 리플렉션이 알맞다. 한 번만 읽으므로 비용이 문제되지 않고 구현이 짧다. (나) 소스 생성기가 알맞다. 호출이 많고 박싱과 메타데이터 조회를 없앨 수 있으며 AOT 와 잘 맞는다. (다) 리플렉션이 알맞다. 컴파일 시점에 볼 수 없는 타입은 생성기가 처리할 수 없으므로 실행 중에 탐색해야 한다.
4번. 규칙표를 만드는 Scan 단계에서 검사하는 것이 좋다. 규칙 특성이 자신이 붙을 수 있는 속성 타입을 알려 주는 멤버(예: 허용 타입 목록)를 가지게 하고, Scan 에서 p.PropertyType 이 맞지 않으면 예외를 던진다. Scan 은 타입당 한 번만 실행되므로 비용이 늘지 않는다. 그리고 그 타입을 처음 검증하는 시점에 곧바로 잘못된 선언이 드러난다. 값을 검사하는 Check 안에서 매번 형식을 확인하는 방법보다 실수를 일찍 알 수 있다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.