.NET으로 만드는
MSIX 인스톨러 자동화
샌드박스 배포를 더블클릭 한 번으로
.NET으로 만드는
MSIX 인스톨러 자동화
샌드박스 배포를 더블클릭 한 번으로
이호원 airtaxi
Windows를 좋아해서 C#과 .NET을 시작했고,
지금은 WinUI와 Uno Platform으로 Windows 앱을 만듭니다.
인디 리듬게임 회사에서
게임 서버와 클라이언트 개발
멀티모달 LLM과 RAG 기반
화재 대응 조언 시스템
박사 졸업 후 사회복무요원으로 복무 중
개발은 퇴근 후에 합니다
이 발표에서 다루는 내용
권한 상승, 인증서 신뢰, 설치된 버전 비교, 배포 API 호출을 모두 코드로 처리합니다.
PackageManager, AsTask(), IProgress<T>로 설치 진행률까지 연결합니다.
공통 라이브러리, NativeAOT, RID별 도구 패키지로 빌드 파이프라인에 넣습니다.
오늘의 순서
| 순서 | 구간 | 다루는 내용 |
|---|---|---|
| 1 | 왜 MSIX인가 | 자체 인스톨러의 한계, 가상화가 해결하는 것 |
| 2 | 사이드로딩의 벽 | 인증서 오류, 공인 인증서가 대안이 되기 어려운 이유 |
| 3 | 데모: Tauri 앱 더블클릭 설치 | 설치부터 제거 후 흔적 비교까지 |
| 4 | 설치기 내부 구조 | 더블클릭 뒤에서 일어나는 일, .NET에서 WinRT 쓰기 |
| 5 | 생성기 내부 구조 | 아키텍처 감지, 매니페스트 읽기, 패키징, SFX 조립 |
| 6 | 데모: .NET 앱 CLI 파이프라인 | dotnet publish부터 인스톨러까지 |
| 7 | .NET 플랫폼 활용 | 코드 공유, NativeAOT, RID별 dotnet tool, CI |
왜 MSIX인가
.atp 인스톨러AT Installer 개발 과정
2023년 12월부터 2026년 10월까지, 커밋 198개
| 시기 | 변화 |
|---|---|
| 2023-12 | ZIP 기반 .atp 패키지와 자체 인스톨러, 7z SFX로 단일 EXE 생성 |
| 2025-12 | .NET 10 전환, Installer와 Composer에 NativeAOT 적용 |
| 2026-04 | 명령줄 Composer 추가, 자동화용 --minimal-log 옵션 |
| 2026-07-08 | Installer가 .msix와 .msixbundle을 직접 설치, 같은 날 MSIX 인스톨러 생성기 시작 |
| 2026-07-11 | 공통 라이브러리 분리, aticmsixgen CLI를 NuGet dotnet tool로 배포 |
자체 인스톨러는 모든 것을 직접 관리해야 했습니다
| 관리 대상 | 직접 처리한 방법 |
|---|---|
| 설치 위치 | %AppData%\{InstallationFolderName}에 압축 해제 |
| 제거 정보 | HKCU\...\CurrentVersion\Uninstall에 직접 등록 |
| 설치한 파일 목록 | 제거할 때 지우려고 InstalledFiles를 매니페스트에 따로 기록 |
| 스크립트 | 설치 후, 재설치 후, 제거 시 실행할 스크립트를 각각 지원 |
| 제거 배치 파일 | 유니코드 문자가 깨지는 문제로 UTF-8 인코딩 지정 |
이 발표에서 말하는 샌드박스는 파일과 레지스트리 가상화입니다
앱의 권한을 제한하는 AppContainer 격리와는 다릅니다. 그래도 설치와 제거를 관리하는 데는 가상화만으로 충분합니다.
.msixbundle 하나에 x64, x86, ARM64자체 인스톨러에서 InstalledFiles로 직접 추적하던 일을 이제 운영체제가 처리합니다.
사이드로딩의 벽
앱 설치 관리자와 PowerShell 모두 같은 이유로 실패합니다
Add-AppxPackage .\DemoApp.msix
설치하려면 사용자가 이 단계를 직접 거쳐야 합니다
패키지 속성의 디지털 서명 탭에서 인증서 확인
로컬 컴퓨터의 신뢰할 수 있는 루트 인증 기관에 설치
정책으로 사이드로딩이 막힌 환경
앱 설치 관리자나 PowerShell, 종속성 오류가 나면 직접 해결
공인 인증서를 사면 되지 않을까요?
| 항목 | 내용 |
|---|---|
| 비용 | OV 코드 서명 인증서가 연 200달러대부터 시작하고, EV는 더 비쌉니다 |
| 하드웨어 | 2023년 6월부터 개인 키를 USB 토큰이나 HSM에 보관해야 합니다. CI에서 쓰려면 클라우드 서명 서비스가 따로 필요합니다 |
| 유효기간 | 2026년 3월부터 최대 460일로 줄어 거의 매년 재발급과 신원 확인을 거칩니다 |
| Azure Artifact Signing | 월 9.99달러로 저렴하지만 개인 개발자는 미국과 캐나다에서만 가입할 수 있고, 한국은 조직만 가입할 수 있습니다 |
2026년 10월 기준이며, 가격과 가입 조건은 자주 바뀝니다
Tauri 앱을
더블클릭으로 설치
%LOCALAPPDATA%에 데이터를 남깁니다Installer-x64.exe 더블클릭, UAC, 설치 완료기본 NSIS 인스톨러로 설치한 뒤 제거해 보겠습니다
같은 앱을 MSIX로 패키징하고 Installer-arm64.exe로 설치합니다
이름, 게시자, 아이콘은 패키지 매니페스트에서 읽어 옵니다. 인증서 등록, 개발자 모드 설정, PowerShell 실행은 사용자가 할 필요가 없습니다.
설치된 앱은 시작 메뉴에 등록되고, 데이터는 패키지 전용 위치에 저장됩니다
앱은 %LOCALAPPDATA%\com.demo.tauridemo에 쓰지만, 실제 파일은 Packages\TauriDemo_…\LocalCache\Local 아래에 저장됩니다.
앱을 제거하면 데이터도 함께 삭제됩니다
NSIS와 MSIX의 제거 결과 비교
| NSIS 인스톨러 | MSIX와 AT Installer | |
|---|---|---|
| 설치 | 인스톨러 실행 | Installer-x64.exe 더블클릭 |
| WebView2 데이터 | %LOCALAPPDATA%\<앱 식별자> | %LOCALAPPDATA%\Packages\TauriDemo_…\LocalCache\Local\com.demo.tauridemo |
| 제거 후 | 데이터 폴더가 그대로 남음 | 패키지 폴더까지 삭제됨 |
두 경우 모두 앱 코드는 같습니다. MSIX로 설치하면 운영체제가 쓰기 경로를 패키지 전용 위치로 리디렉션합니다.
설치기
내부 구조
PackageManager를 일반 클래스처럼 호출더블클릭부터 설치 완료까지
임시 폴더에 압축 해제 후 설치기 실행
.msix면 MSIX 전용 창
관리자로 다시 실행
서명에서 꺼내 신뢰 저장소에 추가
원래 값을 백업하고 활성화
설치, 업데이트, 재설치, 차단
AddPackageAsync와 진행률 표시
설치 결과와 관계없이 원래 값으로 복원
설치 화면의 앱 이름, 아이콘, 버전은 패키지 안의 매니페스트에서 읽어 옵니다.
EXE 인스톨러는 SFX 모듈, 설정, 압축 파일을 이어 붙여 만듭니다
rem SFX 모듈 + 실행 설정 + 압축 파일 = 단일 EXE
copy /b 7zS.sfx + config_x64.txt + Archive.7z Installer-x64.exe
;!@Install@!UTF-8! RunProgram="Installer.exe Package.msixbundle" ;!@InstallEnd@!
silent 설정은 실행 인자 뒤에 /silent를 붙입니다.
// SFX가 작업 디렉터리를 바꾸므로 절대 경로로 고정 var packageFilePath = Path.GetFullPath(args[1]);
상대 경로로 받으면 패키지를 찾지 못하는 문제가 있었습니다.
설치기는 패키지 확장자에 따라 처리 흐름을 나눕니다
var isSilent = args.Contains("/silent", StringComparer.OrdinalIgnoreCase); var shouldAutoInstall = args.Contains("/install", StringComparer.OrdinalIgnoreCase); // Dispatch to the appropriate window based on package file extension var extension = Path.GetExtension(packageFilePath).ToLowerInvariant(); if (extension == ".msix" || extension == ".msixbundle") _window = new MsixInstallerWindow(packageFilePath, isSilent, shouldAutoInstall); else _window = new InstallerWindow(packageFilePath, isSilent, shouldAutoInstall);
.atp기존 자체 설치 흐름압축 해제, 레지스트리 등록, 바로 가기 생성
.msix, .msixbundleMSIX 사이드로딩 흐름권한, 인증서, 개발자 모드, 배포 API
1단계: 관리자 권한으로 자신을 다시 실행합니다
public static bool IsRunningAsAdministrator() { using var identity = WindowsIdentity.GetCurrent(); return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); } public static void RelaunchElevatedPreservingArguments() { var arguments = string.Join(' ', Environment.GetCommandLineArgs().Skip(1) .Select(arg => arg.Contains(' ') ? $"\"{arg}\"" : arg)); if (!arguments.Contains("/install", StringComparison.Ordinal)) arguments += " /install"; try { Process.Start(new ProcessStartInfo { FileName = Environment.ProcessPath, Arguments = arguments, Verb = "runas", UseShellExecute = true }); } catch { } Environment.Exit(0); }
/install 인자가 있으면 다시 실행된 설치기가 사용자 입력 없이 설치를 진행합니다.
2단계: 패키지 서명에서 인증서를 꺼내 신뢰 저장소에 등록합니다
#pragma warning disable SYSLIB0057 var signerCertificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(packageFilePath)); #pragma warning restore SYSLIB0057 using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine); store.Open(OpenFlags.ReadWrite); var existing = store.Certificates.Find(X509FindType.FindByThumbprint, signerCertificate.Thumbprint, false); if (existing.Count == 0) store.Add(signerCertificate);
서명된 패키지 자체에서 서명자 인증서를 읽습니다.
SYSLIB0057.NET 9부터 경고서명된 파일에서 인증서를 꺼내는 대체 API가 X509CertificateLoader에 없어서 경고를 억제했습니다.
3단계: 개발자 모드를 켜고, 설치가 끝나면 원래 값으로 되돌립니다
int? originalDeveloperModeState = null; try { originalDeveloperModeState = DeveloperModeHelper.BackupDeveloperModeState(); bool developerModeEnabled = DeveloperModeHelper.TryEnableDeveloperMode(); // 버전 비교, 배포 ... } finally { if (originalDeveloperModeState.HasValue) DeveloperModeHelper.TryRestoreDeveloperModeState(originalDeveloperModeState.Value); }
4단계: 이미 설치된 버전과 비교합니다
var packageManager = new PackageManager(); foreach (var package in packageManager.FindPackagesForUser(string.Empty)) { if (package.Id.Name != metadata.Name) continue; // package.Id.Version을 System.Version으로 바꿔 패키지 버전과 비교 }
| 설치된 버전 | 버튼 | 동작 |
|---|---|---|
| 없음 | 설치 | 바로 배포 |
| 패키지보다 낮음 | 업데이트 | 그대로 배포하면 운영체제가 업데이트로 처리 |
| 패키지와 같음 | 재설치 | 확인을 받은 뒤 RemovePackageAsync 후 배포 |
| 패키지보다 높음 | 다운그레이드 불가 | 버튼 비활성화, silent 모드면 종료 코드 24 |
5단계: AddPackageAsync로 패키지를 배포합니다
public static async Task<MsixDeploymentResult> AddPackageAsync(string packageFilePath, IProgress<DeploymentProgress> progress) { var packageManager = new PackageManager(); var packageUri = new Uri(packageFilePath); var deploymentOperation = packageManager.AddPackageAsync(packageUri, null, DeploymentOptions.ForceApplicationShutdown); try { await deploymentOperation.AsTask(progress); } catch (Exception exception) { return new MsixDeploymentResult { IsSuccessful = false, ErrorText = exception.Message, ... }; } // 완료 상태와 오류 코드를 MsixDeploymentResult로 정리해 반환 }
ForceApplicationShutdown실행 중인 이전 버전을 종료하고 업데이트AsTask(progress)WinRT 진행률을 IProgress<T>로 받아 진행률 막대에 연결콘솔 앱이나 WPF 앱에서도 PackageManager를 호출할 수 있습니다
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
- 대상 프레임워크에 Windows SDK 버전을 적으면 C#/WinRT 프로젝션이 함께 참조됩니다.
PackageManager,X509Store,Registry를 같은 C# 코드 안에서 섞어 씁니다.ApplicationData.Current처럼 패키지 ID가 필요한 API는 예외입니다.
IAsyncOperationWithProgress는 AsTask()로 awaitAsTask(IProgress<T>)로 그대로 연결silent 모드에서는 설치 결과를 종료 코드로 반환합니다
사내 배포 도구나 스크립트가 설치 결과를 판단할 수 있도록 실패 지점마다 다른 종료 코드를 돌려줍니다.
RunProgram="Installer.exe Package.msix /silent"
| 코드 | 의미 |
|---|---|
| 0 | 설치 성공 |
| 24 | 더 높은 버전이 설치되어 있어 다운그레이드 불가 |
| 27 | 개발자 모드 활성화 실패 |
| 28 | 패키지 배포 실패 |
| 29 | 인증서 추출 또는 등록 실패 |
| 30 | 같은 버전이 이미 설치됨 |
생성기
내부 구조
Installer-*.exe, 여러 개면 ZIPMSIX 인스톨러 생성기의 두 가지 사용 방법
Electron, Tauri, WPF 등 프레임워크와 관계없음
세 단계를 거쳐 .msix 또는 .msixbundle
이미 만든 MSIX도 바로 넣을 수 있습니다
아키텍처가 여러 개면 ZIP으로 묶음
MSIX 패키징 1단계: 인증서 생성
2단계: 매니페스트 작성
3단계: 버전과 빌드 출력을 지정해 패키징
EXE 패키징 탭은 MSIX의 패키지 정보를 읽어 표시합니다
생성기 자체도 MSIX로 만들어 Microsoft Store에서 배포합니다
실행 파일의 PE 헤더로 아키텍처를 판별합니다
| 위치 | 읽는 값 |
|---|---|
| 0x00 | MZ 서명 (0x5A4D) |
| 0x3C | PE 헤더 위치 |
| PE | PE\0\0 서명 |
| PE + 4 | Machine 필드 2바이트 |
빌드 출력 폴더마다 실행 파일을 찾아 아키텍처를 정하고, 같은 아키텍처가 두 번 들어오면 오류로 처리합니다.
stream.Seek(PeOffsetLocation, SeekOrigin.Begin); // 0x3C var peOffset = reader.ReadInt32(); stream.Seek(peOffset, SeekOrigin.Begin); var peSignature = reader.ReadUInt32(); if (peSignature != PeSignature) return "Unknown"; var machine = reader.ReadUInt16(); return machine switch { 0x8664 => "x64", 0x014C => "x86", 0xAA64 => "ARM64", _ => "Unknown" };
MSIX는 ZIP 형식이고, 번들 안에는 아키텍처별 MSIX가 들어 있습니다
App.msixbundle ├─ AppxMetadata/ │ └─ AppxBundleManifest.xml 이름, 게시자, 버전, 아키텍처 목록 ├─ App_x64.msix │ ├─ AppxManifest.xml 표시 이름, 게시자 표시 이름 │ └─ Assets/ │ └─ StoreLogo.scale-400.png 가장 큰 scale을 아이콘으로 ├─ App_arm64.msix └─ App_x86.msix
ZipArchive로 열고XDocument로 매니페스트를 읽습니다.- 번들 안의 첫 애플리케이션 패키지를 다시
ZipArchive로 열어 표시 이름을 가져옵니다. - 로고는 여러 scale 가운데 가장 큰 이미지를 골라 설치 화면에 씁니다.
추가 라이브러리 없이 BCL만으로 처리합니다.
MSIX 패키징은 WinApp CLI로 처리합니다
빌드 출력을 감지된 아키텍처 이름의 작업 폴더로 복사
표시 이름, 버전, 설명, 로고로 매니페스트 생성
폴더가 하나면 .msix, 여러 개면 .msixbundle
winapp.exe pack x64 ARM64 --executable MyApp.exe --cert cert.pfx --cert-password "********"
MSIX를 아키텍처별 EXE로 조립합니다
| 순서 | 작업 | 내용 |
|---|---|---|
| 1 | 템플릿 확인 | GitHub Releases의 최신 SFX 템플릿을 받아 버전별로 캐시 |
| 2 | 아키텍처 교집합 | 사용자가 고른 아키텍처와 패키지에 실제로 든 아키텍처를 비교 |
| 3 | 이전 산출물 정리 | 캐시를 재사용하므로 지난 실행의 패키지와 압축 파일을 먼저 삭제 |
| 4 | 압축 | Package.msixbundle을 복사하고 Archive.7z 생성 |
| 5 | 결합 | copy /b로 SFX 모듈, 설정, 압축 파일을 이어 붙임 |
| 6 | 묶기 | EXE가 하나면 그대로, 여러 개면 Installers.zip으로 |
.NET 앱을
CLI로 패키징
dotnet publish와 aticmsixgenInstaller-x64.exe, Installer-arm64.exe빌드 출력에서 인스톨러를 만드는 명령
# 한 번만: 설치와 인증서 생성 dotnet tool install --global aticmsixgen aticmsixgen cert generate --publisher "Demo" --output cert.pfx # 릴리스마다 dotnet publish -c Release -r win-x64 -o out\x64 dotnet publish -c Release -r win-arm64 -o out\arm64 aticmsixgen msix-quick-pack --display-name "Demo App" --description "Demo" --executable "DemoApp.exe" ` -f out\x64 -f out\arm64 --cert E:\Demo\cert.pfx --version 1.0.0.0 -o Demo.msixbundle aticmsixgen exe-pack -i Demo.msixbundle -a arm64 -o Installer-arm64.exe
자체 서명 PFX 생성
매니페스트 파일 또는 인라인 매개변수로 패키징
MSIX를 더블클릭 인스톨러로
msix-quick-pack 실행 결과
- 두 폴더의
DemoApp.exe에서 ARM64와 x64를 자동으로 감지합니다. - 아키텍처마다 매니페스트를 만들고 패키지를 하나씩 만듭니다.
- 두 패키지를
.msixbundle로 묶고 인증서로 서명합니다. - 사용자가 지정한 것은 빌드 출력 폴더와 인증서 파일입니다.
exe-pack 실행 결과
.NET 플랫폼
활용
GUI에서 공통 코드를 떼어 CLI와 나눠 씁니다
CommunityToolkit.Mvvm, DI, 페이지와 뷰모델
System.CommandLine, Microsoft.Extensions.DependencyInjection
PE 아키텍처 감지, MSIX 매니페스트 읽기, WinApp CLI와 SFX 템플릿 다운로드, 프로세스 실행 도우미, 매니페스트 설정 모델
UI에 묶인 코드는 서비스에 남기고, 파일과 프로세스만 다루는 코드는 대상 프레임워크가 net10.0인 라이브러리로 옮겼습니다. 그래서 CLI가 Windows App SDK 없이 빌드됩니다.
NativeAOT로 가려면 리플렉션을 걷어내야 했습니다
| 걸림돌 | 바꾼 방법 |
|---|---|
| JSON 직렬화 | 리플렉션 기반 직렬화 대신 JsonSerializerContext 소스 생성 |
| WinUI 지역화 라이브러리 | 리플렉션을 쓰는 외부 라이브러리를 걷어내고 MRT Core ResourceLoader로 교체 |
aticmsixgen도 처음부터 NativeAOT로.NET 10부터 NativeAOT 실행 파일을 dotnet tool로 배포할 수 있습니다
<PublishAot>true</PublishAot> <RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers> <ToolPackageRuntimeIdentifiers>$(RuntimeIdentifiers)</ToolPackageRuntimeIdentifiers> <PackAsTool>true</PackAsTool> <ToolCommandName>aticmsixgen</ToolCommandName>
dotnet tool install --global aticmsixgen
csproj 버전을 올려 push하면 NuGet에 자동으로 게시됩니다
RID 패키지를 가리키는 진입점 패키지
win-x86, win-x64, win-arm64를 NativeAOT로
네 개가 모두 만들어졌는지 확인
nuget.org에 게시
runs-on: windows-latest # ... dotnet pack MsixInstallerComposerCommandLine.csproj -c Release -p:ContinuousIntegrationBuild=true dotnet pack MsixInstallerComposerCommandLine.csproj -c Release -p:Platform=$platform # x86, x64, ARM64
데모 2의 명령을 같은 워크플로에 넣으면 앱 릴리스마다 인스톨러가 함께 만들어집니다.
정리

AT-Installer

MSIX 인스톨러 생성기

-g aticmsixgen
감사합니다
질문 받겠습니다

kagamine-rin.com
AT-Installer