맥 앞에 앉지 않고 iPad로 iOS 앱을 고친다 — Claude Code 원격 개발과 Ad-hoc OTA 설치 루프

iOS 앱 개발에는 오래된 전제가 하나 있다. 맥 앞에 앉아 있어야 한다는 것. 코드는 Xcode에서 짜고, 기기는 케이블이나 같은 Wi-Fi로 맥에 페어링하고, Run 버튼을 눌러 올린다. 시뮬레이터로 대부분 확인할 수 있지만 SSH 터널이나 키보드 같은 건 결국 진짜 기기에서 만져봐야 안다.
그런데 요즘 내 코드의 대부분은 Claude Code가 짠다. 나는 뭘 원하는지 설명하고, 설계 문서를 읽고 승인하고, 결과를 만져보고 피드백한다. 이 중에 맥 앞에 앉아야만 할 수 있는 일은 하나도 없다. 유일한 예외가 “빌드된 앱을 내 iPad에 넣는 것”이었다.
그 예외를 없앴다. 지금은 사무실 Mac mini는 사무실에 두고, 밖에서 iPad나 iPhone만으로 앱을 고치고 새 빌드를 설치해 테스트한다. 이 글은 그 루프를 어떻게 만들었는지에 대한 기록이다.
무슨 앱인가#
mintOps는 SwiftUI로 만든 SSH 터미널 앱이다. macOS와 iOS를 같이 지원한다. 여러 서버에 탭으로 붙고, 서버에 herdr가 깔려 있으면 워크스페이스·탭·분할 pane·에이전트 목록을 네이티브 UI로 보여주는 전용 모드로 들어간다. herdr는 코딩 에이전트를 pane 안에서 돌리는 터미널 멀티플렉서다. 즉 mintOps는 iPad에서 Claude Code를 부리기 위해 만든 앱이다.
지난 글에서 tailcat을 다루면서 “iPad에서는 안 된다”고 썼다. CLI 도구라 iOS 앱이 없다는 이유였다. 그 뒤로 tailcat의 Go 라이브러리를 C 아카이브로 빌드해 앱에 내장했다. 이제 iPad에서 mintOps를 열면 Tailscale 계정 없이 WireGuard 터널로 사무실 맥에 붙는다. 그 글의 “한계” 항목 하나는 이걸로 지워졌다.
여기서 재미있는 순환이 생긴다. mintOps를 고치는 데 mintOps를 쓴다. iPad의 mintOps로 맥에 붙어, Claude Code에게 mintOps를 고치라고 하고, 새로 빌드된 mintOps를 iPad에 설치한다. 개밥먹기(dogfooding)의 가장 순수한 형태다.
루프 한 바퀴#
전체 흐름은 이렇다.
iPad (mintOps)
│ tailcat 터널 → SSH
▼
사무실 Mac mini M1 (herdr + Claude Code + Xcode)
│ 1. 수정 요청 → intent / spec / plan → 구현 → 테스트
│ 2. scripts/ota-deploy.sh
│ xcodebuild archive → export (Ad-hoc IPA + manifest)
│ rsync → 리눅스 서버 ~/ota/
▼
리눅스 서버 (nginx 컨테이너, Coolify Traefik, cloudflared)
│ https://ota.mintdev.uk/
▼
iPad Safari → "mintOps 설치" → itms-services → 새 빌드 설치
│
└─ 테스트 → 피드백 → 처음으로
한 바퀴에 사람이 하는 일은 세 가지다. 뭘 고칠지 말하고, 설계 문서를 승인하고, 설치해서 만져본다. 나머지는 스크립트와 에이전트가 한다.
1. 붙기 — iPad에서 맥으로#
mintOps 사이드바 하단 프롬프트에 tailcat ssh [email protected]을 넣으면 끝이다. DNS TXT 레코드에서 tailcat 주소를 찾고, DERP 릴레이로 핸드셰이크한 뒤 WireGuard 터널을 맺고, 그 위로 SSH를 건다. 세션 탭에 relay tok · 79 ms가 떴다가 NAT 통과가 되면 direct 192.168.1.210:59970 · 0.3 ms로 바뀐다. 이 부분의 원리는 지난 글에 다 있으니 반복하지 않는다.
맥에 herdr가 있으니 접속하자마자 herdr 모드로 들어간다. 워크스페이스 목록이 뜨고, mintops 워크스페이스를 고르면 어제 열어둔 Claude Code pane이 그대로 있다. herdr가 서버 쪽에서 세션을 잡고 있으니 클라이언트가 끊겨도 에이전트는 계속 돌고, 다시 붙으면 이어서 본다.
2. 요청하기 — 문서 먼저, 코드는 나중#
이 프로젝트의 CLAUDE.md에는 AI-Native SDLC Playbook을 따라 만든 규칙이 있다. 모든 작업은 intent/YYMMDD-slug/ 디렉토리의 문서 세 장에서 시작한다.
intent/
260913-workspace-file-viewer/
intent.md # 무엇을 왜 하려는가 (해결 방법은 쓰지 않는다)
spec.md # 요구사항 + 설계 + 완료 판정 기준
plan.md # 변경 파일, 순서, 위험, 검증 방법
한 단계씩만 간다. intent 없이 spec을 쓰지 않고, 승인된 plan 없이 코드를 쓰지 않는다. 각 문서를 쓴 뒤에는 멈추고 내 승인을 기다린다. iPad에서 하는 일은 대부분 이 문서를 읽고 “좋다” 또는 “이건 아니다”라고 답하는 것이다.
프롬프트는 mintOps의 프롬프트 작성기로 보낸다. 터미널에 직접 타이핑하는 게 아니라 여러 줄을 편하게 쓰고 한 번에 bracketed paste로 넘기는 방식이다. iPad에서 찍은 스크린샷을 첨부하면 SFTP로 올라가고 원격 경로가 프롬프트 끝에 붙는다. “이 화면에서 이 버튼이 이상하다”를 말로 설명하는 것보다 사진 한 장이 빠르다.
문서와 diff를 읽는 건 워크스페이스 파일 뷰어로 한다. 포커스된 pane의 cwd가 속한 git 저장소의 변경 파일 목록, diff, Markdown 렌더링을 iPad에서 그대로 본다. 이 기능 자체가 이 루프를 돌리다가 필요해서 만든 것이다. 터미널 안에서 cat spec.md로 읽는 건 iPad 화면에서 고통스럽다.
구현이 끝나면 에이전트는 “완료”라고 말하기 전에 빌드와 테스트를 직접 돌린다. 유닛 테스트, 실제 SSH 호스트에 붙는 E2E, 그리고 iOS 시뮬레이터에서 도는 UI 테스트. UI 테스트는 스크린샷을 남기고 에이전트가 그 스크린샷을 직접 열어 본다. 레이아웃이 깨졌으면 내가 설치해 보기 전에 자기가 먼저 안다.
3. 빌드와 배포 — 스크립트 하나#
scripts/ota-deploy.sh가 전부 한다. 핵심만 추리면 이렇다.
BUILD="$(date +%y%m%d%H%M)" # 2609141325 처럼 시각이 곧 빌드 번호
xcodebuild archive \
-project mintOps.xcodeproj -scheme mintOps \
-destination 'generic/platform=iOS' -archivePath /tmp/mintOps.xcarchive \
DEVELOPMENT_TEAM="$TEAM" CURRENT_PROJECT_VERSION="$BUILD" \
-allowProvisioningUpdates
xcodebuild -exportArchive \
-archivePath /tmp/mintOps.xcarchive \
-exportOptionsPlist ExportOptions.plist -exportPath ~/ota \
-allowProvisioningUpdates
rsync -a --delete -e "ssh -i ~/.ssh/id_ota_205u -o IdentityAgent=none" \
~/ota/ 205u:ota/
세 단계다. 아카이브, Ad-hoc으로 내보내기, 서버로 올리기. 빌드 번호는 yymmddHHMM이다. 버전 번호를 손으로 올릴 필요가 없고, 설정 화면에 찍힌 숫자만 보면 언제 빌드된 건지 바로 안다.
ExportOptions.plist가 이 루프의 절반이다.
<key>method</key>
<string>release-testing</string>
<key>manifest</key>
<dict>
<key>appURL</key>
<string>https://ota.mintdev.uk/mintOps.ipa</string>
<key>displayImageURL</key>
<string>https://ota.mintdev.uk/icon57.png</string>
<key>fullSizeImageURL</key>
<string>https://ota.mintdev.uk/icon512.png</string>
</dict>
release-testing이 예전 이름으로 Ad-hoc이다. 그리고 manifest 항목을 주면 xcodebuild가 IPA와 함께 manifest.plist를 만들어 준다. 이 파일이 iOS에게 “앱은 여기서 받고, 아이콘은 여기 있다”를 알려주는 명세다. 아이콘 두 장은 앱 아이콘에서 sips로 잘라 같이 올린다.
Ad-hoc 배포가 뭔가 (모르면 열어보기)
iOS 앱을 기기에 넣는 방법은 서명 방식에 따라 크게 네 가지다.
| 방식 | 누가 설치할 수 있나 | 필요한 것 |
|---|---|---|
| Development | Xcode로 페어링한 기기 | 맥 + 케이블/Wi-Fi 페어링 |
| Ad-hoc | 미리 등록한 기기 (UDID) | 등록된 UDID, HTTPS 서버 |
| TestFlight | 초대한 테스터 | App Store Connect 업로드, 처리 시간 |
| App Store | 누구나 | 심사 |
Ad-hoc은 Apple Developer 계정에 기기 UDID를 미리 등록해 두고, 그 기기들에서만 실행되는 provisioning profile로 서명하는 방식이다. 심사도 없고 업로드 처리 시간도 없다. 서명이 끝나는 순간 설치할 수 있다. 대신 등록할 수 있는 기기가 종류별로 연 100대까지이고, profile은 1년마다 갱신해야 한다.
혼자 또는 소수가 쓰는 도구라면 이게 제일 빠르다. TestFlight는 빌드를 올린 뒤 처리가 끝나기를 기다려야 하고, 외부 테스터가 있으면 심사도 거친다. 하루에 몇 번씩 빌드를 올려 만져보는 루프에는 맞지 않는다.
이 프로젝트는 원래 다른 팀으로 서명하고 있었는데 그 팀에서는 기기를 등록할 수 없었다. 회사 팀으로 옮기는 게 첫 단계였다.
4. 서빙 — 정적 파일 여섯 개#
서버 쪽은 놀랄 만큼 단순하다. ~/ota/ 디렉토리에 파일 여섯 개가 있다.
build.txt mintOps 1.0 (2609141325) · 2026-09-14 13:27
icon57.png
icon512.png
index.html
manifest.plist
mintOps.ipa 13 MB
이 디렉토리를 nginx:stable-alpine 컨테이너가 서빙한다. Coolify로 띄웠고, Coolify의 Traefik이 라우팅하고, cloudflared 터널이 ota.mintdev.uk로 밖에 내놓는다. 공유기 포트를 열지 않았고 공인 IP도 없다. Cloudflare 터널이 HTTPS까지 해결해 준다.
HTTPS는 선택이 아니다. iOS는 itms-services로 받는 manifest와 IPA가 반드시 HTTPS여야 설치한다. 자체 서명 인증서도 안 된다. 이 요구를 Cloudflare 터널이 공짜로 채워 준다.
index.html은 버튼 하나다.
<a href="itms-services://?action=download-manifest&url=https://ota.mintdev.uk/manifest.plist">
mintOps 설치
</a>
<p id="v"></p>
<script>
fetch('build.txt', {cache: 'no-store'})
.then(r => r.ok ? r.text() : '')
.then(t => { document.getElementById('v').textContent = t });
</script>
버튼 아래에 build.txt를 읽어 현재 서버에 있는 빌드 번호를 찍는다. no-store로 받아야 한다. 설치하기 전에 “내가 설치하려는 게 방금 올라간 빌드가 맞나”를 여기서 확인한다.
itms-services 가 뭔가 (모르면 열어보기)
itms-services://는 iOS가 알아듣는 특수한 URL 스킴이다. Safari에서 이 링크를 누르면 브라우저가 아니라 시스템이 받아서, 지정된 manifest.plist를 내려받고 그 안에 적힌 IPA 주소에서 앱을 받아 설치한다. 원래 기업 내부 배포(Enterprise)용으로 만들어진 경로인데 Ad-hoc 서명된 앱도 같은 방식으로 설치된다.
manifest에는 앱의 번들 ID, 버전, IPA 주소, 아이콘 주소가 들어 있다. xcodebuild가 ExportOptions.plist의 manifest 항목을 보고 자동으로 만들어 주니 손으로 쓸 일은 없다.
같은 번들 ID의 앱이 이미 깔려 있으면 덮어쓴다. 앱 데이터는 유지된다. 저장해 둔 SSH 접속 정보와 Keychain의 비밀번호가 새 빌드에서도 그대로 있는 이유다.
5. 설치하고 테스트하기#
iPad Safari에서 ota.mintdev.uk를 연다. 아이콘, “mintOps 설치” 버튼, 그 아래 빌드 번호. 버튼을 누르면 “설치하려고 함” 팝업이 뜨고, 홈 화면의 mintOps 아이콘이 잠시 흐려졌다가 돌아온다. 끝이다. 케이블도 Xcode도 없다.

iPhone에서 본 설치 페이지. 왼쪽이 페이지 전체이고, 오른쪽이 버튼을 누른 직후다. 버튼 아래 숫자가 서버에 올라가 있는 빌드 번호다.
설치 후 설정 → About에서 1.0 (2609141325)를 확인한다. build.txt의 숫자와 같으면 맞는 빌드다. 이게 별것 아닌 것 같지만, 아래 삽질 기록에 나오듯 이 숫자가 없을 때는 내가 뭘 테스트하고 있는지조차 확실하지 않았다.
한 가지 묘한 순간이 있다. 설치하는 앱이 지금 세션을 열고 있는 바로 그 앱이라는 점이다. 새 빌드가 깔리면 iOS가 앱을 교체하니 mintOps는 종료되고 SSH 세션도 끊긴다. 그런데 herdr가 서버 쪽에서 pane을 잡고 있어서 Claude Code는 아무 일 없다는 듯 계속 돌고 있다. 새 mintOps를 열고 다시 붙으면 방금 전 화면이 그대로다. 이 구조가 아니었으면 빌드 설치할 때마다 에이전트 세션이 날아갔을 것이다.
테스트하고, 마음에 안 드는 게 있으면 스크린샷을 찍어 프롬프트에 붙이고, 다시 요청한다. OTA를 만든 9월 10일부터 나흘 동안 이 앱에 들어간 커밋이 열아홉 개다. 프롬프트 작성기 개선, Tailcat 로그, 파일 뷰어, 웹 미리보기, 오픈소스 크레딧 화면. 대부분이 이 루프로 만들어지고 이 루프로 검증됐다.
삽질 기록#
1Password SSH 에이전트가 배포를 멈춰 세웠다#
첫 번째 배포는 rsync에서 멈췄다. 에러도 없이 그냥 기다렸다.
원인은 SSH 에이전트였다. 내 맥의 SSH 키는 전부 1Password가 관리하고, 서명할 때마다 화면에 승인 대화상자를 띄운다. 맥 앞에 앉아 있을 때는 편하다. 그런데 이 배포는 iPad에서 SSH로 들어와 헤드리스로 돌리는 것이다. 대화상자는 맥 화면에 떴고, 누를 사람은 거기 없었다.
해결은 배포 전용 키를 하나 만드는 것이었다. 암호 없는 ed25519 키를 만들어 서버에 한 번 등록하고, 스크립트가 그 키만 쓰게 했다.
ssh-copy-id -i ~/.ssh/id_ota_205u 205u
OTA_SSH="ssh -o BatchMode=yes -i $OTA_KEY -o IdentitiesOnly=yes -o IdentityAgent=none"
IdentityAgent=none이 핵심이다. 이게 없으면 ssh가 파일 키를 시도하기 전에 에이전트한테 먼저 물어보고, 1Password는 또 대화상자를 띄운다. BatchMode=yes는 그래도 뭔가 물어보려 하면 즉시 실패하게 만든다. 멈추는 것보다 실패하는 게 낫다.
이 키는 rsync로 ~/ota/ 하나에 쓰는 것 말고는 할 수 있는 게 없어서 암호 없이 두는 걸 감수했다.
”성공”이라고 말하면서 이전 빌드를 서빙하고 있었다#
rsync 출력에서 잡음을 지우려고 grep -v를 파이프로 물려 놨는데, 그 바람에 rsync의 종료 코드가 사라졌다. 파이프라인의 종료 코드는 마지막 명령의 것이다. rsync가 실패해도 grep이 정상 종료하면 스크립트는 다음 줄로 가서 ✓ mintOps 1.0 (…)을 찍었다. 서버에는 이전 빌드가 그대로 있는데.
iPad에서 설치해 보고 “고친 게 왜 안 보이지”를 몇 번 겪고 나서야 알았다. 빌드 번호를 설정 화면에 표시하기 시작한 게 이 무렵이다. 그 전까지 About에는 하드코딩된 0.1.0이 찍혀 있었고, 어느 빌드를 보고 있는지 알 방법이 없었다.
고친 뒤에는 zsh의 pipestatus로 rsync의 코드를 따로 꺼내 검사한다.
rsync … | grep -v "잡음패턴" || true
rc=${pipestatus[1]}
(( rc == 0 )) || { echo "✗ rsync failed (exit $rc); the remote still has the previous build"; exit $rc; }
그러고 나니 이번엔 반대로 성공했는데 실패로 나왔다. rsync가 조용히 성공하면 출력이 한 줄도 없고, 입력이 없는 grep -v는 종료 코드 1을 돌려준다. set -e 아래에서 그게 스크립트를 죽였다. || true로 grep의 코드를 무시하고 rsync의 것만 보게 해서 끝났다. 셸 스크립트의 파이프와 set -e는 한 줄 고칠 때마다 다른 데가 터진다.
Xcode가 외장 SSD에 있다#
Mac mini M1은 내장 디스크가 작아서 Xcode를 외장 Samsung T3에 뒀다. xcodebuild는 기본으로 /Applications/Xcode.app을 찾으니 DEVELOPER_DIR를 매번 줘야 한다. 스크립트 맨 위에 박아 두고, CLAUDE.md의 검증 명령어 예시에도 적어 뒀다. 에이전트가 “Xcode를 못 찾는다”며 헤매는 걸 한 번 보고 난 뒤다.
서명 팀을 회사 팀으로#
Ad-hoc은 기기 UDID를 등록해야 하는데, 원래 쓰던 팀에서는 그게 안 됐다. 프로젝트의 DEVELOPMENT_TEAM을 회사 팀으로 바꾸고, iPad와 iPhone의 UDID를 개발자 포털에 넣었다. -allowProvisioningUpdates를 주면 xcodebuild가 profile 생성과 갱신을 알아서 한다. Xcode GUI를 열 일이 없다.
한계#
빌드 시간. M1 Mac mini에서 아카이브부터 export까지 몇 분 걸린다. Tailcat의 Go 브리지도 같이 빌드한다. 루프 한 바퀴의 대부분이 이 시간이다. 에이전트가 빌드를 돌리는 동안 나는 다른 걸 하면 되지만, “한 줄 고치고 바로 보기”는 안 된다.
디버거가 없다. Xcode 디버거로 브레이크포인트를 걸 수 없다. 로그와 재현 설명으로 에이전트에게 넘기고, 에이전트가 시뮬레이터에서 재현해 고친다. 지금까지는 그걸로 됐지만, 기기에서만 나는 문제를 만나면 결국 맥 앞으로 가야 할 것이다.
맥이 살아 있어야 한다. 지난 글에 썼듯 이 Mac mini는 FileVault가 켜져 있다. 재부팅되면 누군가 사무실에서 잠금을 풀기 전까지 아무것도 안 뜬다. 정전 한 번이면 루프 전체가 멈춘다.
Ad-hoc의 숫자들. 기기 종류별 연 100대, profile 1년. 혼자 쓰기엔 넉넉하지만 남에게 나눠 주는 용도는 아니다. 그건 TestFlight의 일이다.
macOS 빌드는 이 루프 밖이다. 같은 코드가 맥 앱으로도 빌드되지만 그건 맥에서 실행해 봐야 한다. RustDesk로 화면을 원격으로 보든 맥 앞에 앉든, iPad만으로는 안 된다.
정리#
iOS 앱을 고치는 데 맥 앞에 앉아야 하는 이유는 하나였다. 빌드된 앱을 기기에 넣는 것. 그걸 스크립트 하나와 정적 파일 여섯 개, nginx 컨테이너, Cloudflare 터널로 바꿨다.
지금 루프는 이렇다. iPad의 mintOps가 tailcat 터널로 사무실 맥에 붙는다. herdr 안의 Claude Code에게 뭘 고칠지 말하면 intent, spec, plan 문서를 쓰고 내 승인을 받아 구현하고 테스트한다. scripts/ota-deploy.sh가 Ad-hoc IPA를 만들어 서버로 올린다. Safari에서 버튼 하나로 설치한다. 새 앱이 뜨면 방금 그 세션이 그대로 있다.
배우고 남은 건 세 가지다. 헤드리스 배포는 대화상자를 띄우는 모든 것과 싸운다. 1Password 에이전트가 그랬다. 스크립트가 “성공”이라고 말하는 근거를 의심하라. 파이프 하나가 종료 코드를 삼키고 있었다. 그리고 빌드 번호를 눈에 보이게 하라. 내가 뭘 테스트하고 있는지 모르면 테스트가 아니다.
댓글
댓글을 불러오는 중…
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.