macOS 메뉴바 앱 첫 배포기 — 뒤엎은 출시 전략
메뉴바 클립보드 앱 stash 만들기· 3 / 3
- 1 — Claude Design 목업을 SwiftUI 네이티브 앱으로
- 2 — 클립 하나에 33MB — 썸네일 메모리를 99.4% 줄이기
- 3 — macOS 메뉴바 앱 첫 배포기 — 뒤엎은 출시 전략
stash는 macOS 메뉴바에 상주하는 클립보드 매니저다. 혼자 쓰려고 만든 앱인데, v1.0을 기점으로 처음 외부에 공개하기로 했다. 문제는 두 가지였다. Apple Developer 계정이 없어 코드사인 없이 단순 추출 방식(ExportOptions.plist의 method=mac-application)으로 배포해야 했고, 리포지토리 안에는 .project/ — 태스크와 결정 사유, 인터뷰 기록이 담긴 내부 운영 문서 전체 — 가 그대로 들어 있었다. 이 내부 자료를 노출하지 않으면서 배포 파이프라인을 완성해야 하는 상황이라, dmg 빌드부터 브랜치 전략까지 실행 전에 문서로 촘촘히 박아뒀다.
그런데 그렇게 세운 전략을 실행 직전에 완전히 갈아엎었다. 이 글의 알맹이는 dmg를 어떻게 구웠는지가 아니라, 그 전략을 왜 뒤엎었는지다.
dmg 빌드, 짧게
Scripts/build-dmg.sh는 앱을 빌드해 dmg 파일로 묶는 스크립트다. 알맹이가 아니니 안전장치와 단계만 짚는다.
스크립트 첫 줄은 set -euo pipefail이다. 주석에 이유가 적혀 있다.
안 박으면 중간 명령 실패해도 다음 명령이 계속 실행돼 반쪽짜리 dmg가 나옴.
버전의 단일 진실 소스는 Info.plist의 앱 버전 필드이고, 빌드 번호는 사람이 손으로 올리지 않는다 — 커밋 수를 그대로 주입한다.
빌드는 4단계다.
- 정리 — 이전 산출물을 지우고 시작한다(멱등 빌드 보장).
- 아카이브 — Release 아카이브를 만든다.
- 추출 — 아카이브에서
.app을 뽑는다. - 패키징 — 임시 dmg를 만들어 마운트하고
/Applications심볼릭 링크를 추가한 뒤, 압축된 read-only dmg로 변환한다.
첫 실행에서는 마운트 해제가 “resource busy”로 실패했다 — Spotlight 인덱싱 등이 마운트 포인트를 잠근 탓이다. 동기화와 약간의 대기, 강제 옵션을 더하자 다음 실행부터 통과했다.
산출물은 **4.7MB, 압축률 97.7%**다.
전략 초안: orphan으로 이사하기
목적은 하나, .project/를 공개 리포에서 완전히 차단하는 것이었다. 첫 초안은 main 브랜치 자체를 역사 없는 브랜치로 새로 만드는 방법을 택했다.
git checkout --orphan은 기존 커밋 히스토리와 연결되지 않은 새 브랜치를 만드는 명령이다. 즉, 커밋 그래프에 부모 없는 루트가 하나 더 생기는 셈이다.
이 전략은 7개 Phase로 상세히 스크립트화됐다. 핵심만 추리면 이렇다.
git checkout --orphan main-fresh
git rm -rf .
git checkout dev -- Sources/ Tests/ Scripts/ ...
git commit -m "Release v1.0.0"
git branch -D main # 기존 main 파괴적 삭제물리적으로 보면 이사와 같은 절차다. 낡은 집을 통째로 비우고, 공개해도 되는 짐만 골라 새 집으로 옮긴 뒤, 이전 집 열쇠를 아예 부숴버리는 방식이다. 짐을 하나하나 고르는 손이 많이 가는 이사다.
실행까지는 갔다 — 태그와 해시
이 절차 그대로 로컬에서 실행했다. v1.0.0 태그를 main HEAD에 박고 dmg의 sha256 해시를 뽑아 별도 파일에 기록해뒀다 — 미래에 배포 매니저(brew Formula)가 인용할 걸 대비한 것이다.
gh CLI 인증, SSH push, release 생성처럼 자동으로 진행할 수 없는 영역은 별도 문서에 실행 순서로 남기고, 직접 실행하도록 분리했다.
뒤엎다 — 커튼을 치는 쪽이 더 쌌다
2026-05-30, 이 orphan 절차를 통째로 폐기했다. 대신 내부 운영 자료(.project/ 등)를 .gitignore에 등록해 git 추적 자체를 끊었다. 이사할 필요가 없었다 — 방을 통째로 비우고 짐을 골라 나르는 대신, 그냥 그 방에 커튼을 치면 됐다. 방(파일)은 그 자리에 그대로 있지만 추적(공개)에서만 빠진다.
두 전략을 나란히 놓으면 무엇을 왜 뒤엎었는지가 분명해진다.
| 전략 | 핵심 동작 | main의 정체 | 트레이드오프 |
|---|---|---|---|
| orphan + 선별 카피(초안, 폐기) | 새 루트 브랜치를 만들고 공개 파일만 골라 담은 뒤 기존 main을 파괴 | 히스토리 없는 새 브랜치 | 완전 차단이지만 출시마다 파일을 손으로 골라야 함 |
| gitignore 추적 중단 + merge(실제 채택) | 내부 자료를 .gitignore에 등록해 추적만 끊고 dev→main을 그냥 merge | dev와 연결된 일반 브랜치 | 파일은 막지만 커밋 메시지는 main에 공유됨 — 수용 |
내부 운영 자료를
.gitignore로 git 추적 중단. 추적 안 되는 파일은 어떤 방식으로도 안 올라가므로, orphan/카피 없이dev→mainmerge만으로 노출 차단 달성. 절차 대폭 단순화.
즉, 애초 목표는 “내부 자료 노출 차단”이었지 orphan 자체가 목표는 아니었다. 더 단순한 수단이 같은 목표를 달성한다는 걸 깨닫자 미련 없이 갈아엎었다.
merge 방식은
dev의 커밋 히스토리(메시지)가 main에 공유된다. 단 파일 자체(.project/등)는.gitignore라 노출되지 않는다. 커밋 메시지 공개는 수용 — 내부 파일만 차단하면 충분하다는 판단이다.
v1.0.1은 실제로 이 merge 방식으로 나갔다 — 전환이 문서에만 그친 게 아니라 바로 다음 출시에 적용됐다. 릴리스 노트는 한글과 영문을 병기했는데, 버그 수정과 안정성 개선을 알리는 내용이었다.
문서도 낡는다 — 정정 두 건
전략 문서 RELEASE-GUIDE.md는 최초 작성 이후 두 번 더 손을 봤다. 그중 한 번은 하루 만에 두 가지를 함께 고쳤다.
정정 1: main 전략 자체가 사흘 낡아 있었다
문서는 여전히 orphan + 선별 카피를 설명하고 있었는데, 실제 코드는 사흘 전부터 .gitignore + merge 방식으로 바뀐 뒤였다. 문서 전반(main 정책·merge 흐름·전환 사유)을 실제와 맞춰 갱신했다.
정정 2: 이름 대문자화, 그러나 전부는 아니다
앱 이름이 소문자에서 대문자 시작(Stash)으로 바뀐 게 dmg 파일명과 release 제목에 반영이 안 돼 있던 걸 고쳤다. 다만 brew cask 토큰과 GitHub 리포명은 의도적으로 소문자 그대로 뒀다 — 관례를 따라야 하는 부분과 표시 이름을 따라야 하는 부분을 구분한 것이다.
이후 모든 출시가 따르는 흐름
이 시행착오 끝에 정착한 흐름은 여섯 단계로 줄었다.
- 마케팅 버전을 확인한다. PATCH/MINOR/MAJOR 판단은 사람 몫, 자동 변경 안 함.
Scripts/build-dmg.sh를 실행한다.git checkout main && git merge dev— push 전git log --oneline -5와git status로 의도한 것만 올라가는지 확인한다.shasum -a 256으로 해시를 뽑아 기록한다.- Release Notes를 한·영 병기로 작성한다.
- push + 태그 +
gh release create로 배포한 뒤 리포 페이지에서 육안 검증한다.
배포 전략을 아무리 상세히 — 7개 Phase까지 — 문서화해도, 실행 과정에서 더 단순한 방법을 발견하면 미련 없이 갈아엎는 게 맞다. orphan + 선별 카피는 “내부 자료 노출 차단”이라는 목표를 위한 수단 중 하나였지, 목표 자체가 아니었다. 다만 갈아엎은 계획을 지우지는 않았다 — 폐기 라벨을 붙여 남겨둔 것처럼, 안 쓰기로 한 이유도 함께 기록해야 나중에 같은 삽질을 반복하지 않는다.