파트 6: ASP.NET MVC 응용 프로그램을 위한 더 복잡한 데이터 모델 생성하기

등록일시: 2016-04-21 08:00,  수정일시: 2016-09-02 09:14
조회수: 8,801
이 문서는 ASP.NET MVC 기술을 널리 알리고자 하는 개인적인 취지로 제공되는 번역문서입니다. 이 문서에 대한 모든 저작권은 마이크로소프트에 있으며 요청이 있을 경우 언제라도 게시가 중단될 수 있습니다. 번역 내용에 오역이 존재할 수 있고 주석은 번역자 개인의 의견일 뿐이며 마이크로소프트는 이에 관한 어떠한 보장도 하지 않습니다. 번역이 완료된 이후에도 대상 제품 및 기술이 개선되거나 변경됨에 따라 원문의 내용도 변경되거나 보완되었을 수 있으므로 주의하시기 바랍니다.
이번 파트에서는 지금까지 본 자습서 시리즈에서 사용해온 기본적인 데이터 모델에 엔터티와 관계들을 추가하고, 어트리뷰트와 Fluent API를 이용해서 데이터 서식, 유효성 검사, 데이터베이스 매핑 규칙들을 추가하여 보다 복잡한 데이터 모델을 구성해봅니다.

전체 프로젝트 다운로드PDF 다운로드

본 자습서의 Contoso University 예제 웹 응용 프로그램은 Entity Framework 6와 Visual Studio 2013을 이용해서 ASP.NET MVC 5 응용 프로그램을 구축하는 방법을 보여줍니다. 보다 자세한 정보는 본 자습서 시리즈의 첫 번째 자습서를 참고하시기 바랍니다.

지금까지 본 자습서 시리즈에서는 세 개의 엔터티로 구성된 간단한 데이터 모델을 이용해서 작업을 수행했습니다. 이번 파트에서는 데이터 모델에 더 많은 엔터티와 관계들을 추가하고, 서식, 유효성 검사, 그리고 데이터베이스 매핑 규칙들을 추가해서 보다 복잡한 데이터 모델을 구성해보겠습니다. 본문에서는 데이터 모델을 구성하기 위한 두 가지 방식을 살펴보게 될텐데, 엔터티 클래스에 어트리뷰트를 추가하는 방식과 데이터베이스 컨텍스트 클래스에 코드를 추가하는 방식이 바로 그것입니다.

본문에서 설명하는 모든 작업을 마치고 나면 엔터티 클래스들이 다음 그림에서 볼 수 있는 것과 같은 완전한 데이터 모델을 구성하게 됩니다:

어트리뷰트를 이용하여 데이터 모델 구성하기

먼저 이번 절에서는 서식, 유효성 검사, 그리고 데이터베이스 매핑 규칙을 지정하는 어트리뷰트들을 이용해서 데이터 모델을 구성하는 방법을 살펴봅니다. 그리고 이어지는 몇몇 절들에서는 기존에 생성했던 엔터티 클래스들에 어트리뷰트를 추가하고, 모델에 필요한 나머지 엔터티 형식들에 대한 새로운 클래스들을 생성하여 완전한 School 데이터 모델을 생성해봅니다.

DataType 어트리뷰트

본 자습서의 데이터 모델에서 Student 엔터티의 수강일자 같은 경우, 실제로 관심을 갖고 있는 데이터는 날짜뿐이지만, 현재 모든 웹 페이지에서는 날짜와 시간이 함께 출력되고 있습니다. 데이터 주석 어트리뷰트를 사용하면 단 한번의 코드 변경으로 해당 데이터가 나타나는 모든 뷰의 출력 서식을 변경할 수 있습니다. 적용 결과를 실제로 살펴보고 싶다면 Student 클래스의 EnrollmentDate 속성에 어트리뷰트를 추가하면 됩니다.

다음 코드와 같이 Models\Student.cs 클래스 파일을 열고 System.ComponentModel.DataAnnotations 네임스페이스에 대한 using 문을 추가한 다음, EnrollmentDate 속성에 DataType 어트리뷰트와 DisplayFormat 어트리뷰트를 추가합니다:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        public string LastName { get; set; }
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        public DateTime EnrollmentDate { get; set; }
        
        public virtual ICollection<Enrollment> Enrollments { get; set; }
    }
}

DataType 어트리뷰트는 데이터베이스의 내장 형식보다 더 구체적인 데이터 형식을 지정합니다. 이 예제 코드에서는 날짜 및 시간을 함께 관리하는 대신, 날짜만 유지하도록 지정하고 있습니다. DataType 열거형Date, Time, PhoneNumber, Currency, EmailAddress 등과 같은 다양한 데이터 형식들을 제공해줍니다. DataType 어트리뷰트를 사용하면 자동으로 유형별(Type-Specific) 기능들을 제공하도록 응용 프로그램을 활성화시킬 수도 있습니다. 가령 DataType.EmailAddress 열거형을 지정하면 자동으로 mailto: 링크가 생성되고, DataType.Date 열거형을 지정하면 HTML5를 지원하는 브라우저에서 날짜 선택 기능이 제공되는 식입니다. DataType 어트리뷰트는 HTML5를 지원하는 브라우저에서 인식 가능한 HTML5 data-(데이터 대시 라고 읽습니다) 어트리뷰트를 만들어 냅니다. 다만 DataType 어트리뷰트는 어떠한 유효성 검사 기능도 제공하지 않습니다.

또한 DataType.Date 열거형은 출력될 날짜의 서식은 지정하지 않습니다. 데이터 필드는 기본적으로 서버의 CultureInfo에 기반하는 기본 서식에 맞춰 출력됩니다.

날짜의 서식을 명시적으로 지정하기 위해서는 DisplayFormat 어트리뷰트를 사용하면 됩니다:

[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]

이 코드에서 ApplyFormatInEditMode 속성은 편집 텍스트 상자에 값이 출력될 때도 지정된 서식을 적용하도록 지시합니다. (그러나 필드에 따라서는 이런 기능이 불필요한 경우도 있습니다. 예를 들어서, 통화 값의 경우 편집 텍스트 상자에까지 통화 기호가 함께 나타나는 것을 원하는 경우는 많지 않습니다.)

일반적으로 DisplayFormat 어트리뷰트만 단독으로 사용하는 것보다는 DataType 어트리뷰트를 함께 사용하는 것이 좋습니다. DataType 어트리뷰트는 데이터를 화면에 렌더하는 방법이 아닌, 데이터의 의미(Semantics)를 전달해주며 DisplayFormat 어트리뷰트는 지원하지 않는 다음과 같은 이점들을 제공해줍니다:

  • 브라우저에서 HTML5 기능을 사용할 수 있습니다 (달력 컨트롤 지원, 지역에 적합한 통화 기호, 이메일 링크, 일부 클라이언트 측 입력 유효성 검사 등).
  • 기본적으로 브라우저가 자체적으로 로케일에 따라 올바른 서식으로 데이터를 렌더합니다.
  • DataType 어트리뷰트를 사용하면 MVC가 데이터를 렌더할 올바른 필드 템플릿을 선택하는데 도움이 됩니다. (DisplayFormat 어트리뷰트는 일반적인 문자열 템플릿을 사용합니다). 보다 자세한 정보는 Brad Wilson의 ASP.NET MVC 2 Templates 블로그 포스트를 참고하시기 바랍니다. (비록 이 포스트는 MVC 2를 대상으로 작성된 문서지만 현재 버전의 ASP.NET MVC에서도 여전히 유효한 정보를 담고 있습니다.)

날짜 필드에 DataType 어트리뷰트를 지정하는 경우에는 Chrome 브라우저에서 필드가 올바르게 렌더되도록 반드시 DisplayFormat 어트리뷰트를 함께 지정해야 합니다. 이 문제에 대한 자세한 정보는 관련 StackOverflow 스레드를 참고하시기 바랍니다.

그리고 MVC에서 다른 날짜 서식들을 처리하는 방법에 관한 더 많은 정보들은 파트 7: Edit 메서드와 Edit 뷰 살펴보기 자습서에서 "국제화"와 관련된 부분들을 참고하시기 바랍니다.

이제 응용 프로그램을 실행한 다음, Student 메뉴의 Index 페이지로 이동해보면 더 이상 수강일자에 시간이 출력되지 않는 것을 확인할 수 있습니다. 그리고 이 변경사항은 Student 모델을 사용하는 모든 뷰에 동일하게 반영됩니다.

StringLength 어트리뷰트

데이터 유효성 검사 규칙과 유효성 검사 오류 메시지 역시 어트리뷰트를 이용해서 지정할 수 있습니다. StringLength 어트리뷰트는 데이터베이스의 문자열 컬럼의 최대 길이를 설정하고, ASP.NET MVC의 클라이언트 및 서버 측 유효성 검사를 제공해줍니다. 이 어트리뷰트로 문자열의 최소 길이도 지정할 수 있지만, 그 값은 데이터베이스의 스키마에는 어떠한 영향도 주지 않습니다.

본문의 예제에서는 사용자가 이름에 50글자 이상 입력하지 못하게 제한을 두고 싶다고 가정해보겠습니다. 이 제약사항을 추가하려면 다음과 같이 LastName 속성과 FirstMidName 속성에 각각 StringLength 어트리뷰트를 추가합니다:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [StringLength(50)]
        public string LastName { get; set; }
        [StringLength(50, ErrorMessage = "First name cannot be longer than 50 characters.")]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        public DateTime EnrollmentDate { get; set; }
        
        public virtual ICollection<Enrollment> Enrollments { get; set; }
    }
}

그러나 StringLength 어트리뷰트는 사용자가 이름에 공백 문자를 입력하는 것까지는 막지 않습니다. 만약 공백 문자의 입력까지도 제한하고 싶다면 RegularExpression 어트리뷰트를 함께 지정하면 됩니다. 가령 다음 코드는 첫 번째 문자가 대문자인 알파벳만으로 구성된 문자열만 허용합니다:

[RegularExpression(@"^[A-Z][a-zA-Z]+$")]

MaxLength 어트리뷰트도 StringLength 어트리뷰트와 비슷한 기능을 제공해주지만 이 어트리뷰트는 클라이언트 측 유효성 검사를 지원하지 않습니다.

다시 응용 프로그램을 실행하고 Students 메뉴를 클릭해봅니다. 그러면 다음과 같은 오류가 발생할 것입니다:

The model backing the 'SchoolContext' context has changed since the database was created. Consider using Code First Migrations to update the database (http://go.microsoft.com/fwlink/?LinkId=238269).

이런 오류가 발생하는 이유는 데이터베이스 스키마의 변경이 필요한 데이터 모델 변경이 발생했으며, Entity Framework가 그 사실을 감지했기 때문입니다. 이런 경우 마이그레이션을 사용하면 UI를 통해서 데이터베이스에 추가한 데이터를 모두 유지하면서도 스키마를 갱신하여 오류를 해결할 수 있습니다. 그러나 만약 Seed 메서드에 의해서 생성된 데이터를 변경한 적이 있다면, 해당 데이터들은 Seed 메서드에 작성된 AddOrUpdate 메서드로 인해서 모두 원래 상태로 복원될 것입니다. (AddOrUpdate 메서드는 데이터베이스 용어로 "upsert"와 동일한 작업을 수행합니다.)

패키지 관리자 콘솔에 다음 명령들을 입력합니다:

add-migration MaxLengthOnNames
update-database

먼저 이 명령들 중에서 add-migration 명령은 <timeStamp>_MaxLengthOnNames.cs 라는 이름의 파일을 생성합니다. 이 파일에는 데이터베이스를 현재 상태의 데이터 모델과 일치하게 변경하는 Up 메서드의 코드가 만들어집니다. 그리고 이어지는 update-database 명령은 이 코드를 실행합니다.

마이그레이션 파일명 앞에 추가된 타임스탬프 값은 Entity Framework가 마이그레이션들을 정렬하기 위한 용도로 사용됩니다. update-database 명령을 실행하기 전까지 여러 개의 마이그레이션들을 생성할 수 있으며, 명령이 실행되면 모든 마이그레이션들이 생성된 순서대로 적용됩니다.

이제 Create 페이지로 이동한 다음, 이름에 50자 이상의 문자열을 입력해보십시오. 그런 다음 포커스를 이동하거나 Create 버튼을 클릭해보면 클라이언트 측 유효성 검사 오류 메시지가 출력될 것입니다.

Column 어트리뷰트

어트리뷰트로 모델 클래스 및 속성들과 데이터베이스가 매핑되는 방식을 제어할 수도 있습니다. 가령, 현재 모델에서 이름(First name) 필드의 필드명으로 FirstMidName이라는 이름을 사용하고 있는 이유는, 이 필드에 가운데 이름(Middle name)이 포함될 수도 있다고 가정하고 있기 때문입니다. 그런데 이 데이터베이스를 대상으로 임시 질의를 작성하는 사용자들은 이미 FirstName이라는 컬럼명에 익숙해져 있다는 이유 때문에, 정작 이 필드에 대응하는 데이터베이스의 컬럼명은 FirstName이라는 이름을 사용하고 싶은 상황이라고 생각해보겠습니다. 이런 매핑은 Column 어트리뷰트를 이용해서 구성할 수 있습니다.

Column 어트리뷰트는 데이터베이스가 생성될 때 FirstMidName 속성에 매핑되는 Student 테이블의 컬럼명을 FirstName으로 지정합니다. 다시 말해서 이 얘기는 코드에서 Student.FirstMidName 속성을 참조할 때, 실제 데이터는 Student 테이블의 FirstName 컬럼으로부터 조회되거나, 이 컬럼의 데이터가 갱신된다는 뜻입니다. 컬럼명을 지정하지 않으면 속성명과 동일한 이름이 사용됩니다.

다시 Student.cs 클래스 파일로 이동해서 다음 코드에 강조된 것처럼 System.ComponentModel.DataAnnotations.Schema 네임스페이스에 대한 using 문을 추가한 다음, FirstMidName 속성에 Column 어트리뷰트를 추가합니다:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [StringLength(50)]       
        public string LastName { get; set; }
        [StringLength(50, ErrorMessage = "First name cannot be longer than 50 characters.")]
        [Column("FirstName")]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]

        public DateTime EnrollmentDate { get; set; }
        
        public virtual ICollection<Enrollment> Enrollments { get; set; }
    }
}

이렇게 Column 어트리뷰트를 추가하고 나면 SchoolContext의 데이터 모델이 변경되어 데이터베이스와 일치하지 않게 됩니다. 패키지 관리자 콘솔에 다음의 명령들을 입력해서 또 다른 마이그레이션을 생성하고 데이터베이스에 반영합니다:

add-migration ColumnFirstName
update-database

그리고 서버 탐색기(Server Explorer)에서 Student 테이블을 더블 클릭해서 Student 테이블의 테이블 정의를 확인해봅니다.

다음 그림은 지금까지 본문에서 수행한 두 번의 마이그레이션이 적용되기 전의 컬럼명들을 보여주고 있습니다. 컬럼명이 FirstMidName에서 FirstName으로 변경됐을 뿐만 아니라, 이름과 관련된 두 컬럼들의 형식이 nvarchar(MAX)에서 nvarchar(50)로 변경된 것을 알 수 있습니다.

본문의 뒷부분에서 다시 살펴보겠지만, Fluent API를 사용해서 데이터베이스 매핑을 변경할 수도 있습니다.

노트 이어지는 절들에서 살펴볼 엔터티 클래스들을 모두 생성하기 전에 컴파일을 시도하면 컴파일러 오류가 발생합니다.

Student 엔터티 변경 완료하기

지금까지 살펴봤던 Models\Student.cs 클래스 파일의 코드를 다음 코드로 대체합니다. 변경된 사항들이 코드에 강조되어 있습니다.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [Required]
        [StringLength(50)]
        [Display(Name = "Last Name")]
        public string LastName { get; set; }
        [Required]
        [StringLength(50, ErrorMessage = "First name cannot be longer than 50 characters.")]
        [Column("FirstName")]
        [Display(Name = "First Name")]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Enrollment Date")]
        public DateTime EnrollmentDate { get; set; }

        [Display(Name = "Full Name")]
        public string FullName
        {
            get
            {
                return LastName + ", " + FirstMidName;
            }
        }

        public virtual ICollection<Enrollment> Enrollments { get; set; }
    }
}

Required 어트리뷰트

이 코드에서 Required 어트리뷰트는 이름과 관련된 속성들을 필수 입력 필드로 지정합니다. DateTime, int, double, float 같은 값 형식 필드에는 따로 Required 어트리뷰트를 지정할 필요가 없습니다. 값 형식에는 null 값이 할당될 수 없기 때문에 값 형식의 필드들은 그 자체만으로 필수 입력 필드로 간주됩니다. Required 어트리뷰트를 제거하고 대신 StringLength 어트리뷰트에 최소 길이 매개변수를 지정해도 동일한 효과를 얻을 수 있습니다:

[Display(Name = "Last Name")]
[StringLength(50, MinimumLength=1)]
public string LastName { get; set; }

Display 어트리뷰트

그리고 Display 어트리뷰트는 각 인스턴스의 속성명(단어를 구분하는 빈 문자가 없는) 대신, "First Name", "Last Name", "Full Name", 그리고 "Enrollment Date" 같은 단어들을 텍스트 상자의 캡션으로 지정합니다.

FullName 계산 속성

이 코드에서 새로 추가된 FullName 속성은 두 가지 다른 속성들이 결합되어 만들어지는 값을 반환하는 계산 속성(Calculated Property)입니다. 따라서 이 속성에는 get 접근자만 존재하며, 데이터베이스에도 FullName 컬럼은 만들어지지 않습니다.

Instructor 엔터티 생성하기

계속해서 이번에는 Models\Instructor.cs 클래스 파일을 생성하고, 자동으로 만들어진 템플릿 코드를 다음 코드로 대체합니다:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Instructor
    {
        public int ID { get; set; }

        [Required]
        [Display(Name = "Last Name")]
        [StringLength(50)]
        public string LastName { get; set; }

        [Required]
        [Column("FirstName")]
        [Display(Name = "First Name")]
        [StringLength(50)]
        public string FirstMidName { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Hire Date")]
        public DateTime HireDate { get; set; }

        [Display(Name = "Full Name")]
        public string FullName
        {
            get { return LastName + ", " + FirstMidName; }
        }

        public virtual ICollection<Course> Courses { get; set; }
        public virtual OfficeAssignment OfficeAssignment { get; set; }
    }
}

이 코드를 주의 깊게 살펴보면 Student 엔터티와 Instructor 엔터티에 동일하게 존재하는 속성들이 몇 가지 있다는 사실을 알 수 있습니다. 이후 본 자습서 시리즈의 한 파트인 상속 구현하기 파트에서는 이 코드를 리팩터링 해서 코드 중복을 줄이는 방법을 살펴봅니다.

다수의 어트리뷰트들을 한 줄에 작성할 수도 있으므로, Instructor 클래스를 다음과 같이 작성할 수도 있습니다:

public class Instructor
{
    public int ID { get; set; }
    
    [Display(Name = "Last Name"),StringLength(50, MinimumLength=1)]
    public string LastName { get; set; }
    
    [Column("FirstName"),Display(Name = "First Name"),StringLength(50, MinimumLength=1)]
    public string FirstMidName { get; set; }
    
    [DataType(DataType.Date),Display(Name = "Hire Date"),DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
    public DateTime HireDate { get; set; }
    
    [Display(Name = "Full Name")]
    public string FullName
    {
        get { return LastName + ", " + FirstMidName; }
    }
    
    public virtual ICollection<Course> Courses { get; set; }
    public virtual OfficeAssignment OfficeAssignment { get; set; }
}

Courses 탐색 속성과 OfficeAssignment 탐색 속성

이 엔터티에서 Courses 속성과 OfficeAssignment 속성은 탐색 속성입니다. 이미 본 자습서 시리즈의 첫 번째 파트에서 살펴봤던 것처럼 일반적으로 탐색 속성은 지연 로딩(Lazy Loading)이라는 Entity Framework가 제공해주는 기능의 이점을 활용할 수 있도록 virtual 키워드를 사용해서 정의됩니다. 또한 탐색 속성이 복수의 엔터티들을 담을 수 있어야 한다면, 반드시 해당 속성의 형식은 ICollection<T> 인터페이스를 구현하고 있어야 합니다. 예를 들어서, IList<T> 형식은 탐색 속성으로 사용할 수 있지만 IEnumerable<T> 형식은 사용할 수 없습니다. IEnumerable<T> 형식은 Add 메서드를 구현하지 않기 때문입니다.

강사 한 명이 제한 없이 원하는 수 만큼 강의를 지도할 수 있으므로, Courses 탐색 속성은 Course 엔터티들의 컬렉션으로 정의됩니다.

public virtual ICollection<Course> Courses { get; set; }

반면 업무 규칙상 강사는 최대 하나의 사무실만 배정받을 수 있으므로, OfficeAssignment 탐색 속성은 단일 OfficeAssignment 엔터티로 정의됩니다 (만약 사무실을 배정받지 못하면 null이 설정될 수도 있습니다).

public virtual OfficeAssignment OfficeAssignment { get; set; }

OfficeAssignment 엔터티 생성하기

이번에는 Models\OfficeAssignment.cs 클래스 파일을 생성하고, 자동으로 만들어진 템플릿 코드를 다음 코드로 대체합니다:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class OfficeAssignment
    {
        [Key]
        [ForeignKey("Instructor")]
        public int InstructorID { get; set; }
        [StringLength(50)]
        [Display(Name = "Office Location")]
        public string Location { get; set; }

        public virtual Instructor Instructor { get; set; }
    }
}

그리고 프로젝트를 빌드하여 지금까지 작성한 변경사항들을 저장하고, 혹시 컴파일러가 감지할 수 있는 복사 및 붙여넣기 오류가 존재하지는 않는지 확인합니다.

Key 어트리뷰트

Instructor 엔터티와 OfficeAssignment 엔터티는 일대영 또는 일(One-to-Zero-or-One) 관계를 갖고 있습니다. 사무실 배정 정보는 특정 강사에게 사무실이 배정된 경우에만(즉, 관계가 수립된 경우에만) 존재하므로, OfficeAssignment 엔터티의 기본 키는 Instructor 엔터티에 대한 외래 키이기도 합니다. 다만 Entity Framework는 InstructorID 속성을 엔터티의 기본 키로 스스로 인식하지 못하는데, 그 이유는 기본 키 속성의 이름은 ID 또는 classnameID 형태여야 한다는 명명 규칙을 따르지 있지 않기 때문입니다. 그래서 이 예제 코드에서는 Key 어트리뷰트를 이용해서 InstructorID 속성을 키로 지정하고 있습니다:

[Key]
[ForeignKey("Instructor")]
public int InstructorID { get; set; }

또한 엔터티 자체에 기본 키가 존재하기는 하지만 해당 속성명으로 classnameID 또는 ID 외의 다른 임의의 이름을 사용하고 싶은 경우에도 Key 어트리뷰트를 사용할 수 있습니다. 기본적으로 Entity Framework는 해당 키를 테이터베이스에 의해서 값이 자동으로 생성되지 않는(Non-Database-Generated) 키로 간주하는데, 그 이유는 해당 컬럼의 용도가 관계를 식별하기 위한 것이기 때문입니다.

ForeignKey 어트리뷰트

두 개의 엔터티가 서로 일대영 또는 일(One-to-Zero-or-One) 관계거나 일대일(One-to-One) 관계인 경우 (본문의 OfficeAssignment 엔터티와 Instructor 엔터티처럼), Entity Framework는 어느 쪽이 주 끝(Principal End)이고 어느 쪽이 종속 끝(Dependent End)인지 구분이 불가능합니다. 일대일 관계에서 두 클래스는 서로 다른 클래스에 대한 참조 탐색 속성을 갖게 됩니다. 이런 경우 ForeignKey 어트리뷰트를 종속 클래스에 지정해서 관계를 구성할 수 있습니다. 만약 ForeignKey 어트리뷰트를 누락한 채로 마이그레이션을 생성하려고 시도하면 다음과 같은 오류가 발생하게 됩니다:

Unable to determine the principal end of an association between the types 'ContosoUniversity.Models.OfficeAssignment' and 'ContosoUniversity.Models.Instructor'. The principal end of this association must be explicitly configured using either the relationship fluent API or data annotations.

본 자습서의 뒷부분에서는 Fluent API를 사용해서 이 관계를 구성하는 방법을 살펴봅니다.

Instructor 탐색 속성

Instructor 엔터티는 null을 허용하는 OfficeAssignment 탐색 속성을 갖고 있는 반면 (강사가 사무실을 배정받지 못하는 경우도 있으므로), OfficeAssignment 엔터티는 null을 허용하지 않는 Instructor 탐색 속성을 갖고 있습니다 (강사가 지정되지 않는 사무실 배정은 존재할 수 없으며, InstructorID 속성 역시 null을 허용하지 않습니다). Instructor 엔터티와 관계를 갖고 있는 OfficeAssignment 엔터티가 존재할 경우, 결과적으로 두 엔터티들은 각자 자신의 탐색 속성을 통해서 서로 상대 엔터티에 대한 참조를 갖게 됩니다.

그리고 Instructor 탐색 속성에 [Required] 어트리뷰트를 지정해서 반드시 관계된 강사 정보가 존재해야 함을 규칙으로 지정할 수도 있지만, 이미 InstructorID 외래 키(이 테이블의 키이기도 합니다)가 null을 허용하지 않으므로 반드시 필요한 작업은 아닙니다.

Course 엔터티 수정하기

이번에는 Models\Course.cs 클래스 파일을 열고, 이전에 작성했던 코드를 다음 코드로 대체합니다:

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Course
    {
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        [Display(Name = "Number")]
        public int CourseID { get; set; }
        
        [StringLength(50, MinimumLength = 3)]
        public string Title { get; set; }
        
        [Range(0, 5)]
        public int Credits { get; set; }
        
        public int DepartmentID { get; set; }
        
        public virtual Department Department { get; set; }
        public virtual ICollection<Enrollment> Enrollments { get; set; }
        public virtual ICollection<Instructor> Instructors { get; set; }
    }
}

이 새로운 Course 엔터티는 관련된 Department 엔터티를 가리키는 외래 키 속성인 DepartmentID 속성과 Department 탐색 속성을 모두 갖고 있습니다. Entity Framework는 관련 엔터티에 대한 탐색 속성이 이미 존재할 경우, 데이터 모델에 굳이 외래 키 속성을 추가할 것을 요구하지는 않으며, 필요하다면 데이터베이스 어디에나 자동으로 외래 키를 생성합니다. 그러나 데이터 모델에 외래 키를 갖고 있으면 더 간단하고 효과적으로 데이터를 갱신할 수 있습니다. 가령 편집을 하기 위해서 Course 엔터티를 가져오면서 Department 엔터티를 명시적으로 로드하지 않는다면 탐색 속성은 null이 되므로, Course 엔터티를 갱신하기 위해서는 먼저 Department 엔터티부터 가져와야 할 것입니다. 반면 데이터 모델에 이미 외래 키 속성인 DepartmentID 속성이 포함되어 있으면, 갱신 전에 별도로 Department 엔터티를 가져올 필요가 없습니다.

DatabaseGenerated 어트리뷰트

CourseID 속성에 None 매개변수와 함께 지정된 DatabaseGenerated 어트리뷰트는 기본 키 값이 데이터베이스에 의해 생성되는 것이 아니라 사용자에 의해서 제공됨을 명시합니다.

[DatabaseGenerated(DatabaseGeneratedOption.None)]
[Display(Name = "Number")]
public int CourseID { get; set; }

기본적으로 Entity Framework는 기본 키 값이 데이터베이스에 의해서 생성된다고 가정하며, 대부분의 시나리오에서는 이 가정에 큰 문제가 없습니다. 다만 Course 엔터티에서는 한 학과에 대해서는 1000번대 강의 번호를, 다른 학과에 대해서는 2000번대 강의 번호를 사용하는 식으로 사용자 지정 강의 번호를 사용할 것입니다.

외래 키 및 탐색 속성들

Course 엔터티의 외래 키 속성과 탐색 속성들은 다음과 같은 관계들을 반영하고 있습니다:

  • 강의는 한 학과에 배정되며, 그에 따라 방금 설명했던 것과 같은 이유로 DepartmentID 외래 키와 Department 탐색 속성이 제공됩니다.
    public int DepartmentID { get; set; }
    public virtual Department Department { get; set; }
  • 강의를 수강할 수 있는 학생들의 수에는 제한이 없기 때문에, Enrollments 탐색 속성은 컬렉션입니다:
    public virtual ICollection<Enrollment> Enrollments { get; set; }
  • 강의는 여러 강사에 의해서 진행될 수 있으므로, Instructors 탐색 속성은 컬렉션입니다:
    public virtual ICollection<Instructor> Instructors { get; set; }

Department 엔터티 생성하기

이번에는 다음 코드를 이용해서 Models\Department.cs 클래스 파일을 생성합니다:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Department
    {
        public int DepartmentID { get; set; }
        
        [StringLength(50, MinimumLength=3)]
        public string Name { get; set; }
        
        [DataType(DataType.Currency)]
        [Column(TypeName = "money")]
        public decimal Budget { get; set; }
        
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }
        
        public int? InstructorID { get; set; }
        
        public virtual Instructor Administrator { get; set; }
        public virtual ICollection<Course> Courses { get; set; }
    }
}

Column 어트리뷰트

앞에서는 컬럼명의 매핑을 변경하기 위해서 Column 어트리뷰트를 사용했었습니다. 그러나 Department 엔터티에서는 SQL 데이터 형식의 매핑을 변경해서 데이터베이스에서 해당 컬럼이 SQL Server의 money 형식으로 정의되도록 지정하는 용도로 Column 어트리뷰트를 사용하고 있습니다:

[Column(TypeName="money")]
public decimal Budget { get; set; }

일반적으로는 속성 정의에 사용된 CLR 형식을 기반으로 Entity Framework가 적절한 SQL Server 데이터 형식을 선택해주기 때문에 명시적인 컬럼 매핑이 불필요합니다. 그리고 CLR decimal 형식은 SQL Server의 decimal 형식과 매핑됩니다. 그러나 이 엔터티의 경우, 이 컬럼에 통화 금액이 담길 것이라는 점을 알고 있으며, 이 용도로는 money 데이터 형식이 더 알맞습니다. CLR 데이터 형식들이 SQL Server 데이터 형식과 매치되는 방법에 대한 더 많은 정보는 SqlClient for Entity Framework Types 문서를 참고하시기 바랍니다.

외래 키 및 탐색 속성들

이 엔터티의 외래 키 속성과 탐색 속성들은 다음과 같은 관계들을 반영하고 있습니다:

  • 한 학과에는 관리자가 존재할 수도 또는 존재하지 않을 수도 있으며, 관리자는 항상 강사입니다. 그에 따라, Instructor 엔터티에 대한 외래 키로 InstructorID 속성이 포함되어 있으며, 속성 정의의 int 형식 지정 후 추가되어 있는 물음표는 속성이 nullable 형식임을 나타냅니다. 또한 탐색 속성의 이름은 Administrator지만 Instructor 엔터티를 담습니다:
    public int? InstructorID { get; set; }
    public virtual Instructor Administrator { get; set; }
  • 한 학과에는 여러 가지 종류의 강의가 존재할 수 있으므로, 컬렉션 형태의 Courses 탐색 속성이 제공됩니다:
    public virtual ICollection<Course> Courses { get; set; }
노트 규약에 따라 Entity Framework는 null을 허용하지 않는 외래 키와 다대다(Many-to-Many) 관계에 대해서 하위 삭제(Cascade Delete)를 활성화시킵니다. 그 결과, 순환 하위 삭제 규칙이 만들어질 수 있는데, 이 때 마이그레이션을 추가하려고 시도하면 예외가 발생하게 됩니다. 가령, Department.InstructorID 속성을 nullable로 정의하지 않는다면, "The referential relationship will result in a cyclical reference that's not allowed."라는 예외 메시지가 발생할 것입니다. 만약 업무 규칙 때문에 InstructorID 속성에 null을 허용하지 말아야 한다면, 다음과 같은 Fluent API 구문을 이용해서 해당 관계에 대한 순환 하위 삭제를 비활성화시켜야 합니다:
modelBuilder.Entity<Department>()
            .HasRequired(d => d.Administrator)
            .WithMany()
            .WillCascadeOnDelete(false);

Enrollment 엔터티 수정하기

이번에는 Models\Enrollment.cs 클래스 파일을 열고, 이전에 작성했던 코드를 다음 코드로 대체합니다:

using System.ComponentModel.DataAnnotations;

namespace ContosoUniversity.Models
{
    public enum Grade
    {
        A, B, C, D, F
    }

    public class Enrollment
    {
        public int EnrollmentID { get; set; }
        public int CourseID { get; set; }
        public int StudentID { get; set; }
        [DisplayFormat(NullDisplayText = "No grade")]
        public Grade? Grade { get; set; }

        public virtual Course Course { get; set; }
        public virtual Student Student { get; set; }
    }
}

외래 키 및 탐색 속성들

이 엔터티의 외래 키 속성과 탐색 속성들은 다음과 같은 관계들을 반영하고 있습니다:

  • 특정 수강 기록은 한 강의에 대한 것이므로, CourseID 외래 키 속성과 Course 탐색 속성이 제공됩니다:
    public int CourseID { get; set; }
    public virtual Course Course { get; set; }
  • 특정 수강 기록은 학생 한 명에 대한 것이므로, StudentID 외래 키 속성과 Student 탐색 속성이 제공됩니다:
    public int StudentID { get; set; }
    public virtual Student Student { get; set; }

다대다 관계

예제 데이터 모델에서 Student 엔터티와 Course 엔터티는 다대다(Many-to-Many) 관계를 갖고 있으며, 그 사이에 위치한 Enrollment 엔터티는 데이터베이스 상에서 데이터 적재(Payload) 기능과 다대다 조인 테이블의 기능을 동시에 수행합니다. 다시 말해서 Enrollment 테이블은 조인되는 테이블들에 대한 외래 키와 부가적인 데이터를(이 경우, 기본 키와 Grade 속성을) 함께 담고 있다는 뜻입니다.

다음 그림은 엔터티 다이어그램으로 살펴본 이 관계의 모습을 보여주고 있습니다. (이 다이어그램은 Entity Framework Power Tools를 이용해서 생성된 것으로, 본문에서는 다이어그램 생성 방법에 관해서는 다루지 않습니다. 이 그림은 단지 참고 용도일 뿐입니다.)

이 다이어그램에 나타나 있는 관계선(Relationship Line)들의 한쪽 끝에는 1이, 그리고 다른 끝에는 별표(*)가 자리잡고 있으며, 이 관계선들은 일대다(One-to-Many) 관계를 나타냅니다.

만약 Enrollment 테이블에 학점(Grade) 정보를 담지 않는다면 결과적으로 두 외래 키, 즉 CourseID 컬럼과 StudentID 컬럼만 남게 됩니다. 이 경우, 데이터베이스에서 Enrollment 테이블은 데이터를 적재하지 않는 다대다 조인 테이블 (또는 순수한 조인 테이블) 에 해당되며 모델 클래스 자체를 생성할 필요가 없어집니다. 즉 다음 다이어그램에서 볼 수 있는 것처럼 Instructor 엔터티와 Course 엔터티 간에는 그 사이에 어떠한 엔터티도 존재하지 않는 형태의 다대다 관계가 존재하게 됩니다:

그러나 다음 데이터베이스 다이어그램에서 볼 수 있는 것처럼 데이터베이스에서는 조인 테이블이 필요합니다:

이 경우, Entity Framework는 자동으로 데이터베이스에 CourseInstructor 테이블을 생성해주고, Instructor.Courses 탐색 속성 및 Course.Instructors 탐색 속성을 읽거나 갱신할 때 이 테이블까지 간접적으로 읽거나 갱신하게 됩니다.

엔터티 다이어그램으로 관계 살펴보기

다음 그림은 Entity Framework Power Tools로 생성한 완전한 School 데이터 모델의 다이어그램입니다.

이 다이어그램을 살펴보면 다대다 관계선(* 대 *)과 일대다 관계선(1 대 *)을 비롯해서, Instructor 엔터티와 OfficeAssignment 엔터티 간의 일대영 또는 일 관계선(1 대 0..1)과 Instructor 엔터티와 Department 엔터티 간의 영대일 또는 다 관계선(0..1 대 *)을 확인할 수 있습니다.

데이터베이스 컨텍스트에 코드를 추가하여 데이터 모델 구성하기

계속해서 이번에는 새로운 엔터티들을 SchoolContext 클래스에 추가한 다음, Fluent API 호출을 이용해서 일부 매핑을 구성해보겠습니다. 이 API를 "Fluent"라고 부르는 이유는 주로 다음과 같이 단일 구문에서 일련의 메서드 호출들이 연이어 함께 사용되기 때문입니다:

 modelBuilder.Entity<Course>()
    .HasMany(c => c.Instructors).WithMany(i => i.Courses)
    .Map(t => t.MapLeftKey("CourseID")
        .MapRightKey("InstructorID")
        .ToTable("CourseInstructor"));

본문에서는 어트리뷰트를 이용해서 매핑을 구성하기 어려운 경우에만 Fluent API를 사용합니다. 그러나 Fluent API를 사용해도 어트리뷰트로 지정할 수 있는 대부분의 서식 지정이나 유효성 검사 그리고 규칙 매핑들을 구성할 수 있습니다. 다만 MinimumLength 같은 일부 어트리뷰트들은 Fluent API로는 적용이 불가능합니다. 이미 설명했던 것처럼 MinimumLength 어트리뷰트는 스키마를 변경하지 않으며 단지 클라이언트 및 서버 측 유효성 검사 규칙만 적용합니다.

일부 개발자들은 전적으로 Fluent API만 사용함으로서 엔터티 클래스들을 "깔끔하게" 유지하는 방식을 선호하기도 합니다. 여러분이 원한다면 어트리뷰트와 Fluent API를 함께 섞어서 사용해도 무방하며, Fluent API를 이용하는 경우에만 수행할 수 있는 사용자 지정 설정들도 일부 존재합니다. 그러나 일반적으로 권장되는 방법은 이 두 가지 접근방식 중 한 가지 방식을 선택해서 최대한 일관되게 그 방식만 사용하는 것입니다.

다음 코드로 DAL\SchoolContext.cs 파일의 코드를 대체해서, 데이터 모델에 새로운 엔터티들을 추가하고 어트리뷰트로는 처리할 수 없는 데이터베이스 매핑을 수행합니다:

using ContosoUniversity.Models;
using System.Data.Entity;
using System.Data.Entity.ModelConfiguration.Conventions;

namespace ContosoUniversity.DAL
{
    public class SchoolContext : DbContext
    {
        public DbSet<Course> Courses { get; set; }
        public DbSet<Department> Departments { get; set; }
        public DbSet<Enrollment> Enrollments { get; set; }
        public DbSet<Instructor> Instructors { get; set; }
        public DbSet<Student> Students { get; set; }
        public DbSet<OfficeAssignment> OfficeAssignments { get; set; }
        
        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
            
            modelBuilder.Entity<Course>()
                .HasMany(c => c.Instructors).WithMany(i => i.Courses)
                .Map(t => t.MapLeftKey("CourseID")
                    .MapRightKey("InstructorID")
                    .ToTable("CourseInstructor"));
        }
    }
}

이 코드에서 OnModelCreating 메서드에 추가된 새로운 구문은 다대다 조인 테이블을 구성하는 코드입니다:

  • 이 코드는 Instructor 엔터티와 Course 엔터티 간의 다대다 관계에 대한 조인 테이블의 테이블명과 컬럼명들을 지정합니다. 물론 이 코드 구문을 추가하지 않더라도 Code First가 자동으로 다대다 관계를 구성해주지만, 대신 그럴 경우 InstructorID 컬럼의 이름이 InstructorInstructorID 같은 기본 이름으로 만들어집니다.

    modelBuilder.Entity<Course>()
        .HasMany(c => c.Instructors).WithMany(i => i.Courses)
        .Map(t => t.MapLeftKey("CourseID")
            .MapRightKey("InstructorID")
            .ToTable("CourseInstructor"));

다음 코드는 어트리뷰트 대신 Fluent API를 사용해서 Instructor 엔터티와 OfficeAssignment 엔터티 간에 관계를 지정하는 예제를 보여줍니다:

modelBuilder.Entity<Instructor>()
    .HasOptional(p => p.OfficeAssignment).WithRequired(p => p.Instructor);

Fluent API 구문이 내부적으로 수행하는 작업에 대한 보다 자세한 정보는 Fluent API 블로그 포스트를 참고하시기 바랍니다.

데이터베이스에 테스트 데이터 입력하기

다음 코드로 Migrations\Configuration.cs 파일의 코드를 대체하여 본문에서 생성한 새로운 엔터티들의 시드 데이터를 제공합니다.

namespace ContosoUniversity.Migrations
{
    using ContosoUniversity.Models;
    using ContosoUniversity.DAL;
    using System;
    using System.Collections.Generic;
    using System.Data.Entity;
    using System.Data.Entity.Migrations;
    using System.Linq;
    
    internal sealed class Configuration : DbMigrationsConfiguration<SchoolContext>
    {
        public Configuration()
        {
            AutomaticMigrationsEnabled = false;
        }

        protected override void Seed(SchoolContext context)
        {
            var students = new List<Student>
            {
                new Student { FirstMidName = "Carson",   LastName = "Alexander",
                    EnrollmentDate = DateTime.Parse("2010-09-01") },
                new Student { FirstMidName = "Meredith", LastName = "Alonso",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Arturo",   LastName = "Anand",
                    EnrollmentDate = DateTime.Parse("2013-09-01") },
                new Student { FirstMidName = "Gytis",    LastName = "Barzdukas",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Yan",      LastName = "Li",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Peggy",    LastName = "Justice",
                    EnrollmentDate = DateTime.Parse("2011-09-01") },
                new Student { FirstMidName = "Laura",    LastName = "Norman",
                    EnrollmentDate = DateTime.Parse("2013-09-01") },
                new Student { FirstMidName = "Nino",     LastName = "Olivetto",
                    EnrollmentDate = DateTime.Parse("2005-09-01") }
            };
            students.ForEach(s => context.Students.AddOrUpdate(p => p.LastName, s));
            context.SaveChanges();

            var instructors = new List<Instructor>
            {
                new Instructor { FirstMidName = "Kim",     LastName = "Abercrombie",
                    HireDate = DateTime.Parse("1995-03-11") },
                new Instructor { FirstMidName = "Fadi",    LastName = "Fakhouri",
                    HireDate = DateTime.Parse("2002-07-06") },
                new Instructor { FirstMidName = "Roger",   LastName = "Harui",
                    HireDate = DateTime.Parse("1998-07-01") },
                new Instructor { FirstMidName = "Candace", LastName = "Kapoor",
                    HireDate = DateTime.Parse("2001-01-15") },
                new Instructor { FirstMidName = "Roger",   LastName = "Zheng",
                    HireDate = DateTime.Parse("2004-02-12") }
            };
            instructors.ForEach(s => context.Instructors.AddOrUpdate(p => p.LastName, s));
            context.SaveChanges();

            var departments = new List<Department>
            {
                new Department { Name = "English",     Budget = 350000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID = instructors.Single(i => i.LastName == "Abercrombie").ID },
                new Department { Name = "Mathematics", Budget = 100000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID = instructors.Single(i => i.LastName == "Fakhouri").ID },
                new Department { Name = "Engineering", Budget = 350000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID = instructors.Single(i => i.LastName == "Harui").ID },
                new Department { Name = "Economics",   Budget = 100000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID = instructors.Single(i => i.LastName == "Kapoor").ID }
            };
            departments.ForEach(s => context.Departments.AddOrUpdate(p => p.Name, s));
            context.SaveChanges();

            var courses = new List<Course>
            {
                new Course {CourseID = 1050, Title = "Chemistry",      Credits = 3,
                    DepartmentID = departments.Single(s => s.Name == "Engineering").DepartmentID,
                    Instructors = new List<Instructor>()
                },
                new Course {CourseID = 4022, Title = "Microeconomics", Credits = 3,
                    DepartmentID = departments.Single(s => s.Name == "Economics").DepartmentID,
                    Instructors = new List<Instructor>()
                },
                new Course {CourseID = 4041, Title = "Macroeconomics", Credits = 3,
                    DepartmentID = departments.Single(s => s.Name == "Economics").DepartmentID,
                    Instructors = new List<Instructor>()
                },
                new Course {CourseID = 1045, Title = "Calculus",       Credits = 4,
                    DepartmentID = departments.Single(s => s.Name == "Mathematics").DepartmentID,
                    Instructors = new List<Instructor>()
                },
                new Course {CourseID = 3141, Title = "Trigonometry",   Credits = 4,
                    DepartmentID = departments.Single(s => s.Name == "Mathematics").DepartmentID,
                    Instructors = new List<Instructor>()
                },
                new Course {CourseID = 2021, Title = "Composition",    Credits = 3,
                    DepartmentID = departments.Single(s => s.Name == "English").DepartmentID,
                    Instructors = new List<Instructor>()
                },
                new Course {CourseID = 2042, Title = "Literature",     Credits = 4,
                    DepartmentID = departments.Single(s => s.Name == "English").DepartmentID,
                    Instructors = new List<Instructor>()
                },
            };
            courses.ForEach(s => context.Courses.AddOrUpdate(p => p.CourseID, s));
            context.SaveChanges();

            var officeAssignments = new List<OfficeAssignment>
            {
                new OfficeAssignment {
                    InstructorID = instructors.Single(i => i.LastName == "Fakhouri").ID,
                    Location = "Smith 17" },
                new OfficeAssignment {
                    InstructorID = instructors.Single(i => i.LastName == "Harui").ID,
                    Location = "Gowan 27" },
                new OfficeAssignment {
                    InstructorID = instructors.Single(i => i.LastName == "Kapoor").ID,
                    Location = "Thompson 304" },
            };
            officeAssignments.ForEach(s => context.OfficeAssignments.AddOrUpdate(p => p.InstructorID, s));
            context.SaveChanges();

            AddOrUpdateInstructor(context, "Chemistry", "Kapoor");
            AddOrUpdateInstructor(context, "Chemistry", "Harui");
            AddOrUpdateInstructor(context, "Microeconomics", "Zheng");
            AddOrUpdateInstructor(context, "Macroeconomics", "Zheng");

            AddOrUpdateInstructor(context, "Calculus", "Fakhouri");
            AddOrUpdateInstructor(context, "Trigonometry", "Harui");
            AddOrUpdateInstructor(context, "Composition", "Abercrombie");
            AddOrUpdateInstructor(context, "Literature", "Abercrombie");

            context.SaveChanges();

            var enrollments = new List<Enrollment>
            {
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry").CourseID,
                    Grade = Grade.A
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Microeconomics").CourseID,
                    Grade = Grade.C 
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Macroeconomics").CourseID,
                    Grade = Grade.B
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Calculus").CourseID,
                    Grade = Grade.B 
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Trigonometry").CourseID,
                    Grade = Grade.B 
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Composition").CourseID,
                    Grade = Grade.B 
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Anand").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry").CourseID
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Anand").ID,
                    CourseID = courses.Single(c => c.Title == "Microeconomics").CourseID,
                    Grade = Grade.B
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Barzdukas").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry").CourseID,
                    Grade = Grade.B
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Li").ID,
                    CourseID = courses.Single(c => c.Title == "Composition").CourseID,
                    Grade = Grade.B
                },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Justice").ID,
                    CourseID = courses.Single(c => c.Title == "Literature").CourseID,
                    Grade = Grade.B
                }
            };

            foreach (Enrollment e in enrollments)
            {
                var enrollmentInDataBase = context.Enrollments.Where(
                    s => s.Student.ID == e.StudentID &&
                         s.Course.CourseID == e.CourseID).SingleOrDefault();
                if (enrollmentInDataBase == null)
                {
                    context.Enrollments.Add(e);
                }
            }
            context.SaveChanges();
        }

        void AddOrUpdateInstructor(SchoolContext context, string courseTitle, string instructorName)
        {
            var crs = context.Courses.SingleOrDefault(c => c.Title == courseTitle);
            var inst = crs.Instructors.SingleOrDefault(i => i.LastName == instructorName);
            if (inst == null)
                crs.Instructors.Add(context.Instructors.Single(i => i.LastName == instructorName));
        }
    }
}

본 자습서 시리즈의 첫 번째 파트에서 살펴본 것처럼, 이 코드의 대부분은 단순히 엔터티를 갱신하거나 새로운 엔터티 개체들을 생성하고 테스트에 필요한 예제 데이터들을 속성에 적재하는 코드입니다. 다만 여기서 Instructor 엔터티와 다대다(Many-to-Many) 관계를 갖고 있는 Course 엔터티가 처리되는 방식을 주의해서 살펴보시기 바랍니다:

var courses = new List<Course>
{
    new Course {CourseID = 1050, Title = "Chemistry",      Credits = 3,
        DepartmentID = departments.Single(s => s.Name == "Engineering").DepartmentID,
        Instructors = new List<Instructor>()
    },
    ...
};
courses.ForEach(s => context.Courses.AddOrUpdate(p => p.CourseID, s));
context.SaveChanges();

이 코드는 Course 개체를 생성할 때, Instructors = new List<Instructor>()라는 코드를 이용해서 Instructors 탐색 속성을 빈 컬렉션으로 초기화시키고 있습니다. 그 결과 Instructors.Add 메서드를 사용해서 해당 Course 엔터티와 관계가 존재하는 Instructor 엔터티들을 추가할 수 있게 됩니다. 만약 이 빈 목록을 생성하지 않는다면 Instructors 속성이 null이므로 Add 메서드를 사용할 수 없기 때문에 관계들을 추가할 수가 없습니다. 이 목록 초기화 코드는 생성자에 추가할 수도 있습니다.

마이그레이션 추가 및 데이터베이스 갱신하기

패키지 관리자 콘솔에 add-migration 명령을 입력합니다 (아직 update-database 명령은 수행하지 마십시오):

add-Migration ComplexDataModel

만약 현재 상태에서 update-database 명령을 수행하려고 시도하면 (아직 수행하지 마십시오), 다음과 같은 오류가 발생할 것입니다:

The ALTER TABLE statement conflicted with the FOREIGN KEY constraint "FK_dbo.Course_dbo.Department_DepartmentID". The conflict occurred in database "ContosoUniversity", table "dbo.Department", column 'DepartmentID'.

간혹 기존 데이터가 존재하는 상태에서 마이그레이션을 실행할 경우, 외래 키 제약 조건을 만족시키기 위해서는 데이터베이스에 스텁 데이터를 입력해야 할 필요가 있는데, 바로 지금이 그런 경우입니다. ComplexDataModel 클래스의 Up 메서드에 생성된 코드는 Course 테이블에 null을 허용하지 않는 DepartmentID 외래 키를 추가합니다. 그러나 이 코드가 실행되는 시점에는 Course 테이블에 이미 기존의 로우들이 존재하고 있으므로, SQL Server로서는 새로 추가되는 null을 허용하지 않는 컬럼에 어떤 값을 입력해야 할지를 알 수가 없기 때문에 AddColumn 작업이 실패하게 됩니다. 그러므로 새로운 컬럼에 기본 값을 제공하고, 기본 학과로 사용할 "Temp"라는 이름의 스텁 학과를 생성하도록 코드를 수정해야만 합니다. 결과적으로 기존의 모든 Course 로우들은 Up 메서드가 실행되고 난 뒤에는 모두 "Temp" 학과와 관계를 갖게 됩니다. 그러면 Seed 메서드에서 올바른 학과들을 대상으로 관계를 수립할 수 있습니다.

자동으로 생성된 <timestamp>_ComplexDataModel.cs 파일을 편집해서, Course 테이블에 DepartmentID 컬럼을 추가하는 코드를 주석으로 처리한 다음, 다음에 강조된 코드들을 추가합니다 (주석으로 처리된 줄도 같이 강조되어 있습니다):

   CreateTable(
        "dbo.CourseInstructor",
        c => new
            {
                CourseID = c.Int(nullable: false),
                InstructorID = c.Int(nullable: false),
            })
        .PrimaryKey(t => new { t.CourseID, t.InstructorID })
        .ForeignKey("dbo.Course", t => t.CourseID, cascadeDelete: true)
        .ForeignKey("dbo.Instructor", t => t.InstructorID, cascadeDelete: true)
        .Index(t => t.CourseID)
        .Index(t => t.InstructorID);

    // Create  a department for course to point to.
    Sql("INSERT INTO dbo.Department (Name, Budget, StartDate) VALUES ('Temp', 0.00, GETDATE())");
    //  default value for FK points to department created above.
    AddColumn("dbo.Course", "DepartmentID", c => c.Int(nullable: false, defaultValue: 1));
    //AddColumn("dbo.Course", "DepartmentID", c => c.Int(nullable: false));

    AlterColumn("dbo.Course", "Title", c => c.String(maxLength: 50));

이 상태에서 Seed 메서드가 실행되면, Department 테이블에 로우들이 추가되고 기존의 Course 로우들과 새로운 Department 로우들 사이에 관계가 설정됩니다. 만약 UI를 이용해서 추가된 강의 정보가 전혀 없다면, 더 이상 "Temp" 학과나 Course.DepartmentID 컬럼에 대한 기본값은 필요 없을 것입니다. 그러나 이후로도 누군가가 응용 프로그램을 이용해서 강의 정보를 추가할 수도 있다는 가능성에 대비하려면, 컬럼에서 기본 값을 제거하거나 "Temp" 학과를 삭제하기 전에, 먼저 Course 테이블의 모든 로우들이 (이전에 실행된 Seed 메서드에 의해서 입력된 로우들 외에도) 유효한 DepartmentID 값을 가질 수 있도록 Seed 메서드의 코드를 개선할 필요가 있을 것입니다.

이제 <timestamp>_ComplexDataModel.cs 파일의 편집을 마쳤다면, 패키지 관리자 콘솔에 update-database 명령을 입력하여 마이그레이션을 수행합니다.

update-database

노트: 데이터를 마이그레이션 하거나 스키마를 변경할 때 또 다른 오류들이 발생할 수도 있습니다. 만약 직접 해결하기 어려운 마이그레이션 오류가 발생한다면, 연결 문자열의 데이터베이스 이름을 변경하거나 데이터베이스를 삭제해버리면 됩니다. 가장 간단한 방법은 Web.config 파일에서 데이터베이스의 이름을 변경하는 것입니다. 다음은 데이터베이스의 이름을 CU_Test 테스트로 변경하는 예제를 보여줍니다:

 <add name="SchoolContext" 
      connectionString="Data Source=(LocalDb)\v11.0;Initial Catalog=CU_Test;Integrated Security=SSPI;"
      providerName="System.Data.SqlClient" />

이렇게 새로운 데이터베이스를 지정하면 마이그레이션 할 데이터가 존재하지 않으므로 update-database 명령이 오류 없이 완료될 가능성이 훨씬 더 높습니다. 데이터베이스를 삭제하는 방법에 관해서는 How to Drop a Database from Visual Studio 2012 블로그 포스트를 참고하시기 바랍니다.

만약 실패한다면, 패키지 관리자 콘솔에 다음 명령을 입력해서 데이터베이스를 완전히 다시 초기화시켜볼 수도 있습니다:

update-database -TargetMigration:0

다시 서버 탐색기(Server Explorer)에서 데이터베이스를 열고, 테이블(Tables) 노트를 확장시켜보면 모든 테이블들이 생성된 것을 확인할 수 있습니다. (만약 서버 탐색기(Server Explorer)를 계속 열어 놓고 있었다면, 새로 고침(Refresh) 버튼을 클릭합니다.)

본문에서는 CourseInstructor 테이블에 대한 모델 클래스를 생성하지 않았습니다. 이미 설명했던 것처럼 이 테이블은 Instructor 엔터티와 Course 엔터티 간의 다대다 관계에 대한 조인 테이블입니다. 마우스 오른쪽 버튼으로 CourseInstructor 테이블을 클릭하고 테이블 데이터 표시(Show Table Data)를 선택하면 Course.Instructors 탐색 속성에 Instructor 엔터티들을 추가한 결과에 따른 데이터가 담겨 있는 것을 확인할 수 있습니다.

요약

본문에서는 보다 복잡한 데이터 모델과 그에 대응하는 데이터베이스를 생성해봤습니다. 다음 자습서에서는 관계가 존재하는 데이터에 접근하는 다양한 방법들에 관해서 더 자세하게 살펴봅니다.

자습서 내용 중 만족스러웠던 점이나 개선사항에 대한 의견이 있으시면 피드백으로 남겨주시기 바랍니다. 또한 Show Me How With Code를 통해서 새로운 주제를 요청하실 수도 있습니다.

다른 Entity Framework 리소스들에 대한 링크들은 ASP.NET Data Access - Recommended Resources 기사를 참고하시기 바랍니다.

이 기사는 2014년 2월 14일에 최초 작성되었습니다.