업무 앱의 DB 스키마를 버전 관리한다 ── 「고객사마다 DB가 다르다」를 막는 마이그레이션 실무

· 업데이트: · · 데이터베이스, SQLite, SQL Server, 마이그레이션, 스키마 관리, C#, .NET, 유지보수, 판단표, Windows 개발

수정 이력(9건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
시작 시 적용 흐름과 expand-contract의 시간 축을, 텍스트 그림에서 도식으로 다시 그렸습니다. 내용은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
이전에 추가한 `.tmp` 정리가 동시 시작과 충돌할 수 있던 점을 고쳤습니다. 이 처리는 6.2의 배타 잠금 안에서 돌리는 전제지만, 감싸는 것을 빠뜨리면 두 프로세스가 같은 판정에 동시에 도달하고, 한쪽의 작업 파일을 다른 쪽이 지워 `File.Move` 직전에 `FileNotFoundException`이 납니다. 백업은 제대로 받았는데 시작만 실패하는 형태입니다. 배타 잠금 안에서 돌려야 한다는 점을 본문에 명시하고, 정리 대상도 「충분히 오래된 것」으로 한정했습니다(감싸는 것을 빠뜨렸을 때의 보험이지, 배타 잠금의 대체물이 아니라는 점도 덧붙였습니다).
적용 전 백업 예에서, 주석으로만 적혀 있던 `.tmp` 뒷정리를 구현했습니다. `VACUUM INTO`는 정전이나 프로세스 강제 종료로 중단되면 어중간한 출력 파일을 남깁니다. 임시 이름으로 만들고 있으므로 완성본으로 오인되지는 않지만, 지우지 않는 한 실패할 때마다 DB 하나분의 쓰레기가 이용자 PC에 쌓입니다. 게다가 `VACUUM INTO`는 출력 대상이 존재하지 않거나(또는 비어 있을) 것을 요구하므로, 마이그레이션에서 실패한 앱을 같은 초에 다시 시작하면 이름이 일치해 멈춥니다. 백업을 받기 전에 잔여 파일을 삭제하도록 하고, 공식의 해당 기술도 각주에 보완했습니다.
`schema_meta`를 만드는 SQL을 마이그레이션 목록 밖에 두었기 때문에, 실제로는 아무도 실행하지 않는 상태였습니다. `GetMinCompatibleVersion`은 항상 0을 돌려 차단이 먹지 않고, 첫 contract에서 `UPDATE schema_meta`가 「테이블이 없다」로 실패합니다. 1번 마이그레이션 안에서 만들도록 고치고, 기존 DB에 나중에 붙일 때의 절차도 덧붙였습니다.
이전 앱의 차단을 `user_version`으로 판정하고 있었기 때문에, 5.1절에서 설명하는 공존 기간이 성립하지 않았습니다. 새 앱이 expand를 적용한 시점에 `user_version`이 올라, 「자기가 아는 최댓값보다 크면 거부」라는 판정이면 이전 앱은 그 순간부터 DB를 열지 못합니다. 공존 기간 중에는 이전 앱이 옛 열에 계속 쓴다는 전제이므로, 설계 자체가 움직이지 않습니다. 스키마 번호와 별도로 「이 DB를 열어도 되는 앱의 하한」을 두고, contract 때에만 올리는 형태로 나눴습니다. 단계별 값의 움직임을 표로 만들고, 호환이라고 선언된 미지의 스키마에서는 모르는 열에 손대지 않는다는 전제도 명시했습니다.
expand-contract의 의미를 처음 나오는 위치에서 정의하고, 시작 시 적용 흐름과 단계 이행의 시간 축을 그림으로 만들었습니다. 아울러 코드 예에서 정의되지 않았던 변수의 보완, 현장에서의 확인 절차와 되돌리기, 열 이름 변경 절차의 전개를 추가했습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22174334)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「업무 앱의 DB 스키마를 버전 관리한다 ── 「고객사마다 DB가 다르다」를 막는 마이그레이션 실무」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/db-schema-migration-versioning-business-apps/

DOI(등록된 아카이브)
10.5281/zenodo.22174334
DOI(마지막 등록 버전)
10.5281/zenodo.22174335

「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, 실패 시 복구는 파일 교체가 됩니다.
  • 마이그레이션 하나 = 트랜잭션 하나, 버전 번호 갱신도 같은 트랜잭션에 넣습니다. SQLite는 DDL도 트랜잭션으로 되돌릴 수 있습니다.3 SQL Server에는 예외 DDL이 있으므로, 예외 작업은 단독 마이그레이션으로 분리합니다.4

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 22건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 「고객사마다 DB가 다르다」 문제는 왜 생기는가

원인을 나누면, 어느 쪽이든 「사람이 한다는 전제의 운영」으로 모입니다.

  • 수작업 ALTER의 적용 누락. 절차서 SQL을 실행했는지 기록은 DB 어디에도 없고, 확인 수단이 「테이블 정의 육안 확인」뿐인 시점에서 누락은 반드시 납니다.
  • 도중 실패의 방치. 절차서 SQL 다섯 개 중 세 번째에서 오류가 나면, 작업자는 계속할지 되돌릴지 판단하지 못하고 「앱은 돌아갔으니 그대로」가 됩니다. 그 DB는 이미 어느 버전과도 맞지 않는 세상에 하나뿐인 스키마입니다.
  • 버전 건너뛰기 업데이트. v1.2 다음에 v1.5를 넣는 고객사에서는 v1.3과 v1.4의 스키마 변경을 모아 정확히 따라가야 하지만, 절차서 운영으로는 어렵습니다.
  • 긴급 대응의 현장 패치. 「이 고객사만 먼저 열을 넣었다」가 생기고, 나중에 정식 업데이트에서 이중 적용 오류가 납니다.

서버 한 대의 웹 시스템이라면 DB는 하나이고, 상태는 항상 파악할 수 있습니다. 데스크톱 업무 앱의 본질적인 어려움은 같은 앱의 DB가 고객사·거점 PC에 수십, 수백 개로 흩어지고, 게다가 전부가 같은 버전이라고 할 수 없다는 점입니다. 한 대씩 사람이 대응하는 운영은 대수에 비례해 무너지므로, 결론은 하나입니다. 앱 스스로 자기 DB를 검사해 최신 스키마까지 끌어올리는 능력을 갖게 하는 것입니다.

3. 기본 패턴: 스키마 버전 + 전진 마이그레이션

구조의 골격은 세 가지뿐입니다.

  1. DB 자신이 스키마 버전 번호를 가진다(앱의 제품 버전과는 별개인, 스키마 전용 정수).
  2. 스키마 변경은 번호가 붙은 마이그레이션 목록으로, 앱 코드에 추가해 나간다.
  3. 앱은 시작 시(DB 연결 직후)에, 현재 버전보다 큰 번호의 마이그레이션을 순서대로 트랜잭션으로 적용한다.

시작 시 흐름을 그림으로 그리면 다음과 같습니다. 5장·6장에서 더해 갈 방어는 모두 이 흐름의 어딘가에 들어갑니다.

크다그렇지 않다크다그렇지 않다없다있다도중에 실패했을 때앱 시작·DB에 연결PRAGMA user_version과 최소 호환 번호를 읽는다최소 호환 번호가앱이 아는 최댓값보다 큰가시작을 중단한다(5.2절)현재 번호가앱이 아는 최댓값보다 큰가호환인 미래 스키마.적용하지 않고 그대로 보통 시작(5.2절)미적용 마이그레이션이 있는가그대로 보통 시작적용 전 백업을 받는다(VACUUM INTO·5.3절)미적용 번호를 오름차순으로 한 건씩 적용한다(6.1절)BEGIN TRANSACTION → 스키마 변경·데이터 변환 →PRAGMA user_version = 그 번호 → COMMIT그 한 건만 되돌려지고,직전 번호에서 멈춘다최신까지 도달하면 보통 시작

그림 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 EXISTSINSERT OR IGNORE로 다시 만드세요(번호를 하나 더할 뿐입니다). GetMinCompatibleVersion이 테이블의 유무를 먼저 확인하는 것은, 이 이행 기간 때문입니다.

contract 마이그레이션에서만 이 값을 올립니다.

-- 옛 열을 삭제하는 회차 마이그레이션에서, 같은 트랜잭션 안에서 올린다
UPDATE schema_meta SET value = 7 WHERE key = 'min_compatible_version';
ALTER TABLE customer DROP COLUMN old_name;

이렇게 하면 2장의 문제는 구조적으로 풀립니다. 적용 누락은 일어나지 않고(시작할 때마다 검사한다), 도중 실패는 되돌려지며(6장), 버전 건너뛰기도 문제 없습니다(스키마 v2인 v1.2 DB라면, v1.5 앱이 3·4·5를 순서대로 적용할 뿐입니다). 「이 고객사 DB는 어느 상태인가」도 PRAGMA user_version을 한 번 읽으면 답할 수 있습니다.

운영 규칙은 두 가지만 엄수합니다.

  • 출시한 번호는 고치지 않는다. v3 SQL에 버그가 있어도 v4에서 고칩니다. 고치면 「옛 v3를 적용한 쪽」「새 v3를 적용한 쪽」이라는 새로운 어긋남이 생깁니다.
  • 데이터 변환도 마이그레이션에 넣는다. 열 추가뿐 아니라 기존 데이터 옮기기(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 등), 앞 장의 자체 구현 세 계통이 있습니다. 이름만으로는 무엇을 하는 도구인지 알기 어려우므로, 먼저 한 마디씩 붙입니다.

  • 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을 사전에 확인·수정할 수 없음, 다섯 가지이며, 권장은 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(줄이기)의 두 단계로 나눕니다.

시간 축으로 보면, 둘 사이에 「이전 형식과 새 형식 어느 쪽으로도 움직이는 공존 기간」을 끼우는 것이 요점입니다.

릴리스 A(expand: 넓히기)새 구조를 추가하고, 옛 구조는 그대로 남긴다앱은 이전 구조와 새 구조 양쪽에 쓰고, 읽기는 옛 구조를 정본으로 한다공존 기간이전 앱·옛 도구도 그대로 동작한다(어느 구조도 살아 있다)이 사이에 모든 클라이언트를 새 버전으로 교체한다최소 버전 체크(5.2절)로이전 앱을 차단할 수 있는 상태가 된다릴리스 B(contract: 줄이기)옛 구조의 최신값을 새 구조로 최종 복사 / 최종 변환한다읽기를 새 구조로 전환하고, 옛 구조를 삭제한다

그림 2: expand와 contract 사이에 공존 기간을 끼운다. 이전 앱을 차단할 수 있게 될 때까지 contract를 내지 않는 것이 요점

변경 내용 한 번에 하면 일어나는 일 안전한 2단계
열의 이름 변경 옛 이름을 참조하는 이전 앱·리포트가 즉시 실패한다 절차가 길어서 아래에 나눠 보입니다
열의 삭제 이전 앱의 INSERT/SELECT가 오류 expand: 앱이 참조만 그만둔다(열은 남긴다) → contract: 몇 릴리스 뒤에 삭제
형·의미의 변경(예: 로컬 시각→UTC) 이전 값과 새 값이 한 열에 섞여, 조용히 깨진다 expand: 새 열을 추가해 변환이 끝난 값을 넣는다. 공존 기간은 이름 변경과 같은 취급(새 앱은 양쪽에 쓰고, 읽기는 옛 열을 정본으로 한다) → contract: 이전 앱을 차단한 뒤, 옛 열에서 최종 변환을 한 다음 읽기를 전환하고, 옛 열을 삭제
NOT NULL 제약의 추가 기존 NULL 행에서 적용이 실패. 이전 앱의 NULL 쓰기도 제약 위반으로 즉시 실패한다 expand: 기본값을 마련하고, 모든 클라이언트가 non-NULL을 쓰는 버전으로 갱신 → contract: 이전 앱 차단 후 남은 NULL을 UPDATE로 메운 다음 제약을 추가

열의 이름 변경은 조건 분기가 많아, 표의 한 칸에 들어가지 않습니다. 절차를 나누면 다음과 같습니다.

  1. expand: 새 열을 추가하고, 옛 열의 값을 복사한다. 같은 마이그레이션 번호 안에서 ALTER TABLE ... ADD COLUMNUPDATE를 합니다.
  2. 공존 기간: 새 앱은 이전 구조와 새 구조 양쪽에 쓰고, 읽기는 옛 열을 정본으로 한다. 읽기 쪽을 옛 열로 두는 것은, 이전 앱과 새 앱이 동시에 돌아가는 공유 DB에서는 이전 앱이 옛 열에만 쓰기 때문입니다. 새 열을 읽고 있으면, 이전 앱이 넣은 갱신을 놓칩니다. DB 쪽 트리거로 옛 열→새 열을 동기화하는 방법도 있습니다.
  3. 이전 앱을 차단한다. 최소 버전 체크(5.2절)로, 이전 앱이 그 DB를 열지 못하는 상태로 만듭니다. 여기까지는 차단하지 않습니다. 절차 1에서 user_version은 오르지만, 최소 호환 번호는 그대로이므로, 이전 앱은 DB를 열고 옛 열에 계속 쓸 수 있습니다. 이 둘을 같은 값으로 겸하면, 절차 1을 적용한 순간에 절차 2의 공존 기간이 사라집니다.
  4. contract: 옛 열의 최신값을 새 열로 최종 복사한 뒤, 읽기를 새 열로 전환하고, 옛 열을 삭제한다. 이 순서가 핵심이며, 차단하기 전에 읽기를 새 열로 바꾸면, 이전 앱이 옛 열에만 쓴 갱신을 놓칩니다.

contract(깎는) 쪽 릴리스는, 최소 버전 체크(다음 절)로 이전 앱을 차단할 수 있게 된 뒤에 내는 것이 안전합니다.

SQLite 고유의 사정으로, ALTER TABLE은 테이블 이름 변경·열 이름 변경·열 추가·열 삭제만 지원하며, 열 삭제에도 「PRIMARY KEY나 UNIQUE 제약의 열은 불가, 인덱스·CHECK 제약·외래 키·뷰에서 참조되는 열은 불가」 등 제한이 많습니다. 그 밖의 변경은, 공식 문서가 정한 「트랜잭션 안에서 새 테이블을 만들고, INSERT INTO new_X SELECT ... FROM X로 데이터를 옮기고, 옛 테이블을 삭제한 뒤 이름을 바꾼다」는 절차로 합니다.3 큰 테이블에서는 전체 행을 복사하게 되므로, 적용 시간과 디스크 여유 용량을 잡아 둡니다.

5.2 다운그레이드에 대한 방어 ── 최소 버전 체크

전진 마이그레이션만의 설계에서는, 다운 방향 스크립트는 쓰지 않습니다(고객사에서 쓸 기회가 없고, 테스트되지 않은 코드는 위험할 뿐입니다). 대신 필요한 것이, 이전 버전 앱이 새 DB를 열었을 때 멈추는 장치입니다.

여기서 「스키마 번호」와 「차단 하한」을 나누는 것이 요점입니다. 3장의 코드에서는,

  • user_version ── 지금 스키마 번호. 마이그레이션을 적용할 때마다 오른다
  • schema_metamin_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장의 코드는 마이그레이션 하나를 트랜잭션 하나로 감싸고, 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.AccessControlMutexAcl로 이용 사용자에게 동기·변경 접근 권한을 주어 만들거나, 다음에 말하는 DB 쪽 잠금으로 맞춥니다. 공유 DB에서는 Mutex가 머신을 넘지 못하므로, 「업데이트 배포 전에 서버 쪽에서 적용을 끝낸다」「적용 시작 시 DB 쪽 잠금(SQLite라면 BEGIN IMMEDIATE, SQL Server라면 앱 잠금)을 얻는다」 등, DB 쪽 직렬화로 맞춥니다.

6.3 리허설 ── 「가장 오래된 DB」부터 한 번에 적용을 테스트한다

마이그레이션의 버그는 개발기에서는 좀처럼 나오지 않습니다. 개발기 DB는 항상 최신 스키마이고, 데이터가 깨끗하기 때문입니다. 깨지는 것은 고객사의, 오래되고, 크고, 예상 밖 데이터가 들어 있는 DB입니다. 릴리스 전에 최소한 해야 할 일은 세 가지입니다.

  • 각 스키마 버전의 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절의 백업은, 다음 절차로 되돌립니다.

  1. 앱을 완전히 종료한다. 다중 시작이나, 같은 DB를 보는 다른 단말까지 전부입니다.
  2. 현재 파일을 안전한 곳으로 옮긴다. 현재 DB 파일을, WAL 모드라면 같은 이름의 -wal / -shm 파일까지, 다른 폴더로 옮깁니다. 지우지 말고 남겨 둘 것. 원인 조사에 필요합니다.
  3. 백업 파일을 원래 파일 이름으로 복사한다. 5.3절에서 파일 이름에 스키마 버전을 넣어 두었으므로, 어느 시점으로 돌아가는지가 파일 이름으로 보입니다.
  4. 앱을 시작하고, PRAGMA user_version이 되돌리고 싶은 번호인지 확인한다. 그런 다음, 원인이 고쳐진 버전의 앱을 배포할 때까지는, 이전 버전 앱으로 운영을 계속합니다.

이 절차가 적혀 있는지에 따라, 장애 당일 복구 시간이 달라집니다. 마이그레이션 구현과 같은 릴리스에서, 운영 절차서에도 한 페이지를 더해 두세요.

7. 정리

  • 「고객사마다 DB가 다르다」는 담당자의 주의력이 아니라, 사람이 SQL 절차서를 실행하는 운영의 구조적 귀결입니다. DB가 흩어지는 데스크톱 업무 앱에서는, 앱 스스로 자기 DB를 최신화시키는 수밖에 없습니다.
  • 골격은, DB 자신이 가진 스키마 버전 번호(SQLite라면 PRAGMA user_version1)와, 번호가 붙은 전진 마이그레이션의 시작 시 적용. C#이라면 수십 줄의 자체 구현으로 성립합니다.
  • 수단은 EF Core Migrations·DbUp 등 라이브러리·자체 구현 세 계통. 이미 EF Core를 쓰고 있는지, 생 SQL 자산이 얼마나 있는지로 고릅니다(4장의 판단표). EF Core의 시작 시 Migrate()는 공식에 주의점이 열거되어 있으므로7, 동시 실행 대책과 리허설을 붙여 씁니다.
  • 파괴적 변경은 expand-contract의 2단계 릴리스로 하고, 이전 앱이 새 DB를 여는 사고는 최소 버전 체크로 막습니다. SQLite ALTER TABLE의 제약과 재구성 절차는 공식 문서를 따릅니다.3
  • 마이그레이션 하나 = 트랜잭션 하나, 버전 갱신도 같은 트랜잭션이 원칙입니다. SQL Server에는 트랜잭션에 들어가지 않는 DDL이 있으므로4, 예외 작업은 분리합니다. 적용 전 VACUUM INTO 백업2과, 가장 오래된 버전부터 한 번에 적용하는 리허설까지 포함해야, 비로소 「고객사에 내보낼 수 있는」 마이그레이션입니다.

절차서 ALTER 운영을 하고 있다면, 다음 릴리스에서 「버전 번호의 기록」과 「시작 시 적용」만이라도 넣어 보세요. 토대만 있으면, 2단계 릴리스나 백업은 나중에 조금씩 더해 갈 수 있습니다.

관련 글

관련 상담 영역

合同会社小村ソフト에서는, 고객사마다 설치되는 업무 앱의 DB 설계·마이그레이션 기반 도입, 절차서 운영으로 어긋난 스키마의 조사와 정상화, EF Core / 생 SQL 구성 각각에서의 업데이트 배포 설계를 다루고 있습니다.

참고 링크

  1. SQLite, Pragma statements supported by SQLite - user_version. user_version은 데이터베이스 헤더(오프셋 60)에 저장되는 정수로, 애플리케이션이 자유롭게 쓰도록 마련되어 있으며, SQLite 자신은 이 값을 이용하지 않는다는 점에 대해.  2 3

  2. SQLite, VACUUM. VACUUM INTO가 원본 DB를 변경하지 않고, 가동 중 데이터베이스의 일관된 스냅샷을 다른 파일로 만들 수 있으며, 백업 API의 대체로 쓸 수 있다는 점에 대해. 아울러, 「INTO 절에서 지정한 파일은, 사전에 존재하지 않거나, 빈 파일이어야 한다. 그렇지 않으면 VACUUM INTO 명령은 오류로 실패한다」는 요건과, 「다만, VACUUM INTO 명령이 예기치 않은 종료나 정전으로 중단된 경우, 생성된 출력 데이터베이스는 불완전하고 깨져 있을 수 있다」는 기술에 대해.  2 3 4 5

  3. SQLite, ALTER TABLE. SQLite의 ALTER TABLE이 테이블 이름 변경·열 이름 변경·열 추가·열 삭제에 한정된다는 점, 열 삭제에 제한이 많다는 점, 그 밖의 스키마 변경은 트랜잭션 안에서 새 테이블 생성→데이터 복사→옛 테이블 삭제→이름 변경을 하는 공식 절차로 실시한다는 점에 대해.  2 3 4

  4. Microsoft Learn, ALTER DATABASE (Transact-SQL)CREATE FULLTEXT INDEX (Transact-SQL). ALTER DATABASE는 자동 커밋 모드에서 실행해야 하며 명시적·암시적 트랜잭션 안에서는 허용되지 않는다는 점, CREATE FULLTEXT INDEX를 사용자 트랜잭션 안에 둘 수 없다는 점에 대해.  2 3

  5. DbUp, DbUp DocumentationSupported Databases. SQL Server 데이터베이스로의 변경 배포를 지원하는 .NET 라이브러리이며, 실행이 끝난 SQL 스크립트를 기록해 미실행분만 실행한다는 점, SQLite·PostgreSQL·MySQL 등에도 대응한다는 점에 대해.  2 3 4

  6. Microsoft Learn, SQLite EF Core Database Provider Limitations. SQLite 공급자에서는 많은 마이그레이션 작업이 테이블 재구성으로 실행된다는 점, 멱등 스크립트를 생성할 수 없다는 점에 대해.  2

  7. Microsoft Learn, Applying Migrations (EF Core). 실행 시(시작 시) 마이그레이션 적용이 프로덕션 데이터베이스 관리에는 부적절하다고 여겨지는 다섯 가지 이유, SQL 스크립트 생성이 권장된다는 점, EnsureCreated()와 Migrate()를 병용해서는 안 된다는 점, EF Core 9 이후의 Migrate()가 데이터베이스 전체 잠금을 자동으로 얻는다는 점, SQLite 공급자의 잠금이 테이블로 구현되어 비정상 종료 시 잔류할 수 있다는 점에 대해.  2 3 4 5

  8. Microsoft Learn, Managing Migrations (EF Core). EF Core가 적용 시 각 마이그레이션을 가능한 경우에는 자동으로 트랜잭션으로 감싼다는 점, 일부 작업은 데이터베이스에 따라 트랜잭션 안에서 실행할 수 없다는 점에 대해. 

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

업무 앱의 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로 흡수할 수 있습니다. 이후 변경을 모두 번호가 붙은 마이그레이션에 올리면 차이는 다시 생기지 않습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기