식단서랍의 첫 코드는 Global.asax 로 시작하는 ASP.NET MVC 5 앱이었다. 대상 프레임워크는 .NET Framework 4.8.1, 의존성 주입은 Autofac, 로그인은 OWIN, 데이터는 EF6. 나는 그 코드를 한 줄도 고치지 않고 legacy/ 폴더로 통째로 옮긴 다음, 그 옆에 dotnet new mvc -f net9.0 으로 빈 프로젝트를 만들었다.
고쳐 쓰지 않은 이유
처음에는 프로젝트 파일만 SDK 스타일로 바꾸고 고쳐 나가는 길도 생각했다. 그런데 App_Start 의 Autofac 설정, Startup.Auth.cs 의 OWIN 쿠키 인증, Web.config, packages.config 의 HintPath 들까지, 새 호스팅 모델에 그대로 살아남는 게 거의 없었다.
결정을 굳힌 건 EF6 마이그레이션 설정이었다.
internal sealed class Configuration : DbMigrationsConfiguration<ApplicationDbContext>
{
public Configuration()
{
AutomaticMigrationsEnabled = true;
AutomaticMigrationDataLossAllowed = true;
}
}
DbContext 의 정적 생성자에서 MigrateDatabaseToLatestVersion 을 걸어 두었으니, 앱이 처음 DB 에 붙는 순간 모델과 스키마의 차이가 자동으로 반영된다. 데이터 손실도 허용된 채로. 엔티티에서 속성 하나를 지우면 운영 DB 에서 컬럼이 조용히 사라지는 구조다. 개발 초기에는 편했지만, 실제로 쓰이는 서비스에 둘 설정은 아니었다.
고쳐 쓰면 걷어 내는 동안 어느 쪽 규칙으로 돌아가는지 헷갈리는 코드가 생긴다. 그래서 새 솔루션은 처음부터 ASP.NET Core 의 방식으로만 짓고, 예전 코드는 옆에 펴 놓고 필요한 것만 옮겨 오기로 했다.
아홉 걸음, 바깥에서 안으로
옮기는 일은 아홉 단계로 나눴다. 순서는 바깥에서 안으로였다. 먼저 빈 MVC 프로젝트가 빌드되는 것까지, 그다음 EF Core 9 와 Pomelo MySQL 드라이버, Docker Compose 로 DB 에 붙는 것까지. 그 위에 ASP.NET Core Identity 를 올리고, 도메인 엔티티, 서비스와 컨트롤러, Razor 뷰 순서로 채웠다. 로깅과 헬스 체크, 문서 정리가 마지막이었다.
레시피는 사용자에게 매달리니 Identity 가 엔티티보다 먼저고, 서비스는 엔티티를, 뷰는 ViewModel 을 기다린다. 단계마다 빌드 경고와 오류가 0 인 것을 확인하고 넘어갔다. 아래가 단단하면 위에서 터지는 오류의 범위가 좁아진다.
Identity 에서 가장 많이 덜어 냈다. 예전 앱에는 AccountController 445줄과 ManageController 389줄이 있었다. 새 앱에서는 Identity.UI 의 Razor Pages 기본 UI 를 채택하고 이 두 컨트롤러를 옮기지 않았다. 대신 ApplicationUser 에 붙여 둔 RealName, ProfileImagePath, CreatedAt 과 역할의 Description 은 그대로 가져왔다.
나머지는 대부분 짝이 정해진 치환이었다.
| 예전 (MVC 5) | 지금 (ASP.NET Core) |
|---|---|
User.Identity.GetUserId() |
User.FindFirstValue(ClaimTypes.NameIdentifier) |
HttpPostedFileBase |
IFormFile |
Server.MapPath |
IWebHostEnvironment.WebRootPath |
.Include("Ingredients") 문자열 |
.Include(r => r.Ingredients) 람다 |
@Html.Partial(...) |
<partial> 태그 헬퍼 |
Newtonsoft.Json |
System.Text.Json |
이참에 서비스는 전부 async 와 CancellationToken 으로, 시간은 DateTime.UtcNow 로 맞췄다. 마이그레이션 자동 적용은 개발 환경이나 Database:AutoMigrate 를 명시로 켠 경우에만 돌게 했다. 운영 설정에서는 false 다.
legacy/ 폴더는 이 아홉 걸음 내내 옆에 있었다. 뷰 하나를 옮길 때마다 예전 .cshtml 을 열어 놓고 줄 단위로 비교할 수 있었던 게 컸다. 마지막 단계에서 이 폴더를 지웠다. 파일 96개, 약 415MB. 그중 가장 큰 덩어리는 저장소에 커밋돼 있던 128MB 짜리 Tailwind CLI 실행 파일이었다. 예전 코드는 이제 git 이력 안에만 있다.
Recipes 테이블은 없었다
옮기기가 끝나고 나중에, 샘플 레시피 세 개를 넣는 시드 SQL 을 손으로 썼다. 깍두기, 소불고기, 제육볶음. 재료와 조리 단계까지 넣고 실행하자 첫 DELETE 에서 1146 오류, Table '….Recipes' doesn't exist 로 멈췄다. C# 에서 늘 Recipes, CookingSteps 라고 부르던 이름을 그대로 SQL 에 쓴 것이다. 처음에는 Pomelo 가 테이블 이름을 snake_case 로 바꿨다고 생각했다. 그런데 ApplicationDbContext 를 다시 열어 보니 범인은 내 코드였다.
// 도메인 테이블명: snake_case lowercase (legacy 호환)
modelBuilder.Entity<Recipe>().ToTable("recipes");
modelBuilder.Entity<Ingredient>().ToTable("ingredients");
modelBuilder.Entity<CookingStep>().ToTable("cooking_steps");
예전 EF6 컨텍스트에 있던 "MySQL 테이블명 소문자 매핑"을 엔티티를 옮길 때 그대로 가져온 것이다. 반면 Identity 테이블은 AspNetUsers 처럼 PascalCase 를 유지한다. 한 DB 안에 두 가지 이름 규칙이 섞여 있었고, 나는 시드 SQL 을 쓰면서 그중 하나만 기억하고 있었다.
여기에 MySQL 의 성질이 한 겹 더 얹힌다. MySQL 은 테이블을 파일 시스템 위의 이름으로 다루기 때문에, 리눅스에서는 기본 설정(lower_case_table_names=0)으로 테이블 이름의 대소문자를 구분한다. Windows 에 깔린 MySQL 이었다면 Recipes 로도 통했을지 모른다. 컨테이너 안의 MySQL 은 리눅스였고, 정확히 recipes 만 알아들었다.
이름을 고치자 이번에는 ingredients 에 넣는 값에서 걸렸다. Ingredient.SortOrder 가 NOT NULL 에 기본값이 없는 컬럼이었다. EF Core 를 거쳐 저장할 때는 엔티티의 기본값이 늘 채워 주니 한 번도 의식하지 않았던 컬럼이다. 재료마다 1, 2, 3 순서를 직접 붙이고 나서야 시드가 끝까지 돌았다. ORM 뒤에서 살던 스키마를 손 SQL 로 만나면 이런 것들이 드러난다.
인증 방식이 바뀐 MySQL
DB 쪽에서는 서버가 먼저 움직였다. 앱이 붙어 있던 MySQL 컨테이너가 8.0 에서 8.4 LTS 로 올라갔다. 8.4 에서는 예전 기본값이던 mysql_native_password 플러그인이 기본으로 꺼져 있고, 기본 인증은 caching_sha2_password 다.
초기화 SQL 의 CREATE USER 에 플러그인을 명시하고, 예전 플러그인으로 만들어진 사용자도 전환되게 ALTER USER ... IDENTIFIED WITH caching_sha2_password 를 이어 두어 몇 번을 돌려도 결과가 같게 했다.
연결 문자열에는 AllowPublicKeyRetrieval=true 가 붙었다. caching_sha2_password 는 서버 쪽 캐시에 인증 정보가 없을 때 비밀번호를 RSA 공개 키로 암호화해서 보낸다. TLS 를 쓰지 않는 연결에서는 그 공개 키를 서버에서 받아 와야 하는데, 드라이버는 기본적으로 이것을 거부한다. 외부로 나가지 않는 내부 연결이라 이 옵션을 켰다.
연결에는 다른 문제도 있었다. 컨테이너가 아니라 Visual Studio 에서 F5 로 호스트에 앱을 띄우자 시작하자마자 MySqlException 이 났다. 직접적인 원인은 호스트에서 DB 컨테이너로 가는 길이 열려 있지 않았고 스키마도 아직 만들지 않은 상태였다는 것이라 그쪽은 설정으로 풀었다. 다만 연결이 잠깐 끊기는 순간에 시작 자체가 죽는 구조는 그대로라 마음에 걸렸다. EF Core 의 재시도 실행 전략을 켰다.
options.UseMySql(connectionString, serverVersion, mysql =>
mysql.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null));
일시적인 오류로 분류되는 예외면 지연을 늘려 가며 다시 시도한다. 이 전략에서는 직접 연 트랜잭션을 실행 전략으로 감싸야 하는데, 서비스 코드에 BeginTransaction 이 없어서 그대로 둘 수 있었다.
스타일이 사라진 화면
뷰를 다 옮기고 처음 페이지를 열었을 때 화면은 맨 HTML 이었다. 클래스 이름은 전부 bg-white, rounded-xl 같은 Tailwind 클래스인데, 그것을 CSS 로 바꿔 줄 빌드가 새 프로젝트에 없었다. 예전 앱은 커밋된 Tailwind 실행 파일로 tailwind.css 를 만들었고, 그 파이프라인은 legacy/ 에 남아 있었다.
급한 대로 레이아웃 <head> 에 Tailwind Play CDN 스크립트를 넣었다. 브라우저가 클래스를 즉석에서 CSS 로 만드는 개발용 도구라, "운영 전 교체 필요" 주석을 달아 두고 곧 standalone CLI 빌드로 바꿨다.
실행 파일은 이제 저장소에 넣지 않는다. 빌드 스크립트가 없을 때만 내려받고 .gitignore 로 뺐다. 그리고 MSBuild 타깃으로 빌드에 엮었다.
<Target Name="BuildTailwindCss"
BeforeTargets="Build"
Inputs="@(TailwindInputs)"
Outputs="wwwroot/css/site.css">
Inputs 와 Outputs 를 주면 MSBuild 는 입력이 출력보다 새로울 때만 이 타깃을 실행한다. Windows 에서는 PowerShell 스크립트를, 리눅스 Docker 빌드에서는 bash 스크립트를 부른다.
증분 빌드는 나중에 한 번 더 값을 받아 갔다. 처음 TailwindInputs 에는 Views/**/*.cshtml 만 있었는데, Identity 페이지는 Areas/ 아래에 있다. 계정 삭제 화면에 bg-rose-600 같은 새 클래스를 넣어도 MSBuild 는 입력이 바뀌지 않았다고 보고 컴파일을 건너뛰었고, 위험한 동작을 하는 빨간 버튼이 배경도 글씨색도 없이 그려졌다. Areas/** 와 Pages/** 를 입력에 더해 고쳤다. 빌드를 빠르게 하는 장치는 무엇을 지켜볼지 정확히 알려 줄 때만 맞게 동작한다.
별표 하나, 파일 하나
마지막 두 문제는 코드가 아니라 버전에서 나왔다.
패키지 참조는 처음에 9.0.* 와일드카드로 적어 두었다. CLI 에서는 의도대로 9.0.16 으로 복원됐다. 그런데 Visual Studio 의 NuGet 경로에서는 Microsoft.AspNetCore.Identity.EntityFrameworkCore 가 10.0.8 로 잡히면서 NU1202 가 났다. net10.0 용 패키지를 net9.0 프로젝트에 넣으려 한 것이다. Identity, EF Core, 헬스 체크 패키지를 모두 9.0.16 으로 명시해 고정했다. 패치를 따라가는 편리함보다 어느 도구로든 같은 결과가 나오는 쪽이 중요했다.
SDK 도 같은 문제를 겪었다. 머신에 .NET SDK 9 와 10 이 함께 깔려 있었고, global.json 이 없으니 dotnet 은 가장 최신인 10 을 골랐다. SDK 10 이 끌고 오는 MSBuild 18 계열이 net9 프로젝트에서 대상 프레임워크 경고를 냈다. 저장소 루트에 global.json 을 두고 "version": "9.0.0", "rollForward": "latestFeature" 로 9.x 안의 최신 SDK 만 쓰게 하자 빌드는 다시 경고 0 으로 돌아왔다.
나중에 머신에 SDK 10 만 남았을 때는 이 파일이 빌드를 막았고, 버전을 올리는 일은 이 파일 한 줄을 고치는 명시적인 결정이 됐다. 버전을 고정한다는 건 바뀌지 않게 막는 게 아니라, 바뀔 때 내가 알게 만드는 일이었다.
아홉 걸음 끝에 남은 건 예전과 같은 화면을 띄우는 새 앱이었다. 사용자는 같은 레시피와 같은 달력을 보지만, 그 아래는 전부 바뀌었다. 다음 편부터는 이 새 그릇 위에 예전 앱에는 없던 것들을 올리기 시작한다.