「A사에 넣은 DB에는 이 열이 있는데, B사의 DB에는 없더군요. 어느 버전에서 추가한 열인지도 이제는 모르겠고요」──거래처마다 설치되는 업무 앱의 유지보수를 인계받으면 상당한 확률로 이런 상황을 마주하게 됩니다.
업데이트 절차서에는 「이 SQL을 DB에 실행해 주세요」라고 적혀 있습니다. 하지만 실제로 실행했는지 여부는 현지 작업자만 알고 있고, 실행을 잊은 거래처, 도중에 오류가 난 채 방치된 거래처, 버전을 건너뛰어 갱신한 탓에 중간의 ALTER TABLE이 빠진 거래처가 뒤섞여 갑니다. 몇 년 뒤, 「특정 거래처에서만 발생하는 오류」의 조사에 공수가 끝없이 빨려 들어갑니다.
당 블로그에서는 「Windows 앱의 데이터 저장 위치 고르는 법」에서 스키마에 버전 번호를 붙이는 최소한의 코드를, 「C#에서 SQLite를 업무 앱에 쓰는 법」에서 SQLite의 운영 설계를 설명했습니다. 이 글은 그 후속으로 DB 스키마 변경을 어떻게 버전 관리하고, 거래처에 흩어진 다수의 DB에 어떻게 안전하게 적용할지를 다룹니다. SQLite를 주된 소재로 삼으면서, SQL Server (Express)에도 공통되는 설계로 정리합니다.
1. 먼저 결론
- 스키마 변경은 SQL 절차서가 아니라 코드(번호가 매겨진 마이그레이션)로 앱 본체에 동봉해, 실행 시에 자동으로 적용합니다. 사람이 절차서를 실행하는 운영은 DB가 거래처에 분산된 시점에서 파탄합니다.
- DB 자신에게 현재 스키마 버전을 기록합니다. SQLite라면
PRAGMA user_version이 바로 이 용도를 위한 영역입니다.1 SQL Server라면 전용 테이블에 적용 이력을 남깁니다. - 마이그레이션은 전진뿐, 추가뿐입니다. 한 번 출시한 번호의 SQL은 다시 쓰지 않고, 수정도 새로운 번호로 합니다. 이렇게 하면 v1.2→v1.5로 건너뛰는 업데이트도 「미적용분을 순서대로 흘리기만 하면」 되는 일이 됩니다.
- 파괴적 변경(열 삭제・이름 변경)은 expand-contract의 2단계 릴리스로 진행합니다. expand-contract란 기존 구조를 남긴 채 새로운 구조를 추가하고(expand), 앱 쪽 이행이 끝난 뒤 옛 구조를 삭제하는(contract), 2단계 릴리스 기법입니다. 추가만 하는 릴리스를 먼저 내놓고, 옛 형식에 대한 참조가 사라진 뒤 삭제하는 릴리스를 내놓습니다(5.1절).
- 옛 버전 앱이 새로운 DB를 여는 사고는 최소 버전 체크로 방어합니다. 이때 「지금의 스키마 번호」와 「퇴출 하한선」은 별개의 값으로 갖습니다. 같은 값으로 겸하면 expand를 적용한 순간 옛 앱이 퇴출되어, 위에서 말한 공존 기간이 성립하지 않습니다(5.2절).
- 적용 전에 자동 백업을 받습니다. SQLite라면
VACUUM INTO한 문장으로 일관된 복사본을 만들 수 있고2, 실패 시 복구는 파일 교체로 끝납니다. - 1 마이그레이션 = 1 트랜잭션, 버전 번호 갱신도 같은 트랜잭션에 포함시킵니다. SQLite는 DDL도 트랜잭션으로 되돌릴 수 있습니다.3 SQL Server에는 예외 DDL이 있으므로 예외 조작은 단독 마이그레이션으로 분리합니다.4
2. 「거래처마다 DB가 다르다」는 문제는 왜 일어나는가
원인을 분해하면 어느 것이든 「사람이 하는 것을 전제로 한 운영」으로 귀결됩니다.
- 수작업 ALTER의 적용 누락. 절차서의 SQL을 실행했는지 여부의 기록은 DB 어디에도 남지 않고, 확인 수단이 「테이블 정의 육안 확인」뿐인 시점에서 누락은 반드시 발생합니다.
- 도중 실패의 방치. 절차서의 SQL 5개 중 3번째에서 오류가 나면, 작업자는 계속할지 되돌릴지 판단할 수 없어 「앱은 동작했으니 그대로」가 됩니다. 그 DB는 이미 어느 버전과도 일치하지 않는 세상에 하나뿐인 스키마입니다.
- 버전 건너뛰기 업데이트. v1.2 다음에 v1.5를 넣는 거래처에서는 v1.3과 v1.4의 스키마 변경을 모아서 정확히 따라가야 하지만, 절차서 운영으로는 어렵습니다.
- 긴급 대응의 현지 패치. 「이 거래처만 먼저 열을 추가했다」가 발생하고, 훗날 정식 업데이트에서 이중 적용 오류가 됩니다.
서버 한 대짜리 웹 시스템이라면 DB는 하나이고 상태는 항상 파악할 수 있습니다. 데스크톱 업무 앱의 본질적인 어려움은 같은 앱의 DB가 거래처・거점의 PC에 수십, 수백 개로 흩어져 있고, 게다가 전부가 같은 버전이라고 할 수 없다는 점입니다. 한 대씩 사람이 대처하는 운영은 대수에 비례해 파탄하므로 결론은 하나입니다. 앱 자신이 자기 DB를 검사해 최신 스키마까지 끌어올리는 능력을 갖추는 것입니다.
3. 기본 패턴: 스키마 버전 + 전진 마이그레이션
구조의 골격은 3가지 요소뿐입니다.
- DB 자신이 스키마 버전 번호를 갖는다(앱의 제품 버전과는 별개인, 스키마 전용 정수).
- 스키마 변경은 번호가 매겨진 마이그레이션의 목록으로 앱 코드에 추가해 나간다.
- 앱은 실행 시(DB 연결 직후)에 현재 버전보다 큰 번호의 마이그레이션을 순서대로 트랜잭션으로 적용한다.
실행 시의 흐름을 그림으로 그리면 다음과 같습니다. 5장・6장에서 추가해 나갈 방어 장치는 모두 이 흐름의 어딘가에 들어갑니다.
flowchart TD
S["앱 실행, DB에 연결"] --> R["PRAGMA user_version과 최소 호환 번호를 읽는다"]
R --> Q1{"최소 호환 번호가<br/>앱이 아는 최댓값보다 큰가"}
Q1 -->|"크다"| STOP["실행을 중단한다(5.2절)"]
Q1 -->|"아니다"| Q2{"현재 번호가<br/>앱이 아는 최댓값보다 큰가"}
Q2 -->|"크다"| FUT["호환되는 미래 스키마.<br/>적용하지 않고 그대로 통상 실행(5.2절)"]
Q2 -->|"아니다"| Q3{"미적용 마이그레이션이 있는가"}
Q3 -->|"없다"| OK["그대로 통상 실행"]
Q3 -->|"있다"| BK["적용 전 백업을 받는다<br/>(VACUUM INTO, 5.3절)"]
BK --> LOOP["미적용 번호를 오름차순으로 하나씩 적용한다(6.1절)<br/>BEGIN TRANSACTION → 스키마 변경, 데이터 변환 →<br/>PRAGMA user_version = 해당 번호 → COMMIT"]
LOOP -.->|"도중에 실패했을 때"| FAIL["그 하나만 롤백되어,<br/>직전 번호에서 멈춘다"]
LOOP --> DONE["최신까지 도달하면 통상 실행"]
그림 1: 실행 시 적용 흐름. 5장・6장에서 추가하는 방어 장치는 모두 이 흐름의 어딘가에 들어간다
SQLite의 경우, 버전 번호를 둘 장소로 PRAGMA user_version을 쓸 수 있습니다. 데이터베이스 헤더(오프셋 60)에 저장되는 정수로, 공식 문서에 「애플리케이션이 자유롭게 사용할 수 있으며, SQLite 자신은 이 값을 사용하지 않는다」고 명시되어 있습니다.1 전용 테이블을 만들지 않아도 DB 파일 하나만으로 자기 버전을 밝힐 수 있습니다.
C#에서의 직접 구현은 다음 수십 줄로 실용화됩니다.
using Microsoft.Data.Sqlite;
public static class SchemaMigrator
{
// 추가 전용 목록. 한 번 출시한 번호의 SQL은 절대 다시 쓰지 않는다
private static readonly (int Version, string Sql)[] Migrations =
{
(1, """
CREATE TABLE customer (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
-- 퇴출 하한선을 두는 테이블. 1번 안에서 만들고 초깃값을 넣어 둔다.
-- 이것을 별도로 분리하면 아무도 만들지 않은 채 GetMinCompatibleVersion이
-- 항상 0을 반환해 퇴출이 작동하지 않고, 첫 contract에서
-- UPDATE schema_meta가 「테이블 없음」으로 쓰러진다(5.2절)
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
INSERT INTO schema_meta (key, value) VALUES ('min_compatible_version', 0);
"""),
(2, "ALTER TABLE customer ADD COLUMN phone TEXT"),
(3, """
CREATE TABLE invoice (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customer(id),
issued_at TEXT NOT NULL, -- UTC・고정 형식으로 저장한다
amount INTEGER NOT NULL -- 금액은 최소 통화 단위의 정수
)
"""),
};
public static void Migrate(SqliteConnection conn)
{
// 추가 실수(번호 중복・역순)는 조용한 이중 적용・적용 누락이 되므로,
// 무언가를 적용하기 전에 검출해 멈춘다
for (int i = 1; i < Migrations.Length; i++)
if (Migrations[i].Version <= Migrations[i - 1].Version)
throw new InvalidOperationException(
"마이그레이션 번호는 오름차순・고유해야 합니다.");
int current = GetUserVersion(conn);
int latest = Migrations[^1].Version;
int minCompatible = GetMinCompatibleVersion(conn);
// 퇴출은 「스키마 번호」가 아니라 「최소 호환 번호」로 판정한다.
// 여기를 current > latest로 판정하면, 새 앱이 expand를
// 적용한 순간 user_version이 올라가 옛 앱은 그 자리에서 DB를
// 열 수 없게 된다 ── 5.1에서 「공존 기간은 신구 양쪽이 쓴다」로 정한
// 설계 자체가 애초에 성립하지 않는다. 최소 호환 번호는 contract일 때만 올린다
if (minCompatible > latest)
throw new InvalidOperationException(
$"이 데이터베이스는 스키마 v{minCompatible} 이상을 이해하는 앱을 " +
$"필요로 합니다(이 앱이 아는 것은 v{latest}까지입니다). " +
"앱을 업데이트해 주세요.");
if (current > latest)
// 자신이 모르는 미래의 스키마이지만, 호환된다고 선언되어 있다.
// 적용해야 할 것은 없으므로(모두 current 이하),
// 모르는 열은 건드리지 않은 채 통상 실행으로 넘어간다(자세히는 5.2절)
return;
foreach (var (version, sql) in Migrations)
{
if (version <= current) continue;
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
// 버전 갱신도 같은 트랜잭션에서 확정시킨다.
// 이렇게 하면 「변경은 들어갔지만 번호는 예전 그대로」인 상태가 사라진다
cmd.CommandText = $"PRAGMA user_version = {version}";
cmd.ExecuteNonQuery();
tx.Commit();
}
}
private static int GetUserVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version";
return Convert.ToInt32(cmd.ExecuteScalar());
}
// 「이 DB를 열어도 되는 앱의 하한선」. user_version과 별개로 갖는 것이 핵심으로,
// 같은 값으로 겸하면 expand를 적용한 순간 옛 앱을 퇴출시켜 버린다.
// 올리는 것은 contract 마이그레이션일 때뿐(5.1・5.2절)
private static int GetMinCompatibleVersion(SqliteConnection conn)
{
using var exists = conn.CreateCommand();
exists.CommandText =
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_meta'";
if (exists.ExecuteScalar() is null) return 0; // 테이블이 없는 오래된 DB. 하한 없음
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT value FROM schema_meta WHERE key = 'min_compatible_version'";
var value = cmd.ExecuteScalar();
return value is null or DBNull ? 0 : Convert.ToInt32(value);
}
}
schema_meta는 위와 같이 1번 마이그레이션 안에서 만듭니다. 별도 스크립트로 분리하면 아무도 실행하지 않은 채 GetMinCompatibleVersion이 항상 0을 반환해, 퇴출이 전혀 작동하지 않는 상태가 됩니다. 게다가 첫 contract에서 UPDATE schema_meta가 「테이블 없음」으로 쓰러집니다.
이 구조를 기존 DB에 나중에 붙일 때는, 1번이 적용된 DB에는 schema_meta가 없습니다. 도입 회차의 마이그레이션에서 CREATE TABLE IF NOT EXISTS와 INSERT OR IGNORE를 사용해 다시 만들어 주세요(번호를 하나 더할 뿐입니다). GetMinCompatibleVersion이 테이블 존재 여부를 먼저 확인하는 것은 이 이행 기간을 위해서입니다.
contract 마이그레이션에서만 이 값을 올립니다.
-- 옛 열을 삭제하는 회차의 마이그레이션에서, 같은 트랜잭션 안에서 올린다
UPDATE schema_meta SET value = 7 WHERE key = 'min_compatible_version';
ALTER TABLE customer DROP COLUMN old_name;
이렇게 하면 2장의 문제는 구조적으로 해결됩니다. 적용 누락은 일어나지 않고(실행할 때마다 검사됨), 도중 실패는 롤백되며(6장), 버전 건너뛰기도 문제없습니다(v1.2의 DB가 스키마 v2라면, v1.5의 앱이 3・4・5를 순서대로 적용할 뿐). 「이 거래처의 DB가 어떤 상태인가」도 PRAGMA user_version을 한 번 읽으면 답할 수 있습니다.
운영 규칙은 두 가지만 엄수합니다.
- 출시된 번호는 다시 쓰지 않는다. v3의 SQL에 버그가 있어도 v4에서 수정합니다. 다시 쓰면 「예전 v3를 적용한 DB」와 「새 v3를 적용한 DB」라는 새로운 차이가 생겨납니다.
- 데이터 변환도 마이그레이션에 포함시킨다. 열 추가뿐 아니라 기존 데이터의 이관(UPDATE)도 같은 번호 안에서 수행합니다. 날짜/시간 열의 형식・시간대는 「업무 앱의 날짜/시간과 시간대」대로 처음부터 UTC・고정 형식으로 통일해 두면 이후의 마이그레이션이 단순해집니다.
또 하나, 이 구조를 기존 시스템에 나중에 붙일 때만 해당하는 작업이 있습니다. 수작업 패치로 운영해 온 DB는 「user_version은 0인 채, 실제 스키마만 부분적으로 진행됐다」는 상태가 있을 수 있습니다(2장의 긴급 패치가 바로 그것입니다). 그대로 이 체인에 태우면, 이미 적용된 변경에 대한 ALTER TABLE이 「열이 이미 존재합니다」 오류로 쓰러집니다. 도입 첫 릴리스에서는 한 번뿐인 베이스라인 처리로 실제 스키마를 검사하고(SQLite라면 PRAGMA table_info로 열의 존재 여부를 확인), 알려진 수작업 패치가 적용된 DB에는 해당하는 버전 번호를 새겨 넣은 뒤, 이후는 전진 마이그레이션에 맡깁니다. 이 과정을 건너뛸 수 있는 것은 초기 릴리스부터 이 구조를 넣은 경우뿐입니다.
SQL Server에는 user_version에 해당하는 구조가 없으므로, 전용 테이블(예: schema_version)에 「버전 번호・적용 일시・적용 시 앱 버전」을 한 행씩 INSERT합니다. 이력이 행으로 남는 만큼 이후 조사에 강해집니다.
4. 도구를 쓸 것인가 직접 만들 것인가 ── 판단표
같은 것을 실현하는 수단은 EF Core Migrations, 마이그레이션 라이브러리(DbUp 등), 앞 장의 직접 구현이라는 3가지 계통이 있습니다. 이름만으로는 무엇을 하는 도구인지 알기 어려우니, 먼저 한마디씩 덧붙입니다.
- EF Core(Entity Framework Core) ── Microsoft가 만든 .NET용 O/R 매퍼(객체와 테이블을 대응시켜 SQL을 자동 생성하는 라이브러리)입니다. 부속 기능인 EF Core Migrations는 C#으로 작성한 모델(클래스 정의)의 변경을 감지해 스키마 변경 코드를 자동 생성합니다.
- DbUp ── SQL 스크립트의 적용 관리에 특화한 오픈소스 .NET 라이브러리입니다. SQL은 직접 작성하고, 「어느 스크립트를 적용했는지」의 기록과 미적용분의 실행만을 맡습니다.5
| 비교 축 | EF Core Migrations | 마이그레이션 라이브러리(DbUp 등) | 직접 구현 |
|---|---|---|---|
| 변경의 기술 방식 | C# 모델 변경으로부터 자동 생성 | SQL 스크립트를 그대로 자산화 | SQL 문자열 또는 C# 코드 |
| 학습 비용 | 높음(모델・도구・제약 이해 필요) | 낮음~중간 | 최소(수십 줄만 이해하면 됨) |
| SQLite와의 궁합 | △ 열 변경・삭제가 테이블 재구성이 됨. 멱등 스크립트 생성 불가6 | ○ SQL Server 중심이지만 SQLite 등도 지원5 | ◎ 제약을 알고 직접 작성 가능 |
| 기존 원시 SQL 자산 | 재활용하기 어려움(모델 정의로의 치환 필요) | ◎ 절차서의 SQL을 거의 그대로 옮길 수 있음 | ◎ 동일 |
| 적용 이력 관리 | 이력 테이블(자동) | 저널 테이블(자동)5 | user_version / 자체 제작 테이블 |
| 배포 형태와의 궁합 | 앱 동봉, 실행 시 Migrate()(주의점 있음, 후술) |
앱 동봉, 실행 시 실행 | 앱 동봉, 실행 시 실행 |
상황별 권장은 다음과 같습니다.
| 상황 | 권장 | 이유 |
|---|---|---|
| 이미 EF Core로 데이터 접근 중 | EF Core Migrations | 모델과 스키마의 이중 관리를 피할 수 있음. 별도 도구를 더할 이유가 없음 |
| 원시 SQL(ADO.NET / Dapper) 중심 + SQLite | 직접 구현 | 의존성 없이 충분함. SQLite의 ALTER TABLE 제약은 결국 직접 의식하게 됨 |
| 원시 SQL 중심 + SQL Server, 절차서 SQL 축적이 많음 | DbUp 등의 라이브러리 | 기존 SQL을 스크립트로 자산화할 수 있고 적용 관리를 직접 만들지 않아도 됨 |
| 저장 프로시저・뷰가 많음 | DbUp 등의 라이브러리 | 모델에서 생성할 수 없는 객체는 SQL 스크립트 관리가 자연스러움 |
| DB가 작고 변경 빈도도 낮음 | 직접 구현 | 구조 유지 비용을 최소화함 |
DbUp는 「SQL Server 데이터베이스로의 변경 배포를 돕는 .NET 라이브러리」로, 실행한 스크립트를 저널 테이블에 기록하고 미실행분만 실행합니다. SQLite・PostgreSQL・MySQL 등도 지원합니다.5 「절차서의 SQL을, 실행 기록이 있는 자동 적용으로 바꾼다」는 이행 경로로는 가장 짧습니다.
4.1 EF Core Migrations를 배포용 앱에서 쓸 때의 주의점
EF Core의 개발 중 적용은 dotnet ef database update이지만, 거래처 PC에는 SDK도 소스도 없습니다. 현실적인 적용 방법은 앱 실행 시의 context.Database.Migrate()입니다.
여기서 알아 두어야 할 것은, Microsoft 문서가 프로덕션 데이터베이스의 관리 수단으로서 실행 시 적용에 명확히 주의를 환기하고 있다는 점입니다. 이유는 (1) 여러 인스턴스의 동시 적용에 의한 실패나 손상(EF Core 9 이전), (2) 적용 중인 DB에 다른 앱이 접근하면 심각한 문제가 일어날 수 있음, (3) 앱에 스키마 변경의 승격 권한이 필요해짐, (4) 롤백 수단이 부족함, (5) 실행되는 SQL을 사전에 확인・수정할 수 없음의 5가지이며, 권장하는 방법은 SQL 스크립트를 생성해 배포 공정에서 적용하는 것입니다.7
다만 이 권장 사항은 「DB가 하나이고, 배포 공정이 존재하는」 서버 시스템을 전제로 합니다. 거래처 PC마다 로컬 DB가 있는 데스크톱 앱에서는, 스크립트를 들고 현지를 도는 운영 자체가 2장의 문제 그 자체이므로 실행 시 Migrate()가 사실상의 표준 해법이 됩니다. 남은 우려에는 대책을 마련합니다.
- 동시 실행: EF Core 9 이후는
Migrate()가 잠금을 자동으로 획득해 여러 프로세스가 동시에 마이그레이션을 실행하는 것을 방지합니다.7 그 이전 버전에서는 6장대로 직접 직렬화합니다. 다만 이 잠금이 직렬화하는 것은 마이그레이션 실행 자체뿐이며, 적용 중에 옛 버전 앱이 평범하게 읽기/쓰기를 하는 것은 막을 수 없습니다. 공유 DB에서는 최소 버전 체크(5.2절)나 유지보수 시간대와의 병행 사용이 전제입니다. - SQL의 사전 확인: 생성된 마이그레이션을 반드시 검토하고, 릴리스 전에 실제 데이터에 준하는 DB로 리허설합니다(6.3절).
EnsureCreated()와 혼용하지 않는다: 마이그레이션 이력 없이 스키마가 구축되어 이후Migrate()가 실패합니다. 처음부터Migrate()로 통일합니다.7
SQLite 공급자에서는 열의 타입 변경이나 삭제를 포함한 마이그레이션이 「새 테이블 생성 → 데이터 복사 → 옛 테이블 삭제 → 이름 변경」이라는 테이블 재구성으로 실행되며, 멱등 스크립트 생성도 불가능합니다.6 EF Core 자체를 쓸지 말지의 판단은 「C#에서 SQLite를 업무 앱에 쓰는 법」의 8장에서 정리한 대로입니다.
5. 깨지지 않는 마이그레이션을 작성하는 법
개별 마이그레이션의 원칙은 「하위 호환을 깨는 변경을 한 번의 릴리스로 하지 않는다」입니다.
5.1 파괴적 변경은 expand-contract(2단계 릴리스)로
열 추가는 안전하지만, 삭제・이름 변경・타입 변경은 「옛 형식을 전제로 한 무언가」를 깨뜨립니다. 로컬 SQLite에서 앱과 DB가 1대1이더라도, (a) 장애 시 앱을 옛 버전으로 되돌릴 가능성, (b) DB를 직접 읽는 다른 도구(보고서 도구, CSV 내보내기 도구, Access 연동), (c) SQL Server를 신구 클라이언트가 동시에 참조하는 구성 중 하나는 대개 존재합니다. 그래서 파괴적 변경은 expand(넓히다)→contract(좁히다)의 2단계로 나눕니다.
시간축으로 보면, 둘 사이에 「신구 어느 형태로도 동작하는 공존 기간」을 두는 것이 핵심입니다.
flowchart TB
A["릴리스 A(expand, 넓히다)<br/>새로운 구조를 추가하고, 옛 구조는 그대로 남긴다<br/>앱은 신구 양쪽에 쓰고, 읽기는 옛 구조를 정답으로 삼는다"]
B["공존 기간<br/>옛 앱・옛 도구도 그대로 동작한다(어느 구조도 살아있음)<br/>이 사이에 전체 클라이언트를 새 버전으로 교체한다"]
C["최소 버전 체크(5.2절)로<br/>옛 앱을 퇴출할 수 있는 상태가 된다"]
D["릴리스 B(contract, 좁히다)<br/>옛 구조의 최신값을 새 구조로 최종 복사, 최종 변환한다<br/>읽기를 새 구조로 전환하고, 옛 구조를 삭제한다"]
A --> B --> C --> D
그림 2: expand와 contract 사이에 공존 기간을 둔다. 옛 앱을 퇴출할 수 있게 될 때까지 contract를 내놓지 않는 것이 핵심
| 변경 내용 | 한 번에 하면 일어나는 일 | 안전한 2단계 |
|---|---|---|
| 열 이름 변경 | 옛 이름을 참조하는 옛 앱・보고서가 즉사 | 절차가 길어 아래에 나누어 제시함 |
| 열 삭제 | 옛 앱의 INSERT/SELECT가 오류 | expand: 앱이 참조를 그만두기만 함(열은 남김) → contract: 몇 릴리스 후에 삭제 |
| 타입・의미의 변경(예: 로컬 시각 → UTC) | 신구 값이 한 열에 섞여 조용히 깨짐 | expand: 새 열을 추가해 변환된 값을 넣음. 공존 기간은 이름 변경과 동일하게 취급(새 앱은 양쪽에 쓰고, 읽기는 옛 열을 정답으로 삼음) → contract: 옛 앱을 퇴출한 후, 옛 열에서 최종 변환을 하고 나서 읽기를 전환, 옛 열을 삭제 |
| NOT NULL 제약 추가 | 기존 NULL 행에서 적용 실패. 옛 앱의 NULL 쓰기도 제약 위반으로 즉사 | expand: 기본값을 준비하고, 전체 클라이언트가 NULL이 아닌 값을 쓰는 버전으로 갱신 → contract: 옛 앱 퇴출 후 남은 NULL을 UPDATE로 채우고, 그다음 제약을 추가 |
열 이름 변경은 분기가 많아 표의 한 칸에 담기지 않습니다. 절차를 분해하면 다음과 같습니다.
- expand: 새 열을 추가하고, 옛 열의 값을 복사한다. 같은 마이그레이션 번호 안에서
ALTER TABLE ... ADD COLUMN과UPDATE를 수행합니다. - 공존 기간: 새 앱은 신구 양쪽에 쓰고, 읽기는 옛 열을 정답으로 삼는다. 읽기 쪽을 옛 열 그대로 두는 이유는, 옛 앱이 신구와 동시에 가동되는 공유 DB에서는 옛 앱이 옛 열에만 쓰기 때문입니다. 새 열을 읽고 있으면 옛 앱이 넣은 갱신을 놓치게 됩니다. DB 쪽 트리거로 옛 열→새 열을 동기화하는 방법도 있습니다.
- 옛 앱을 퇴출한다. 최소 버전 체크(5.2절)로, 옛 앱이 그 DB를 열 수 없는 상태로 만듭니다. 여기까지는 퇴출시키지 않습니다. 절차 1에서
user_version은 올라가지만, 최소 호환 번호는 그대로이므로 옛 앱은 DB를 열어 옛 열에 계속 쓸 수 있습니다. 이 둘을 같은 값으로 겸하면 절차 1을 적용한 순간 절차 2의 공존 기간이 사라집니다. - contract: 옛 열의 최신값을 새 열로 최종 복사한 다음, 읽기를 새 열로 전환하고, 옛 열을 삭제한다. 이 순서가 핵심으로, 퇴출시키기 전에 읽기를 새 열로 전환하면 옛 앱이 옛 열에만 쓴 갱신을 놓치게 됩니다.
contract(삭제) 쪽 릴리스는 최소 버전 체크(다음 절)로 옛 앱을 퇴출할 수 있게 된 뒤에 내놓는 것이 안전합니다.
SQLite 고유의 사정으로, ALTER TABLE은 테이블 이름 변경・열 이름 변경・열 추가・열 삭제만 지원하며, 열 삭제에도 「PRIMARY KEY나 UNIQUE 제약이 걸린 열은 불가, 인덱스・CHECK 제약・외래 키・뷰에서 참조되는 열은 불가」등 많은 제한이 있습니다. 그 외의 변경은 공식 문서가 정한 「트랜잭션 안에서 새 테이블을 만들고, INSERT INTO new_X SELECT ... FROM X로 데이터를 옮기고, 옛 테이블을 삭제하고 이름을 바꾸는」 절차로 수행합니다.3 큰 테이블에서는 전체 복사가 되므로 적용 시간과 디스크 여유 공간을 예상해 둡니다.
5.2 다운그레이드에 대한 방어 ── 최소 버전 체크
전진 마이그레이션만의 설계에서는 다운 방향의 스크립트는 작성하지 않습니다(거래처에서 쓸 일이 없고, 테스트되지 않는 코드는 위험할 뿐입니다). 대신 필요한 것이 옛 버전 앱이 새로운 DB를 열어 버렸을 때 멈추는 구조입니다.
여기서 「스키마 번호」와 「퇴출 하한선」을 나누는 것이 핵심입니다. 3장의 코드에서는,
user_version── 지금의 스키마 번호. 마이그레이션을 적용할 때마다 올라감schema_meta의min_compatible_version── 이 DB를 열어도 되는 앱의 하한선. contract일 때만 올림
이 두 가지를 가지고, 퇴출은 후자만으로 판정합니다. user_version으로 퇴출시키면 5.1절의 공존 기간이 성립하지 않습니다. 새 앱이 expand를 적용한 시점에 user_version은 올라가므로, 「자신이 아는 최댓값보다 크면 거부」라는 판정이라면 옛 앱은 그 순간부터 DB를 열 수 없게 됩니다. 그러면 「공존 기간은 신구 양쪽이 쓴다」는 설계 자체가 작동하지 않습니다.
나누어 두면 다음과 같이 진행됩니다.
| 단계 | user_version |
min_compatible_version |
옛 앱 |
|---|---|---|---|
| 릴리스 A(expand) 적용 후 | 올라감 | 그대로 | 열 수 있음. 옛 열에 계속 씀 |
| 옛 앱의 업데이트가 배포됨 | 변화 없음 | 그대로 | ── |
| 릴리스 B(contract) 적용 후 | 올라감 | 올라감 | 열 수 없음. 업데이트를 요청하며 정지 |
옛 앱 쪽에서 보면 「자신이 모르는 번호의 스키마이지만, 호환된다고 선언되어 있는」 상태로 동작하게 됩니다. 이때 모르는 열은 건드리지 않는 것이 전제입니다. 그렇기에 expand 쪽에서 넣는 변경은 열 추가에 한정하고, 기존 열의 의미는 바꾸지 않도록 합니다.
또한 「롤백 가능성이 있는 릴리스에는 파괴적 변경을 넣지 않는다(expand만 한다)」고 정해 두면, 새 DB를 옛 앱으로 읽는 것 자체는 안전합니다. 퇴출을 「경고하고 읽기 전용으로 실행」으로 완화하는 설계도 선택할 수 있습니다. 어느 쪽으로 할지는 업무를 멈출 수 없는 정도로 결정합니다.
5.3 적용 전 자동 백업
마이그레이션은 「다른 사람의 PC에 있는 프로덕션 데이터」에 대한 수술입니다. 백업을 받고 나서 실행하는 것을 기계화합니다. SQLite라면 VACUUM INTO가 최적으로, 가동 중인 DB에서도 일관성 있는 스냅숏을 별도 파일로 한 문장에 만들 수 있습니다.2
// conn … 열려 있는 SqliteConnection(3장의 Migrate에 넘기는 것과 동일)
// latest … Migrations의 마지막 번호(3장의 latest와 동일)
// backupDir … 백업을 둘 위치. DB 본체와 같은 폴더에 두면
// 디스크 고장으로 동시에 잃을 수 있으므로 다른 드라이브나 공유 폴더를 권장
var backupDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "db-backup");
// 적용이 필요할 때만, 직전에 1세대 백업을 받는다
if (GetUserVersion(conn) < latest)
{
Directory.CreateDirectory(backupDir);
// 지난번 실패로 남은 작업 파일을, 여기서 실제로 정리한다.
// 남아 있어도 보통은 다음번 실행에 방해가 되지 않지만(이름에 시각이 들어가므로),
// 지우지 않는 한 실패할 때마다 DB 하나 분량의 쓰레기가 쌓인다.
// 그리고 같은 초에 재실행되면 이름이 충돌해, VACUUM INTO는
// 「출력 위치가 존재하지 않거나 비어 있어야 함」을 요구하므로 거기서 멈춘다.
//
// 지우는 것은 「충분히 오래된 것」만으로 한정한다. 이 블록은 6.2의 뮤텍스
// 안쪽에서 동작하는 것을 전제로 하지만, 그래도 다른 버전이나 다른 도구가 같은
// 폴더를 사용하는 경우가 있으며, 지금 실행 중인 처리의 작업 파일을 지우면
// 그 처리가 File.Move 직전에 FileNotFoundException이 된다
DateTime staleBefore = DateTime.UtcNow - TimeSpan.FromHours(1);
foreach (var stale in Directory.EnumerateFiles(backupDir, "*.db.tmp"))
{
try
{
if (File.GetLastWriteTimeUtc(stale) < staleBefore) { File.Delete(stale); }
}
catch (IOException) { } // 다른 프로세스가 잡고 있음. 다음으로 미룸
catch (UnauthorizedAccessException) { }
}
var backupPath = Path.Combine(backupDir,
$"app_schema_v{GetUserVersion(conn)}_{DateTime.Now:yyyyMMdd_HHmmss}.db");
// 실행 도중 정전・프로세스 강제 종료가 일어난 불완전한 파일을
// 「완성된 백업」으로 보이지 않게 하려고, 임시 이름으로 만들고 성공 후 이름을 바꾼다
var tempPath = backupPath + ".tmp";
using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM INTO $path";
cmd.Parameters.AddWithValue("$path", tempPath);
cmd.ExecuteNonQuery(); // VACUUM은 트랜잭션 밖에서 실행한다
File.Move(tempPath, backupPath);
}
이 블록은 6.2에서 준비하는 배타 제어 안쪽에서 동작시켜 주세요. 백업은 마이그레이션의 일부이지 별도 공정이 아닙니다. 「버전을 본다 → 백업을 받는다 → 적용한다」를 하나로 묶어 감싸지 않으면, 아침 일찍 모두가 동시에 앱을 실행하는 업무 앱의 일상 속에서 두 프로세스가 같은 판정에 동시에 도달합니다. 한쪽의 작업 파일을 다른 쪽이 청소해 버려, File.Move 직전에 FileNotFoundException ── 백업은 정상적으로 받았는데 실행만 실패한다는, 설명하기 어려운 형태가 됩니다. 위 코드에서 「충분히 오래된 것만」을 지우는 것은 감싸는 것을 잊었을 때의 피해를 줄이기 위한 보험입니다. 보험일 뿐, 배타 제어를 대신하지 않습니다.
.tmp 청소는 「나중에 작성」이 아니라 이 처리의 일부로 작성해 주세요. VACUUM INTO는 정전이나 프로세스 강제 종료로 중단되면 어중간하게 손상된 출력 파일을 남깁니다.2 임시 이름으로 만들기 때문에 「완성된 백업」으로 보일 일은 없지만, 지우지 않는 한 실패할 때마다 DB 하나 분량의 쓰레기가 이용자의 PC에 쌓입니다. 백업 위치의 용량을 조용히 소모하고, 복구할 때는 「어느 것이 진짜인가」의 판단을 하나 더 늘립니다.
그리고 VACUUM INTO는 출력 위치 파일이 존재하지 않거나(또는 비어 있음) 해야 함을 요구합니다.2 위의 명명 방식에는 시각이 들어가므로 보통은 충돌하지 않지만, 마이그레이션에서 죽은 앱을 이용자가 곧바로 다시 실행하면(또는 모니터링 서비스가 재시작하면) 같은 초에 들어가 이름이 일치해 그 자리에서 멈춥니다. 「백업에 실패해서 실행할 수 없다」는, 가장 설명하기 어려운 형태의 장애입니다.
파일 이름에 스키마 버전을 넣어 두면 복구 시 「어디까지 되돌릴지」가 한눈에 보입니다. 가동 중 DB의 단순 파일 복사가 손상의 온상이 되는 등 백업의 세부 사항은 「C#에서 SQLite를 업무 앱에 쓰는 법」의 7장을 참조하세요. SQL Server라면 BACKUP DATABASE를 적용 전에 실행하는 형태로, 사고방식은 같습니다.
6. 운영의 함정
6.1 도중 실패와 트랜잭션 ── DBMS 차이를 알아 둔다
3장의 코드는 1 마이그레이션을 1 트랜잭션으로 감싸고, user_version 갱신도 같은 트랜잭션에 포함시켰습니다. 이것이 성립하는 것은 SQLite가 DDL(CREATE TABLE, ALTER TABLE 등)을 트랜잭션 안에서 실행할 수 있고, 실패 시 되돌릴 수 있기 때문입니다. 공식 테이블 재구성 절차 자체가 「트랜잭션을 시작하고, CREATE/INSERT/DROP/RENAME을 수행하고, 커밋한다」는 구성입니다.3 도중에 전원이 꺼져도, 다음 실행 시의 DB는 「그 마이그레이션 직전」의 일관된 상태입니다.
SQL Server도 많은 DDL을 트랜잭션 안에서 실행할 수 있지만 예외가 있습니다. 예를 들어 ALTER DATABASE는 명시적 트랜잭션 안에서는 사용할 수 없고, CREATE FULLTEXT INDEX도 사용자 트랜잭션 안에 둘 수 없습니다.4 EF Core도 가능한 경우 각 마이그레이션을 자동으로 트랜잭션으로 감싸는 한편, 「일부 조작은 데이터베이스에 따라 트랜잭션 안에서 실행할 수 없다」고 명시하고 있습니다.8 실무 규칙은 트랜잭션에 들어가지 않는 조작을 통상의 스키마 변경과 같은 마이그레이션에 섞지 않는다, 이것으로 요약됩니다. DBMS를 옮길 때는 「DDL이 트랜잭션에 참여하는지」를 반드시 확인해 주세요.
단골 사고가 「버전 갱신만 별도 트랜잭션」입니다. 변경 본체는 성공하고 번호 갱신 전에 죽으면, 다음 실행 시 같은 마이그레이션이 재실행되어 「테이블이 이미 존재합니다」로 영구히 실행 실패합니다. 번호 갱신을 같은 트랜잭션에 포함시켜 두면 원리적으로 발생하지 않습니다.
6.2 여러 프로세스의 동시 실행 ── 적용의 직렬화
업무 앱은 「아침에 모두가 일제히 실행하는」 소프트웨어입니다. 공유 DB(SQL Server)를 보는 여러 클라이언트나 같은 PC에서의 다중 실행이 동시에 마이그레이션을 돌릴 가능성이 있습니다.
- EF Core 9 이후는
Migrate()가 자동으로 데이터베이스 전체의 잠금을 획득해 동시 적용을 방지합니다(그 이전에는 이 보호가 없습니다). 또한 SQLite 공급자의 잠금은 잠금용 테이블로 구현되어 있어, 적용 중인 프로세스가 비정상 종료하면 테이블이 남을 수 있음이 공식적으로 명시되어 있습니다.7 만약 잠금 대기 상태로 실행할 수 없게 되면, 마이그레이션을 실행 중인 다른 프로세스가 없음을 확인한 뒤, 남은 잠금용 테이블(__EFMigrationsLock)을 DROP하면 복구할 수 있습니다. - 직접 구현에서는 로컬 DB라면 이름이 붙은 Mutex로 직렬화하는 것이 간단합니다.
// using System.Threading; (Mutex / AbandonedMutexException)
// conn … 열려 있는 SqliteConnection. 마이그레이션을 돌리기 전에
// 연결만 마쳐 두고, Mutex를 획득한 뒤 Migrate를 호출한다
using var conn = new SqliteConnection(connectionString);
conn.Open();
// Global\ 을 붙여, RDP나 사용자 전환으로 여러 로그온 세션에서
// 실행되어도 PC 전체에서 직렬화되도록 한다(Local\ 은 같은 세션 안에서만)
using var mutex = new Mutex(false, @"Global\MyApp.SchemaMigration");
try
{
mutex.WaitOne();
}
catch (AbandonedMutexException)
{
// 이전 소유 프로세스가 Release하지 않고 비정상 종료한 경우.
// 예외가 발생해도 소유권 자체는 획득했으므로 그대로 진행해도 된다.
// 적용이 어중간하게 끝났을 가능성에는 이후의 버전 재확인과
// 마이그레이션별 트랜잭션으로 대비한다
}
try
{
SchemaMigrator.Migrate(conn);
}
finally
{
mutex.ReleaseMutex();
}
대기했던 쪽은 잠금 획득 후 다시 한번 버전을 확인하므로(3장의 코드는 적용 전에 매번 version <= current를 확인합니다), 이중 적용이 되지는 않습니다. 또한 Global\ 이름이 붙은 객체는 기본적으로 만든 사용자에서 유래한 ACL을 가지므로, 다른 Windows 계정의 세션에서 같은 Mutex를 열려고 하면 UnauthorizedAccessException이 발생할 수 있습니다. 여러 계정에서의 사용을 전제로 한다면 System.Threading.AccessControl의 MutexAcl로 이용 사용자에게 동기화・변경 접근 권한을 부여해 만들거나, 다음에 설명할 DB 쪽 잠금으로 옮기는 것이 좋습니다. 공유 DB에서는 Mutex가 기계를 넘나들 수 없으므로, 「업데이트 배포 전에 서버 쪽에서 적용을 마친다」, 「적용 시작 시 DB 쪽 잠금(SQLite라면 BEGIN IMMEDIATE, SQL Server라면 애플리케이션 잠금)을 획득한다」등 DB 쪽에서의 직렬화로 옮겨 갑니다.
6.3 리허설 ── 「가장 오래된 DB」에서의 일괄 적용을 테스트한다
마이그레이션의 버그는 개발 머신에서는 거의 발견되지 않습니다. 개발 머신의 DB는 항상 최신 스키마이고 데이터도 깨끗하기 때문입니다. 깨지는 것은 거래처의, 오래되고 크고 예상치 못한 데이터가 들어 있는 DB입니다. 릴리스 전에 최소한 해야 할 일은 3가지입니다.
- 각 스키마 버전의 DB 파일을 테스트 픽스처로 보관하고, 각각에서 최신까지 일괄 적용하는 테스트를 자동화한다. 「v1에서 v5」, 「v3에서 v5」 같은 건너뛰기 패턴이야말로 거래처의 현실입니다. SQLite라면 DB 파일을 저장소에 두기만 하면 되므로 테스트를 작성하기 쉬운 편입니다.
- 실제 데이터에 준하는 양과 질로 시험한다. NULL로 가득한 열, 예상치 못한 중복, 큰 테이블에서의 재구성 시간(5.1절)은 데이터가 실물에 가깝지 않으면 드러나지 않습니다. 가능하면 익명화한 거래처 DB로 리허설합니다.
- 실패 계열을 시험한다. 적용 도중에 프로세스를 죽이고, 다음 실행 시 정상적으로 복귀하는지(되돌아간 버전에서 재적용되는지) 확인합니다.
6.4 현장에서의 확인 절차와 롤백
구조를 도입했다면, 「잘 됐는지」, 「안 됐을 때 무엇을 할지」를 절차로 적어 둡니다. 전화로 작업자에게 지시하는 상황이 반드시 오므로, 명령어 형태로 만들어 두는 것이 실용적입니다.
적용 결과를 확인한다. SQLite의 공식 명령줄 셸(sqlite3)이 있으면 한 줄로 현재 스키마 버전을 읽을 수 있습니다. 반환되는 것은 정수 하나입니다.
sqlite3 "C:\ProgramData\MyApp\app.db" "PRAGMA user_version;"
거래처 PC에 sqlite3.exe를 둘 수 없는 경우도 많으므로, 앱의 버전 정보 화면에 제품 버전과 스키마 버전을 모두 표시해 두면 전화 한 통으로 상태를 확인할 수 있습니다. 3장의 GetUserVersion을 그대로 호출하기만 하면 됩니다. SQL Server라면 SELECT MAX(version) FROM schema_version;이 같은 역할을 합니다.
실패했을 때 되돌린다. 5.3절의 백업은 다음 절차로 되돌립니다.
- 앱을 완전히 종료한다. 다중 실행이나 같은 DB를 보는 다른 단말도 포함해 전부입니다.
- 원본을 대피시킨다. 현재 DB 파일을, WAL 모드라면 같은 이름의
-wal/-shm파일까지 함께 별도 폴더로 옮깁니다. 지우지 말고 보관해 둘 것. 원인 조사에 필요합니다. - 백업 파일을 원래 파일 이름으로 복사한다. 5.3절에서 파일 이름에 스키마 버전을 넣어 두었으므로, 어느 시점으로 되돌리는지가 파일 이름으로 보입니다.
- 앱을 실행하고,
PRAGMA user_version이 되돌리고자 하는 번호가 되어 있는지 확인한다. 그런 다음, 원인이 수정된 버전의 앱을 배포할 때까지는 옛 버전 앱으로 운영을 계속합니다.
이 절차가 적혀 있는지 여부로 장애 당일의 복구 시간이 달라집니다. 마이그레이션 구현과 같은 릴리스에서, 운영 절차서에도 한 페이지 더해 두세요.
7. 정리
- 「거래처마다 DB가 다르다」는 담당자의 주의력 문제가 아니라, 사람이 SQL 절차서를 실행하는 운영의 구조적 귀결입니다. DB가 분산되는 데스크톱 업무 앱에서는 앱 자신이 자기 DB를 최신화하게 하는 수밖에 없습니다.
- 골격은 DB 자신이 갖는 스키마 버전 번호(SQLite라면
PRAGMA user_version1)와 번호가 매겨진 전진 마이그레이션의 실행 시 적용입니다. C#이라면 수십 줄의 직접 구현으로 성립합니다. - 수단은 EF Core Migrations・DbUp 등의 라이브러리・직접 구현의 3가지 계통입니다. 이미 EF Core를 쓰고 있는지, 원시 SQL 자산이 얼마나 있는지로 선택합니다(4장의 판단표). EF Core의 실행 시
Migrate()는 공식적으로 주의점이 나열되어 있으므로7, 동시 실행 대책과 리허설을 곁들여 사용합니다. - 파괴적 변경은 expand-contract의 2단계 릴리스로 진행하고, 옛 앱이 새 DB를 여는 사고는 최소 버전 체크로 막습니다. SQLite의 ALTER TABLE 제약과 재구성 절차는 공식 문서를 따릅니다.3
- 1 마이그레이션 = 1 트랜잭션, 버전 갱신도 같은 트랜잭션이 원칙입니다. SQL Server에는 트랜잭션에 들어가지 않는 DDL이 있으므로4 예외 조작은 분리합니다. 적용 전
VACUUM INTO백업2과, 가장 오래된 버전에서의 일괄 적용 리허설까지 포함해야 비로소 「거래처에 내놓을 수 있는」 마이그레이션이 됩니다.
절차서 ALTER 운영에 짚이는 데가 있다면, 다음 릴리스에서 「버전 번호 기록」과 「실행 시 적용」만이라도 넣어 보세요. 토대만 있으면 2단계 릴리스나 백업은 나중에 조금씩 더해 갈 수 있습니다.
관련 글
- C#에서 SQLite를 업무 앱에 쓰는 법 ── WAL 모드・배타 제어・손상 대책・EF Core와의 구분
- Windows 앱 데이터 저장 위치를 고르는 방법 ── SQLite / JSON / 레지스트리 / Access 판단표
- appsettings.json만으로는 부족하다 ── Windows 업무 앱 구성 관리 실무(환경별 설정・비밀 정보・쓰기 위치)
- 업무 앱의 날짜/시간과 시간대 ── DateTime의 함정부터 UTC 저장 원칙, 테스트 설계까지
관련 상담 영역
합동회사 코무라소프트는 거래처마다 설치되는 업무 앱의 DB 설계・마이그레이션 기반 도입, 절차서 운영으로 흩어진 스키마의 조사와 정상화, EF Core / 원시 SQL 구성 각각에서의 업데이트 배포 설계를 다루고 있습니다.
참고 링크
-
SQLite, Pragma statements supported by SQLite - user_version. user_version은 데이터베이스 헤더(오프셋 60)에 저장되는 정수로, 애플리케이션이 자유롭게 사용하도록 마련되어 있으며 SQLite 자신은 이 값을 이용하지 않는다는 점에 대해. ↩ ↩2 ↩3
-
SQLite, VACUUM. VACUUM INTO가 원본 DB를 변경하지 않고, 가동 중인 데이터베이스의 일관성 있는 스냅숏을 별도 파일로 만들 수 있으며 백업 API의 대안으로 쓸 수 있다는 점에 대해. 아울러 「INTO 절에 지정한 파일은 사전에 존재하지 않거나 빈 파일이어야 한다. 그렇지 않으면 VACUUM INTO 명령은 오류로 실패한다」는 요구 사항과, 「다만 VACUUM INTO 명령이 계획되지 않은 종료나 정전으로 중단된 경우, 생성된 출력 데이터베이스는 불완전하고 손상되어 있을 수 있다」는 기술에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
SQLite, ALTER TABLE. SQLite의 ALTER TABLE이 테이블 이름 변경・열 이름 변경・열 추가・열 삭제로 한정되어 있는 점, 열 삭제에 많은 제한이 있는 점, 그 외의 스키마 변경은 트랜잭션 안에서 새 테이블 생성 → 데이터 복사 → 옛 테이블 삭제 → 이름 변경을 수행하는 공식 절차로 실시한다는 점에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, ALTER DATABASE (Transact-SQL) 및 CREATE FULLTEXT INDEX (Transact-SQL). ALTER DATABASE는 자동 커밋 모드에서 실행해야 하며 명시적・암시적 트랜잭션 안에서는 허용되지 않는다는 점, CREATE FULLTEXT INDEX가 사용자 트랜잭션 안에 둘 수 없다는 점에 대해. ↩ ↩2 ↩3
-
DbUp, DbUp Documentation 및 Supported Databases. SQL Server 데이터베이스로의 변경 배포를 지원하는 .NET 라이브러리이며, 실행한 SQL 스크립트를 기록해 미실행분만 실행한다는 점, SQLite・PostgreSQL・MySQL 등도 지원한다는 점에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, SQLite EF Core Database Provider Limitations. SQLite 공급자에서는 많은 마이그레이션 조작이 테이블 재구성으로 실행된다는 점, 멱등 스크립트를 생성할 수 없다는 점에 대해. ↩ ↩2
-
Microsoft Learn, Applying Migrations (EF Core). 실행 시(기동 시)의 마이그레이션 적용이 프로덕션 데이터베이스 관리에는 적합하지 않다고 여겨지는 5가지 이유, SQL 스크립트 생성이 권장된다는 점, EnsureCreated()와 Migrate()를 함께 써서는 안 된다는 점, EF Core 9 이후의 Migrate()가 데이터베이스 전체의 잠금을 자동으로 획득한다는 점, SQLite 공급자의 잠금이 테이블로 구현되어 있어 비정상 종료 시 남을 수 있다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Managing Migrations (EF Core). EF Core가 적용 시 가능한 경우 각 마이그레이션을 자동으로 트랜잭션으로 감싼다는 점, 일부 조작은 데이터베이스에 따라 트랜잭션 안에서 실행할 수 없다는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
업무 시스템의 코드 설계 ── 상품 코드・고객 코드 정하는 방법과 체크 디지트
상품 코드・고객 코드 등 업무 시스템의 코드 체계를 정하는 실전 가이드. 유의미 코드와 무의미 일련번호의 판단표, JAN・Luhn 등 체크 디지트 산식과 C# 구현, Excel의 앞자리 0 소실 대책, 자릿수 초과와 이관까지 정리합니다.
VB6 애플리케이션은 언제까지 동작할까 ── 런타임 지원 현황과 현실적인 .NET 이전 진행 방법
VB6 애플리케이션은 언제까지 동작할까요. VB6 런타임의 지원 정책(Windows 11도 지원 대상)과 IDE 지원은 이미 종료되었다는 비대칭적인 현황을 정리하고, 전면 재작성・자동 변환・단계적 이전의 판단표, 이전 전 자산 파악, VB6와 ....
WinForms / WPF 앱의 CI/CD 실전 ── GitHub Actions로 빌드부터 서명·배포까지 자동화하기
WinForms / WPF 앱의 CI/CD를 GitHub Actions로 구성하는 실무 가이드입니다. windows-latest에서의 빌드+테스트 최소 YAML, 태그 기반 버전 번호 부여, signtool을 이용한 서명 적용, MSI/MSIX/...
테스트 없는 레거시 업무 애플리케이션에 안전하게 손을 대는 방법 ── 특성화 테스트와 리팩터링의 실천
테스트가 없는 업무 애플리케이션에 안전하게 손을 대기 위해, 현재 동작을 고정하는 특성화 테스트(골든 마스터 기법)의 절차, 이음매(seam)를 만드는 방법, 리팩터링과 기능 추가를 섞지 않는 운영 규칙을 C# 예제로 설명합니다.
Windows 앱 데이터 저장 위치를 고르는 방법 ── SQLite / JSON / 레지스트리 / Access 판단표
Windows 데스크톱 앱의 데이터를 어디에·무엇으로 저장할지 정리합니다. AppData/ProgramData 구분, SQLite・JSON 파일・레지스트리・Access(.accdb) 각각의 강점과 함정을 판단표로 정리하고, 손상 대책과 비트 수 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 업무 앱의 DB 스키마 변경은 어떻게 관리해야 하나요?
- SQL 절차서를 사람이 실행하는 운영 방식이 아니라, 번호가 매겨진 마이그레이션(스키마 변경 코드)을 앱 본체에 동봉해 실행 시(기동 시)에 자동으로 적용하는 형태여야 합니다. DB 자신에게 현재 스키마 버전을 기록해 두고(SQLite라면 PRAGMA user_version, SQL Server라면 전용 테이블), 앱은 미적용 번호만 순서대로 트랜잭션으로 적용합니다. 이 형태라면 v1.2에서 v1.5로 건너뛰는 업데이트에서도 중간의 스키마 변경이 모두 적용되어, 「거래처마다 DB 형태가 다르다」는 상태가 구조적으로 발생하지 않게 됩니다.
- EF Core의 Migrate()를 앱 실행 시에 호출해도 되나요?
- 조건부로 현실적인 선택입니다. Microsoft 문서는 여러 인스턴스의 동시 적용, 앱에 스키마 변경 권한을 부여하는 것, SQL을 사전에 확인할 수 없는 것 등을 이유로 프로덕션 환경에서의 실행 시 적용에 주의를 환기하고 있으며, 서버 앱에서는 SQL 스크립트 생성을 통한 적용을 권장합니다. 반면 클라이언트 PC마다 로컬 DB를 갖는 데스크톱 업무 앱에서는 현지에서 스크립트를 실행하는 운영이 성립하지 않기 때문에, 실행 시 Migrate()가 사실상의 표준 해법이 됩니다. 이 경우에도 동시 실행 대책(EF Core 9 이후의 자동 잠금 또는 직접 구현한 Mutex)과 적용 전 백업을 반드시 함께 사용해야 합니다.
- 마이그레이션이 도중에 실패하면 DB는 어떻게 되나요?
- 하나의 마이그레이션을 하나의 트랜잭션으로 감싸고 버전 번호 갱신도 같은 트랜잭션에 포함시켰다면, 실패 시에는 그 마이그레이션 시작 전 상태로 롤백되어 어중간한 스키마가 남지 않습니다. SQLite는 CREATE TABLE이나 ALTER TABLE 같은 DDL도 트랜잭션 안에서 실행할 수 있으며, 공식 테이블 재구성 절차 자체가 트랜잭션을 전제로 작성되어 있습니다. SQL Server도 많은 DDL을 트랜잭션 안에서 실행할 수 있지만 ALTER DATABASE나 전문 검색 인덱스 관련 등 예외가 있으므로, 예외 조작은 단독 마이그레이션으로 분리합니다. 여기에 적용 전 자동 백업까지 있으면 최악의 경우에도 파일 교체로 복구할 수 있습니다.
- 이미 거래처마다 DB 스키마가 제각각인 경우 어떻게 정상화해야 하나요?
- 먼저 「있어야 할 스키마의 정답」을 하나 정하고, 각 거래처의 DB를 조사해 현재 상태와의 차이를 파악합니다. 다음으로 버전 번호가 없는 DB에 대해, 실제로 존재하는 각 패턴을 검출해 정규형으로 맞추는 초기 마이그레이션을 작성하고, 그 완료 시점에 버전 번호를 새깁니다. SQLite라면 sqlite_master나 PRAGMA table_info로 열의 존재 여부를 기계적으로 판정할 수 있어, 「열이 없으면 추가한다」는 형태의 방어적인 SQL로 흡수할 수 있습니다. 이후의 모든 변경을 번호가 매겨진 마이그레이션에 실으면 차이는 재발하지 않습니다.