Windows 앱 데이터 저장 위치 고르기 ── SQLite / JSON / 레지스트리 / Access 판단표

· 업데이트: · · SQLite, Windows, .NET, C#, 데이터 저장, 레지스트리, Access, 설계, 판단표, 기술 상담

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

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

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
관련 기사 링크를 한국어 permalink에 맞추는 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 모은 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하세요.
WAL을 「선행 기록 로그」로 풀어 쓰고, `-wal`과 `-shm`이 따라붙는다는 점과 네트워크 파일 시스템에서는 동작하지 않는다는 점을 보탰습니다. VirtualStore로 바뀌는 구조를 권한·비트 수·매니페스트 분기의 그림으로 보이고, 쓸 수 있는지를 실제 기기에서 확인하는 절(Procmon 확인과 `icacls`)을 새로 만들었습니다. 분류와 형식 한눈에 보기 표도 다시 구성했습니다.
본문의 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 부분을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.21635340)

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

Go Komura (2026). 「Windows 앱 데이터 저장 위치 고르기 ── SQLite / JSON / 레지스트리 / Access 판단표」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-app-local-data-storage-decision-table/

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

「설정은 INI 파일이면 되나」「이력 데이터가 늘어서 Access에 넣고 싶다」「레지스트리와 설정 파일은 어떻게 나누나」. Windows 업무 앱을 만들다 보면 데이터 저장 위치 선택은 반드시 거치게 됩니다. 그런데 이 선택은 처음에 대충 정해진 채 다시 보지 않는 경우가 많고, 몇 년 뒤에 「JSON 파일이 수십 MB로 커져서 시작이 느리다」「공유 폴더의 Access 파일이 일주일에 한 번 깨진다」「Program Files 바로 아래에 쓰고 있어서 Windows 11 환경에서 동작하지 않는다」는 식으로 문제가 드러납니다.

이 글에서는 업무 Windows 앱의 로컬 데이터 저장을 「어디에 둘지」(폴더 선택)와 「무엇으로 저장할지」(형식·엔진 선택)로 나눠 정리합니다. 이 블로그에서 여러 번 써 온 판단표 형식으로, SQLite / JSON / 레지스트리 / Access 각각의 강점과 함정을 정리합니다.

1. 먼저 결론

  • 저장 위치 선택은 「어디에 둘지」와 「무엇으로 저장할지」라는 서로 독립된 두 판단입니다. 전자를 잘못하면 권한·멀티 사용자 사고, 후자를 잘못하면 손상·성능·유지보수 사고가 됩니다.
  • 두는 위치의 기본은, 사용자별 설정·데이터라면 %LOCALAPPDATA%(Environment.SpecialFolder.LocalApplicationData), 모든 사용자 공유라면 %PROGRAMDATA%, 그리고 exe와 같은 폴더(Program Files 아래)에는 쓰지 않는다입니다.1
  • 형식의 1순위 후보는 단순하게 두 가지입니다. 구조화된 작은 설정은 JSON 파일, 늘어나는 업무 데이터·이력·검색할 데이터는 SQLite. 이 두 가지로 업무 앱 로컬 저장의 대부분은 커버됩니다.2
  • 레지스트리는 「작은 플래그나 Windows와의 연동 정보를 두는 곳」이지, 앱의 데이터 스토어가 아닙니다. 32bit/64bit 레지스트리 리디렉션(Wow6432Node)을 이해하지 않고 쓰면, 「썼다고 생각한 값이 보이지 않는」 문제에 빠집니다.3
  • Access(.accdb)를 신규 개발의 데이터 스토어로 고를 이유는 거의 없어졌습니다. 기존 자산과의 연동으로 쓰는 경우에도, ACE provider의 비트 수 일치라는 배포 제약이 따라붙습니다.4
  • 어떤 형식이든, 기밀 정보(비밀번호·API 키)만은 따로 다룹니다. 평문으로 JSON이나 레지스트리에 두지 않고, DPAPI로 보호합니다. DPAPI(Data Protection API)는 암호 키 관리를 Windows에 맡길 수 있는 OS 기능이라고 생각하면 됩니다. 앱은 ProtectedData.Protect에 평문을 넘겨 암호화된 bytes를 받고, 저장하는 것은 그 bytes뿐입니다. 키는 로그온한 사용자(또는 대상 머신)에 묶여 OS가 관리하므로, 앱 쪽에 키를 심을 필요가 없어집니다. 뒤집으면, 같은 사용자·같은 머신에서만 복호화할 수 있다는 것이 기본 성질이고, 단말 이전이나 백업 설계에 영향을 줍니다(6.3절).5 사용법의 자세한 내용은 별도 글 「Windows 앱의 기밀 정보 저장 - DPAPI로 평문 설정을 피하기」를 참고하세요.

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

2. 저장할 데이터를 4종류로 분류한다

무엇으로 저장할지를 정하기 전에, 저장하려는 데이터의 성질을 분류합니다. 업무 앱의 로컬 데이터는 대체로 다음 4종류로 나뉩니다.

분류 예 특징
설정 연결 대상, 화면 레이아웃, 지난번에 연 폴더 작다. 시작 때 전부 읽는다. 사용자가 직접 편집하고 싶은 경우도 있다
업무 데이터·이력 측정 결과, 처리 이력, 마스터의 로컬 복사 계속 늘어난다. 검색·집계하고 싶다. 깨지면 업무 영향이 크다
캐시 썸네일, 다운로드한 리소스 사라져도 다시 만들 수 있다. 용량 관리가 필요하다
기밀 정보 저장된 비밀번호, 토큰 소량. 평문으로 두면 안 된다

이 분류마다 적절한 두는 위치와 형식이 다르다는 것이 이 글의 뼈대입니다. 「설정도 이력도 전부 하나의 XML에 들어 있다」는 식의 앱은, 이 분류를 다시 하는 곳이 개선의 첫걸음이 됩니다.

3. 어디에 둘지 ── 폴더 선택의 기본

.NET이라면 Environment.GetFolderPath로 얻을 수 있는 장소를 기준으로 합니다.6

장소 얻는 방법 용도
%LOCALAPPDATA%\회사명\앱명 SpecialFolder.LocalApplicationData 사용자별 데이터의 기본. 우선 여기
%APPDATA%\회사명\앱명(Roaming) SpecialFolder.ApplicationData 이동 사용자 프로필 환경에서 사용자를 따라가게 할 설정만
%PROGRAMDATA%\회사명\앱명 SpecialFolder.CommonApplicationData 모든 사용자 공유 데이터. ACL 설계가 필요
문서 아래 SpecialFolder.MyDocuments 사용자가 자기 파일로 다루는 산출물(내보낸 장표 등)만

코드로 보면 이뿐이지만, 「회사명\앱명」 계층을 끼우는 것, 처음에 폴더를 만드는 것까지 포함해 공통 처리로 두면, 저장 위치가 그때그때 늘어나는 일을 막을 수 있습니다.

public static class AppPaths
{
    public static string DataDir { get; } = CreateDir(
        Environment.SpecialFolder.LocalApplicationData);

    private static string CreateDir(Environment.SpecialFolder root)
    {
        var dir = Path.Combine(
            Environment.GetFolderPath(root), "KomuraSoft", "MyApp");
        Directory.CreateDirectory(dir);  // 이미 있으면 아무것도 하지 않는다
        return dir;
    }
}

환경 변수 %LOCALAPPDATA%를 문자열 연결로 조립하지 않고 Environment.GetFolderPath를 쓰는 것은, 서비스 계정이나 다른 사용자로 실행할 때, 폴더 리디렉션이 구성된 환경에서도 올바른 장소를 돌려주기 때문입니다. 작업 스케줄러에서 다른 계정으로 돌리는 순간에 경로가 바뀌는 사고(작업 스케줄러 글 5장에서 쓴 「수동이면 된다」 문제의 일종입니다)도 이것으로 피할 수 있습니다.

함정을 3가지 듭니다.

  • exe가 있는 폴더에 쓰지 않는다. Program Files 아래는 표준 사용자가 쓸 수 없습니다. 오래된 32bit 앱에서는 UAC(User Account Control, 사용자 계정 컨트롤)의 호환 기능인 파일 가상화로 VirtualStore에 조용히 리디렉트되는 일이 있어, 「관리자로 실행했을 때와 표준 사용자로 실행했을 때 설정 파일 내용이 다르다」는 원인을 알기 어려운 증상의 원인이 됩니다.
  • ProgramData는 「쓸 수 있지만 안전하지는 않다」. 기본 ACL에서는, 어떤 사용자가 만든 파일을 다른 사용자가 변경하지 못하는 구성이 될 수 있습니다. 모든 사용자 공유로 읽고 쓸 거라면, 설치 프로그램에서 폴더를 만들고 ACL을 명시적으로 설정합니다.
  • Roaming을 기본으로 두지 않는다. 도메인의 이동 사용자 프로필 환경에서는 Roaming 아래가 로그온·로그오프 때 동기화됩니다. 크기가 큰 데이터나 머신 고유 데이터(캐시, 하드웨어 설정)를 Roaming에 두면, 동기화 지연이나 다른 머신으로의 「오염」 원인이 됩니다. 고민되면 Local입니다.

3.1 VirtualStore로 바뀌는 구조

첫 번째 함정만, 구조를 그림으로 남깁니다. UAC의 파일 가상화는, 매니페스트에 requestedExecutionLevel이 없는 32bit 앱이 Program Files처럼 보호된 장소에 쓰려 할 때, 실패시키는 대신 쓰기 대상을 사용자 프로필 안으로 바꿔 치는 호환 기능입니다. 읽기도 가상화된 장소가 우선되므로, 쓴 본인에게는 「제대로 쓰인 것처럼」 보입니다.7

관리자로 실행표준 사용자예아니오 = 권한 상승 없는 32bit 앱앱이 Program Files 아래의settings.ini에 쓴다쓰기 권한이 있는가원래 장소에 쓰인다64bit이거나requestedExecutionLevel 지정이 있는가액세스 거부로 그대로 실패한다UAC의 파일 가상화가 발동LOCALAPPDATA의 VirtualStore 아래에사용자별 복제가 만들어진다읽기는 가상화된 복제가 우선본인에게는 쓰인 것처럼 보인다관리자로 실행했을 때와 표준 사용자로 실행했을 때읽는 파일이 다르다

실제로 리디렉트되는 위치는 %LOCALAPPDATA%\VirtualStore\Program Files\...입니다. 가상화는 사용자마다 다른 복제를 만들므로, 「A 씨의 단말에서는 설정이 남지만, 공용 단말에서 다른 사람이 로그온하면 초기값으로 돌아간다」는 형태로도 드러납니다. 어디까지나 레거시 앱 구제의 잠정 기능이며, 64bit 프로세스나 권한 상승된 프로세스, 매니페스트를 가진 프로세스에서는 작동하지 않습니다.7 레지스트리 쪽의 같은 구조(HKLM\Software가 HKCU\Software\Classes\VirtualStore로 전송된다)는 「레지스트리의 32bit/64bit 리디렉션과 가상화의 함정」에서 자세히 정리하고 있습니다.

3.2 쓸 수 있는지를 실제 기기에서 확인한다

두는 위치 설계는, 개발 머신의 관리자 계정으로 돌리는 한 검증이 되지 않습니다. 릴리스 전에 다음 3가지는 통과해 두세요.

  1. 표준 사용자로 실행한다. 개발 머신에 표준 사용자 로컬 계정을 하나 만들고, 그 계정으로 로그온해 설치부터 시작·저장·재시작까지 한 바퀴 돌립니다. 관리자 계정에서 「관리자로 실행」만 끈 상태로는, UAC 권한 상승이 없다는 점은 재현할 수 있어도, 그 사용자가 관리자 그룹에 속한다는 사실은 바뀌지 않으므로, ACL 검증으로는 충분하지 않습니다.
  2. 어디에 쓰는지를 Process Monitor로 확인한다. 앱 프로세스로 좁히고, 조작을 CreateFile / WriteFile로 좁혀 캡처합니다. 목적 장소가 ACCESS DENIED가 아닌지, Path 열에 VirtualStore를 포함한 경로가 나오는지 봅니다. Procmon은 리디렉트 해결 후의 실제 경로를 표시하므로, 「썼다고 생각한 장소」와 「실제로 쓰인 장소」의 어긋남이 그대로 보입니다. 사용법은 「Process Monitor(ProcMon) 실전 가이드」를 참고하세요.
  3. ACL을 읽는다. icacls "C:\ProgramData\KomuraSoft\MyApp"으로, 상정한 사용자·그룹에 쓰기가 붙어 있는지 확인합니다. ProgramData 아래를 모든 사용자 공유 읽기 쓰기 영역으로 쓰는 경우는, 설치 프로그램에서 설정한 ACL이 여기에 나와 있는지도 봅니다.

이 3가지에 걸리지 않으면, 적어도 「배포한 순간에 저장하지 못한다」는 종류의 사고는 피할 수 있습니다.

4. 무엇으로 저장할지 ── 네 가지 선택지의 성격

4.1 JSON 파일 ── 설정의 1순위 후보

System.Text.Json으로 그대로 읽고 쓸 수 있고, 사람이 읽을 수 있고, Git 관리나 차이 비교가 쉽다는 점이 설정 용도에 맞춰져 있습니다. 주의점은 두 가지입니다.

손상 대책을 할 것. 쓰기 중 전원 차단으로 어중간한 파일이 남으면, 다음 시작 때 읽지 못합니다. 「임시 파일에 쓴 뒤 교체한다」가 정석이고, .NET이라면 File.Replace가 백업이 있는 교체를 제공합니다.

var json = JsonSerializer.Serialize(settings, options);
var tmp = path + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(path))
    File.Replace(tmp, path, path + ".bak");
else
    File.Move(tmp, path);

읽기 쪽도 「깨져 있으면 .bak을 시도하고, 그것도 안 되면 기본값으로 시작해 경고한다」는 대체 동작을 처음부터 넣어 두면, 설정 파일 손상이 지원 문의로 번지지 않습니다.

데이터 스토어로 쓰지 말 것. 「시작 때 전부 읽고, 종료 때 전부 쓴다」가 성립하는 크기(기준으로 수백 KB까지)가 JSON의 적용 범위입니다. 계속 추가되는 이력이나 레코드 검색이 필요한 데이터를 JSON에 넣기 시작했다면, 그것은 다음 SQLite의 신호입니다.

4.2 SQLite ── 늘어나는 데이터·검색하는 데이터의 1순위 후보

SQLite는 서버 불필요·단일 파일·퍼블릭 도메인의 임베디드 데이터베이스이고, .NET에서는 Microsoft가 유지 관리하는 ADO.NET provider Microsoft.Data.Sqlite, 또는 EF Core의 SQLite provider로 다룹니다.2 Microsoft 자신이 Windows 앱의 로컬 데이터 저장 수단으로 권장하며8, 「로컬에서 늘어나는 구조화 데이터」에는 우선 SQLite라고 생각해도 되는 상황입니다.

먼저, 어느 정도 가벼운지를 코드로 보입니다. NuGet으로 Microsoft.Data.Sqlite를 추가하면, 서버 준비도 연결 문자열 관리 화면도 없이, 파일 경로만 지정하면 쓰기 시작합니다.

using Microsoft.Data.Sqlite;

var dbPath = Path.Combine(AppPaths.DataDir, "app.db");
using var conn = new SqliteConnection($"Data Source={dbPath}");
conn.Open();

// 처음만: WAL 모드 활성화와 테이블 생성
using (var cmd = conn.CreateCommand())
{
    cmd.CommandText = """
        PRAGMA journal_mode=WAL;
        CREATE TABLE IF NOT EXISTS measurement (
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
            device_id  TEXT    NOT NULL,
            value      REAL    NOT NULL,
            created_at TEXT    NOT NULL DEFAULT (datetime('now'))
        );
        CREATE INDEX IF NOT EXISTS ix_measurement_device
            ON measurement(device_id, created_at);
        """;
    cmd.ExecuteNonQuery();
}

// 삽입은 매개변수 필수(문자열 연결로 SQL을 조립하지 않는다)
using (var cmd = conn.CreateCommand())
{
    cmd.CommandText =
        "INSERT INTO measurement (device_id, value) VALUES ($device, $value)";
    cmd.Parameters.AddWithValue("$device", "CAM-01");
    cmd.Parameters.AddWithValue("$value", 23.5);
    cmd.ExecuteNonQuery();
}

「JSON 파일에 이어 붙인다」와 큰 차이 없는 수고로, 인덱스가 있는 검색·집계·건수 제한 없는 이력을 얻는다는 점이 보일 것입니다. ORM을 끼우고 싶다면 EF Core의 SQLite provider가 이 라이브러리 위에 올라갑니다.

그 위에서, 실무 포인트를 골라 듭니다.

  • WAL 모드를 켠다. 위 코드의 PRAGMA journal_mode=WAL;입니다. WAL은 Write-Ahead Logging(선행 기록 로그)의 약자로, 변경을 데이터베이스 본체에 직접 쓰지 않고, 옆에 만들어지는 -wal 파일에 이어 붙인 뒤, 나중에 모아 본체에 반영하는 방식을 가리킵니다. 쓰는 쪽은 끝에 이어 붙이기만 하므로 읽는 쪽을 방해하지 않고, 읽기와 쓰기를 동시에 진행할 수 있습니다.9 UI 스레드와 백그라운드 처리가 같은 DB를 다루는 구성에서도 잘 막히지 않는 것은 이 때문입니다. WAL 설정은 데이터베이스 파일 자체에 유지되므로, 연결할 때마다 발행할 필요는 없습니다(한 번 설정하면, 닫고 다시 열어도 WAL입니다).9 부작용으로, DB가 한 파일로 끝나지 않고 app.db 옆에 app.db-wal과 app.db-shm이 생깁니다. 가동 중에 app.db만 복사한 백업이 위험한 것은 이 이유입니다(6.3절).
  • 쓰기는 프로세스당 하나로 모은다. SQLite의 쓰기는 데이터베이스 단위 배타입니다. 여러 스레드에서 쓸 거라면, 큐를 거쳐 쓰기 역할을 하나로 모으는 설계가 안전합니다. 또한 잘게 나뉜 INSERT를 대량으로 할 때는 명시적 트랜잭션으로 묶으면, 한 건씩 commit하는 것보다 자릿수가 다를 만큼 빨라집니다.
  • 네트워크 공유에 두지 않는다. SMB 너머의 파일 잠금은 환경에 따라 문제가 많고, SQLite 공식도 손상의 가장 큰 원인으로 네트워크 파일 시스템 위 공유를 듭니다.10 애초에 WAL 모드는 「같은 DB를 쓰는 프로세스가 같은 머신 위에 있을 것」을 전제로 하며, 네트워크 파일 시스템 위에서는 동작하지 않습니다(프로세스 간에 공유 메모리를 쓰기 때문입니다).9 여러 대·여러 사용자가 동시에 쓰고 싶어지면, 그것은 이미 클라이언트·서버형 DB(SQL Server Express 등)의 영역입니다.
  • 형이 4종류뿐이라는 것을 알아 둔다. SQLite의 실체는 INTEGER / REAL / TEXT / BLOB이고, 날짜나 GUID는 TEXT로 저장됩니다. Microsoft.Data.Sqlite의 형 매핑 규칙을 한 번 확인해 두면, 날짜 비교나 정렬에서 헤매지 않습니다.11 위 예에서 created_at을 datetime('now')(UTC)로 한 것도, 로컬 시각을 섞으면 정렬과 서머타임 경계에서 문제가 나기 때문입니다. 표시할 때 로컬 시각으로 바꾸는 방침이 안전합니다.
  • 백업은 「파일 복사」가 아니라 VACUUM INTO나 Backup API로. 가동 중인 DB 파일을 단순 복사하면 WAL과 본체의 불일치를 집어들 수 있습니다(자세한 내용은 6장).

4.3 레지스트리 ── 작은 플래그와 Windows 연동 정보만

레지스트리가 적절한 것은 「설치되었는지」「시작 프로그램 등록」「파일 연결」처럼 Windows 자체와의 연동 정보와, 아주 작은 사용자 설정까지입니다. 앱이 스스로 쓰는 설정은 HKCU 아래, 머신 공통 정보는 설치 프로그램이 HKLM에 쓰는 것이 원칙입니다(실행 중에 HKLM에 쓰는 설계는 관리자 권한을 요구하게 되므로 피합니다).

가장 큰 함정은 비트 수입니다. 64bit Windows에서는 32bit 프로세스에서 본 HKLM\Software가 HKLM\Software\Wow6432Node로 리디렉트됩니다.3 「레지스트리 편집기에서는 값이 있는데 앱에서는 읽히지 않는다」「32bit 앱에서 쓴 값이 64bit 유지보수 도구에서는 보이지 않는다」는 증상은 거의 이것입니다. AnyCPU로의 이전이나 64bit화 시점에 드러나므로, COM이나 ActiveX의 32bit/64bit 문제(「COM/OCX/ActiveX 개발에서 막히는 등록과 bitness의 함정」)와 같은 맥락으로 잡아 두세요.

.NET에서 굳이 다른 비트의 뷰를 읽어야 하는 경우(32bit 그대로 유지 중인 앱이 64bit 쪽에 등록된 값을 읽는 등)는, RegistryView로 뷰를 명시할 수 있습니다.

using Microsoft.Win32;

// 32bit 프로세스에서 64bit 뷰의 HKLM을 읽는다
using var hklm64 = RegistryKey.OpenBaseKey(
    RegistryHive.LocalMachine, RegistryView.Registry64);
using var key = hklm64.OpenSubKey(@"SOFTWARE\KomuraSoft\MyApp");
var installDir = key?.GetValue("InstallDir") as string;

거꾸로 말하면, 이 지정이 필요해진 시점에서 「32bit와 64bit 중 어디에 쓰는 것이 맞는가」라는 설계 판단을 미루고 있다는 신호이기도 합니다. 쓰기 쪽과 읽기 쪽의 비트 수를 맞추는 것이 본래 해법입니다.

수 KB를 넘는 데이터나 배열성 데이터를 레지스트리에 넣는 것은 백업·이전·진단 어느 면에서든 불리합니다. 그 용도는 파일(JSON / SQLite)에 맡깁니다.

4.4 Access (.accdb) ── 신규 채택은 거의 없음, 기존 연동은 선을 긋고

한때 업무 앱의 로컬 DB라면 Access(JET/ACE)였지만, 신규 개발에서 고를 이유는 지금은 거의 없습니다. 이유는 주로 배포입니다. .accdb에 코드에서 접근하려면 ACE(Access Database Engine) provider가 필요하고, 앱의 비트 수와 ACE의 비트 수가 일치하지 않으면 연결할 수 없습니다.4 Office 비트 수와의 공존 문제도 있어, 「개발 머신에서는 되지만 고객 환경에서 Microsoft.ACE.OLEDB.12.0 공급자가 로컬 컴퓨터에 등록되지 않았습니다가 된다」는 전형적인 지원 건입니다. 재배포 가능 패키지(Access Database Engine 2016 Redistributable) 도입이 필요해지는 점도 배포물을 늘립니다.12

그래도 Access가 얽히는 장면은 현실에 있습니다. 기존 Access 업무 시스템과의 데이터 연동, Access로 만든 마스터 읽기 등입니다. 그 경우는,

  • 읽고 쓰는 프로세스의 비트 수를 고정하고(x86 고정이 현실적인 경우가 많다), 설치 프로그램에서 대응하는 ACE가 있는지 확인한다
  • 공유 폴더에 둔 .accdb에 여러 명이 동시에 쓰는 것은 설계로서 피한다(깨졌을 때 복구 비용이 수지가 맞지 않습니다)
  • 장기적으로는 SQLite 또는 서버형 DB로의 이전 경로를 가진다

는 선을 긋는 쪽을 권합니다. Excel/VBA 자산을 포함한 기존 자산의 다루기는 「VBA란 무엇인가 - 제약, 장래성, 바꿔야 할 장면」에서도 정리하고 있습니다.

5. 판단표

5.1 분류×형식 한눈에 보기

먼저, 2장의 4분류와 4장의 4형식을 맞춘 표를 둡니다. 「내 데이터는 이 분류이므로, 이 형식·이 두는 위치」를 한 줄로 찾기 위한 표입니다.

분류(2장) JSON 파일 SQLite 레지스트리 Access 두는 위치의 기본(3장)
설정 ◎ 1순위 후보 ○ 앞으로 늘어나면 처음부터 이쪽 △ 아주 작은 플래그와 Windows 연동 정보만 ✕ %LOCALAPPDATA%(사용자를 따라가게 할 설정만 Roaming)
업무 데이터·이력 ✕ 전부 읽기 전제가 무너진다 ◎ 1순위 후보 ✕ △ 기존 Access 자산과 연동할 때만 %LOCALAPPDATA%(모든 사용자 공유라면 %PROGRAMDATA%+ACL 설계)
캐시 △ 작은 것만 ○ 건수가 많으면 ✕ ✕ %LOCALAPPDATA%(Roaming에 두지 않는다)
기밀 정보 ○ DPAPI로 보호한 값을 넣는 그릇으로 ○ 왼쪽과 같음 △ 왼쪽과 같음. 다만 작은 것만 ✕ 평문으로 두지 않는다. DPAPI로 보호(1장)

읽는 법은 두 가지입니다. 첫째, 행을 가로질러 하나의 그릇에 쑤셔 넣지 않는다. 「설정도 이력도 캐시도 전부 하나의 JSON」이, 나중에 문제가 되는 전형적인 설계입니다. 둘째, 기밀 정보 행은 「어느 형식에 넣을지」보다 「저장하기 전에 DPAPI로 암호화했는지」가 본질이고, 그릇 선택은 나머지 3분류를 따르면 됩니다.

5.2 형식별 성격

관점 JSON 파일 SQLite 레지스트리 Access (.accdb)
잘 맞는 데이터 작은 설정 늘어나는 구조화 데이터, 검색·집계 작은 플래그, Windows 연동 기존 Access 자산과의 연동
데이터양 기준 〜수백KB 〜수십GB 〜수KB 〜2GB(사양 상한)
검색·집계 ✕(전부 읽기 전제) ◎(SQL) ✕ ○(SQL)
사람이 직접 읽을 수 있음 ◎ △(도구 필요) △ △(Access 필요)
손상에 대한 강도 △(스스로 대책) ○(트랜잭션) ○ △
여러 프로세스 동시 접근 ✕ ○(같은 머신 안) ○ △
여러 머신에서의 공유 ✕ ✕ ✕ ✕(사실상)
추가 배포물 없음 없음(NuGet에 포함) 없음 ACE provider 필수

마지막 행이 보여 주듯, 로컬 저장 기술은 어느 것이든 「여러 머신에서의 공유」에는 맞지 않습니다. 공유 폴더에 두면 공유되는 것처럼 보이지만, JSON은 배타가 없고, SQLite는 SMB 위 잠금을 믿을 수 없으며, Access는 손상 위험과 함께 한계가 옵니다. 여러 거점·여러 사용자가 같은 데이터를 다루는 요구가 나오면, SQL Server Express 같은 서버형 DB나 Web API를 세우는 판단선이라고 생각하면 됩니다.

6. 깨지지 않고·이전할 수 있고·되돌릴 수 있다 ── 형식을 가리지 않는 공통 설계

어느 형식을 골라도, 몇 년 운용한다면 반드시 필요해지는 설계가 세 가지 있습니다. 첫 릴리스에 넣을지 여부로, 이후 유지보수 비용이 크게 달라지는 부분입니다.

6.1 스키마·형식에 버전 번호를 붙인다

앱을 갱신하면, 저장하는 데이터의 모양도 바뀝니다. 「옛 버전이 쓴 데이터를 새 버전이 읽는」 순간은 반드시 오므로, 데이터 쪽에 형식 버전을 갖게 해 둡니다.

SQLite라면 PRAGMA user_version이 바로 이 용도로 준비되어 있습니다.

int GetVersion(SqliteConnection conn)
{
    using var cmd = conn.CreateCommand();
    cmd.CommandText = "PRAGMA user_version";
    return Convert.ToInt32(cmd.ExecuteScalar());
}

void Migrate(SqliteConnection conn)
{
    void Exec(string sql)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        cmd.ExecuteNonQuery();
    }

    var v = GetVersion(conn);
    if (v > 2)
        // 더 새 버전의 앱이 만든 DB를 구 앱에서 연 경우.
        // 모르는 스키마를 건드리지 않고, 여기서 멈추는 것이 안전
        throw new InvalidOperationException(
            $"이 데이터베이스(버전 {v})는 더 새 앱에서 만들어졌습니다.");

    using var tx = conn.BeginTransaction();
    if (v < 1) Exec("ALTER TABLE measurement ADD COLUMN unit TEXT");
    if (v < 2) Exec("CREATE TABLE operator (id INTEGER PRIMARY KEY, name TEXT)");
    Exec("PRAGMA user_version = 2");
    tx.Commit();
}

시작 때 버전을 보고 차이만 적용하는, 이른바 마이그레이션의 최소 형태입니다. 맨 앞에서 「자신보다 새 버전」을 거절하는 것은, 앱을 구 버전으로 되돌린(롤백한) 때에, 옛 코드가 모르는 스키마에 써서 깨뜨리는 사고를 막기 위해서입니다. JSON에서도 생각은 같고, 루트에 "version": 2를 두고, 읽을 때 옛 형식에서의 변환을 끼우며, 너무 새 형식은 읽기를 거절합니다. 「버전 번호 없는 데이터 형식을 세상에 내놓지 않는다」, 이것만 지키면 미래의 자신이 구제됩니다.

6.2 손상 시의 대체 동작을 정해 둔다

4장에서 형식별 손상 대책(JSON의 원자적 쓰기, SQLite의 트랜잭션)을 다뤘지만, 그래도 「읽지 못하는 데이터」는 만납니다. 디스크 장애, 바이러스 대책 소프트웨어의 오탐으로 인한 격리, 사용자의 손 편집. 그때 앱이 어떻게 동작할지를 정해 두지 않으면, 시작조차 하지 않는 앱이 됩니다.

  • 설정을 읽지 못한다 → 기본값으로 시작하고, 그 취지를 사용자에게 알린다(조용히 기본값으로 두면 「설정이 사라졌다」는 문의가 됩니다)
  • 업무 데이터를 읽지 못한다 → 읽기 전용 모드나 오류 화면에서 「어느 파일이 깨졌는지」를 제시한다. 자동으로 덮어써 복구하지 않는다(증거가 사라집니다)
  • 백업이 있다 → 복원을 제안한다. 다만 자동 복원은 「깨졌다고 오탐해 옛 데이터로 되감는」 위험과 표리이므로, 원칙적으로 사용자 조작을 끼운다

6.3 백업은 「받아 두었는지」보다 「되돌릴 수 있는지」

로컬 데이터는 서버 데이터베이스와 달리 아무도 백업해 주지 않습니다. 앱 쪽에서 챙긴다면, 다음 세 가지를 정합니다.

  • 무엇을: 업무 데이터는 대상, 캐시는 대상 밖, 기밀 정보는 DPAPI 성질상 같은 사용자·같은 머신에서만 복호화할 수 있음을 고려(다른 머신으로의 이전 절차가 따로 필요)
  • 언제·어디로: 시작 때나 일 단위로, %LOCALAPPDATA% 안의 backup 폴더에 세대별로. 나아가 공유 폴더나 기존 PC 백업 대상 폴더에 넣을지는 운용과 상의
  • 어떻게: SQLite는 가동 중 단순 파일 복사 금지. VACUUM INTO 'backup.db'라면 일관성 있는 스냅샷을 한 문으로 받을 수 있습니다
// VACUUM INTO는 부모 폴더를 만들지 않고, 출력 대상이 이미 있으면 오류가 된다.
// 폴더 생성과 겹치지 않는 파일 이름 결정을 먼저 마쳐 둔다
var backupDir = Path.Combine(AppPaths.DataDir, "backup");
Directory.CreateDirectory(backupDir);
var backupPath = Path.Combine(backupDir, $"app-{DateTime.Now:yyyyMMdd-HHmmss}.db");

using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM INTO $path";
cmd.Parameters.AddWithValue("$path", backupPath);
cmd.ExecuteNonQuery();

세대가 계속 쌓이므로, 백업 뒤에 「최근 N세대만 남기고 옛것을 지운다」는 처리도 세트로 넣어 둡니다.

그리고 한 번은 복원 리허설을 하세요. 백업 파일은 있는데 되돌리는 법을 아무도 모른다·시험한 적이 없다, 는 업무 시스템의 「흔한 일」입니다. PC 교체 때 데이터를 새 단말로 옮기는 절차서를 써 보면, 백업 설계의 구멍(DPAPI로 보호한 자격 증명이 옮겨지지 않는다, 경로가 사용자 이름을 포함해 다른 사용자에서 깨진다 등)이 대체로 찾아집니다. PC 폐기 때 데이터를 지우는 방법은 「Windows PC를 폐기하기 전에 해 두고 싶은 일」도 참고하세요.

7. 헷갈리기 쉬운 케이스의 지침

  • 「설정인데 앞으로 늘어날 것 같다」 ── 시작 때 전부 읽는 쓰임이 무너질 전망이 있다면, 처음부터 SQLite로 합니다. SQLite에 「settings 테이블」을 만드는 것은 나쁜 일이 아닙니다.
  • 「INI/XML에서의 이전」 ── 형식 교체만이면 JSON으로, 그 시점에 이력 계열 데이터가 섞여 있으면 분리해 SQLite로. 읽기 쪽에 옛 형식 폴백을 1〜2버전 남기면 이전이 안전합니다.
  • 「Excel로 보고 싶다고 한다」 ── 데이터 스토어를 Excel/Access로 하지 않고, SQLite에 저장한 뒤 CSV/Excel로 내보내는 기능을 붙이는 편이, 데이터의 신뢰성과 요청 둘 다를 채웁니다. 장표 출력 만드는 법은 「Excel 장표 출력을 어떻게 만들 것인가」를 참고하세요.
  • 「여러 프로세스가 같은 파일을 읽고 쓰고 싶다」 ── 같은 머신 안이라면 SQLite(WAL)로 꽤 버틸 수 있지만, 쓰기 경합 설계가 필요합니다. 파일 기반으로 연동한다면 「파일 연동과 잠금의 베스트 프랙티스」의 배타 패턴을 쓰세요.
  • 「여러 머신에서 공유하고 싶다」 ── 로컬 저장에서의 졸업입니다. 1순위 후보는 SQL Server Express(무료, 데이터베이스 10GB까지)를 파일 서버에 해당하는 머신에 세우는 클라이언트·서버 구성. 참고로 SQL Server 「LocalDB」는 이름과 달리 개발 용도의 단일 사용자 환경이므로, 공유 목적으로는 고르지 마세요. 거점을 넘고, 사외에서도 쓴다, 가 되면 Web API를 끼우는 구성을 검토하는 선입니다.

8. 정리

저장 위치 선택은, 「어디에 둘지」(LocalAppData / ProgramData, 그리고 Program Files에는 쓰지 않는다)와 「무엇으로 저장할지」(설정은 JSON, 늘어나는 데이터는 SQLite, 레지스트리는 최소, Access는 기존 연동만)로 나눠 생각하면, 대부분의 경우 헤매지 않고 정할 수 있습니다.

그 위에서, 형식을 가리지 않고 6장의 3점 세트──형식 버전 번호, 손상 시의 대체 동작, 되돌릴 수 있는 백업──를 첫 릴리스에 넣어 둘 것. 기밀 정보만은 항상 별도로 DPAPI로. 이 글의 판단표와 공통 설계를 잡아 두면, 「수십 MB로 자란 JSON」「일주일에 한 번 깨지는 공유 Access」처럼, 나중에 비용이 커지는 구성은 거의 피할 수 있습니다. 기존 앱의 저장 방식에 불안이 있다면, 먼저 「무엇이 어디에 저장되어 있는지 파악」부터 시작하는 것을 권합니다.

관련 글

관련 상담 영역

合同会社小村ソフト에서는, 업무 앱의 데이터 저장 방식 재검토(INI/XML/Access에서의 이전 설계를 포함)나, 데이터 손상·성능 저하의 원인 조사를 다룹니다.

참고 링크

  1. Microsoft Learn, KNOWNFOLDERID. LocalAppData, RoamingAppData, ProgramData 등 Windows의 알려진 폴더 정의에 대해. ↩

  2. Microsoft Learn, Microsoft.Data.Sqlite overview. Microsoft가 유지 관리하는 SQLite용 ADO.NET provider의 개요와, EF Core SQLite provider의 기반이라는 점에 대해. ↩ ↩2

  3. Microsoft Learn, Registry Redirector. 64bit Windows에서 32bit 프로세스의 레지스트리 접근이 Wow6432Node로 리디렉트되는 구조에 대해. ↩ ↩2

  4. Microsoft Learn, Can’t establish a connection to Access Database Engine OLE DB. ACE OLE DB provider는 접근하는 프로세스와 비트 수가 일치해야 한다는 점에 대해. ↩ ↩2

  5. Microsoft Learn, DataProtectionScope Enum. ProtectedData.Protect / Unprotect에 지정하는 보호 범위의 정의. CurrentUser에서는 현재 사용자 컨텍스트에서 움직이는 스레드만 복호화할 수 있다는 점, LocalMachine에서는 그 컴퓨터 위 임의의 프로세스가 복호화할 수 있으므로 그 머신의 모든 계정을 신뢰할 수 있는 경우로 한정해야 하며, 많은 장면에서는 CurrentUser를 써야 한다는 점에 대해. ↩

  6. Microsoft Learn, Environment.SpecialFolder Enum. .NET에서 알려진 폴더를 얻기 위한 열거형에 대해. ↩

  7. Microsoft Learn, UAC Architecture. UAC의 파일/레지스트리 가상화가 머신 단위 쓰기 요구를 사용자 단위 장소로 리디렉트하고, 읽기도 가상화된 장소를 우선한다는 점, Program Files처럼 보호된 폴더에 대한 쓰기에 사용자 프로필 안의 복제가 쓰이며 사용자마다 다른 복제가 된다는 점, 가상화가 32bit 앱만 대상으로 하고 권한 상승된 프로세스나 requestedExecutionLevel을 가진 매니페스트 있는 앱에서는 무효라는 점(권한 상승하지 않은 64bit 앱은 액세스 거부가 된다는 점), 그리고 잠정적 호환 기능이라 의존하면 안 된다는 점에 대해. ↩ ↩2

  8. Microsoft Learn, Use a SQLite database in a Windows app. Windows 앱의 로컬 데이터 저장에 SQLite와 Microsoft.Data.Sqlite / EF Core를 권장하는 공식 튜토리얼. ↩

  9. SQLite, Write-Ahead Logging. 변경을 본체가 아니라 WAL 파일에 이어 붙이는 구조, 쓰는 쪽이 이어 붙이기만 하므로 읽는 쪽과 동시에 움직일 수 있다는 점, journal_mode=WAL이 영속적이어서 다시 열어도 유지된다는 점, -wal과 -shm 두 파일이 따라붙는다는 점, 그리고 같은 데이터베이스를 쓰는 프로세스가 같은 머신 위에 있어야 하며 네트워크 파일 시스템 위에서는 WAL이 동작하지 않는다는 점에 대해. ↩ ↩2 ↩3

  10. SQLite, How To Corrupt An SQLite Database File. 네트워크 파일 시스템 위의 잠금 불량이 데이터베이스 손상의 주요 원인이라는 점에 대해. ↩

  11. Microsoft Learn, Data types (Microsoft.Data.Sqlite). SQLite의 4가지 원시 형과, DateTime이나 Guid가 TEXT로 매핑되는 규칙에 대해. ↩

  12. Microsoft, Microsoft Access Database Engine 2016 Redistributable. .accdb / .mdb에 접근하기 위한 ACE 재배포 가능 패키지(32bit/64bit)에 대해. ↩

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

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

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

자주 묻는 질문

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

Windows 앱의 설정 파일은 어디에 저장해야 합니까?
사용자별 설정·데이터라면 %LOCALAPPDATA%(Environment.SpecialFolder.LocalApplicationData) 아래에 「회사명\앱명」 계층으로 두는 것이 기본입니다. 모든 사용자 공유라면 %PROGRAMDATA%이지만, 기본 ACL에서는 다른 사용자가 변경하지 못하는 구성이 될 수 있으므로, 설치 프로그램에서 ACL을 명시적으로 설정합니다. exe와 같은 폴더(Program Files 아래)에는 쓰면 안 됩니다. 표준 사용자는 쓸 수 없고, 오래된 32bit 앱에서는 VirtualStore로 조용히 리디렉트되어 원인을 알기 어려운 증상의 원인이 됩니다.
설정은 JSON과 SQLite 중 어느 쪽으로 저장해야 합니까?
구조화된 작은 설정은 JSON 파일, 늘어나는 업무 데이터·이력·검색할 데이터는 SQLite가 1순위 후보이고, 이 두 가지로 업무 앱 로컬 저장의 대부분은 커버됩니다. JSON의 적용 범위는 「시작 때 전부 읽고 종료 때 전부 쓴다」가 성립하는 수백 KB까지가 기준입니다. 계속 추가되는 이력이나 레코드 검색이 필요한 데이터를 JSON에 넣기 시작했다면, SQLite로 옮길 신호입니다. 설정이라도 앞으로 늘어날 전망이 있다면, 처음부터 SQLite에 settings 테이블을 만드는 형태여도 문제없습니다.
SQLite 데이터베이스를 네트워크 공유 폴더에 두어도 됩니까?
피해야 합니다. SMB 너머의 파일 잠금은 환경에 따라 문제가 많고, SQLite 공식도 네트워크 파일 시스템 위 공유를 손상의 가장 큰 원인으로 꼽습니다. JSON은 배타 제어가 없고, Access도 손상 위험과 함께 한계가 오므로, 로컬 저장 기술은 어느 것이든 여러 머신에서의 공유에는 맞지 않습니다. 여러 거점·여러 사용자가 같은 데이터를 다루는 요구가 나오면, SQL Server Express(무료, 데이터베이스 10GB까지) 같은 서버형 DB나 Web API를 세우는 판단선입니다.
레지스트리에 앱 데이터를 저장해도 됩니까?
레지스트리가 적절한 것은 시작 프로그램 등록이나 파일 연결처럼 Windows 자체와의 연동 정보와, 아주 작은 사용자 설정까지입니다. 수 KB를 넘는 데이터나 배열성 데이터는 백업·이전·진단 어느 면에서든 불리하므로 JSON이나 SQLite에 맡깁니다. 또한 64bit Windows에서는 32bit 프로세스에서 본 HKLM\Software가 Wow6432Node로 리디렉트되므로, 「레지스트리 편집기에서는 값이 있는데 앱에서는 읽히지 않는다」는 증상의 원인이 됩니다. 쓰기 쪽과 읽기 쪽의 비트 수를 맞추는 것이 본래 해법입니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기