블로그 구축기 여섯 번째 글이다. 첫 글에서 "같은 날 같은 사이트를 두 번 만든 셈"이라고 썼던 바로 그 이야기다.
블로그의 첫 버전은 .NET Framework 4.8 위의 ASP.NET MVC 5였다. 익숙한 스택이라 빠르게 완성했고 운영 서버에 첫 배포까지 마쳤다. 그런데 앞으로 오래 운영할 사이트를 유지보수만 이어지는 플랫폼에 두는 게 마음에 걸렸다. 글이 쌓이기 전, 운영 데이터가 거의 없을 때가 옮기기 가장 좋은 때라고 판단했다. 그래서 같은 날 ASP.NET Core 10으로 옮겼다.
변경 규모는 파일 100개, 약 2천 줄을 추가하고 2천 줄을 지운 정도다.
원칙: 바꾸지 않을 것부터 정한다
프레임워크 전환은 손대는 범위가 넓어서, 무엇을 바꿀지보다 무엇을 바꾸지 않을지를 먼저 정했다.
| 유지할 것 | 이유 |
|---|---|
| 모든 URL | 이미 검색엔진과 사이트맵에 나간 주소가 바뀌면 안 된다 |
| 운영 DB와 스키마 | 데이터 이전 없이 같은 DB에 그대로 붙는다 |
| 관리자 비밀번호 해시 | 운영 설정에 있는 해시로 그대로 로그인돼야 한다 |
| 업로드 이미지 경로 | 본문에 들어간 /uploads/... 주소가 그대로 열려야 한다 |
| 배포 방식 | 같은 배포 스크립트, 같은 IIS 사이트로 배포한다 |
이 표가 전환 작업의 체크리스트이자 완료 기준이 됐다.
무엇이 무엇으로 바뀌었나
| MVC 5 (.NET Framework 4.8) | ASP.NET Core 10 |
|---|---|
Global.asax, App_Start/* |
Program.cs 한 파일 |
| Autofac | 내장 DI |
Web.config appSettings + 별도 비밀 설정 파일 |
appsettings.json + 서버 전용 appsettings.Production.json, 개발은 user-secrets |
| FormsAuthentication | 쿠키 인증 |
| EF6 + MySql.Data.EntityFramework | EF Core 10 + MySql.EntityFrameworkCore |
| 사이드바 Child Action | ViewComponent |
customErrors, Application_Error |
UseExceptionHandler, UseStatusCodePages |
| 자체 파일 로거 | ILoggerProvider로 만든 파일 로거 |
Content 폴더 |
wwwroot |
대부분은 대응되는 기능으로 옮기는 기계적인 작업이었다. 시간이 걸린 건 DB와 로그인, 그리고 운영 서버 쪽이었다.
가장 까다로운 부분: 기존 DB 이어받기
운영 DB에는 EF6가 만든 테이블이 이미 있다. EF Core가 이 DB를 "자기가 만든 것처럼" 쓰게 하려면 두 가지를 맞춰야 했다.
1. 이름을 하나하나 맞춘다
EF6와 EF Core는 기본 이름 규칙이 다르다. 예를 들어 글의 카테고리 외래 키 인덱스는 EF6에서 IX_CategoryId로 만들어졌지만, EF Core의 기본 이름은 IX_Post_CategoryId다. 그대로 두면 EF Core는 인덱스가 없다고 판단하고 다음 마이그레이션에서 새로 만들려 든다.
그래서 테이블, 컬럼 타입, 인덱스, 외래 키 이름을 기존 스키마와 똑같이 지정했다.
post.ToTable("Post");
post.Property(p => p.Title).IsRequired().HasMaxLength(200).HasColumnType("varchar(200)");
post.Property(p => p.PublishedAt).HasColumnType("datetime");
post.HasIndex(p => p.Slug).IsUnique().HasDatabaseName("IX_Post_Slug");
post.HasIndex(p => p.CategoryId).HasDatabaseName("IX_CategoryId");
post.HasOne(p => p.Category)
.WithMany(c => c.Posts)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("FK_Post_Category_CategoryId");
글과 태그를 잇는 다대다 테이블(PostTag)도 테이블 이름, 복합 키, 인덱스 이름을 그대로 지정했다. 이렇게 맞춘 모델로 만든 EF Core의 첫 마이그레이션(InitialCreate)은 EF6 스키마와 내용이 같다.
2. 이미 적용된 것으로 기록한다
이름이 같아도 문제가 하나 더 있다. EF6는 마이그레이션 이력을 __MigrationHistory 테이블에, EF Core는 __EFMigrationsHistory 테이블에 기록한다. 운영 DB에는 EF Core의 이력 테이블이 없으니, 그대로 마이그레이션을 실행하면 이미 있는 테이블을 다시 만들려다 실패한다.
그래서 앱이 시작할 때 이렇게 판단하게 했다.
- EF Core 이력 테이블이 없고,
Post테이블은 이미 있다 → EF6 시절 DB다. - 이 경우
InitialCreate는 실행하지 않고 "이미 적용됨"으로 기록만 한다. - 그 뒤의 마이그레이션부터는 평소처럼 실제로 적용한다.
var history = db.GetService<IHistoryRepository>();
var baseline = db.Database.GetMigrations().FirstOrDefault(m => m.EndsWith("_InitialCreate"));
if (baseline != null && !history.Exists() && TableExists(db, "Post"))
{
logger.LogInformation("기존(EF6) 스키마 감지 → 마이그레이션 {Migration} 을 적용된 것으로 기록", baseline);
db.Database.ExecuteSqlRaw(history.GetCreateScript());
db.Database.ExecuteSqlRaw(history.GetInsertScript(new HistoryRow(baseline, ProductInfo.GetVersion())));
}
db.Database.Migrate(); // 기준선 이후의 마이그레이션만 적용된다
테이블이 있는지 확인할 때는 대소문자를 무시하고 비교했다. 운영 MySQL은 lower_case_table_names=1이라 Post 테이블이 post로 저장되어 있기 때문이다. 개발 DB와 운영 DB의 이런 차이는 직접 붙여 보기 전에는 잘 보이지 않는다.
이 방식의 효과는 다음 단계에서 확인됐다. 방문 통계 기능을 넣으며 VisitLog 테이블을 추가했는데, 운영에 배포하자 앱이 시작하면서 새 마이그레이션만 자동으로 적용했다.
로그인을 그대로 유지하기
관리자 비밀번호는 PBKDF2(HMAC-SHA256)로 해시해 운영 설정에 넣어 두었다. 형식은 PBKDF2-SHA256$반복횟수$솔트$해시다. Core 버전의 해시 코드도 같은 형식, 같은 결과가 나오도록 만들었다. 덕분에 운영 서버의 해시를 다시 만들 필요가 없었다. 로그인 실패 잠금 규칙(같은 IP에서 10분 안에 5회 실패하면 10분 잠금)도 그대로 옮겼다.
ASP.NET Core에서 새로 신경 쓸 부분도 있었다. 로그인 쿠키를 암호화하는 데이터 보호 키다. 이 키를 따로 보관하지 않으면 앱풀이 재시작될 때마다 키가 바뀌어 로그인이 풀린다. 키를 사이트의 App_Data/keys 폴더에 파일로 저장하고, Windows의 DPAPI로 보호하게 했다. 이때 앱풀 계정에 그 폴더의 쓰기 권한이 없으면 키가 저장되지 않는다. 그래서 배포 스크립트가 권한까지 부여하게 했다.
운영 서버 쪽 준비
IIS에서 ASP.NET Core를 돌리려면 서버에도 바뀌는 것이 있다.
- ASP.NET Core Hosting Bundle 설치: IIS가 앱을 실행하는 ASP.NET Core 모듈(ANCM)이 들어 있다. 앱은 IIS 작업자 프로세스 안에서 도는 in-process 방식으로 띄웠다.
- 앱풀은 "관리 코드 없음" 권장: in-process 방식에서는 .NET Framework CLR을 올릴 필요가 없다.
- 설정 파일 전환: 서버의 예전 비밀 설정 파일을
appsettings.Production.json으로 옮겨야 했다. 배포 스크립트가 예전 파일이 있으면 자동으로 변환해서 보내게 했다. 값은 화면에 출력하지 않는다.
Hosting Bundle이나 URL Rewrite 모듈이 빠지면 사이트 전체가 500.19 오류를 낸다. 이것도 배포 문서의 장애 대응 표에 올려 두었다.
검증
전환 뒤에는 정해 둔 원칙을 기준으로 확인했다.
- 공개 화면: 기존 URL과 화면이 그대로 동작하는지
- 관리자: 로그인·잠금, 글 작성·수정·발행·삭제, 이미지 업로드 등 22개 시나리오
- IIS(ASP.NET Core 모듈) 운영 설정 20개 시나리오
정리하며
- 바꾸지 않을 것을 먼저 정한다. 유지할 항목 표가 곧 테스트 목록이 됐고, 무엇이 끝났는지 판단하는 기준이 됐다.
- ORM을 바꿀 때는 이름 규칙의 차이를 의심한다. 모델이 같아도 인덱스·외래 키의 기본 이름이 다르면 새 ORM은 스키마가 다르다고 판단한다.
- 기존 DB는 "기준선"으로 이어받는다. 첫 마이그레이션을 이미 적용된 것으로 기록하면, 데이터 이전 없이 그 다음부터 평소처럼 마이그레이션을 쓸 수 있다.
- 옮길 거라면 일찍 옮긴다. 운영 데이터와 사용자가 적을 때일수록 전환 비용이 작다.
다음 글에서는 디자인을 Bootstrap에서 Tailwind CSS v4로 바꾸면서 생긴 일을 다룬다.