ASP.NET Core 3.2 · 3부. 데이터 접근

DbContext와 모델 매핑

형식을 표에 맞춥니다.

예상 학습 시간 18분 난이도 중급
개념 설명

규약이 맞지 않을 때

3.1에서 클래스만 적었는데 표가 만들어졌습니다. 규약이 정해져 있기 때문입니다. 그런데 이미 있는 데이터베이스에 맞춰야 하는 경우가 많습니다. 표 이름이 TODO_LIST 이고 컬럼이 NUM·SUBJECT 라면 규약과 어긋납니다.

적는 방법이 둘입니다.

  • 어트리뷰트 — 형식과 속성 위에 붙입니다. 2.3의 검사 규칙과 같은 모양입니다.
  • Fluent APIOnModelCreating 에 코드로 적습니다.

둘 다 사용할 수 있고, 같은 것을 두 곳에 적으면 Fluent API 가 이깁니다. 아래에서 확인합니다.

최소 예제

같은 표를 세 가지로

먼저 규약만 따랐을 때입니다. 3.1과 같습니다.

규약만
public class Plain {
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string? Memo { get; set; }
    public decimal Cost { get; set; }
}
만들어진 표
CREATE TABLE "Plains" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_Plains" PRIMARY KEY AUTOINCREMENT, "Title" TEXT NOT NULL, "Memo" TEXT NULL, "Cost" TEXT NOT NULL );

이 코드는 EF Core 와 데이터베이스가 있어야 하므로 브라우저에서 실행할 수 없습니다.

decimalTEXT 가 된 것을 보십시오. SQLite 에 decimal 형식이 없어 글자로 담습니다. 같은 코드라도 데이터베이스가 다르면 다른 표가 만들어집니다. SQL Server 였다면 decimal(18,2) 입니다.

어트리뷰트로 적으면

어트리뷰트
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

[Table("TODO_LIST")]
public class Attr {
    [Key]
    [Column("NUM")]
    public int Num { get; set; }

    [Required]
    [MaxLength(20)]
    [Column("SUBJECT")]
    public string? Title { get; set; }

    [NotMapped]
    public string 임시 { get; set; } = "";
}
만들어진 표
CREATE TABLE "TODO_LIST" ( "NUM" INTEGER NOT NULL CONSTRAINT "PK_TODO_LIST" PRIMARY KEY AUTOINCREMENT, "SUBJECT" TEXT NOT NULL );

[NotMapped] 를 붙인 임시컬럼이 되지 않았습니다. 화면에만 필요하고 담을 것은 아닌 값에 사용합니다.

Fluent API 로 적으면

Fluent API
protected override void OnModelCreating(ModelBuilder b) {
    b.Entity<Fluent>(e => {
        e.ToTable("TODO_LIST");
        e.HasKey(x => x.Num);
        e.Property(x => x.Num).HasColumnName("NUM");
        e.Property(x => x.Title).HasColumnName("SUBJECT").HasMaxLength(20).IsRequired();
        e.Property(x => x.Done).HasDefaultValue(false);
        e.HasIndex(x => x.Title).IsUnique();
        e.Ignore(x => x.임시);
    });
}
만들어진 표
CREATE TABLE "TODO_LIST" ( "NUM" INTEGER NOT NULL CONSTRAINT "PK_TODO_LIST" PRIMARY KEY AUTOINCREMENT, "SUBJECT" TEXT NOT NULL, "Done" INTEGER NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX "IX_TODO_LIST_SUBJECT" ON "TODO_LIST" ("SUBJECT");

어트리뷰트로는 적을 수 없던 것이 둘 있습니다. 기본값과 인덱스입니다. 그래서 표를 제대로 맞추려면 Fluent API 가 필요한 자리가 생깁니다.

상세 사용법

어느 쪽으로 적을 것인가

어트리뷰트Fluent API
적는 곳형식 파일 안DbContext 한 곳
읽기형식만 보면 보입니다매핑이 한곳에 모입니다
할 수 있는 것일부입니다전부입니다
형식이 EF 를 아는가압니다 — 참조가 붙습니다모릅니다

마지막 줄이 정하는 자리입니다. 형식을 다른 계층에서도 사용한다면, 그 형식이 데이터베이스를 아는 것이 달갑지 않을 수 있습니다. 그때는 Fluent API 로 DbContext 쪽에 몰아 둡니다. 형식이 그 데이터베이스 전용이라면 어트리뷰트가 읽기 쉽습니다.

둘 다 적으면 Fluent API 가 이깁니다

Both.cs · BothContext.cs
[Table("어트리뷰트가_정한_이름")]
public class Both {
    public int Id { get; set; }
    public string? Title { get; set; }
}

protected override void OnModelCreating(ModelBuilder b) =>
    b.Entity<Both>().ToTable("Fluent가_정한_이름");
만들어진 표
CREATE TABLE "Fluent가_정한_이름" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_Fluent가_정한_이름" PRIMARY KEY AUTOINCREMENT, "Title" TEXT NULL );

규약보다 어트리뷰트가, 어트리뷰트보다 Fluent API 가 앞섭니다. 어트리뷰트를 고쳤는데 바뀌지 않는다면 OnModelCreating 에 같은 것이 적혀 있는지 먼저 보십시오. 오류가 나지 않아 알아채기 어렵습니다.

상세 사용법

DbContext 는 요청 하나에 하나입니다

3.1에서는 new TodoContext(…) 로 직접 만들었습니다. 웹에서는 그렇게 하지 않고 등록해 두고 받아 사용합니다.

// Program.cs — Build() 앞
builder.Services.AddDbContext<TodoContext>(o =>
    o.UseSqlite(builder.Configuration.GetConnectionString("Default")));

// 컨트롤러 — 기본 생성자로 받습니다
public class TodosController(TodoContext db) : ControllerBase {
    [HttpGet]
    public IEnumerable<Todo> List() => db.Todos.AsNoTracking().ToList();
}

AddDbContextScoped 로 등록합니다. 요청 하나가 하나의 범위이므로, 같은 요청 안에서는 어디서 받든 같은 것이고 다른 요청과는 다릅니다.

확인해 본 것
services.AddDbContext<TodoContext>(…);

var a = scope1.ServiceProvider.GetRequiredService<TodoContext>();
var b = scope1.ServiceProvider.GetRequiredService<TodoContext>();
var c = scope2.ServiceProvider.GetRequiredService<TodoContext>();
출력
수명 : Scoped 같은 범위 안에서 두 번 : True 다른 범위에서 가져오면 : False

이 수명에는 까닭이 있습니다. 3.1에서 본 대로 DbContext 는 가져온 것을 지켜봅니다. 오래 살아 있으면 그 기억이 계속 쌓이고, 여러 요청이 하나를 함께 사용하면 동시에 건드려 깨집니다. DbContext 는 여러 스레드가 함께 사용하도록 만들어져 있지 않습니다.

그래서 싱글턴으로 등록하지 마십시오. 백그라운드 작업처럼 요청 밖에서 필요하다면 IDbContextFactory 를 등록해 그때마다 새로 만들어 사용합니다.

버전 배지

연결 문자열은 코드에 두지 않습니다

설정

위에서 GetConnectionString("Default") 로 읽었습니다. 1.2에서 본 appsettings.jsonConnectionStrings 에서 가져옵니다.

비밀번호가 들어가는 자리라 저장소에 담지 않습니다. 개발 기계에서는 사용자 비밀(dotnet user-secrets)에, 운영에서는 환경 변수에 둡니다. 4.4와 5.1에서 다시 봅니다.

실습 문제

직접 해보기

1. 이미 있는 표에 맞추기 난이도 하

아래 표에 맞는 클래스와 매핑을 적어보세요. 어느 방식이든 좋습니다.

CREATE TABLE MEMBER (
    MEM_NO   INT           PRIMARY KEY,
    MEM_NAME NVARCHAR(50)  NOT NULL,
    MEM_MEMO NVARCHAR(200) NULL
);
표 이름, 컬럼 이름, 길이, null 허용 넷을 맞춥니다. null 허용은 물음표로도 됩니다.
[Table("MEMBER")] public class Member { [Key][Column("MEM_NO")] public int No { get; set; } [Column("MEM_NAME")][MaxLength(50)][Required] public string? Name { get; set; } [Column("MEM_MEMO")][MaxLength(200)] public string? Memo { get; set; } } // 번호를 데이터베이스가 매기지 않는다면 이것도 필요합니다. // [DatabaseGenerated(DatabaseGeneratedOption.None)]
2. 컬럼이 되지 않게 하기 난이도 중

FullName 이라는 속성을 더하되 표에는 만들지 않고 이름과 성을 이어 돌려주게 하려면 어떻게 할지 적어보세요.

읽기 전용 속성이라도 EF Core 는 컬럼으로 만들려 합니다. 만들지 말라고 적어야 합니다.
[NotMapped] public string FullName => Last + Name; // Fluent API 라면 e.Ignore(x => x.FullName); // 주의: 이 속성으로는 걸러 낼 수 없습니다. SQL 에 없는 컬럼이므로 // Where(x => x.FullName == "홍길동") 은 실행할 때 예외가 발생합니다.
3. 싱글턴으로 두면 난이도 상

DbContext 를 AddSingleton 으로 등록하면 어떤 일이 생길지 두 가지 적어보세요.

3.1에서 본 "가져온 것을 지켜본다"를 떠올려 보십시오. 그리고 요청은 여러 개가 동시에 들어옵니다.
// 1. 지켜보는 기억이 계속 쌓입니다. // 한 번 읽은 것을 끝까지 들고 있어 메모리가 늘고, 다른 곳에서 // 바뀐 값을 다시 읽지 않아 옛 값을 돌려줍니다. // 2. 여러 요청이 동시에 건드립니다. // DbContext 는 여러 스레드가 함께 사용하도록 만들어져 있지 // 않아, 몰릴 때만 이따금 예외가 발생합니다. 재현하기 어렵습니다. // 요청 밖에서 필요하면 IDbContextFactory 를 등록해 그때마다 만듭니다.
요약
  • 규약이 맞지 않으면 어트리뷰트Fluent API 로 적습니다.
  • 기본값과 인덱스는 Fluent API 로만 적을 수 있습니다.
  • 같은 것을 두 곳에 적으면 Fluent API 가 이깁니다. 오류가 나지 않습니다.
  • 같은 코드라도 데이터베이스가 다르면 다른 표가 만들어집니다(decimal 이 SQLite 에서 TEXT).
  • AddDbContextScoped 입니다. 싱글턴으로 두지 마십시오.