ASP.NET LAB
ASP.NET Core 2.3 · 2부. MVC / Minimal API

모델 바인딩과 유효성 검사

들어온 값을 형식에 담고 옳은지 봅니다.

예상 학습 시간 18분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 기초
개념 설명

담는 일과 보는 일은 다릅니다

요청에 실려 오는 것은 전부 글자입니다. 주소의 7 도, 본문의 JSON 도 그렇습니다. 그것을 int 나 우리가 만든 형식에 옮겨 담는 것이 모델 바인딩입니다.

담기는 것과 담긴 값이 옳은 것은 다릅니다. 9 는 숫자라 int 에 담기지만, 중요도가 1에서 5 사이여야 한다면 옳은 값이 아닙니다. 그것을 보는 것이 유효성 검사입니다.

둘은 차례로 일어납니다. 담지 못하면 검사까지 가지 않습니다. 둘 다 실패하면 400 이지만 돌아오는 내용이 다릅니다.

최소 예제

규칙을 형식에 적습니다

검사 규칙은 코드로 적지 않고 속성 위에 붙입니다. 어떤 값이 허용되는지가 그 형식을 보면 드러납니다.

TodoInput.cs · TodosController.cs
using System.ComponentModel.DataAnnotations;

public class TodoInput {
    [Required(ErrorMessage = "제목은 반드시 적어야 합니다.")]
    [StringLength(20, MinimumLength = 2, ErrorMessage = "제목은 2자에서 20자 사이입니다.")]
    public string? Title { get; set; }

    [Range(1, 5, ErrorMessage = "중요도는 1에서 5 사이입니다.")]
    public int Priority { get; set; }

    [EmailAddress(ErrorMessage = "전자 메일 모양이 아닙니다.")]
    public string? Notify { get; set; }
}

// ─────────────────────────────

[HttpPost]
public IActionResult Add(TodoInput input) => Ok(input);
받은 응답
// {"title":"우유 사기","priority":3} 200 {"title":"우유 사기","priority":3,"notify":null} // {"priority":3} 400 "errors": { "Title": ["제목은 반드시 적어야 합니다."] } // {"title":"우","priority":9,"notify":"abc"} 400 "errors": { "Title": ["제목은 2자에서 20자 사이입니다."], "Notify": ["전자 메일 모양이 아닙니다."], "Priority": ["중요도는 1에서 5 사이입니다."] }

이 코드는 서버가 있어야 하므로 브라우저에서 실행할 수 없습니다. 아래에서 같은 규칙을 실행해 볼 수 있습니다.

마지막 것을 눈여겨보십시오. 틀린 곳이 셋이었는데 셋을 모두 돌려주었습니다. 처음 하나에서 멈추지 않습니다. 부르는 쪽이 한 번에 고칠 수 있어야 하기 때문입니다.

최소 예제

같은 규칙을 서버 없이

[ApiController] 가 부르는 것이 Validator 입니다. 직접 부르면 서버 없이도 같은 판정을 볼 수 있습니다.

Program.cs
using System.ComponentModel.DataAnnotations;

void 검사(TodoInput input) {
    var errors = new List<ValidationResult>();
    var ok = Validator.TryValidateObject(input, new ValidationContext(input), errors, true);

    Console.WriteLine(ok ? "통과" : "막힘");
    foreach (var e in errors)
        Console.WriteLine("  " + string.Join(", ", e.MemberNames) + " : " + e.ErrorMessage);
}

검사(new TodoInput { Title = "우유 사기", Priority = 3 });
검사(new TodoInput { Title = "우", Priority = 9, Notify = "abc" });

class TodoInput {
    [Required(ErrorMessage = "제목은 반드시 적어야 합니다.")]
    [StringLength(20, MinimumLength = 2, ErrorMessage = "제목은 2자에서 20자 사이입니다.")]
    public string? Title { get; set; }

    [Range(1, 5, ErrorMessage = "중요도는 1에서 5 사이입니다.")]
    public int Priority { get; set; }

    [EmailAddress(ErrorMessage = "전자 메일 모양이 아닙니다.")]
    public string? Notify { get; set; }
}
출력
통과 막힘 Title : 제목은 2자에서 20자 사이입니다. Priority : 중요도는 1에서 5 사이입니다. Notify : 전자 메일 모양이 아닙니다.

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

마지막 인수 true 를 빼면 속성에 붙인 것을 보지 않습니다. 형식 자체에 붙인 것만 봅니다. 빠뜨리기 쉬운 자리이므로 규칙을 붙였는데 통과한다면 여기를 먼저 보십시오.

상세 사용법

값이 어디서 오는지 적을 수 있습니다

2.1에서 본 대로 적지 않아도 알아서 정합니다. 다만 이름이 겹치거나 다른 곳에서 가져와야 할 때는 직접 적습니다.

TodosController.cs
[HttpGet("{id:int}/where")]
public object Where(
    [FromRoute] int id,
    [FromQuery] string? q,
    [FromHeader(Name = "X-Trace")] string? trace) => new { id, q, trace };
받은 응답
// GET /todos/7/where?q=test 머리글 X-Trace: abc123 200 {"id":7,"q":"test","trace":"abc123"}
어트리뷰트가져오는 곳
FromRoute주소의 자리표
FromQuery질의 문자열
FromBody본문(JSON). 한 매개 변수에만 붙일 수 있습니다.
FromForm폼으로 보낸 값. 파일 올리기가 이쪽입니다.
FromHeader요청 머리글
FromServices등록해 둔 서비스

자주 사용하는 검사 규칙

규칙보는 것
Required값이 있어야 합니다.
StringLength글자 수의 위아래를 정합니다.
Range숫자와 날짜의 범위를 정합니다.
EmailAddress · Url · Phone정해진 모양인지 봅니다.
RegularExpression직접 적은 모양과 맞는지 봅니다.
Compare다른 속성과 같은지 봅니다. 비밀번호 확인이 이것입니다.

ErrorMessage 를 적지 않으면 영어 문구가 나갑니다. 사용자에게 그대로 보일 수 있으므로 적어 두는 편이 낫습니다.

상세 사용법

물음표 없는 문자열은 이미 규칙입니다

Required 를 붙이지 않아도 막히는 자리가 있습니다. nullable 을 켜 둔 프로젝트에서 물음표 없이 선언한 문자열이 그렇습니다. 값을 보내지 않으면 400 입니다.

public class NoInit {
    public string Name { get; set; }        // 물음표 없음
    public string? Nickname { get; set; }
}

그런데 무엇을 막는지가 짐작과 다릅니다. 네 가지를 보내 보았습니다.

보낸 것과 받은 것
{}                    // 아예 없음
{"name": null}        // null 이라고 적음
{"name": ""}          // 빈 문자열
{"name": "홍길동"}    // 값이 있음
받은 응답
400 "errors": { "Name": ["The Name field is required."] } 400 "errors": { "Name": [...] } 200 {"name":"","nickname":null} 200 {"name":"홍길동","nickname":null}

빈 문자열은 지나갑니다. 막는 것은 null 뿐입니다. 제목이 비어 있으면 안 된다면 이것만으로는 모자라므로, [Required][StringLength(MinimumLength = 1)] 을 함께 적어야 합니다.

한 가지가 더 있습니다. 선언에 기본값을 주어 두면 아예 막지 않습니다. 값을 보내지 않아도 그 기본값이 남아 null 이 되지 않기 때문입니다.

public string Name { get; set; } = "";   // {} 를 보내도 200, name 은 ""

컴파일러 경고를 없애려고 = "" 를 붙이는 일이 흔한데, 그때 검사도 함께 사라집니다. 입력을 받는 형식에서는 물음표를 붙여 string? 로 두고 규칙을 어트리뷰트로 적는 편이 뜻이 분명합니다.

버전 배지

언제부터 있었나

ASP.NET Core 3.0 부터

물음표 없는 참조 형식을 Required 로 보는 것은 nullable 참조 형식(C# 8.0)이 들어온 뒤에 생겼습니다. 그 전에는 물음표에 아무 뜻이 없어 검사에 사용할 수 없었습니다.

이 동작이 달갑지 않다면 끌 수 있습니다. SuppressImplicitRequiredAttributeForNonNullableReferenceTypes 를 켜면 어트리뷰트로 적은 것만 봅니다. 옛 코드를 옮겨 올 때 사용합니다.

실습 문제

직접 해보기

1. 규칙 추가하기 난이도 하

위 실행 예제의 TodoInput 에 마감일을 더하고, 오늘 이후여야 한다는 규칙을 붙여보세요.

날짜의 범위는 Range 로도 되지만 오늘을 어트리뷰트에 적을 수 없습니다. 형식 자체에 규칙을 두는 방법을 생각해보세요.
// 형식에 IValidatableObject 를 구현하면 여러 속성을 함께 볼 수 있습니다. class TodoInput : IValidatableObject { public DateOnly? Due { get; set; } public IEnumerable<ValidationResult> Validate(ValidationContext ctx) { if (Due is { } d && d < DateOnly.FromDateTime(DateTime.Today)) yield return new("마감일은 오늘 이후여야 합니다.", [nameof(Due)]); } }
2. 빈 제목 막기 난이도 중

public string Title { get; set; } 만으로는 빈 문자열이 지나갑니다. 공백만 적은 것도 막으려면 어떻게 해야 할지 적어보세요.

공백 두 칸은 길이가 2 라 StringLength 로는 막히지 않습니다.
[Required(AllowEmptyStrings = false)] [RegularExpression(@"\S.*", ErrorMessage = "제목을 적어 주십시오.")] public string? Title { get; set; } // Required 만으로는 공백이 지나갑니다. 공백이 아닌 글자가 // 하나라도 있어야 한다는 것을 함께 적습니다.
3. 두 400을 구분하기 난이도 상

{"priority":"셋"}{"priority":9} 는 둘 다 400 입니다. 무엇이 다른지, 그리고 응답에서 어떻게 알아볼 수 있는지 적어보세요.

하나는 담지도 못한 것이고, 다른 하나는 담긴 뒤에 규칙에 걸린 것입니다. 오류의 키를 보십시오.
// {"priority":"셋"} — 바인딩 실패 "errors": { "$.priority": ["The JSON value could not be converted…"] } // {"priority":9} — 검사 실패 "errors": { "Priority": ["중요도는 1에서 5 사이입니다."] } // 키가 다릅니다. 담지 못한 것은 JSON 안의 자리($.priority)를 가리키고, // 규칙에 걸린 것은 속성 이름(Priority)을 가리킵니다.
요약
  • 담는 일(바인딩)과 보는 일(검사)은 차례로 일어납니다. 담지 못하면 검사까지 가지 않습니다.
  • 규칙은 코드가 아니라 속성 위 어트리뷰트로 적습니다. 틀린 곳이 여럿이면 한꺼번에 돌려줍니다.
  • 서버 없이 확인하려면 Validator.TryValidateObject 를 직접 부릅니다. 마지막 인수를 true 로 두어야 속성의 규칙을 봅니다.
  • 물음표 없는 문자열은 null 만 막고 빈 문자열은 지나갑니다.
  • 선언에 = "" 를 붙이면 그 검사가 사라집니다.