기능이 다 돌아가는 것과 남에게 건넬 수 있는 것은 다르다. 마지막 글에서는 Cliply를 "내 PC에서 돌아가는 앱"에서 "홈페이지에서 내려받아 쓰는 앱"으로 만든 일들을 정리한다.

메인 창이 없는 앱

Cliply는 실행해도 창이 뜨지 않는다. 트레이 아이콘만 생긴다. WPF는 기본적으로 StartupUri의 창을 띄우고 마지막 창이 닫히면 종료하는데, 둘 다 바꿨다. App.xaml에서 StartupUri를 없애고 ShutdownMode="OnExplicitShutdown"으로 두면 창이 하나도 없어도 앱이 산다.

시작 순서는 이렇다.

// 1. 이미 실행 중이면 기존 인스턴스를 깨우고 끝낸다
// 2. 파일 로그, 전역 예외 처리
// 3. Generic Host + DI
// 4. 설정을 읽어 언어 결정        ← DB보다 먼저
// 5. DB 마이그레이션, FTS, (빈 DB면) 샘플 시드
// 6. 테마·글꼴
// 7. 트레이 아이콘
// 8. 빠른 검색 창 미리 렌더링, 전역 단축키 등록
// 9. 자동 실행 경로 갱신

트레이 아이콘은 H.NotifyIcon.Wpf를 썼다. 메뉴는 빠른 검색 열기·스니펫 관리·설정·종료이고, 더블클릭하면 관리 창이 열린다. 관리 창의 X 버튼은 숨김이다. 실수로 닫아도 단축키가 계속 살아 있어야 하기 때문이다. 실제 종료는 트레이 메뉴에서만 한다.

단일 인스턴스: 두 번째 실행은 첫 번째를 깨운다

트레이 앱은 사용자가 이미 실행 중인 줄 모르고 바탕화면 아이콘을 또 누르는 경우가 많다. 그때 두 번째 인스턴스가 뜨면 단축키 등록이 충돌한다. 그래서 이름 있는 Mutex로 하나만 실행되게 했다.

두 번째로 실행된 쪽이 그냥 조용히 끝나면 사용자는 "아무 반응이 없다"고 느낀다. 그래서 기존 인스턴스에 신호를 보내 빠른 검색을 띄우고 끝낸다.

_mutex = new Mutex(initiallyOwned: true, AppConstants.SingleInstanceMutexName, out var createdNew);
IsFirstInstance = createdNew;
if (createdNew)
{
    _activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, AppConstants.ActivationEventName);
}

첫 인스턴스는 ThreadPool.RegisterWaitForSingleObject로 이벤트를 기다리다가, 신호가 오면 UI 스레드로 넘겨 팝업을 띄운다. 두 번째 인스턴스는 신호를 보내기 전에 AllowSetForegroundWindow(ASFW_ANY)를 호출한다. Windows는 포그라운드가 아닌 프로세스가 마음대로 창을 앞으로 가져오는 것을 막는데, 지금 포그라운드인 두 번째 프로세스가 허락해 주면 첫 인스턴스가 팝업을 앞으로 띄울 수 있다.

이름은 Global\ 대신 Local\Cliply_SingleInstance를 썼다. 원격 데스크톱처럼 한 PC에 여러 사용자가 로그인해 있을 때 각 세션에서 따로 실행할 수 있어야 한다.

설정은 저장하는 순간 적용

설정 창

설정 항목은 단축키, Windows 시작 시 자동 실행, "복사됨" 표시, 언어, 테마, 본문 글꼴이다. 어느 것도 다시 시작을 요구하지 않는다.

  • 단축키: 3편의 HotkeyManager.Register로 다시 등록한다. 실패하면 이전 값 유지.
  • 자동 실행: HKCU\Software\Microsoft\Windows\CurrentVersion\Run에 Cliply 값을 쓰거나 지운다. 관리자 권한이 필요 없는 사용자 범위다. 앱을 시작할 때마다 경로를 다시 써서, exe를 다른 폴더로 옮겨도 자동 실행이 깨지지 않는다.
  • 테마: 라이트/다크 색상 사전을 DynamicResource로 연결해 두고 사전만 바꾼다. "시스템 설정 따름"이면 레지스트리 AppsUseLightTheme를 읽고, SystemEvents.UserPreferenceChanged로 Windows 앱 모드가 바뀌는 것도 따라간다. 창 제목 표시줄은 DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE)로 같이 어둡게 한다.
  • 글꼴: 본문 기본은 Consolas, D2Coding이 설치돼 있으면 고를 수 있다.

settings.json은 임시 파일에 먼저 쓰고 File.Move(..., overwrite: true)로 바꿔 끼운다. 쓰는 도중 앱이 죽어도 반쯤 쓴 설정 파일이 남지 않는다. 그래도 파일이 깨져 있으면 기본값으로 시작한다.

범위 외였던 영어 화면을 넣다

설계 문서의 범위 외 목록에는 "다국어(영어 UI)"가 있었다. 홈페이지 공유도구 페이지에 올리기로 하면서 이 판단을 바꿨다. 홈페이지는 한/영 두 언어로 운영하는데, 영문 페이지에서 내려받은 사람이 한글 화면을 보게 둘 수는 없었다.

구조는 단순하게 갔다. 문구를 표 하나에 모았다.

public static IReadOnlyDictionary<string, (string Ko, string En)> Table { get; } =
    new Dictionary<string, (string Ko, string En)>(StringComparer.Ordinal)
    {
        ["Common.Copy"] = ("복사", "Copy"),
        ["Tray.QuickSearch"] = ("빠른 검색 열기(_Q)", "_Quick search"),
        ["App.HotkeyFailed"] = ("단축키 {0}를 등록하지 못했습니다. ...",
            "Couldn't register the shortcut {0} because ..."),
        // ...
    };

.resx 리소스 파일도 고려했지만, 언어가 둘뿐이고 번역가에게 넘길 일도 없다. 한 줄에 한국어와 영어가 나란히 있으면 빠뜨린 번역이 바로 보이고, 테스트로 검사하기도 쉽다.

XAML에서는 마크업 확장 하나로 쓴다.

<Button Content="{loc:Tr Common.Copy}" />

TrExtension은 문자열을 바로 돌려주지 않고, LocalizationSource.Instance["Common.Copy"]에 대한 바인딩을 돌려준다. 언어가 바뀌면 LocalizationSource가 인덱서 전체 변경(Binding.IndexerName)을 알리고, 화면의 모든 문구가 그 자리에서 바뀐다. 설정에서 English를 고르고 저장하면 다시 시작하지 않아도 관리 창까지 영어가 된다.

영문 빠른 검색 팝업

언어는 Windows 표시 언어가 한국어면 한국어, 그 밖에는 영어다. 설정에서 직접 고를 수도 있다. 여기서 순서 하나가 중요했다. 설정 파일을 DB 초기화보다 먼저 읽는다. 첫 실행 때 넣는 예시 스니펫과 DB 오류 메시지가 그 언어를 따라야 하기 때문이다. 영문 샘플은 제목뿐 아니라 변수명도 영어({{container}})로 넣고, 5편에서 말한 고정 GUID도 언어별로 나눴다.

테스트도 12개 추가했다.

  • 모든 키에 한국어와 영어가 다 있는가
  • 영어 문구에 한글이 섞여 있지 않은가
  • {0} 같은 자리표시자 개수가 두 언어에서 같은가
  • 언어별 샘플이 그 언어로 들어가는가

Localizer는 전역 상태라 언어를 바꾸는 테스트끼리 병렬로 돌면 서로 간섭한다. 이런 테스트는 xUnit 컬렉션 하나로 묶어 병렬 실행을 껐다. 로그 메시지는 개발자가 읽는 것이라 한국어 그대로 두었다.

범위 외 기능을 이렇게 짧은 시간에 넣을 수 있었던 것은 구조 덕분이다. 1차부터 화면 로직은 ViewModel에, 화면 구성은 XAML에 나눠 두었기 때문에 문자열을 바꿀 곳이 정해져 있었다. 화면 문자열이 코드 곳곳에 흩어져 있었다면 영어 화면은 2차로 미뤘을 것이다.

로그와 전역 예외 처리

배포하면 사용자의 PC에서 일어난 일을 볼 방법이 로그뿐이다. %LOCALAPPDATA%\Cliply\logs\cliply-yyyyMMdd.log에 Information 이상을 남기고, 7일 지난 파일은 시작할 때 지운다. 로그 공급자는 직접 짠 100줄 정도의 파일 로거다. 로그 하나 때문에 라이브러리를 추가하지 않았다.

처리되지 않은 예외는 세 곳에서 받는다.

이벤트 처리
DispatcherUnhandledException (UI 스레드) 로그 + 안내 메시지, 앱은 계속 실행
AppDomain.UnhandledException 로그
TaskScheduler.UnobservedTaskException 로그, 관찰됨 처리

트레이 앱이 예외 하나로 조용히 사라지면 사용자는 단축키가 왜 안 되는지 모른다. UI 스레드 예외는 메시지를 보여 주고 계속 살아 있게 했다. 같은 오류가 연달아 나도 대화상자는 하나만 뜨게 막았다.

단일 exe: 155.6MB → 70.3MB, 그 대가

받는 사람 PC에 .NET 8 런타임이 있다고 가정할 수 없다. 그래서 런타임을 포함한 self-contained 단일 exe로 게시한다.

dotnet publish src/Cliply.App -c Release -r win-x64 --self-contained true
  -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish

WPF는 트리밍을 지원하지 않아 크기가 커진다. 처음 게시한 exe는 155.6MB였다. 스니펫 노트 앱으로는 너무 크다. 단일 파일 압축을 켰다.

<!-- 단일 exe 배포 시 내부 파일 압축 (self-contained 전용 옵션) -->
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true' And '$(SelfContained)' == 'true'">
  <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>

EnableCompressionInSingleFile은 self-contained 단일 파일에서만 쓸 수 있어서 조건을 걸었다. 일반 빌드나 dotnet run에는 영향이 없다. 결과는 70.3MB, zip으로는 약 62MB다.

공짜는 아니다. 압축된 어셈블리를 실행할 때 풀어야 하니 시작이 느려진다. 직접 재 봤다.

트레이·단축키 준비까지 압축 전 압축 후
이후 실행 1.53초 1.78초
첫 실행 1.67초 2.72초

시작이 0.25초(첫 실행은 약 1초) 늦어진다. 하지만 Cliply는 PC를 켤 때 한 번 시작해서 하루 종일 트레이에 있는 앱이다. 중요한 숫자는 시작 시간이 아니라 팝업 표시 시간이고, 이건 압축과 무관하게 그대로였다. 다운로드 크기가 절반이 되는 쪽을 택했다.

배포 패키지와 홈페이지

배포는 스크립트 하나(scripts\package.ps1)로 만든다.

  1. 단일 exe 게시
  2. Cliply.exe + 사용설명서.txt + User Guide.txt를 Cliply-<버전>-win-x64.zip으로 묶기
  3. SHA-256 해시 파일 생성

버전은 Directory.Build.props의 <Version> 하나에서 읽는다. 설명서는 메모장에서 한글이 깨지지 않게 UTF-8 BOM과 CRLF로 바꿔 넣고, zip 안의 한글 파일명은 UTF-8로 인코딩한다. 실행 중인 Cliply가 publish\Cliply.exe를 잡고 있으면 게시가 실패하므로 스크립트가 먼저 확인한다.

exe에는 코드 서명이 없다. 그래서 처음 실행하면 SmartScreen이 경고한다. 사용설명서 맨 앞에 zip 속성에서 [차단 해제]하는 방법과 SmartScreen에서 [추가 정보] → [실행]을 누르는 방법을 적었다. 대신 정품인지 확인할 수 있게 exe 파일 속성에 회사·저작권·파일 설명을 넣고, 제품 버전에 git 커밋 해시가 붙지 않게 했다(IncludeSourceRevisionInInformationalVersion=false).

zip과 SHA-256은 애드소프트 홈페이지에 새로 만든 공유도구 > Cliply 페이지에서 받을 수 있다. 페이지에는 한/영 스크린샷과 사용법, 해시값을 같이 올렸다.

연재를 마치며

이 글로 「Cliply 개발기」 연재를 마친다.

  1. 자주 쓰는 명령어를 단축키 한 번으로 — 「Cliply」 개발기
  2. SQLite FTS5 trigram으로 한글·부분 문자열 검색하기
  3. 어느 창에서든 단축키로 뜨는 WPF 검색 팝업
  4. {{이름:기본값}} 변수 치환 파서와 입력 창
  5. 동기화를 대비한 스키마 — GUID, UpdatedAt, 소프트 삭제와 JSON 가져오기
  6. 트레이 상주 앱 마무리 — 단일 인스턴스, 한/영 전환, 70MB 단일 exe (이 글)

돌아보면 이 프로젝트에서 가장 잘한 일은 코드를 쓰기 전에 설계 문서를 쓴 것이다. 성능 목표를 숫자로 적어 둔 덕에 첫 호출 82ms를 측정하고 줄일 수 있었고, 범위 외 목록 덕에 1차를 끝낼 수 있었다. 아직 없는 동기화를 위해 넣은 GUID와 UpdatedAt은 가져오기에서 먼저 쓸모를 증명했다. 반대로 범위 외에 있던 영어 화면은 상황이 바뀌자 범위로 끌어왔다. 계획은 지키라고 있는 것이지만, 바꿀 때는 이유를 적고 바꾸면 된다.

2차 목록에는 서버 동기화, 복사 후 자동 붙여넣기, 구문 강조, 자동 업데이트가 남아 있다. 동기화를 만들게 되면 그때 다시 정리하겠다. 여기까지 읽어 주셔서 감사하다.