C#에서 SQLite를 업무 앱에 쓰는 법 ── WAL 모드・배타 제어・손상 대책・EF Core와의 구분

· · SQLite, C#, .NET, Microsoft.Data.Sqlite, EF Core, 데이터 저장, Windows, 운영, 기술 상담

지난 글 「Windows 앱의 데이터 저장 위치 고르는 법」에서, 계속 늘어나는 업무 데이터・이력의 저장 위치로는 SQLite가 첫 번째 후보라고 썼습니다. 판단은 그것으로 끝나지만, 실제로 통합하는 단계에 들어서면 또 다른 고민이 생깁니다. 상담에서 자주 듣는 이야기는 「NuGet에서 SQLite로 검색하면 패키지가 여러 개 나오는데 어느 것을 넣어야 할지 모르겠다」「동작은 하는데 가끔 database is locked가 나온다. 재시도를 넣어서 버티고 있는데 이게 맞는 방법인지 모르겠다」「백업은 DB 파일을 그냥 복사만 하면 되는지」 같은 것들입니다.

이런 것들은 업무 앱에서 SQLite를 몇 년간 운영한다면 반드시 마주치게 되는 논점이며, 처음 설계할 때 잡아 두면 나중에 고생하지 않는 부류입니다. 이 글에서는 Microsoft.Data.Sqlite를 전제로, 라이브러리 선택, 연결 문자열과 풀링, WAL 모드의 원리, SQLITE_BUSY를 대하는 방법, 타입 매핑의 함정, 손상 대책과 백업, 그리고 EF Core와의 구분까지, 설계 리뷰에서 매번 확인하는 항목들을 한 번에 정리합니다.

1. 먼저 결론

  • 신규 개발에서 쓸 라이브러리는 Microsoft.Data.Sqlite(또는 그 위에 올라가는 EF Core의 SQLite 공급자)가 기본입니다. System.Data.SQLite와는 연결 문자열도 세부 동작도 호환되지 않는 별개의 라이브러리이므로, 웹에서 본 예제가 어느 쪽을 전제로 하는지 항상 의식해야 합니다.1
  • WAL 모드를 첫 릴리스부터 활성화합니다. 읽기와 쓰기의 동시성이 올라가고, database is locked의 대부분이 사라집니다. 설정은 DB 파일 자체에 영구적으로 저장되지만, 네트워크 공유 폴더 위에서는 쓸 수 없습니다.2
  • database is locked(SQLITE_BUSY)는 「에러 처리를 추가할」 대상이 아니라 설계를 다시 봐야 한다는 신호입니다. Microsoft.Data.Sqlite는 타임아웃(기본 30초)까지 자동으로 재시도해 주지만3, 근본적인 대책은 쓰기 경로를 하나로 모으는 것입니다.
  • 잦은 INSERT를 대량으로 실행할 때는, 명시적인 트랜잭션으로 묶기만 해도 자릿수가 2~3개 빨라집니다. 「SQLite가 느리다」고 느껴진다면, 먼저 커밋 단위를 의심해 보세요.
  • SQLite의 타입은 실질적으로 INTEGER / REAL / TEXT / BLOB의 4가지뿐이며, DateTime・Guid・decimal은 TEXT로 저장됩니다. 특히 decimal은 비교・정렬이 문자열 규칙을 따르게 되므로, 금액은 최소 통화 단위의 정수로 보관하는 것이 안전합니다.4
  • 백업은 실행 중 상태에서의 단순 파일 복사가 금지입니다. VACUUM INTO나 Backup API(SqliteConnection.BackupDatabase)를 씁니다. -wal / -shm 파일을 수동으로 지우는 것도 엄금입니다.5
  • 순수 SQLite는 암호화를 지원하지 않습니다. 연결 문자열의 Password가 효과를 내는 것은 SQLCipher 계열의 네이티브 라이브러리를 넣었을 때뿐이며6, 소량의 민감 정보라면 DB 암호화보다 DPAPI를 이용한 보호를 먼저 검토하세요.

2. 라이브러리 선택 ── 이름은 비슷한데 내용은 다르다

.NET에서 SQLite를 쓰는 패키지는 여러 개 있고, 이름이 헷갈리는 것이 첫 번째 걸림돌입니다. 정리하면 다음과 같습니다.

패키지 위치 신규 채택 기준
Microsoft.Data.Sqlite Microsoft가 유지 관리하는 ADO.NET 공급자. 가볍고, 네이티브 SQLite 본체도 NuGet에 함께 포함 ◎ 1순위 후보
Microsoft.EntityFrameworkCore.Sqlite EF Core의 SQLite 공급자. 내부적으로 Microsoft.Data.Sqlite를 사용 ◎ 엔티티 중심 앱(8장)
System.Data.SQLite SQLite 개발팀 계열의 오래된 공급자. .NET Framework 시절의 실적 다수 △ 기존 자산 유지보수에만
Dapper ADO.NET 위에 올라가는 경량 매퍼. SQLite 전용은 아님 ○ 순수 ADO.NET의 반복 코드를 줄이고 싶을 때

Microsoft.Data.Sqlite는 EF Core 팀이 유지 관리하며, EF Core의 SQLite 공급자를 위한 기반이기도 합니다.1 NuGet 패키지에 네이티브 바이너리(SQLite 본체)가 함께 포함되어 있어서 클라이언트 PC에 별도로 배포할 필요가 없고, x86/x64/ARM64 차이도 NuGet 쪽에서 흡수해 줍니다.

주의할 점은, System.Data.SQLite를 위한 정보를 그대로 쓸 수 없다는 것입니다. 오래 쓰여 온 만큼 웹에는 System.Data.SQLite를 전제로 한 예제가 많이 남아 있고, 다음과 같은 비호환 문제를 겪게 됩니다.

  • 연결 문자열이 호환되지 않습니다. Version=3이나 UseUTF16Encoding, 날짜 형식을 바꾸는 DateTimeFormat 같은 키워드는 Microsoft.Data.Sqlite에는 존재하지 않으며, 지정하면 예외가 발생합니다. 키워드는 Data Source / Mode / Cache / Password / Foreign Keys / Default Timeout / Pooling 등 극히 적습니다.6
  • 타입 처리 방식이 다릅니다. 예를 들어 Guid는 System.Data.SQLite의 기본값에서는 BLOB, Microsoft.Data.Sqlite에서는 TEXT로 저장됩니다. 두 라이브러리로 같은 DB 파일을 읽고 쓰는 마이그레이션 기간에는, 이 차이가 데이터 불일치로 나타납니다.

Microsoft.Data.Sqlite는 의도적으로 얇게 만들어져 있어서, 독자적인 타입 변환이나 편의 기능이 없는 만큼 SQLite 공식 문서의 설명이 그대로 통합니다. 기존 앱이 System.Data.SQLite로 안정적으로 돌아가고 있다면 무리하게 옮길 필요는 없지만, 새로 작성하는 코드는 Microsoft.Data.Sqlite 쪽으로 맞추고, 마이그레이션할 때는 위의 비호환 항목들을 먼저 마이그레이션 항목으로 뽑아 두는 것이 원칙입니다.

3. 연결과 연결 문자열 ── 풀링이 파일을 붙잡고 있다

연결 문자열의 기본형은 파일 경로뿐입니다. 실무에서 신경 써야 할 것은 ModePooling입니다.6

using Microsoft.Data.Sqlite;

var builder = new SqliteConnectionStringBuilder
{
    DataSource = dbPath,
    Mode = SqliteOpenMode.ReadWriteCreate  // 기본값. 없으면 새로 만든다
};
using var conn = new SqliteConnection(builder.ConnectionString);
conn.Open();
  • Mode: 기본값은 ReadWriteCreate(없으면 생성). 참조 전용 마스터 DB를 배포하는 경우라면 ReadOnly를 지정해 두면 버그나 잘못된 조작으로 인한 쓰기를 막을 수 있습니다.
  • Cache: 보통은 기본값 그대로 둡니다. Cache=Shared는 WAL 모드와 함께 쓰는 것이 권장되지 않는다고 문서에 명시되어 있어서, WAL을 쓰는 이 글의 방침에서는 쓸 일이 없습니다.6
  • Password: 지정하면 연결 직후 PRAGMA key가 전송되지만, 표준 네이티브 라이브러리는 암호화를 지원하지 않으므로 아무 일도 일어나지 않습니다.6 DB 전체 암호화가 요구 사항이라면 SQLCipher 계열 번들(SQLitePCLRaw.bundle_e_sqlcipher 등)로 교체해야 하며, 그 암호화 키를 보관하는 데는 결국 DPAPI(1장)가 필요합니다.
  • Default Timeout: 명령 타임아웃(기본 30초). 5장에서 다루는 재시도 시간의 상한이 됩니다.

한 가지 더, 비동기 API에 대한 이야기입니다. SQLite 자체가 비동기 I/O를 지원하지 않기 때문에, ExecuteNonQueryAsync 같은 async 메서드도 내부적으로는 동기 실행입니다.7 「async로 만들었으니 UI가 멈추지 않는다」는 성립하지 않으므로, 무거운 쿼리는 Task.Run 등으로 명시적으로 작업자 스레드로 넘겨야 합니다. async/await 판단 기준은 「C# async/await 실무 판단표」에서 정리한 대로입니다.

3.1 풀링의 함정 ── Close해도 파일은 열려 있다

Microsoft.Data.Sqlite는 버전 6.0부터 연결 풀링이 기본으로 활성화되어 있습니다.6 Open할 때마다 파일을 다시 열지 않아도 된다는 장점이 있는 반면, Close / Dispose를 호출해도 네이티브 연결은 풀에 남아서 DB 파일의 핸들을 계속 붙잡고 있습니다. 그 결과 다음과 같은 작업이 「파일이 사용 중입니다」로 실패합니다.

  • 「데이터 초기화」 기능에서 DB 파일을 삭제하고 다시 만드는 경우
  • 백업에서 복원할 때 DB 파일을 교체하는 경우
  • 제거(언인스톨)나 대피 처리에서 DB 파일을 이동하는 경우

대처법은 파일 작업 직전에 풀을 비우는 것입니다.

// 이 연결 문자열에 대응하는 풀을 폐기하고 파일 핸들을 해제한다
SqliteConnection.ClearPool(new SqliteConnection(connectionString));
File.Delete(dbPath);

WAL 모드(다음 장)라면 -wal / -shm 파일이 남아 있을 수 있으므로 함께 정리합니다. 앱 종료 시 전부 해제하고 싶다면 SqliteConnection.ClearAllPools(), 애초에 풀이 필요 없는 단발성 도구라면 연결 문자열에서 Pooling=False로 설정하는 방법도 있습니다. 「Close했는데 지울 수 없다」는 SQLite로 옮긴 뒤에 자주 들어오는 문의이므로, DB 파일을 다루는 유틸리티 처리에는 처음부터 ClearPool을 넣어 두세요.

4. WAL 모드의 원리 ── 무슨 일이 일어나는지 알고 쓴다

지난 글에서는 「WAL 모드를 활성화한다」고만 썼으므로, 이번에는 원리까지 들어가 봅니다. 기본값인 롤백 저널 방식에서는 쓰기 중에 읽기가 차단되며, 이것이 database is locked의 주된 원인입니다. WAL(Write-Ahead Logging) 방식에서는 변경 내용을 DB 본체가 아니라 추가 전용 로그 파일에 쓰기 때문에, 읽기가 쓰기를 차단하지 않고, 쓰기도 읽기를 차단하지 않습니다.2 UI 스레드가 이력을 표시하는 동시에 백그라운드에서 측정값을 기록하는, 업무 앱의 전형적인 구성이 그대로 성립합니다.

WAL 모드로 바꾸면, DB 본체(체크포인트로 확정된 내용) 옆에 파일 2개가 나타납니다.

파일 역할
app.db-wal 추가되는 변경 로그. 커밋은 됐지만 본체에는 아직 반영되지 않은 변경을 포함
app.db-shm wal-index라 불리는 공유 메모리. 프로세스 간에 WAL의 읽기 위치를 조정한다

알아둬야 할 성질은 4가지입니다.

  • 체크포인트: -wal의 내용을 본체로 옮겨 쓰는 처리이며, 기본적으로 WAL이 1,000페이지(약 4MB)에 도달하면 자동으로 실행됩니다.2 오래 지속되는 읽기 트랜잭션이 계속 눌러앉아 있으면 체크포인트가 진행되지 못해 -wal이 커지므로, 「읽기용 연결을 계속 열어 놓고 돌려쓰는」 설계는 피하고, 쓸 때 열고 바로 닫아야 합니다(풀링 덕분에 다시 여는 비용은 낮습니다).
  • 설정은 DB에 영구 저장된다: PRAGMA journal_mode=WAL은 한 번 실행하면 DB 파일 자체에 기록되며, 이후 어떤 연결로 열어도 WAL 상태를 유지합니다.2 연결할 때마다 다시 실행할 필요는 없습니다.
  • 쓰기는 여전히 한 번에 하나뿐: WAL이 올려 주는 것은 읽기와 쓰기의 동시성이며, 쓰기끼리는 여전히 배타적입니다. 이 부분을 오해해서 「WAL로 바꿨으니 여러 스레드에서 마음껏 써도 된다」고 생각하면, 5장의 SQLITE_BUSY를 만나게 됩니다.
  • 네트워크 공유에서는 쓸 수 없다: wal-index가 공유 메모리를 전제로 하기 때문에, 서로 다른 머신의 프로세스 사이에서는 동작하지 않습니다.2 애초에 SQLite를 네트워크 공유 폴더에 두지 말아야 한다는 것은 지난 글에서 다룬 대로입니다.

운영상 주의할 점으로, -wal 파일에는 커밋은 됐지만 아직 본체에 반영되지 않은 트랜잭션이 들어 있습니다. 「본체 .db만 복사하면 된다」「-wal은 임시 파일이니 지워도 된다」는 둘 다 틀린 생각으로, 최근 커밋이 사라지거나 최악의 경우 DB가 손상됩니다.5 이 내용이 7장의 「백업은 파일 복사로 하면 안 된다」는 이야기와 바로 연결됩니다.

5. 배타 제어와 SQLITE_BUSY ── 쓰기를 하나로 모은다

SQLITE_BUSY(예외 메시지로는 database is locked)는 다른 연결이 쓰기 잠금을 가지고 있을 때 발생합니다. 먼저 알아 두어야 할 것은, Microsoft.Data.Sqlite가 busy/locked 오류에 대해 명령 타임아웃(기본 30초)까지 자동으로 재시도한다는 점입니다.3 즉 앱 쪽에서 catch해서 잠깐 자고 다시 실행하는 식의 재시도 로직은 보통 필요 없습니다. 그런데도 타임아웃에 도달해 예외가 날아온다면, 다음 중 하나입니다.

  • 다른 연결(또는 다른 프로세스)이 30초를 넘는 긴 트랜잭션을 쥐고 있는 경우
  • 읽기로 시작한 트랜잭션 안에서 쓰기로 승격하려다 다른 쓰기와 충돌한 경우 ── 기다려도 해결되지 않으므로 즉시 실패합니다. 「읽고 나서 쓰는」 처리는 처음부터 쓰기 트랜잭션으로 설계하는 것이 정석입니다
  • 여러 스레드에서 잦은 쓰기가 대량으로 몰려들어 잠금을 놓고 다투는 경우

어느 쪽이든 「재시도 횟수를 늘리는」 방법으로는 해결되지 않습니다. 대책은 트랜잭션을 짧게 만드는 것쓰기 경로를 하나로 모으는 것입니다.

5.1 System.Threading.Channels로 쓰기 큐 만들기

여러 스레드가 발생원이 되는 데이터(측정값, 조작 로그 등)는 각 스레드가 DB에 직접 쓰지 않고, 큐로 던진 뒤 전담 쓰기 루프가 처리하는 형태로 만듭니다. .NET이라면 System.Threading.Channels를 그대로 쓸 수 있습니다.

using System.Globalization;
using System.Threading.Channels;
using Microsoft.Data.Sqlite;

public sealed record Measurement(string DeviceId, double Value, DateTime CreatedAtUtc);

public sealed class MeasurementWriter : IAsyncDisposable
{
    private readonly Channel<Measurement> _channel =
        Channel.CreateBounded<Measurement>(new BoundedChannelOptions(10_000)
        {
            FullMode = BoundedChannelFullMode.Wait  // 넘치면 생산 쪽을 기다리게 한다
        });
    private readonly string _connectionString;
    private readonly Task _loop;

    public MeasurementWriter(string connectionString)
    {
        _connectionString = connectionString;
        _loop = Task.Run(WriteLoop);
    }

    // 어떤 스레드에서 호출해도 된다. DB는 만지지 않는다. 쓰기 루프가
    // 죽어 있으면 ChannelClosedException으로 투입 쪽도 바로 알 수 있다
    public ValueTask EnqueueAsync(Measurement m) => _channel.Writer.WriteAsync(m);

    private async Task WriteLoop()
    {
        try
        {
            await WriteLoopCore();
        }
        catch (Exception ex)
        {
            // 디스크가 가득 찬 경우 등으로 쓰기 담당이 죽었다는 사실을 채널을 통해
            // 투입 쪽에 전달한다. 이걸 게을리하면 큐가 가득 찼을 때 EnqueueAsync가
            // 영원히 대기하게 되어 아무도 장애를 알아채지 못한다
            _channel.Writer.TryComplete(ex);
            throw;
        }
    }

    private async Task WriteLoopCore()
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        var buffer = new List<Measurement>(500);
        while (await _channel.Reader.WaitToReadAsync())
        {
            // 쌓여 있는 만큼 최대 500건까지 모아서 1개의 트랜잭션으로 쓴다
            buffer.Clear();
            while (buffer.Count < 500 && _channel.Reader.TryRead(out var m))
                buffer.Add(m);

            using var tx = conn.BeginTransaction();
            using var cmd = conn.CreateCommand();
            cmd.Transaction = tx;
            cmd.CommandText =
                "INSERT INTO measurement (device_id, value, created_at) " +
                "VALUES ($device, $value, $at)";
            var pDevice = cmd.Parameters.Add("$device", SqliteType.Text);
            var pValue  = cmd.Parameters.Add("$value",  SqliteType.Real);
            var pAt     = cmd.Parameters.Add("$at",     SqliteType.Text);
            foreach (var m in buffer)
            {
                pDevice.Value = m.DeviceId;
                pValue.Value  = m.Value;
                // 문화권 기본 달력・숫자 표기에 영향받지 않도록 InvariantCulture를 명시
                pAt.Value     = m.CreatedAtUtc.ToString(
                    "yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture);
                cmd.ExecuteNonQuery();
            }
            tx.Commit();
        }
    }

    public async ValueTask DisposeAsync()
    {
        // 쓰기 루프가 실패로 먼저 채널을 닫았어도 예외로 만들지 않는다
        // (Complete()라면 이중 클로즈로 예외를 던져 원래 예외를 숨기게 된다)
        _channel.Writer.TryComplete();
        await _loop;  // 남은 것을 다 쓴다. 루프가 죽어 있었다면 원래 예외가 여기서 나온다
    }
}

이런 형태로 만들면 쓰기 충돌이 구조적으로 일어나지 않게 되고, 큐에 쌓인 만큼 자연스럽게 배치로 처리할 수 있어 다음 절에서 다룰 고속화도 동시에 얻게 됩니다. 종료 시 DisposeAsync로 큐를 다 쓴 다음에 닫는 부분, FullMode로 가득 찼을 때의 동작을 명시적으로 정한 부분, 그리고 쓰기 루프의 실패를 TryComplete(ex)로 투입 쪽에 전파하는 부분(하나로 모은 쓰기 담당이 조용히 죽으면, 장애가 큐가 가득 찼을 때의 영원한 대기로 나타납니다)도 업무 앱에서는 효과가 있습니다. 같은 「경로를 하나로 모은다」는 발상은, 파일로 연계하는 경우의 배타 설계(「파일 연계와 잠금의 모범 사례」)와도 공통됩니다.

5.2 트랜잭션으로 묶으면 자릿수가 달라진다

SQLite는 커밋할 때마다 스토리지에 동기 쓰기(fsync)를 하기 때문에, 한 건씩 암묵적 커밋으로 INSERT하면 SSD에서도 초당 수백~수천 건, HDD라면 초당 수십 건에서 한계에 부딪힙니다. 같은 INSERT를 1,000건 단위의 명시적 트랜잭션(BeginTransaction으로 감싸고 마지막에 Commit)으로 묶기만 해도, 초당 수만~수십만 건 수준에 도달합니다. 튜닝이라고 부르기도 거창한 3줄짜리 수정으로 2~3자릿수가 달라집니다.

「CSV 가져오기에 20분이나 걸린다」「시작할 때의 데이터 이전이 끝나지 않는다」 같은 상담의 대부분은 이것이 원인이며, 트랜잭션화와 파라미터 재사용(앞 절 코드의 형태)만으로 해결됩니다. 반대로 트랜잭션을 너무 길게 만들면 이번엔 다른 쓰기를 기다리게 하므로, 「수백~수천 건, 또는 수백 밀리초 분량」을 1커밋으로 묶는 정도가 실무적으로 적당한 지점입니다.

6. 타입 매핑의 함정 ── 4가지뿐인 타입에 무엇을 배정할까

SQLite가 실제로 저장할 수 있는 타입은 INTEGER / REAL / TEXT / BLOB의 4가지뿐이며, .NET의 타입은 이 중 하나로 배정됩니다. Microsoft.Data.Sqlite의 주요 매핑은 다음과 같습니다.4

.NET 타입 SQLite 타입 저장 형식 실무상 주의점
bool / int / long INTEGER   bool은 0 / 1
double REAL   부동소수점 오차는 그대로
string TEXT UTF-8  
DateTime TEXT yyyy-MM-dd HH:mm:ss.FFFFFFF 서식과 시간대 통일이 필수
DateTimeOffset TEXT 오프셋 포함 오프셋이 섞이면 정렬이 불가능해짐
Guid TEXT 하이픈 구분 System.Data.SQLite의 기본값(BLOB)과 비호환
decimal TEXT 0.0###... 형식 비교・정렬이 문자열 규칙을 따름

TEXT로 떨어지는 3가지가 특히 주의해야 할 대상입니다.

  • DateTime: TEXT의 ISO 8601 계열 서식은 서식과 시간대만 통일되어 있으면 문자열 정렬 = 시각 정렬이 되므로 실용상 문제는 없습니다. 반대로 말하면, UTC와 로컬 시각이 섞이는 순간 무너집니다. 「저장은 UTC, 표시할 때 로컬로 변환」을 처음부터 정해서 모든 경로에서 지키는 것이 유일한 해법이며, datetime('now') 같은 SQLite 함수가 반환하는 값도 UTC입니다. 하나 더, 직접 문자열로 바꿀 때는 ToStringCultureInfo.InvariantCulture를 반드시 넘겨야 합니다. 문화권 기본값 그대로 두면, 그레고리력이 아닌 문화권에서 동작한 단말기에서만 연도 표기가 바뀌어 정렬도 읽기도 깨집니다(5.1의 코드 예시 참고).
  • Guid: 문자열로 저장되므로, 대조는 문자열 일치로 이루어집니다. 다른 도구나 라이브러리가 다른 표기(대소문자, BLOB 형식)로 쓰면 대조에 실패하므로, 여러 언어・도구가 함께 다루는 경우라면 표기 방식을 명세로 문서화해 두세요.
  • decimal: 가장 큰 함정입니다. REAL로 저장하면 정밀도가 깎이기 때문에 TEXT로 저장되지만4, TEXT 열에 대한 WHERE amount > 1000 같은 비교는 SQLite의 타입 순서에서 TEXT가 항상 숫자보다 크게 취급되기 때문에 의도한 대로 동작하지 않습니다. SUM 같은 집계도 내부적으로 REAL로 변환되어, decimal을 선택한 의미인 정밀도가 사라집니다.

decimal에 대한 실무적인 지침은 단순합니다. 금액은 최소 통화 단위의 정수(INTEGER)로 보관하는 것입니다. 원화라면 원 단위의 long으로 저장하고, 표시할 때 변환합니다. 정수라면 비교도 집계도 정확하고 빠르며, 타입 함정도 사라집니다. 기존 스키마 때문에 어쩔 수 없이 decimal 그대로 다뤄야 한다면, 비교・집계는 SQL 쪽에서 하지 말고 읽어 온 뒤 .NET 쪽에서 처리한다고 선을 그어 두세요.

덧붙여 CREATE TABLE에 적는 타입명은 「어피니티(affinity)」에 대한 힌트일 뿐이며, STRING처럼 독자적인 타입명을 쓰면 암묵적 변환으로 인한 사고의 원인이 됩니다. 열 타입명도 INTEGER / REAL / TEXT / BLOB의 4가지만 쓰는 것이 공식 권장 사항입니다.4

7. 운영 ── 망가뜨리지 않기・망가져도 되돌릴 수 있게

7.1 시작 시의 quick_check

SQLite는 트랜잭션으로 보호되고 있다고는 해도, 디스크 장애나 잘못된 파일 조작으로 인한 손상을 0으로 만들 수는 없습니다. 손상된 DB로 계속 동작해서 피해를 키우지 않도록, 시작할 때 일관성 검사를 넣어 둡니다. 완전한 integrity_check는 큰 DB에서는 시간이 걸리므로, 일상적으로는 경량판인 quick_check로 충분합니다.

using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA quick_check";
var result = (string)cmd.ExecuteScalar()!;
if (result != "ok")
{
    // 손상된 DB에 더 쓰지 않는다. 읽기 전용으로 축소해서 복구를 유도한다
    logger.LogError("데이터베이스 손상을 감지: {Detail}", result);
    EnterReadOnlyMode(result);
}

손상을 감지했을 때 자동 복구나 자동 되돌리기를 하지 않는(사용자 조작을 거치는) 방침은, 지난 글의 6.2절에서 쓴 대로입니다.

7.2 백업 ── 왜 파일 복사로는 안 될까

실행 중인 DB 파일을 단순 복사하면 트랜잭션 도중의 상태를 뒤섞어 가져올 수 있으며, SQLite 공식 문서에서도 손상 원인으로 명시하고 있습니다.5 WAL 모드라면 여기에 더해, 본체만 복사하고 -wal(본체에 아직 반영되지 않은 커밋을 포함. 4장)을 두고 가면 최근 데이터가 빠지게 됩니다.

올바른 방법은 2가지이며, 둘 다 실행 중에도 일관성 있는 스냅샷을 뜰 수 있습니다.

  • VACUUM INTO: SQL 한 문장으로, 단편화를 해소한 최소 크기의 복사본을 만듭니다.8 코드 예시는 지난 글의 6.3절에 실었으니 그쪽을 참고하세요.
  • SqliteConnection.BackupDatabase: SQLite의 Backup API를 감싼 래퍼로, 연결 객체끼리 복사합니다.
using var source = new SqliteConnection($"Data Source={dbPath}");
using var target = new SqliteConnection($"Data Source={backupPath}");
source.Open();
target.Open();
source.BackupDatabase(target);  // 실행 중이어도 일관된 스냅샷이 만들어진다

다만 BackupDatabase에는 주의할 점이 있습니다. Microsoft.Data.Sqlite의 현재 구현은 가능한 한 빠르게 복사하며, 완료될 때까지 다른 연결의 쓰기를 차단합니다.9 큰 DB를 측정이나 사용자 조작이 계속되는 도중에 백업하면, 그 사이의 쓰기가 SQLITE_BUSY나 화면 멈춤으로 나타납니다. 일상적인 세대별 백업은 VACUUM INTO, BackupDatabase는 인메모리 DB와의 상호 복사나, 쓰기가 멈춰 있는 시간대・유지보수 처리에서의 복제에 쓰는 식으로 구분하는 것이 안전합니다. 정기 실행에 작업 스케줄러를 쓴다면, 외부에서의 파일 복사가 아니라 앱 자신(또는 SQLite를 제대로 여는 작은 도구)이 위의 백업을 실행하게 하세요. 정기 실행 자체의 설계는 「작업 스케줄러로 정기 실행을 안전하게 운영하기」에서 쓴 대로입니다.

7.3 저장 위치와 마이그레이션

DB 파일의 저장 위치는 %LOCALAPPDATA%\회사명\앱이름이 기본이고, 스키마 버전 관리는 PRAGMA user_version을 이용한 시작 시 마이그레이션이 최소 구성입니다. 둘 다 지난 글(3장과 6.1절)에서 코드와 함께 썼으므로 여기서는 반복하지 않습니다. 한 가지만 덧붙이면, 마이그레이션을 실행하기 전에 7.2의 백업을 한 세대 떠 두면 「마이그레이션에 실패해서 시작 불가」라는 최악의 경우에서 복구가 단순히 파일을 교체하는 것으로 끝납니다.

8. EF Core를 써야 할 때 ── ORM의 손익분기점

여기까지는 순수한 Microsoft.Data.Sqlite로 이야기했지만, EF Core의 SQLite 공급자를 써야 하는 상황도 분명히 있습니다. 판단의 기준은 앱의 성격입니다.

앱의 성격 권장 이유
화면 수가 많고 엔티티 중심의 CRUD가 주된 업무(수발주, 마스터 관리 등) EF Core + 마이그레이션 반복 코드와 손으로 쓴 SQL의 총량이 줄고, 스키마 변경을 dotnet ef migrations로 추적할 수 있다
쓰기 위주이고 스키마가 작음(측정 로그, 감사 로그, 캐시) 순수 Microsoft.Data.Sqlite(+ 필요하면 Dapper) 변경 추적의 오버헤드가 낭비. 5장의 쓰기 큐+배치를 그대로 구성할 수 있다
두 성격이 섞여 있음 병용 같은 DB 파일에 대해, CRUD 화면은 EF Core, 로그 쓰기는 순수 ADO.NET으로 처리해도 문제없다

EF Core를 선택하는 경우에는, SQLite 공급자 특유의 제약을 알아 두어야 합니다.10

  • ALTER TABLE 제약으로 인한 테이블 재구성: SQLite는 열의 타입 변경이나 삭제를 직접 지원하지 않기 때문에, AlterColumn이나 DropColumn을 포함한 마이그레이션은 「새 테이블 생성 → 데이터 복사 → 기존 테이블 삭제 → 이름 변경」이라는 재구성 방식으로 실행됩니다. 데이터량이 많은 환경에서는 적용 시간과 디스크 사용량에 영향을 주므로, 큰 테이블의 스키마 변경은 계획적으로 진행해야 합니다.
  • 멱등 스크립트를 만들 수 없다: SQL Server처럼 if-then이 붙은 마이그레이션 스크립트는 생성할 수 없습니다. 적용은 앱 시작 시 dbContext.Database.Migrate()를 호출하는 방식이 현실적입니다.
  • decimal / DateTimeOffset의 연산은 클라이언트 평가: 6장에서 다룬 타입 문제는 EF Core에서도 사라지지 않습니다. 동등 비교가 아닌 비교나 정렬은 클라이언트 평가가 되므로, 금액을 정수 최소 단위로 보관하는 지침은 EF Core에서도 마찬가지입니다(값 변환기로 long으로 변환해서 저장할 수 있습니다).
  • WAL은 기본으로 활성화됨: EF Core가 만든 DB는 처음부터 WAL 모드이므로7, 4장의 설정은 필요 없습니다. 다만 동작 방식에 대한 이해는 여전히 필요합니다.

덧붙여, EF Core를 채택한 경우에도 저장소 계층의 단위 테스트에 SQLite 인메모리 DB를 쓰는 방법이 효과적입니다. 실제 운영과 같은 공급자로 동작하므로 「목(mock)에서는 통과하지만 실제 DB에서는 실패한다」는 틈이 줄어듭니다. 테스트를 어느 계층에서 작성할지에 대한 사고방식은 「단위 테스트와 통합 테스트의 경계」를 참고하세요.

9. 정리

SQLite는 「그냥 넣기만 한다면 30분, 제대로 운영하려면 설계가 필요한」 라이브러리입니다. 그렇다고 해도 필요한 설계는 정해져 있고, 이 글의 내용을 체크리스트로 만들면 다음 6가지가 됩니다.

  • 라이브러리는 Microsoft.Data.Sqlite(또는 그 위의 EF Core). System.Data.SQLite를 전제로 한 정보와 섞지 않기
  • WAL 모드를 첫 릴리스부터 활성화하고, -wal / -shm의 역할을 이해해 두기
  • 쓰기는 하나로 모으고(Channels 쓰기 큐), 잦은 INSERT는 트랜잭션으로 묶기
  • DateTime은 UTC로 통일하고, 금액은 최소 통화 단위의 정수로. decimal을 TEXT 상태로 비교・집계하지 않기
  • 시작할 때 quick_check, 백업은 VACUUM INTOBackupDatabase로. 실행 중 파일 복사는 하지 않기
  • DB 파일을 지우거나 교체하는 처리에는 SqliteConnection.ClearPool을 잊지 않기

database is locked가 나올 때마다 재시도를 거듭해서 버티고 있다거나, 백업을 파일 복사로 뜨고 있다거나 하는 구성에 짐작이 가신다면, 망가지기 전에 한 번 이 글의 항목들로 점검해 보세요. 어느 것이든 고치는 작업 자체는 작은 수정으로 끝납니다.

관련 글

관련 상담 분야

합동회사 코무라소프트에서는 SQLite를 통합한 업무 앱의 설계 리뷰(배타 제어・백업・마이그레이션 설계)와, database is locked・데이터 손상・성능 저하 같은 운영 중인 앱의 문제 조사, Access 등 기존 데이터 저장소로부터의 이전 지원을 다루고 있습니다.

참고 자료

  1. Microsoft Learn, Microsoft.Data.Sqlite overview. Microsoft가 유지 관리하는 경량 ADO.NET 공급자이며, EF Core SQLite 공급자의 기반이라는 점에 대해.  2

  2. SQLite, Write-Ahead Logging. -wal / -shm 파일의 역할, 체크포인트(기본 1,000페이지), 읽기・쓰기의 동시성, 모드의 영구 저장, 네트워크 파일 시스템에서는 동작하지 않는다는 점에 대해.  2 3 4 5

  3. Microsoft Learn, Database errors (Microsoft.Data.Sqlite). busy / locked 오류에 대해 명령 타임아웃(기본 30초)까지 자동으로 재시도한다는 점, 연결・명령 등의 객체가 스레드 안전하지 않다는 점에 대해.  2

  4. Microsoft Learn, Data types (Microsoft.Data.Sqlite). SQLite의 4가지 기본 타입, DateTime / Guid / decimal이 TEXT로 매핑된다는 점, 열 타입명도 이 4가지 기본 타입명으로 한정해야 한다는 점에 대해.  2 3 4

  5. SQLite, How To Corrupt An SQLite Database File. 실행 중(트랜잭션 중)인 DB 파일 복사나, 핫 저널・WAL 파일의 삭제・분리가 손상 원인이 된다는 점에 대해.  2 3

  6. Microsoft Learn, Connection strings (Microsoft.Data.Sqlite). 연결 문자열 키워드 목록, Pooling의 기본값이 활성화되어 있다는 점, Password는 네이티브 라이브러리가 암호화를 지원하지 않으면 효과가 없다는 점, Cache=Shared와 WAL을 함께 쓰는 것이 권장되지 않는다는 점에 대해.  2 3 4 5 6

  7. Microsoft Learn, Async limitations (Microsoft.Data.Sqlite). SQLite가 비동기 I/O를 지원하지 않아 async 메서드가 동기적으로 실행된다는 점, EF Core가 만든 DB에서는 WAL이 기본으로 활성화되어 있다는 점에 대해.  2

  8. SQLite, VACUUM. VACUUM INTO 절을 이용해 원본 파일을 바꾸지 않고 일관성 있는 최소 크기의 복사본을 다른 파일로 만들 수 있다는 점에 대해. 

  9. Microsoft Learn, Backup (Microsoft.Data.Sqlite). BackupDatabase가 가능한 한 빠르게 백업하며, 완료될 때까지 다른 연결의 쓰기를 차단하는 현재 구현에 대해. 

  10. Microsoft Learn, SQLite EF Core Database Provider Limitations. 마이그레이션의 많은 작업이 테이블 재구성으로 실행된다는 점, 멱등 스크립트를 생성할 수 없다는 점, decimal / DateTimeOffset의 연산이 클라이언트 평가가 된다는 점에 대해. 

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

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

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

자주 묻는 질문

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

SQLite에서 「database is locked」가 나오는 이유는 무엇인가요?
SQLITE_BUSY는 다른 연결이 쓰기 잠금을 가지고 있을 때 발생합니다. Microsoft.Data.Sqlite는 명령 타임아웃(기본 30초)까지 자동으로 재시도하므로, 직접 재시도 로직을 넣을 필요는 보통 없습니다. 그래도 예외가 발생한다면 30초를 넘는 긴 트랜잭션, 읽기에서 쓰기로 승격할 때의 충돌, 여러 스레드에서 잦은 쓰기가 경합하는 것이 원인이며, 재시도 횟수를 늘려도 해결되지 않습니다. 근본적인 대책은 트랜잭션을 짧게 만드는 것과, System.Threading.Channels 같은 큐를 써서 쓰기 경로를 하나로 모으는 것입니다. WAL 모드를 활성화하면 읽기와 쓰기 사이의 차단도 대부분 사라집니다.
C#에서 SQLite를 쓸 때 어떤 라이브러리를 골라야 하나요?
신규 개발에서는 Microsoft가 유지 관리하는 ADO.NET 공급자인 Microsoft.Data.Sqlite(또는 그 위에 올라가는 EF Core의 SQLite 공급자)가 기본입니다. NuGet 패키지에 네이티브 SQLite 본체가 함께 포함되어 있어 별도의 배포 작업이 필요 없습니다. 오래된 System.Data.SQLite와는 연결 문자열과 타입 처리 방식 모두 호환되지 않는 별개의 라이브러리로, 예를 들어 Guid는 System.Data.SQLite의 기본값에서는 BLOB, Microsoft.Data.Sqlite에서는 TEXT로 저장됩니다. 웹에서 찾은 예제가 어느 쪽을 전제로 하는지 항상 의식해야 합니다.
SQLite 백업은 파일 복사로 해도 되나요?
실행 중인 상태에서의 단순 파일 복사는 금지입니다. 트랜잭션 도중의 상태를 뒤섞어 가져올 수 있으며, SQLite 공식 문서에서도 손상 원인으로 명시하고 있습니다. WAL 모드에서는 -wal 파일에 본체에 아직 반영되지 않은 커밋이 들어 있기 때문에, 본체만 복사하면 최근 데이터가 빠지게 됩니다. 올바른 방법은 VACUUM INTO(SQL 한 문장으로 일관성 있는 최소 크기의 복사본을 생성)이거나 SqliteConnection.BackupDatabase입니다. 다만 BackupDatabase는 완료될 때까지 다른 연결의 쓰기를 차단하므로, 일상적인 세대별 백업에는 VACUUM INTO가 더 안전합니다.
SQLite에서 대량 INSERT가 느린 이유는 무엇인가요?
대부분 커밋 단위가 원인입니다. SQLite는 커밋할 때마다 스토리지에 동기 쓰기(fsync)를 수행하므로, 한 건씩 암묵적 커밋으로 INSERT하면 SSD에서도 초당 수백~수천 건에서 한계에 부딪힙니다. 같은 INSERT를 1,000건 단위의 명시적 트랜잭션(BeginTransaction으로 감싸고 마지막에 Commit)으로 묶기만 해도 초당 수만~수십만 건 수준에 도달해 2~3자릿수 빨라집니다. 반대로 트랜잭션을 너무 길게 만들면 다른 쓰기를 기다리게 하므로, 수백~수천 건 또는 수백 밀리초 분량을 1커밋으로 묶는 것이 실무적으로 적당한 지점입니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기