블로그 구축기 두 번째 글이다. 첫 글에서 예고한 대로, 이번에는 2단계에서 데이터베이스를 붙이며 만난 문제들을 정리한다.

처음 버전의 블로그는 .NET Framework 4.8 위의 ASP.NET MVC 5였고, 데이터 접근은 Entity Framework 6(EF6)로 했다. DB는 MySQL이다. EF6는 원래 SQL Server를 기준으로 만들어졌고, MySQL 지원은 Oracle이 내놓는 프로바이더(MySQL Connector/NET)가 맡는다. 문제는 대부분 이 경계에서 생겼다.

환경

항목 버전
런타임 .NET Framework 4.8, ASP.NET MVC 5
ORM EntityFramework 6.5.2
MySQL 프로바이더 MySql.Data.EntityFramework
개발 DB Docker mysql:8.4 (utf8mb4)

테이블은 글(Post), 카테고리(Category), 태그(Tag), 그리고 글과 태그를 잇는 PostTag 네 개다. Code First로 엔티티를 만들고 Add-Migration InitialCreate로 첫 마이그레이션을 생성했다. Visual Studio 없이도 돌릴 수 있도록 ef.ps1이라는 작은 스크립트를 만들어 EF6 패키지에 들어 있는 ef6.exe를 호출하게 했다.

먼저 알아 둘 것: 생성기가 두 개다

EF6 마이그레이션에는 생성기가 두 단계로 있다. 이걸 구분해야 아래 문제들이 이해된다.

  • 코드 생성기(MigrationCodeGenerator): Add-Migration을 실행할 때 C# 마이그레이션 파일(InitialCreate.cs)을 만든다.
  • SQL 생성기(MigrationSqlGenerator): Update-Database를 실행할 때 그 C# 코드를 실제 DDL(SQL 문)로 바꾼다.

MySQL 프로바이더는 두 가지를 모두 MySQL용으로 제공한다. MySqlMigrationCodeGenerator와 MySqlMigrationSqlGenerator다. 처음에는 둘 다 MySQL용을 썼다.

버그 1. 인덱스 이름에 따옴표가 빠진다

슬러그(URL에 쓰는 짧은 이름)는 중복되면 안 되므로 고유 인덱스를 걸었다. 그런데 MySqlMigrationCodeGenerator가 만든 마이그레이션 파일에서 인덱스 이름이 C# 문자열 따옴표 없이 출력됐다.

// 생성된 코드 (문제)
.Index(t => t.Slug, unique: true, name: IX_Post_Slug)

// 기대한 코드
.Index(t => t.Slug, unique: true, name: "IX_Post_Slug")

IX_Post_Slug가 문자열이 아니라 식별자로 읽히니 프로젝트가 빌드되지 않는다. 생성된 파일을 손으로 고칠 수도 있지만, 마이그레이션을 추가할 때마다 같은 일을 반복해야 한다.

해결: 코드 생성기만 EF 기본 생성기(CSharpMigrationCodeGenerator)로 바꿨다. 코드 생성 단계에서는 C# 코드만 만들 뿐 DB 문법은 상관이 없다. 그래서 MySQL 전용일 필요가 없다. SQL 생성기는 그대로 MySQL용을 쓴다.

버그 2. dbo. 접두사 때문에 Down()이 실패한다

기본 코드 생성기로 바꾸자 이번에는 테이블 이름에 SQL Server 스키마 접두사가 붙었다.

CreateTable("dbo.Post", ...);
DropForeignKey("dbo.PostTag", "TagId", "dbo.Tag");

MySQL에는 SQL Server 같은 스키마 개념이 없다. MySQL용 SQL 생성기는 CreateTable 같은 구문에서는 dbo.를 알아서 떼어 낸다. 그래서 Update-Database로 테이블을 만드는 건 문제없이 됐다.

문제는 마이그레이션을 되돌릴 때(Down()) 드러났다. DropForeignKey 같은 일부 구문에서는 접두사를 떼지 못했다. 그래서 addsoft_blog.dbo.PostTag 같은 테이블을 찾다가 실패했다. 만들기만 해 보고 되돌려 보지 않았다면 운영 중에 롤백이 필요해진 순간 처음 알았을 것이다.

해결: 기본 코드 생성기를 상속해서, 이름을 출력할 때 dbo.를 떼어 내도록 했다. 생성 단계에서 아예 접두사가 없는 코드가 나오게 하는 방식이다.

internal sealed class MySqlCSharpMigrationCodeGenerator : CSharpMigrationCodeGenerator
{
    private const string DefaultSchemaPrefix = "dbo.";

    protected override string Quote(string identifier)
    {
        if (identifier != null && identifier.StartsWith(DefaultSchemaPrefix))
        {
            identifier = identifier.Substring(DefaultSchemaPrefix.Length);
        }
        return base.Quote(identifier);
    }
}

마이그레이션 설정에서는 SQL 생성기와 코드 생성기를 따로 지정한다.

public Configuration()
{
    AutomaticMigrationsEnabled = false;
    SetSqlGenerator("MySql.Data.MySqlClient", new MySqlMigrationSqlGenerator());
    CodeGenerator = new MySqlCSharpMigrationCodeGenerator();
}

이렇게 하면 버그 1(따옴표)과 버그 2(접두사)가 함께 해결된다. 마이그레이션 파일은 CreateTable("Post", ...)처럼 깔끔하게 나온다.

버그 3. 기본값(defaultValue)이 무시된다

글의 발행 여부(IsPublished)와 조회수(ViewCount)는 DB 기본값을 0으로 두고 싶었다. 마이그레이션에서 defaultValue: 0을 지정했지만, 만들어진 테이블에는 기본값이 없었다. MySQL용 SQL 생성기가 이 옵션을 DDL에 반영하지 않은 것이다.

앱에서 EF로만 저장한다면 티가 나지 않는다. C#의 bool과 int는 기본값이 false와 0이라 EF가 항상 값을 채워 넣기 때문이다. 하지만 스크립트로 테스트 글을 넣거나 DB에서 직접 행을 추가하면 NOT NULL 컬럼에 값이 없다는 오류가 난다.

해결: 마이그레이션 끝에 SQL을 직접 넣었다.

// MySqlMigrationSqlGenerator 는 defaultValue 를 무시하므로 DB 기본값을 직접 지정한다
Sql("ALTER TABLE `Post` ALTER `IsPublished` SET DEFAULT 0");
Sql("ALTER TABLE `Post` ALTER `ViewCount` SET DEFAULT 0");

정리하며

세 문제 모두 원인은 같다. SQL Server를 기준으로 만든 EF6 위에 MySQL 프로바이더가 얹혀 있고, 그 경계에서 빈틈이 생겼다. 이번 일로 몇 가지 습관을 얻었다.

  • 마이그레이션은 만들고 끝내지 말고 되돌려 본다. Update-Database 후 -TargetMigration:0으로 모두 되돌렸다가 다시 올려 보면 Down()의 문제를 미리 찾을 수 있다.
  • 생성된 마이그레이션 코드를 읽는다. 자동 생성 코드라고 그냥 커밋하지 않는다. 이번 버그 1과 3은 생성된 파일을 읽어 보고 바로 찾았다.
  • 코드 생성기와 SQL 생성기를 따로 본다. 문제가 어느 단계에서 생기는지 알면 필요한 부분만 바꿀 수 있다.

후일담이 있다. 같은 날 블로그를 ASP.NET Core 10과 EF Core 10으로 옮기면서, 여기서 만든 보완 코드는 프로젝트에서 모두 빠졌다. EF Core는 마이그레이션 코드를 만드는 방식부터 달라서 이 보완 코드를 그대로 가져갈 필요가 없었다. 대신 이미 운영 중인 DB의 테이블, 인덱스, 외래 키 이름을 그대로 맞춰야 하는 새로운 숙제가 생겼다. 그 이야기는 전환 편에서 따로 다룬다.

다음 글에서는 MySQL을 재시작하자 사이트 전체가 500 오류를 낸 일을 다룬다.