<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>진재명의 블로그</title><description>만들면서 알게 된 것들을 적어 둡니다. iOS와 소프트웨어, 그리고 덜어내는 일에 대한 기술 노트와 짧은 생각. 부산에서.</description><link>https://jaemyeong.com/en/blog/</link><language>en</language><atom:link href="https://jaemyeong.com/en/blog/rss.xml" rel="self" type="application/rss+xml"/><lastBuildDate>Sun, 13 Sep 2026 15:00:00 GMT</lastBuildDate><image><url>https://jaemyeong.com/_blog/og/default.png</url><title>진재명의 블로그</title><link>https://jaemyeong.com/en/blog/</link></image><item><title>macOS 27에서 끊긴 Time Capsule 백업, TimeCapsuleSMB로 되살린 기록</title><link>https://jaemyeong.com/ko/blog/macos-27-time-capsule-timecapsulesmb/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/macos-27-time-capsule-timecapsulesmb/</guid><description>macOS 27은 AFP 클라이언트를 제거해 Time Capsule을 Time Machine 대상으로 쓸 수 없습니다. 4세대 Time Capsule에 TimeCapsuleSMB로 SMB3 Samba를 올린 절차와, 설치 전에 꺼야 할 NBNS·텔레메트리 설정을 정리합니다.</description><pubDate>Sun, 13 Sep 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;집에는 오래 써 온 4세대 AirPort Time Capsule이 있습니다. 네트워크에 붙은 하드디스크라서, Mac이 같은 네트워크에 있기만 하면 Time Machine 백업이 알아서 돌아갔습니다. 이 방식이 macOS 27부터 공식적으로 끝났습니다. Apple은 Time Machine 지원 문서에서 Time Capsule 백업은 AFP (Apple Filing Protocol - Apple이 Mac 파일 공유용으로 만든 네트워크 파일 프로토콜)를 쓰기 때문에 macOS 27 이상에서 지원하지 않는다고 적었습니다.&lt;/p&gt;
&lt;p&gt;설정 몇 개로 우회할 수 있는 문제가 아니었습니다. macOS 27.0에는 AFP 클라이언트 자체가 없습니다. Time Capsule은 기본 펌웨어에서 AFP와, SMB (Server Message Block - Windows 파일 공유로 널리 쓰였고 지금은 macOS 파일 공유에도 쓰이는 네트워크 파일 프로토콜)의 오래된 첫 버전인 SMB1만 제공합니다. 기기를 관리하던 AirPort Utility도 새로 설치한 macOS에서는 빠졌습니다. Apple 문서가 대안으로 드는 것은 외장 저장장치, Time Machine을 지원하는 NAS (Network Attached Storage - 네트워크에 연결해 쓰는 저장장치), 다른 Mac의 공유 폴더입니다.&lt;/p&gt;
&lt;p&gt;저는 기기를 정리하기 전에 커뮤니티 프로젝트 TimeCapsuleSMB를 써 봤습니다. Time Capsule 안에서 Samba 4 계열을 직접 실행해, SMB3로 Time Machine 백업을 받게 만드는 도구입니다. 기본 설정으로 설치한 뒤 함께 설치된 바이너리와 이슈를 살펴보다가, 그대로 두면 안 되겠다고 판단한 텔레메트리 동작을 알게 됐습니다. 그래서 설정을 바꿔 다시 배포했습니다. 기기에서 Samba가 올라오는 것과, macOS 27의 Mac mini에서 Time Machine 첫 백업이 끝나는 것까지 확인했습니다.&lt;/p&gt;
&lt;p&gt;이 글은 원인과 재현 조건, 설치 전에 꺼야 할 설정, 4세대 기기 기준 설치 절차와 부트 훅, 확인한 방법을 순서대로 정리합니다. 5세대 기기는 문서 기준으로 짧게만 다룹니다. 아직 Time Capsule로 백업하고 있다면 macOS 27로 올리기 전에 읽어 볼 만한 내용입니다.&lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;h3&gt;Apple은 AFP를 두 단계로 걷어냈습니다&lt;/h3&gt;
&lt;p&gt;첫 단계는 폐기 예고였습니다. Apple은 macOS Sequoia 15.5의 기업용 릴리스 노트에 다음 문장을 넣었습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Apple Filing Protocol (AFP) client is deprecated and will be removed in a future version of macOS.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;두 번째 단계는 지원 종료 명시입니다. 2026년 7월 7일 게시된 Time Machine 백업 디스크 지원 문서는 NAS를 AFP로 쓰는 백업을 권장하지 않으며 macOS 27 이상에서 지원하지 않는다고 적습니다. AirPort Extreme과 Time Capsule 항목에도 같은 이유가 붙었습니다. 두 제품은 AFP를 쓰기 때문에 더 이상 권장하지 않고, AFP는 macOS 27 이상에서 지원되지 않는다는 설명입니다.&lt;/p&gt;
&lt;p&gt;Apple이 Time Capsule을 AFP 서버로 본다는 점은 이전 문서에도 드러납니다. macOS Sequoia 15 릴리스 노트는 &quot;Time Capsule or other AFP file server&quot;에서 새 암호화 백업을 만들 때 실패하던 문제를 고쳤다고 적었습니다.&lt;/p&gt;
&lt;h3&gt;Time Capsule의 SMB로는 Time Machine 백업을 받을 수 없습니다&lt;/h3&gt;
&lt;p&gt;macOS가 기본으로 쓰는 SMB가 있으니 AFP 대신 쓰면 될 것 같지만, 그렇지 않습니다. TimeCapsuleSMB README와 The Eclectic Light Company는 Time Capsule이 기본적으로 AFP와 SMB1만 지원한다고 설명합니다.&lt;/p&gt;
&lt;p&gt;SMB로 Time Machine 백업을 받으려면 서버가 Apple 전용 기능을 제공해야 합니다. Samba에서는 &lt;code&gt;vfs_fruit&lt;/code&gt; 모듈이 이 역할을 맡습니다. Samba 테스트 설정에 있는 Time Machine 공유 정의를 일부만 옮기면 다음과 같습니다. 이 글의 절차에서 직접 고칠 파일은 아니고, 서버에 무엇이 필요한지 보여 주는 예시입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[vfs_fruit_timemachine]
	vfs objects = fruit streams_xattr acl_xattr xattr_tdb
	fruit:time machine = yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fruit:time machine = yes&lt;/code&gt;를 켠 공유는 Bonjour에 &lt;code&gt;_adisk._tcp&lt;/code&gt; 서비스로 광고되고, macOS는 이 광고를 보고 백업 대상으로 인식합니다. &lt;code&gt;vfs_fruit&lt;/code&gt;는 macOS와 SMB2의 &lt;code&gt;AAPL&lt;/code&gt; 확장을 협상해 Finder 메타데이터 같은 Apple 전용 정보를 처리합니다. 이 확장은 SMB2 이상에서 동작하므로, SMB1만 제공하는 기본 펌웨어로는 조건을 맞출 방법이 없습니다.&lt;/p&gt;
&lt;h3&gt;TLS 1.2 요건 때문은 아닙니다&lt;/h3&gt;
&lt;p&gt;MacRumors 같은 일부 기사는 AFP 제거와 함께 macOS 27의 TLS (Transport Layer Security - 네트워크 연결을 암호화하는 프로토콜) 1.2 최소 요건도 Time Capsule이 넘지 못하는 조건으로 듭니다. macOS 27 RC (Release Candidate - 정식 출시 직전 빌드) 릴리스 노트를 보면 이 요건은 기기 관리와 자동 기기 등록, 구성 프로파일 설치, 앱 설치, 소프트웨어 업데이트에 관여하는 일부 시스템 프로세스에 적용됩니다. 적용 대상에 Time Machine은 없으므로, Time Capsule 백업이 끊긴 이유는 AFP 제거로 보는 것이 맞습니다.&lt;/p&gt;
&lt;p&gt;같은 릴리스 노트에는 AFP 제거 항목이 따로 없습니다. 제거 사실은 앞의 지원 문서와, 실제 macOS 27.0에서 AFP 실행 파일이 사라진 상태로 확인해야 합니다.&lt;/p&gt;
&lt;h2&gt;재현 조건&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;macOS 27.0이 설치된 Mac&lt;/li&gt;
&lt;li&gt;TimeCapsuleSMB를 설치하기 전, 기본 펌웨어 상태의 AirPort Time Capsule&lt;/li&gt;
&lt;li&gt;Time Machine이나 Finder에서 AFP로 이 기기에 접속하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;macOS 27.0에 AFP 클라이언트가 없다는 사실은 터미널에서 바로 확인할 수 있습니다. &lt;code&gt;mount -t afp&lt;/code&gt;는 AFP 공유를 마운트하는 명령입니다. 아래는 제 Mac(macOS 27.0)에서 존재하지 않는 주소로 실행한 결과이고, 마운트할 디렉터리 경로만 &lt;code&gt;&amp;lt;dir&amp;gt;&lt;/code&gt;로 바꿨습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ mount -t afp //example.invalid/share &amp;lt;dir&amp;gt;
mount: exec /Library/Filesystems/afp.fs/Contents/Resources/mount_afp for &amp;lt;dir&amp;gt;: No such file or directory
mount: &amp;lt;dir&amp;gt; failed with 72
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 오류는 네트워크 연결 실패가 아닙니다. 주소를 찾기도 전에 &lt;code&gt;mount_afp&lt;/code&gt; 실행 파일이 없어서 끝납니다. 같은 Mac에서 &lt;code&gt;/sbin/mount_afp&lt;/code&gt;, &lt;code&gt;/System/Library/Filesystems&lt;/code&gt; 안의 AFP 번들, 네트워크 파일시스템 플러그인 목록의 AFP 항목이 모두 없는 것을 확인했습니다. 반면 &lt;code&gt;mount_smbfs&lt;/code&gt;는 그대로 있습니다.&lt;/p&gt;
&lt;p&gt;매뉴얼에는 흔적이 남아 있습니다. &lt;code&gt;man tmutil&lt;/code&gt;의 &lt;code&gt;setdestination&lt;/code&gt; 설명은 아직 &quot;AFP share, or SMB share&quot;를 대상으로 적고 있습니다. 매뉴얼 문구가 남아 있다고 AFP 백업이 되는 것은 아닙니다.&lt;/p&gt;
&lt;p&gt;AirPort Utility도 조건에 따라 다릅니다. macOS 27 RC 릴리스 노트는 AirPort Utility가 새로 설치한 macOS에는 포함되지 않고, 이미 설치된 상태에서 업데이트하면 남지만 macOS 27부터 동작을 보장하지 않는다고 적습니다. 제 Mac은 macOS 26.6.2에서 업데이트했기 때문에 AirPort Utility 6.3.9가 남아 있었습니다.&lt;/p&gt;
&lt;h2&gt;해결 방법&lt;/h2&gt;
&lt;h3&gt;선택지부터 비교했습니다&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;선택지&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;한계&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;외장 저장장치를 Mac에 직접 연결&lt;/td&gt;
&lt;td&gt;Apple 문서가 가장 흔한 구성으로 소개하고, 네트워크 문제가 없습니다&lt;/td&gt;
&lt;td&gt;노트북은 연결해 둘 때만 백업됩니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMB로 Time Machine을 지원하는 NAS&lt;/td&gt;
&lt;td&gt;Apple 문서가 제시하는 방법이고, 여러 Mac이 함께 씁니다&lt;/td&gt;
&lt;td&gt;새 장비 비용이 듭니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다른 Mac의 공유 폴더&lt;/td&gt;
&lt;td&gt;가진 장비를 재사용합니다&lt;/td&gt;
&lt;td&gt;Apple 문서는 두 Mac이 모두 macOS 11 이상일 때만 권장합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;특정 Mac을 macOS 26에 묶어 두기&lt;/td&gt;
&lt;td&gt;당분간 AFP로 계속 씁니다&lt;/td&gt;
&lt;td&gt;폐기가 예고된 경로라서 그 Mac의 보안 업데이트가 끝나면 이어 가기 어렵습니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TimeCapsuleSMB&lt;/td&gt;
&lt;td&gt;기기를 그대로 쓰고, 기존 백업도 옮겨서 이어 쓸 수 있습니다&lt;/td&gt;
&lt;td&gt;비공식 도구이고, 아래에서 설명할 보안상 판단이 필요합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;저는 기기를 그대로 쓰고, 제거 명령으로 설치 파일을 지울 수 있다는 점을 보고 TimeCapsuleSMB를 골랐습니다. 1~4세대에 쓴 펌웨어 부트 훅까지 순정으로 되돌리려면 제거 명령과 별개로 &lt;code&gt;flash --restore&lt;/code&gt;를 써야 합니다. 대신 비공식 도구가 기기 안에서 root 권한으로 돈다는 사실을 받아들여야 했습니다.&lt;/p&gt;
&lt;h3&gt;설치 전에 확인할 것&lt;/h3&gt;
&lt;p&gt;TimeCapsuleSMB는 Time Capsule 안에 서버 프로그램을 설치합니다. 저는 기본 설정으로 먼저 설치했고, 그 뒤에 함께 설치된 바이너리와 이슈를 살펴보면서 아래 내용을 알게 됐습니다. 같은 순서를 밟지 않도록 설치 절차보다 앞에 둡니다. 이 절은 제가 설치한 v2.2.9 기준입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 기기가 heartbeat 실행 파일을 내려받아 실행합니다.&lt;/strong&gt; v2.2.9에서는 NBNS (NetBIOS Name Service - 오래된 Windows 방식 네트워크 탐색에 쓰는 이름 응답 서비스) 응답기인 &lt;code&gt;nbns-advertiser&lt;/code&gt; 바이너리에 heartbeat 기능이 들어 있습니다. 바이너리 문자열을 보면 &lt;code&gt;http://timecapsulesmb.jamesyc.com/downloads/bin/heartbeat4le&lt;/code&gt;와 서명 파일을 HTTP (HyperText Transfer Protocol - 암호화하지 않은 웹 요청 방식)로 내려받고, 서명을 확인한 뒤 실행하는 흐름이 보입니다. 이 기능의 소스는 v2.2.9 태그에 없습니다.&lt;/p&gt;
&lt;p&gt;이슈 #299는 이 동작이 12시간마다 root 권한으로 실행되며 기기 정보를 보낸다고 보고했습니다. 관리자는 텔레메트리이며 설정에서 끌 수 있고 v3.0.0에서 구조를 바꿨다고 답했습니다. 하지만 2026년 9월 14일 기준 v3.0.0은 릴리스되지 않았습니다. 서명을 확인하므로 아무 파일이나 실행되지는 않지만, 무엇을 실행할지는 관리자의 서버와 서명 키가 정합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 앱의 텔레메트리 설정은 Mac 쪽만 끕니다.&lt;/strong&gt; 앱과 CLI (Command Line Interface - 터미널 명령 도구)의 텔레메트리 끄기는 Mac의 &lt;code&gt;.bootstrap&lt;/code&gt; 파일에 &lt;code&gt;TELEMETRY=false&lt;/code&gt;를 쓰고, Mac에서 보내는 사용 이벤트를 멈춥니다. v2.2.9의 배포 코드와 &lt;code&gt;nbns-advertiser&lt;/code&gt; 바이너리 문자열을 보면, 기기로 가는 설정에는 이 값이 들어가지 않습니다. #299에서 관리자가 말한 「설정」이 어느 항목인지는 이슈에 적혀 있지 않습니다. Mac 쪽과 기기 쪽은 따로 꺼야 한다고 보는 편이 안전합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 기기 heartbeat를 멈추려면 NBNS를 끄고 재배포합니다.&lt;/strong&gt; v2.2.9의 배포는 &lt;code&gt;nbns-advertiser&lt;/code&gt;를 항상 하드디스크의 &lt;code&gt;.samba4&lt;/code&gt; 폴더에 올립니다. 하지만 부팅 스크립트는 &lt;code&gt;NBNS_ENABLED=1&lt;/code&gt;일 때만 이 바이너리를 램디스크로 복사해 실행합니다. 네트워크에서 요청이 사라지는 것까지 확인하지는 않았지만, 코드상으로는 NBNS를 끄면 heartbeat 코드가 실행되지 않습니다. 앱에서 기기의 고급 설정 &quot;Enable NBNS&quot;를 끄고 Install/Update를 다시 실행하면 이 값이 0으로 배포됩니다. CLI라면 &lt;code&gt;deploy --no-nbns&lt;/code&gt;를 씁니다.&lt;/p&gt;
&lt;p&gt;대가로 NetBIOS 이름 응답이 사라져, Windows 방식 네트워크 탐색에서는 기기가 보이지 않을 수 있습니다. Mac은 Bonjour로 기기를 찾으므로 NBNS가 꼭 필요하지는 않습니다. 제 기기는 NBNS를 끄고 재배포한 뒤에도 정상 동작했습니다.&lt;/p&gt;
&lt;h3&gt;네트워크와 권한 설정도 확인합니다&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;4. &quot;Bind SMB to LAN Only&quot;를 켭니다.&lt;/strong&gt; 이 설정은 기본으로 꺼져 있습니다. FAQ (Frequently Asked Questions - 자주 묻는 질문 문서)는 꺼 두면 Samba가 WAN (Wide Area Network - 인터넷 쪽 외부 네트워크) 인터페이스를 포함한 모든 인터페이스에 바인딩될 수 있다고 적습니다. 켜면 LAN (Local Area Network - 집 안 내부 네트워크) 쪽 인터페이스에만 바인딩합니다. Time Capsule을 공유기로도 쓰고 있다면 특히 켜 두는 편이 안전합니다. 다만 켜 두면 Time Capsule의 LAN이 아닌 다른 네트워크에 있는 Mac은 SMB에 연결하지 못합니다. 이때는 Checkup이 이 설정을 끄라고 안내합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. 인증과 권한 모델을 알고 씁니다.&lt;/strong&gt; SMB 사용자 이름은 아무 값이나 받고, 비밀번호는 Time Capsule 기기 비밀번호를 씁니다. 게스트 접속은 막혀 있습니다. 들어온 SMB 사용자는 기기 안에서 root로 매핑됩니다. README는 LAN 안에서만 쓰고 인터넷에 포트를 열지 말라고 적고, FAQ는 집 네트워크라면 아마 괜찮지만 보안에 민감하다면 쓰지 말라고 적습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. 공식 릴리스만 받습니다.&lt;/strong&gt; 앱을 처음 열 때 Gatekeeper 경고가 뜨면 직접 허용해야 합니다. 이슈 #284에서는 관리자가 미공개 빌드를 외부 파일 공유 링크로 전달한 적도 있습니다. 릴리스 페이지가 아닌 곳에서 받은 파일은 쓰지 않는 편이 안전합니다.&lt;/p&gt;
&lt;h3&gt;TimeCapsuleSMB가 하는 일&lt;/h3&gt;
&lt;p&gt;TimeCapsuleSMB v2.2.9는 Time Capsule 안에서 Samba 4.24.3을 실행합니다. Samba는 SMB3 연결을 받고, 별도의 mDNS (multicast DNS - Bonjour가 로컬 네트워크에 기기와 서비스를 알리는 방식) 헬퍼가 &lt;code&gt;_smb._tcp&lt;/code&gt; 서비스와 Time Machine용 &lt;code&gt;_adisk._tcp&lt;/code&gt; 레코드를 광고합니다. 기본 설정은 SMB2와 SMB3만 허용합니다. &quot;Allow Any SMB Protocol&quot;을 켜야 이 제한이 풀립니다.&lt;/p&gt;
&lt;p&gt;기기의 저장 구조 때문에 설치 방식이 조금 복잡합니다. 플래시 영역 &lt;code&gt;/mnt/Flash&lt;/code&gt;는 재부팅 뒤에도 남지만 공간이 작습니다. 그래서 여기에는 부팅 스크립트와 설정 파일, 작은 mDNS 헬퍼만 두고, Samba 본체는 하드디스크의 &lt;code&gt;.samba4&lt;/code&gt; 폴더에 둡니다. 부팅할 때는 본체를 램디스크 &lt;code&gt;/mnt/Memory&lt;/code&gt;로 복사해 실행합니다. Apple 펌웨어가 쉬는 동안 하드디스크를 내리므로, 하드디스크에서는 바로 실행할 수 없습니다.&lt;/p&gt;
&lt;p&gt;설치 과정에서 앱은 기기의 SSH (Secure Shell - 원격 셸 접속 프로토콜)를 켜고, 이 연결로 파일을 올립니다. 기존 디스크 데이터는 지우지 않습니다. 제거 명령은 하드디스크의 설치 파일과 &lt;code&gt;/mnt/Flash&lt;/code&gt;의 로더 파일을 지웁니다. 펌웨어까지 순정으로 되돌리려면 README가 소개하는 &lt;code&gt;flash --restore&lt;/code&gt;를 따로 써야 합니다. 5세대는 NetBSD 6용, 1~4세대는 NetBSD 4용 바이너리를 씁니다. 제 4세대 기기에는 NetBSD 4.0 little-endian용 바이너리가 설치됐습니다.&lt;/p&gt;
&lt;h3&gt;앱으로 설치하기&lt;/h3&gt;
&lt;p&gt;README의 macOS 앱 빠른 시작 순서에, 앞에서 정한 설정 변경을 끼워 넣으면 다음과 같습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;GitHub 릴리스 페이지에서 앱을 받아 압축을 풀고 실행합니다. 열 수 없다는 경고가 뜨면 Gatekeeper에서 직접 허용합니다.&lt;/li&gt;
&lt;li&gt;시스템 설정의 개인정보 보호 및 보안에서 로컬 네트워크 항목을 열고 TimeCapsuleSMB를 허용한 뒤, 앱을 종료했다가 다시 엽니다.&lt;/li&gt;
&lt;li&gt;왼쪽 사이드바의 Add Device로 기기를 고르고, 기기 비밀번호를 입력해 Save Device를 누릅니다. 앱이 기기의 SSH를 켤 때까지 기다립니다.&lt;/li&gt;
&lt;li&gt;앱 설정에서 텔레메트리를 끄고, 기기의 고급 설정에서 &quot;Enable NBNS&quot;를 끄고 &quot;Bind SMB to LAN Only&quot;를 켭니다. NBNS와 LAN 전용 바인딩 설정은 다음 Install/Update 때 기기에 반영되고, 텔레메트리 설정은 Mac에만 적용됩니다.&lt;/li&gt;
&lt;li&gt;기기를 선택하고 Install/Update 탭에서 Install/Update를 누릅니다.&lt;/li&gt;
&lt;li&gt;1~4세대라면 다음 절의 부트 훅을 씁니다.&lt;/li&gt;
&lt;li&gt;5~10분 기다린 뒤 Checkup 탭에서 Checkup을 실행합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;제 4세대 기기에서는 배포가 약 6분 걸렸습니다. 앱이 기기를 재부팅하고 런타임 활성화까지 검증한 뒤 끝났습니다. 이어서 실행한 Checkup은 실패와 경고 없이 통과했습니다.&lt;/p&gt;
&lt;p&gt;README는 SSH 활성화가 실패하면 앱을 다시 열고 기기를 다시 추가하거나, 기기를 재부팅해 보라고 안내합니다. 배포가 실패하면 저장한 기기를 지우고 다시 추가한 뒤 배포하라고 하며, 파일을 모두 올리는 데 배포를 여러 번 해야 할 때도 있다고 적습니다.&lt;/p&gt;
&lt;h3&gt;CLI로 설치한다면&lt;/h3&gt;
&lt;p&gt;CLI로 설치한다면 README의 순서에 &lt;code&gt;--no-nbns&lt;/code&gt;를 더해 다음처럼 실행합니다. 저는 앱으로 설치했기 때문에 이 명령은 실행하지 않았습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;./tcapsule bootstrap
.venv/bin/tcapsule configure
.venv/bin/tcapsule deploy --no-nbns
.venv/bin/tcapsule doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bootstrap&lt;/code&gt;은 저장소 폴더 안에 Python 가상환경을 만들고, &lt;code&gt;configure&lt;/code&gt;는 기기 주소와 비밀번호를 담은 설정 파일을 씁니다. &lt;code&gt;deploy --no-nbns&lt;/code&gt;는 NBNS 응답기를 끈 설정으로 배포하고, &lt;code&gt;doctor&lt;/code&gt;는 설치 상태를 점검합니다. CLI는 이 옵션을 저장하지 않으므로, 업데이트나 재배포 때도 &lt;code&gt;--no-nbns&lt;/code&gt;를 다시 붙여야 합니다. 1~4세대라면 &lt;code&gt;deploy&lt;/code&gt; 뒤에 &lt;code&gt;flash&lt;/code&gt;로 펌웨어를 백업하고 &lt;code&gt;flash --patch&lt;/code&gt;로 부트 훅을 씁니다. CLI 경로는 macOS 14 이상, Python 3.9 이상, Homebrew, &lt;code&gt;smbclient&lt;/code&gt;가 필요하고, NetBSD 4 기기에는 &lt;code&gt;sshpass&lt;/code&gt;도 필요합니다. Mac 쪽 텔레메트리는 &lt;code&gt;.bootstrap&lt;/code&gt;에 &lt;code&gt;TELEMETRY=false&lt;/code&gt;를 넣어 끕니다.&lt;/p&gt;
&lt;h3&gt;1~4세대는 부트 훅을 써야 합니다&lt;/h3&gt;
&lt;p&gt;1~4세대 Time Capsule은 NetBSD 4 펌웨어를 씁니다. 이 펌웨어는 Samba를 자동으로 시작할 부팅 훅을 재부팅 뒤까지 유지하지 못합니다. 그대로 두면 기기를 재부팅할 때마다 &lt;code&gt;tcapsule activate&lt;/code&gt;로 Samba를 다시 켜야 합니다.&lt;/p&gt;
&lt;p&gt;이를 피하려면 펌웨어에 부트 훅을 씁니다. 앱의 유지보수 화면에 있는 &quot;Persistent NetBSD4 Boot Hook&quot; 절에서 Back Up and Inspect, Plan Patch, Write Patch를 순서대로 실행합니다.&lt;/p&gt;
&lt;p&gt;제 기기는 펌웨어 7.8.1이었습니다. 앱은 먼저 펌웨어 뱅크 두 개를 모두 Mac에 백업했습니다. 그다음 Apple 카탈로그에서 받은 7.8.1 펌웨어를 바탕으로 패치 이미지를 만들고, primary 뱅크에만 썼습니다. secondary 뱅크는 원본 그대로 남았습니다. 쓰기가 끝난 뒤에는 뱅크를 다시 읽어 기대한 내용과 같은지 검증했고, 통과했습니다.&lt;/p&gt;
&lt;p&gt;패치 모드는 쓰기가 끝난 뒤 재부팅 명령을 보낼 수 없습니다. 그래서 전원 코드를 직접 뽑았다가 다시 꽂았습니다. 기기가 다시 켜진 뒤 &lt;code&gt;activate&lt;/code&gt; 없이 Samba가 올라왔습니다.&lt;/p&gt;
&lt;p&gt;이 단계가 TimeCapsuleSMB에서 가장 위험합니다. FAQ는 플래시에 쓰는 도중 전원이 끊기면 기기가 벽돌이 될 수 있다고 경고합니다. 쓰는 동안에는 전원과 네트워크를 건드리지 않아야 합니다. README에 따르면 &lt;code&gt;tcapsule flash --restore&lt;/code&gt;로 선택한 뱅크를 Apple 순정 펌웨어로 되돌리는 기능도 있습니다. 저는 복원 기능은 써 보지 않았습니다.&lt;/p&gt;
&lt;h3&gt;5세대라면&lt;/h3&gt;
&lt;p&gt;README와 FAQ는 5세대 기기가 부트 훅 없이 재부팅 뒤 자동으로 Samba를 시작한다고 적습니다. 배포할 때 기기를 한 번 재부팅합니다. 저는 5세대 기기로는 확인하지 못했습니다.&lt;/p&gt;
&lt;h3&gt;기존 백업 옮기기&lt;/h3&gt;
&lt;p&gt;main 브랜치 FAQ(2026년 9월 14일, &lt;code&gt;34c075f&lt;/code&gt; 기준)에 따르면 설치는 디스크를 지우거나 기존 &lt;code&gt;.sparsebundle&lt;/code&gt; 백업을 삭제하지 않습니다. 기존 백업을 이어 쓰려면 먼저 SMB 연결이 되는지 확인합니다. 그다음 &lt;code&gt;.sparsebundle&lt;/code&gt;을 Time Machine이 보는 공유 루트로 옮깁니다. 기본 설정에서는 &lt;code&gt;ShareRoot&lt;/code&gt; 폴더입니다. 예전 설정에서 사용자별 폴더 안에 백업이 있었다면 그것도 공유 루트로 옮깁니다. 마지막으로 Time Machine에서 SMB 공유를 다시 선택하고, macOS가 기존 백업을 제안하면 그것을 고릅니다. FAQ는 Time Machine이 기존 백업을 찾거나 재사용하지 못하면 새 백업 묶음을 만들 수도 있다고 적습니다.&lt;/p&gt;
&lt;p&gt;이 절은 FAQ 기준으로 정리했습니다. 옮긴 뒤에도 macOS가 기존 백업을 찾지 못하면, FAQ는 백업이 마운트되지 않은 상태에서 Time Capsule과 Mac을 차례로 재부팅해 보라고 안내합니다.&lt;/p&gt;
&lt;h2&gt;검증 방법&lt;/h2&gt;
&lt;p&gt;확인한 환경은 다음과 같습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;설치에 쓴 Mac: macOS 27.0&lt;/li&gt;
&lt;li&gt;Time Machine 백업을 확인한 Mac: Mac mini (macOS 27)&lt;/li&gt;
&lt;li&gt;Time Capsule: 4세대, 펌웨어 7.8.1, NetBSD 4.0&lt;/li&gt;
&lt;li&gt;TimeCapsuleSMB: 앱 2.2.9 (Samba 4.24.3)&lt;/li&gt;
&lt;li&gt;기준일: 2026년 9월 14일&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;확인한 항목입니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;배포와 Checkup:&lt;/strong&gt; 앱 기록에 배포 성공과 런타임 활성화 검증이 남았고, Checkup은 실패 0건, 경고 0건으로 통과했습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;부트 훅:&lt;/strong&gt; primary 뱅크 쓰기 뒤 읽기 검증이 통과했고, 전원을 다시 넣은 뒤 &lt;code&gt;activate&lt;/code&gt; 없이 Samba가 올라왔습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time Machine:&lt;/strong&gt; macOS 27의 Mac mini에서 Time Capsule의 SMB 공유를 백업 대상으로 골라 첫 백업이 끝나는 것까지 확인했습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NBNS 끄기:&lt;/strong&gt; NBNS를 끄고 재배포한 뒤에도 정상 동작했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;설치 뒤 상태는 앱의 Checkup이나 CLI의 &lt;code&gt;doctor&lt;/code&gt;로 점검합니다. README에 따르면 &lt;code&gt;doctor&lt;/code&gt;는 Samba 프로세스와 445 포트, Bonjour 광고, 인증된 공유 목록, 공유에서의 파일 작업까지 확인합니다. Mac에서 직접 보고 싶다면 먼저 Bonjour 광고를 확인합니다. 아래 두 명령은 끝나지 않고 결과를 계속 보여 주므로, 하나씩 실행하고 Control-C로 멈춥니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dns-sd -B _smb._tcp local.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;dns-sd -B _adisk._tcp local.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;첫 명령은 SMB 서비스로, 두 번째 명령은 Time Machine 백업 디스크로 광고되는 기기 이름을 보여 줍니다. 그다음 Time Machine 대상을 확인합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tmutil destinationinfo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 명령은 지금 Mac에 설정된 Time Machine 대상 목록을 보여 줍니다. 광고가 보인다고 인증과 파일 쓰기까지 된다는 뜻은 아니므로, 최종 판단은 Checkup과 실제 백업 결과로 합니다.&lt;/p&gt;
&lt;p&gt;확인하지 못한 것도 적어 둡니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;heartbeat 요청이 네트워크에서 실제로 사라졌는지는 패킷으로 확인하지 않았습니다. NBNS를 끄면 heartbeat가 멈춘다는 판단은 v2.2.9의 부팅 스크립트와 바이너리 문자열을 읽고 내린 것입니다.&lt;/li&gt;
&lt;li&gt;5세대 기기, AirPort Extreme, Time Capsule에 연결한 외장 디스크는 확인하지 않았습니다.&lt;/li&gt;
&lt;li&gt;기본 펌웨어의 SMB1 파일 공유가 macOS 27에서 연결되는지는 시험하지 않았습니다. 이 글은 Time Machine 백업이 끊긴 문제로 범위를 한정했습니다.&lt;/li&gt;
&lt;li&gt;2026년 9월 14일 기준 TimeCapsuleSMB main 브랜치(&lt;code&gt;34c075f&lt;/code&gt;)는 heartbeat를 별도 &lt;code&gt;telemetry&lt;/code&gt; 헬퍼로 옮겼습니다. 이 헬퍼도 서버가 지정하면 서명된 debug 실행 파일을 내려받아 실행합니다. 대신 기기의 &lt;code&gt;/mnt/Flash/tcapsulesmb.conf&lt;/code&gt;에 &lt;code&gt;TELEMETRY=false&lt;/code&gt;가 있으면 동작을 멈추고, main의 배포 코드는 Mac 쪽 텔레메트리 설정을 이 파일에 함께 씁니다. 아직 릴리스되지 않은 코드이므로 확인한 동작은 아닙니다. 다음 릴리스에서는 이 글의 NBNS 방법 대신 앱의 텔레메트리 설정으로 기기 쪽까지 끄게 될 수 있으니, 업데이트 전에 릴리스 노트와 FAQ를 다시 확인해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;다시 막는 방법&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;앱과 CLI 폴더를 지우지 않습니다.&lt;/strong&gt; Checkup, 재배포, &lt;code&gt;activate&lt;/code&gt;, 제거가 모두 앱이나 CLI 폴더에서 이뤄집니다. FAQ는 CLI 폴더를 유지보수용으로 남겨 두라고 권합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;업데이트는 기존 설치 위에 다시 배포합니다.&lt;/strong&gt; FAQ는 업데이트 전에 제거할 필요가 없다고 적습니다. CLI로 배포한다면 &lt;code&gt;--no-nbns&lt;/code&gt;를 빠뜨리지 않습니다. 업데이트한 뒤에는 NBNS와 LAN 전용 바인딩 설정이 그대로인지, 텔레메트리 구조가 바뀌지 않았는지 확인하고 Checkup을 실행합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;전원이 끊긴 뒤에는 5~10분 기다렸다가 Checkup을 실행합니다.&lt;/strong&gt; 부트 훅을 쓰지 않은 1~4세대라면 &lt;code&gt;activate&lt;/code&gt;가 필요합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;macOS를 업데이트한 뒤에는 첫 Time Machine 백업을 확인합니다.&lt;/strong&gt; FAQ는 macOS 26.4.x와 15.7.5~15.7.7에서 Time Machine 네트워크 백업 회귀가 있었다고 안내합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;백업이 중간에 끊기면 네트워크부터 봅니다.&lt;/strong&gt; 관리자가 정리한 이슈 #294는 Time Machine 백업 중 SMB 연결이 멈추는 문제를 다룹니다. Time Capsule 자체 Wi-Fi보다 유선이나 별도의 최신 무선 액세스 포인트를 거친 연결이 훨씬 안정적이었고, 원인은 아직 확인되지 않았다고 적습니다. 관리자는 비슷한 이슈 #271을 Samba와 네트워크 불안정 문제로 판단했습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;설치 전에 기기 설정을 기록해 둡니다.&lt;/strong&gt; 이슈 #177에는 첫 배포 도중 기기 설정이 공장 초기화된 사례가 있습니다. 디스크 데이터는 남지만 네트워크 설정을 AirPort Utility로 다시 해야 합니다. macOS 27에서는 AirPort Utility 동작이 보장되지 않으므로, 이 위험은 예전보다 무겁게 봐야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;백업을 한 곳에만 두지 않습니다.&lt;/strong&gt; Time Capsule은 Apple의 지원이 끝난 기기이고, TimeCapsuleSMB는 비공식 프로젝트입니다. 중요한 백업은 외장 저장장치나 SMB NAS에 한 벌 더 두는 편이 안전합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://support.apple.com/en-us/102423&quot;&gt;Backup disks you can use with Time Machine - Apple Support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.apple.com/en-us/121011&quot;&gt;What&apos;s new for enterprise in macOS Sequoia - Apple Support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/macos-release-notes/macos-27-release-notes&quot;&gt;macOS 27 Golden Gate RC Release Notes - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/macos-release-notes/macos-15-release-notes&quot;&gt;macOS Sequoia 15 Release Notes - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB&quot;&gt;jamesyc/TimeCapsuleSMB - GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/v2.2.9/README.md&quot;&gt;TimeCapsuleSMB v2.2.9 README&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/v2.2.9/FAQ.md&quot;&gt;TimeCapsuleSMB v2.2.9 FAQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/README.md&quot;&gt;TimeCapsuleSMB README (main 34c075f)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/FAQ.md&quot;&gt;TimeCapsuleSMB FAQ (main 34c075f)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/build/native/README.md&quot;&gt;TimeCapsuleSMB native helpers README (main 34c075f)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/299&quot;&gt;TimeCapsuleSMB issue #299: periodically downloads and executes arbitrary code as root&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/294&quot;&gt;TimeCapsuleSMB issue #294: SMB connections intermittently stall during Time Machine backup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/271&quot;&gt;TimeCapsuleSMB issue #271: Time Machine fails on macOS Tahoe with TimeCapsuleSMB 2.2.9&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/177&quot;&gt;TimeCapsuleSMB issue #177: device will randomly factory reset during a deploy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/jamesyc/TimeCapsuleSMB/issues/284&quot;&gt;TimeCapsuleSMB issue #284: No backups since firmware update&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://gitlab.com/samba-team/samba/-/blob/master/source3/modules/vfs_fruit.c&quot;&gt;Samba vfs_fruit.c - GitLab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://gitlab.com/samba-team/samba/-/blob/master/source3/smbd/avahi_register.c&quot;&gt;Samba avahi_register.c - GitLab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://gitlab.com/samba-team/samba/-/blob/master/selftest/target/Samba3.pm&quot;&gt;Samba selftest Samba3.pm - GitLab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.macrumors.com/2026/06/17/macos-27-golden-gate-kills-time-capsule-support/&quot;&gt;macOS 27 Golden Gate Kills Time Capsule Support - MacRumors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://eclecticlight.co/2025/05/15/check-your-network-backups-and-shares-as-afp-is-being-removed/&quot;&gt;Check your network backups and shares, as AFP is being removed - The Eclectic Light Company&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>macOS</category><category>Time Machine</category><category>SMB</category><category>Troubleshooting</category></item><item><title>READY_FOR_REVIEW는 «제출됨»이 아니다: App Store Connect 제출을 멱등하게 만들기</title><link>https://jaemyeong.com/ko/blog/app-store-connect-idempotent-submission/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/app-store-connect-idempotent-submission/</guid><description>응답이 유실되면 재실행이 중복 제출로 거부되어, 복구용 재시도가 오히려 확실한 실패가 됐습니다. 멱등 가드를 붙였더니 이번엔 그 가드가 세 가지 방향으로 배신했습니다.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 혼자 만드는 앱이라 릴리스도 자동화해 뒀습니다. 태그를 밀면 빌드가 올라가고 App Store Connect에 심사 제출까지 갑니다.&lt;/p&gt;
&lt;p&gt;이 워크플로는 &lt;strong&gt;채널별로 다시 돌려서 복구한다&lt;/strong&gt;는 전제로 설계했습니다. 웹 배포가 실패하면 웹만 다시, iOS 제출이 실패하면 iOS만 다시 돌리면 됩니다.&lt;/p&gt;
&lt;p&gt;그런데 iOS 제출에서 그 전제가 깨져 있었습니다.&lt;/p&gt;
&lt;p&gt;App Store Connect가 제출 요청을 받았는데 &lt;strong&gt;응답이 유실되면&lt;/strong&gt;, 재실행이 중복 제출로 거부됩니다. 그러니까 복구하려고 누르는 재시도가 오히려 &lt;strong&gt;확실한 실패&lt;/strong&gt;가 됩니다. 성공했는지 실패했는지 모르는 상태가, 다시 돌리는 순간 실패로 확정되는 셈입니다.&lt;/p&gt;
&lt;p&gt;Android 쪽은 같은 지적을 받아 이미 고쳐져 있었고 iOS만 남아 있었습니다. 그래서 멱등 가드를 붙였습니다. 그리고 그 가드가 세 가지 방향으로 배신했습니다.&lt;/p&gt;
&lt;p&gt;이 글은 그 세 번의 배신과, 결국 무엇을 계약으로 고정했는지를 정리한 기록입니다.&lt;/p&gt;
&lt;h2&gt;추측 대신 원본을 읽었습니다&lt;/h2&gt;
&lt;p&gt;가드를 짜려면 「지금 이 버전이 어떤 상태인가」를 조회해야 합니다. 그런데 상태 조회 API의 값 집합을 정확히 몰랐습니다.&lt;/p&gt;
&lt;p&gt;여기서 추측했으면 틀렸을 겁니다. &lt;code&gt;Gemfile.lock&lt;/code&gt;이 고정한 fastlane 버전의 spaceship 소스를 직접 읽었더니 이렇게 돼 있었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;attr_accessor :app_store_state      # Deprecated in App Store Connect API specification 3.3
attr_accessor :app_version_state    # ← 현행
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이름이 비슷한 접근자가 둘인데 하나는 &lt;strong&gt;deprecated&lt;/strong&gt;입니다. 그리고 값 집합도 다릅니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;deprecated&lt;/th&gt;
&lt;th&gt;현행&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;READY_FOR_SALE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;READY_FOR_DISTRIBUTION&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PROCESSING_FOR_APP_STORE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PROCESSING_FOR_DISTRIBUTION&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;구버전 이름으로 분기를 짰다면 &lt;strong&gt;모든 상태가 「모르는 값」으로 떨어졌을&lt;/strong&gt; 겁니다. spaceship 자신이 버전을 조회할 때 현행 필드로 필터한다는 것이 근거였습니다.&lt;/p&gt;
&lt;p&gt;함정이 하나 더 있었습니다. &lt;strong&gt;접근자는 스네이크 케이스입니다.&lt;/strong&gt; &lt;code&gt;appVersionState&lt;/code&gt;는 JSON 키이고, Ruby 쪽에서는 매핑이 그 이름을 없앱니다. 카멜 케이스로 쓰면 그냥 &lt;code&gt;nil&lt;/code&gt;이 나오는데, Ruby는 &lt;code&gt;nil&lt;/code&gt;에 대한 비교를 오류로 만들지 않으니 &lt;strong&gt;문법 검사로는 안 잡힙니다.&lt;/strong&gt; 조용히 「모르는 상태」로 흐를 뿐입니다.&lt;/p&gt;
&lt;h2&gt;가드는 세 방향으로 배신했습니다&lt;/h2&gt;
&lt;p&gt;멱등 가드가 실패하는 방향은 하나가 아니었습니다. 세 번 다 형태가 달랐습니다.&lt;/p&gt;
&lt;h3&gt;① 「준비됨」을 「제출됨」으로 읽었습니다&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;READY_FOR_REVIEW&lt;/code&gt;라는 상태가 있습니다. 이름만 보면 「심사 준비 완료 = 제출됨」으로 읽힙니다. 그래서 처음에는 그렇게 분류했습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;틀렸습니다.&lt;/strong&gt; 그 값은 *&quot;제출할 준비가 됐다&quot;*이지 *&quot;심사 큐에 들어갔다&quot;*가 아닙니다. 앞선 실행이 버전 준비와 제출 객체 생성까지 끝내고 &lt;strong&gt;최종 제출 호출 직전에 죽으면&lt;/strong&gt; 정확히 이 값이 나옵니다.&lt;/p&gt;
&lt;p&gt;그것을 「이미 제출됨」으로 읽으면 재시도가 제출 단계 &lt;strong&gt;앞에서 성공으로 빠져나갑니다.&lt;/strong&gt; 그리고 &lt;strong&gt;제출되지 않은 버전을 제출했다고 보고합니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;멱등성을 얻으려다 「조용한 미제출」을 만들 뻔한 것입니다. 재시도가 확실한 실패가 되는 것보다 나쁩니다. 실패는 눈에 보이지만 이건 안 보입니다.&lt;/p&gt;
&lt;p&gt;여기에 딸린 문제가 하나 더 있었습니다. 같은 마케팅 버전이 &lt;strong&gt;다른 빌드&lt;/strong&gt;로 이미 제출돼 있을 수 있습니다. 수동으로 제출했거나, 앞선 실행이 다른 번호로 올렸거나. 그대로 성공을 돌려주면 &lt;strong&gt;요청한 빌드가 아닌 바이너리가 심사 중인데 제출했다고 보고&lt;/strong&gt;하게 됩니다.&lt;/p&gt;
&lt;p&gt;「그 빌드가 App Store Connect 어딘가에 있다」와 「그 빌드가 이 버전에 선택돼 있다」는 &lt;strong&gt;다른 축&lt;/strong&gt;입니다. 앞의 것만 확인하고 있었습니다.&lt;/p&gt;
&lt;h3&gt;② 불리언으로 접어 거부를 성공으로 만들었습니다&lt;/h3&gt;
&lt;p&gt;①을 고치고 나니 상태 하나만으로는 부족하다는 게 드러났습니다.&lt;/p&gt;
&lt;p&gt;제출 도구가 실제로 하드 실패하는 조건은 버전 상태가 아니라 **앱 단위의 「진행 중 심사 제출」**이었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if app.get_in_progress_review_submission(platform:)
  UI.user_error!(&quot;Cannot submit for review - A review submission is already in progress&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;앞선 실행이 제출 객체를 만들고 죽은 경우가 정확히 &lt;strong&gt;「&lt;code&gt;READY_FOR_REVIEW&lt;/code&gt; + 진행 중 제출」&lt;/strong&gt; 조합입니다. 상태 하나만 보면 이 조합을 다룰 수 없습니다. 그래서 두 축을 함께 보게 바꿨습니다.&lt;/p&gt;
&lt;p&gt;그런데 그 조회 결과를 &lt;strong&gt;&lt;code&gt;nil&lt;/code&gt;인지 아닌지로 접었습니다.&lt;/strong&gt; 이게 두 번째 배신입니다.&lt;/p&gt;
&lt;p&gt;그 조회는 세 가지 상태를 함께 잡습니다 — 심사 대기, 심사 중, 그리고 **「심사가 문제를 제기함」**입니다. 불리언으로 접는 순간 세 번째가 사라집니다. 그러면 버전이 거부 상태여도 빌드와 릴리스 노트만 맞으면 「이미 제출됨」으로 빠져나가, &lt;strong&gt;거부된 릴리스를 성공으로 보고&lt;/strong&gt;할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;고친 방향은 접근자가 상태를 그대로 돌려주게 하고, &lt;strong&gt;차단이 제출을 이기도록 순서를 구조에 박는 것&lt;/strong&gt;이었습니다.&lt;/p&gt;
&lt;p&gt;같은 라운드에서 분류 누락도 하나 나왔습니다. 상태 열거형에 값이 15개인데 14개만 분류하고 있었습니다. 빠진 것은 「승인됨」이었고, 승인 후 수동 출시를 기다리는 중에 재시도가 오면 그 자리에서 죽었을 겁니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;여기서 상수를 하나 더 적는 것은 근본 수정이 아닙니다.&lt;/strong&gt; 다음 누락도 똑같이 태그를 미는 순간에 드러날 테니까요. 그래서 self-test가 &lt;strong&gt;고정된 젬의 상태 열거형을 전수 열거해 미분류가 0인지 단언&lt;/strong&gt;하게 했습니다. 젬을 올릴 때 상태가 추가돼 있으면 심사 큐 앞이 아니라 self-test에서 죽습니다.&lt;/p&gt;
&lt;h3&gt;③ 가드가 차단기로 뒤집혔습니다&lt;/h3&gt;
&lt;p&gt;세 번째는 방향이 반대입니다. 이번엔 가드가 &lt;strong&gt;너무 잘 막았습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;제출된 릴리스 노트가 이번에 보내려는 것과 같은지 대조하는 가드가 있습니다. 로케일별로 비교합니다. 그런데 그 검증이 절반만 보고 있었습니다. 테스트 표에 양쪽 로케일 키를 &lt;strong&gt;손으로 써넣어&lt;/strong&gt; 두는 바람에, 「비교 함수가 맞게 도는가」만 확인하고 &lt;strong&gt;「우리 키와 API 응답이 같은 이름 공간인가」는 아무도 안 봤습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;철자가 어긋나면 어떻게 될까요. 모든 로케일이 「제출본에 없음」이 됩니다. 그러면 안전을 위해 멈추도록 만든 가드가 &lt;strong&gt;모든 재시도를 영구히 막는 차단기&lt;/strong&gt;가 됩니다. 거짓 성공을 막으려던 것이 아무것도 성공하지 못하게 만드는 것입니다.&lt;/p&gt;
&lt;p&gt;더 나쁜 것은 그때 나오는 메시지였습니다. 「릴리스 노트가 낡았다」라고 알려주니, 사람은 멀쩡한 App Store Connect의 노트만 들여다보게 됩니다. 진짜 원인인 키 이름 불일치는 화면 어디에도 없습니다.&lt;/p&gt;
&lt;p&gt;고친 뒤에는 「전부 없음 + 제출본은 비어 있지 않음」이라는 조합을 따로 판별해서, 그 경우 &lt;strong&gt;양쪽 키 집합을 그대로 찍습니다.&lt;/strong&gt; 사람이 봐야 할 것을 사람에게 보여주는 것이 수정의 핵심이었습니다.&lt;/p&gt;
&lt;h2&gt;결국 무엇을 고정했나&lt;/h2&gt;
&lt;p&gt;세 번을 거치고 나서 남은 계약은 네 가지입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 판정 축을 분리합니다.&lt;/strong&gt; 버전 상태와 앱 단위 진행 중 제출은 서로 다른 질문입니다. 하나로 합치면 앞선 실행이 중간에 죽은 시나리오를 표현할 수 없습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 모르는 값은 fail-closed입니다.&lt;/strong&gt; 알려진 상태를 전부 분류하고, 「그 밖」은 사람에게 넘깁니다. Apple이 상태를 추가했을 수 있고, 모르는 채로 제출에 흘리면 심사 큐를 눈감고 건드리는 셈입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;셋째, 차단이 제출을 이깁니다.&lt;/strong&gt; 두 축 어느 쪽에서든 멈춰야 할 신호가 있으면 멈춥니다. 이 우선순위를 조건문 순서가 아니라 구조에 박아 뒀습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;넷째, 드라이런은 멈추지 않습니다.&lt;/strong&gt; 검증 전용 모드에서는 상태를 보고만 합니다. 리허설 시점의 상태가 태그 시점의 상태와 같다는 보장이 없으니, 여기서 막으면 통과든 실패든 태그 시점에 대해 아무것도 보장하지 못한 채 리허설만 못 돌게 됩니다.&lt;/p&gt;
&lt;h2&gt;확인한 방법&lt;/h2&gt;
&lt;p&gt;이 글은 2026년 8월 16일 DailySudoku &lt;code&gt;develop&lt;/code&gt; 브랜치의 iOS 릴리스 자동화를 기준으로 합니다.&lt;/p&gt;
&lt;p&gt;제출 판정은 &lt;strong&gt;자격 증명 없이 도는 순수 함수&lt;/strong&gt;로 빼뒀습니다. 그래서 self-test가 실제 App Store Connect를 건드리지 않고 진리표 전체를 잠급니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;축1(버전 상태) 분류 표를 케이스별로 단언&lt;/li&gt;
&lt;li&gt;축2(진행 중 제출) 표를 별도로 단언&lt;/li&gt;
&lt;li&gt;고정된 젬의 상태 열거형을 전수 열거해 &lt;strong&gt;미분류 0&lt;/strong&gt; 단언&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;대조군을 따로 넣은 것이 핵심입니다.&lt;/strong&gt; 축1 표만으로는 두 번째 축을 &lt;strong&gt;아예 읽지 않는 구현&lt;/strong&gt;도 통과합니다. 파라미터로 받고 무시하면 그만이니까요. 그래서 「같은 상태가 축에 따라 답이 갈리는가」를 검사하는 줄을 넣었고, &lt;strong&gt;두 줄이 같은 답을 내면 실패&lt;/strong&gt;합니다.&lt;/p&gt;
&lt;p&gt;릴리스 노트 대조 쪽은 &lt;strong&gt;뮤테이션을 양방향으로&lt;/strong&gt; 돌렸습니다. 비교 함수를 항상 거짓으로 바꾸면 구멍이 생기고, 항상 참으로 바꾸면 과잉 차단이 생깁니다. 둘 다 실제로 잡히는 것을 확인했습니다. 이 기법은 &lt;a href=&quot;/ko/blog/verification-script-negative-control/&quot;&gt;검증 스크립트의 자기검증 글&lt;/a&gt;에서 따로 정리했습니다.&lt;/p&gt;
&lt;h3&gt;못 잰 것&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;검증 기록이 덮는 것은 판정 로직까지입니다.&lt;/strong&gt; 제출 요청은 도달했는데 응답만 사라지는 상황을 실제로 만들어 통과시켜 본 기록은 없습니다. 그 조건을 인위적으로 재현할 방법이 마땅치 않기 때문입니다. 가드의 정당성은 그 상황이 남기는 상태 조합을 정확히 읽는다는 데 기대고 있지, 그 상황을 통째로 밟아본 데 기대고 있지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;상태 분류의 정확성은 고정된 젬 버전에 대해서만 검증됩니다.&lt;/strong&gt; 전수 열거 단언이 잡아주는 것은 「젬이 아는 상태를 우리가 전부 분류했는가」이지 「젬이 App Store Connect의 현재 상태를 전부 아는가」가 아닙니다. 후자는 젬을 올리기 전까지 알 수 없습니다.&lt;/p&gt;
&lt;h2&gt;사이드 프로젝트라서 배운 것&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;멱등성은 「두 번 불러도 괜찮다」가 아니라 「무엇이 이미 일어났는가를 정확히 읽는다」입니다.&lt;/strong&gt; 처음에 저는 앞의 정의로 접근했고, 그래서 「이미 제출됨처럼 보이면 넘어간다」를 짰습니다. 그 결과가 조용한 미제출이었습니다. 읽기가 틀리면 멱등 가드는 실패를 감추는 장치가 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;가드는 두 방향으로 고장 납니다.&lt;/strong&gt; 안 막아야 할 때 막지 않는 것만 생각하기 쉽지만, 막지 말아야 할 때 막는 것도 같은 무게의 결함입니다. 특히 후자는 「안전하게 실패했다」처럼 보여서 더 오래 살아남습니다. 뮤테이션을 양방향으로 돌린 이유가 그것입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;이름을 믿지 말고 소스를 읽어야 할 때가 있습니다.&lt;/strong&gt; &lt;code&gt;READY_FOR_REVIEW&lt;/code&gt;는 이름만 보면 제출된 상태 같고, 접근자 두 개는 이름이 거의 같습니다. 둘 다 이름이 아니라 원본을 읽어서 갈렸습니다. 릴리스 파이프라인처럼 되돌리기 어려운 경로에서는, 소스를 확인하는 비용이 태그를 잘못 미는 비용보다 쌉니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;그리고 이미 조사해 둔 것을 다시 조사했습니다.&lt;/strong&gt; 두 번째 축의 존재는 이 저장소에 이미 조사 기록으로 남아 있었습니다. 저는 그 문서를 안 읽고 상태 하나만 보고 고쳤고, 그래서 한 라운드를 더 썼습니다. 혼자 만드는 프로젝트에서 과거의 기록은 남이 남긴 문서가 아니라 &lt;strong&gt;몇 달 전의 내가 남긴 문서&lt;/strong&gt;인데, 그렇다고 더 잘 읽게 되지는 않더군요.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/appstoreconnectapi&quot;&gt;App Store Connect API - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/appstoreconnectapi/app_store_versions&quot;&gt;App Store Versions - App Store Connect API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/appstoreconnectapi/review_submissions&quot;&gt;Review Submissions - App Store Connect API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.fastlane.tools/actions/deliver/&quot;&gt;fastlane deliver&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.fastlane.tools/advanced/Spaceship/&quot;&gt;fastlane spaceship&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;DailySudoku - App Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;DailySudoku - Google Play&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;DailySudoku 웹&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>CI-CD</category><category>fastlane</category><category>Automation</category><category>App Store Connect</category></item><item><title>UMP가 ATT를 직접 부른다: 프레임워크 바이너리로 확인한 순서 계약</title><link>https://jaemyeong.com/ko/blog/att-ump-ordering-ios-consent/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/att-ump-ordering-ios-consent/</guid><description>AdMob IDFA 설명 메시지를 게시하려다 막혔습니다. 어느 쪽이 requestTrackingAuthorization을 부르는지 공식 문서에 없어서 UMP 프레임워크 바이너리를 읽었고, 거기서 앱과 SDK가 일회성 프롬프트를 두고 경합한다는 걸 확인했습니다.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 무료로 내려면 배너 광고가 필요했고, 광고를 붙이는 순간 앱 안에 「동의」라는 세계가 하나 생겼습니다.&lt;/p&gt;
&lt;p&gt;AdMob 콘솔에는 IDFA (Identifier for Advertisers - Apple의 광고 식별자) 설명 메시지라는 기능이 있습니다. ATT (App Tracking Transparency - Apple이 앱의 추적 권한을 사용자에게 묻게 하는 프레임워크) 시스템 팝업은 문구를 앱이 거의 못 바꾸는데, 그 앞에 왜 추적 권한이 필요한지 설명하는 화면을 붙일 수 있게 해줍니다. 8개 로케일로 초안을 만들어 두고 게시 버튼을 누르려던 참이었습니다.&lt;/p&gt;
&lt;p&gt;그런데 누르지 못했습니다. 지금 게시하면 이 메시지가 &lt;strong&gt;한 번도 뜨지 않을 것&lt;/strong&gt;이라는 걸 알게 됐기 때문입니다.&lt;/p&gt;
&lt;p&gt;원인을 확인하려면 「앱과 UMP (User Messaging Platform - Google이 GDPR 동의 폼을 띄워주는 SDK) 중 어느 쪽이 &lt;code&gt;requestTrackingAuthorization&lt;/code&gt;을 부르는가」에 답해야 했는데, 공식 개발자 문서에는 그 답이 없었습니다. 그래서 프레임워크 바이너리를 읽었습니다.&lt;/p&gt;
&lt;p&gt;이 글은 그 과정과, 답을 알고 나서 고친 네 가지를 정리한 기록입니다. AdMob을 붙인 iOS 앱이라면 IDFA 설명 메시지를 게시하는 순간 같은 문제를 만납니다.&lt;/p&gt;
&lt;h2&gt;처음 구현은 이랬습니다&lt;/h2&gt;
&lt;p&gt;ATT 배선은 한 달 전에 이미 끝나 있었습니다. 그때 설계 감사에서 함정을 하나 잡아뒀습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;UMP 폼 완료 콜백 안에 ATT 요청을 넣으면 안 된다&lt;/strong&gt;는 것입니다. UMP 폼은 지역 조건부입니다. GDPR (General Data Protection Regulation - EU 일반 개인정보 보호법)과 영국, 그리고 일부 미국 주 규정이 적용되는 지역에서만 뜹니다. EEA (European Economic Area - 유럽경제지역)와 영국을 벗어나면 폼이 아예 뜨지 않으니, 완료 콜백도 없고, 콜백에 얹은 ATT도 영영 뜨지 않습니다. 주 시장이 한국이라 이 함정에 걸리면 IDFA를 전혀 못 받게 됩니다.&lt;/p&gt;
&lt;p&gt;그래서 호출 지점을 이렇게 못 박았습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;AppCoordinator&lt;/code&gt; 런치 task에서 &lt;code&gt;await refreshConsent()&lt;/code&gt; &lt;strong&gt;완료 직후 · &lt;code&gt;maybeStartAdMob()&lt;/code&gt; 이전에, UMP 폼 노출 여부와 무관하게&lt;/strong&gt; 비-adFree 전원 대상으로 호출한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;「&lt;strong&gt;폼 노출 여부와 무관하게&lt;/strong&gt;」가 이 계약의 핵심이었습니다. 지역에 따라 갈리는 UMP에 ATT를 묶지 않겠다는 선언입니다.&lt;/p&gt;
&lt;p&gt;NPA (Non-Personalized Ads - 비맞춤 광고) 폴백도 같이 정했습니다. ATT 거부 &lt;strong&gt;또는&lt;/strong&gt; UMP 비동의면 요청에 &lt;code&gt;&quot;npa&quot;: &quot;1&quot;&lt;/code&gt;을 실어 보냅니다. 보수적 union이라 과하게 제한하는 쪽만 허용합니다.&lt;/p&gt;
&lt;p&gt;검증도 했습니다. ATT와 UMP 조합 4케이스 유닛 테스트, 그리고 시뮬레이터에서 첫 실행 ATT 프롬프트가 비EEA 로케일 포함해 뜨는 것까지 확인했습니다.&lt;/p&gt;
&lt;p&gt;이 결정은 옳았습니다. 다만 &lt;strong&gt;한 방향으로만&lt;/strong&gt; 옳았습니다.&lt;/p&gt;
&lt;h2&gt;어디서 어긋났을까요&lt;/h2&gt;
&lt;p&gt;3주 반 뒤, IDFA 설명 메시지를 게시하려다 막혔습니다.&lt;/p&gt;
&lt;p&gt;AdMob 콘솔의 설명 문구가 단서였습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;설명 메시지를 사용하여 Apple의 iOS ATT 알림을 &lt;strong&gt;트리거&lt;/strong&gt;하고&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;「트리거한다」는 표현이 걸렸습니다. 이 문장을 그대로 읽으면 &lt;strong&gt;UMP가 ATT를 띄운다&lt;/strong&gt;는 뜻입니다. 그런데 앱은 이미 자기가 ATT를 부르고 있었습니다. 그것도 UMP 흐름과 &lt;strong&gt;병렬로&lt;/strong&gt;, 별도 &lt;code&gt;Task&lt;/code&gt;에서요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;requestTrackingAuthorization&lt;/code&gt;은 일회성입니다. 추적 권한 상태가 &lt;code&gt;.notDetermined&lt;/code&gt;일 때만 실제로 팝업을 띄우고, 사용자가 한 번 응답하면 그 뒤 호출은 팝업 없이 현재 상태로 즉시 완료됩니다. 앱을 지우고 다시 깔기 전까지 다시 물을 수 없습니다.&lt;/p&gt;
&lt;p&gt;즉 &lt;strong&gt;먼저 부른 쪽이 소비합니다.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱이 먼저 부르면: 설명 메시지는 영영 안 뜹니다. 게시해도 no-op입니다.&lt;/li&gt;
&lt;li&gt;UMP가 먼저 부르면: 설명 다음에 ATT. 의도한 동작입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;병렬이니 어느 쪽이 이길지는 그날의 네트워크 속도가 정합니다. 그리고 최악의 순서가 있습니다. &lt;strong&gt;ATT가 먼저 뜬 뒤에 설명이 나오는 것&lt;/strong&gt;입니다. 설명 메시지가 막으려던 바로 그 혼란을, 설명 메시지가 만들어냅니다.&lt;/p&gt;
&lt;p&gt;문제는 이 추론이 콘솔 문구 한 줄에만 기대고 있다는 점이었습니다. 이 PR 전체가 「어느 쪽이 부르는가」에 달려 있는데, 근거가 마케팅 문구 하나였습니다.&lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;h3&gt;문서가 답을 주지 않았습니다&lt;/h3&gt;
&lt;p&gt;Google 개발자 문서를 뒤졌습니다. IDFA 설명 메시지를 만드는 방법은 있는데, &lt;strong&gt;코드 샘플도 순서 지침도 없었습니다.&lt;/strong&gt; 앱이 ATT를 직접 불러야 하는지, 부르면 안 되는지, 부른다면 언제인지 — 어디에도 없었습니다.&lt;/p&gt;
&lt;p&gt;Apple 문서에도 없습니다. Apple 입장에서 UMP는 서드파티 SDK일 뿐입니다.&lt;/p&gt;
&lt;p&gt;추측으로 배선을 바꿀 수는 없었습니다. 틀리면 사용자마다 딱 한 번뿐인 프롬프트를 잘못 태우게 되고, 되돌릴 방법이 없습니다.&lt;/p&gt;
&lt;h3&gt;바이너리에는 답이 있었습니다&lt;/h3&gt;
&lt;p&gt;그래서 UMP 프레임워크 바이너리를 직접 읽었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UserMessagingPlatform 바이너리:
  _objc_msgSend$requestTrackingAuthorizationWithCompletionHandler:
  _objc_msgSend$trackingAuthorizationStatus
  &quot;ATTrackingManager&quot;   ← 문자열(동적 조회)

otool -L: AppTrackingTransparency 하드 링크 없음
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;세 가지가 한 번에 드러났습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;UMP는 &lt;code&gt;requestTrackingAuthorizationWithCompletionHandler:&lt;/code&gt;를 &lt;strong&gt;호출합니다.&lt;/strong&gt; 심볼이 바이너리에 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ATTrackingManager&lt;/code&gt;를 &lt;strong&gt;문자열로 동적 조회&lt;/strong&gt;합니다. 클래스를 컴파일 타임에 참조하지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;otool -L&lt;/code&gt;에 &lt;code&gt;AppTrackingTransparency&lt;/code&gt; 하드 링크가 &lt;strong&gt;없습니다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;2번과 3번이 함께 의미하는 것은, UMP가 이 프레임워크에 의존하되 &lt;strong&gt;자기가 링크하지는 않는다&lt;/strong&gt;는 것입니다. 앱이 &lt;code&gt;AppTrackingTransparency&lt;/code&gt;를 링크해야 UMP의 동적 조회가 성공합니다. 링크가 빠졌을 때 어떤 증상이 나오는지는 재현해보지 않았습니다 — 여기까지가 심볼이 말해주는 범위입니다.&lt;/p&gt;
&lt;p&gt;이 확인이 이 글에서 가장 오래 걸린 부분이자, 나머지 결정을 전부 좌우한 부분입니다. 문서에 없다고 알 수 없는 건 아니었습니다.&lt;/p&gt;
&lt;h3&gt;계약이 반대 방향으로 뒤집혔습니다&lt;/h3&gt;
&lt;p&gt;답을 알고 나니 한 달 전 계약이 다시 보였습니다.&lt;/p&gt;
&lt;p&gt;「&lt;strong&gt;UMP 폼 노출 여부와 무관하게&lt;/strong&gt; 호출한다」는 것은, UMP가 ATT를 안 부른다는 전제 위에서만 옳습니다. UMP가 부른다면 그 「무관하게」가 바로 경합의 원인입니다.&lt;/p&gt;
&lt;p&gt;한 달 전에는 &lt;strong&gt;ATT를 UMP에 묶지 않는 것&lt;/strong&gt;이 결함을 막는 방법이었고, 지금은 &lt;strong&gt;ATT를 UMP 뒤에 묶는 것&lt;/strong&gt;이 결함을 막는 방법이 됐습니다. 같은 축의 반대편으로 넘어간 셈입니다.&lt;/p&gt;
&lt;h2&gt;이렇게 바꿨습니다&lt;/h2&gt;
&lt;p&gt;네 가지를 바꿨습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;변경&lt;/th&gt;
&lt;th&gt;왜&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;①&lt;/td&gt;
&lt;td&gt;ATT 요청을 UMP 흐름 &lt;strong&gt;뒤로&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;경합 제거. UMP가 이미 물었으면 즉시 반환(&lt;code&gt;.notDetermined&lt;/code&gt;가 아니므로) — 이중 프롬프트 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;②&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;지우지는 않았다&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;UMP가 ATT를 띄우는 건 메시지가 &lt;strong&gt;게시돼 있을 때뿐&lt;/strong&gt;. 지우면 미게시 상태에서 ATT를 영영 안 물어 IDFA가 사라지고 AdMob이 전부 비개인화로 떨어진다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③&lt;/td&gt;
&lt;td&gt;폼이 «필요 없어도» UMP 흐름을 한 번&lt;/td&gt;
&lt;td&gt;&lt;code&gt;needsForm&lt;/code&gt;은 &lt;strong&gt;동의 폼&lt;/strong&gt;만 가리킨다. IDFA 설명은 그 축이 아니라서, 이 분기가 없으면 비EEA는 흐름 자체가 안 돌아 게시해도 설명이 안 뜬다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;④&lt;/td&gt;
&lt;td&gt;&lt;code&gt;maybeStartAdMob()&lt;/code&gt;은 막지 않음&lt;/td&gt;
&lt;td&gt;주 시장은 광고 체인이 AdFit으로 고정이라 기다려 얻는 AdMob 첫 노출이 없다. 기다리면 모달 ATT가 배너 앞을 가로막기만 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;②가 이 변경의 안전판입니다. 호출이 멱등이라 — &lt;code&gt;.notDetermined&lt;/code&gt;일 때만 실제로 묻습니다 — &lt;strong&gt;게시 전과 후 두 상태 모두에서 옳습니다.&lt;/strong&gt; 콘솔 게시와 코드 머지의 순서를 맞출 필요가 없어집니다. 되돌릴 수 없는 자원을 다룰 때, 순서 의존을 하나 없애는 것은 그 자체로 값어치가 있습니다.&lt;/p&gt;
&lt;p&gt;③은 한 달 전 감사가 남긴 교훈과 같은 축입니다. 그때는 「폼 콜백에 넣으면 비EEA는 ATT가 영영 안 뜬다」였고, 이번엔 「폼이 필요 없다고 흐름을 건너뛰면 비EEA는 설명이 영영 안 뜬다」입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;case .none, .formOnly:
    guard AdsConfig.adMobEnabled else { break }
    // 폼이 «필요 없어도» UMP 흐름을 한 번 돌립니다.
    // 메시지가 게시돼 있지 않으면 즉시 반환합니다(no-op).
    umpFlowRan = await consentProvider.presentConsentForm()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;if AdsConfig.adMobEnabled, umpFlowRan {
    Task { await ATTAuthorization.request() }
}
maybeStartAdMob()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 코드가 해결하는 것은 경합과 「설명 없는 ATT」입니다. 남는 한계는 메시지를 아직 게시하지 않았다면 여전히 앱이 ATT를 묻는다는 점인데, 그건 ②가 의도한 동작입니다.&lt;/p&gt;
&lt;h3&gt;적대적 리뷰가 잡은 두 가지&lt;/h3&gt;
&lt;p&gt;여기까지 하고 코드 리뷰에 넣었더니 두 개가 더 나왔습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 동의 갱신 실패가 「폼 없음」에 접혀 있었습니다.&lt;/strong&gt; 네트워크 오류로 UMP 상태를 못 읽은 경우입니다. 분기를 &lt;code&gt;if&lt;/code&gt; / &lt;code&gt;else if&lt;/code&gt;로 짜뒀더니 &lt;code&gt;.refreshFailed&lt;/code&gt;가 조용히 &lt;code&gt;.none&lt;/code&gt; 갈래로 흘렀고, UMP가 캐시된 「폼 없음」으로 성공 반환하면서 그 뒤 ATT가 태워졌습니다. 갱신이 실패했다는 건 설명 메시지가 게시됐는지도 모른다는 뜻인데 말입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;switch prompt {
case .refreshFailed:
    // 게시 여부를 모르는 상태입니다. 아무것도 하지 않습니다.
    // 동의 상태는 UMP가 마지막으로 아는 값이 유지되고, 다음 런치에 재시도됩니다.
    break
case .introThenForm:
    umpFlowRan = await presentConsentIntro()
case .none, .formOnly:
    // 위 참조
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;switch&lt;/code&gt;로 바꾼 것이 수정의 전부입니다. 케이스가 늘어날 때 「그 밖」이 기존 갈래에 조용히 접히지 않도록, 컴파일러가 결정을 강요하게 만든 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 안내 시트를 닫은 경로가 남아 있었습니다.&lt;/strong&gt; DailySudoku는 UMP 폼 앞에 커스텀 안내 시트를 하나 둡니다. 사용자가 그 시트를 scrim이나 그래버로 닫으면 &lt;code&gt;presentConsentForm()&lt;/code&gt;이 불리지 않고, UMP는 설명을 띄울 기회를 못 갖습니다. 그 상태에서 앱이 ATT를 띄우면 &lt;strong&gt;설명 없는 ATT&lt;/strong&gt;가 됩니다. 이번 변경이 없애려던 결함이 그대로 남습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;umpFlowRan&lt;/code&gt; 게이트가 여기서 일합니다. 흐름이 돌지 않았으면 묻지 않습니다. 안내 시트는 런치마다 다시 뜨므로 다음 기회가 있습니다.&lt;/p&gt;
&lt;p&gt;그리고 이 게이트가 한 달 전의 「&lt;strong&gt;폼 노출 여부와 무관하게&lt;/strong&gt;」를 최종적으로 뒤집었습니다.&lt;/p&gt;
&lt;h3&gt;게이트가 두 종류였습니다&lt;/h3&gt;
&lt;p&gt;부수적으로 하나 더 걸렸습니다. 바깥 가드는 「제3자 광고가 켜져 있는가」였는데, 광고 제공자가 여럿이라 이 가드는 &lt;strong&gt;그중 아무거나 하나만 켜져 있어도&lt;/strong&gt; 통과합니다. 그래서 AdMob이 꺼진 구성에서도 ATT 경로에 도달했습니다.&lt;/p&gt;
&lt;p&gt;그런데 그중 하나는 &lt;code&gt;WKWebView&lt;/code&gt; 기반이라 IDFA를 쓰지 않습니다. 쓰지도 않는 추적 권한을 사용자에게 미리 요구할 이유가 없습니다. 그래서 ATT와 UMP 호출의 게이트만 IDFA를 실제로 쓰는 제공자 쪽으로 좁혔습니다.&lt;/p&gt;
&lt;h2&gt;ATTAuthorization이 지키는 두 가지&lt;/h2&gt;
&lt;p&gt;순서와 별개로, ATT 요청 자체에도 함정이 두 개 있습니다. &lt;code&gt;AppTrackingTransparency&lt;/code&gt; import를 한 파일에 격리해두고 거기서 처리합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 앱이 &lt;code&gt;.active&lt;/code&gt;일 때 불러야 합니다.&lt;/strong&gt; 그렇지 않으면 팝업 없이 &lt;code&gt;.notDetermined&lt;/code&gt;로 즉시 반환합니다. &lt;code&gt;didFinishLaunching&lt;/code&gt;이나 &lt;code&gt;willConnectTo&lt;/code&gt;에서 직접 부르면 걸리는 함정입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static func request() async {
    guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
    if UIApplication.shared.applicationState != .active {
        await withCheckedContinuation { continuation in
            var observer: NSObjectProtocol?
            observer = NotificationCenter.default.addObserver(
                forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
            ) { _ in
                if let observer { NotificationCenter.default.removeObserver(observer) }
                continuation.resume()
            }
        }
    }
    await withCheckedContinuation { continuation in
        ATTrackingManager.requestTrackingAuthorization { _ in continuation.resume() }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;호출 경로상 앞의 UMP 네트워크 왕복이 서스펜드하는 동안 메인 런루프가 &lt;code&gt;sceneDidBecomeActive&lt;/code&gt;를 처리하므로 대체로 active에 도달합니다. 하지만 그건 호출부의 &lt;strong&gt;우연한 타이밍&lt;/strong&gt;이지 이 함수가 보장하는 제약이 아닙니다. 그래서 여기서 직접 기다립니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, &lt;code&gt;.notDetermined&lt;/code&gt;일 때만 부릅니다.&lt;/strong&gt; 이미 응답한 상태면 Apple이 팝업 없이 즉시 완료하므로 매 런치 호출해도 안전하지만, 그 왕복조차 생략합니다. 그리고 이 가드가 앞서 말한 ②의 안전판을 성립시킵니다.&lt;/p&gt;
&lt;h2&gt;확인한 방법&lt;/h2&gt;
&lt;p&gt;이 글은 2026년 8월 16일 DailySudoku &lt;code&gt;develop&lt;/code&gt; 브랜치, 커밋 &lt;code&gt;2ae29c42&lt;/code&gt;부터 &lt;code&gt;657d87c7&lt;/code&gt;까지의 iOS 구현을 기준으로 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS &lt;code&gt;xcodebuild test&lt;/code&gt; — &lt;strong&gt;301 tests / 60 suites 통과&lt;/strong&gt;. Android &lt;code&gt;compileDebugKotlin&lt;/code&gt; 통과.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;시뮬레이터 실측&lt;/strong&gt;: 첫 런치에서 ATT 프롬프트가 그대로 뜬다. 호출 위치를 옮겨도 깨지지 않는다는 것이 이 변경의 핵심 회귀 검증이다. 새로 추가한 「미게시 → no-op」 분기가 예상 밖 모달을 만들지 않는 것도 같은 실행에서 확인했다.&lt;/li&gt;
&lt;li&gt;두 번째 런치 로그도 확인: &lt;code&gt;[ATTrackingManager] Returning from trackingAuthorizationStatus - 0&lt;/code&gt;(미결정) → 재요청.&lt;/li&gt;
&lt;li&gt;미국 주 지리 시뮬레이션 훅을 검증 수단으로 추가: iOS &lt;code&gt;-AdMobDebugUSState&lt;/code&gt;, Android &lt;code&gt;--ez admobDebugUsState true&lt;/code&gt; (&lt;code&gt;UMPDebugGeographyRegulatedUSState&lt;/code&gt; = 3, SDK 헤더로 확인).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;못 잰 것&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;simctl&lt;/code&gt;에는 ATT 응답을 주입하는 API가 없습니다. &lt;code&gt;simctl privacy grant tracking&lt;/code&gt;은 &lt;em&gt;Operation not permitted&lt;/em&gt;로 거부됩니다. 그래서 &lt;strong&gt;「응답 후 재프롬프트 없음」은 측정하지 못했습니다.&lt;/strong&gt; 그 성질은 &lt;code&gt;guard status == .notDetermined&lt;/code&gt;와 iOS의 일회성 계약에 기대고 있고, 실기기 QA 몫으로 남겼습니다.&lt;/p&gt;
&lt;p&gt;같은 이유로 **「게시 후 두 번째 런치에서 설명 메시지가 다시 뜨지 않는가」**도 이 시점에는 잴 수 없었습니다. 게시가 선행 조건이기 때문입니다.&lt;/p&gt;
&lt;p&gt;바이너리 심볼 조회는 특정 UMP 버전에서 확인한 것입니다. Google 공식 문서가 명시하는 동작이 아니므로, SDK를 올릴 때 다시 확인해야 합니다.&lt;/p&gt;
&lt;h2&gt;사이드 프로젝트라서 배운 것&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;문서에 없으면 바이너리를 봅니다.&lt;/strong&gt; 혼자 만들면 물어볼 팀이 없습니다. 그런데 이번 경우 팀이 있었어도 답은 같은 곳에 있었을 겁니다. 「문서에 없다」와 「알 수 없다」는 다릅니다. &lt;code&gt;otool&lt;/code&gt;과 심볼 목록으로 30분이면 확인되는 것을, 추측으로 배선했다면 되돌릴 수 없는 자원을 잘못 태웠을 겁니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;배포 준비가 검증 수단이 됐습니다.&lt;/strong&gt; 이 결함은 테스트가 잡은 게 아닙니다. AdMob 콘솔에 메시지 초안을 만들고 게시 버튼 앞까지 가본 것이 잡았습니다. 코드 안에서만 맴돌면 안 보이는 결함이 있고, 실제 배포 경로를 끝까지 밟아보는 것이 그걸 드러냅니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;한 방향으로만 옳은 결정이 있습니다.&lt;/strong&gt; 한 달 전 감사는 「ATT를 UMP에 묶지 마라」를 정확히 잡아냈고, 그 결정은 그 시점에 옳았습니다. 전제가 하나 바뀌자 같은 결정이 반대편 결함이 됐습니다. 결정을 기록할 때 결론만 적으면 이런 반전을 못 따라갑니다. &lt;strong&gt;어떤 전제 위에서 옳은지&lt;/strong&gt;를 함께 적어야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;그리고 계약을 바꾸면 주석도 바꿔야 합니다.&lt;/strong&gt; 이 글을 쓰면서 &lt;code&gt;ATTAuthorization.swift&lt;/code&gt;의 문서 주석이 아직 한 달 전 계약을 담고 있다는 걸 발견했습니다. 「폼 노출 여부와 &lt;strong&gt;무관하게&lt;/strong&gt; 호출한다」, 「&lt;code&gt;refreshConsent()&lt;/code&gt; 완료 &lt;strong&gt;직후&lt;/strong&gt;」, 「ATT는 UMP와 &lt;strong&gt;독립된&lt;/strong&gt; Apple 플랫폼 요건」 — 세 줄 다 지금은 사실이 아닙니다. 같은 변경이 다른 파일의 낡은 주석 두 건은 정정했는데, 정작 계약을 바꾼 파일은 지나쳤습니다. 다음에 이 파일을 읽는 사람이 주석을 믿으면 이 글이 설명한 결함을 그대로 되살리게 됩니다. 별도 이슈로 올려뒀습니다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/apptrackingtransparency&quot;&gt;App Tracking Transparency - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/apptrackingtransparency/attrackingmanager/requesttrackingauthorization(completionhandler:)&quot;&gt;requestTrackingAuthorization(completionHandler:) - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/ios/privacy&quot;&gt;User Messaging Platform for iOS - Google AdMob&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;DailySudoku - App Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;DailySudoku - Google Play&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;DailySudoku 웹&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Swift</category><category>Advertising</category><category>Privacy</category><category>ATT</category></item><item><title>경고 주석은 있는데 대조는 없었다: ITSAppUsesNonExemptEncryption 두 원천 검증</title><link>https://jaemyeong.com/ko/blog/export-compliance-two-source-validation/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/export-compliance-two-source-validation/</guid><description>수출 규정 선언은 Info.plist와 제출 선언 파일 두 곳에 있습니다. 어긋나면 자동 제출이 멈추는데, 「한쪽만 고치지 말 것」이라는 경고 주석만 있고 실제로 대조하는 코드는 없었습니다.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 태그를 밀면 App Store Connect에 심사 제출까지 자동으로 갑니다.&lt;/p&gt;
&lt;p&gt;그 자동 제출을 &lt;a href=&quot;/ko/blog/app-store-connect-idempotent-submission/&quot;&gt;멱등하게 만드는 작업&lt;/a&gt;을 하다가 곁가지로 붙은 것이 수출 규정 선언 검증입니다. 곁가지인 줄 알았는데 네 번을 고쳤고, 네 번 다 제가 만든 결함이었습니다.&lt;/p&gt;
&lt;p&gt;수출 규정은 App Store에 앱을 올릴 때 암호화 사용 여부를 신고하는 절차입니다. 그리고 이 신고값이 &lt;strong&gt;두 곳에 있습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Info.plist&lt;/code&gt;의 &lt;code&gt;ITSAppUsesNonExemptEncryption&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;제출 자동화가 읽는 선언 파일&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;두 값이 어긋나면 App Store Connect가 매 제출마다 되묻고, &lt;strong&gt;자동 제출이 거기서 멈춥니다.&lt;/strong&gt; 그래서 선언 파일에는 이런 주석이 달려 있었습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Info.plist&lt;/code&gt;의 &lt;code&gt;ITSAppUsesNonExemptEncryption&lt;/code&gt;이 false다. 둘이 어긋나면 ASC가 되묻는다 — &lt;strong&gt;한쪽만 고치지 말 것.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;경고는 있었습니다. 그런데 &lt;strong&gt;그 대조를 아무도 하고 있지 않았습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이 글은 그 사실을 알아채기까지 네 번 잘못 고친 기록입니다.&lt;/p&gt;
&lt;h2&gt;처음 구현은 이랬습니다&lt;/h2&gt;
&lt;p&gt;제출 판정에는 이미 상태 검사가 있었습니다. 버전 상태에 「수출 규정 응답 대기」가 있고, 그 상태면 사람에게 넘깁니다.&lt;/p&gt;
&lt;p&gt;그래서 처음에는 이렇게 정리했습니다. &lt;strong&gt;「신고값은 상태 기계가 덮는다.」&lt;/strong&gt; 상태가 대기 중이면 멈추니, 별도 대조는 필요 없다고 본 것입니다.&lt;/p&gt;
&lt;h2&gt;첫 번째 오류 — 「해결됨」은 「우리 값과 일치」가 아닙니다&lt;/h2&gt;
&lt;p&gt;논거가 틀렸습니다.&lt;/p&gt;
&lt;p&gt;「수출 규정 응답 대기」 상태는 &lt;strong&gt;「아직 답하지 않았다」만&lt;/strong&gt; 잡습니다. 답이 &lt;strong&gt;내가 승인한 값과 같은지&lt;/strong&gt;는 말하지 않습니다. 두 질문이 다른데 하나로 읽은 것입니다.&lt;/p&gt;
&lt;p&gt;그러면 이런 경로가 열립니다. 사람이 App Store Connect 화면에서 직접 답했거나, 앞선 자동 실행이 다른 값을 넣었다고 해봅시다. 상태는 「해결됨」이 됩니다. 그리고 빌드도 릴리스 노트도 출시 방식도 전부 맞으면, 제출 판정은 성공을 돌려줍니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;승인하지 않은 신고로 심사가 진행되는데 성공이 보고됩니다.&lt;/strong&gt; 잘못된 신고는 리젝이나 정책 위반 사유가 됩니다.&lt;/p&gt;
&lt;p&gt;그래서 실제 신고값을 읽어 승인값과 대조하도록 고쳤습니다. 미응답도 통과시키지 않았습니다.&lt;/p&gt;
&lt;h2&gt;두 번째 오류 — 키 하나가 가드를 차단기로 만들었습니다&lt;/h2&gt;
&lt;p&gt;30분 뒤, 방금 넣은 그 가드가 &lt;strong&gt;모든 재시도를 영구히 막고 있다&lt;/strong&gt;는 것을 알게 됐습니다.&lt;/p&gt;
&lt;p&gt;승인값을 읽어 오는 함수가 키를 &lt;strong&gt;심볼&lt;/strong&gt;로 담고 있었는데, 저는 &lt;strong&gt;문자열&lt;/strong&gt;로 조회했습니다. Ruby에서 이 둘은 다른 키입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;문자열로 조회: nil     ← 옛 코드
심볼로 조회  : false   ← 고친 코드 (= 선언 파일의 승인값)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;승인값이 항상 &lt;code&gt;nil&lt;/code&gt;이니 App Store Connect가 무슨 값을 갖고 있든 **「다르다」**가 됩니다. 거짓 성공을 막으려던 가드가 다시 영구 차단기가 됐습니다.&lt;/p&gt;
&lt;p&gt;이게 같은 작업에서 &lt;strong&gt;세 번째로 같은 형태&lt;/strong&gt;였습니다. 앞서 승격 대조에서 한 번, 로케일 식별자에서 한 번, 그리고 이번입니다. 가드를 붙일 때마다 그 가드가 상시 발동하는 경로를 함께 만들고 있었던 것입니다.&lt;/p&gt;
&lt;p&gt;여기서 self-test에 &lt;strong&gt;키 타입 계약&lt;/strong&gt;을 넣었습니다. 종전 검사는 키 개수와 값 타입만 봤습니다. &lt;strong&gt;어떤 키로 꺼내는지는 아무도 안 봤습니다.&lt;/strong&gt; 그래서 실제로 쓰는 그 키로 꺼내 보는 단언을 추가했습니다.&lt;/p&gt;
&lt;h2&gt;세 번째 오류 — nil이 정상 상태였습니다&lt;/h2&gt;
&lt;p&gt;여기서 리뷰를 기다리지 않고, 이 작업이 세 번 만든 형태(가드가 영구 차단기)를 스스로 훑어봤습니다. &lt;strong&gt;네 번째 후보가 나왔습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;앞 커밋에서 저는 App Store Connect 쪽 신고값이 &lt;code&gt;nil&lt;/code&gt;이면 실패로 봤습니다. 미응답이니 막아야 한다고 본 것입니다.&lt;/p&gt;
&lt;p&gt;그런데 이 앱은 &lt;code&gt;Info.plist&lt;/code&gt;에 &lt;code&gt;ITSAppUsesNonExemptEncryption&lt;/code&gt;을 구워서 냅니다. 그러면 App Store Connect가 &lt;strong&gt;바이너리에서&lt;/strong&gt; 규정 준수를 해결하고, Build 레코드의 신고 필드는 &lt;strong&gt;채워지지 않을 수 있습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;근거는 제출 도구 자신에게 있었습니다. 그 도구도 이 값을 &lt;strong&gt;&lt;code&gt;nil&lt;/code&gt;일 때만&lt;/strong&gt; 쓰려고 합니다. 즉 &lt;strong&gt;&lt;code&gt;nil&lt;/code&gt;이 오류가 아니라 정상 상태&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;p&gt;그대로 뒀다면 정당한 재시도가 전부 막혔을 겁니다. 네 번째 영구 차단기를, 이번엔 배포 전에 잡은 셈입니다.&lt;/p&gt;
&lt;p&gt;그래서 런타임 대조는 &lt;strong&gt;App Store Connect가 값을 갖고 있을 때만&lt;/strong&gt; 하도록 좁혔습니다. 규정 준수가 정말 미해결이면 버전 상태가 「응답 대기」라 판정이 이미 멈춥니다.&lt;/p&gt;
&lt;h2&gt;그럼 그 축은 누가 보나&lt;/h2&gt;
&lt;p&gt;런타임 대조를 좁히고 나니 질문이 남았습니다. &lt;strong&gt;두 선언이 어긋나는 상황은 이제 누가 잡나요?&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;두 선언의 정적 대조&lt;/h3&gt;
&lt;p&gt;진짜 원천은 App Store Connect의 응답이 아니라 &lt;strong&gt;저장소 안의 두 파일&lt;/strong&gt;이었습니다. &lt;code&gt;Info.plist&lt;/code&gt;와 제출 선언 파일. 어긋나는 순간은 둘 중 하나만 고칠 때고, 그건 코드를 커밋하는 시점에 이미 결정됩니다.&lt;/p&gt;
&lt;p&gt;그래서 네트워크 없이 도는 정적 대조를 넣었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plist_value = plist[key]                    # Info.plist
declared = decl[field]                      # 제출 선언 파일
assert plist_value == declared, (
    &quot;수출 규정 선언이 두 곳에서 어긋난다 — ASC가 되묻거나 잘못된 신고로 심사가 진행된다. &quot;
    &quot;**한쪽만 고치지 말 것**(선언 파일 주석).&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;주석이 경고하던 그 대조입니다. 경고를 읽고 사람이 지키기를 기대하는 대신, 매 PR에서 기계가 확인하게 했습니다.&lt;/p&gt;
&lt;h3&gt;문자열로 읽으면 조용히 통과합니다&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Info.plist&lt;/code&gt;를 읽는 방법도 함정이었습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;plutil&lt;/code&gt;로 값을 뽑으면 &lt;code&gt;&amp;lt;string&amp;gt;false&amp;lt;/string&amp;gt;&lt;/code&gt;도 문자열 &lt;code&gt;&quot;false&quot;&lt;/code&gt;로 돌려줍니다. 그걸 &lt;code&gt;== &quot;true&quot;&lt;/code&gt; 같은 비교로 접으면 어떻게 될까요. &lt;strong&gt;Boolean이 아닌 선언이 조용히 통과합니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;App Store Connect는 이 키를 Boolean으로 요구합니다. 그래서 표준 라이브러리 &lt;code&gt;plistlib&lt;/code&gt;로 읽어 &lt;strong&gt;타입까지&lt;/strong&gt; 봅니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;assert isinstance(plist_value, bool), (
    f&quot;Info.plist 의 {key} 가 Boolean 이 아니다: {plist_value!r}. &quot;
    &quot;ASC 는 이 키를 Boolean 으로 요구한다 — &amp;lt;true/&amp;gt; 또는 &amp;lt;false/&amp;gt; 여야 한다&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;키가 아예 없는 경우도 막습니다. 없으면 App Store Connect가 매 제출마다 되묻고 자동 제출이 거기서 멈춥니다.&lt;/p&gt;
&lt;h3&gt;검사를 어디에 두느냐가 검사의 성립 조건입니다&lt;/h3&gt;
&lt;p&gt;이 대조를 처음에는 iOS 릴리스 워크플로의 self-test에 넣었습니다. 그리고 그 배치가 틀렸습니다.&lt;/p&gt;
&lt;p&gt;그 워크플로의 PR 트리거는 &lt;code&gt;apps/ios/**&lt;/code&gt; 경로로 걸려 있습니다. 그런데 &lt;strong&gt;제출 선언 파일은 그 경로에 없습니다.&lt;/strong&gt; 즉 선언 파일만 바꾼 PR에서는 이 검사가 아예 돌지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;이 검사가 잡으려던 바로 그 변경이, 검사를 우회하는 배치였습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;그래서 모든 PR과 push에서 도는 스토어 메타데이터 검증기로 옮겼습니다. 그 검증기는 두 파일 다 자기 주제로 다루고 있어서, 자리로도 더 맞았습니다.&lt;/p&gt;
&lt;h2&gt;확인한 방법&lt;/h2&gt;
&lt;p&gt;이 글은 2026년 8월 16일 DailySudoku &lt;code&gt;develop&lt;/code&gt; 브랜치의 스토어 메타데이터 검증기와 iOS 릴리스 자동화를 기준으로 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;정적 대조는 네트워크 없이 돌아, 스토어 메타데이터 검증 잡이 &lt;strong&gt;모든 PR과 push&lt;/strong&gt;에서 실행합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;뮤테이션으로 검증했습니다.&lt;/strong&gt; &lt;code&gt;Info.plist&lt;/code&gt;의 값을 반대로 뒤집으면 이 단언이 실제로 잡습니다. 뮤테이션으로 검증 코드 자체를 확인하는 방법은 &lt;a href=&quot;/ko/blog/verification-script-negative-control/&quot;&gt;검증 스크립트의 자기검증 글&lt;/a&gt;에서 정리했습니다.&lt;/li&gt;
&lt;li&gt;키 타입 계약도 self-test가 잠급니다 — 실제로 쓰는 그 키로 꺼내 봅니다.&lt;/li&gt;
&lt;li&gt;이 작업이 참조한 젬 접근자와 상수를 고정된 소스와 1:1로 전수 대조했습니다. 반복된 결함의 뿌리가 **「이름을 확인 없이 참조」**였으므로 그 목록을 한 번에 닫았습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;못 잰 것&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;두 선언이 어긋난 상태로 실제 제출을 시도해 본 기록은 없습니다.&lt;/strong&gt; App Store Connect가 되묻는 화면까지 밟아보려면 실제로 잘못된 신고를 올려야 하는데, 심사 큐를 상대로 할 실험이 아닙니다. 이 검사의 정당성은 선언 파일 주석에 적힌 경험과 API 문서의 요구사항에 기대고 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;nil&lt;/code&gt;이 정상이라는 판단의 근거는 제출 도구의 코드입니다.&lt;/strong&gt; 도구가 그 값을 &lt;code&gt;nil&lt;/code&gt;일 때만 쓴다는 것을 소스에서 확인했지만, App Store Connect가 어떤 조건에서 Build 레코드를 채우는지는 문서로 확인하지 못했습니다.&lt;/p&gt;
&lt;h2&gt;사이드 프로젝트라서 배운 것&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;경고 주석은 검사가 아닙니다.&lt;/strong&gt; 선언 파일에는 「한쪽만 고치지 말 것」이라고 분명히 적혀 있었습니다. 문제는 그 문장이 &lt;strong&gt;사람이 읽어야만 작동한다&lt;/strong&gt;는 것입니다. 주석이 정확할수록 오히려 위험합니다. 정확한 경고를 보면 「이건 관리되고 있구나」라고 느끼게 되니까요. 주석에 적을 만큼 중요한 계약이라면, 그건 대체로 단언으로 적을 수 있는 계약입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;같은 실수를 세 번 하고 나서야 패턴이 보였습니다.&lt;/strong&gt; 가드를 붙일 때마다 그 가드가 상시 발동하는 경로를 함께 만들고 있었는데, 세 번째에 가서야 「이게 반복되는 형태구나」를 알아챘습니다. 그리고 그때 리뷰를 기다리지 않고 같은 형태로 스스로 훑어본 것이 네 번째를 잡았습니다. &lt;strong&gt;결함을 고치는 것과 결함의 형태를 아는 것은 다른 일이고, 후자가 훨씬 오래 갑니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;검사의 자리가 검사의 성립 조건입니다.&lt;/strong&gt; 경로 필터 뒤에 둔 검사는 그 경로를 안 건드리는 변경에 대해 존재하지 않는 것과 같습니다. 검사를 추가했으면 「이 검사가 잡으려는 변경이 이 검사를 돌게 하는가」를 한 번 물어야 합니다. 저는 안 물었고, 그래서 잡으려던 변경이 검사를 우회하는 배치를 만들었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;그리고 네 번 다 제가 만든 결함이었습니다.&lt;/strong&gt; 원래 있던 버그를 고친 게 아니라, 고치는 과정에서 만든 것을 다시 고쳤습니다. 혼자 만드는 프로젝트에서는 이게 흔한 모양입니다. 안전장치를 붙이는 작업이 가장 위험한 작업이 되는 이유는, 그 장치가 실패하는 방향이 막으려던 방향보다 많기 때문인 것 같습니다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations&quot;&gt;Complying with Encryption Export Regulations - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption&quot;&gt;ITSAppUsesNonExemptEncryption - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/appstoreconnectapi&quot;&gt;App Store Connect API - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/3/library/plistlib.html&quot;&gt;plistlib - Python 표준 라이브러리&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.fastlane.tools/actions/deliver/&quot;&gt;fastlane deliver&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;DailySudoku - App Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;DailySudoku - Google Play&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;DailySudoku 웹&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>CI-CD</category><category>App Store Connect</category><category>Automation</category><category>Testing</category></item><item><title>@Suite(.serialized)로는 못 막는다: Swift Testing 병렬 실행과 전역 NotificationCenter</title><link>https://jaemyeong.com/ko/blog/parallel-test-global-notification-isolation/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/parallel-test-global-notification-isolation/</guid><description>CI에서만 죽는 테스트 하나를 쫓다 45분 hang의 원인을 두 번 진단했습니다. 첫 진단은 hang만 고쳤고, 진짜 원인은 전역 NotificationCenter를 구독하는 뷰와 병렬 실행이 만든 격리 결함이었습니다.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 무료 앱이라 배너 광고를 붙였고, 그 배너를 검증하는 테스트가 몇 개 있습니다.&lt;/p&gt;
&lt;p&gt;그중 하나가 &lt;strong&gt;CI에서만&lt;/strong&gt; 죽었습니다. 로컬에서는 0.02초에 통과하는데, CI에서는 잡 전체가 &lt;strong&gt;45분 타임아웃&lt;/strong&gt;까지 매달렸습니다.&lt;/p&gt;
&lt;p&gt;원인을 두 번 진단했습니다. 첫 번째 진단으로 hang은 사라졌지만 테스트는 여전히 실패했고, 두 번째에 가서야 진짜 원인이 나왔습니다. &lt;strong&gt;테스트 격리 결함&lt;/strong&gt;이었습니다.&lt;/p&gt;
&lt;p&gt;이 글은 그 두 번의 진단과, 흔히 처방되는 두 가지 해법이 왜 이 경우에 통하지 않는지를 정리한 기록입니다.&lt;/p&gt;
&lt;h2&gt;처음 구현은 이랬습니다&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;AdBannerView&lt;/code&gt;는 광고 관련 상태가 비동기로 바뀔 때 자기를 갱신해야 합니다. 광고 제거 구매가 반영되거나, 동의 상태가 바뀌거나, 앱이 포그라운드로 돌아오는 경우입니다.&lt;/p&gt;
&lt;p&gt;그래서 프로세스 전역 &lt;code&gt;NotificationCenter.default&lt;/code&gt;를 구독합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
    NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;didBecomeActiveNotification&lt;/code&gt;이 목록에 있는 것도 이유가 있습니다. 슬롯 활성 여부를 판정에 넣은 뒤로 「비활성 중에 멈춘 것들」이 생겼습니다. 게이트에 막혀 되돌아간 로드 시작, 그리고 미뤄진 노출 기록입니다. 그것들을 깨울 트리거가 없으면 슬롯이 빈 채로, 노출이 미기록인 채로 남습니다. &lt;code&gt;viewWillAppear&lt;/code&gt;로는 부족합니다 — &lt;strong&gt;이미 떠 있는 화면으로 복귀할 때는 그게 불리지 않기 때문&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;p&gt;앱 코드로서는 타당한 구조입니다. 문제는 테스트 프로세스 안에서 드러났습니다.&lt;/p&gt;
&lt;h2&gt;어디서 어긋났을까요&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;holdsImpressionUntilTheSlotIsLiveAgain&lt;/code&gt;이라는 테스트가 CI에서만 실패했습니다. 그리고 실패에서 끝나지 않고 잡이 45분 타임아웃까지 매달렸습니다.&lt;/p&gt;
&lt;p&gt;CI 러너를 여러 작업이 함께 쓰는 구성이라, 처음에는 부하 문제로 보였습니다. 실제로 그럴듯한 정황도 있었습니다.&lt;/p&gt;
&lt;h2&gt;첫 번째 진단은 hang만 고쳤습니다&lt;/h2&gt;
&lt;p&gt;hang 자체의 메커니즘은 금방 나왔습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#expect(await waitForChain(...))          // 실패해도 실행이 멈추지 않는다
while banner.shownKind == nil { ... }     // 그래서 여기서 영원히 대기한다
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swift Testing의 &lt;code&gt;#expect&lt;/code&gt;는 &lt;strong&gt;비치명적&lt;/strong&gt;입니다. 실패해도 그 자리에서 테스트를 끝내지 않고 다음 줄로 넘어갑니다. 그리고 다음 줄에 &lt;strong&gt;타임아웃 없는 &lt;code&gt;while&lt;/code&gt; 루프&lt;/strong&gt;가 있었습니다. 앞의 대기가 실패하면 이 루프는 영원히 돕니다.&lt;/p&gt;
&lt;p&gt;환경 쪽 정황도 실측했습니다. 체인 형성의 MainActor 비동기 단계가 다른 작업과 경합하면 몇 초 밀립니다. &lt;strong&gt;로컬 격리 실행 0.024초 대 경합 중 4초 관측창을 넘겨 놓친 사례&lt;/strong&gt;를 확인했습니다.&lt;/p&gt;
&lt;p&gt;무관한 원인들도 하나씩 배제했습니다. 광고 강제 라우팅 PR은 &lt;code&gt;releaseForcedProvider&lt;/code&gt;가 DEBUG 빌드에서 무조건 &lt;code&gt;nil&lt;/code&gt;이라 테스트 환경에 영향을 줄 수 없었고, 리뷰 요청 PR은 광고 디렉터리를 전혀 건드리지 않았습니다. git ancestry와 diff로 각각 확인했습니다.&lt;/p&gt;
&lt;p&gt;그래서 이렇게 고쳤습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;대기 타임아웃을 4초에서 8초로 늘려 광고 provider의 실 네트워크 타임아웃과 정렬&lt;/li&gt;
&lt;li&gt;같은 폴링 패턴의 &lt;code&gt;waitForShown&lt;/code&gt; 헬퍼 추가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;#expect&lt;/code&gt;를 &lt;code&gt;#require&lt;/code&gt;로 전환 — 실패하면 그 자리에서 멈춘다&lt;/li&gt;
&lt;li&gt;타임아웃 없는 &lt;code&gt;while&lt;/code&gt; 루프를 상한 있는 대기로 대체&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 수정으로 &lt;strong&gt;hang은 물리적으로 불가능&lt;/strong&gt;해졌습니다. 체인이 안 만들어지면 그 자리에서 멈추고, 로드가 오래 걸려도 8초에서 fail-fast합니다. 이건 그 자체로 옳은 수정이었습니다.&lt;/p&gt;
&lt;p&gt;그런데 테스트는 여전히 실패했습니다. &lt;strong&gt;45분 매달리던 것이 8초에 실패하게 됐을 뿐입니다.&lt;/strong&gt; 4초를 8초로 늘린 것은 원인을 짚지 못한 조치였습니다.&lt;/p&gt;
&lt;h2&gt;원인 — 전역 알림과 병렬 실행&lt;/h2&gt;
&lt;p&gt;환경 문제가 아니었습니다. 테스트 격리 결함이었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Swift Testing은 스위트를 병렬로 돌립니다.&lt;/strong&gt; 그리고 &lt;code&gt;AdBannerView&lt;/code&gt;는 프로세스 전역 &lt;code&gt;NotificationCenter.default&lt;/code&gt;를 구독합니다. 이 둘이 만나면 이렇게 됩니다.&lt;/p&gt;
&lt;p&gt;무관한 테스트가 &lt;code&gt;SudokuStore.didChange&lt;/code&gt;를 발생시키면 — 그 알림을 내는 테스트 파일이 &lt;strong&gt;7개&lt;/strong&gt;입니다 — 그 프로세스에 살아 있는 &lt;strong&gt;모든&lt;/strong&gt; 배너의 &lt;code&gt;refresh()&lt;/code&gt;가 동기로 불립니다. 그때 광고가 꺼진 상태면 &lt;code&gt;!enabled&lt;/code&gt; 분기가 &lt;code&gt;chain&lt;/code&gt;·&lt;code&gt;shownKind&lt;/code&gt;·&lt;code&gt;pendingImpression&lt;/code&gt;을 통째로 리셋하고 세대를 올립니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;무관한 테스트가 서로의 뷰 상태를 지웁니다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;왜 그 테스트만 죽었을까요&lt;/h3&gt;
&lt;p&gt;같은 파일의 다른 테스트는 멀쩡했습니다. 이유가 두 겹입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 리셋 분기가 열리는 시점이 다릅니다.&lt;/strong&gt; 이 테스트의 watcher는 광고 제거 여부를 &lt;code&gt;shownKind&lt;/code&gt;의 함수로 정의합니다. 그래서 &lt;code&gt;shownKind&lt;/code&gt;에 값이 대입되는 순간부터 리셋 분기가 열립니다. 그 전에 오는 알림은 무해합니다 — &lt;code&gt;refresh()&lt;/code&gt;가 &lt;code&gt;setNeedsLayout()&lt;/code&gt;만 하고 끝나기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 이 테스트만 회복 경로가 없었습니다.&lt;/strong&gt; 다른 테스트는 첫 대기 앞에 &lt;code&gt;layoutIfNeeded()&lt;/code&gt;가 있는데 이 테스트에는 없었습니다. 리셋된 배너가 재로드를 열려면 레이아웃 패스가 필요한데, 그게 오지 않으니 &lt;strong&gt;영영 회복하지 못합니다.&lt;/strong&gt; 그래서 8초 타임아웃까지 갑니다.&lt;/p&gt;
&lt;p&gt;두 조건이 겹친 테스트가 하나뿐이었던 것입니다. 로컬에서 안 죽은 이유는 단순합니다. 병렬로 함께 돌던 다른 스위트가 하필 그 타이밍에 알림을 쏘지 않았기 때문입니다.&lt;/p&gt;
&lt;h3&gt;재현으로 확정했습니다&lt;/h3&gt;
&lt;p&gt;여기까지는 가설입니다. 그래서 재현했습니다.&lt;/p&gt;
&lt;p&gt;알림을 쏘는 &lt;code&gt;Task&lt;/code&gt;를 하나 넣자 로컬에서 &lt;strong&gt;결정적으로&lt;/strong&gt; 재현됐습니다. 그리고 그때 관측한 것이 결정적이었습니다. &lt;strong&gt;병렬로 돌던 원래 테스트가 같은 줄에서 함께 죽었습니다.&lt;/strong&gt; 한 테스트의 전역 알림이 무관한 테스트를 죽인다는 것을 직접 본 것입니다.&lt;/p&gt;
&lt;h2&gt;이렇게 바꿨습니다&lt;/h2&gt;
&lt;p&gt;생성자 주입 한 줄입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;init(
    colors: SudokuColors,
    adsProvider: AdsProvider,
    consentProvider: ConsentProvider,
    notificationCenter: NotificationCenter = .default   // 추가
) {
    ...
    for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
        notificationCenter.addObserver(self, selector: #selector(refresh), name: name, object: nil)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기본값이 &lt;code&gt;.default&lt;/code&gt;라 &lt;strong&gt;프로덕션 동작은 바뀌지 않습니다.&lt;/strong&gt; 테스트 세 파일이 각자 새 인스턴스를 넘겨 전역 방송에서 격리됩니다.&lt;/p&gt;
&lt;p&gt;이게 「테스트를 위해 캡슐화를 낮추는 것」과 다르다는 점은 짚어둘 만합니다. 이 저장소는 &lt;code&gt;adsProvider&lt;/code&gt;·&lt;code&gt;consentProvider&lt;/code&gt;·&lt;code&gt;makeAd&lt;/code&gt;·&lt;code&gt;logger&lt;/code&gt;에 이미 같은 의존성 주입을 쓰고 있습니다. 새 구멍을 뚫은 게 아니라 기존 패턴에 하나를 더한 것입니다.&lt;/p&gt;
&lt;p&gt;프로덕션의 리셋 동작 자체는 건드리지 않았습니다. &lt;code&gt;refresh()&lt;/code&gt;와 &lt;code&gt;didMoveToWindow()&lt;/code&gt;의 리셋은 의도된 의미입니다 — 크리에이티브를 방금 떼어냈다는 뜻이니까요. 테스트가 불편하다고 프로덕션 의미를 바꾸면 안 됩니다.&lt;/p&gt;
&lt;h2&gt;기각한 대안 두 가지&lt;/h2&gt;
&lt;p&gt;이 증상에 가장 흔히 처방되는 두 가지가 여기서는 통하지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;@Suite(.serialized)&lt;/code&gt; — 스위트 안만 직렬화합니다.&lt;/strong&gt; 이 속성은 한 스위트에 속한 테스트들의 동시 실행을 막습니다. 그런데 여기서 알림을 쏘는 쪽은 대개 &lt;strong&gt;다른 스위트&lt;/strong&gt;입니다. 같은 스위트를 직렬화해도 바깥에서 날아오는 방송은 그대로입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;타임아웃 증가 — 회복 경로가 없으면 무의미합니다.&lt;/strong&gt; 리셋된 배너는 재로드를 여는 레이아웃 패스가 없어 영영 회복하지 않습니다. 8초를 80초로 늘려도, 무한대로 늘려도 실패합니다. 앞서 4초를 8초로 늘렸던 것이 정확히 이 함정이었습니다.&lt;/p&gt;
&lt;p&gt;두 처방의 공통점은 &lt;strong&gt;증상의 형태만 보고 고른 해법&lt;/strong&gt;이라는 것입니다. 「병렬이라 깨진다」에는 직렬화를, 「오래 기다리다 죽는다」에는 더 긴 대기를 붙이는 식입니다. 어느 쪽도 「누가 무엇을 지우는가」를 묻지 않습니다.&lt;/p&gt;
&lt;h2&gt;확인한 방법&lt;/h2&gt;
&lt;p&gt;이 글은 2026년 8월 14일 DailySudoku &lt;code&gt;develop&lt;/code&gt; 브랜치, 커밋 &lt;code&gt;d44808bb&lt;/code&gt;와 &lt;code&gt;b7646ee2&lt;/code&gt;의 iOS 구현을 기준으로 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS &lt;code&gt;xcodebuild test&lt;/code&gt; — &lt;strong&gt;299 tests / 60 suites 통과&lt;/strong&gt;. 회귀 테스트 1개를 추가해 298에서 299가 됐습니다.&lt;/li&gt;
&lt;li&gt;문제 테스트 단독 실행 0.023초. 전체 스위트 8.58초.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;뮤테이션으로 회귀 테스트 자체를 검증했습니다.&lt;/strong&gt; 격리를 &lt;code&gt;.default&lt;/code&gt;로 되돌리면 새 회귀 테스트와 &lt;strong&gt;원래 테스트가 함께&lt;/strong&gt; 실패합니다. CI에서 보던 증상 그대로입니다. 즉 이 테스트는 실제로 이 회귀를 잡습니다. 검증 코드가 진짜 결함을 탐지하는지 확인하는 방법은 &lt;a href=&quot;/ko/blog/verification-script-negative-control/&quot;&gt;검증 스크립트의 자기검증 글&lt;/a&gt;에서 따로 다뤘습니다.&lt;/p&gt;
&lt;h3&gt;못 잰 것&lt;/h3&gt;
&lt;p&gt;재현은 알림을 쏘는 &lt;code&gt;Task&lt;/code&gt;를 인위적으로 넣어 만든 것입니다. 그 조건에서 원래 테스트가 함께 죽는 것까지가 직접 관측한 범위이고, &lt;strong&gt;CI의 특정 실행에서 어느 스위트가 알림을 쐈는지까지 좁힌 근거는 아닙니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SudokuStore.didChange&lt;/code&gt;를 내는 테스트 파일이 7개라는 것도 정적 검색 결과입니다. 그중 어느 것이 문제 시점에 실제로 돌고 있었는지는 이 근거로 알 수 없습니다.&lt;/p&gt;
&lt;h2&gt;사이드 프로젝트라서 배운 것&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;「CI에서만 실패한다」는 환경 문제라는 뜻이 아닙니다.&lt;/strong&gt; 그렇게 읽는 순간 타임아웃을 늘리고 재시도를 붙이게 됩니다. 로컬과 CI의 진짜 차이는 머신 성능이 아니라 &lt;strong&gt;무엇이 동시에 돌고 있는가&lt;/strong&gt;였습니다. 첫 진단에서 실측한 「경합 중 4초 초과」는 사실이었지만 원인이 아니었습니다. 맞는 데이터가 틀린 결론을 지지할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;가설을 재현으로 승격시켜야 합니다.&lt;/strong&gt; 「전역 알림 때문일 것이다」까지는 코드를 읽어서 도달했지만, 그건 그럴듯한 이야기일 뿐입니다. 알림 쏘는 &lt;code&gt;Task&lt;/code&gt;를 넣어 원래 테스트가 함께 죽는 것을 본 순간에야 확정됐습니다. 재현 없이 고쳤다면 증상이 사라져도 그게 이 수정 때문인지 알 수 없었을 겁니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;회귀 테스트도 검증받아야 합니다.&lt;/strong&gt; 격리를 되돌렸을 때 실패하지 않는 회귀 테스트는 아무것도 지키지 않습니다. 테스트를 추가한 뒤에 한 번 깨뜨려 보는 데는 1분이 걸립니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫 수정이 틀렸다고 버릴 필요는 없습니다.&lt;/strong&gt; 타임아웃과 &lt;code&gt;#require&lt;/code&gt; 수정은 원인을 짚지 못했지만, hang을 물리적으로 불가능하게 만든 것은 그 자체로 가치가 있습니다. 다음에 다른 이유로 대기가 실패해도 이제 45분이 아니라 8초에 알게 됩니다. 원인 수정과 방어 수정은 둘 다 필요하고, 다만 &lt;strong&gt;방어를 원인 수정으로 착각하지 않으면 됩니다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/testing&quot;&gt;Swift Testing - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/testing/parallelization&quot;&gt;Parallelization - Swift Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/testing/expect(_:_:sourcelocation:)&quot;&gt;expect(&lt;em&gt;:&lt;/em&gt;:sourceLocation:) - Swift Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/testing/require(_:_:sourcelocation:)&quot;&gt;require(&lt;em&gt;:&lt;/em&gt;:sourceLocation:) - Swift Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/foundation/notificationcenter&quot;&gt;NotificationCenter - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;DailySudoku - App Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;DailySudoku - Google Play&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;DailySudoku 웹&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Swift</category><category>Testing</category><category>CI-CD</category><category>Troubleshooting</category></item><item><title>이미 만료된 Task.sleep은 throw하지 않는다: 버전당 1회 리뷰 요청 지키기</title><link>https://jaemyeong.com/ko/blog/task-sleep-cancellation-review-prompt-gate/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/task-sleep-cancellation-review-prompt-gate/</guid><description>AppStore.requestReview(in:)는 실패를 알려주지 않는데 게이트는 버전당 1회입니다. 같은 설계를 iOS와 Android에 옮겼더니 Swift Concurrency와 Kotlin의 취소 의미론 차이로 iOS에만 결함이 남았습니다.</description><pubDate>Mon, 17 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 최근에 게임을 이긴 뒤 스토어 리뷰를 요청하는 기능을 넣었습니다.&lt;/p&gt;
&lt;p&gt;iOS는 &lt;code&gt;AppStore.requestReview(in:)&lt;/code&gt;을 씁니다. 이 API에는 성질이 두 개 있는데, 둘이 겹치면 다루기가 까다로워집니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 실패를 알려주지 않습니다.&lt;/strong&gt; 반환값이 없고 완료 콜백도 없습니다. 다이얼로그가 떴는지 안 떴는지 앱은 알 수 없습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 시스템이 표시 횟수를 제한합니다.&lt;/strong&gt; 여기에 더해 제 앱은 자체적으로 「버전당 1회」 게이트를 뒀습니다. 한 버전에서 한 번 요청하면 다음 릴리스까지 다시 묻지 않습니다.&lt;/p&gt;
&lt;p&gt;두 성질을 합치면 이렇게 됩니다. &lt;strong&gt;아무도 보지 않는 화면에 대고 호출해도 앱은 성공한 줄 압니다.&lt;/strong&gt; 그리고 그 조용한 실패가 이번 버전의 유일한 기회를 태웁니다.&lt;/p&gt;
&lt;p&gt;이 글은 그 조용한 실패를 막으려고 짠 설계와, 그 설계에 두 번 남아 있던 구멍, 그리고 같은 설계를 Android에 옮겼을 때 &lt;strong&gt;iOS에만 결함이 생긴 이유&lt;/strong&gt;를 정리한 기록입니다.&lt;/p&gt;
&lt;h2&gt;처음 구현은 이랬습니다&lt;/h2&gt;
&lt;p&gt;「이겼을 때 요청한다」를 그대로 코드로 옮기면 완료 이벤트에서 &lt;code&gt;requestReview&lt;/code&gt;를 부르게 됩니다. 그런데 완료 이벤트 시점에는 결과 화면이 실제로 표시될지, 표시된다면 얼마나 오래 떠 있을지 알 수 없습니다.&lt;/p&gt;
&lt;p&gt;그래서 처음부터 세 단계로 나눴습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th&gt;시점&lt;/th&gt;
&lt;th&gt;하는 일&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;markPending()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;완료 이벤트&lt;/td&gt;
&lt;td&gt;자격 판정이 참이면 &lt;strong&gt;신호만&lt;/strong&gt; 세운다. StoreKit을 전혀 건드리지 않는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;screenDidShowCompletion()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;결과 화면이 실제로 표시될 때&lt;/td&gt;
&lt;td&gt;pending이면 &lt;strong&gt;그 시점부터&lt;/strong&gt; 2초 뒤 호출을 예약한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;screenWillDisappear()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;결과 화면이 사라지기 직전&lt;/td&gt;
&lt;td&gt;예약이 있으면 취소한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;2초 지연을 완료 &lt;strong&gt;이벤트&lt;/strong&gt;가 아니라 화면 &lt;strong&gt;표시&lt;/strong&gt; 시점부터 재는 것은 Apple 공식 샘플과 같습니다. 그리고 예약 시점에 &lt;code&gt;windowScene&lt;/code&gt;을 캡처해두지 않고, 2초 뒤 발화 시점에 &lt;code&gt;presenter.view.window&lt;/code&gt;를 &lt;strong&gt;새로 다시&lt;/strong&gt; 역참조합니다. 그 사이 사용자가 화면을 떠났으면 &lt;code&gt;presenter&lt;/code&gt;가 살아 있어도 &lt;code&gt;.view.window&lt;/code&gt;는 이미 &lt;code&gt;nil&lt;/code&gt;이기 때문입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private func fire(presenter: UIViewController?, repository: ReviewPromptRepository) {
    scheduledTask = nil
    guard let presenter, let scene = presenter.view.window?.windowScene else { return }
    let version = ReviewPromptConfig.currentAppVersion
    let epochDay = EpochDays.today(millis: Int64(Date().timeIntervalSince1970 * 1000))
    // 시도 기록은 여기에서만 합니다.
    repository.recordAttempt(epochDay: epochDay, version: version)
    AppStore.requestReview(in: scene)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;recordAttempt&lt;/code&gt;를 &lt;code&gt;markPending()&lt;/code&gt;이 아니라 여기서 부르는 것도 의도입니다. 승리 직후 곧장 홈으로 나가 취소된 pending까지 「시도」로 세면, 쿨다운을 헛되이 소모하게 됩니다.&lt;/p&gt;
&lt;p&gt;여기까지가 최초 구현입니다. 설계는 옳았습니다. 구멍은 &lt;strong&gt;그 설계를 실행하는 방식&lt;/strong&gt;에 있었습니다.&lt;/p&gt;
&lt;h2&gt;어디서 어긋났을까요&lt;/h2&gt;
&lt;p&gt;코드 리뷰가 이 파일을 두 번에 걸쳐 잡았습니다. 이틀 간격이었고, 둘 다 같은 문장으로 요약됩니다. &lt;strong&gt;가드가 참인데 화면은 이미 없었습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;첫 번째는 백그라운드 전환이었습니다. 2초를 기다리는 동안 사용자가 홈 버튼을 누르면, 앱은 백그라운드로 갑니다. 그런데 &lt;code&gt;presenter.view.window&lt;/code&gt;는 여전히 값이 있습니다.&lt;/p&gt;
&lt;p&gt;두 번째가 더 미묘했습니다. &lt;code&gt;screenWillDisappear()&lt;/code&gt;가 &lt;code&gt;cancel()&lt;/code&gt;을 부르는데도 &lt;code&gt;fire&lt;/code&gt;가 실행되는 경로가 있었습니다.&lt;/p&gt;
&lt;h2&gt;원인과 수정&lt;/h2&gt;
&lt;p&gt;두 결함이 서로 독립적이라, 각각 원인과 고친 코드를 함께 봅니다.&lt;/p&gt;
&lt;h3&gt;백그라운드에서도 window는 살아 있습니다&lt;/h3&gt;
&lt;p&gt;UIKit은 앱이 백그라운드로 가도 화면을 window에서 제거하지 않습니다. 뷰 계층은 그대로 있고, &lt;code&gt;presenter.view.window&lt;/code&gt;도 그대로 값을 돌려줍니다.&lt;/p&gt;
&lt;p&gt;그러니 「window가 있는가」만 확인하는 가드는 백그라운드를 걸러내지 못합니다. 아무것도 표시할 수 없는 상태에서 &lt;code&gt;recordAttempt&lt;/code&gt;가 실행되고 &lt;code&gt;requestReview&lt;/code&gt;가 나갑니다. 그리고 이 API는 실패를 알려주지 않으니, 게이트만 조용히 사라집니다.&lt;/p&gt;
&lt;p&gt;scene의 활성 상태까지 봐야 했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;guard let presenter, let scene = presenter.view.window?.windowScene,
      scene.activationState == .foregroundActive else { return }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;이미 만료된 sleep은 throw하지 않습니다&lt;/h3&gt;
&lt;p&gt;두 번째가 이 글의 핵심입니다.&lt;/p&gt;
&lt;p&gt;예약은 &lt;code&gt;Task&lt;/code&gt; 안의 &lt;code&gt;Task.sleep&lt;/code&gt;으로 구현했습니다. 취소하면 &lt;code&gt;sleep&lt;/code&gt;이 &lt;code&gt;CancellationException&lt;/code&gt;을 던지고, &lt;code&gt;catch&lt;/code&gt;에서 조용히 빠져나가는 구조입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;scheduledTask = Task { [weak presenter] in
    do { try await Task.sleep(for: .seconds(2)) }
    catch { return }   // 취소됨 — 여기서 끝난다고 생각했습니다
    fire(presenter: presenter, repository: repository)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;문제는 &lt;strong&gt;경계에 있는 순간&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sleep&lt;/code&gt;이 이미 만료돼 continuation이 MainActor 큐에 올라간 상태를 생각해 봅시다. 아직 실행되지는 않았고, 큐에서 차례를 기다리고 있습니다. 이때 &lt;code&gt;screenWillDisappear&lt;/code&gt;가 &lt;code&gt;cancel()&lt;/code&gt;을 부릅니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;이미 완료된 &lt;code&gt;sleep&lt;/code&gt;은 throw하지 않습니다.&lt;/strong&gt; 취소는 「앞으로 기다릴 시간」에 작용하지, 이미 끝난 대기를 소급해서 실패시키지 않습니다. continuation은 그대로 재개되고, &lt;code&gt;catch&lt;/code&gt;를 건너뛰고, &lt;code&gt;fire&lt;/code&gt;로 진행합니다.&lt;/p&gt;
&lt;p&gt;그리고 그 순간 화면은 사라지는 &lt;strong&gt;전환 중&lt;/strong&gt;입니다. window는 아직 있고 scene도 아직 &lt;code&gt;foregroundActive&lt;/code&gt;라, 방금 추가한 가드까지 통과합니다. 아무도 보지 않는 화면에 대고 &lt;code&gt;recordAttempt&lt;/code&gt;와 StoreKit 호출이 나가 버전당 1회 게이트를 태웁니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cancel()&lt;/code&gt;은 &lt;code&gt;sleep&lt;/code&gt;의 throw 여부와 무관하게 취소 플래그를 세웁니다. 그래서 플래그를 직접 봐야 합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;do { try await Task.sleep(for: .seconds(2)) }
catch { return }
guard !Task.isCancelled else { return }   // 이 줄이 있어야 합니다
fire(presenter: presenter, repository: repository)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;catch&lt;/code&gt;와 &lt;code&gt;guard&lt;/code&gt;가 둘 다 필요합니다. &lt;code&gt;catch&lt;/code&gt;는 「아직 기다리는 중에 취소된 경우」를, &lt;code&gt;guard&lt;/code&gt;는 「기다림이 끝난 뒤 취소된 경우」를 잡습니다.&lt;/p&gt;
&lt;h3&gt;Android에는 같은 결함이 없었습니다&lt;/h3&gt;
&lt;p&gt;같은 설계를 Android에도 옮겼는데, 이 결함은 iOS에만 있었습니다. 이유가 두 겹입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, Kotlin의 &lt;code&gt;delay&lt;/code&gt;는 재개 시점에 취소를 확인합니다.&lt;/strong&gt; &lt;code&gt;CancellationException&lt;/code&gt;을 던지므로 「이미 완료된 sleep」 문제 자체가 생기지 않습니다. Swift의 &lt;code&gt;Task.sleep&lt;/code&gt;은 그 확인을 대기 구간에서만 합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 게이트를 태우는 호출이 취소 지점 뒤에 있습니다.&lt;/strong&gt; Android에서 시도를 기록하는 &lt;code&gt;recordPrompt&lt;/code&gt;는 &lt;code&gt;suspendCancellableCoroutine&lt;/code&gt; &lt;strong&gt;뒤&lt;/strong&gt;에 있어서, 그 취소 지점을 반드시 통과해야 도달합니다. iOS의 &lt;code&gt;recordAttempt&lt;/code&gt;는 &lt;strong&gt;동기 함수&lt;/strong&gt;라 그런 관문이 없었습니다.&lt;/p&gt;
&lt;p&gt;정리하면 이렇습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;대기 API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Task.sleep&lt;/code&gt; — 만료된 뒤 &lt;code&gt;cancel()&lt;/code&gt;은 throw하지 않음&lt;/td&gt;
&lt;td&gt;&lt;code&gt;delay&lt;/code&gt; — 재개 시점에 취소 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;게이트 소모 지점&lt;/td&gt;
&lt;td&gt;&lt;code&gt;recordAttempt&lt;/code&gt; (동기)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;recordPrompt&lt;/code&gt; (취소 지점 뒤)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과&lt;/td&gt;
&lt;td&gt;취소 뒤에도 도달 가능&lt;/td&gt;
&lt;td&gt;구조적으로 도달 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;크로스플랫폼으로 같은 설계를 옮길 때 저는 「대칭이어야 한다」를 기본값으로 삼습니다. 여기서는 그 대칭이 성립하지 않았고, 성립하지 않는 이유가 런타임의 취소 의미론 차이였습니다. &lt;strong&gt;한쪽에서 안전한 패턴이 다른 쪽에서도 안전하리라는 보장은 없습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;판정과 실행을 갈랐습니다&lt;/h2&gt;
&lt;p&gt;「언제 요청할 것인가」는 StoreKit과 아무 관계가 없는 판단입니다. 그래서 순수 함수로 빼뒀습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static func shouldRequestReview(
    wonCompletionCount: Int,
    lastAttemptEpochDay: Int64,
    todayEpochDay: Int64,
    lastPromptedVersion: String?,
    currentVersion: String,
    forceOverride: Bool
) -&amp;gt; Bool {
    if forceOverride { return true }
    guard wonCompletionCount &amp;gt;= ReviewPromptConfig.wonCompletionThreshold else { return false }
    guard todayEpochDay - lastAttemptEpochDay &amp;gt;= ReviewPromptConfig.cooldownDays else { return false }
    if let lastPromptedVersion, lastPromptedVersion == currentVersion { return false }
    return true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;값만 다루고 StoreKit을 건드리지 않으니, 유닛 테스트가 &lt;code&gt;AppStore&lt;/code&gt; 접촉을 하나도 켜지 않고 진리표 전체를 잠글 수 있습니다. 리더보드 제출에서 같은 이유로 판정과 실행을 갈랐던 것과 같은 구조입니다. 그때 정리한 내용은 &lt;a href=&quot;/ko/blog/daily-leaderboard-date-boundary-submission-reliability/&quot;&gt;Game Center·Play Games 리더보드 글&lt;/a&gt;에 있습니다.&lt;/p&gt;
&lt;p&gt;판정 로직은 두 플랫폼이 같아야 하므로, 공유 JSON 픽스처 12케이스를 양쪽이 각자 읽어 parity 테스트를 돌립니다. 여기서도 비대칭이 하나 나왔습니다. &lt;strong&gt;Android 쪽 케이스 수 검증이 &lt;code&gt;&amp;gt;= 10&lt;/code&gt;이라 픽스처가 드리프트해도 잡지 못했습니다.&lt;/strong&gt; iOS와 같이 정확히 12건을 요구하도록 맞췄습니다. 「최소 몇 개 이상」은 「같은 것을 보고 있는가」를 검사하지 못합니다.&lt;/p&gt;
&lt;p&gt;승리 횟수 임계값과 쿨다운 일수는 제가 직접 정했습니다. Apple HIG의 *&quot;사용자가 의견을 형성할 시간을 갖기 전에 요청하지 말라&quot;*는 권고와 &lt;strong&gt;긴장 관계에 있다는 걸 알면서&lt;/strong&gt; 고른 값입니다. 플랫폼 자체 상한이 최종 백스톱 역할을 한다고 보고 내린 결정이지, 권고를 그대로 따른 값은 아닙니다. 그래서 상수 정의 옆에 그 사실을 주석으로 남겼습니다.&lt;/p&gt;
&lt;h2&gt;확인한 방법&lt;/h2&gt;
&lt;p&gt;이 글은 2026년 8월 15일 DailySudoku &lt;code&gt;develop&lt;/code&gt; 브랜치, 커밋 &lt;code&gt;eeb4d90d&lt;/code&gt;·&lt;code&gt;9884d36f&lt;/code&gt;·&lt;code&gt;fa69105c&lt;/code&gt;의 iOS 구현을 기준으로 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS &lt;code&gt;xcodebuild test&lt;/code&gt; — &lt;strong&gt;287 tests / 60 suites 통과&lt;/strong&gt;. Android &lt;code&gt;testDebugUnitTest&lt;/code&gt; — 297 tests / 42 classes 통과.&lt;/li&gt;
&lt;li&gt;자격 판정 진리표는 공유 픽스처 12케이스로 양 플랫폼이 각자 검증.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;못 잰 것&lt;/h3&gt;
&lt;p&gt;이 기능은 자동화 검증이 유난히 어렵습니다. 세 가지를 못 쟀습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;시스템 다이얼로그가 실제로 떴는지 확인할 수 없습니다.&lt;/strong&gt; &lt;code&gt;AppStore.requestReview(in:)&lt;/code&gt;이 결과를 앱에 알려주지 않기 때문입니다. 이 글의 전제이자 검증의 한계입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;취소 경로를 실행하는 자동화 테스트가 양 플랫폼 다 없습니다.&lt;/strong&gt; 코드 추적과 정적 call-graph로 검증했습니다. iOS는 이런 테스트 파일 자체가 없고, 있었더라도 xctestplan의 킬스위치가 실제 호출 지점을 막았을 것입니다. pending 신호가 세팅되는지 여부까지는 유닛 테스트로 덮여 있습니다. &lt;strong&gt;이 글이 설명한 두 결함은 테스트가 아니라 코드 리뷰가 잡았습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TestFlight로는 확인할 수 없습니다.&lt;/strong&gt; Apple 문서에 따르면 TestFlight 빌드에서는 &lt;code&gt;AppStore.requestReview&lt;/code&gt;가 애초에 효과가 없습니다. 그래서 실기기 확인은 Xcode 디버그 빌드에서 자격 조건을 전부 무시하는 강제 트리거 런치 인자로 합니다.&lt;/p&gt;
&lt;h2&gt;사이드 프로젝트라서 배운 것&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;조용히 실패하는 API는 되돌릴 수 없는 자원과 만날 때만 위험합니다.&lt;/strong&gt; &lt;code&gt;requestReview&lt;/code&gt;가 실패를 안 알려주는 것 자체는 문제가 아닙니다. 그 호출이 버전당 1회 게이트를 소모하기 때문에 문제가 됩니다. 코드를 볼 때 「이 호출이 실패하면 무엇을 잃는가」를 먼저 묻는 편이, 「이 호출이 성공하는가」를 묻는 것보다 결함을 잘 찾습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;대칭을 기본값으로 두되, 런타임이 다르면 검증해야 합니다.&lt;/strong&gt; iOS와 Android에 같은 설계를 옮겨놓고 「양쪽 다 됐다」고 넘어갔다면 이 결함은 남았을 겁니다. 두 플랫폼의 취소 의미론이 다르다는 것을 확인하고 나서야, 왜 한쪽에만 결함이 생겼는지 설명할 수 있었습니다. 대칭이 깨지는 곳에는 근거를 남겨두는 편이 낫습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;테스트가 못 잡는 영역이 있다는 걸 인정하는 것도 검증입니다.&lt;/strong&gt; 이 기능의 핵심 결함 두 개는 전부 코드 리뷰가 잡았습니다. 자동화로 덮을 수 없는 영역이라면, 덮은 척하는 것보다 어디까지 덮였는지 적어두는 편이 다음 사람에게 도움이 됩니다. 혼자 만드는 앱에서는 그 다음 사람이 대체로 몇 달 뒤의 저입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;수치를 권고와 다르게 정했다면 그 사실을 적어둡니다.&lt;/strong&gt; 자격 임계값과 쿨다운은 HIG 권고보다 공격적인 쪽으로 골랐습니다. 상수 옆에 「권고와 긴장 관계임을 알고 고른 값」이라고 적어두지 않으면, 나중에 읽는 사람은 그것을 검토된 기본값으로 오해합니다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/requesting-app-store-reviews&quot;&gt;Requesting App Store reviews - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)&quot;&gt;requestReview(in:) - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/ratings-and-reviews&quot;&gt;Ratings and Reviews - Apple Human Interface Guidelines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:)&quot;&gt;Task.sleep(for:tolerance:clock:) - Apple Developer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kotlinlang.org/docs/cancellation-and-timeouts.html&quot;&gt;Cancellation - Kotlin coroutines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/guide/playcore/in-app-review&quot;&gt;In-app reviews (Android) - Google Play&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://apps.apple.com/app/id1149229748&quot;&gt;DailySudoku - App Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://play.google.com/store/apps/details?id=so.object.sudoku&quot;&gt;DailySudoku - Google Play&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dailysudoku.app/ko/&quot;&gt;DailySudoku 웹&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Swift</category><category>StoreKit</category><category>Concurrency</category><category>Android</category></item><item><title>Apple Pencil 손글씨 숫자 입력: PencilKit·Core ML reject 설계</title><link>https://jaemyeong.com/ko/blog/apple-pencil-handwriting-digit-input-pipeline/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/apple-pencil-handwriting-digit-input-pipeline/</guid><description>PencilKit stroke를 스도쿠 숫자 입력으로 바꿀 때 필요한 tap·ink 세션 분류, 28×28 전처리, confidence reject, 비동기 추론 계약을 DailySudoku 구현으로 살펴봅니다.</description><pubDate>Thu, 06 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;스도쿠 보드에 Apple Pencil로 &lt;code&gt;7&lt;/code&gt;을 쓰면 앱은 숫자 7을 넣어야 한다. 작은 점을 찍었다면 숫자 인식이 아니라 셀 선택이어야 한다. 손바닥이 닿거나 모델이 애매한 답을 내놓았다고 오답을 입력해서도 안 된다.&lt;/p&gt;
&lt;p&gt;이 기능의 핵심은 &lt;code&gt;PKCanvasView&lt;/code&gt;를 화면에 올리는 일이 아니다. Pencil touch를 기존 입력과 충돌 없이 소유하고, 여러 stroke를 하나의 의도로 묶고, 모델이 학습한 형태로 정규화한 뒤, 확실한 결과만 게임 action으로 보내는 &lt;strong&gt;실패 허용 파이프라인&lt;/strong&gt;을 만드는 일이다.&lt;/p&gt;
&lt;p&gt;이 글은 2026년 8월 7일 DailySudoku &lt;code&gt;develop&lt;/code&gt; 커밋 &lt;code&gt;6db30672&lt;/code&gt;의 iOS 구현을 기준으로 한다. 현재 코드가 보장하는 것과 아직 실기기에서 증명하지 못한 것, Apple 공식 문서와 어긋나는 동시성 전제까지 구분해서 다룬다.&lt;/p&gt;
&lt;h2&gt;입력 소유권부터 분리한다&lt;/h2&gt;
&lt;p&gt;DailySudoku는 &lt;code&gt;BoardView&lt;/code&gt; 위에 투명한 &lt;code&gt;PencilDrawOverlay&lt;/code&gt;를 자식 view로 붙인다. overlay는 &lt;code&gt;PKCanvasView&lt;/code&gt;이고 drawing policy는 &lt;code&gt;.pencilOnly&lt;/code&gt;다. Apple 문서상 이 정책에서는 Pencil touch만 canvas에 그린다.&lt;/p&gt;
&lt;p&gt;그렇다고 &lt;code&gt;BoardView&lt;/code&gt;의 gesture recognizer를 그대로 두면 안 된다. Pencil touch를 보드의 tap이나 pan이 먼저 소비할 수 있기 때문이다. 현재 구현은 drawing capture가 켜진 동안 보드 recognizer의 허용 목록을 다음 세 종류로 제한한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;허용: direct, indirect, indirectPointer
제외: pencil
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pencil은 overlay로 가고, 손가락·마우스·트랙패드는 기존 셀 선택 경로에 남는다. stroke가 진행되는 동안에는 손바닥 접촉이 셀을 훑지 않도록 pan만 잠시 끄고 tap은 남긴다. overlay 자체는 접근성 트리에서 숨겨 &lt;code&gt;BoardView&lt;/code&gt;의 가상 셀이 계속 노출되도록 구성한다. 이 구성이 Pencil과 VoiceOver를 함께 쓰는 실기기 경험까지 보장하는지는 별도 검증 항목이다.&lt;/p&gt;
&lt;p&gt;여기서 보드의 81개 접근성 셀과 공용 hit-test geometry는 이전의 &lt;a href=&quot;/ko/blog/adaptive-accessible-sudoku-board/&quot;&gt;UIKit·Jetpack Compose 스도쿠 보드 접근성 글&lt;/a&gt;에서 이미 다뤘다. Pencil 입력에 필요한 추가 계약은 &lt;strong&gt;같은 geometry를 쓰되 touch 소유권은 분리하는 것&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h2&gt;한 stroke가 아니라 한 입력 세션을 판정한다&lt;/h2&gt;
&lt;p&gt;Pencil이 닿았다는 사실만으로 숫자 입력을 시작할 수는 없다. 현재 구현은 첫 stroke의 bounding box 대각선이 6pt 이하이고 지속 시간이 0.12초 이하면 tap으로 분류한다. 이때는 ink를 지우고 bounding box 중심이 가리키는 셀만 선택한다. 기존 session과 가까운 두 번째 stroke부터는 크기와 시간이 작아도 ink로 분류한다.&lt;/p&gt;
&lt;p&gt;ink는 Pencil을 뗄 때마다 바로 인식하지 않는다. &lt;code&gt;4&lt;/code&gt;, &lt;code&gt;5&lt;/code&gt;, &lt;code&gt;7&lt;/code&gt;처럼 여러 stroke로 쓸 수 있기 때문이다. 마지막 stroke가 끝난 뒤 0.45초 동안 새 stroke가 없으면 session을 확정한다. 새 stroke가 들어오면 deadline을 다시 미룬다.&lt;/p&gt;
&lt;p&gt;tap으로 분류되지 않은 새 stroke의 중심이 기존 session 중심에서 어느 축으로든 60pt보다 멀면 reducer는 이전 숫자를 즉시 확정하고 새 ink session을 시작한다. 작고 빠른 far stroke는 먼저 tap으로 분류되므로 이전 session을 확정한 뒤 tapped cell만 선택한다. session 전체 bounding box의 중심을 기존 &lt;code&gt;BoardView.cellIndex&lt;/code&gt;에 넣어 target cell을 구하므로, tap과 ink가 둥근 보드 모서리까지 같은 판정을 쓴다.&lt;/p&gt;
&lt;p&gt;이 구조는 세 결정을 분리한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;이 stroke는 tap인가 ink인가.&lt;/li&gt;
&lt;li&gt;이 stroke는 기존 session에 속하는가 새 session인가.&lt;/li&gt;
&lt;li&gt;완성된 session은 어느 셀을 가리키는가.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;다만 60pt는 보드 크기와 무관한 고정값이다. 보드가 커지거나 작아질 때 인접 셀 사이의 실제 거리는 달라진다. 현재 테스트는 가까운 stroke와 아주 먼 stroke를 구분하지만, 여러 화면 크기에서 인접 셀을 빠르게 이어 쓰는 경계는 고정하지 않는다. 이 값은 셀 크기에 비례시키거나, 최소한 지원 기기별 실측으로 보정해야 하는 calibration 값이다.&lt;/p&gt;
&lt;h2&gt;전처리가 모델의 실질적인 API다&lt;/h2&gt;
&lt;p&gt;Core ML 모델이 받는 값은 &lt;code&gt;PKDrawing&lt;/code&gt;이 아니다. 현재 파이프라인은 stroke path를 점 배열로 바꾸고, 보드 크기의 grayscale buffer에 같은 굵기로 rasterize한 뒤 28×28 tensor로 정규화한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PKDrawing
  -&amp;gt; [[CGPoint]]
  -&amp;gt; round-cap grayscale raster
  -&amp;gt; ink bounding-box crop
  -&amp;gt; longest side 20px, aspect ratio 유지
  -&amp;gt; bilinear resample
  -&amp;gt; center of mass를 28×28 중앙으로 이동
  -&amp;gt; [1, 1, 28, 28] Float tensor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;화면에서는 일반 입력 ink를 6pt, note mode ink를 3pt로 그린다. 하지만 모델 입력은 두 경우 모두 6pt로 rasterize한다. 시각적 mode 표시가 모델 분포까지 바꾸지 않게 한 것이다.&lt;/p&gt;
&lt;p&gt;전처리는 단순한 이미지 유틸리티가 아니라 모델의 입력 계약이다. crop, scale, pixel 방향, intensity 범위 중 하나만 학습 때와 달라져도 정상적으로 실행되는 오인식기를 만들 수 있다. 그래서 현재 테스트는 빈 입력의 &lt;code&gt;nil&lt;/code&gt;, 결정성, 20px longest side, center-of-mass 정렬과 Python에서 고정한 Core ML 출력의 label·확률 parity를 따로 확인한다.&lt;/p&gt;
&lt;p&gt;이 테스트가 실제 필기 정확도를 증명하는 것은 아니다. renderer부터 모델까지 기대 숫자를 검사하는 손글씨 모양 fixture도 &lt;code&gt;1&lt;/code&gt;과 &lt;code&gt;7&lt;/code&gt;뿐이다. 학습 스크립트의 threshold sweep 역시 회전·굵기 변화가 적용된 MNIST와 0·noise를 사용한 proxy이며, 결과 report는 임시 경로에 기록된다. 따라서 “실제 Pencil 필기 정확도가 몇 퍼센트다” 또는 “0.8이 최적값이다”라고 말할 근거는 아직 없다.&lt;/p&gt;
&lt;h2&gt;숫자를 맞히는 것보다 잘못 놓지 않는 것이 중요하다&lt;/h2&gt;
&lt;p&gt;모델은 0부터 9까지 열 개 class를 출력한다. 스도쿠에 넣을 수 없는 0을 없애지 않고 reject sink로 남긴다. 애매한 원을 억지로 6이나 9에 배정하는 대신 0으로 빠질 자리를 주는 선택이다.&lt;/p&gt;
&lt;p&gt;현재 입력 정책의 base confidence threshold는 0.8이다. note mode에서는 잘못된 note를 지우는 비용이 일반 숫자 오입력보다 낮다고 보고 0.1을 낮춘 0.7을 쓴다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델 결과&lt;/th&gt;
&lt;th&gt;사용자 action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1...9, confidence가 threshold 이상&lt;/td&gt;
&lt;td&gt;target cell 선택 후 숫자 입력&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0 또는 범위 밖 label&lt;/td&gt;
&lt;td&gt;reject&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;confidence가 threshold 미만&lt;/td&gt;
&lt;td&gt;reject&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모델 load·전처리·추론 실패&lt;/td&gt;
&lt;td&gt;reject&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;reject는 mistake를 늘리지 않는 silent no-op이다. ink는 성공 여부와 관계없이 사라진다. 잘못된 숫자를 자동으로 놓는 것보다 사용자가 다시 쓰게 하는 편이 싼 제품에서는 합리적인 비대칭이다.&lt;/p&gt;
&lt;p&gt;그러나 사용자에게 조용하다는 말이 운영에서도 보이지 않아야 한다는 뜻은 아니다. 모델 load 실패, low confidence, 잘못된 label을 내부에서는 구분해야 threshold와 전처리 문제를 찾을 수 있다. 현재 코드는 이 세 경우를 최종 &lt;code&gt;.reject&lt;/code&gt; 하나로 합치고, stale result 여부는 검사하지 않는다. 실기기 보정 전에 원인별 계측과 stale 판정 경계가 필요하다. 원본 stroke를 서버로 보내지 않고 기기 안에서 집계 가능한 결과만 남기면 handwriting data를 수집하지 않고도 실패율을 볼 수 있다.&lt;/p&gt;
&lt;h2&gt;비동기 결과에도 입력 정체성이 필요하다&lt;/h2&gt;
&lt;p&gt;현재 구현은 rasterize와 Core ML prediction을 &lt;code&gt;Task.detached&lt;/code&gt;에서 실행하고, 성공 결과만 main actor로 돌아와 &lt;code&gt;selectCell&lt;/code&gt;과 &lt;code&gt;placeDigit&lt;/code&gt;을 호출한다. UI thread를 막지 않는 방향은 맞지만 두 계약이 비어 있다.&lt;/p&gt;
&lt;p&gt;첫째, 한 &lt;code&gt;MLModel&lt;/code&gt; instance의 호출을 직렬화해야 한다. 현재 &lt;code&gt;CoreMLDigitRecognizer&lt;/code&gt;는 &lt;code&gt;@unchecked Sendable&lt;/code&gt;이고 여러 detached task가 같은 model을 공유할 수 있다. 하지만 Apple의 &lt;code&gt;MLModel&lt;/code&gt; 문서는 한 instance를 한 thread 또는 한 dispatch queue에서 사용하고, 호출을 serialize하거나 queue마다 별도 instance를 만들라고 명시한다. &lt;code&gt;@unchecked Sendable&lt;/code&gt;은 컴파일러 검사를 우회할 뿐 이 런타임 계약을 바꾸지 않는다.&lt;/p&gt;
&lt;p&gt;가장 작은 수정은 model을 actor 하나가 소유하게 하는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;actor DigitInference {
    private let recognizer: CoreMLDigitRecognizer

    init() throws {
        recognizer = try CoreMLDigitRecognizer()
    }

    func recognize(_ pixels: [Float]) throws -&amp;gt; DigitRecognition? {
        try recognizer.recognize(pixels: pixels)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 코드는 prediction 호출을 직렬화하며 호출부는 actor method를 &lt;code&gt;await&lt;/code&gt;해야 한다. queue마다 model을 복제하는 방식은 실제 latency와 throughput이 actor 하나로 부족하다고 측정된 뒤에 선택해도 된다.&lt;/p&gt;
&lt;p&gt;둘째, 추론을 시작한 입력과 결과를 적용할 게임 상태를 묶어야 한다. 현재 completion은 여전히 playing인지만 확인한다. 그 사이 pause 후 resume하거나 note mode가 바뀌면 이전 stroke의 결과가 다시 활성화된 상태에 적용될 수 있다. 특히 threshold는 캡처한 mode로 계산하지만 &lt;code&gt;placeDigit&lt;/code&gt;은 completion 시점의 현재 mode를 사용한다.&lt;/p&gt;
&lt;p&gt;요청마다 최소한 다음 값을 함께 캡처하면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;input revision + target cell + entry mode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과를 적용하기 직전에 이 값이 현재 상태와 같은지 확인하고, 다르면 cost-free reject로 버린다. 범용 작업 관리 framework보다 monotonically increasing revision 하나가 이 경계에는 충분하다.&lt;/p&gt;
&lt;h2&gt;자동 테스트와 물리 기기 검증을 나눈다&lt;/h2&gt;
&lt;p&gt;현재 자동 테스트가 강한 부분은 UI framework 밖으로 꺼낸 순수 결정이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;rounded-board hit test, tap·ink threshold, debounce와 far restart&lt;/li&gt;
&lt;li&gt;28×28 normalization의 결정성·크기·center of mass&lt;/li&gt;
&lt;li&gt;0·범위 밖·low-confidence reject table&lt;/li&gt;
&lt;li&gt;bundled model load와 Python·Swift output parity&lt;/li&gt;
&lt;li&gt;Pencil을 제외한 touch-type whitelist&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PKCanvasView&lt;/code&gt;가 자기 자신을 delegate로 삼지 않는 wiring&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;마지막 항목은 특히 중요하다. DailySudoku 소스 주석에는 iOS 26.5 기기에서 canvas 자신을 delegate로 연결했을 때 PencilKit 내부 delegate 전달이 자기 재귀로 이어졌고, 별도 bridge 객체로 끊었다는 진단이 기록돼 있다. 현재 test가 독립적으로 검사하는 것은 delegate가 canvas 자신이 아니라는 wiring뿐이며, 당시 기기 log는 이 글에서 다시 확인하지 않았다. &lt;code&gt;.pencilOnly&lt;/code&gt; 경로는 simulator mouse로 실행되지 않으므로 구조적 회귀 test와 물리 기기 test의 역할이 다르다.&lt;/p&gt;
&lt;p&gt;아직 다음 항목은 자동 테스트 통과만으로 말할 수 없다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;다양한 필체의 1...9 precision과 reject rate&lt;/li&gt;
&lt;li&gt;첫 결과가 나오기까지의 latency와 연속 입력 순서&lt;/li&gt;
&lt;li&gt;화면 크기별 60pt session 분리 경계&lt;/li&gt;
&lt;li&gt;Pencil·손가락·pointer·VoiceOver 동시 사용&lt;/li&gt;
&lt;li&gt;pause·resume와 mode 전환 중 stale result 폐기&lt;/li&gt;
&lt;li&gt;모델 load 실패와 반복 reject가 사용자에게 이해되는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;실기기 검증에서는 전체 정확도 하나보다 &lt;code&gt;accepted 중 오입력 비율&lt;/code&gt;, &lt;code&gt;reject 비율&lt;/code&gt;, &lt;code&gt;p95 latency&lt;/code&gt;를 나눠 보는 편이 낫다. 이 제품에서는 coverage를 조금 포기해도 오입력을 줄이는 것이 목표이기 때문이다.&lt;/p&gt;
&lt;h2&gt;iOS 27 beta API는 아직 drop-in 교체가 아니다&lt;/h2&gt;
&lt;p&gt;Apple은 iOS 27 beta의 PencilKit에 &lt;code&gt;PKStrokeRecognizer&lt;/code&gt;를 제공한다. 공식 문서상 이 타입은 actor이고, on-device에서 비동기로 동작하며, &lt;code&gt;PKDrawing&lt;/code&gt;의 stroke를 text로 인식한다. 인식 결과를 저장한다면 OS에 따라 결과가 바뀔 수 있으므로 &lt;code&gt;recognitionVersion&lt;/code&gt;도 함께 보존해야 한다.&lt;/p&gt;
&lt;p&gt;기준 커밋의 배포 대상은 iOS 26.5다. 따라서 이 경로를 구현하려면 Xcode 27 beta SDK, &lt;code&gt;#available(iOS 27.0, *)&lt;/code&gt; gate와 기존 Core ML fallback이 필요하다.&lt;/p&gt;
&lt;p&gt;현재 DailySudoku의 &lt;code&gt;DigitRecognizing&lt;/code&gt; protocol은 미래 교체 지점으로 설명돼 있지만 method는 이미 정규화된 &lt;code&gt;[Float]&lt;/code&gt;를 받고 digit과 confidence를 반환한다. 반면 &lt;code&gt;PKStrokeRecognizer&lt;/code&gt;에는 &lt;code&gt;PKDrawing&lt;/code&gt;을 &lt;code&gt;updateDrawing(_:)&lt;/code&gt;로 제공한 뒤 &lt;code&gt;recognizedText(strokeIDs:)&lt;/code&gt;에서 &lt;code&gt;String?&lt;/code&gt;을 받는다. 이 method는 confidence 값을 반환하지 않는다. 지금 signature 그대로는 renderer를 삭제하고 구현만 바꾸는 drop-in 교체가 아니다.&lt;/p&gt;
&lt;p&gt;진짜 migration seam은 한 단계 위여야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;recognize(drawing, context) async -&amp;gt; candidate 또는 reject
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그 아래에서 현재 Core ML adapter는 rasterize와 confidence policy를 쓰고, iOS 27 adapter는 &lt;code&gt;PKStrokeRecognizer&lt;/code&gt;의 text를 1...9로 제한한다. confidence가 없는 API에서 어떤 결과를 자동 입력할지는 별도의 제품 검증이 필요하다. API가 beta인 동안에는 현재 모델을 지우기보다 이 경계만 정확히 잡아 두는 것으로 충분하다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;Apple Pencil 숫자 입력은 handwriting model 하나를 붙이는 기능이 아니다. Pencil touch 소유권, tap·ink session, target cell geometry, 28×28 전처리, reject 정책, 비동기 결과 정체성이 차례로 맞아야 하나의 안전한 game action이 된다.&lt;/p&gt;
&lt;p&gt;DailySudoku의 현재 구현은 이 결정을 순수 reducer와 renderer로 분리하고, 0과 low confidence를 비용 없는 reject로 처리한다. 반면 실제 Pencil 정확도와 latency, 크기별 session 경계, stale result, &lt;code&gt;MLModel&lt;/code&gt; 직렬화는 아직 닫히지 않았다. iOS 27의 &lt;code&gt;PKStrokeRecognizer&lt;/code&gt;도 현재 pixels 기반 protocol에 그대로 꽂히지는 않는다.&lt;/p&gt;
&lt;p&gt;먼저 잘못된 숫자를 놓지 않는 pipeline을 만들고, acceptance와 reject를 실기기에서 측정한 뒤 threshold를 조정해야 한다. handwriting 기능에서 중요한 수치는 모델의 단독 정확도보다 &lt;strong&gt;사용자 action으로 commit된 결과의 precision&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;DailySudoku 비공개 저장소(권한 필요) — 기준 commit &lt;code&gt;6db30672d5b8fa63826ad10da7daba7287d29e75&lt;/code&gt;: &lt;code&gt;apps/ios/DailySudoku/Game/PencilDrawOverlay.swift:15-119,124-223,251-365&lt;/code&gt;, &lt;code&gt;apps/ios/DailySudoku/Game/DigitImageRenderer.swift:27-236&lt;/code&gt;, &lt;code&gt;apps/ios/DailySudoku/Game/DigitRecognizer.swift:20-144&lt;/code&gt;, &lt;code&gt;apps/ios/DailySudoku/Game/GameViewController.swift:84-98,584-675&lt;/code&gt;, &lt;code&gt;apps/ios/DailySudoku/Game/GameViewModel.swift:241-263&lt;/code&gt;, &lt;code&gt;apps/ios/DailySudoku/Game/BoardView.swift:43-49,180-242&lt;/code&gt;, &lt;code&gt;apps/ios/DailySudoku.xcodeproj/project.pbxproj:427,515&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;DailySudoku 비공개 저장소(권한 필요) — 같은 commit의 검증 근거: &lt;code&gt;apps/ios/DailySudokuTests/PencilDrawTests.swift:15-206&lt;/code&gt;, &lt;code&gt;apps/ios/DailySudokuTests/PencilDigitRecognitionTests.swift:59-210&lt;/code&gt;, &lt;code&gt;apps/ios/DailySudokuTests/BoardViewPencilCaptureTouchTypesTests.swift:5-40&lt;/code&gt;, &lt;code&gt;apps/ios/scripts/pencil_digit_train_export.py:44-180,201-244,247-334&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/pencilkit/pkcanvasviewdrawingpolicy&quot;&gt;Apple Developer Documentation — PKCanvasViewDrawingPolicy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coreml/mlmodel&quot;&gt;Apple Developer Documentation — MLModel&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/pencilkit/pkstrokerecognizer&quot;&gt;Apple Developer Documentation — PKStrokeRecognizer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/pencilkit/recognizing-handwriting-and-converting-to-text&quot;&gt;Apple Developer Documentation — Recognizing handwriting and converting it to text&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Swift</category><category>PencilKit</category><category>Core ML</category><category>Apple Pencil</category></item><item><title>픽셀보다 계약: UIKit·Jetpack Compose 스도쿠 보드 접근성</title><link>https://jaemyeong.com/ko/blog/adaptive-accessible-sudoku-board/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/adaptive-accessible-sudoku-board/</guid><description>하나의 9×9 커스텀 보드를 VoiceOver와 TalkBack이 탐색할 수 있는 81개 셀로 바꾸고, 위치·값·선택·오류·입력 동작을 두 플랫폼에 매핑하는 기준을 정리합니다.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;스도쿠 보드를 구현할 때 픽셀만 보면 문제는 단순하다. 정사각형 하나를 그리고 9×9로 나눈 뒤 숫자와 메모를 올리면 된다. 손가락 좌표도 행과 열로 환산할 수 있다. 그러나 화면을 보지 않고 사용하는 사람에게 이 보드는 정사각형 한 장일 뿐이다. 어느 셀인지, 값이 무엇인지, 선택됐는지, 어떻게 활성화하는지 알 수 없다.&lt;/p&gt;
&lt;p&gt;DailySudoku의 iOS와 Android 보드는 각각 UIKit custom drawing과 Jetpack Compose를 사용한다. 두 구현을 2026년 8월 6일 소스 기준으로 비교해 보니, 공통으로 가져가야 할 것은 픽셀이 아니라 &lt;strong&gt;셀의 의미 계약&lt;/strong&gt;이었다. 이 글은 현재 구현에서 잘 된 부분뿐 아니라 아직 자동 테스트와 실기기에서 증명하지 못한 부분도 함께 다룬다. 접근성 지원 완료 선언이 아니라, 완료 여부를 판단할 수 있는 설계와 검증 기준이다.&lt;/p&gt;
&lt;h2&gt;보드는 하나지만 접근성 트리는 81개 셀이어야 한다&lt;/h2&gt;
&lt;p&gt;시각적 렌더링 단위와 접근성 탐색 단위는 같을 필요가 없다. UIKit에서는 하나의 &lt;code&gt;UIView&lt;/code&gt;가 보드 전체를 그릴 수 있고, Compose에서는 하나의 제스처 컨테이너가 보드 전체의 tap과 drag를 처리할 수 있다. 그래도 VoiceOver와 TalkBack에는 각 칸이 독립적인 요소로 보여야 한다.&lt;/p&gt;
&lt;p&gt;셀 하나가 최소한 답할 수 있어야 하는 질문은 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;질문&lt;/th&gt;
&lt;th&gt;의미 계약&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;어디인가&lt;/td&gt;
&lt;td&gt;1부터 시작하는 행과 열&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;무엇이 들어 있는가&lt;/td&gt;
&lt;td&gt;빈 셀, 주어진 숫자, 사용자 입력, 오류, 메모 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;현재 상태는 무엇인가&lt;/td&gt;
&lt;td&gt;선택 여부와 필요한 추가 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;무엇을 할 수 있는가&lt;/td&gt;
&lt;td&gt;활성화하면 해당 셀을 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;어디에 있는가&lt;/td&gt;
&lt;td&gt;화면에 그려진 셀과 일치하는 접근성 영역&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;game.cell.r0c0&lt;/code&gt; 같은 identifier나 test tag는 자동화에 유용하지만 사용자 설명이 아니다. 지역화되지 않은 selector를 읽어 주는 대신, “2행 7열, 값 4”처럼 위치와 내용을 짧게 조합해야 한다.&lt;/p&gt;
&lt;h2&gt;공유할 것은 UI가 아니라 셀의 의미다&lt;/h2&gt;
&lt;p&gt;DailySudoku의 두 플랫폼은 코어 상태를 &lt;code&gt;CellUi&lt;/code&gt;에 가까운 동일한 렌더 모델로 투영한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CellUi = value + given + error + notes + highlight
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 점은 UIKit과 Compose가 같은 뷰 계층을 만들도록 강제하지 않는 것이다. 같은 셀 모델에서 각 플랫폼에 맞는 세 가지 결과를 만든다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;화면에 그릴 색과 숫자&lt;/li&gt;
&lt;li&gt;포인터 좌표를 셀 index로 바꾸는 geometry&lt;/li&gt;
&lt;li&gt;스크린리더에 전달할 설명·상태·동작&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;셀을 선택하는 입력은 마지막에 같은 &lt;code&gt;selectCell(index)&lt;/code&gt; 동작으로 합쳐진다. 손가락 tap, 보드 위 drag, VoiceOver 활성화, TalkBack double-tap이 서로 다른 상태 변경 경로를 만들지 않게 하는 것이다. 접근성 전용 비즈니스 로직을 따로 두면 시각 UI와 상태가 어긋나기 쉽다.&lt;/p&gt;
&lt;h2&gt;UIKit에서는 그리지 않은 81개 가상 셀을 만든다&lt;/h2&gt;
&lt;p&gt;iOS 보드는 81개의 &lt;code&gt;UIView&lt;/code&gt;를 배치하지 않고 하나의 &lt;code&gt;BoardView&lt;/code&gt;가 배경, 격자, 숫자, 메모를 직접 그린다. Apple은 이런 non-view 항목을 보조 기술에 노출할 때 컨테이너가 항목마다 &lt;code&gt;UIAccessibilityElement&lt;/code&gt;를 만들 수 있다고 안내한다.&lt;/p&gt;
&lt;p&gt;현재 구현은 셀 81개를 행 우선 순서로 만들고 다음 정보를 갱신한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;accessibilityLabel&lt;/code&gt;: 행·열과 empty/given/error/value/notes 중 하나&lt;/li&gt;
&lt;li&gt;&lt;code&gt;accessibilityTraits&lt;/code&gt;: 모든 셀은 button, 현재 셀은 selected 추가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;accessibilityFrameInContainerSpace&lt;/code&gt;: 실제 셀 사각형&lt;/li&gt;
&lt;li&gt;&lt;code&gt;accessibilityActivate()&lt;/code&gt;: 기존 &lt;code&gt;onSelect(index)&lt;/code&gt; 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;레이아웃이 바뀔 때 frame도 다시 계산한다. 화면 회전이나 Split View로 보드 크기가 달라졌는데 접근성 frame만 이전 위치에 남는 문제를 피하기 위해서다. &lt;code&gt;accessibilityFrameInContainerSpace&lt;/code&gt;를 쓰면 컨테이너 좌표계의 셀 사각형을 그대로 전달할 수 있다.&lt;/p&gt;
&lt;p&gt;여기서 순서는 구현 세부 사항이 아니라 탐색 계약이다. 배열을 0부터 80까지 생성하면 현재 구조에서는 행 우선 순서가 되지만, VoiceOver로 좌우 swipe와 전체 읽기를 직접 실행해 의도한 순서인지 확인해야 한다. 보드가 갱신된 뒤 현재 포커스의 값 변화가 즉시 읽히는지도 아직 실기기 검증이 필요하다.&lt;/p&gt;
&lt;h2&gt;Compose에서는 보드 제스처와 셀 semantics를 분리한다&lt;/h2&gt;
&lt;p&gt;Android 보드는 한 컨테이너가 포인터 좌표를 9×9 index로 바꾼다. tap뿐 아니라 손가락을 움직이며 셀을 훑는 drag도 같은 hit test를 사용하고, 처리한 포인터 변화는 소비해 바깥 스크롤과 충돌하지 않게 한다.&lt;/p&gt;
&lt;p&gt;하지만 raw &lt;code&gt;pointerInput&lt;/code&gt;은 버튼이 제공하는 접근성 의미를 자동으로 만들지 않는다. Android 공식 문서도 &lt;code&gt;Button&lt;/code&gt;, &lt;code&gt;clickable&lt;/code&gt;, &lt;code&gt;pointerInput&lt;/code&gt; 순으로 내려갈수록 기본 semantics와 focus 지원이 줄어든다고 설명한다. 그래서 각 &lt;code&gt;BoardCell&lt;/code&gt;은 포인터 처리와 별개로 다음 semantics를 제공한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Modifier.semantics {
    contentDescription = cellDescription
    role = Role.Button
    onClick {
        onSelect(index)
        true
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 분리는 두 요구를 함께 만족시킨다. 일반 touch에서는 보드가 끊김 없는 drag 선택을 처리하고, TalkBack에서는 포커스된 셀의 double-tap이 같은 선택 동작을 실행한다. 셀마다 &lt;code&gt;clickable&lt;/code&gt;과 보드 전체 &lt;code&gt;pointerInput&lt;/code&gt;을 겹쳐 놓고 어느 쪽이 이벤트를 소유하는지 경쟁시키지 않는다.&lt;/p&gt;
&lt;p&gt;다만 현재 Android 구현에는 &lt;code&gt;selected&lt;/code&gt;나 &lt;code&gt;stateDescription&lt;/code&gt;이 없다. 선택 배경색은 바뀌지만 TalkBack은 그 상태를 알 수 없다. &lt;code&gt;collectionInfo&lt;/code&gt;와 &lt;code&gt;collectionItemInfo&lt;/code&gt;도 없어 9행 9열 그리드라는 구조는 각 셀의 텍스트 설명으로만 전달된다. Compose 공식 문서가 custom grid에 collection semantics를 제공하는 이유도 보조 기술이 전체 크기와 현재 항목의 위치를 알 수 있게 하기 위해서다.&lt;/p&gt;
&lt;p&gt;따라서 두 플랫폼의 현재 계약은 완전히 대칭이 아니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;UIKit&lt;/th&gt;
&lt;th&gt;Compose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;독립 셀 81개&lt;/td&gt;
&lt;td&gt;가상 &lt;code&gt;UIAccessibilityElement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;셀별 semantics node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;위치·내용 설명&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용자 입력 출처&lt;/td&gt;
&lt;td&gt;숫자 값만 읽음&lt;/td&gt;
&lt;td&gt;input으로 구분&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;button 역할과 활성화&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;선택 상태&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.selected&lt;/code&gt; trait&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9×9 collection 구조&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자동화 selector&lt;/td&gt;
&lt;td&gt;별도 identifier&lt;/td&gt;
&lt;td&gt;별도 test tag&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;첫 수정 후보는 새로운 추상화가 아니라 Android 셀에 선택 상태를 노출하고 두 플랫폼에서 실제 발화 결과를 확인하는 일이다. 9×9 collection semantics도 작은 후속 후보지만, 행·열을 이미 포함한 설명과 중복해서 어떻게 읽히는지 TalkBack에서 확인해야 한다. custom rotor 같은 별도 탐색 수단은 81개 선형 탐색이 실제 사용자에게 불편하다는 근거가 생길 때 추가하면 된다.&lt;/p&gt;
&lt;h2&gt;화면 크기가 달라도 유지할 것은 geometry 계약이다&lt;/h2&gt;
&lt;p&gt;adaptive layout은 모든 플랫폼에서 같은 숫자를 쓰는 일이 아니다. iOS 보드는 가용 영역 안에서 정사각형을 유지하고 최대 560pt로 제한한다. Android는 보드를 최대 520dp로 제한하며, 화면 너비 840dp 이상에서는 조작부와 나란히 놓고, 폴더블 tabletop 자세에서는 hinge를 기준으로 위아래 영역을 나눈다.&lt;/p&gt;
&lt;p&gt;숫자는 달라도 다음 불변 조건은 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;화면에 그린 9×9 셀과 hit test의 행·열이 일치한다.&lt;/li&gt;
&lt;li&gt;접근성 element 또는 semantics node의 행·열도 같은 index를 가리킨다.&lt;/li&gt;
&lt;li&gt;회전, 창 크기 변경, 폴더블 자세 변경 뒤 geometry를 다시 계산한다.&lt;/li&gt;
&lt;li&gt;일시정지나 완료 modal이 나타나면 뒤의 보드를 접근성 탐색에서 제외한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;iOS의 modal overlay는 modal 접근성 영역으로 표시하고, Android는 뒤 컨텐츠의 semantics를 지운다. 화면에 보이는 overlay 뒤로 포커스가 빠져나가지 않게 하는 것도 보드 계약의 일부다.&lt;/p&gt;
&lt;h2&gt;9×9 보드와 권장 target 크기의 긴장을 숨기지 않는다&lt;/h2&gt;
&lt;p&gt;한 화면에 9개 셀을 나란히 놓으면 휴대폰에서 각 셀이 작아진다. iOS HIG의 현재 표는 iOS·iPadOS control의 기본 크기를 44×44pt, 최소 크기를 28×28pt로 제시한다. Android Compose 지침은 상호작용 요소에 48dp 최소 크기를 권장한다.&lt;/p&gt;
&lt;p&gt;현재 iOS 보드는 폭이 396pt보다 작으면 셀 한 변이 44pt보다 작아진다. Android compact 360dp 화면에서는 코드 산술상 셀 한 변이 약 34dp다. 특히 Android 셀은 &lt;code&gt;clickable&lt;/code&gt;이 아니라 수동 semantics를 쓰므로 Compose가 작은 clickable target을 자동 확장해 주는 경로에도 기대지 않는다.&lt;/p&gt;
&lt;p&gt;그렇다고 81개 접근성 frame을 기계적으로 44pt나 48dp로 키우면 인접 target이 겹친다. 어느 셀이 선택될지 더 모호해질 수 있다. 먼저 실제 기기에서 VoiceOver와 TalkBack focus 영역, touch exploration, 일반 손가락 오입력을 함께 측정해야 한다. 권장 크기를 만족해야 한다면 보드 확대, 행·박스 단위 탐색, 대체 입력처럼 겹치지 않는 방식이 제품 선택지가 된다. 이 글의 코드 조사만으로 최적안을 확정할 수는 없다.&lt;/p&gt;
&lt;p&gt;고정 비율로 그린 숫자와 메모가 Dynamic Type을 따르지 않는 iOS의 현재 상태도 별도 검증 항목이다. 보드 geometry를 유지하면서 확대된 텍스트를 어디에 어떻게 제공할지는 layout만의 문제가 아니라 정보 접근 방식의 문제다.&lt;/p&gt;
&lt;h2&gt;자동 검사는 semantics를, 실기기는 경험을 확인한다&lt;/h2&gt;
&lt;p&gt;현재 테스트는 셀 상태 투영, 좌표 hit test, drag 경계, 일부 색 대비와 폴더블 영역을 검사한다. 그러나 iOS의 81개 가상 요소와 Android 셀 semantics를 직접 고정하는 테스트는 없다. 스크린리더 계약을 회귀 방지하려면 다음 순서가 작다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;검증&lt;/th&gt;
&lt;th&gt;기대 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;playing 상태의 노드 수&lt;/td&gt;
&lt;td&gt;정확히 81개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;첫 셀과 마지막 셀&lt;/td&gt;
&lt;td&gt;1행 1열, 9행 9열&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;셀 내용 변형&lt;/td&gt;
&lt;td&gt;given/error/notes/empty와 일반 값 설명이 각각 맞음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;선택 상태&lt;/td&gt;
&lt;td&gt;선택 셀만 상태가 전달됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;활성화&lt;/td&gt;
&lt;td&gt;정확히 한 번 &lt;code&gt;selectCell(index)&lt;/code&gt; 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;resize 후 frame&lt;/td&gt;
&lt;td&gt;화면 셀과 접근성 영역이 다시 일치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;modal 표시&lt;/td&gt;
&lt;td&gt;뒤의 보드가 탐색되지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compose collection&lt;/td&gt;
&lt;td&gt;전체 9×9와 현재 행·열 정보가 전달됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;자동화 selector 존재만 확인해서는 부족하다. Compose UI test는 semantics property와 action을 검사하고, UIKit unit test는 element의 label·traits·frame·activation을 검사할 수 있다. 그 뒤 실제 iPhone과 Android 기기에서 다음을 수동으로 확인한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;좌우 swipe 순서와 행 경계가 자연스러운가&lt;/li&gt;
&lt;li&gt;touch exploration으로 가리킨 칸과 읽는 칸이 같은가&lt;/li&gt;
&lt;li&gt;double-tap 뒤 포커스가 사라지거나 엉뚱한 셀로 이동하지 않는가&lt;/li&gt;
&lt;li&gt;숫자, 메모, 오류가 바뀌었을 때 새 상태를 알 수 있는가&lt;/li&gt;
&lt;li&gt;화면을 보지 않고 선택, 입력, 수정, 완료까지 수행할 수 있는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Apple도 VoiceOver를 켜고 모든 요소의 접근 가능 여부, 탐색 순서, 시각 정보에 의존하는 작업을 직접 감사하라고 안내한다. Android의 Compose 접근성 검사도 작은 target, traversal order 같은 공통 결함을 찾지만, 문서상 자동 검사는 수동 경험 검증을 대체하지 않는다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;접근 가능한 스도쿠 보드의 핵심은 81개의 뷰를 만드는 것이 아니다. 하나의 셀 상태와 geometry에서 화면, 포인터 입력, 스크린리더 설명, 활성화 동작을 일관되게 투영하는 것이다. UIKit의 가상 &lt;code&gt;UIAccessibilityElement&lt;/code&gt;와 Compose의 셀 semantics는 서로 다른 API지만 같은 계약을 구현할 수 있다.&lt;/p&gt;
&lt;p&gt;현재 DailySudoku는 두 플랫폼 모두 81개 셀의 위치·내용·활성화를 노출한다. iOS는 선택 상태까지 제공하지만 Android는 아직 그렇지 않고, 두 플랫폼 모두 grid 구조, target 크기, 상태 변화 발화, 실기기 순회를 더 검증해야 한다. 픽셀을 같게 만드는 일보다 이 차이를 측정하고 닫는 일이 접근성 parity에 가깝다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/uiaccessibilityelement&quot;&gt;Apple Developer Documentation — UIAccessibilityElement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/uiaccessibilityelement/accessibilityframeincontainerspace&quot;&gt;Apple Developer Documentation — accessibilityFrameInContainerSpace&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/supporting-voiceover-in-your-app&quot;&gt;Apple Developer Documentation — Supporting VoiceOver in your app&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/voiceover&quot;&gt;Apple Human Interface Guidelines — VoiceOver&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/accessibility&quot;&gt;Apple Human Interface Guidelines — Accessibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/accessibility&quot;&gt;Android Developers — Accessibility in Jetpack Compose&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/accessibility/semantics&quot;&gt;Android Developers — Semantics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures&quot;&gt;Android Developers — Understand gestures&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/accessibility/api-defaults&quot;&gt;Android Developers — API defaults&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/accessibility/testing&quot;&gt;Android Developers — Testing accessibility in Compose&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>Accessibility</category><category>UIKit</category><category>Jetpack Compose</category><category>iOS</category><category>Android</category></item><item><title>iOS·Android·Web 광고 노출: loaded·rendered·impression 구분법</title><link>https://jaemyeong.com/ko/blog/cross-platform-ad-impression-event-taxonomy/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/cross-platform-ad-impression-event-taxonomy/</guid><description>광고 SDK의 load·impression·paid callback과 Web 가시성 신호를 분리해, 자동 수집과 수동 로깅이 중복되지 않는 이벤트 계약을 정리합니다.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;iOS에서는 광고 SDK의 load 완료를 받고, Android에서는 &lt;code&gt;onAdImpression()&lt;/code&gt;을 받으며, Web에서는 DOM에 배너가 붙었는지 확인한다고 해 보자. 세 플랫폼이 모두 &lt;code&gt;ad_impression&lt;/code&gt;이라는 이름으로 이벤트를 보내도 같은 현상을 측정한다고 말할 수는 없다.&lt;/p&gt;
&lt;p&gt;광고를 내려받은 것, 화면 계층에 붙인 것, viewport와 겹친 것, 광고 SDK가 impression을 기록한 것, 수익 값 callback이 온 것은 서로 다른 사건이다. 이 경계를 합치면 한 플랫폼은 과다 집계되고 다른 플랫폼은 과소 집계된다. 자동 수집 이벤트 위에 같은 수동 이벤트를 얹으면 숫자는 더 조용히 부풀어 오른다.&lt;/p&gt;
&lt;p&gt;이 글에서는 광고를 어디에서 가져올지 정하는 라우팅은 다루지 않는다. iOS·Android·Web의 서로 다른 신호를 하나의 측정 계약으로 분류하고, 어떤 신호에서 무엇을 기록하면 안 되는지 정리한다. API 이름은 2026년 8월 6일 Google·Firebase·Web 공식 문서 기준이다.&lt;/p&gt;
&lt;h2&gt;loaded를 impression으로 세면 숫자가 어긋난다&lt;/h2&gt;
&lt;p&gt;Google Mobile Ads SDK는 load와 impression을 별도 callback으로 제공한다. Android의 &lt;code&gt;onAdLoaded()&lt;/code&gt;는 광고 수신 완료이고 &lt;code&gt;onAdImpression()&lt;/code&gt;은 impression이 기록됐을 때 호출된다. iOS 배너도 &lt;code&gt;bannerViewDidReceiveAd(_:)&lt;/code&gt;와 &lt;code&gt;bannerViewDidRecordImpression(_:)&lt;/code&gt;을 구분한다.&lt;/p&gt;
&lt;p&gt;load 성공은 광고를 표시할 준비가 됐다는 뜻에 가깝다. 아직 화면에 붙지 않았거나, 붙기 전에 화면이 닫히거나, 다른 광고가 슬롯을 차지할 수 있다. 따라서 load callback에서 &lt;code&gt;ad_impression&lt;/code&gt;을 보내면 실제 impression 신호보다 앞선 사건을 노출로 바꾸게 된다.&lt;/p&gt;
&lt;p&gt;Web도 마찬가지다. DOM에 element를 추가했다고 사용자가 광고를 봤다고 할 수 없다. &lt;code&gt;IntersectionObserver&lt;/code&gt;는 target과 root의 교차 비율이 threshold를 넘었는지 알려 준다. 기본 threshold 0은 경계에 닿는 것만으로 callback이 생길 수 있고, &lt;code&gt;observe()&lt;/code&gt; 직후에는 보이지 않는 대상에도 첫 callback이 온다. 이것은 기하 신호이지 광고 네트워크의 과금 판정이 아니다.&lt;/p&gt;
&lt;h2&gt;요청·렌더링·노출·수익 신호를 분리한다&lt;/h2&gt;
&lt;p&gt;플랫폼 callback을 바로 분석 이벤트 이름으로 바꾸기 전에 다음 단계로 분류하면 경계가 선명해진다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th&gt;확인된 사실&lt;/th&gt;
&lt;th&gt;아직 확인되지 않은 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;requested&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;광고 요청이 실제로 시작됨&lt;/td&gt;
&lt;td&gt;응답, 렌더링, 노출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;광고 응답 또는 creative를 받음&lt;/td&gt;
&lt;td&gt;화면 표시, impression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rendered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;view 또는 DOM이 화면 계층에 붙음&lt;/td&gt;
&lt;td&gt;실제 가시성, SDK impression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_visible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;앱이 정한 viewport 규칙을 만족함&lt;/td&gt;
&lt;td&gt;광고 네트워크의 과금 impression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sdk_impression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;광고 SDK가 impression을 기록함&lt;/td&gt;
&lt;td&gt;수익 값의 존재와 정확도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;paid_value&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SDK가 impression-level 수익 값을 전달함&lt;/td&gt;
&lt;td&gt;정산 완료 금액, 항상 정확한 값&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;이 분류에서 &lt;code&gt;ad_impression&lt;/code&gt;으로 올릴 수 있는 가장 강한 신호는 광고 SDK가 제공하는 impression callback이다. SDK가 없는 인하우스 콘텐츠라면 제품이 정한 가시성 규칙을 사용할 수 있지만, &lt;code&gt;evidence=app_visible&lt;/code&gt;처럼 근거를 따로 남겨 SDK impression과 같은 품질로 보지 않아야 한다.&lt;/p&gt;
&lt;p&gt;paid callback도 새로운 impression을 하나 더 만드는 신호가 아니다. Google의 impression-level ad revenue 값에는 통화와 precision이 포함되며, precision은 unknown이나 estimated일 수 있다. 이 값은 이미 발생한 impression에 연결된 수익 관측값이지 정산 완료를 의미하지 않는다.&lt;/p&gt;
&lt;h2&gt;공통 이벤트는 이름보다 발화 조건으로 정의한다&lt;/h2&gt;
&lt;p&gt;이벤트 계약에는 “언제 보낸다”와 함께 “언제 보내지 않는다”를 적어야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ad_load_failed&lt;/code&gt;: 실제 요청을 시작한 Provider가 응답을 채우지 못했을 때만 보낸다. 미등록·비활성 Provider를 건너뛴 것은 실패가 아니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ad_impression&lt;/code&gt;: SDK impression callback 또는 문서화한 인하우스 가시성 규칙에서만 보낸다. load 성공이나 Provider 선택만으로 보내지 않는다.&lt;/li&gt;
&lt;li&gt;수익 관측: paid callback이 제공한 값·통화·precision을 그대로 보존한다. 값이 없다고 0으로 추정하거나 impression 수로 수익을 역산하지 않는다.&lt;/li&gt;
&lt;li&gt;취소: 화면 이탈이나 unmount로 요청을 취소했다면 이후 늦게 도착한 callback을 현재 슬롯의 이벤트로 보내지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같은 이벤트 이름을 쓴다는 사실은 계약의 결과일 뿐이다. 발화 조건과 금지 조건이 다르면 이름이 같아도 모집단이 다르다.&lt;/p&gt;
&lt;h2&gt;자동 수집과 수동 로깅을 한 Provider에 겹치지 않는다&lt;/h2&gt;
&lt;p&gt;Firebase와 연결된 AdMob은 사용자가 광고 impression을 볼 때 &lt;code&gt;ad_impression&lt;/code&gt;을 자동으로 기록할 수 있다. 이 경로가 켜져 있는데 앱이 &lt;code&gt;onAdImpression()&lt;/code&gt;에서 같은 GA4 이벤트를 다시 보내면 한 impression이 두 건이 된다.&lt;/p&gt;
&lt;p&gt;Provider별 소유권을 한 곳에서 정하면 중복을 막기 쉽다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider 유형&lt;/th&gt;
&lt;th&gt;impression의 소유자&lt;/th&gt;
&lt;th&gt;앱의 수동 &lt;code&gt;ad_impression&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Firebase 자동 수집이 켜진 AdMob&lt;/td&gt;
&lt;td&gt;SDK와 Firebase 연결&lt;/td&gt;
&lt;td&gt;보내지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자동 수집이 없는 광고 SDK&lt;/td&gt;
&lt;td&gt;SDK impression callback&lt;/td&gt;
&lt;td&gt;callback에서 1회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인하우스 콘텐츠&lt;/td&gt;
&lt;td&gt;앱의 명시적 가시성 규칙&lt;/td&gt;
&lt;td&gt;규칙 충족 시 1회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web DOM만 확인 가능한 외부 콘텐츠&lt;/td&gt;
&lt;td&gt;확인 가능한 근거에 따라 별도 정의&lt;/td&gt;
&lt;td&gt;네트워크 impression으로 과장하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;자동 이벤트에는 앱이 만든 &lt;code&gt;slot&lt;/code&gt;이나 &lt;code&gt;selection_source&lt;/code&gt;가 없을 수 있다. 이를 채우겠다고 같은 impression을 수동으로 한 건 더 보내면 총량이 틀어진다. 리포트에서 자동 수집 행의 필드가 비어 있음을 허용하고 &lt;code&gt;evidence&lt;/code&gt;별 모집단을 분리하는 편이 낫다.&lt;/p&gt;
&lt;p&gt;paid callback을 다른 분석 서버로 전달하는 경우도 마찬가지다. 그 callback을 새 GA4 &lt;code&gt;ad_impression&lt;/code&gt;으로 다시 변환하기 전에, Firebase 자동 수집이 이미 같은 impression을 기록하는지 확인해야 한다.&lt;/p&gt;
&lt;h2&gt;iOS·Android·Web 신호를 같은 분류에 매핑한다&lt;/h2&gt;
&lt;p&gt;플랫폼 코드는 달라도 분류표는 같게 유지할 수 있다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;플랫폼 신호&lt;/th&gt;
&lt;th&gt;공통 분류&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ad_impression&lt;/code&gt; 발화&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iOS &lt;code&gt;bannerViewDidReceiveAd(_:)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;아니오&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS &lt;code&gt;bannerViewDidRecordImpression(_:)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sdk_impression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;자동 수집이 없다면 예&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android &lt;code&gt;onAdLoaded()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;아니오&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android &lt;code&gt;onAdImpression()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sdk_impression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;자동 수집이 없다면 예&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web element mount&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rendered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;아니오&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web &lt;code&gt;IntersectionObserver&lt;/code&gt; 규칙 충족&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app_visible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;인하우스 규칙에서만 예&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS &lt;code&gt;paidEventHandler&lt;/code&gt;·Android &lt;code&gt;OnPaidEventListener&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;paid_value&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;새 impression으로 세지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;슬롯·시도·광고 인스턴스는 서로 다른 생명주기다&lt;/h2&gt;
&lt;p&gt;중복 제거를 세션 전체의 Boolean 하나로 처리하면 자동 갱신 광고를 과소 집계한다. 반대로 callback마다 보내면 재시도와 재마운트가 같은 광고를 중복 집계할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slot instance
  ├─ attempt 1 → load failed
  └─ attempt 2 → loaded → impression 1
                            └─ refresh → impression 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;슬롯은 UI가 유지되는 기간이다. attempt는 특정 Provider에 요청한 한 번의 시도다. 광고 인스턴스는 impression을 만들 수 있는 creative의 생명주기다. 실패 중복 제거는 attempt 단위로 하고, impression 중복 제거는 광고 인스턴스 단위로 해야 한다.&lt;/p&gt;
&lt;p&gt;이 식별자는 메모리 안에서 callback을 정리하기 위한 값이면 충분하다. GA4에 고유 ID를 그대로 보내 고카디널리티 차원을 만들 필요는 없다. SDK가 새 광고에 대해 impression callback을 보낸 자동 갱신은 새 광고 인스턴스로 세고, 같은 인스턴스의 중복 callback만 제거한다.&lt;/p&gt;
&lt;h2&gt;이벤트 envelope에는 비교 가능한 문맥만 남긴다&lt;/h2&gt;
&lt;p&gt;공통 envelope는 작고 닫힌 값으로 유지한다. 다음은 GA4 표준 필드 자체가 아니라, 플랫폼 신호를 분석 시스템에 넘기기 위한 애플리케이션 내부 예시다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;ad_impression&quot;,
  &quot;provider&quot;: &quot;global_network&quot;,
  &quot;format&quot;: &quot;banner&quot;,
  &quot;slot&quot;: &quot;bottom&quot;,
  &quot;evidence&quot;: &quot;sdk_impression&quot;,
  &quot;selection_source&quot;: &quot;normal&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;provider&lt;/code&gt;, &lt;code&gt;format&lt;/code&gt;, &lt;code&gt;slot&lt;/code&gt;은 플랫폼별 enum을 명시적으로 매핑한다. 새 Provider가 추가됐는데 매핑이 없으면 조용히 임의 문자열을 보내기보다 빌드나 테스트가 실패하게 만든다. &lt;code&gt;evidence&lt;/code&gt;는 &lt;code&gt;sdk_auto&lt;/code&gt;, &lt;code&gt;sdk_callback&lt;/code&gt;, &lt;code&gt;app_visible&lt;/code&gt;처럼 관측 근거를 구분한다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;selection_source&lt;/code&gt;는 이벤트의 주어에 맞춰 계산해야 한다. 강제 선택한 Provider가 실패하고 다른 광고가 표시됐다면, 실패 이벤트의 source와 최종 impression의 source는 같지 않을 수 있다. 한 번 계산한 값을 체인 전체에 복사하면 실패 원인과 실제 노출이 함께 오염된다.&lt;/p&gt;
&lt;p&gt;수익 값은 이 envelope에 억지로 기본값을 넣지 않는다. Firebase &lt;code&gt;ad_impression&lt;/code&gt;에 value를 제공한다면 currency도 함께 제공해야 하고, SDK가 준 precision은 별도 수익 파이프라인에서 보존한다. 자동 수집 행과 수동 행의 필드 완성도가 다르다는 사실도 계약에 포함한다.&lt;/p&gt;
&lt;h2&gt;실패 사례로 이벤트 계약을 검증한다&lt;/h2&gt;
&lt;p&gt;실제 광고 재고를 기다리는 테스트보다 callback을 주입한 작은 표가 더 안정적이다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;입력&lt;/th&gt;
&lt;th&gt;기대 이벤트&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;load 성공만 도착&lt;/td&gt;
&lt;td&gt;impression 0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SDK impression callback 1회&lt;/td&gt;
&lt;td&gt;impression 1건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 광고 인스턴스 callback 중복&lt;/td&gt;
&lt;td&gt;impression 1건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;새 광고로 refresh 후 impression callback&lt;/td&gt;
&lt;td&gt;impression 누적 2건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firebase 자동 수집 대상 Provider&lt;/td&gt;
&lt;td&gt;수동 impression 0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;요청 전에 Provider를 건너뜀&lt;/td&gt;
&lt;td&gt;load failure 0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;요청 실패 후 다른 Provider가 표시됨&lt;/td&gt;
&lt;td&gt;실패 1건, impression 1건, 각 source 별도 계산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unmount 뒤 늦은 callback&lt;/td&gt;
&lt;td&gt;이벤트 0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web observer의 보이지 않는 첫 callback&lt;/td&gt;
&lt;td&gt;impression 0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;독립된 슬롯 두 곳에서 각각 표시&lt;/td&gt;
&lt;td&gt;impression 2건&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Web 가시성 규칙을 쓴다면 threshold뿐 아니라 유지 시간과 이탈 시 reset 조건도 테스트해야 한다. &lt;code&gt;IntersectionObserver&lt;/code&gt; entry 하나는 특정 순간만 나타내므로 callback이 왔다는 사실만으로 노출 시간을 만들 수 없다. &lt;code&gt;trackVisibility&lt;/code&gt;도 제한적이고 실험적인 기능이므로 필수 전제로 두지 않는다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;광고 노출 이벤트를 하나의 언어로 만든다는 것은 모든 callback을 &lt;code&gt;ad_impression&lt;/code&gt;으로 바꾸는 일이 아니다. &lt;code&gt;loaded&lt;/code&gt;, &lt;code&gt;rendered&lt;/code&gt;, 앱 가시성, SDK impression, paid value를 먼저 분리하고, 각 플랫폼에서 가장 강한 근거만 공통 계약에 올리는 일이다.&lt;/p&gt;
&lt;p&gt;자동 수집과 수동 로깅의 소유권을 Provider별로 하나만 정하고, 실패는 attempt 단위로, impression은 광고 인스턴스 단위로 중복 제거해야 한다. 그러면 iOS·Android·Web의 구현이 달라도 대시보드의 숫자가 무엇을 뜻하는지는 같게 유지할 수 있다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/android/banner&quot;&gt;Google for Developers — Set up banner ads on Android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/ios/banner&quot;&gt;Google for Developers — Set up banner ads on iOS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/android/impression-level-ad-revenue&quot;&gt;Google for Developers — Impression-level ad revenue on Android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/ios/impression-level-ad-revenue&quot;&gt;Google for Developers — Impression-level ad revenue on iOS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://firebase.google.com/docs/analytics/measure-ad-revenue&quot;&gt;Firebase — Measure ad revenue&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API&quot;&gt;MDN — Intersection Observer API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.w3.org/TR/intersection-observer/&quot;&gt;W3C — Intersection Observer&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>iOS</category><category>Android</category><category>Web</category><category>Advertising</category><category>Analytics</category></item><item><title>Game Center·Play Games 리더보드: 날짜 경계·제출 신뢰성</title><link>https://jaemyeong.com/ko/blog/daily-leaderboard-date-boundary-submission-reliability/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/daily-leaderboard-date-boundary-submission-reliability/</guid><description>퍼즐 날짜, 점수 제출 시각, 플랫폼 표시 구간을 분리하고 best-effort 보류와 durable outbox 사이에서 리더보드 전달 보장을 선택하는 방법을 정리합니다.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;23시 50분에 오늘의 퍼즐을 시작해 다음 날 0시 5분에 풀었다고 해 보자. 이 점수는 어느 날 기록일까? 앱의 오늘 퍼즐에는 시작한 날의 기록이지만, 리더보드 SDK에는 제출한 날의 점수로 들어갈 수 있다. 사용자가 여는 “오늘” 순위 화면은 둘 중 어느 쪽도 아닌 플랫폼 고유의 시간 구간을 보여 줄 수도 있다.&lt;/p&gt;
&lt;p&gt;일일 리더보드에서 날짜를 하나로 취급하면 자정 부근에서만 나타나는 버그가 생긴다. 더 까다로운 점은 제출 실패다. 게임 완료와 로컬 통계는 정상인데 원격 점수만 사라질 수 있고, 이를 막겠다고 완료 화면에서 로그인을 띄우면 핵심 플레이 흐름이 인증 상태에 종속된다.&lt;/p&gt;
&lt;p&gt;이 글에서는 같은 일일 퍼즐을 Game Center와 Google Play Games에 제출하는 흐름을 기준으로 날짜 경계, 인증, 보류 제출, 로컬 실패 격리를 정리한다. API 동작은 2026년 8월 6일 Apple·Google 공식 문서 기준이다.&lt;/p&gt;
&lt;h2&gt;daily는 하나의 날짜가 아니다&lt;/h2&gt;
&lt;p&gt;먼저 “오늘”을 세 개의 값으로 나눈다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;th&gt;뜻&lt;/th&gt;
&lt;th&gt;소유자&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;puzzle_day&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;어떤 일일 퍼즐을 풀었는지 나타내는 도메인 날짜&lt;/td&gt;
&lt;td&gt;앱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;submitted_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;점수를 SDK에 보낸 시각&lt;/td&gt;
&lt;td&gt;앱과 기기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;leaderboard_window&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;순위 화면이 daily 또는 today로 묶는 시간 구간&lt;/td&gt;
&lt;td&gt;플랫폼 SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;puzzle_day&lt;/code&gt;는 퍼즐을 만든 순간 결정되고 플레이 도중 바뀌지 않아야 한다. &lt;code&gt;submitted_at&lt;/code&gt;은 재시도 여부에 따라 달라질 수 있다. &lt;code&gt;leaderboard_window&lt;/code&gt;는 앱의 로컬 자정과 일치한다고 가정할 수 없다.&lt;/p&gt;
&lt;p&gt;따라서 점수 제출 payload에 날짜를 넣는 것과 플랫폼의 일일 순위 화면을 그 날짜로 필터링하는 것은 별개의 기능이다. 앱은 앞의 두 값을 통제할 수 있지만, 표준 리더보드 UI의 시간 구간은 플랫폼 계약을 따른다.&lt;/p&gt;
&lt;h2&gt;퍼즐 날짜는 완료 시각이 아니라 보드 정체성에서 가져온다&lt;/h2&gt;
&lt;p&gt;자정을 넘긴 게임에서 완료 시각으로 날짜를 다시 계산하면, 같은 퍼즐이 플레이 속도에 따라 서로 다른 날짜에 기록된다. 점수에 붙일 날짜는 완료 순간의 시계가 아니라 게임 세션이 들고 있던 &lt;code&gt;puzzle_day&lt;/code&gt;여야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;퍼즐 생성: 2026-08-05 23:50 → puzzle_day = 2026-08-05
게임 완료: 2026-08-06 00:05 → submitted_at = 2026-08-06 00:05
제출 메타데이터                    → 2026-08-05
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Android에서는 이 날짜를 &lt;code&gt;yyyy-MM-dd&lt;/code&gt; 형태의 &lt;code&gt;scoreTag&lt;/code&gt;로 보낼 수 있다. 중요한 것은 tag가 점수의 출처를 설명할 뿐이라는 점이다. 날짜별 중복 제거 키도 아니고, 표준 Play Games 순위 화면의 날짜 필터도 아니다.&lt;/p&gt;
&lt;p&gt;Game Center의 점수 제출 API에는 앱별 데이터를 담는 &lt;code&gt;context&lt;/code&gt;가 있지만, 날짜를 전달하려면 앱이 직접 인코딩 규칙을 정해야 한다. &lt;code&gt;context: 0&lt;/code&gt;으로 제출하는 구현이라면 Game Center 점수만 보고 원래의 &lt;code&gt;puzzle_day&lt;/code&gt;를 복원할 수 없다. 두 플랫폼이 같은 날짜 의미를 가져야 한다면 이 차이를 숨기지 말고 제품 계약에 적어야 한다.&lt;/p&gt;
&lt;h2&gt;scoreTag와 context는 날짜 파티션이 아니다&lt;/h2&gt;
&lt;p&gt;Google의 &lt;code&gt;submitScore&lt;/code&gt;는 선택적인 &lt;code&gt;scoreTag&lt;/code&gt;를 받는다. 문서상 tag는 점수에 붙는 URI-safe 메타데이터이며 최대 64자다. 앱이 &lt;code&gt;2026-08-05&lt;/code&gt;를 넣어도 &lt;code&gt;TIME_SPAN_DAILY&lt;/code&gt; 화면이 그 tag를 기준으로 행을 나누지는 않는다.&lt;/p&gt;
&lt;p&gt;Game Center의 &lt;code&gt;context&lt;/code&gt;도 앱이 해석하는 부가 데이터다. 표준 UI의 &lt;code&gt;timeScope&lt;/code&gt;를 바꾸지 않는다. 날짜별 순위를 정확히 분리해야 한다면 가능한 선택지는 다음처럼 요구 수준에 따라 달라진다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;플랫폼이 제공하는 daily 또는 today 화면이면 충분하다면 메타데이터만 남기고 표준 UI를 쓴다.&lt;/li&gt;
&lt;li&gt;Game Center에서 명시적인 회차가 필요하다면 recurring leaderboard를 검토한다.&lt;/li&gt;
&lt;li&gt;두 플랫폼에서 동일한 달력 날짜와 조회 규칙이 반드시 필요하다면 별도 저장소와 커스텀 UI가 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;마지막 선택지는 운영·부정행위 방지·개인정보 처리까지 함께 생긴다. “오늘의 경쟁”이면 충분한 제품에 미리 도입할 이유는 없다.&lt;/p&gt;
&lt;h2&gt;두 플랫폼의 오늘은 같은 구간이 아니다&lt;/h2&gt;
&lt;p&gt;Google Play Games는 모든 리더보드에 daily·weekly·all-time 변형을 만들며, daily는 연중 UTC-7 자정에 초기화한다. Game Center의 &lt;code&gt;.today&lt;/code&gt;는 달력상의 오늘이 아니라 최근 24시간 범위다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;화면&lt;/th&gt;
&lt;th&gt;시간 의미&lt;/th&gt;
&lt;th&gt;앱의 &lt;code&gt;puzzle_day&lt;/code&gt;와 일치 보장&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Play Games &lt;code&gt;TIME_SPAN_DAILY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UTC-7 기준 일일 구간&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game Center &lt;code&gt;.today&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;현재 시점 이전 24시간&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game Center recurring occurrence&lt;/td&gt;
&lt;td&gt;App Store Connect에서 정한 반복 회차&lt;/td&gt;
&lt;td&gt;설정에 따름&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;서울 자정에 새 퍼즐을 여는 앱이라면 이 차이가 바로 드러난다. 0시 직후 새 퍼즐 점수가 앱의 전날 퍼즐 점수와 같은 Play Games daily 구간에 보이거나, Game Center today 화면에 어제 점수와 오늘 점수가 함께 보일 수 있다. 이것은 &lt;code&gt;scoreTag&lt;/code&gt;나 &lt;code&gt;context&lt;/code&gt;로 고칠 수 있는 표시 버그가 아니라 서로 다른 시간 계약이다.&lt;/p&gt;
&lt;p&gt;점수 선택 정책도 따로 본다. Android 앱의 latest-wins 보류 슬롯은 “어떤 미전송 시도를 남길지” 정하는 로컬 정책이다. Play Games는 서버의 현재 기록보다 나은 점수를 반영하고, Game Center는 리더보드 설정에 따라 best score 또는 most recent score를 사용할 수 있다. 로컬에서 최신 시도를 골랐다고 서버가 최신 값을 순위 기록으로 채택한다는 뜻은 아니다.&lt;/p&gt;
&lt;h2&gt;완료 화면에서 인증을 시작하지 않는다&lt;/h2&gt;
&lt;p&gt;게임 완료는 인증 UI가 나타나기 가장 나쁜 시점이다. 제출 조건은 부작용 없는 판정으로 닫는 편이 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;submit = won &amp;amp;&amp;amp; daily &amp;amp;&amp;amp; authenticated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;무료 플레이, 패배, 비인증 상태에서는 제출하지 않는다. 인증 상태가 불확실하다면 조용히 확인할 수는 있지만 완료 처리 중 로그인 화면을 띄우지는 않는다. 대화형 로그인은 사용자가 리더보드 버튼을 누른 경로에만 둔다.&lt;/p&gt;
&lt;p&gt;이렇게 하면 Game Center나 Play Games가 꺼져 있거나 로그아웃된 상태에서도 퍼즐 완료, 로컬 통계, 다음 화면 이동이 그대로 작동한다. 로그인 취소도 게임 완료 실패로 바뀌지 않는다.&lt;/p&gt;
&lt;h2&gt;latest-wins 한 칸은 오프라인 큐가 아니다&lt;/h2&gt;
&lt;p&gt;Android UI host가 아직 연결되지 않은 짧은 구간을 위해 메모리에 제출 한 건만 보류할 수 있다. 새 점수가 들어오면 이전 점수를 교체하고, Activity가 연결되면 한 번 꺼내 인증을 확인한 뒤 제출한다. 이 정책의 범위는 작고 명확하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Activity 없음
  score A 보류
  score B 도착 → A를 B로 교체
Activity 연결
  B를 꺼내 인증 확인 → 제출 시도
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그러나 이것은 재시도 큐가 아니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;프로세스가 종료되면 보류 값이 사라진다.&lt;/li&gt;
&lt;li&gt;값을 꺼낸 뒤 조용한 인증 확인이 false이거나 요청 자체가 실패해도 다시 넣지 않는다.&lt;/li&gt;
&lt;li&gt;네트워크 제출 결과를 받지 않는 fire-and-forget 호출은 앱에서 성공 여부를 확정할 수 없다.&lt;/li&gt;
&lt;li&gt;iOS에 같은 보류 슬롯이 없다면 플랫폼별 전달 가능성도 다르다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Google의 &lt;code&gt;submitScore&lt;/code&gt; 문서도 호출 결과를 앱에 알리지 않는 fire-and-forget 방식이라고 설명한다. &lt;code&gt;submitScoreImmediate&lt;/code&gt;는 &lt;code&gt;Task&lt;/code&gt;를 돌려주지만, 그것만으로 프로세스 재시작을 견디는 durable retry가 생기지는 않는다.&lt;/p&gt;
&lt;p&gt;일일 점수가 부가 기능이고 일부 누락을 허용한다면 이 한 칸이 합리적인 상한선이다. 전달을 보장해야 할 때만 디스크 기반 outbox, 재시작 복구, backoff, 성공 확인을 추가한다.&lt;/p&gt;
&lt;h2&gt;로컬 완료와 원격 제출을 다른 실패 도메인으로 둔다&lt;/h2&gt;
&lt;p&gt;원격 리더보드는 게임 완료의 원장이 아니다. 로컬 완료 경로는 최소한 다음 책임을 가진다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;같은 terminal transition을 한 번만 기록한다.&lt;/li&gt;
&lt;li&gt;재개용 저장 슬롯을 지운다.&lt;/li&gt;
&lt;li&gt;통계와 일일 완료 기록을 갱신한다.&lt;/li&gt;
&lt;li&gt;원격 제출은 별도 best-effort 경로로 시도한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;iOS처럼 로컬 저장을 요청한 뒤 비동기 Game Center 제출을 시작하거나, Android처럼 로컬 기록 작업과 원격 제출을 독립적으로 시작할 수 있다. 어느 쪽이든 원격 실패가 완료 상태를 되돌리면 안 된다.&lt;/p&gt;
&lt;p&gt;여기서 “local-first”를 “원격 호출 전에 디스크 flush까지 끝났다”는 뜻으로 과장해서는 안 된다. Android처럼 로컬 쓰기를 비동기 작업으로 예약하고 곧바로 원격 호출을 시작했다면 두 작업의 완료 순서는 보장되지 않는다. 보장되는 계약은 원격 오류가 로컬 완료를 rollback하지 않는다는 것이다. 디스크 확정 순서까지 필요하다면 저장 완료를 기다리는 별도 설계가 필요하다.&lt;/p&gt;
&lt;h2&gt;전달 보장을 먼저 선택한다&lt;/h2&gt;
&lt;p&gt;구현을 키우기 전에 제품이 요구하는 손실 허용치를 고른다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;정책&lt;/th&gt;
&lt;th&gt;보장&lt;/th&gt;
&lt;th&gt;비용&lt;/th&gt;
&lt;th&gt;적합한 경우&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;best-effort 직접 제출&lt;/td&gt;
&lt;td&gt;현재 세션에서 한 번 시도&lt;/td&gt;
&lt;td&gt;가장 낮음&lt;/td&gt;
&lt;td&gt;순위가 부가 기능이고 누락 허용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메모리 latest-wins 한 칸&lt;/td&gt;
&lt;td&gt;UI host 부재 중 최신 한 건 보존&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;짧은 lifecycle race만 완화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;durable outbox&lt;/td&gt;
&lt;td&gt;재시작 뒤 재시도 가능&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;제출 누락이 보상·대회 결과에 영향&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;durable outbox가 필요하다면 payload를 먼저 영속화하고, 안정적인 제출 식별자와 &lt;code&gt;puzzle_day&lt;/code&gt;, 점수, 대상 리더보드를 함께 저장한다. 성공 응답 뒤에만 제거하고 재시작 시 복구한다. 서버가 더 좋은 기존 점수를 유지하는 정책과 앱의 재전송 완료 판정도 분리해야 한다.&lt;/p&gt;
&lt;p&gt;그 요구가 없다면 한 칸짜리 보류를 범용 큐로 확장하지 않는 편이 낫다. 손실 가능성을 테스트와 문서에 남기는 것으로 충분하다.&lt;/p&gt;
&lt;h2&gt;경계 사례로 계약을 검증한다&lt;/h2&gt;
&lt;p&gt;SDK 실서버에 의존하기 전에 순수 판정과 보류 정책을 작은 표로 고정한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;입력&lt;/th&gt;
&lt;th&gt;기대 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;무료 플레이 승리&lt;/td&gt;
&lt;td&gt;원격 제출 0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;일일 퍼즐 패배&lt;/td&gt;
&lt;td&gt;원격 제출 0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;일일 퍼즐 승리, 비인증&lt;/td&gt;
&lt;td&gt;로그인 UI 없이 제출 0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:50 시작, 00:05 완료&lt;/td&gt;
&lt;td&gt;완료 시각이 아닌 세션의 &lt;code&gt;puzzle_day&lt;/code&gt; 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activity 없이 A, B 순서로 완료&lt;/td&gt;
&lt;td&gt;보류 슬롯에는 B 한 건만 남음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;보류 뒤 프로세스 종료&lt;/td&gt;
&lt;td&gt;재시작 제출 없음이 현재 계약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;보류를 꺼낸 뒤 인증 실패&lt;/td&gt;
&lt;td&gt;자동 재보류 없음이 현재 계약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;원격 제출 실패&lt;/td&gt;
&lt;td&gt;로컬 완료와 통계는 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;더 낮은 최신 점수 제출&lt;/td&gt;
&lt;td&gt;로컬 latest 선택과 서버의 best-score 선택을 별도 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;terminal event 중복 전달&lt;/td&gt;
&lt;td&gt;완료 기록과 제출 시도 각각 1회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;표준 daily/today 화면 열기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;scoreTag&lt;/code&gt;·&lt;code&gt;context&lt;/code&gt;가 아니라 SDK 시간 구간 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;실제 SDK 통합 테스트에서는 인증 취소, 네트워크 단절, leaderboard intent 또는 access point 표시 실패를 별도로 확인한다. 단위 테스트가 보장하는 것은 앱의 분기와 payload이며, 플랫폼 서버가 점수를 반영했다는 사실은 아니다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;일일 리더보드의 날짜는 &lt;code&gt;puzzle_day&lt;/code&gt;, &lt;code&gt;submitted_at&lt;/code&gt;, &lt;code&gt;leaderboard_window&lt;/code&gt;로 나눠야 한다. 퍼즐 날짜는 세션의 정체성에서 가져오고, &lt;code&gt;scoreTag&lt;/code&gt;와 &lt;code&gt;context&lt;/code&gt;는 메타데이터일 뿐 표준 순위 화면의 날짜 파티션으로 보지 않는다.&lt;/p&gt;
&lt;p&gt;완료 경로에서는 인증 UI를 띄우지 않고 로컬 기록과 원격 제출을 서로 다른 실패 도메인으로 둔다. 메모리 latest-wins 한 칸은 lifecycle race를 줄이는 best-effort 장치이지 재시도 큐가 아니다. 누락이 제품적으로 허용되지 않을 때만 durable outbox를 추가한다. 이 세 경계를 먼저 정하면 자정을 넘긴 점수와 실패한 제출이 게임 완료 자체를 흔들지 않는다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/games/pgs/leaderboards&quot;&gt;Android Developers — Leaderboards&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/android/reference/com/google/android/gms/games/LeaderboardsClient&quot;&gt;Google for Developers — LeaderboardsClient&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/games/pgs/android/android-signin&quot;&gt;Android Developers — Platform authentication&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/gamekit/gkleaderboard/submitscore(_:context:player:leaderboardids:completionhandler:)&quot;&gt;Apple Developer — Submit scores and ranks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/gamekit/gkleaderboard/timescope/today&quot;&gt;Apple Developer — GKLeaderboard.TimeScope.today&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/gamekit/authenticating-a-player&quot;&gt;Apple Developer — Authenticating a player&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/gamekit/creating-recurring-leaderboards&quot;&gt;Apple Developer — Creating recurring leaderboards&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/help/app-store-connect/reference/game-center/leaderboards/&quot;&gt;App Store Connect Help — Game Center leaderboards&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>iOS</category><category>Android</category><category>GameKit</category><category>Google Play Games</category><category>Testing</category></item><item><title>같은 날, 같은 퍼즐: generatorVersion이 있는 PuzzleIdentity 설계</title><link>https://jaemyeong.com/ko/blog/daily-puzzle-identity-generator-version/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/daily-puzzle-identity-generator-version/</guid><description>같은 seed를 재현하는 것과 같은 퍼즐을 영구히 식별하는 것은 다르다. DailySudoku의 날짜·시간대·난이도·생성기 흐름을 바탕으로 generatorVersion, 마이그레이션, 롤백 계약을 정리합니다.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;데일리 퍼즐은 보통 날짜를 seed로 바꾸는 데서 시작한다. 같은 날짜를 같은 정수로 만들고, 같은 난수 생성기와 같은 알고리즘에 넣으면 같은 보드를 얻는다. 여기까지 구현하면 “오늘의 퍼즐”이 완성된 것처럼 보인다.&lt;/p&gt;
&lt;p&gt;문제는 생성 알고리즘이 바뀌는 날 시작된다. 같은 seed라도 셀 제거 순서, 재시도 횟수, 난이도 기준이 바뀌면 결과 보드는 달라질 수 있다. 그런데 저장·분석·리더보드가 날짜만 퍼즐 ID로 쓰고 있다면 서로 다른 두 보드를 같은 퍼즐로 기록하게 된다.&lt;/p&gt;
&lt;p&gt;DailySudoku의 개발 소스는 이 경계를 잘 보여준다. 이 글의 구현 근거는 2026년 8월 6일에 고정해 확인한 &lt;code&gt;develop&lt;/code&gt; 커밋 &lt;code&gt;9c999305&lt;/code&gt;다. 이 커밋의 fresh generation은 결정적이지만, 생성기 버전을 포함한 영구 식별 계약까지 구현된 상태는 아니다.&lt;/p&gt;
&lt;h2&gt;seed 하나로는 퍼즐 정체성이 되지 않는다&lt;/h2&gt;
&lt;p&gt;지원되는 정상 daily UI에서 시작한 fresh game은 세 플랫폼에서 대략 같은 흐름을 따른다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;현재 시각
  -&amp;gt; 기기 로컬 시간대의 epochDay
  -&amp;gt; dailySeed(epochDay)
  -&amp;gt; generate(MEDIUM, seed)
  -&amp;gt; puzzleId = &quot;DAILY-&amp;lt;epochDay&amp;gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dailySeed&lt;/code&gt;는 64비트 wrapping 연산과 logical shift를 사용하는 SplitMix64 finalizer다. 생성기는 프로젝트가 소유한 RNG로 완성 보드를 만든 뒤, 해답이 하나로 유지되는 동안 clue를 제거한다. iOS와 Android는 UniFFI, Web은 WebAssembly를 통해 같은 Rust core를 호출한다.&lt;/p&gt;
&lt;p&gt;따라서 현재 코드가 보장하는 범위는 구체적이다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;동일한 local epoch day, 정상 daily 진입점의 MEDIUM 난이도, 동일한 Rust core 빌드라면 fresh game의 보드와 해답이 같다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;여기서 seed는 생성 입력이지 영구 ID가 아니다. seed의 의미는 RNG와 generator 구현에 의존한다. 실제 개발 이력에서도 Expert 퍼즐의 최대 재시도 횟수를 8회에서 4,096회로 바꾸자 seed &lt;code&gt;159&lt;/code&gt;가 종전 25-clue fallback 대신 24-clue 보드를 만들게 됐다. 현재 daily는 MEDIUM이므로 이 사례가 당일 보드를 바꿨다는 뜻은 아니다. 다만 &lt;strong&gt;같은 seed와 난이도만으로 알고리즘 변경 전후의 보드까지 동일하다고 말할 수 없다는 실제 반례&lt;/strong&gt;다.&lt;/p&gt;
&lt;p&gt;같은 seed의 결정성과 golden fixture를 어떻게 검사하는지는 이전의 &lt;a href=&quot;/ko/blog/rust-shared-core-07-testing-ci/&quot;&gt;Rust 공통 모듈 테스트와 CI 글&lt;/a&gt;에서 이미 다뤘다. 여기서 필요한 다음 질문은 “결과가 바뀌는 변경을 어떻게 식별할 것인가”다.&lt;/p&gt;
&lt;h2&gt;epochDay를 계산하기 전에 시간대 계약을 고정한다&lt;/h2&gt;
&lt;p&gt;DailySudoku의 &lt;code&gt;epochDay&lt;/code&gt;는 UTC 날짜가 아니라 기기 로컬 날짜다. 여기서 &lt;code&gt;offsetAtInstant&lt;/code&gt;는 해당 instant의 &lt;code&gt;local - UTC&lt;/code&gt;를 밀리초로 환산한 값이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;localEpochDay = floor((utcMillis + offsetAtInstant) / 86_400_000)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;API의 부호와 단위는 서로 다르다. Android의 &lt;code&gt;TimeZone.getOffset&lt;/code&gt;은 그대로 밀리초를 쓰고, iOS의 &lt;code&gt;secondsFromGMT(for:)&lt;/code&gt;는 초에 1,000을 곱한다. Web의 &lt;code&gt;Date.getTimezoneOffset()&lt;/code&gt;은 반대 방향인 &lt;code&gt;UTC - local&lt;/code&gt; 분을 반환하므로 &lt;code&gt;-getTimezoneOffset() * 60_000&lt;/code&gt;으로 바꾼다. 세 구현은 이렇게 정규화한 뒤 같은 식을 적용한다. 따라서 서머타임이 있는 지역에서도 offset을 고정값처럼 하드코딩하지 않는다.&lt;/p&gt;
&lt;p&gt;이 정책에는 중요한 의미가 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서울과 로스앤젤레스의 사용자는 같은 UTC 순간에도 서로 다른 local epoch day를 가질 수 있다.&lt;/li&gt;
&lt;li&gt;사용자가 여행 중 시간대를 바꾸면 다음 실행에서 다른 daily ID가 계산될 수 있다.&lt;/li&gt;
&lt;li&gt;이미 시작한 게임은 그 판을 시작할 때의 epoch day를 유지하고, 다음 daily 진입에서 새 날짜를 판단한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그러므로 “전 세계가 같은 순간에 같은 보드를 받는다”는 현재 구현의 보장이 아니다. 정확한 표현은 “같은 local epoch day를 정상 daily UI의 MEDIUM으로 선택한 사용자는 같은 core 빌드에서 같은 보드를 받는다”다.&lt;/p&gt;
&lt;p&gt;시간대 ID 자체를 퍼즐 ID에 넣을 필요는 없다. 같은 core 빌드의 정상 MEDIUM daily에서 서울과 도쿄가 같은 epoch day라면 같은 보드를 공유하는 것이 현재 정책이기 때문이다. 대신 날짜를 고르는 규칙은 버전된 값으로 명시해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type DailyPuzzleIdentity = {
  dayPolicy: &quot;device-local-v1&quot;;
  epochDay: number;
  difficulty: &quot;MEDIUM&quot;;
  generatorVersion: number;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dayPolicy&lt;/code&gt;는 어떤 시간대를 관측 메타데이터로 남길지와 별개다. 나중에 UTC 자정이나 서버 기준 날짜로 정책을 바꾸더라도 기존 ID를 새 규칙으로 재해석하지 않게 만드는 최소 계약이다.&lt;/p&gt;
&lt;h2&gt;generatorVersion이 같은 seed의 의미를 고정한다&lt;/h2&gt;
&lt;p&gt;현재 daily ID는 &lt;code&gt;DAILY-&amp;lt;epochDay&amp;gt;&lt;/code&gt;다. 정상 UI에서는 난이도가 MEDIUM으로 고정되지만 그 값은 ID에 없고, generator 버전도 없다. Rust 저장 JSON의 &lt;code&gt;version: 1&lt;/code&gt;은 &lt;strong&gt;직렬화 schema version&lt;/strong&gt;이며 다른 버전은 migration 없이 거부된다. 생성 알고리즘 버전과는 관계가 없다.&lt;/p&gt;
&lt;p&gt;최소한의 버전된 ID는 다음처럼 만들 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DAILY-device-local-v1-20671-MEDIUM-g1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;필드별 책임은 분리된다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;필드&lt;/th&gt;
&lt;th&gt;답하는 질문&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dayPolicy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;어떤 날짜 경계로 daily를 선택했는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;epochDay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;그 정책에서 어느 날인가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;difficulty&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;어떤 생성 규칙과 목표를 요청했는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;generatorVersion&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;seed 파생·RNG·보드 생성 규칙의 어느 버전인가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;그 버전의 생성기를 재현할 입력은 무엇인가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;seed는 &lt;code&gt;epochDay&lt;/code&gt;에서 다시 계산할 수 있어 ID에 중복 저장하지 않아도 된다. 다만 장애 분석과 fixture 생성에 유용하므로 persistence나 telemetry에 파생값으로 남길 수 있다. 핵심은 seed를 저장했느냐가 아니라 그 seed를 해석하는 generator 버전을 함께 고정했느냐다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;generatorVersion&lt;/code&gt;은 Cargo package version과도 분리하는 편이 낫다. UI 수정이나 광고 SDK 업데이트마다 퍼즐 정체성이 바뀔 이유는 없다. 반대로 RNG, shuffle, 난이도 목표, clue 제거, 재시도 정책처럼 보드 결과를 바꿀 수 있는 변경은 generator 버전을 올려야 한다.&lt;/p&gt;
&lt;p&gt;한 번 발급한 version의 의미는 불변이어야 한다. Rust generator가 version을 입력받아 identity와 board를 함께 반환하게 만들면 caller가 &lt;code&gt;g2&lt;/code&gt; board에 &lt;code&gt;g1&lt;/code&gt; ID를 붙이는 실수를 줄일 수 있다. 그래도 version 번호를 올리지 않은 코드 변경까지 자동으로 막아 주는 것은 아니다. immutable fixture는 선택된 입력의 drift를 잡고, 모든 board의 절대 일치를 확인해야 하는 시스템은 canonical board digest나 archive를 추가로 사용해야 한다.&lt;/p&gt;
&lt;h2&gt;퍼즐 ID 유일성과 해답 유일성은 다른 검증이다&lt;/h2&gt;
&lt;p&gt;“unique puzzle”이라는 표현에는 서로 다른 두 문제가 섞이기 쉽다.&lt;/p&gt;
&lt;p&gt;첫째는 스도쿠 규칙의 유일성이다. 현재 generator는 clue를 하나 제거할 때마다 해답 수를 최대 2개까지 세고, 정확히 하나일 때만 제거를 유지한다. 이 검사는 fresh puzzle에 해답이 하나뿐인지 확인한다.&lt;/p&gt;
&lt;p&gt;둘째는 데이터 식별자의 유일성이다. &lt;code&gt;DAILY-20671&lt;/code&gt;이라는 문자열이 오직 한 보드만 가리키는지 확인하는 문제다. 해답이 하나인 보드 두 개가 같은 ID를 쓸 수도 있고, 같은 보드가 서로 다른 ID로 기록될 수도 있다. solver는 이 충돌을 잡지 못한다.&lt;/p&gt;
&lt;p&gt;따라서 검증도 나눠야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;solver invariant
  -&amp;gt; 이 board에는 해답이 정확히 하나인가

identity invariant
  -&amp;gt; 이 PuzzleIdentity는 배포·복원·분석 전체에서 같은 board를 뜻하는가
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;board hash를 ID에 추가하면 결과 drift를 탐지하는 데는 도움이 된다. 하지만 버전 계약을 대신하지는 않는다. hash만 있으면 왜 결과가 바뀌었는지, 어떤 generator를 다시 실행해야 하는지 알 수 없다. 먼저 버전된 identity를 만들고, 실제 불일치 탐지가 필요할 때 checksum을 붙이는 순서가 단순하다.&lt;/p&gt;
&lt;h2&gt;과거 PuzzleIdentity는 새 generator로 재해석하지 않는다&lt;/h2&gt;
&lt;p&gt;생성기 교체에서 가장 위험한 시점은 배포 채널과 앱 binary가 독립적으로 움직여 서로 다른 core 버전이 공존하는 기간이다.&lt;/p&gt;
&lt;p&gt;버전이 없는 ID에서 &lt;code&gt;g1&lt;/code&gt;을 &lt;code&gt;g2&lt;/code&gt;로 교체하면 같은 날짜에 다음 상태가 공존할 수 있다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;사용자 상태&lt;/th&gt;
&lt;th&gt;생성되는 보드&lt;/th&gt;
&lt;th&gt;저장되는 ID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;이전 앱의 신규 사용자&lt;/td&gt;
&lt;td&gt;&lt;code&gt;g1(day)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DAILY-day&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;새 앱의 신규 사용자&lt;/td&gt;
&lt;td&gt;&lt;code&gt;g2(day)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DAILY-day&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이전 저장을 복원한 사용자&lt;/td&gt;
&lt;td&gt;저장된 &lt;code&gt;g1&lt;/code&gt; board&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DAILY-day&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;현재 저장 schema와 호환되는 generator 변경이라면 저장된 board와 solution을 그대로 복원할 수 있어 진행 중인 구 보드를 새 generator로 다시 만들 필요는 없다. 하지만 외부 ID는 여전히 같아서 analytics는 두 보드를 구분하지 못한다. 표준 Game Center·Play Games 리더보드도 동적 puzzle ID로 점수를 분할하지 않으므로, 제출 전에 어느 버전의 점수를 받을지 결정하지 않으면 같은 기간의 결과가 섞인다. &lt;code&gt;context&lt;/code&gt;나 score tag의 한계는 &lt;a href=&quot;/ko/blog/daily-leaderboard-date-boundary-submission-reliability/&quot;&gt;데일리 리더보드 날짜 경계 글&lt;/a&gt;에서 별도로 정리했다.&lt;/p&gt;
&lt;p&gt;새 identity를 넣을 때는 저장 schema도 함께 올려야 한다. 현재 legacy 저장본에는 generator version이 없으므로 “기존 identity를 그대로 보존한다”는 말만으로는 부족하다. 최소 마이그레이션 규칙은 다음과 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;schema v2는 board snapshot과 versioned identity를 함께 쓴다.&lt;/li&gt;
&lt;li&gt;v1 저장본이 모두 &lt;code&gt;g1&lt;/code&gt;에서 만들어졌음을 릴리스 이력으로 확인할 수 있을 때만 &lt;code&gt;g1&lt;/code&gt;으로 승격한다.&lt;/li&gt;
&lt;li&gt;그 출처를 확인할 수 없다면 저장 시점마다 바뀌는 entry·history·경과 시간은 제외하고, &lt;code&gt;given + solution + difficulty&lt;/code&gt;처럼 불변인 퍼즐 내용의 canonical digest를 사용한 &lt;code&gt;legacy-&amp;lt;digest&amp;gt;&lt;/code&gt; namespace로 격리해 version별 비교에 넣지 않는다.&lt;/li&gt;
&lt;li&gt;board나 version이 없는 과거 analytics 기록은 사후에 정확히 복구할 수 없으므로 &lt;code&gt;legacy-unknown&lt;/code&gt;으로 남긴다.&lt;/li&gt;
&lt;li&gt;fresh game만 활성 &lt;code&gt;generatorVersion&lt;/code&gt;으로 생성하고, 과거 날짜를 재생성할 때는 기록된 version으로 dispatch한다.&lt;/li&gt;
&lt;li&gt;지원하지 않는 과거 version은 최신 generator로 조용히 재해석하지 않고 명시적으로 거부한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;과거 daily를 다시 열지 않는 제품이라면 모든 generator 구현을 영구 보관할 필요는 없다. 진행 중 저장본에 board와 solution이 있고, 이미 발급한 identity를 저장·분석 경로가 유지하면 된다. 과거 퍼즐 재플레이나 서버 검증을 제공할 때만 구 generator 또는 immutable board archive가 필요하다.&lt;/p&gt;
&lt;p&gt;versioning은 구·신 보드를 같은 ID로 합치지 않게 할 뿐, rollout 중 “하루에 보드 하나”를 보장하지 않는다. 그것이 제품 요구라면 immutable한 &lt;code&gt;epochDay -&amp;gt; generatorVersion&lt;/code&gt; 발급 정책과 구 client 처리 전략을 서버 같은 단일 권위에서 운영해야 한다.&lt;/p&gt;
&lt;h2&gt;롤백해도 이미 발급한 퍼즐은 바꾸지 않는다&lt;/h2&gt;
&lt;p&gt;배포 직후 &lt;code&gt;g2&lt;/code&gt;에 문제가 생겨 &lt;code&gt;g1&lt;/code&gt;으로 롤백하는 상황도 같은 계약으로 처리해야 한다. 여기서 안전한 롤백은 pre-version binary로 되돌리는 것이 아니라, version-aware binary를 유지한 채 활성 발급만 &lt;code&gt;g2&lt;/code&gt;에서 &lt;code&gt;g1&lt;/code&gt;으로 바꾸는 것이다. 이미 &lt;code&gt;g2&lt;/code&gt; ID로 발급한 보드를 &lt;code&gt;g1&lt;/code&gt; 결과로 바꾸면 안 된다.&lt;/p&gt;
&lt;p&gt;안전한 롤백 기준은 코드 버전이 아니라 identity다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PuzzleIdentity.generatorVersion == 1 -&amp;gt; g1 또는 저장된 g1 board
PuzzleIdentity.generatorVersion == 2 -&amp;gt; g2 또는 저장된 g2 board
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 롤백을 가능하게 하려면 먼저 versioned ID와 schema v1/v2 읽기를 지원하지만 &lt;code&gt;g1&lt;/code&gt;만 발급하는 호환 build를 배포해야 한다. 그다음 build나 원격 정책에서 &lt;code&gt;g2&lt;/code&gt;를 활성화한다. 롤백 기간에는 &lt;code&gt;g1&lt;/code&gt; 생성과 &lt;code&gt;g2&lt;/code&gt; snapshot decoding을 모두 보존하고, pre-version binary로 내려가지 않는다.&lt;/p&gt;
&lt;p&gt;현재 DailySudoku의 generator는 클라이언트 binary에 포함돼 있고 중앙 버전 선택기가 없다. 서버나 Web 배포를 롤백해도 이미 설치된 &lt;code&gt;g2&lt;/code&gt; 모바일 앱은 계속 &lt;code&gt;g2&lt;/code&gt;를 실행한다. versioned identity는 이 공존을 숨기지 않게 만들 뿐, 배포를 원자적으로 되돌려 주지는 않는다. 모든 사용자에게 한 version만 강제해야 한다면 서버가 identity나 board를 발급하거나 최소 지원 앱 version을 올리는 별도 운영 장치가 필요하다.&lt;/p&gt;
&lt;p&gt;서버 또는 Remote Config가 버전을 선택하고 두 generator가 클라이언트에 함께 들어 있는 구조라면 새 발급만 &lt;code&gt;g1&lt;/code&gt;으로 되돌릴 수 있다. 그래도 이미 저장된 &lt;code&gt;g2&lt;/code&gt; 게임은 &lt;code&gt;g2&lt;/code&gt; identity와 board로 끝낼 수 있어야 한다. 표준 Game Center·Play Games는 임의의 puzzle ID로 점수를 제외하거나 분할하는 기능이 아니므로, &lt;code&gt;g2&lt;/code&gt; 점수를 받지 않기로 했다면 제출 전 정책에서 차단해야 한다. version별 조회가 필요하면 커스텀 백엔드나 미리 분리한 leaderboard 구성이 필요하다.&lt;/p&gt;
&lt;h2&gt;버전 전환 경계만 계약 테스트로 고정한다&lt;/h2&gt;
&lt;p&gt;현재 Rust parity fixture는 4개 epoch day와 5개 난이도의 seed·givens·solution을 고정해 선택된 입력에서 generator 출력의 비의도적 변경을 탐지한다. 하지만 fixture를 새 알고리즘 결과로 재생성하면 구 version의 의미는 사라진다.&lt;/p&gt;
&lt;p&gt;새로운 대규모 테스트 체계보다 버전 경계 몇 개를 불변 fixture로 남기는 편이 낫다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;g1 fixture: 변경 금지
  (device-local-v1, day-before-cutover, MEDIUM, g1) -&amp;gt; board A

g2 fixture: 새 파일
  (device-local-v1, cutover-day, MEDIUM, g2) -&amp;gt; board B

resolver test
  저장된 g1 identity -&amp;gt; g1 또는 저장 snapshot
  새 g2 identity     -&amp;gt; g2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기에 다음 세 검사면 버전 전환의 핵심을 잠글 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;어느 지원 build에서든 &lt;code&gt;g1&lt;/code&gt; identity는 immutable &lt;code&gt;g1&lt;/code&gt; fixture와 같은 board와 solution을 만든다.&lt;/li&gt;
&lt;li&gt;서로 다른 generator version은 같은 외부 puzzle ID를 만들지 않는다.&lt;/li&gt;
&lt;li&gt;롤백 후에도 저장된 g2 identity를 g1으로 읽지 않는다.&lt;/li&gt;
&lt;li&gt;Rust generator가 identity와 board를 함께 반환해 caller가 서로 다른 version을 조합할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Swift, Kotlin, TypeScript가 Rust 알고리즘을 그대로 복제해 테스트할 필요는 없다. 버전 전환과 관련된 플랫폼 테스트는 versioned ID를 손실 없이 저장·복원하고 analytics와 리더보드 제출 정책까지 전달하는지 확인한다.&lt;/p&gt;
&lt;h2&gt;현재 보장과 다음 계약을 구분한다&lt;/h2&gt;
&lt;p&gt;DailySudoku의 현재 구현을 과장 없이 정리하면 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;현재 보장&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;동일 local epoch day의 seed&lt;/td&gt;
&lt;td&gt;고정된 64비트 연산과 golden value로 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;선택된 입력의 fresh board&lt;/td&gt;
&lt;td&gt;Rust generator와 parity fixture로 출력 drift 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fresh puzzle의 유일해&lt;/td&gt;
&lt;td&gt;clue 제거 단계와 선택된 seed 테스트로 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;날짜 경계&lt;/td&gt;
&lt;td&gt;기기 로컬 시간대의 해당 instant offset 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;daily ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DAILY-&amp;lt;epochDay&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;generator version 식별&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;구·신 generator 공존&lt;/td&gt;
&lt;td&gt;구분할 계약 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;version별 migration·rollback&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;다음 구현의 최소 순서는 Rust가 identity와 board를 함께 반환하게 만들기, schema v2에서 identity 보존, 분석 키를 versioned ID로 전환, 리더보드 제출 허용 정책에서 version 확인, version별 immutable fixture 추가다. 별도 factory나 범용 migration framework부터 만들 필요는 없다. 실제 두 번째 generator가 생기기 전까지 dispatch는 작은 &lt;code&gt;switch&lt;/code&gt; 하나면 충분하다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;결정론적 생성은 “같은 입력과 같은 코드가 같은 출력을 낸다”는 보장이다. 퍼즐 정체성은 배포 버전과 시간이 지나도 한 ID가 같은 보드를 뜻하게 하는 데이터 계약이다. 둘은 연결돼 있지만 같은 문제가 아니다.&lt;/p&gt;
&lt;p&gt;DailySudoku의 지원되는 정상 daily UI는 local epoch day, MEDIUM 난이도, 공통 Rust core와 golden fixture로 고정 커밋 안의 일관성을 만든다. 반면 &lt;code&gt;DAILY-&amp;lt;epochDay&amp;gt;&lt;/code&gt;에는 날짜 정책, 난이도, generator version이 없다. 알고리즘 변경이나 롤백이 시작되면 같은 ID 아래에 다른 보드가 들어갈 수 있다.&lt;/p&gt;
&lt;p&gt;seed를 더 정교하게 섞는 것이 해법은 아니다. &lt;code&gt;dayPolicy&lt;/code&gt;, &lt;code&gt;epochDay&lt;/code&gt;, &lt;code&gt;difficulty&lt;/code&gt;, &lt;code&gt;generatorVersion&lt;/code&gt;을 identity로 만들고, 저장·분석과 리더보드 제출 정책이 그 값을 끝까지 보존해야 한다. 여기에 version 불변성과 identity·board의 원자적 발급을 지켜야 서로 다른 보드를 같은 ID로 합치는 일을 막을 수 있다. versioning만으로 mixed rollout의 모든 사용자에게 같은 보드를 강제할 수는 없다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;DailySudoku 비공개 저장소(권한 필요) — 기준 commit &lt;code&gt;9c99930582be23d2c72d37fb46d77a488b9fe04c&lt;/code&gt;: &lt;code&gt;core/sudoku-rs/crates/sudoku-core/src/seed.rs:1-27&lt;/code&gt;, &lt;code&gt;core/sudoku-rs/crates/sudoku-core/src/generator.rs:1-130&lt;/code&gt;, &lt;code&gt;core/sudoku-rs/crates/sudoku-core/src/serialization.rs:14-160&lt;/code&gt;, &lt;code&gt;core/sudoku-rs/crates/sudoku-core/tests/parity.rs:1-113&lt;/code&gt;, &lt;code&gt;apps/web/lib/data/session.ts:27-50&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;DailySudoku 비공개 저장소(권한 필요) — Expert generator 재시도 정책 변경 commit &lt;code&gt;08857af1581fac3346314dbba8622155e4460324&lt;/code&gt;: &lt;code&gt;core/sudoku-rs/crates/sudoku-core/src/generator.rs:20-26,147-161&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/foundation/timezone/secondsfromgmt%28for%3A%29&quot;&gt;Apple Developer Documentation — TimeZone.secondsFromGMT(for:)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/reference/java/util/TimeZone#getOffset(long)&quot;&gt;Android Developers — TimeZone.getOffset&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tc39.es/ecma262/#sec-date.prototype.gettimezoneoffset&quot;&gt;ECMAScript Language Specification — Date.prototype.getTimezoneOffset&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doc.rust-lang.org/stable/std/primitive.u64.html#method.wrapping_add&quot;&gt;Rust Standard Library — u64::wrapping_add&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doc.rust-lang.org/stable/std/primitive.u64.html#method.wrapping_mul&quot;&gt;Rust Standard Library — u64::wrapping_mul&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>Rust</category><category>Testing</category><category>iOS</category><category>Android</category><category>WebAssembly</category></item><item><title>위치 권한 없이 광고를 지역화하기: GeoIP와 Provider Chain 설계</title><link>https://jaemyeong.com/ko/blog/geoip-ad-routing-without-location-permission/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/geoip-ad-routing-without-location-permission/</guid><description>OS 위치 권한 없이 동의 상태와 GeoIP 힌트로 광고 Provider Chain을 구성하고, 실패를 안전하게 흡수하는 방법을 정리합니다.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;지역마다 사용할 수 있는 광고 공급자가 다르면 앱은 사용자의 지역을 어느 정도 구분해야 한다. 그렇다고 광고를 고르기 위해 OS 위치 권한부터 요청하는 것은 기능과 권한의 무게가 맞지 않는다. 광고 라우팅에 필요한 것은 사용자의 정확한 좌표가 아니라, 어느 공급자를 먼저 시도할지 정하는 대략적인 힌트이기 때문이다.&lt;/p&gt;
&lt;p&gt;그 힌트로 GeoIP를 쓸 수 있다. 다만 GeoIP는 현재 위치의 정답도, 개인정보 문제를 없애 주는 장치도 아니다. VPN이나 중계 서비스, 이동통신사 게이트웨이 때문에 실제 지역과 다를 수 있고, 조회 서버는 요청 과정에서 IP 주소를 보게 된다. 따라서 설계의 목표는 위치를 알아맞히는 것이 아니라 &lt;strong&gt;불완전한 신호로도 안전하게 실패하는 광고 계획을 만드는 것&lt;/strong&gt;이어야 한다.&lt;/p&gt;
&lt;h2&gt;광고 지역화에 정밀 위치는 필요하지 않다&lt;/h2&gt;
&lt;p&gt;앱에서 접할 수 있는 지역 신호는 서로 다른 질문에 답한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;신호&lt;/th&gt;
&lt;th&gt;실제로 알려 주는 것&lt;/th&gt;
&lt;th&gt;광고 라우팅에 쓸 때의 한계&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;앱 언어&lt;/td&gt;
&lt;td&gt;사용자가 읽고 싶은 언어&lt;/td&gt;
&lt;td&gt;거주지나 현재 위치가 아니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시스템 Locale의 국가&lt;/td&gt;
&lt;td&gt;사용자가 선택한 지역 설정&lt;/td&gt;
&lt;td&gt;여행 중이거나 설정을 바꾸지 않았을 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS 위치 서비스&lt;/td&gt;
&lt;td&gt;기기가 측정한 위치&lt;/td&gt;
&lt;td&gt;권한 요청과 민감한 데이터 처리가 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeoIP&lt;/td&gt;
&lt;td&gt;네트워크가 외부에 보이는 IP의 대략적 지역&lt;/td&gt;
&lt;td&gt;VPN·중계·통신망에 따라 틀리거나 비어 있을 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;언어가 한국어라는 이유로 국내 광고를 선택해서는 안 되고, Locale의 국가가 현재 위치라고 가정해서도 안 된다. 반대로 광고 공급자 선택에 국가나 넓은 지역 정도면 충분한데 정밀 위치 권한을 요구할 이유도 없다. Apple과 Android의 권한 지침처럼, 위치 기능이 실제로 필요할 때 사용자 맥락 안에서 필요한 수준만 요청하는 편이 맞다.&lt;/p&gt;
&lt;p&gt;광고 라우팅은 가장 낮은 정밀도의 신호부터 시작할 수 있다. Locale은 앱의 언어·정책 기본값을 고르는 데만 쓰고, 지리적 공급자 가용성을 확정하지 않는다. 실제 지역에 따른 분기가 필요할 때 GeoIP를 별도로 조회하되, 어느 단계에서도 확신할 수 없다면 &lt;code&gt;unknown&lt;/code&gt;을 반환한다. 추측으로 빈칸을 채우지 않는 것이 첫 번째 폴백이다.&lt;/p&gt;
&lt;h2&gt;동의가 끝난 뒤 한 번만 조회한다&lt;/h2&gt;
&lt;p&gt;지역 판정보다 먼저 확인할 것은 광고 요청 가능 상태다. Google UMP의 &lt;code&gt;canRequestAds&lt;/code&gt; 같은 게이트가 열리기 전에 GeoIP를 미리 조회하면, 아직 광고를 요청할 수 없는 세션에서도 불필요한 네트워크 호출이 발생한다. 동의 결과가 광고 요청 허용을 뜻할 뿐 지역이 정확하다는 뜻은 아니라는 점도 분리해야 한다.&lt;/p&gt;
&lt;p&gt;시작 순서는 다음 정도면 충분하다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;저장된 동의 정보를 갱신하고 필요한 동의 화면을 처리한다.&lt;/li&gt;
&lt;li&gt;광고 요청 가능 상태가 되면 지역 해석을 시작한다.&lt;/li&gt;
&lt;li&gt;한 프로세스에서 지역을 한 번만 해석하고 결과 버킷을 메모리에 보관한다.&lt;/li&gt;
&lt;li&gt;버킷으로 Provider Chain을 만들고 첫 번째 공급자를 시도한다.&lt;/li&gt;
&lt;li&gt;실패할 때마다 다음 공급자로 이동하고 인하우스 콘텐츠에서 끝낸다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;동의 갱신 직후와 화면 처리 완료 콜백 양쪽에서 광고 요청 가능 상태가 참이 될 수 있다. 두 경로가 같은 초기화를 시작하지 않도록, 이미 생성한 지역 조회 작업이나 광고 계획을 재사용해야 한다. 이 단일 실행 보장은 요청 횟수와 경쟁 상태를 함께 줄인다.&lt;/p&gt;
&lt;p&gt;동의가 확인되지 않았거나 외부 광고 요청을 허용하지 않는 상태라면 지역 조회를 생략한다. 이때 무엇을 보여 줄지는 제품과 적용 정책에 따라 별도로 정하되, 외부 광고를 우회 호출하는 폴백을 만들어서는 안 된다.&lt;/p&gt;
&lt;h2&gt;GeoIP는 정답이 아니라 라우팅 힌트다&lt;/h2&gt;
&lt;p&gt;CDN이 제공하는 뷰어 위치 헤더는 서버가 본 IP 주소를 바탕으로 만들어진다. 도시 같은 세부 값은 모든 IP에 존재하지 않으며, 비 ASCII 문자는 인코딩될 수도 있다. 앱이 원시 도시 문자열을 직접 해석하고 광고 정책까지 결정하게 만들면 플랫폼마다 파싱과 예외 처리가 반복된다.&lt;/p&gt;
&lt;p&gt;더 작은 경계는 서버에서 원시 값을 넓은 버킷으로 접는 것이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;regionalMarket&lt;/code&gt;: 지역 공급자를 먼저 시도할 수 있음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;otherMarket&lt;/code&gt;: 전역 공급자만 시도함&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unknown&lt;/code&gt;: 판정할 수 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;응답에는 좌표나 원시 IP, 도시 이름 대신 이 버킷만 담는다. 도시 단위 라우팅이 꼭 필요하더라도 앱에는 사업 규칙에 맞춘 식별자만 반환하는 편이 낫다. 헤더가 없거나 디코딩·검증에 실패하면 &lt;code&gt;unknown&lt;/code&gt;으로 끝낸다.&lt;/p&gt;
&lt;p&gt;CDN 응답이 지역에 따라 달라진다면 캐시 정책도 같은 경계를 알아야 한다. 필요한 헤더를 캐시 키에 포함하지 않으면 한 지역의 결과가 다른 지역에 재사용될 수 있고, 지나치게 세분화하면 캐시 효율과 데이터 노출 면적이 나빠진다. 작은 GeoIP 응답을 캐시하지 않거나, 서버에서 만든 거친 버킷만 캐시 기준으로 쓰는 식으로 정책을 명시해야 한다.&lt;/p&gt;
&lt;h2&gt;지역 판정과 Provider 선택을 분리한다&lt;/h2&gt;
&lt;p&gt;지역 해석기는 광고 SDK를 알 필요가 없고, 광고 로더는 IP나 Locale을 알 필요가 없다. 둘 사이에는 작은 열거형만 두면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enum ConsentState {
    case pending
    case adsAllowed
    case externalAdsBlocked
}

enum AdRegion {
    case regionalMarket
    case otherMarket
    case unknown
}

enum AdProvider {
    case regional
    case global
    case inHouse
}

func makeAdPlan(
    consent: ConsentState,
    resolveRegion: () async -&amp;gt; AdRegion
) async -&amp;gt; [AdProvider] {
    switch consent {
    case .pending, .externalAdsBlocked:
        return [.inHouse]
    case .adsAllowed:
        break
    }

    switch await resolveRegion() {
    case .regionalMarket:
        return [.regional, .global, .inHouse]
    case .otherMarket, .unknown:
        return [.global, .inHouse]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 함수는 광고를 불러오지 않고 순서만 만든다. 단일 실행과 캐시는 이 함수 바깥의 호출자나 지역 해석기가 이미 만든 작업을 재사용해 보장한다. 실제 로더는 배열을 앞에서부터 순회해 첫 성공에서 멈춘다. 실패한 공급자를 즉시 다시 시도하거나 체인을 처음부터 반복하지 않는다. 마지막의 &lt;code&gt;inHouse&lt;/code&gt;는 네트워크 광고가 모두 실패해도 화면이 빈 채로 끝나지 않게 하는 종료점이다.&lt;/p&gt;
&lt;p&gt;iOS, Android, Web은 같은 버킷과 우선순위를 공유할 수 있지만 구현까지 한 모듈로 묶을 필요는 없다. 각 플랫폼은 자신의 동의 SDK와 광고 SDK를 사용하고, &lt;code&gt;ConsentState → AdRegion → [AdProvider]&lt;/code&gt; 계약만 동일하게 유지하면 된다.&lt;/p&gt;
&lt;h2&gt;짧은 timeout과 프로세스 캐시로 실패를 흡수한다&lt;/h2&gt;
&lt;p&gt;광고 슬롯은 GeoIP 응답을 오래 기다릴 이유가 없다. 지역 해석기의 네트워크 경계에는 다음 원칙이면 충분하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;예상한 HTTPS endpoint만 호출하고 뜻밖의 redirect는 따르지 않는다.&lt;/li&gt;
&lt;li&gt;timeout은 수 초 이내로 짧게 두고 렌더링 경로에서 재시도하지 않는다.&lt;/li&gt;
&lt;li&gt;응답 크기와 허용 버킷을 제한하고 나머지는 &lt;code&gt;unknown&lt;/code&gt;으로 처리한다.&lt;/li&gt;
&lt;li&gt;한 프로세스에서 결과와 진행 중인 요청을 재사용한다.&lt;/li&gt;
&lt;li&gt;원시 IP·도시 응답을 디스크나 분석 로그에 남기지 않는다.&lt;/li&gt;
&lt;li&gt;HTTP 오류, timeout, 오프라인, 파싱 실패를 모두 정상적인 실패 결과로 접는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;프로세스 캐시는 네트워크가 바뀐 직후 잠시 오래된 버킷을 쓸 수 있다. 광고 공급자 우선순위를 정하는 거친 힌트라면 보통 감수할 수 있는 범위이고, 다음 실행에서 다시 평가하면 된다. 더 긴 캐시가 실제로 필요하다는 측정 결과가 생기기 전에는 영구 저장과 만료 정책을 추가하지 않는 편이 단순하다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;unknown&lt;/code&gt;에서도 전역 공급자를 시도할지는 그 공급자의 지원 범위와 동의 조건에 달려 있다. 핵심은 이 결정을 지역 해석기 내부에 숨기지 않고 Provider Chain 정책에 명시하는 것이다.&lt;/p&gt;
&lt;h2&gt;위치 권한이 없다고 개인정보 처리가 사라지지는 않는다&lt;/h2&gt;
&lt;p&gt;OS 위치 API를 호출하지 않으면 위치 권한 프롬프트는 피할 수 있다. 그러나 GeoIP 요청을 받은 서버나 CDN은 네트워크 주소를 처리한다. 따라서 “위치 권한 없음”을 “위치 관련 데이터 처리 없음”이나 “익명”으로 표현해서는 안 된다.&lt;/p&gt;
&lt;p&gt;수집 목적, 보관 여부, 제3자 전송, 동의와의 관계는 실제 데이터 흐름을 기준으로 검토해야 한다. 앱에는 거친 버킷만 전달하고, 서버 로그에서도 원시 위치 헤더와 IP를 불필요하게 보존하지 않으며, 광고 라우팅 결과를 장기 사용자 프로필로 재사용하지 않는 것이 안전한 기본값이다. 이 설계는 데이터 최소화에 도움을 줄 뿐, 법률이나 스토어 정책 준수를 자동으로 보장하지 않는다.&lt;/p&gt;
&lt;h2&gt;네트워크가 아니라 경계를 주입해 검증한다&lt;/h2&gt;
&lt;p&gt;단위 테스트에서 실제 GeoIP 서비스나 광고 SDK를 호출하면 VPN, 네트워크 상태, 광고 재고에 따라 결과가 흔들린다. 대신 지역 해석 함수와 Provider 로더를 주입해 다음 경계를 검증한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;th&gt;기대 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;동의 상태가 &lt;code&gt;pending&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;지역 조회 0회, 인하우스만 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;광고 요청 가능, 지역 버킷 반환&lt;/td&gt;
&lt;td&gt;지역 공급자를 먼저 시도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;지역 공급자 실패&lt;/td&gt;
&lt;td&gt;전역 공급자로 한 번 이동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeoIP timeout 또는 잘못된 응답&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unknown&lt;/code&gt; 경로로 전역 공급자 시도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모든 외부 공급자 실패&lt;/td&gt;
&lt;td&gt;인하우스에서 종료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동의 완료 콜백이 두 번 도착&lt;/td&gt;
&lt;td&gt;지역 조회와 광고 계획 생성은 1회&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;통합 환경에서는 오프라인, 느린 응답, 빈 헤더, 인코딩된 도시 값, VPN이나 Private Relay처럼 관측 IP가 달라지는 조건을 확인한다. 여기서 통과 기준은 실제 위치를 맞히는 것이 아니다. 어떤 입력에서도 허용된 체인만 선택하고, 제한 시간 안에 종료하며, 민감한 원시 값을 로그에 남기지 않는지가 기준이다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;위치 권한 없이 광고를 지역화하는 핵심은 GeoIP의 정확도를 믿는 데 있지 않다. 동의가 끝난 뒤 한 번만 대략적 지역 버킷을 구하고, 지역 판정과 광고 공급자 우선순위를 분리하며, 모든 실패를 더 일반적인 공급자와 인하우스 콘텐츠로 접는 데 있다.&lt;/p&gt;
&lt;p&gt;이 구조에서는 GeoIP가 틀려도 앱이 멈추지 않는다. 새로운 광고 공급자가 생겨도 위치 해석기를 고치지 않고 체인만 조정할 수 있다. 무엇보다 정밀 위치 권한을 광고 선택의 지름길로 쓰지 않으면서도, 네트워크 기반 지역 신호가 가진 개인정보 경계를 숨기지 않게 된다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services&quot;&gt;Apple Developer Documentation — Requesting authorization to use location services&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/privacy&quot;&gt;Apple Human Interface Guidelines — Privacy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/privacy-and-security/minimize-permission-requests&quot;&gt;Android Developers — Minimize your permission requests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/develop/sensors-and-location/location/permissions/runtime&quot;&gt;Android Developers — Request location access at runtime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html&quot;&gt;AWS — Add CloudFront request headers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/android/privacy&quot;&gt;Google for Developers — Set up UMP SDK for Android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/ios/privacy&quot;&gt;Google for Developers — Set up UMP SDK for iOS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>iOS</category><category>Android</category><category>Advertising</category><category>Privacy</category><category>GeoIP</category></item><item><title>일회성 광고 제거 IAP: StoreKit 2·Play Billing에서 구매와 권한 분리하기</title><link>https://jaemyeong.com/ko/blog/one-time-remove-ads-entitlement-storekit-play-billing/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/one-time-remove-ads-entitlement-storekit-play-billing/</guid><description>결제 완료 콜백을 영구 권한으로 착각하지 않고, 앱 시작·다른 기기·pending·환불 뒤에도 광고 제거 상태를 다시 맞추는 StoreKit 2와 Play Billing 계약을 정리합니다.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;광고 제거 상품은 화면상으로는 버튼 하나다. 결제가 성공하면 배너를 숨기고, 이미 산 사용자는 다시 결제하지 않게 하면 끝처럼 보인다. 실제 문제는 그 다음 실행부터 시작된다. 사용자가 앱을 재설치하거나 다른 기기에서 열 수 있고, 결제가 앱 밖에서 완료될 수 있으며, 환불이나 revoke로 권한이 사라질 수도 있다. 구매 직후 받은 콜백 하나로는 이 상태를 설명할 수 없다.&lt;/p&gt;
&lt;p&gt;DailySudoku는 iOS에서 StoreKit 2 non-consumable, Android에서 소비하지 않는 one-time product로 영구 광고 제거를 구현한다. 이 글에서 말하는 현재 구현은 2026년 8월 6일 &lt;code&gt;develop&lt;/code&gt;의 &lt;code&gt;9c999305&lt;/code&gt; 소스 기준이며 스토어 배포 상태와는 구분한다. 두 플랫폼의 API 모양은 다르지만 핵심 계약은 같다. &lt;strong&gt;구매 이벤트는 입력이고, 현재 entitlement 조회가 소유 상태를 다시 맞추는 기준이다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;구매 응답과 현재 권한은 같은 사실이 아니다&lt;/h2&gt;
&lt;p&gt;구매 버튼의 반환값만 저장하면 다음 상황을 놓친다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;결제창은 열렸지만 사용자가 취소했다.&lt;/li&gt;
&lt;li&gt;결제가 &lt;code&gt;pending&lt;/code&gt; 상태로 남았다가 앱을 닫은 뒤 완료됐다.&lt;/li&gt;
&lt;li&gt;같은 계정이 다른 기기에서 상품을 샀다.&lt;/li&gt;
&lt;li&gt;앱 데이터가 삭제되거나 새 기기에 설치됐다.&lt;/li&gt;
&lt;li&gt;구매가 환불·취소·revoke됐다.&lt;/li&gt;
&lt;li&gt;결제는 끝났지만 앱이 결과를 받기 전에 네트워크가 끊겼다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 입력 경로를 둘로 나눠야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;구매 이벤트 ───────┐
앱 시작·복귀 조회 ─┼─&amp;gt; 검증된 현재 권한 ─&amp;gt; 로컬 캐시 ─&amp;gt; 광고 게이트
스토어 외부 변경 ──┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;구매 이벤트는 사용자에게 즉시 결과를 보여 주는 빠른 경로다. 앱 시작과 foreground 복귀 때의 조회는 놓친 이벤트와 다른 기기의 변경을 회복하는 경로다. 스토어가 보내는 update는 앱이 살아 있는 동안 발생한 외부 변경을 줄이는 경로다. 셋이 마지막에 같은 &lt;code&gt;adFree&lt;/code&gt; 상태로 합쳐져야 한다.&lt;/p&gt;
&lt;p&gt;로컬 Boolean은 소유권의 원본이 아니다. 스토어 권한을 매 화면에서 직접 기다리지 않도록 만든 렌더링 캐시다. 캐시를 빠르게 읽되, 스토어 snapshot과 update가 true뿐 아니라 false도 기록해야 환불 뒤 광고가 다시 활성화된다.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;adFree&lt;/code&gt;와 &lt;code&gt;entitlementsSynced&lt;/code&gt;를 함께 둔다&lt;/h2&gt;
&lt;p&gt;앱을 막 시작한 순간의 &lt;code&gt;adFree == false&lt;/code&gt;에는 두 뜻이 섞여 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;실제로 상품을 보유하지 않았다.&lt;/li&gt;
&lt;li&gt;아직 스토어를 조회하지 못했다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 둘을 구분하지 않으면 기존 구매자에게 콜드 스타트 동안 광고가 잠깐 나타날 수 있다. DailySudoku는 별도의 &lt;code&gt;entitlementsSynced&lt;/code&gt;를 두고 광고 조건을 다음처럼 접는다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adsEnabled = entitlementsSynced &amp;amp;&amp;amp; !adFree &amp;amp;&amp;amp; consentAllowsAds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;앱 시작 시에는 &lt;code&gt;entitlementsSynced == false&lt;/code&gt;라 광고 요청 자체가 닫힌다. 복원이 한 번 끝난 뒤에만 비구매자가 광고 경로로 들어간다. 구매자라면 &lt;code&gt;adFree == true&lt;/code&gt;가 계속 게이트를 닫는다. 설정 화면, 홈, 난이도 선택, 게임, 통계가 각자 결제 상태를 해석하지 않고 이 결과만 소비한다.&lt;/p&gt;
&lt;p&gt;이 구조에서 중요한 것은 배너를 시각적으로 가리는 데서 멈추지 않는 것이다. Android는 게이트가 닫히면 Compose 광고 subtree를 만들지 않고 이미 붙은 native view도 해제한다. iOS는 배너를 접고 추가 load를 막지만, 현재 개발 소스에서는 이미 로드된 provider를 함께 파기하는지까지 자동 검증돼 있지 않다. “안 보인다”와 “광고 SDK 작업이 멈췄다”는 별도 확인 항목이다.&lt;/p&gt;
&lt;h2&gt;StoreKit 2는 verified transaction만 권한으로 바꾼다&lt;/h2&gt;
&lt;p&gt;iOS 구매 경로는 &lt;code&gt;Product.purchase()&lt;/code&gt; 결과를 세 갈래로 나눈다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.success(.verified(transaction))&lt;/code&gt;: 광고 제거를 부여하고 transaction을 finish한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.success(.unverified(...))&lt;/code&gt;: 권한을 부여하지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.pending&lt;/code&gt; 또는 &lt;code&gt;.userCancelled&lt;/code&gt;: 권한을 부여하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;결제창을 닫았다는 사실이 아니라 StoreKit 검증을 통과한 transaction이 기준이다. 앱이 실행 중일 때는 &lt;code&gt;Transaction.updates&lt;/code&gt;를 계속 관찰해 Ask to Buy, 다른 기기의 구매, revoke 같은 변화를 받는다. 앱 시작과 foreground 복귀 때는 &lt;code&gt;Transaction.currentEntitlements&lt;/code&gt;에서 광고 제거 product ID의 verified transaction을 다시 찾는다.&lt;/p&gt;
&lt;p&gt;Apple 문서상 &lt;code&gt;currentEntitlements&lt;/code&gt;는 non-consumable의 최신 entitlement를 제공하며 환불되거나 revoke된 상품은 포함하지 않는다. 따라서 현재 목록에서 상품을 찾았을 때 true만 저장하는 것으로는 부족하다. 조회를 끝냈는데 일치하는 항목이 없다면 false도 기록해야 닫혀 있던 광고 경로가 정상 상태로 돌아온다.&lt;/p&gt;
&lt;p&gt;복원 버튼은 일반적인 시작 경로와 다르게 취급한다. StoreKit은 재설치나 새 기기에서도 최신 transaction 정보를 자동으로 제공하므로 평소에는 &lt;code&gt;currentEntitlements&lt;/code&gt;를 읽으면 된다. &lt;code&gt;AppStore.sync()&lt;/code&gt;는 인증 prompt를 띄울 수 있어 앱 시작 때 자동 호출하면 안 된다. DailySudoku도 자동 복원에서는 snapshot만 읽고, 사용자가 설정에서 명시적으로 복원을 눌렀을 때만 &lt;code&gt;AppStore.sync()&lt;/code&gt; 후 다시 조회한다.&lt;/p&gt;
&lt;p&gt;가격 조회도 entitlement 복원과 분리한다. 현지화 가격이 늦게 도착했다는 이유로 광고 권한 확인과 동의 흐름 전체가 기다릴 필요는 없다. 현재 개발 구현은 가격을 별도로 새로 고치고, 소유권 조회는 그 결과를 기다리지 않는다.&lt;/p&gt;
&lt;h2&gt;Play Billing은 &lt;code&gt;PURCHASED&lt;/code&gt;·검증·acknowledge를 구분한다&lt;/h2&gt;
&lt;p&gt;Android의 광고 제거 상품은 &lt;code&gt;INAPP&lt;/code&gt; one-time product다. 별도의 “non-consumable API”를 쓰는 것이 아니라 구매를 consume하지 않아서 한 번 산 상품으로 유지하고, 처리 완료는 acknowledge한다.&lt;/p&gt;
&lt;p&gt;구매 흐름은 다음 순서다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;queryProductDetailsAsync&lt;/code&gt;로 현재 사용자에게 판매 가능한 상품과 현지화 가격을 얻는다.&lt;/li&gt;
&lt;li&gt;같은 &lt;code&gt;ProductDetails&lt;/code&gt;와 offer token으로 &lt;code&gt;launchBillingFlow&lt;/code&gt;를 연다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PurchasesUpdatedListener&lt;/code&gt; 또는 이후 &lt;code&gt;queryPurchasesAsync&lt;/code&gt;에서 구매를 찾는다.&lt;/li&gt;
&lt;li&gt;product ID와 상태를 확인하고, &lt;code&gt;PURCHASED&lt;/code&gt;일 때만 권한을 부여한다.&lt;/li&gt;
&lt;li&gt;비소모성 상품을 acknowledge한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;PENDING&lt;/code&gt;은 결제 실패가 아니다. 현금 결제나 추가 승인처럼 아직 결제가 끝나지 않은 상태다. Google은 &lt;code&gt;PENDING&lt;/code&gt;일 때 benefit을 주지 말고 &lt;code&gt;PURCHASED&lt;/code&gt;로 바뀐 뒤 처리하라고 안내한다. 앱이 꺼져 있을 때 전환될 수 있으므로 실행 중 listener만 믿지 말고 &lt;code&gt;onResume()&lt;/code&gt;에서도 &lt;code&gt;queryPurchasesAsync()&lt;/code&gt;로 회복해야 한다.&lt;/p&gt;
&lt;p&gt;acknowledgement는 구매 검증과도 다르다. 검증은 이 구매가 정당하고 아직 처리되지 않았는지 확인하는 단계이고, acknowledge는 entitlement를 전달했다는 사실을 Google Play에 알리는 단계다. 일반 구매는 &lt;code&gt;PURCHASED&lt;/code&gt;가 된 뒤 3일 안에 acknowledge하지 않으면 자동 환불될 수 있다. 라이선스 테스터 구매에서는 이 시간이 3분으로 줄어 테스트 실패가 빨리 드러난다.&lt;/p&gt;
&lt;p&gt;여기에는 현재 구현의 명확한 경계가 있다. DailySudoku Android 코드는 BillingClient가 돌려준 &lt;code&gt;PURCHASED&lt;/code&gt; 상태와 product ID를 확인하고 client에서 acknowledge하지만, purchase token을 서버로 보내 Google Play Developer API로 검증하지 않는다. Google의 현재 보안 지침은 benefit 부여 전 secure backend 검증과 서버 acknowledgement를 권장한다. 따라서 지금 구조는 변조 저항이 필요한 권한 시스템의 완성형이 아니라, client trust를 받아들인 광고 제거 구현이다. 이를 서버 검증이 끝난 것처럼 표현해서는 안 된다.&lt;/p&gt;
&lt;h2&gt;복원은 true만 찾는 함수가 아니라 reconcile이다&lt;/h2&gt;
&lt;p&gt;두 플랫폼의 복원 함수는 “예전에 샀는가?”에만 답하면 안 된다. 현재 snapshot을 로컬 캐시에 맞추는 reconcile이어야 한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;구매 직후&lt;/td&gt;
&lt;td&gt;verified transaction이면 true&lt;/td&gt;
&lt;td&gt;검증된 &lt;code&gt;PURCHASED&lt;/code&gt;라면 true가 이상적이나, 현재 코드는 client 상태만 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결제 대기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.pending&lt;/code&gt;, 권한 미부여&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PENDING&lt;/code&gt;, 권한 미부여&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;앱 시작·복귀&lt;/td&gt;
&lt;td&gt;&lt;code&gt;currentEntitlements&lt;/code&gt; 재조회&lt;/td&gt;
&lt;td&gt;&lt;code&gt;queryPurchasesAsync(INAPP)&lt;/code&gt; 재조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 중 외부 변경&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Transaction.updates&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;listener, 놓치면 다음 resume 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;명시적 복원&lt;/td&gt;
&lt;td&gt;사용자 동작에서 &lt;code&gt;AppStore.sync()&lt;/code&gt; 후 재조회&lt;/td&gt;
&lt;td&gt;같은 inventory 재조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;환불·revoke&lt;/td&gt;
&lt;td&gt;update 또는 다음 snapshot에서 false&lt;/td&gt;
&lt;td&gt;다음 성공한 inventory 조회에서 false&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;실패할 때 false를 쓰는 규칙은 더 조심해야 한다. “상품이 없음”과 “스토어에 연결하지 못함”은 같은 결과가 아니다.&lt;/p&gt;
&lt;p&gt;현재 Android 구현은 연결 또는 inventory 조회가 실패하면 기존 &lt;code&gt;adFree&lt;/code&gt; 캐시를 유지하고 &lt;code&gt;entitlementsSynced&lt;/code&gt;만 true로 바꾼다. 일시 장애 때문에 구매자의 권한을 지우지 않는 선택이다. 대신 재설치 직후처럼 캐시가 false인 구매자는 Play가 복구될 때까지 광고를 볼 수 있고, 환불된 사용자는 다음 성공한 조회까지 광고가 계속 숨겨질 수 있다.&lt;/p&gt;
&lt;p&gt;현재 iOS 구현은 verified matching entitlement가 없으면 false를 기록한다. 성공한 빈 snapshot에서는 올바른 동작이지만, unverified 결과나 일시적인 false negative를 별도 상태로 구분하지 않는다. 어느 쪽도 “항상 안전하다”고 말할 수 없다. &lt;code&gt;unknown&lt;/code&gt;, &lt;code&gt;owned&lt;/code&gt;, &lt;code&gt;notOwned&lt;/code&gt;처럼 조회 결과를 명시하거나, 최소한 오류와 성공한 빈 결과를 구분해 제품 정책에 맞는 fail-open/fail-closed 결정을 해야 한다.&lt;/p&gt;
&lt;p&gt;동시에 시작된 복원과 foreground 조회의 완료 순서도 테스트 대상이다. connection만 잠그고 전체 reconcile을 직렬화하지 않으면 오래된 snapshot이 나중에 도착해 새 상태를 덮을 수 있다. 단순 Boolean 캐시는 작지만, 그 값을 쓰는 비동기 경로는 구매·update·시작·resume·수동 복원으로 여러 개다.&lt;/p&gt;
&lt;h2&gt;가격이 없다고 복원 경로까지 숨기지 않는다&lt;/h2&gt;
&lt;p&gt;스토어의 현지화 가격을 사용하고 하드코딩 가격을 표시하지 않는 것은 맞다. 그러나 가격 조회 실패를 “이 사용자에게 IAP 기능이 없다”로 해석하면 문제가 생긴다.&lt;/p&gt;
&lt;p&gt;현재 두 앱의 설정 화면은 가격이 &lt;code&gt;nil&lt;/code&gt;이면 광고 제거 행을 숨기고, 복원 동작은 그 행이 여는 화면 안에 있다. 자동 시작·resume 복원은 계속 작동하지만 사용자가 직접 복원을 다시 시도할 진입점은 사라진다. 제품 미등록처럼 지속적인 구성 오류와 네트워크 같은 일시 오류도 UI에서는 똑같이 보인다.&lt;/p&gt;
&lt;p&gt;가격 표시와 복원은 서로 다른 자격 조건이다. 판매할 가격을 얻지 못했으면 구매 버튼을 잠시 비활성화할 수 있지만, 이미 구매한 사용자의 복원 동작은 독립적으로 남기는 편이 회복 가능하다. 재시도 상태와 오류 관측도 제품 조회의 빈 결과와 예외를 구분해야 한다.&lt;/p&gt;
&lt;h2&gt;테스트는 성공 결제 한 번으로 끝나지 않는다&lt;/h2&gt;
&lt;p&gt;iOS는 Xcode의 StoreKit configuration으로 transaction, interrupted purchase, refund·revoke 같은 로컬 시나리오를 빠르게 만들고, Sandbox나 TestFlight에서 실제 App Store 계정 흐름을 확인할 수 있다. Android는 license tester가 실제 구매 흐름과 테스트 결제 수단을 사용할 수 있다. 현재 Google 문서상 package name과 tester 계정 조건을 만족하면 debug 서명 앱을 sideload해 개발 테스트도 할 수 있으며, 테스트 트랙 설치본은 배포 전 검증에 사용한다. “반드시 직접 ADB 설치라서 실패한다”거나 “항상 Play 설치본이어야 한다”처럼 단정하면 현재 문서와 맞지 않는다.&lt;/p&gt;
&lt;p&gt;최소 검증 행렬은 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시나리오&lt;/th&gt;
&lt;th&gt;확인할 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;신규 구매 성공&lt;/td&gt;
&lt;td&gt;광고 요청 중단, 재실행 뒤에도 유지, 중복 구매 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용자 취소&lt;/td&gt;
&lt;td&gt;권한·성공 안내 없음, 다시 구매 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pending 후 승인&lt;/td&gt;
&lt;td&gt;pending 동안 광고 제거 없음, &lt;code&gt;PURCHASED&lt;/code&gt; 뒤 한 번만 부여&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pending 후 취소&lt;/td&gt;
&lt;td&gt;권한 부여 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이미 보유&lt;/td&gt;
&lt;td&gt;새 기기·재설치·수동 복원에서 복구&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;앱 밖 구매 완료&lt;/td&gt;
&lt;td&gt;update 또는 다음 resume에서 복구&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;네트워크 단절&lt;/td&gt;
&lt;td&gt;성공한 빈 목록과 오류를 혼동하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acknowledge 실패&lt;/td&gt;
&lt;td&gt;재시도되고 제한 시간 안에 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;환불·revoke&lt;/td&gt;
&lt;td&gt;로컬 캐시가 false로 돌아가고 광고 gate가 다시 열림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빠른 background/foreground&lt;/td&gt;
&lt;td&gt;오래된 조회가 새 상태를 덮지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;현재 자동 검증도 이 행렬 전체를 덮지는 않는다. iOS 테스트는 미소유 복원이 false를 저장하는 경로와 가격 새로고침을 확인하지만 hosted unit test에서 실제 구매 성공 UI 경로는 검증하지 못한다. Android에는 &lt;code&gt;PlayBillingProvider&lt;/code&gt; 자체의 구매·복원 테스트가 없고, pending 안내, acknowledgement 실패, Settings 행, 광고 게이트의 전체 truth table도 비어 있다. 따라서 로컬 테스트 통과만으로 스토어 결제를 검증했다고 결론 내릴 수 없다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;일회성 광고 제거는 결제 버튼보다 entitlement 수명 주기를 구현하는 일에 가깝다. 구매 콜백은 즉시 반응을 위한 한 입력일 뿐이고, 앱 시작·복귀 snapshot과 실행 중 update가 같은 로컬 캐시로 수렴해야 한다. &lt;code&gt;entitlementsSynced&lt;/code&gt;는 “아직 모름”과 “미보유”를 구분해 기존 구매자에게 광고가 번쩍이는 일을 막는다.&lt;/p&gt;
&lt;p&gt;StoreKit 2에서는 verified transaction, &lt;code&gt;currentEntitlements&lt;/code&gt;, &lt;code&gt;Transaction.updates&lt;/code&gt;, 사용자 동작에 한정한 &lt;code&gt;AppStore.sync()&lt;/code&gt;가 이 계약을 만든다. Play Billing에서는 &lt;code&gt;PURCHASED&lt;/code&gt; 확인, &lt;code&gt;queryPurchasesAsync&lt;/code&gt;, 검증, acknowledgement가 각각 다른 책임을 가진다. 환불과 오류에서 false를 언제 기록할지, 가격 조회가 막혔을 때 복원 진입점을 남길지, 겹친 reconcile의 순서를 어떻게 보장할지는 별도의 제품 결정이다.&lt;/p&gt;
&lt;p&gt;DailySudoku의 현재 개발 구현은 구매와 광고 렌더링을 분리하고 시작·resume 복원을 수행하지만, Android 서버 검증, pending UX, 오류 상태 모델, 동시 reconcile, 복원 진입점과 실기기 스토어 행렬에는 검증 공백이 남아 있다. 결제가 한 번 성공했다는 사실보다 이 공백을 명시하고 재현 가능한 시나리오로 닫는 일이 영구 권한에 더 중요하다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/product/purchase(options:)&quot;&gt;Apple Developer Documentation — purchase(options:)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/transaction/currententitlements&quot;&gt;Apple Developer Documentation — currentEntitlements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/transaction/updates&quot;&gt;Apple Developer Documentation — updates&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/appstore/sync()&quot;&gt;Apple Developer Documentation — sync()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/finishing-a-transaction&quot;&gt;Apple Developer Documentation — Finishing a transaction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/testing-in-app-purchases-in-xcode&quot;&gt;Apple Developer Documentation — Testing In-App Purchases in Xcode&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/google/play/billing/one-time-products&quot;&gt;Android Developers — One-time products&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/google/play/billing/integrate&quot;&gt;Android Developers — Integrate the Google Play Billing Library&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/google/play/billing/security&quot;&gt;Android Developers — Fight fraud and abuse&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/google/play/billing/test&quot;&gt;Android Developers — Test your Google Play Billing Library integration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>iOS</category><category>Android</category><category>StoreKit</category><category>Google Play Billing</category><category>In-App Purchase</category></item><item><title>검증 스크립트가 성공을 거짓말할 때: Negative Control과 Mutation으로 자기검증하기</title><link>https://jaemyeong.com/ko/blog/verification-script-negative-control/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/verification-script-negative-control/</guid><description>검증 스크립트가 실제 결함을 탐지하는지 Negative Control과 입력 Mutation으로 확인하고, baseline과 실패 계약을 CI에 고정하는 방법을 정리합니다.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;릴리스 직전에 실행한 검증 스크립트가 모두 성공했다. 그런데 산출물을 직접 열어 보니 필수 파일이 빠져 있거나 서로 달라야 할 콘텐츠가 같았다. 스크립트는 실행됐지만 정작 보호해야 할 규칙은 검사하지 않은 상태였다.&lt;/p&gt;
&lt;p&gt;크로스플랫폼 앱의 리소스와 메타데이터를 자동 검사할 때 이런 문제가 생긴다. 파일 탐색이 빗나가 입력이 비어도, 예외가 없으면 exit code 0을 반환할 수 있다.&lt;/p&gt;
&lt;p&gt;exit code는 프로세스의 종료 상태다. 검증기가 필요한 결함을 감지할 능력이 있다는 증명은 아니다. 결함을 넣었을 때 예상한 이유로 실패하는지까지 확인해야 검증 결과를 신뢰할 수 있다.&lt;/p&gt;
&lt;p&gt;이 글은 검증기 자체를 검증하는 방법만 다룬다. 플랫폼별 빌드나 테스트 매트릭스는 다루지 않는다. 깨끗한 baseline을 만든 뒤 의도적 결함 입력을 하나씩 넣고, 새로 발생한 실패의 종류를 확인하는 데 집중한다.&lt;/p&gt;
&lt;h2&gt;검증 스크립트의 성공은 검증 능력을 증명하지 않는다&lt;/h2&gt;
&lt;p&gt;검증 스크립트가 exit code 0으로 끝났다는 사실은 정의된 성공 경로에 도달했다는 뜻뿐이다. 그 경로가 릴리스 계약을 충분히 검사했다는 뜻은 아니다.&lt;/p&gt;
&lt;p&gt;다음과 같은 스크립트는 잘못된 입력도 조용히 통과시킬 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;glob 패턴이 실제 파일과 맞지 않아 검사 대상이 0개다.&lt;/li&gt;
&lt;li&gt;필수 도구나 환경 변수가 없을 때 검사를 건너뛰고 성공한다.&lt;/li&gt;
&lt;li&gt;생성기와 검증기가 같은 잘못된 기본값을 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;빈 입력과 skip-success도 구분해야 한다. 필수 산출물을 0개 찾았다면 입력 계약 위반이다. 선택 검사는 skip할 수 있지만, CI 필수 환경에서 도구나 입력이 없으면 설정 오류로 실패해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pytest&lt;/code&gt;도 전체 통과와 테스트 0개 수집을 다른 exit code로 구분한다. 자체 검증기도 &lt;code&gt;pass&lt;/code&gt;, &lt;code&gt;validation-failure&lt;/code&gt;, &lt;code&gt;invalid-setup&lt;/code&gt;, &lt;code&gt;skip&lt;/code&gt;을 한 종류로 뭉개지 않아야 한다.&lt;/p&gt;
&lt;h2&gt;무엇을 실패시킬지 계약으로 적는다&lt;/h2&gt;
&lt;p&gt;검증기를 테스트하기 전에 “어떻게 실패해야 하는가”를 정한다. 0이 아닌 exit code만 기대하면 문법 오류나 파일 권한 문제로 중단돼도 조건을 만족한다.&lt;/p&gt;
&lt;p&gt;실패 계약에는 최소한 다음 항목이 필요하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;어떤 규칙을 검사했는지 식별하는 안정적인 &lt;code&gt;category&lt;/code&gt; 또는 &lt;code&gt;rule_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;실제로 검사한 입력 개수와 필수 검사 개수&lt;/li&gt;
&lt;li&gt;검증 실패와 실행 환경 실패를 구분하는 exit code&lt;/li&gt;
&lt;li&gt;0개 검사와 허용된 skip을 구분하는 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;필수 이미지의 크기가 틀렸다면 &lt;code&gt;wrong_size&lt;/code&gt;가 보고돼야 한다. 파일을 읽지 못해 &lt;code&gt;internal_error&lt;/code&gt;로 끝났다면 결함을 제대로 잡은 것이 아니다.&lt;/p&gt;
&lt;p&gt;CI는 바뀌기 쉬운 메시지 전체가 아니라 안정적인 실패 분류를 비교한다. 메시지는 디버깅 정보로 남긴다.&lt;/p&gt;
&lt;h2&gt;Negative Control로 실패 경로를 고정한다&lt;/h2&gt;
&lt;p&gt;이 글에서 &lt;code&gt;Negative Control&lt;/code&gt;은 &lt;strong&gt;검증기가 반드시 거부해야 하는 의도적 결함 입력&lt;/strong&gt;이라는 작업 정의로만 사용한다. 실험·통계 용어를 확장해 적용하려는 것이 아니다.&lt;/p&gt;
&lt;p&gt;가장 작은 Negative Control은 정상 입력의 복사본 하나를 바꾸는 방식이다. 허용되지 않은 &lt;code&gt;dark&lt;/code&gt; 같은 값을 넣고 &lt;code&gt;invalid_value&lt;/code&gt;가 발생하는지 보면 입력 읽기부터 실패 전달까지 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;운영 산출물을 훼손하지 않는다. 검토한 fixture를 임시 디렉터리에 복사해 변경하고 실행 후 버린다. 정상 입력이 실패하거나 결함 입력이 성공하면 결과를 신뢰할 수 없다.&lt;/p&gt;
&lt;h2&gt;Mutation은 한 번에 하나만 적용한다&lt;/h2&gt;
&lt;p&gt;여기서 &lt;code&gt;Mutation&lt;/code&gt;은 정상 fixture에 작은 결함을 의도적으로 넣는 행위다. 한 번의 실행에는 하나의 규칙만 바꾼다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;허용 목록에 없는 &lt;code&gt;dark&lt;/code&gt; 같은 잘못된 값으로 교체한다.&lt;/li&gt;
&lt;li&gt;필수 파일 하나를 삭제한다.&lt;/li&gt;
&lt;li&gt;서로 달라야 할 locale 콘텐츠 두 개를 중복시킨다.&lt;/li&gt;
&lt;li&gt;이미지 메타데이터를 잘못된 크기로 바꾼다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;여러 결함을 함께 넣으면 어떤 규칙이 작동했는지 알기 어렵다. 첫 오류에서 중단되면 뒤의 규칙은 실행되지 않는다. mutation 하나, 예상 실패 분류 하나를 기본 단위로 둔다.&lt;/p&gt;
&lt;p&gt;Stryker와 PIT는 코드를 바꾸고 테스트가 변화를 감지하는지 확인한다. 다만 원본과 동작이 같은 equivalent mutant는 테스트로도 구분할 수 없다. 수동 입력 mutation도 실제 계약을 바꾸는 결함만 선택해야 한다.&lt;/p&gt;
&lt;h2&gt;깨끗한 baseline 뒤에 새 실패만 본다&lt;/h2&gt;
&lt;p&gt;fixture에 기존 실패가 있으면 검증기가 mutation을 놓쳐도 비정상 종료한다. exit code만 본 테스트는 이를 성공적인 탐지로 오해한다.&lt;/p&gt;
&lt;p&gt;그래서 순서는 고정한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;baseline의 exit code가 0이고 실패도 0개인지 확인한다.&lt;/li&gt;
&lt;li&gt;fixture 복사본에 mutation 하나를 적용한다.&lt;/li&gt;
&lt;li&gt;다시 검증해 baseline에 없던 &lt;code&gt;introduced failure&lt;/code&gt;를 계산한다.&lt;/li&gt;
&lt;li&gt;새 실패에 예상한 category가 있는지 확인한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;다음 Python 코드는 이 흐름의 최소 형태다. 검증기는 JSON으로 실패 category와 실제 검사 수를 반환하고, &lt;code&gt;0&lt;/code&gt;은 통과, &lt;code&gt;1&lt;/code&gt;은 계약 위반, 그 밖의 값은 설정·내부 오류로 정의했다고 가정한다. 예제 fixture의 계약은 입력 4개이며, 매 case마다 새 임시 디렉터리에 복사한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json
import shutil
import subprocess
import tempfile
from pathlib import Path

FIXTURE = Path(&quot;validator-fixture&quot;)
PASS = 0
VALIDATION_FAILED = 1
REQUIRED_CHECKS = 4


def validate(root: Path) -&amp;gt; tuple[int, set[str], int]:
    result = subprocess.run(
        [&quot;python&quot;, &quot;validate.py&quot;, str(root), &quot;--json&quot;],
        check=False,
        capture_output=True,
        text=True,
    )
    report = json.loads(result.stdout)
    failures = {item[&quot;category&quot;] for item in report[&quot;failures&quot;]}
    return result.returncode, failures, report[&quot;checked&quot;]


baseline_code, baseline_failures, baseline_checked = validate(FIXTURE)
if baseline_code != PASS or baseline_failures or baseline_checked != REQUIRED_CHECKS:
    raise SystemExit(
        f&quot;invalid baseline: checked={baseline_checked}, &quot;
        f&quot;failures={sorted(baseline_failures)}&quot;
    )

cases = [
    (&quot;invalid-value&quot;, &quot;invalid_value&quot;),
    (&quot;missing-file&quot;, &quot;missing_file&quot;),
    (&quot;duplicate-content&quot;, &quot;duplicate_content&quot;),
    (&quot;wrong-size&quot;, &quot;wrong_size&quot;),
]

for mutation, expected_category in cases:
    with tempfile.TemporaryDirectory() as temp:
        root = Path(temp) / &quot;fixture&quot;
        shutil.copytree(FIXTURE, root)
        subprocess.run([&quot;python&quot;, &quot;mutate.py&quot;, mutation, str(root)], check=True)

        code, failures, checked = validate(root)
        introduced_failures = failures - baseline_failures

        if code != VALIDATION_FAILED or expected_category not in introduced_failures:
            raise SystemExit(
                f&quot;survived mutation: {mutation}, checked={checked}, &quot;
                f&quot;failures={sorted(failures)}&quot;
            )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 코드는 baseline 통과를 고정한 뒤 mutation이 예상한 실패를 새로 만드는지 확인한다. 기존 오류에 기대어 우연히 통과하는 것을 막는다.&lt;/p&gt;
&lt;p&gt;CI 판정에는 &lt;code&gt;assert&lt;/code&gt;를 사용하지 않았다. Python의 &lt;code&gt;-O&lt;/code&gt; 옵션은 &lt;code&gt;assert&lt;/code&gt; 문을 제거하므로, 검증 실패는 실행 옵션과 무관하게 남는 명시적 분기로 처리해야 한다.&lt;/p&gt;
&lt;p&gt;이 예시는 category만 비교한다. 실패 대상도 구분해야 한다면 &lt;code&gt;rule_id&lt;/code&gt;와 정규화된 상대 경로를 함께 비교한다. 전체 메시지나 출력 순서까지 고정할 필요는 없다.&lt;/p&gt;
&lt;h2&gt;생성기와 검증기가 같은 정답을 공유하면 함께 틀린다&lt;/h2&gt;
&lt;p&gt;생성기와 검증기가 같은 판단 함수를 쓰면 함께 틀릴 수 있다. 결과는 일관돼도 계약에는 맞지 않는다. 생성된 최신 산출물만 fixture로 써도 같은 문제가 생긴다.&lt;/p&gt;
&lt;p&gt;중립적인 schema는 공유할 수 있다. 그러나 허용 값, 크기, 콘텐츠 고유성 판단은 생성 결과를 정답으로 삼지 않는다. 검증기는 명시된 계약과 검토된 fixture를 기준으로 정상·결함 입력을 분류한다.&lt;/p&gt;
&lt;h2&gt;CI는 실패해야 성공인 검사를 실행한다&lt;/h2&gt;
&lt;p&gt;핵심 규칙마다 대표 Negative Control 하나를 두고, 검증기나 입력 탐색 로직이 바뀔 때 실행하면 된다.&lt;/p&gt;
&lt;p&gt;CI 단계는 다음처럼 단순하게 유지한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;필수 도구와 fixture를 확인한다.&lt;/li&gt;
&lt;li&gt;baseline이 exit code 0, 실패 0개, 필수 검사 수 이상인지 확인한다.&lt;/li&gt;
&lt;li&gt;mutation을 임시 복사본에 하나씩 적용한다.&lt;/li&gt;
&lt;li&gt;각 실행이 예상 category로 실패하는지 확인한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;필수 환경에서 입력이나 도구가 없으면 실패해야 한다. 선택 검사만 명시적인 skip으로 허용하고 사유를 남긴다. 테스트 0개, 검사 대상 0개, mutation 0개도 필수 CI에서는 실패 조건이다.&lt;/p&gt;
&lt;p&gt;Mutation이 살아남으면 입력 탐색, 오류 반환, 기대 category를 확인한다. baseline과 예상·실제 category는 CI artifact에 남긴다.&lt;/p&gt;
&lt;h2&gt;체크리스트&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;정상 fixture의 baseline 실패가 0개인가.&lt;/li&gt;
&lt;li&gt;필수 입력을 0개 찾으면 검증 실패로 처리하는가.&lt;/li&gt;
&lt;li&gt;허용된 skip과 환경 누락을 다른 상태로 표현하는가.&lt;/li&gt;
&lt;li&gt;Negative Control이 실제 계약을 위반하는 입력인가.&lt;/li&gt;
&lt;li&gt;한 실행에서 mutation을 하나만 적용하는가.&lt;/li&gt;
&lt;li&gt;비정상 exit code뿐 아니라 예상 failure category를 확인하는가.&lt;/li&gt;
&lt;li&gt;baseline에 없던 introduced failure를 비교하는가.&lt;/li&gt;
&lt;li&gt;생성기 구현과 검증 판단이 같은 함수에 의존하지 않는가.&lt;/li&gt;
&lt;li&gt;필수 CI에서 검사 0개나 mutation 0개를 성공으로 바꾸지 않는가.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;검증기의 초록색 결과는 출발점일 뿐이다. 신뢰하려면 정상 입력에서 실패가 0개이고, 의도적 결함 입력에서는 예상한 규칙이 새로 실패해야 한다.&lt;/p&gt;
&lt;p&gt;깨끗한 fixture와 몇 개의 Negative Control이면 시작할 수 있다. baseline, 단일 mutation, introduced failure를 차례로 확인한다. 검증 스크립트도 실패할 능력까지 테스트해야 한다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://stryker-mutator.io/docs/&quot;&gt;Stryker Mutator - What is mutation testing?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/&quot;&gt;Stryker Mutator - Mutant states and metrics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pitest.org/quickstart/basic_concepts/&quot;&gt;PIT Mutation Testing - Basic Concepts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.pytest.org/en/9.0.x/reference/exit-codes.html&quot;&gt;pytest documentation - Exit codes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/3/using/cmdline.html#cmdoption-O&quot;&gt;Python documentation - &lt;code&gt;-O&lt;/code&gt; optimization option&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://testing.googleblog.com/2007/04/tott-refactoring-tests-in-red.html&quot;&gt;Google Testing Blog - Refactoring Tests in the Red&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Testing</category><category>CI-CD</category><category>Automation</category><category>Troubleshooting</category></item><item><title>재시도 Cron은 자기 죽음을 알릴 수 없다: Watch the Watcher 설계</title><link>https://jaemyeong.com/ko/blog/watch-the-watcher-retry-cron-reconciliation/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/watch-the-watcher-retry-cron-reconciliation/</guid><description>기록된 job ID와 live scheduler 상태를 대조하고, 작업의 존재·실행·성공·업무 진척을 분리해 사라진 재시도 작업을 찾는 최소 운영 계약을 정리합니다.</description><pubDate>Wed, 05 Aug 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;실패한 재시도 작업은 로그를 남길 수 있다. 예외 횟수와 마지막 실패 시각도 기록할 수 있다. 그러나 재시도 작업 자체가 scheduler에서 사라지면 아무것도 실행되지 않는다. 실행되지 않은 작업은 자신의 죽음을 보고할 수도 없다.&lt;/p&gt;
&lt;p&gt;DailySudoku 자동화 루프를 점검하다가 이 차이를 마주쳤다. 운영 원장에는 재시도 job이 등록됐다고 적혀 있었지만, live scheduler 목록에는 다른 gate poller만 남아 있었다. 살아남은 poller는 자기 책임을 계속 수행했기 때문에 전체 루프가 멈추지 않은 것처럼 보였다. 정확히 언제 왜 job이 사라졌는지는 당시 증거만으로 확정할 수 없었다.&lt;/p&gt;
&lt;p&gt;이것은 현재 장애 보고가 아니라 과거 스냅샷에서 얻은 설계 사례다. 이 글에서는 특정 scheduler나 내부 job ID 대신, scheduled job이 사라지는 공통 실패 모드와 이를 찾는 최소한의 Watch the Watcher 계약을 정리한다. 제품 동작은 2026년 8월 6일 공식 문서 기준이다.&lt;/p&gt;
&lt;h2&gt;실패한 작업과 사라진 작업은 다르다&lt;/h2&gt;
&lt;p&gt;작업 실패와 작업 부재는 서로 다른 상태다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상태&lt;/th&gt;
&lt;th&gt;남는 신호&lt;/th&gt;
&lt;th&gt;작업 내부의 재시도로 복구 가능한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 후 실패&lt;/td&gt;
&lt;td&gt;시작 기록, 오류, 실패 횟수&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 중 정지&lt;/td&gt;
&lt;td&gt;시작 기록, 오래된 heartbeat&lt;/td&gt;
&lt;td&gt;경우에 따라 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비활성화 또는 suspend&lt;/td&gt;
&lt;td&gt;scheduler 객체&lt;/td&gt;
&lt;td&gt;불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scheduler 객체 삭제&lt;/td&gt;
&lt;td&gt;내부 신호 없음&lt;/td&gt;
&lt;td&gt;불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scheduler 전체 중단&lt;/td&gt;
&lt;td&gt;내부 신호 없음&lt;/td&gt;
&lt;td&gt;불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;성공률과 오류율은 작업이 실행됐다는 전제에서만 의미가 있다. “오류가 0건”은 건강하다는 증거가 아니라 실행이 0건이라는 결과일 수도 있다. 그래서 재시도 성공률보다 먼저 작업의 존재와 최근 실행을 확인해야 한다.&lt;/p&gt;
&lt;p&gt;이 글에서 &lt;code&gt;Cron&lt;/code&gt;은 Unix &lt;code&gt;crontab&lt;/code&gt;에만 한정하지 않는다. GitHub Actions의 &lt;code&gt;schedule&lt;/code&gt;, Kubernetes CronJob처럼 정해진 시각에 일을 시작하는 제어 평면을 통칭한다.&lt;/p&gt;
&lt;h2&gt;원장에 기록된 job ID는 live 상태가 아니다&lt;/h2&gt;
&lt;p&gt;job ID를 파일이나 데이터베이스에 저장해 두면 나중에 작업을 찾는 데 쓸 수 있다. 하지만 그 값은 “등록하려고 했던 작업”의 포인터이지, scheduler가 지금도 그 작업을 보유한다는 증명이 아니다.&lt;/p&gt;
&lt;p&gt;두 상태를 분리해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;desired state: 어떤 역할의 작업이 어떤 주기와 명령으로 존재해야 하는가&lt;/li&gt;
&lt;li&gt;actual state: scheduler가 현재 반환한 작업 목록과 활성 상태는 무엇인가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;점검은 저장된 ID 하나를 조회하는 데서 끝내지 않고, 역할을 나타내는 안정적인 키로 두 집합을 비교한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;expected = desired_jobs_by_role
actual = scheduler.list()

missing    = expected.roles - actual.roles
duplicates = actual.group_by(role).where(count &amp;gt; 1)
mismatched = actual.where(spec_version != expected.spec_version)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;일회성 ID는 재생성 때 바뀔 수 있다. 비교 키에는 &lt;code&gt;retry&lt;/code&gt;, &lt;code&gt;gate&lt;/code&gt;처럼 작업의 역할을 쓰고, schedule과 실행 템플릿에는 버전을 붙이는 편이 낫다. 그래야 “같은 이름의 오래된 작업”도 존재하는 척 통과하지 않는다.&lt;/p&gt;
&lt;h2&gt;감시자를 피감시자 안에 두면 한 축이 빈다&lt;/h2&gt;
&lt;p&gt;문제가 된 구조에서는 재시도 job이 다른 poller를 점검하고 필요하면 복구했다. 반대편 poller는 자기 업무만 확인했다. 따라서 재시도 job이 먼저 사라지면 이를 되살릴 주체도 함께 사라졌다.&lt;/p&gt;
&lt;p&gt;두 job이 서로를 감시하게 만들면 한쪽 삭제는 찾을 수 있다. 하지만 둘이 같은 scheduler에 있다면 scheduler 전체 중단처럼 두 실행 경로를 함께 막는 공통 장애에는 동시에 침묵할 수 있다. 상호 감시는 실패 도메인을 분리하지 않는다.&lt;/p&gt;
&lt;p&gt;필요한 독립성은 요구 수준에 따라 두 단계로 나뉜다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;운영자가 세션을 재개하거나 scheduler 설정을 바꿀 때 live 목록을 다시 대조한다. 가장 작은 보완책이지만 점검 사이의 공백은 남는다.&lt;/li&gt;
&lt;li&gt;24시간 감지가 필요하면 scheduler 밖의 dead-man monitor가 마지막 성공 시각을 확인한다. 내부 scheduler가 완전히 멈춰도 외부 신호는 남는다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Google SRE는 내부 지표를 보는 white-box monitoring과 사용자 관점에서 외부 동작을 확인하는 black-box monitoring을 구분한다. scheduler 목록, 실행 기록, job이 내보낸 heartbeat는 white-box 신호다. 별도 시스템이 최종 결과를 사용자 관점에서 직접 확인하면 black-box 신호가 된다.&lt;/p&gt;
&lt;p&gt;외부 monitor도 완전히 무결하지는 않다. 그 monitor와 알림 전달 경로의 상태까지 별도로 확인해야 한다. 핵심은 감시 계층을 무한히 늘리는 것이 아니라, 가장 치명적인 공통 실패 원인 하나를 끊는 것이다.&lt;/p&gt;
&lt;h2&gt;존재·실행·성공·업무 진척을 따로 본다&lt;/h2&gt;
&lt;p&gt;scheduled job의 건강 상태를 한 Boolean으로 표현하면 원인을 찾기 어렵다. 기대 상태 하나와 관측 단계 네 개로 나누면 된다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th&gt;확인할 질문&lt;/th&gt;
&lt;th&gt;대표 증거&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;desired&lt;/td&gt;
&lt;td&gt;이 역할의 작업이 존재해야 하는가&lt;/td&gt;
&lt;td&gt;버전 관리된 작업 명세&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;registered&lt;/td&gt;
&lt;td&gt;scheduler에 활성 객체가 있는가&lt;/td&gt;
&lt;td&gt;live list, enabled 또는 suspend 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;executed&lt;/td&gt;
&lt;td&gt;예정된 구간에 시작했는가&lt;/td&gt;
&lt;td&gt;last started, run history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;succeeded&lt;/td&gt;
&lt;td&gt;허용 시간 안에 성공했는가&lt;/td&gt;
&lt;td&gt;last success timestamp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;progressed&lt;/td&gt;
&lt;td&gt;작업이 맡은 업무가 실제로 전진했는가&lt;/td&gt;
&lt;td&gt;checkpoint, queue depth, 처리 완료 시각&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;등록과 실행은 같지 않다. Kubernetes CronJob은 객체가 존재해도 &lt;code&gt;.spec.suspend: true&lt;/code&gt;이면 새 Job을 시작하지 않는다. 실행과 성공도 같지 않다. 프로세스가 시작된 뒤 멈추거나 오류로 끝날 수 있다. 성공과 업무 진척 역시 다르다. 빈 입력을 정상 처리한 것인지, 잘못된 조건 때문에 계속 아무 일도 하지 않는지는 도메인 checkpoint를 봐야 구분된다.&lt;/p&gt;
&lt;p&gt;모든 작업에 다섯 지표를 새로 만들 필요는 없다. 먼저 live list와 last success 두 신호를 확보하고, “성공했지만 아무 일도 안 하는” 실패가 실제로 문제가 될 때 업무 진척 지표를 추가하면 된다.&lt;/p&gt;
&lt;h2&gt;가장 작은 복구 루프는 live reconciliation이다&lt;/h2&gt;
&lt;p&gt;복구는 등록 API를 무조건 다시 호출하는 것이 아니다. 먼저 actual state를 읽고 차이를 분류한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;observe → classify → re-read → repair → verify
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;scheduler의 live 목록과 활성 상태를 읽는다.&lt;/li&gt;
&lt;li&gt;누락, 중복, 비활성화, spec drift를 구분한다.&lt;/li&gt;
&lt;li&gt;일시적 지연이나 경쟁 조건이 아닌지 한 번 더 읽는다.&lt;/li&gt;
&lt;li&gt;누락된 작업만 버전 관리된 literal template에서 재생성한다.&lt;/li&gt;
&lt;li&gt;다시 목록을 읽고 역할·schedule·spec version을 확인한 뒤 원장의 ID를 갱신한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;알 수 없는 작업을 자동 삭제하는 기능은 이 복구에 필요하지 않다. 소유권을 증명하지 못한 객체를 지우는 것보다, 기대한 작업을 정확히 한 개 확보하고 중복을 경보로 남기는 편이 안전하다.&lt;/p&gt;
&lt;p&gt;reconciliation을 실행할 시점도 작게 시작할 수 있다. 작업을 소유한 프로세스의 시작·재개 직후와 scheduler 설정 변경 직후면 된다. 이것으로 감지 지연을 허용할 수 없다면 그때 외부 주기 점검을 추가한다.&lt;/p&gt;
&lt;h2&gt;heartbeat는 존재가 아니라 최신성을 증명해야 한다&lt;/h2&gt;
&lt;p&gt;batch job에는 &lt;code&gt;healthy = true&lt;/code&gt;보다 마지막 성공 시각이 유용하다. Prometheus도 batch job의 핵심 지표로 마지막 성공 시각을 권장하며, 경과 시간이 아니라 Unix timestamp를 내보내도록 안내한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;time() - job_last_success_timestamp_seconds{job=&quot;retry&quot;} &amp;gt; stale_after_seconds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;stale_after_seconds&lt;/code&gt;는 모든 시스템에 같은 상수가 아니다. 이 글에서는 &lt;code&gt;2 × 실행 주기 + 최대 예상 실행 시간&lt;/code&gt;을 초기값으로 삼고, 허용 가능한 감지 지연과 실제 실행 분산에 맞춰 정한다. 한 번의 지연을 곧바로 장애로 만들기보다 최소 두 번의 기대 실행 구간을 관찰하는 이유다.&lt;/p&gt;
&lt;p&gt;시계열 자체가 만들어지지 않는 경우에는 &lt;code&gt;absent()&lt;/code&gt;나 &lt;code&gt;absent_over_time()&lt;/code&gt;을 별도 경보에 사용할 수 있다. 다만 “시계열이 존재한다”와 “최근 값이다”도 구분해야 한다. Prometheus Pushgateway는 push된 시계열을 자동 만료하지 않으므로, 오래전에 성공한 값이 계속 존재할 수 있다. 부재 검사는 missing series를, timestamp 비교는 stale success를 찾는다.&lt;/p&gt;
&lt;p&gt;다음 세 신호를 한 경보로 섞지 않으면 대응도 명확해진다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;신호&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;우선 확인할 곳&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;job object 없음&lt;/td&gt;
&lt;td&gt;등록 또는 reconciliation 실패&lt;/td&gt;
&lt;td&gt;scheduler live list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;success metric 없음&lt;/td&gt;
&lt;td&gt;계측·수집·첫 실행 중 하나가 없음&lt;/td&gt;
&lt;td&gt;scrape 또는 push 경로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;success timestamp 오래됨&lt;/td&gt;
&lt;td&gt;실행 지연·실패·정지 가능성&lt;/td&gt;
&lt;td&gt;run history와 작업 로그&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;scheduler는 정확히 한 번을 보장하지 않을 수 있다&lt;/h2&gt;
&lt;p&gt;watcher가 작업의 존재를 확인해도 모든 예정 실행을 보장하는 것은 아니다. scheduler별 계약을 따로 읽어야 한다.&lt;/p&gt;
&lt;p&gt;Kubernetes는 CronJob이 예정 시각마다 대략 한 번 Job을 만들지만, 두 개가 만들어지거나 하나도 만들어지지 않는 경우를 완전히 막을 수 없다고 문서화한다. 그래서 Job은 멱등적으로 만들어야 한다. &lt;code&gt;startingDeadlineSeconds&lt;/code&gt;와 &lt;code&gt;concurrencyPolicy&lt;/code&gt;도 지연 실행과 겹침을 제어하지만 exactly-once 보장은 아니다.&lt;/p&gt;
&lt;p&gt;GitHub Actions도 &lt;code&gt;schedule&lt;/code&gt; 이벤트가 부하에 따라 지연될 수 있고, 충분히 높은 부하에서는 대기 중인 작업이 드롭될 수 있다고 밝힌다. 예약 workflow는 기본 브랜치에 파일이 있어야 하며 그 브랜치에서만 실행된다. 따라서 workflow 파일 존재만 확인하거나 정각 실행만 가정해서는 안 된다.&lt;/p&gt;
&lt;p&gt;이 예시는 모든 scheduler가 같은 방식으로 실패한다는 뜻이 아니다. 공통 원칙은 live 객체, run history, 성공 freshness를 나눠 확인하는 것이다. Kubernetes처럼 중복 가능성이 문서화된 scheduler에서는 작업도 중복 실행을 견디게 설계한다.&lt;/p&gt;
&lt;h2&gt;상태표가 경보보다 먼저다&lt;/h2&gt;
&lt;p&gt;경보 문구를 만들기 전에 관측 결과와 복구 행동을 표로 고정하면 자동 복구의 경계가 선명해진다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;관측 결과&lt;/th&gt;
&lt;th&gt;분류&lt;/th&gt;
&lt;th&gt;안전한 첫 행동&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;expected에는 있고 actual에는 없음&lt;/td&gt;
&lt;td&gt;missing&lt;/td&gt;
&lt;td&gt;한 번 재조회 후 template에서 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;객체는 있으나 disabled 또는 suspended&lt;/td&gt;
&lt;td&gt;inactive&lt;/td&gt;
&lt;td&gt;변경 이력 확인 후 명시적으로 활성화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 역할이 둘 이상&lt;/td&gt;
&lt;td&gt;duplicate&lt;/td&gt;
&lt;td&gt;겹침 위험을 경보하고 소유권을 수동 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;schedule 또는 spec version 불일치&lt;/td&gt;
&lt;td&gt;drift&lt;/td&gt;
&lt;td&gt;기대 버전과 변경 주체 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최근 시작 없음&lt;/td&gt;
&lt;td&gt;scheduler 또는 trigger 문제&lt;/td&gt;
&lt;td&gt;run history와 scheduler 상태 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최근 시작은 있으나 성공 없음&lt;/td&gt;
&lt;td&gt;job failure 또는 hang&lt;/td&gt;
&lt;td&gt;실행 로그와 timeout 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;성공은 최신이나 checkpoint 정지&lt;/td&gt;
&lt;td&gt;업무 판정 오류&lt;/td&gt;
&lt;td&gt;입력과 도메인 조건 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;watcher heartbeat도 없음&lt;/td&gt;
&lt;td&gt;monitoring failure&lt;/td&gt;
&lt;td&gt;외부 probe와 알림 경로 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;이 표에서 자동 수리는 &lt;code&gt;missing&lt;/code&gt;처럼 소유권과 기대 명세가 분명한 경우에만 좁게 적용한다. 비활성화와 drift는 의도적인 운영 변경일 수 있으므로 이력을 확인하지 않고 덮어쓰지 않는다.&lt;/p&gt;
&lt;h2&gt;경계 사례로 watcher 계약을 검증한다&lt;/h2&gt;
&lt;p&gt;실제 장애를 기다리지 않고 제어 평면의 경계만 확인해도 맹점을 찾을 수 있다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th&gt;기대 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;원장에는 ID가 있지만 live 객체 없음&lt;/td&gt;
&lt;td&gt;missing 경보, 저장된 ID만으로 정상 판정하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;live 객체가 suspended&lt;/td&gt;
&lt;td&gt;registered와 executed를 분리해 inactive 판정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 역할의 객체 두 개&lt;/td&gt;
&lt;td&gt;duplicate 경보, 둘 다 실행하지 않도록 보호&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;schedule은 같지만 spec version이 오래됨&lt;/td&gt;
&lt;td&gt;drift 판정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;한 주기 지연 뒤 정상 성공&lt;/td&gt;
&lt;td&gt;설정한 grace window 안에서는 경보 보류&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 시작 뒤 timeout 초과&lt;/td&gt;
&lt;td&gt;stale execution 또는 hang 판정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pushgateway에 오래된 성공 값만 남음&lt;/td&gt;
&lt;td&gt;metric 존재가 아니라 timestamp로 stale 판정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재시도 job만 삭제되고 gate poller는 생존&lt;/td&gt;
&lt;td&gt;전체 루프 정상으로 오인하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 scheduler의 두 watcher가 함께 중단&lt;/td&gt;
&lt;td&gt;외부 heartbeat에서 감지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;누락 job 재생성 직후 목록 반영 지연&lt;/td&gt;
&lt;td&gt;재조회 후 검증하고 중복 생성을 피함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;여기서 단위 테스트로 검증할 대상은 expected/actual 비교와 상태 분류다. scheduler가 실제로 지연·중복·누락되는지는 staging 또는 의도적으로 격리한 운영 점검에서 확인해야 한다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;재시도 작업은 자신의 실패는 보고할 수 있어도 자신의 부재는 보고할 수 없다. 저장된 job ID를 live 상태로 믿지 말고, 버전 관리된 desired state와 scheduler의 actual state를 역할 키로 대조해야 한다.&lt;/p&gt;
&lt;p&gt;가장 작은 보완책은 소유 프로세스가 시작하거나 설정이 바뀔 때 live reconciliation을 실행하는 것이다. 24시간 감지가 필요할 때만 scheduler 밖의 heartbeat를 추가한다. 그리고 작업의 건강을 등록·실행·성공·업무 진척으로 나누면, “오류가 없어서 정상”인 침묵과 “아무것도 실행되지 않아 조용한” 침묵을 구별할 수 있다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/&quot;&gt;Kubernetes — CronJob&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://prometheus.io/docs/practices/instrumentation/&quot;&gt;Prometheus — Instrumentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/querying/functions/&quot;&gt;Prometheus — Query functions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://prometheus.io/docs/practices/alerting/&quot;&gt;Prometheus — Alerting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://prometheus.io/docs/practices/pushing/&quot;&gt;Prometheus — When to use the Pushgateway&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule&quot;&gt;GitHub Docs — Events that trigger workflows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sre.google/sre-book/monitoring-distributed-systems/&quot;&gt;Google SRE — Monitoring Distributed Systems&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-dev</category><category>CI-CD</category><category>Automation</category><category>Observability</category><category>Reliability</category><category>Cron</category></item><item><title>Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 7편. 테스트와 CI로 같은 결과 보장하기</title><link>https://jaemyeong.com/ko/blog/rust-shared-core-07-testing-ci/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/rust-shared-core-07-testing-ci/</guid><description>Rust 공통 모듈이 iOS·Android·Web에서 같은 결과를 내도록 cargo test, golden fixture, 플랫폼별 CI job 분리, release checklist를 어떻게 나눌지 정리합니다.</description><pubDate>Sat, 25 Jul 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Rust 공통 모듈을 쓰는 이유는 코드 줄 수를 줄이기 위해서만은 아니다.&lt;/p&gt;
&lt;p&gt;진짜 장점은 같은 규칙을 한 곳에서 검증하고, iOS, Android, Web이 같은 결과를 쓰게 만드는 데 있다. 그런데 테스트와 CI가 받쳐주지 않으면 이 장점은 금방 약해진다.&lt;/p&gt;
&lt;p&gt;Rust test는 통과했지만 iOS에서 link가 깨질 수 있다. Android debug build는 되지만 release AAB에 특정 ABI가 빠질 수 있다. Web dev server에서는 되지만 production build에서 wasm 초기화가 실패할 수 있다. 공통 모듈은 오히려 CI에서 더 엄격하게 다뤄야 한다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 Rust core 공유 구조에서 테스트와 CI를 어떻게 나누면 좋은지 정리한다.&lt;/p&gt;
&lt;h2&gt;가장 많은 테스트는 Rust core에 둔다&lt;/h2&gt;
&lt;p&gt;도메인 규칙은 Rust core에 있으므로, 가장 많은 테스트도 Rust core에 있어야 한다.&lt;/p&gt;
&lt;p&gt;스도쿠 앱이라면 이런 테스트가 core에 들어간다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;puzzle generation&lt;/li&gt;
&lt;li&gt;solver correctness&lt;/li&gt;
&lt;li&gt;invalid move validation&lt;/li&gt;
&lt;li&gt;note toggle reducer&lt;/li&gt;
&lt;li&gt;daily seed determinism&lt;/li&gt;
&lt;li&gt;difficulty classification&lt;/li&gt;
&lt;li&gt;serialization round-trip&lt;/li&gt;
&lt;li&gt;boundary value&lt;/li&gt;
&lt;li&gt;error mapping&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 테스트들은 iOS simulator나 Android emulator 없이도 실행되어야 한다. &lt;code&gt;cargo test&lt;/code&gt;만으로 도메인 규칙의 대부분을 검증할 수 있어야 한다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 것은 deterministic test다. 같은 seed와 같은 action sequence를 넣으면 항상 같은 결과가 나와야 한다. 시간, locale, random source, timezone이 결과에 숨어들면 플랫폼마다 다른 결과가 나올 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 Rust core에는 외부 상태를 직접 넣지 않는 편이 좋다. 현재 시각이 필요하면 timestamp를 인자로 받는다. random이 필요하면 seed를 명시한다. locale이 필요하면 locale code를 외부에서 넘긴다.&lt;/p&gt;
&lt;h2&gt;golden fixture로 플랫폼 간 결과를 고정한다&lt;/h2&gt;
&lt;p&gt;공통 Rust core에서는 fixture가 매우 중요하다.&lt;/p&gt;
&lt;p&gt;fixture는 단순한 샘플 데이터가 아니다. “이 입력이면 이 결과가 나와야 한다”는 계약이다.&lt;/p&gt;
&lt;p&gt;예를 들어 이런 fixture를 둘 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fixtures/
  start-game-easy-seed-001.json
  apply-action-set-value-valid.json
  apply-action-set-value-conflict.json
  daily-puzzle-2026-06-24.json
  completed-game-score.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rust test는 이 fixture를 읽어 engine output을 검증한다. iOS test는 같은 fixture를 읽어 Swift adapter가 DTO를 올바르게 해석하는지 확인한다. Android test는 Kotlin adapter와 serialization 설정을 검증한다. Web test는 TypeScript wrapper가 wasm output을 맞게 변환하는지 확인한다.&lt;/p&gt;
&lt;p&gt;이렇게 하면 각 플랫폼 테스트가 Rust core의 모든 알고리즘을 다시 검증할 필요가 없다. 각 플랫폼은 자신의 adapter 경계를 검증하면 된다.&lt;/p&gt;
&lt;p&gt;역할 분리가 중요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Rust test
  -&amp;gt; 도메인 규칙이 맞는가

iOS test
  -&amp;gt; Swift adapter가 Rust 결과를 맞게 해석하는가

Android test
  -&amp;gt; Kotlin adapter와 native library 연결이 맞는가

Web test
  -&amp;gt; wasm package와 TypeScript wrapper가 맞는가
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;CI는 “빌드된다”보다 “재현된다”를 봐야 한다&lt;/h2&gt;
&lt;p&gt;공통 Rust core 프로젝트에서 CI의 목표는 단순히 pass 표시를 받는 것이 아니다. fresh clone에서 모든 산출물이 재현되는지 확인해야 한다.&lt;/p&gt;
&lt;p&gt;최소한 다음 단계는 나눠서 확인하는 편이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Rust
  cargo fmt
  cargo clippy
  cargo test

UniFFI
  generate Swift binding
  generate Kotlin binding
  generated output validation

Apple
  build Rust static library
  create XCFramework
  xcodebuild build/test

Android
  build Rust .so per ABI
  generate Kotlin binding
  Gradle unit test
  assemble release or debug
  native library packaging check

Web
  wasm-pack build
  typecheck
  test
  production build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 단계 중 하나라도 로컬에서만 되고 CI에서 안 되면 위험하다. 특히 generated binding과 binary artifact는 stale 상태가 되기 쉽다. CI에서 재생성했을 때 diff가 생기는지 확인하는 방식도 좋다.&lt;/p&gt;
&lt;h2&gt;플랫폼별 실패를 분리해서 봐야 한다&lt;/h2&gt;
&lt;p&gt;Rust core가 하나라고 해서 실패 원인도 하나가 되는 것은 아니다.&lt;/p&gt;
&lt;p&gt;iOS 실패는 XCFramework slice 누락, Swift binding mismatch, Xcode version 차이, simulator/device target 차이에서 생길 수 있다. Android 실패는 ABI 누락, &lt;code&gt;.so&lt;/code&gt; 이름 mismatch, NDK version, 16 KB page size, Gradle packaging에서 생길 수 있다. Web 실패는 wasm 초기화, bundler 설정, SSR 경계, TypeScript declaration mismatch에서 생길 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 CI job을 너무 크게 하나로 묶으면 디버깅이 어려워진다.&lt;/p&gt;
&lt;p&gt;가능하면 job을 역할별로 나눈다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;rust-core&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uniffi-bindings&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;apple-xcframework&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;android-native&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;web-wasm&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contract-fixtures&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이렇게 나누면 어떤 계층이 깨졌는지 빨리 보인다. 공통 fixture가 깨졌다면 도메인 계약 문제일 가능성이 높고, iOS job만 깨졌다면 Apple packaging 문제일 가능성이 높다.&lt;/p&gt;
&lt;h2&gt;release checklist가 필요하다&lt;/h2&gt;
&lt;p&gt;공통 Rust core는 앱 릴리즈의 일부다. Rust crate만 업데이트했다고 끝이 아니다.&lt;/p&gt;
&lt;p&gt;release 전에 확인해야 할 항목이 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rust core version이 앱에 반영됐는가&lt;/li&gt;
&lt;li&gt;UniFFI binding이 최신 Rust API와 일치하는가&lt;/li&gt;
&lt;li&gt;iOS XCFramework가 device/simulator slice를 모두 포함하는가&lt;/li&gt;
&lt;li&gt;Android release artifact에 필요한 ABI가 모두 포함되는가&lt;/li&gt;
&lt;li&gt;Android 16 KB page size 요구사항을 만족하는가&lt;/li&gt;
&lt;li&gt;Web production build에서 wasm package가 정상 초기화되는가&lt;/li&gt;
&lt;li&gt;fixture가 모든 플랫폼 테스트에서 같은 결과를 내는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 체크리스트는 문서로만 남기면 잘 잊힌다. 가능하면 CI로 자동화하고, 자동화가 어려운 항목만 release checklist에 남긴다.&lt;/p&gt;
&lt;p&gt;좋아 보이지만 팀 단위로 도입할 때는 기준이 필요하다. Rust core 변경 PR은 플랫폼 앱 build까지 요구할 것인지, 아니면 nightly나 release branch에서만 전체 matrix를 돌릴 것인지 정해야 한다. 모든 PR에서 full matrix를 돌리면 비용이 커질 수 있다.&lt;/p&gt;
&lt;p&gt;현실적인 타협은 이렇다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rust core PR: Rust test, binding generation, fixture test는 항상 실행&lt;/li&gt;
&lt;li&gt;플랫폼 통합 PR: 해당 플랫폼 build/test 실행&lt;/li&gt;
&lt;li&gt;release candidate: iOS, Android, Web 전체 matrix 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이렇게 하면 비용과 안정성 사이의 균형을 잡을 수 있다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Rust core에 가장 많은 도메인 테스트를 둔다.&lt;/li&gt;
&lt;li&gt;seed, 시간, locale 같은 외부 상태는 명시적으로 주입한다.&lt;/li&gt;
&lt;li&gt;golden fixture를 만들어 세 플랫폼이 같은 결과를 검증하게 한다.&lt;/li&gt;
&lt;li&gt;플랫폼 테스트는 core 알고리즘보다 adapter 해석을 검증한다.&lt;/li&gt;
&lt;li&gt;CI에서 binding generation과 binary artifact 생성을 재현한다.&lt;/li&gt;
&lt;li&gt;iOS, Android, Web job을 분리해 실패 원인을 좁힌다.&lt;/li&gt;
&lt;li&gt;Android는 release artifact와 16 KB page size를 체크한다.&lt;/li&gt;
&lt;li&gt;Web은 production build 기준으로 wasm 초기화를 확인한다.&lt;/li&gt;
&lt;li&gt;release checklist를 문서가 아니라 CI 중심으로 옮긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Rust 공통 모듈은 잘 만들면 세 플랫폼의 도메인 규칙을 하나로 묶어준다. 하지만 그 신뢰는 테스트와 CI에서 나온다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cargo test&lt;/code&gt;만 통과하는 것으로는 부족하다. Swift adapter, Kotlin adapter, wasm package, XCFramework, Android &lt;code&gt;.so&lt;/code&gt;, Web production build까지 같은 흐름 안에서 검증해야 한다.&lt;/p&gt;
&lt;p&gt;결국 핵심은 “같은 코드를 쓴다”가 아니라 “같은 결과를 증명한다”에 있다. 이 기준을 잡아두면 Rust 공통 모듈은 단순한 기술 실험이 아니라, 앱 품질을 지키는 중심 계층이 될 수 있다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doc.rust-lang.org/reference/linkage.html&quot;&gt;Rust Reference - Linkage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mozilla.github.io/uniffi-rs/&quot;&gt;Mozilla UniFFI - The UniFFI user guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle&quot;&gt;Apple Developer - Creating a multiplatform binary framework bundle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/guide/practices/page-sizes&quot;&gt;Android Developers - Support 16 KB page sizes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rustwasm.github.io/docs/wasm-pack/&quot;&gt;Rust and WebAssembly - The wasm-pack Book&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>CI-CD</category><category>Testing</category><category>UniFFI</category><category>FFI</category></item><item><title>Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 6편. WebAssembly로 Web에 배포하기</title><link>https://jaemyeong.com/ko/blog/rust-shared-core-06-web-wasm/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/rust-shared-core-06-web-wasm/</guid><description>Rust 공통 모듈을 WebAssembly로 빌드해 Web 앱의 도메인 엔진으로 쓰는 구조를 wasm-bindgen, wasm-pack, browser 초기화 경계, FFI 계약, fixture 공유 기준으로 정리합니다.</description><pubDate>Thu, 23 Jul 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;iOS와 Android에서 Rust core를 공유했다면 Web에서도 같은 로직을 쓰고 싶어진다.&lt;/p&gt;
&lt;p&gt;Web에서는 Rust를 WebAssembly로 빌드하는 방식이 자연스럽다. wasm-bindgen은 Rust와 JavaScript 사이의 상호작용을 도와주고, wasm-pack은 Rust crate를 npm 생태계에서 사용할 수 있는 형태로 묶어준다.&lt;/p&gt;
&lt;p&gt;하지만 WebAssembly를 도입한다고 해서 Web 앱 전체가 Rust 중심으로 바뀌는 것은 아니다. DOM, routing, browser storage, fetch, hydration, accessibility는 여전히 TypeScript와 Web framework 쪽이 자연스럽다. Rust는 계산과 규칙의 일관성이 중요한 부분을 맡는 편이 좋다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 Rust core를 Web에서 어떻게 패키지처럼 다루면 좋은지 정리한다.&lt;/p&gt;
&lt;h2&gt;wasm은 Web 앱의 한 모듈이다&lt;/h2&gt;
&lt;p&gt;WebAssembly는 modern browser에서 실행되는 low-level binary format이다. Rust 같은 언어를 Web target으로 컴파일할 수 있고, JavaScript와 함께 실행된다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 표현은 “함께 실행된다”다. wasm이 JavaScript를 대체한다고 보면 설계가 어긋난다. 실제 Web 앱에서는 TypeScript가 UI와 browser API를 다루고, wasm module은 특정 계산을 맡는 구조가 더 자연스럽다.&lt;/p&gt;
&lt;p&gt;스도쿠 앱을 예로 들면 Rust/Wasm이 맡기 좋은 영역은 이렇다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;퍼즐 생성&lt;/li&gt;
&lt;li&gt;solver&lt;/li&gt;
&lt;li&gt;validation&lt;/li&gt;
&lt;li&gt;action reducer&lt;/li&gt;
&lt;li&gt;score 계산&lt;/li&gt;
&lt;li&gt;daily puzzle seed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반대로 다음 영역은 TypeScript에 남기는 편이 좋다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React component&lt;/li&gt;
&lt;li&gt;routing&lt;/li&gt;
&lt;li&gt;localStorage 또는 IndexedDB 접근&lt;/li&gt;
&lt;li&gt;keyboard shortcut&lt;/li&gt;
&lt;li&gt;pointer interaction&lt;/li&gt;
&lt;li&gt;analytics&lt;/li&gt;
&lt;li&gt;service worker&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉 Web에서도 원칙은 모바일과 같다. Rust는 도메인 엔진이고, Web 앱은 그 엔진을 호출해 UI state를 구성한다.&lt;/p&gt;
&lt;h2&gt;wasm-bindgen과 wasm-pack의 역할&lt;/h2&gt;
&lt;p&gt;wasm-bindgen은 Rust와 JavaScript 사이의 고수준 상호작용을 가능하게 한다. Rust 함수를 JavaScript에서 호출할 수 있게 하고, 필요한 wrapper와 TypeScript declaration을 생성하는 흐름을 제공한다.&lt;/p&gt;
&lt;p&gt;wasm-pack은 한 단계 더 앱 개발자에게 가까운 도구다. Rust crate를 빌드해 WebAssembly binary, JavaScript glue code, package metadata를 만들어준다. 결과물을 npm package처럼 다룰 수 있다.&lt;/p&gt;
&lt;p&gt;실무 구조는 이렇게 잡을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;core/sudoku-rs/crates/
  sudoku-core/      # 순수 Rust 로직
  sudoku-wasm/      # wasm-bindgen 공개 API

packages/
  sudoku-wasm/      # wasm-pack output 또는 wrapper package

apps/web/
  app/              # Next.js / React 앱
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sudoku-wasm&lt;/code&gt; crate는 &lt;code&gt;sudoku-core&lt;/code&gt;를 호출한다. Web 앱은 직접 Rust 내부 모델을 알지 않고, npm package처럼 &lt;code&gt;@app/sudoku-wasm&lt;/code&gt;을 import한다.&lt;/p&gt;
&lt;p&gt;이 구조의 장점은 Web 앱 관점에서 의존성이 명확하다는 점이다. Rust build output이 앱 내부 어딘가에 숨어 있지 않고, package 단위로 관리된다.&lt;/p&gt;
&lt;h2&gt;초기화 경계를 분리한다&lt;/h2&gt;
&lt;p&gt;Web에서 wasm을 쓸 때 가장 조심해야 할 부분은 초기화 시점이다.&lt;/p&gt;
&lt;p&gt;Next.js 같은 framework에서는 server environment와 browser environment가 섞인다. browser 전용 wasm module을 서버 컴포넌트나 static build 시점에 바로 실행하려고 하면 문제가 생길 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 wasm engine을 사용하는 코드는 browser boundary 안에 두는 편이 안전하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client Component
  -&amp;gt; dynamic import(&quot;@app/sudoku-wasm&quot;)
  -&amp;gt; init wasm
  -&amp;gt; create SudokuEngineClient
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;또는 Web 앱의 state layer에서 lazy initialization을 관리할 수 있다. 중요한 것은 UI가 wasm 초기화 상태를 알 수 있어야 한다는 점이다. 초기화 중, 실패, 준비 완료 상태를 명확히 다루지 않으면 사용자는 빈 화면이나 멈춘 버튼을 보게 된다.&lt;/p&gt;
&lt;p&gt;실제 앱에서는 이런 상태가 필요하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;wasm loading&lt;/li&gt;
&lt;li&gt;wasm ready&lt;/li&gt;
&lt;li&gt;wasm failed&lt;/li&gt;
&lt;li&gt;engine action pending&lt;/li&gt;
&lt;li&gt;engine action failed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rust core가 안정적이어도 Web 초기화 경험이 나쁘면 사용자에게는 앱이 불안정해 보인다.&lt;/p&gt;
&lt;h2&gt;FFI 경계는 Web에서도 굵게 가져간다&lt;/h2&gt;
&lt;p&gt;Web에서도 FFI API를 너무 잘게 쪼개면 좋지 않다.&lt;/p&gt;
&lt;p&gt;React render 중에 cell마다 wasm 함수를 호출하거나, pointer move마다 Rust를 호출하는 구조는 피하고 싶다. UI event 하나에 대해 현재 snapshot과 action을 넘기고, 다음 snapshot과 effect를 받는 방식이 낫다.&lt;/p&gt;
&lt;p&gt;예를 들어 TypeScript에서는 이런 형태로 감싼다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export interface SudokuEngineClient {
  startGame(request: StartGameRequest): Promise&amp;lt;GameSnapshot&amp;gt;;
  applyAction(snapshot: GameSnapshot, action: GameAction): Promise&amp;lt;Transition&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;내부 구현은 wasm package를 호출한다. React component는 이 client만 알고, wasm-bindgen이 생성한 raw function은 모른다.&lt;/p&gt;
&lt;p&gt;이 구조는 모바일 adapter와도 개념적으로 맞아 떨어진다. iOS의 &lt;code&gt;SudokuEngineClient&lt;/code&gt;, Android의 &lt;code&gt;SudokuEngineClient&lt;/code&gt;, Web의 &lt;code&gt;SudokuEngineClient&lt;/code&gt;가 같은 도메인 의미를 갖게 된다. 구현은 플랫폼마다 다르지만 앱이 기대하는 계약은 같다.&lt;/p&gt;
&lt;h2&gt;TypeScript 타입과 fixture를 같이 관리한다&lt;/h2&gt;
&lt;p&gt;wasm-pack이 TypeScript declaration을 만들어주더라도, 앱 도메인 타입을 그대로 맡겨두기에는 부족할 수 있다.&lt;/p&gt;
&lt;p&gt;Web 앱에서는 Rust/Wasm output을 앱 내부 type으로 변환하는 wrapper를 두는 편이 좋다. 이 wrapper에서 JSON decoding, schema validation, error code mapping을 처리한다.&lt;/p&gt;
&lt;p&gt;특히 Rust core와 Web 앱 사이에는 fixture가 중요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fixtures/
  daily-seed-2026-06-24.json
  apply-action-note-toggle.json
  invalid-move-conflict.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;같은 fixture를 Rust test와 Web test에서 같이 쓰면 좋다. Rust에서는 engine output을 검증하고, Web에서는 TypeScript wrapper가 같은 output을 올바르게 해석하는지 검증한다.&lt;/p&gt;
&lt;p&gt;이렇게 하면 “Rust core는 맞는데 Web wrapper가 타입을 잘못 해석한” 문제를 잡기 쉽다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;WebAssembly를 Web 앱 전체의 대체재가 아니라 도메인 엔진 모듈로 본다.&lt;/li&gt;
&lt;li&gt;Rust/Wasm에는 계산과 규칙 일관성이 중요한 로직만 넣는다.&lt;/li&gt;
&lt;li&gt;wasm-bindgen 공개 API는 &lt;code&gt;sudoku-wasm&lt;/code&gt; 같은 별도 crate에 둔다.&lt;/li&gt;
&lt;li&gt;wasm-pack output은 npm package처럼 관리한다.&lt;/li&gt;
&lt;li&gt;Next.js 같은 framework에서는 browser-only 초기화 경계를 분리한다.&lt;/li&gt;
&lt;li&gt;UI에는 loading, ready, failed 상태를 명확히 제공한다.&lt;/li&gt;
&lt;li&gt;wasm raw function은 TypeScript adapter 내부에 숨긴다.&lt;/li&gt;
&lt;li&gt;Rust fixture와 Web fixture를 공유해 같은 결과를 검증한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Web에서 Rust core를 쓰는 일은 성능 최적화만의 문제가 아니다. iOS, Android와 같은 도메인 규칙을 Web에서도 보장하기 위한 구조적 선택에 가깝다.&lt;/p&gt;
&lt;p&gt;다만 Web은 Web답게 남겨야 한다. React, routing, browser API, hydration 경계는 TypeScript가 다루고, Rust/Wasm은 결정적인 도메인 계산을 맡는다. 이 경계가 선명하면 wasm은 부담이 아니라 꽤 좋은 공통 엔진 배포 단위가 된다.&lt;/p&gt;
&lt;p&gt;다음 글에서는 이 구조가 실제로 유지되도록 테스트와 CI를 어떻게 잡아야 하는지 정리해보겠다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/WebAssembly&quot;&gt;MDN Web Docs - WebAssembly&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rustwasm.github.io/docs/wasm-bindgen/&quot;&gt;Rust and WebAssembly - The wasm-bindgen Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rustwasm.github.io/docs/wasm-pack/&quot;&gt;Rust and WebAssembly - The wasm-pack Book&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>WebAssembly</category><category>wasm-bindgen</category><category>wasm-pack</category><category>TypeScript</category></item><item><title>Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 5편. Android 네이티브 라이브러리 패키징</title><link>https://jaemyeong.com/ko/blog/rust-shared-core-05-android-native-packaging/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/rust-shared-core-05-android-native-packaging/</guid><description>Rust 공통 모듈을 Android 앱에 넣을 때 ABI별 .so, Gradle task, UniFFI adapter, 16 KB page size 검증을 release artifact 기준으로 정리합니다.</description><pubDate>Sat, 04 Jul 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Android 앱에 Rust core를 넣는 순간, Kotlin 코드만 보던 작업은 native library packaging까지 이어진다.&lt;/p&gt;
&lt;p&gt;UniFFI가 Kotlin binding을 만들어주더라도 실제 앱에 들어가는 것은 ABI별 &lt;code&gt;.so&lt;/code&gt; 파일이다. 이 파일이 누락되거나, 잘못된 ABI로 들어가거나, Google Play와 Android platform 요구사항을 만족하지 못하면 앱은 빌드, 설치, 실행 중 어느 단계에서든 깨질 수 있다.&lt;/p&gt;
&lt;p&gt;이 글은 2026-07-05 기준 Android Developers 문서를 바탕으로 Rust 공통 모듈을 Android 앱에 통합할 때 확인해야 할 packaging 기준을 정리한다. 특히 ABI별 산출물, Gradle 연결, Kotlin adapter, 16 KB page size, release artifact 검증을 한 흐름으로 본다.&lt;/p&gt;
&lt;h2&gt;Android는 ABI별 산출물이 필요하다&lt;/h2&gt;
&lt;p&gt;Android native library는 ABI별로 패키징된다. Android NDK 문서는 &lt;code&gt;armeabi-v7a&lt;/code&gt;, &lt;code&gt;arm64-v8a&lt;/code&gt;, &lt;code&gt;x86&lt;/code&gt;, &lt;code&gt;x86_64&lt;/code&gt;를 지원 ABI로 설명한다. 실제 배포 범위는 앱 정책에 따라 달라질 수 있지만, release APK나 AAB 안에 어떤 ABI가 들어가는지는 명확해야 한다.&lt;/p&gt;
&lt;p&gt;Rust core를 Android에서 사용한다면 각 ABI target에 맞춰 &lt;code&gt;.so&lt;/code&gt;를 만들어야 한다. 일반적인 배치 형태는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apps/android/
  app/
    src/main/
      jniLibs/
        arm64-v8a/libdomain_core.so
        armeabi-v7a/libdomain_core.so
        x86_64/libdomain_core.so
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조가 해결하는 것은 Android packaging system이 ABI에 맞는 native library를 찾을 수 있게 하는 일이다. 한계도 있다. 파일을 수동으로 복사하면 Rust core는 바뀌었는데 Android 앱에는 예전 &lt;code&gt;.so&lt;/code&gt;가 들어간 상태가 쉽게 생긴다.&lt;/p&gt;
&lt;p&gt;그래서 Rust 빌드는 Android 빌드 과정에 연결하는 편이 낫다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;preBuild
  dependsOn buildRustCoreForAndroid

buildRustCoreForAndroid
  -&amp;gt; ABI별 cargo build
  -&amp;gt; UniFFI Kotlin binding 생성
  -&amp;gt; jniLibs / generated source 경로에 output 배치
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 흐름은 stale artifact 문제를 줄인다. 다만 Gradle task의 inputs와 outputs를 선언하지 않으면 매번 Rust를 다시 빌드하거나, 반대로 변경을 놓치는 문제가 생길 수 있다. 로컬 개발 속도와 CI 재현성을 같이 보려면 task 경계를 명시해야 한다.&lt;/p&gt;
&lt;h2&gt;Kotlin binding은 앱 계층에 바로 퍼뜨리지 않는다&lt;/h2&gt;
&lt;p&gt;UniFFI가 생성한 Kotlin binding은 편리하다. 하지만 그 타입을 ViewModel, Composable, repository에 직접 퍼뜨리면 앱 전체가 binding 구조에 묶인다.&lt;/p&gt;
&lt;p&gt;Android 앱에는 Kotlin adapter를 둔다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface SudokuEngineClient {
    fun startGame(request: StartGameRequest): GameSnapshot
    fun applyAction(snapshot: GameSnapshot, action: GameAction): Transition
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 interface가 해결하는 것은 앱 내부 모델과 generated binding의 결합을 줄이는 일이다. 실제 구현체는 UniFFI binding을 호출하고, ViewModel은 interface만 본다. 테스트에서는 fake implementation을 넣을 수 있다.&lt;/p&gt;
&lt;p&gt;아직 남는 판단도 있다. Rust 호출이 CPU 비용이 크다면 main thread에서 실행하면 안 된다. ViewModel scope 안에서 적절한 dispatcher를 사용하고, 결과만 UI state로 반영하는 구조가 필요하다. Compose UI는 Rust가 있는지 몰라도 되게 만드는 편이 유지보수에 유리하다.&lt;/p&gt;
&lt;h2&gt;16 KB page size는 release checklist에 넣는다&lt;/h2&gt;
&lt;p&gt;Android native library를 포함한다면 16 KB page size 요구사항을 반드시 확인해야 한다.&lt;/p&gt;
&lt;p&gt;Google Play는 2025년 11월 1일부터 Android 15 이상을 타깃하는 신규 앱과 기존 앱 업데이트가 64-bit 기기에서 16 KB page size를 지원해야 한다고 안내한다. 앱이 직접 NDK를 쓰지 않더라도 SDK를 통해 native library를 포함하면 영향을 받을 수 있다. Rust로 만든 &lt;code&gt;.so&lt;/code&gt;도 예외가 아니다.&lt;/p&gt;
&lt;p&gt;최신 NDK를 쓰면 기본값은 좋아졌다. Android 문서는 NDK r28 이상이 기본적으로 16 KB alignment로 compile한다고 설명한다. 그래도 검사는 생략하면 안 된다. release artifact에 들어간 모든 native library가 최종 기준이다.&lt;/p&gt;
&lt;p&gt;릴리즈 전에 다음을 확인한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱에 포함된 모든 native library가 16 KB ELF alignment를 만족하는지&lt;/li&gt;
&lt;li&gt;16 KB page size emulator 또는 실제 기기에서 실행되는지&lt;/li&gt;
&lt;li&gt;third-party SDK가 native library를 포함하는지&lt;/li&gt;
&lt;li&gt;debug build가 아니라 release APK 또는 AAB 기준으로 검사했는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;특히 third-party SDK가 문제를 만들 수 있다. 우리 Rust &lt;code&gt;.so&lt;/code&gt;는 NDK r28 이상으로 맞췄지만, 광고 SDK나 분석 SDK가 오래된 native library를 포함하면 전체 앱이 영향을 받는다.&lt;/p&gt;
&lt;p&gt;검사 명령도 release artifact에 붙여야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;zipalign -c -P 16 -v 4 app-release.apk
adb shell getconf PAGE_SIZE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;첫 번째 명령은 APK alignment를 확인한다. 두 번째 명령은 테스트 기기가 실제로 어떤 page size 환경인지 확인한다. 이 두 값이 해결하는 것은 &quot;빌드는 됐다&quot;와 &quot;16 KB 환경에서 실행할 수 있다&quot; 사이의 간극이다. AAB만 만드는 프로젝트라면 Play Console pre-launch report나 bundle에서 생성한 APK 기준 검사까지 포함해야 한다.&lt;/p&gt;
&lt;h2&gt;AGP와 NDK 버전을 명시한다&lt;/h2&gt;
&lt;p&gt;2026-07-05 기준 Android Gradle plugin 9.1.1 문서는 Gradle 9.3.1, JDK 17, 기본 NDK 28.2.13676358을 호환성 표에 적고 있다. AGP 9.0 계열부터 기본 NDK가 r28 계열로 올라왔고, 9.1.1에서도 같은 NDK 기본값을 유지한다.&lt;/p&gt;
&lt;p&gt;Rust native build를 Android 빌드에 붙일 때는 이 환경을 문서화해야 한다. 로컬 개발자마다 NDK 버전이 다르면 &lt;code&gt;.so&lt;/code&gt; alignment나 linker behavior가 달라질 수 있다.&lt;/p&gt;
&lt;p&gt;최소한 다음 값은 한 곳에 고정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AGP version
Gradle version
JDK version
NDK version
Rust toolchain
Android Rust targets
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 목록은 새 설정 파일을 만들자는 뜻이 아니다. 이미 version catalog, Gradle wrapper, CI image, README 중 한 곳에서 관리하고 있다면 그곳을 기준으로 삼으면 된다. 중요한 것은 fresh clone에서 &lt;code&gt;./gradlew assembleRelease&lt;/code&gt; 또는 CI release build가 Rust core 생성까지 포함해 성공하는 것이다.&lt;/p&gt;
&lt;h2&gt;release artifact를 기준으로 검사한다&lt;/h2&gt;
&lt;p&gt;native library 문제는 debug build에서는 안 보이다가 release에서 드러날 수 있다.&lt;/p&gt;
&lt;p&gt;R8, minify, packagingOptions, ABI split, App Bundle 생성 과정이 관여하기 때문이다. 그래서 debug APK만 보고 끝내면 부족하다.&lt;/p&gt;
&lt;p&gt;확인 대상은 실제 배포에 가까운 artifact여야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;release APK 또는 AAB 안에 필요한 ABI가 들어 있는지&lt;/li&gt;
&lt;li&gt;native library 이름이 Kotlin binding에서 기대하는 이름과 맞는지&lt;/li&gt;
&lt;li&gt;&lt;code&gt;System.loadLibrary&lt;/code&gt; 시점에 실패하지 않는지&lt;/li&gt;
&lt;li&gt;16 KB page size 검사에서 통과하는지&lt;/li&gt;
&lt;li&gt;fresh install 후 첫 Rust 호출이 성공하는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 검사는 가능하면 CI에 넣는 것이 좋다. 최소한 release candidate를 만들 때 수동 체크리스트로라도 남겨야 한다. Rust core는 앱 내부 구현처럼 보여도, 배포 관점에서는 native binary dependency다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Android 앱에 포함할 ABI 범위를 정한다.&lt;/li&gt;
&lt;li&gt;Rust &lt;code&gt;.so&lt;/code&gt;를 ABI별로 생성하는 Gradle task를 만든다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;jniLibs&lt;/code&gt;에 수동 복사한 산출물이 stale해지지 않게 한다.&lt;/li&gt;
&lt;li&gt;UniFFI Kotlin binding은 adapter 내부에 숨긴다.&lt;/li&gt;
&lt;li&gt;Rust 호출이 무거우면 main thread에서 실행하지 않는다.&lt;/li&gt;
&lt;li&gt;AGP, Gradle, JDK, NDK, Rust toolchain 버전을 문서화한다.&lt;/li&gt;
&lt;li&gt;16 KB page size 대응을 release checklist에 넣는다.&lt;/li&gt;
&lt;li&gt;third-party SDK native library도 함께 검사한다.&lt;/li&gt;
&lt;li&gt;debug build가 아니라 release artifact 기준으로 검증한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Android에서 Rust core를 쓰는 일은 Kotlin API를 하나 추가하는 정도로 끝나지 않는다. ABI, &lt;code&gt;.so&lt;/code&gt;, NDK, Gradle task, Play 요구사항이 같이 따라온다.&lt;/p&gt;
&lt;p&gt;특히 2026년에는 16 KB page size 대응을 가볍게 보면 안 된다. Rust native library를 넣는 순간 앱은 native packaging 검증 대상이 된다. 빌드가 되는지보다, 어떤 artifact가 만들어지고 어떤 기기 조건에서 실행되는지를 확인해야 한다.&lt;/p&gt;
&lt;p&gt;다음 글에서는 Web에서 같은 Rust core를 WebAssembly package로 다루는 방식을 정리하겠다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/ndk/guides/abis&quot;&gt;Android Developers - Android ABIs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/guide/practices/page-sizes&quot;&gt;Android Developers - Support 16 KB page sizes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/build/releases/agp-9-1-0-release-notes&quot;&gt;Android Developers - Android Gradle plugin 9.1.1 Release Notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/build/releases/agp-9-0-0-release-notes&quot;&gt;Android Developers - Android Gradle plugin 9.0.1 Release Notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/ndk/guides/jni-tips&quot;&gt;Android Developers - JNI tips&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/ndk/guides/other_build_systems&quot;&gt;Android Developers - Use the NDK with other build systems&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mozilla.github.io/uniffi-rs/&quot;&gt;Mozilla UniFFI - The UniFFI user guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>Android</category><category>Kotlin</category><category>Gradle</category><category>NDK</category></item><item><title>Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 4편. Apple 플랫폼과 XCFramework</title><link>https://jaemyeong.com/ko/blog/rust-shared-core-04-apple-xcframework/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/rust-shared-core-04-apple-xcframework/</guid><description>Rust 공통 모듈을 iOS 앱에서 쓰기 위해 static library, XCFramework, Swift Package, UniFFI binding을 같은 release unit으로 묶고 Xcode와 CI에서 재현하는 기준을 정리합니다.</description><pubDate>Tue, 30 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;iOS 개발자 입장에서 Rust 공통 모듈 도입은 결국 Xcode가 이해할 수 있는 산출물을 만드는 일로 이어진다.&lt;/p&gt;
&lt;p&gt;Rust에서 테스트가 잘 통과해도 iOS 앱에서 링크되지 않으면 실제 제품에는 의미가 없다. simulator에서는 되는데 device에서 안 되거나, 로컬에서는 되는데 CI에서 안 되거나, Swift Package로 묶었을 때 binary artifact checksum이 맞지 않는 문제도 생길 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 Apple 플랫폼 통합은 Rust 코드 자체보다 빌드 산출물과 배포 단위를 먼저 이해해야 한다. 이 글은 2026-07-01 기준 Apple Developer 문서, Rust Reference, rustc platform support 문서, UniFFI 문서를 바탕으로 Rust core를 iOS 앱에서 쓰기 위한 XCFramework 중심 흐름을 정리한다.&lt;/p&gt;
&lt;h2&gt;Rust library를 Apple 플랫폼 산출물로 만든다&lt;/h2&gt;
&lt;p&gt;Rust는 여러 형태의 library 산출물을 만들 수 있다. 외부 언어에서 링크하기 위한 용도로는 &lt;code&gt;staticlib&lt;/code&gt;이나 &lt;code&gt;cdylib&lt;/code&gt; 같은 crate type을 보게 된다.&lt;/p&gt;
&lt;p&gt;iOS 앱에 Rust core를 넣는 경우에는 static library로 빌드해 XCFramework에 묶는 흐름이 흔하다. device용 &lt;code&gt;aarch64-apple-ios&lt;/code&gt;, simulator용 target을 각각 빌드하고, 필요한 header와 module map을 함께 준비한다. 그다음 &lt;code&gt;xcodebuild -create-xcframework&lt;/code&gt;로 여러 variant를 하나의 framework bundle로 묶는다.&lt;/p&gt;
&lt;p&gt;개념적으로는 이런 흐름이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cargo build --target aarch64-apple-ios
cargo build --target aarch64-apple-ios-sim

libdomain_core.a
headers/
module.modulemap

-&amp;gt; DomainCore.xcframework
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 흐름이 해결하는 것은 Xcode가 device와 simulator slice를 구분해 링크할 수 있는 배포 단위를 만드는 일이다. 아직 남는 한계도 있다. target 설치, toolchain version, build profile, output path, header generation 경로가 개발자 Mac과 CI에서 같아야 한다.&lt;/p&gt;
&lt;p&gt;산출물 이름보다 중요한 것은 재현성이다. 개발자 Mac에서 되는 명령이 CI에서도 같은 결과를 내야 한다. 그래서 Rust target 설치와 XCFramework 생성은 문서에만 남기지 말고 스크립트로 고정하는 편이 좋다.&lt;/p&gt;
&lt;h2&gt;XCFramework는 Xcode가 이해하는 경계다&lt;/h2&gt;
&lt;p&gt;XCFramework는 여러 플랫폼과 아키텍처 variant를 하나로 묶는 binary framework bundle이다. iOS device, iOS simulator, macOS, visionOS처럼 서로 다른 platform slice를 함께 담을 수 있다.&lt;/p&gt;
&lt;p&gt;Rust core를 Apple 앱에 넣을 때도 이 형식이 유리하다. Xcode project나 Swift Package에서 binary dependency로 다루기 쉽고, simulator와 device 전환 시에도 Xcode가 올바른 slice를 선택할 수 있다.&lt;/p&gt;
&lt;p&gt;다만 XCFramework가 있다고 해서 모든 통합 문제가 끝나는 것은 아니다.&lt;/p&gt;
&lt;p&gt;Swift에서 호출하려면 header, module map, generated Swift binding이 같이 맞아야 한다. UniFFI를 사용한다면 Rust library 산출물과 UniFFI가 생성한 Swift 파일의 버전이 맞아야 한다. 이 둘이 어긋나면 compile은 되는데 link 단계에서 깨지거나, runtime에서 symbol mismatch가 날 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 XCFramework 생성 스크립트와 UniFFI binding 생성 스크립트는 같은 release unit으로 관리하는 편이 좋다.&lt;/p&gt;
&lt;h2&gt;Swift Package로 감싸면 배포가 쉬워진다&lt;/h2&gt;
&lt;p&gt;앱 하나에서만 쓴다면 Xcode project에 직접 XCFramework를 넣어도 된다. 하지만 여러 앱, 샘플, 테스트 target에서 같이 쓴다면 Swift Package로 감싸는 방식이 더 깔끔하다.&lt;/p&gt;
&lt;p&gt;Swift Package는 binary target으로 XCFramework를 배포할 수 있다. 앱 프로젝트에서는 package dependency처럼 추가하고, Swift adapter는 별도 target으로 둘 수 있다.&lt;/p&gt;
&lt;p&gt;구조는 대략 이렇게 잡을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DomainEnginePackage/
  Package.swift
  Sources/
    DomainEngine/
      DomainEngineClient.swift
      UniFFIAdapter.swift
      GeneratedBindings.swift
  Artifacts/
    DomainCore.xcframework
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 Swift 앱이 직접 보는 것은 &lt;code&gt;DomainEngineClient&lt;/code&gt;다. &lt;code&gt;GeneratedBindings.swift&lt;/code&gt;와 binary target은 package 내부 구현으로 숨긴다.&lt;/p&gt;
&lt;p&gt;이 방식의 장점은 앱 코드가 Rust 통합 세부사항을 덜 알게 된다는 점이다. Xcode project에 build phase script를 계속 추가하는 방식보다 dependency 경계가 선명해진다.&lt;/p&gt;
&lt;p&gt;하지만 trade-off도 있다. binary artifact를 어디에 둘지, remote zip으로 배포한다면 checksum을 어떻게 관리할지, source package와 binary version을 어떻게 맞출지 결정해야 한다. 사내 앱이라면 repository 내부 artifact로 시작하고, 안정화 이후 별도 release artifact로 분리하는 방식이 현실적일 수 있다.&lt;/p&gt;
&lt;h2&gt;Xcode와 CI에서 같은 경로를 재현해야 한다&lt;/h2&gt;
&lt;p&gt;Apple 플랫폼 통합에서 가장 흔한 문제는 로컬과 CI의 빌드 경로가 다르다는 것이다.&lt;/p&gt;
&lt;p&gt;로컬 개발자는 이미 Rust target을 설치해두었고, 예전에 만든 XCFramework가 남아 있을 수 있다. CI는 매번 fresh clone에서 시작한다. 이 차이를 무시하면 “내 Mac에서는 되는데 CI에서 깨지는” 문제가 반복된다.&lt;/p&gt;
&lt;p&gt;그래서 다음 명령은 하나의 스크립트로 묶는 편이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;install/check rust targets
cargo build for ios device
cargo build for ios simulator
generate UniFFI Swift bindings
generate headers/module map
create XCFramework
run xcodebuild test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 스크립트가 해결하는 것은 빌드 순서와 입력값을 한 곳에 모으는 일이다. 아직 해결하지 못하는 것도 있다. Xcode build phase에서 매 build마다 Rust를 다시 빌드하면 개발 속도가 느려질 수 있다. 로컬에서는 inputs/outputs와 cache를 잡고, CI에서는 clean build를 우선하는 식으로 나누는 편이 현실적이다.&lt;/p&gt;
&lt;p&gt;생성물을 커밋하지 않는다면 더 엄격해야 한다. fresh clone에서 Xcode project를 열고 build했을 때 필요한 산출물이 자동으로 만들어져야 한다. 그렇지 않으면 새로운 팀원이 첫 빌드부터 막힌다.&lt;/p&gt;
&lt;h2&gt;Swift adapter는 필수에 가깝다&lt;/h2&gt;
&lt;p&gt;iOS 앱 코드가 UniFFI generated binding을 직접 호출하게 만들면 초기 구현은 빠르다. 하지만 시간이 지나면 앱 전체가 바인딩 구조에 묶인다.&lt;/p&gt;
&lt;p&gt;Swift adapter를 두면 경계가 훨씬 좋아진다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;protocol SudokuEngineClient {
    func startGame(request: StartGameRequest) throws -&amp;gt; GameSnapshot
    func apply(_ action: GameAction, to snapshot: GameSnapshot) throws -&amp;gt; Transition
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 protocol 뒤에 UniFFI 구현체를 둔다. ViewModel은 protocol만 알고, generated binding은 adapter 내부에서만 사용한다.&lt;/p&gt;
&lt;p&gt;이 구조가 해결하는 것은 앱 코드와 generated binding의 결합을 줄이는 일이다. Rust 호출이 무거우면 adapter에서 async API로 감싸고, UI 업데이트는 MainActor에서 처리하게 만들 수 있다. 테스트에서는 fake client를 넣어 ViewModel을 검증할 수 있다.&lt;/p&gt;
&lt;p&gt;결국 iOS 앱에서 중요한 건 Rust를 호출할 수 있느냐만이 아니다. Rust 호출이 앱의 구조를 흐리지 않게 만드는 것이 더 중요하다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Rust crate type을 Apple 플랫폼 통합에 맞게 정한다.&lt;/li&gt;
&lt;li&gt;device와 simulator target을 모두 빌드하는 스크립트를 만든다.&lt;/li&gt;
&lt;li&gt;XCFramework 생성 과정을 로컬과 CI에서 같은 명령으로 재현한다.&lt;/li&gt;
&lt;li&gt;UniFFI Swift binding과 Rust binary 산출물의 버전을 함께 관리한다.&lt;/li&gt;
&lt;li&gt;Swift Package binary target으로 감쌀지, Xcode project에 직접 넣을지 결정한다.&lt;/li&gt;
&lt;li&gt;generated binding은 Swift adapter 내부에 숨긴다.&lt;/li&gt;
&lt;li&gt;ViewModel은 Swift protocol만 바라보게 한다.&lt;/li&gt;
&lt;li&gt;fresh clone에서 Xcode build가 재현되는지 반드시 확인한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Apple 플랫폼에서 Rust를 쓰는 일은 Rust 자체보다 Xcode 통합이 더 큰 비중을 차지한다.&lt;/p&gt;
&lt;p&gt;XCFramework는 이 경계를 다루기 좋은 배포 단위다. 하지만 XCFramework만 만들었다고 끝은 아니다. Swift binding, adapter, Xcode build phase, Swift Package, CI 재현성까지 같이 봐야 실제 앱에서 안정적으로 쓸 수 있다.&lt;/p&gt;
&lt;p&gt;다음 글에서는 Android에서 Rust &lt;code&gt;.so&lt;/code&gt;를 패키징할 때 확인해야 하는 ABI, Gradle, NDK, 16 KB page size 문제를 정리해보겠다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle&quot;&gt;Apple Developer - Creating a multiplatform binary framework bundle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages&quot;&gt;Apple Developer - Distributing binary frameworks as Swift packages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doc.rust-lang.org/reference/linkage.html&quot;&gt;Rust Reference - Linkage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doc.rust-lang.org/rustc/platform-support/apple-ios.html&quot;&gt;The rustc book - Apple iOS platform support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mozilla.github.io/uniffi-rs/&quot;&gt;Mozilla UniFFI - The UniFFI user guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>iOS</category><category>Xcode</category><category>SwiftPM</category><category>FFI</category></item><item><title>automationmodetool, Xcode UI 테스트의 Automation Mode 암호 프롬프트 정리</title><link>https://jaemyeong.com/ko/blog/xcode-ui-automation-automationmodetool/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/xcode-ui-automation-automationmodetool/</guid><description>macOS에서 Xcode UI 테스트가 Automation Mode 암호 프롬프트에 막힐 때 automationmodetool이 정확히 무엇을 해결하는지 정리한다. TCC/PPPC 권한과 구분해 CI 러너 부트스트랩 위치까지 다룬다.</description><pubDate>Tue, 30 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;macOS에서 Xcode UI 테스트를 CI로 돌릴 때 가장 난감한 실패는 테스트 코드의 실패가 아니다. 테스트가 시작되기도 전에 시스템이 암호를 요구하는 경우다.&lt;/p&gt;
&lt;p&gt;대표적인 로그는 &lt;code&gt;Timed out while enabling automation mode&lt;/code&gt;다. 화면이 붙어 있는 Mac에서는 사람이 암호를 입력하면 지나간다. 하지만 self-hosted runner나 랩 장비에서는 그 순간부터 테스트가 아니라 운영 장애가 된다.&lt;/p&gt;
&lt;p&gt;이 글은 macOS 26.5.1, Xcode 26.6 환경에서 &lt;code&gt;man automationmodetool&lt;/code&gt;을 확인하고, 2026년 7월 1일 기준 공개 문서를 다시 본 뒤 정리했다. 범위는 &lt;code&gt;automationmodetool&lt;/code&gt; 하나다. Accessibility, AppleEvents, keychain, signing, &lt;code&gt;DevToolsSecurity&lt;/code&gt;는 관련은 있지만 이 글의 주제가 아니다.&lt;/p&gt;
&lt;h2&gt;이 도구가 해결하는 문제&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;automationmodetool&lt;/code&gt;은 UI 테스트용 Automation Mode 보안 설정을 관리하는 명령이다.&lt;/p&gt;
&lt;p&gt;로컬 man page와 Xcode man page는 이 도구의 역할을 거의 같은 문장으로 설명한다. 장비를 설정해서 UI testing용 Automation Mode가 사용자 인증 없이 활성화될 수 있게 만든다. CI 장비나 관리자 권한이 없는 사용자가 쓰는 테스트 랩 장비를 준비할 때 쓰는 도구다.&lt;/p&gt;
&lt;p&gt;핵심은 “앱이 Mac을 제어해도 되는가”가 아니다. “이 장비가 UI automation mode에 들어갈 때마다 사람의 암호를 물어야 하는가”다.&lt;/p&gt;
&lt;p&gt;확인 명령은 인자 없이 실행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;automationmodetool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정상적으로 CI에 맞게 준비된 장비라면 이런 식의 상태를 볼 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Automation Mode is disabled.
This device DOES NOT REQUIRE user authentication to enable Automation Mode.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;첫 줄의 &lt;code&gt;disabled&lt;/code&gt;만 보고 실패로 판단하면 안 된다. 여기서 더 중요한 줄은 두 번째다. &lt;code&gt;DOES NOT REQUIRE user authentication&lt;/code&gt;이면 XCTest나 &lt;code&gt;testmanagerd&lt;/code&gt;가 테스트 실행 중 Automation Mode를 켤 때 사람의 암호를 요구하지 않아도 되는 상태다.&lt;/p&gt;
&lt;p&gt;설정 명령은 다음이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo automationmodetool enable-automationmode-without-authentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;되돌리는 명령도 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo automationmodetool disable-automationmode-without-authentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 설정 자체는 관리자 인증이 필요하다. 그래서 아무 PR이나 실행할 수 있는 workflow 안에서 매번 실행할 성격의 명령이 아니다. CI 머신을 준비하는 부트스트랩 단계에서 한 번 처리해야 한다.&lt;/p&gt;
&lt;h2&gt;TCC 권한과 섞으면 진단이 틀어진다&lt;/h2&gt;
&lt;p&gt;macOS에서 UI 테스트가 멈추는 보안 프롬프트는 한 종류가 아니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Xcode Helper&lt;/code&gt;나 테스트 러너가 Accessibility, AppleEvents, Post Event 권한을 요구하는 경우가 있다. 이건 TCC 영역이다. Apple의 PPPC 문서는 Accessibility가 앱이 Accessibility API로 Mac을 제어하도록 허용하고, AppleEvents가 제한된 AppleEvent 전송을 허용하며, Post Event가 CoreGraphics 이벤트 전송을 허용한다고 설명한다.&lt;/p&gt;
&lt;p&gt;반면 &lt;code&gt;automationmodetool&lt;/code&gt;이 다루는 것은 Automation Mode의 사용자 인증 요구다. TCC 권한을 부여하지 않는다. PPPC 프로파일을 설치하지 않는다. Accessibility 권한을 대신 주지도 않는다.&lt;/p&gt;
&lt;p&gt;그래서 아래 두 상황은 다르게 봐야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Xcode Helper does not have permission to use Accessibility.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이건 TCC/Accessibility 문제다. Privacy &amp;amp; Security 설정이나 MDM으로 배포한 PPPC 프로파일을 봐야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Timed out while enabling automation mode.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이건 Automation Mode 인증 게이트일 가능성이 높다. 먼저 &lt;code&gt;automationmodetool&lt;/code&gt; 상태를 확인해야 한다.&lt;/p&gt;
&lt;p&gt;두 문제가 동시에 있을 수도 있다. CI 장비에서 &lt;code&gt;automationmodetool&lt;/code&gt;을 설정했는데도 테스트가 계속 멈춘다면, 그 다음에는 TCC 로그로 실제로 막는 프로세스를 확인해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;log stream --debug --predicate &apos;subsystem == &quot;com.apple.TCC&quot; AND eventMessage BEGINSWITH &quot;AttributionChain&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apple의 PPPC 문서도 이 로그를 책임 주체 확인용으로 제시한다. 추측으로 &lt;code&gt;Xcode.app&lt;/code&gt;이나 앱 under test를 계속 추가하기보다, 실제 attribution chain에 잡히는 프로세스를 봐야 한다.&lt;/p&gt;
&lt;h2&gt;workflow에 넣을 명령이 아니라 장비 설정이다&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;automationmodetool&lt;/code&gt;의 사용 위치는 workflow 본문이 아니라 runner bootstrap이다.&lt;/p&gt;
&lt;p&gt;좋은 위치는 이런 단계다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
sudo automationmodetool enable-automationmode-without-authentication
automationmodetool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 흐름은 “이 Mac은 Xcode UI 테스트를 unattended로 돌릴 장비”라고 정하는 단계다. 반대로 아래처럼 PR마다 실행되는 job에 넣으면 문제가 생긴다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Enable automation mode
  run: sudo automationmodetool enable-automationmode-without-authentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;hosted CI 서비스가 sudo 환경을 이미 제어하고 있고, 그 서비스 문서가 이 방식을 안내한다면 예외가 될 수 있다. Bitrise는 macOS 테스트에서 &lt;code&gt;Timed out while enabling automation mode&lt;/code&gt;를 만났을 때 workflow의 Script Step에서 &lt;code&gt;sudo automationmodetool enable-automationmode-without-authentication&lt;/code&gt;을 실행하라고 안내한다.&lt;/p&gt;
&lt;p&gt;하지만 self-hosted runner라면 판단이 달라진다. workflow가 임의 코드 실행 경로라면 sudo를 열어두는 순간 테스트 안정성 문제가 권한 상승 문제가 된다. 이 경우에는 장비 소유자가 한 번 부트스트랩하고, workflow는 빌드와 테스트만 해야 한다.&lt;/p&gt;
&lt;h2&gt;GitHub Actions 사례에서 보이는 패턴&lt;/h2&gt;
&lt;p&gt;GitHub Actions macOS runner 이미지 이슈에서도 &lt;code&gt;Timed out while enabling automation mode&lt;/code&gt;는 반복적으로 등장했다.&lt;/p&gt;
&lt;p&gt;macOS 12 Monterey 시기의 보고에서는 headless runner에서 UI automation 동의가 필요하지만 상호작용할 사람이 없어서 timeout이 난다고 정리되어 있다. macOS 13과 Xcode 14.3.1 조합에서도 같은 계열의 오류가 보고되었다.&lt;/p&gt;
&lt;p&gt;Flutter 인프라 이슈는 다른 관점에서 같은 문제를 보여준다. XCUITest 기반 integration test를 돌리려면 &lt;code&gt;automationmodetool enable-automationmode-without-authentication&lt;/code&gt;이 필요하지만, 그 명령 자체가 암호 프롬프트를 요구한다는 점이 운영 문제로 남는다.&lt;/p&gt;
&lt;p&gt;여기서 얻을 결론은 단순하다.&lt;/p&gt;
&lt;p&gt;CI에서 매번 암호를 넣어 해결하는 구조는 불안정하다. “암호를 자동 입력하는 expect 스크립트”는 문제를 옮길 뿐이다. runner 계정과 장비 상태를 명시적으로 준비하고, 준비가 끝났는지 job 초반에 읽기 전용으로 확인하는 편이 낫다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Show UI automation state
  run: |
    automationmodetool || true
    xcodebuild -version
    xcode-select -p
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 단계는 설정을 바꾸지 않는다. 실패했을 때 원인을 빨리 좁히기 위한 관찰값만 남긴다.&lt;/p&gt;
&lt;h2&gt;운영 기준&lt;/h2&gt;
&lt;p&gt;self-hosted Mac에서 Xcode UI 테스트를 안정적으로 돌리려면 &lt;code&gt;automationmodetool&lt;/code&gt;은 다음 기준으로 다룬다.&lt;/p&gt;
&lt;p&gt;첫째, runner 이미지를 만들거나 Mac mini를 세팅하는 단계에서 실행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo automationmodetool enable-automationmode-without-authentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;둘째, job에서는 상태만 출력한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;automationmodetool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;셋째, &lt;code&gt;This device DOES NOT REQUIRE user authentication to enable Automation Mode.&lt;/code&gt;가 보이는지 확인한다.&lt;/p&gt;
&lt;p&gt;넷째, 그래도 멈추면 &lt;code&gt;automationmodetool&lt;/code&gt;을 다시 의심하기 전에 프롬프트의 성격을 분류한다. 암호를 요구하면 Automation Mode 쪽이다. 특정 앱이 컴퓨터를 제어해도 되는지 묻거나 Accessibility 권한을 요구하면 TCC/PPPC 쪽이다.&lt;/p&gt;
&lt;p&gt;다섯째, 여러 Xcode 버전이나 여러 runner 계정을 운영한다면 설정 단위를 명확히 나눈다. &lt;code&gt;automationmodetool&lt;/code&gt;은 장비 상태를 바꾸지만, TCC 권한과 keychain 접근은 사용자·프로세스·서명 요구사항의 영향을 받는다. 한 명령으로 전체 macOS UI 테스트 권한 문제가 끝난다고 보면 안 된다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;automationmodetool&lt;/code&gt;은 Xcode UI 테스트에서 Automation Mode 암호 프롬프트를 제거하는 도구다.&lt;/p&gt;
&lt;p&gt;하지만 이 도구는 TCC 권한을 주지 않는다. Accessibility, AppleEvents, Post Event, signing keychain, Developer Tools Access 문제는 별도로 봐야 한다. 그래서 실무 진단 순서는 “모든 권한을 한 번에 허용”이 아니라 “어떤 subsystem이 막고 있는지 분류”가 되어야 한다.&lt;/p&gt;
&lt;p&gt;CI 장비에서는 &lt;code&gt;sudo automationmodetool enable-automationmode-without-authentication&lt;/code&gt;을 runner 부트스트랩에서 실행한다. workflow에는 상태 확인만 남긴다. 그게 이 도구를 안전하게 쓰는 가장 단순한 기준이다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://keith.github.io/xcode-man-pages/automationmodetool.1.html&quot;&gt;automationmodetool(1) Xcode man page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/forums/thread/693850&quot;&gt;Apple Developer Forums - testmanagerd constantly requests password to enable UI Automation on macOS Monterey&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.apple.com/guide/deployment/privacy-preferences-policy-control-payload-dep38df53c2a/web&quot;&gt;Privacy Preferences Policy Control device management payload settings for Apple devices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2025/344/&quot;&gt;Record, replay, and review: UI automation with Xcode - WWDC25&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/actions/runner-images/issues/5410&quot;&gt;GitHub Actions runner-images #5410 - Xcode UI test not working on MacOS 12 Monterey runner&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/actions/runner-images/issues/7778&quot;&gt;GitHub Actions runner-images #7778 - Timed out while enabling automation mode&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/flutter/flutter/issues/166011&quot;&gt;Flutter #166011 - Enable automation without authentication on LUCI macOS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.bitrise.io/en/articles/9676606-enabling-ui-automation-for-macos-tests&quot;&gt;Bitrise Help Center - Enabling UI automation for macOS tests&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>macOS</category><category>Xcode</category><category>XCTest</category><category>CI-CD</category><category>Automation</category></item><item><title>Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 3편. UniFFI로 iOS와 Android 연결하기</title><link>https://jaemyeong.com/ko/blog/rust-shared-core-03-uniffi-mobile/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/rust-shared-core-03-uniffi-mobile/</guid><description>Rust 공통 모듈을 iOS와 Android에서 함께 쓰기 위해 UniFFI 바인딩을 설계하고, generated binding을 adapter 뒤에 숨기는 구조와 모바일 런타임 고려사항을 정리합니다.</description><pubDate>Mon, 29 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Rust core를 iOS와 Android에서 같이 쓰려면 결국 Swift와 Kotlin이 Rust 함수를 호출할 수 있어야 한다.&lt;/p&gt;
&lt;p&gt;직접 C ABI를 설계하고 Swift wrapper와 Kotlin/JNI wrapper를 손으로 작성하는 방법도 가능하다. 작은 실험이라면 그렇게 해도 된다. 하지만 앱이 커지고 API가 늘어나면 wrapper 유지보수 비용이 생각보다 빨리 커진다.&lt;/p&gt;
&lt;p&gt;UniFFI는 이 지점을 해결하기 위한 도구다. Rust 라이브러리에서 외부로 노출할 API를 정의하고, Swift와 Kotlin 바인딩을 생성한다. 모바일 앱 입장에서는 Rust 엔진을 Swift/Kotlin API처럼 호출할 수 있게 된다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 UniFFI를 도입할 때 어떤 구조로 바라보면 좋은지 정리한다.&lt;/p&gt;
&lt;h2&gt;UniFFI가 해주는 일&lt;/h2&gt;
&lt;p&gt;UniFFI의 핵심은 Rust API를 여러 언어 바인딩으로 생성해주는 것이다. Rust 함수나 타입을 외부에 노출하고, 생성된 Swift/Kotlin 코드는 C-compatible FFI 계층을 통해 Rust 라이브러리와 통신한다.&lt;/p&gt;
&lt;p&gt;실무적으로 좋은 점은 명확하다.&lt;/p&gt;
&lt;p&gt;Swift wrapper와 Kotlin wrapper를 각각 손으로 유지하지 않아도 된다. Rust API 변경이 있을 때 바인딩을 다시 생성하면 된다. iOS와 Android가 같은 Rust 함수를 호출하므로 도메인 규칙이 플랫폼마다 갈라질 가능성도 줄어든다.&lt;/p&gt;
&lt;p&gt;하지만 UniFFI가 모든 설계를 대신해주는 것은 아니다.&lt;/p&gt;
&lt;p&gt;어떤 함수를 외부에 노출할지, 어떤 DTO를 쓸지, 에러를 어떻게 표현할지, generated binding을 앱 어디까지 노출할지는 여전히 앱 팀이 결정해야 한다. 특히 Rust 내부 모델을 그대로 공개하면 Swift와 Kotlin 코드가 불편해질 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 UniFFI는 “아키텍처”가 아니라 “바인딩 생성 도구”로 보는 편이 안전하다. 앱 구조의 중심은 여전히 adapter와 domain boundary에 있어야 한다.&lt;/p&gt;
&lt;h2&gt;추천 구조: generated binding은 숨긴다&lt;/h2&gt;
&lt;p&gt;UniFFI를 도입할 때 가장 피하고 싶은 구조는 ViewModel이나 ViewController가 generated binding을 직접 호출하는 것이다.&lt;/p&gt;
&lt;p&gt;처음에는 간단하다. 함수 하나를 호출하고 결과를 받으면 된다. 하지만 generated type이 앱 전역에 퍼지는 순간 변경 비용이 커진다. Rust API를 조금만 바꿔도 Swift UI 코드와 Kotlin UI 코드가 같이 흔들린다.&lt;/p&gt;
&lt;p&gt;그래서 각 플랫폼에는 얇은 adapter를 둔다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;iOS
  SudokuEngineClient protocol
    -&amp;gt; UniFFISudokuEngineClient
      -&amp;gt; generated Swift binding

Android
  SudokuEngineClient interface
    -&amp;gt; UniFFISudokuEngineClient
      -&amp;gt; generated Kotlin binding
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조에서 앱의 ViewModel은 generated binding을 모른다. Swift에서는 앱 내부 DTO나 domain type만 보고, Android에서는 Kotlin data class만 본다. generated binding은 adapter 내부 구현 세부사항이 된다.&lt;/p&gt;
&lt;p&gt;이 방식은 테스트에도 유리하다. ViewModel 테스트에서는 fake &lt;code&gt;SudokuEngineClient&lt;/code&gt;를 넣으면 된다. Rust 엔진을 실제로 호출하는 integration test는 별도로 둔다.&lt;/p&gt;
&lt;h2&gt;Rust API는 모바일에서 호출하기 쉬워야 한다&lt;/h2&gt;
&lt;p&gt;UniFFI를 쓴다고 해서 Rust API가 자동으로 좋은 모바일 API가 되는 것은 아니다.&lt;/p&gt;
&lt;p&gt;모바일 앱에서 중요한 것은 호출 경계다. 화면이 그려질 때마다 Rust를 자주 호출하는 구조는 피하는 편이 좋다. 사용자 액션 하나가 들어왔을 때 Rust에 현재 상태와 action을 넘기고, 다음 상태를 한 번에 받는 모델이 낫다.&lt;/p&gt;
&lt;p&gt;예를 들어 이런 API가 더 안정적이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;start_game(request_json: String) -&amp;gt; String
apply_action(snapshot_json: String, action_json: String) -&amp;gt; String
validate_snapshot(snapshot_json: String) -&amp;gt; String
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;물론 모든 프로젝트가 JSON string을 써야 하는 것은 아니다. UniFFI가 지원하는 record, enum, sequence를 사용할 수도 있다. 다만 외부 공개 타입이 복잡해질수록 Swift/Kotlin 양쪽에서 같은 의미로 유지하기 어려워진다.&lt;/p&gt;
&lt;p&gt;개인적으로는 초기에는 단순한 DTO와 fixture를 우선한다. 성능 문제가 실제로 확인되면 그때 더 세밀한 타입으로 바꾼다. 성능을 이유로 처음부터 FFI 표면을 복잡하게 만들면, 정작 병목은 다른 곳에 있을 가능성도 크다.&lt;/p&gt;
&lt;h2&gt;iOS와 Android의 차이를 인정해야 한다&lt;/h2&gt;
&lt;p&gt;UniFFI는 양쪽 바인딩을 만들어주지만, iOS와 Android 앱 구조가 같아지는 것은 아니다.&lt;/p&gt;
&lt;p&gt;iOS에서는 Swift concurrency, MainActor, UIKit/SwiftUI state update 흐름을 고려해야 한다. Rust 호출이 CPU 비용이 크다면 main thread에서 직접 호출하지 않는 편이 좋다. adapter에서 background queue나 async boundary를 정리하고, UI 업데이트는 main actor로 돌아오게 해야 한다.&lt;/p&gt;
&lt;p&gt;Android에서는 ViewModel scope, coroutine dispatcher, Compose state update, native library loading 순서를 봐야 한다. Rust &lt;code&gt;.so&lt;/code&gt;가 제대로 로드되지 않으면 앱 시작 시점이나 첫 호출 시점에 실패할 수 있다. ABI별 packaging도 같이 확인해야 한다.&lt;/p&gt;
&lt;p&gt;즉 UniFFI의 generated binding은 공통일 수 있지만, 앱에 붙이는 방식은 플랫폼답게 달라져야 한다.&lt;/p&gt;
&lt;p&gt;이 지점에서 adapter가 다시 중요해진다. generated binding은 같아도 adapter 내부 구현은 iOS와 Android의 런타임 특성에 맞춰 다르게 가져갈 수 있다.&lt;/p&gt;
&lt;h2&gt;generated code는 빌드 산출물로 볼지 결정해야 한다&lt;/h2&gt;
&lt;p&gt;UniFFI binding을 생성하면 Swift/Kotlin 파일이 생긴다. 이 파일을 커밋할지, 빌드 때마다 생성할지는 팀마다 선택이 갈린다.&lt;/p&gt;
&lt;p&gt;생성물을 커밋하면 Xcode나 Android Studio에서 바로 탐색하기 쉽고, PR diff로 API 변화를 볼 수 있다. 반대로 generated code diff가 크고 노이즈가 될 수 있다.&lt;/p&gt;
&lt;p&gt;빌드 때마다 생성하면 source of truth가 Rust 쪽에 명확히 남는다. 대신 fresh clone에서 반드시 생성 스크립트가 동작해야 한다. CI도 같은 경로를 재현해야 한다. 개발자 환경에 UniFFI CLI나 Rust toolchain 버전 차이가 있으면 빌드가 흔들릴 수 있다.&lt;/p&gt;
&lt;p&gt;어느 쪽이든 기준이 필요하다.&lt;/p&gt;
&lt;p&gt;나는 앱 프로젝트에서는 초기에 생성물을 커밋하는 편도 나쁘지 않다고 본다. 특히 iOS/Xcode 통합이 아직 안정되지 않은 단계라면 generated Swift 파일을 눈으로 볼 수 있는 장점이 있다. 대신 생성 명령을 문서화하고, CI에서 “재생성했을 때 diff가 없는지” 확인하면 좋다.&lt;/p&gt;
&lt;p&gt;라이브러리 성격이 강하거나 generated code 노이즈가 너무 크다면 빌드 생성 방식이 더 맞을 수 있다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;UniFFI를 아키텍처가 아니라 바인딩 생성 도구로 본다.&lt;/li&gt;
&lt;li&gt;Rust 내부 모델을 그대로 Swift/Kotlin에 노출하지 않는다.&lt;/li&gt;
&lt;li&gt;generated binding은 iOS/Android adapter 내부에 숨긴다.&lt;/li&gt;
&lt;li&gt;ViewModel과 UI는 앱 내부 DTO만 보게 한다.&lt;/li&gt;
&lt;li&gt;CPU 비용이 큰 Rust 호출은 main thread에서 직접 실행하지 않는다.&lt;/li&gt;
&lt;li&gt;Android에서는 native library loading과 ABI packaging을 같이 확인한다.&lt;/li&gt;
&lt;li&gt;generated code를 커밋할지, 빌드 때 생성할지 팀 기준을 정한다.&lt;/li&gt;
&lt;li&gt;CI에서 binding generation을 반드시 재현한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;UniFFI는 Rust core를 iOS와 Android에서 공유하게 해주는 꽤 실용적인 도구다. 하지만 도구가 경계를 대신 설계해주지는 않는다.&lt;/p&gt;
&lt;p&gt;중요한 것은 generated binding을 앱 전체에 퍼뜨리지 않는 것이다. Swift와 Kotlin 앱은 각자 플랫폼다운 구조를 유지하고, Rust 엔진은 adapter 뒤에 숨긴다. 그렇게 해야 Rust core가 바뀌어도 UI와 앱 구조가 과하게 흔들리지 않는다.&lt;/p&gt;
&lt;p&gt;다음 글에서는 iOS 개발자 관점에서 Rust 산출물을 Xcode가 이해할 수 있는 XCFramework로 묶는 흐름을 정리해보겠다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://mozilla.github.io/uniffi-rs/&quot;&gt;Mozilla UniFFI - The UniFFI user guide&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;UniFFI가 Rust 라이브러리에서 Swift/Kotlin 바인딩을 생성하는 방식과 기본 사용 흐름을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.android.com/ndk/guides/jni-tips&quot;&gt;Android Developers - JNI tips&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Android에서 Kotlin/Java 코드와 native library가 상호작용하는 JNI 경계의 기본 개념을 확인했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://doc.rust-lang.org/reference/linkage.html&quot;&gt;Rust Reference - Linkage&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rust 라이브러리를 외부 언어에서 사용할 수 있는 library crate type과 linkage 개념을 확인했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>iOS</category><category>Android</category><category>UniFFI</category><category>FFI</category></item><item><title>macOS 디버깅 권한 오류와 DevToolsSecurity -enable 작동 방식</title><link>https://jaemyeong.com/ko/blog/macos-devtoolssecurity-enable-developer-mode/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/macos-devtoolssecurity-enable-developer-mode/</guid><description>macOS에서 디버깅 도구를 사용하거나 CI/CD 러너를 빌드할 때 마주치는 디버깅 권한 팝업을 차단하고, DevToolsSecurity가 내부 인증 데이터베이스를 어떻게 조작하는지 상세히 분석합니다.</description><pubDate>Thu, 25 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Xcode나 터미널에서 C/C++ 또는 Swift 코드를 빌드하고 디버거를 연결하려 할 때, 혹은 성능 측정을 위해 Instruments를 켤 때 매번 &quot;Developer Tools Access가 다른 프로세스를 제어하려고 합니다&quot;라는 성가신 암호 입력 팝업창을 만난 적이 있을 것이다.&lt;/p&gt;
&lt;p&gt;로컬 개발 환경이라면 암호를 한 번 치고 넘어가면 그만이지만, 관리자가 상주하지 않는 헤드리스 CI/CD 환경이나 자동화 테스팅 런타임에서는 이 인증 팝업이 빌드 파이프라인 전체를 무한 대기 상태로 마비시키는 치명적인 블로커가 된다.&lt;/p&gt;
&lt;p&gt;이러한 문제를 사전 예방하기 위해 실행하는 명령어가 바로 &lt;code&gt;sudo DevToolsSecurity -enable&lt;/code&gt;이다. 이 유틸리티가 시스템 내부에서 어떤 권한을 수정하고 무엇을 가능하게 만드는지, 그리고 실무에서 마주치는 보안 정책과의 트레이드오프까지 깊이 있게 짚어본다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1. macOS 디버거와 task_for_pid의 관계&lt;/h2&gt;
&lt;p&gt;macOS는 운영체제 설계 레벨에서 사용자 프로세스 간의 메모리 격리를 엄격히 통제한다. 특정 프로세스가 임의로 다른 프로세스의 실행 흐름을 멈추거나 메모리 값을 읽고 쓰는 주입(Injection) 공격을 방지하기 위해서다.&lt;/p&gt;
&lt;p&gt;하지만 디버거(&lt;code&gt;lldb&lt;/code&gt; 등)나 성능 프로파일러(&lt;code&gt;Instruments&lt;/code&gt; 등)는 대상 앱 프로세스를 제어하고 메모리 세부 구조를 뜯어봐야만 하는 모순적인 요구사항을 가진다.&lt;/p&gt;
&lt;p&gt;이를 해결하기 위해 macOS 커널(XNU)은 타겟 프로세스의 Mach task 포트를 반환하는 &lt;strong&gt;&lt;code&gt;task_for_pid&lt;/code&gt;&lt;/strong&gt; 시스템 콜을 제공한다. 이 시스템 콜을 호출해 타겟 프로세스의 제어권을 얻어야 비로소 줄 단위 디버깅나 메모리 덤프 추적이 가능해진다.&lt;/p&gt;
&lt;p&gt;당연하게도 &lt;code&gt;task_for_pid&lt;/code&gt;는 극도로 민감한 시스템 호출이기에 권한 검증 절차가 매우 까다롭다. 권한이 없는 프로세스가 이를 호출하면 시스템은 즉각 거부하거나, 사용자에게 관리자(root) 패스워드를 요구하는 보안 프롬프트를 노출시킨다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. DevToolsSecurity가 해결하는 것과 그 내부 원리&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;/usr/sbin/DevToolsSecurity&lt;/code&gt; 도구는 바로 이 &lt;code&gt;task_for_pid&lt;/code&gt; 호출 권한을 개발자가 서명한 도구에 한해 패스워드 검증 없이 통과하도록 시스템 인증 정책을 조작하는 역할을 수행한다.&lt;/p&gt;
&lt;h3&gt;인증 데이터베이스 (Authorization Database) 제어&lt;/h3&gt;
&lt;p&gt;macOS는 권한 상승 및 접근 승인 규칙들을 &lt;code&gt;/var/db/auth.db&lt;/code&gt;라는 중앙 인증 데이터베이스를 통해 관리한다.
&lt;code&gt;DevToolsSecurity -enable&lt;/code&gt;을 실행하면, 시스템은 이 데이터베이스 안에 정의되어 있는 &lt;strong&gt;&lt;code&gt;system.privilege.taskport&lt;/code&gt;&lt;/strong&gt; 규칙을 수정한다.&lt;/p&gt;
&lt;p&gt;보통 기본값 상태의 &lt;code&gt;system.privilege.taskport&lt;/code&gt; 규칙은 요청 시 매번 사용자 로그인 암호를 요구하거나 root 권한을 증명할 것을 요구한다. 하지만 개발자 모드가 활성화되면 이 규칙의 클래스가 &lt;code&gt;rule&lt;/code&gt;로 변경되고, 그에 종속된 권한 부여 대상으로 &lt;code&gt;_developer&lt;/code&gt; 시스템 그룹이 등록된다.&lt;/p&gt;
&lt;p&gt;그 결과, &lt;code&gt;_developer&lt;/code&gt; 그룹의 멤버가 Apple 개발자 계정으로 코드 서명(Code Sign)된 디버깅 도구를 실행하는 경우에 한하여 시스템이 암호 입력 없이도 즉각 &lt;code&gt;task_for_pid&lt;/code&gt; 접근을 허용하는 메커니즘이 완성된다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 개발자 그룹 &lt;code&gt;_developer&lt;/code&gt; 소속 확인과 강제 등록&lt;/h2&gt;
&lt;p&gt;가끔 &lt;code&gt;sudo DevToolsSecurity -enable&lt;/code&gt; 명령을 성공적으로 쳤음에도 불구하고 터미널에서 &lt;code&gt;lldb&lt;/code&gt;를 올릴 때나 VS Code에서 C++ 디버거를 작동시킬 때 계속해서 권한 상승 팝업이 노출되거나 &lt;code&gt;attach failed&lt;/code&gt; 에러를 뿜는 경우가 있다.&lt;/p&gt;
&lt;p&gt;이것은 현재 터미널을 사용하고 있는 사용자 계정(User Account)이 시스템의 개발자 그룹인 **&lt;code&gt;_developer&lt;/code&gt;**에 포함되어 있지 않아서 발생하는 전형적인 문제다.&lt;/p&gt;
&lt;p&gt;이를 해결하기 위해서는 다음과 같이 디렉토리 서비스 명령행 도구인 &lt;code&gt;dscl&lt;/code&gt;을 이용하여 사용자의 숏네임(Short Username)을 &lt;code&gt;_developer&lt;/code&gt; 그룹 멤버십 목록에 직접 추가해 주어야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 현재 로그인된 계정을 _developer 그룹에 강제 멤버십 추가
sudo dscl . append /Groups/_developer GroupMembership $(whoami)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그룹 추가가 끝나면 새로운 권한 테이블이 프로세스 환경에 반영되도록 터미널 창을 완전히 닫았다가 다시 열거나, 시스템 로그아웃 후 로그인을 수행하는 것을 추천한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. CI/CD 빌드 머신과 테스팅 자동화에서의 필수 설정&lt;/h2&gt;
&lt;p&gt;실무적으로 모바일 앱을 배포하는 팀이라면 Mac Mini나 클라우드 상의 macOS 가상머신을 활용해 자동화 러너(Runner)를 구축하게 된다.&lt;/p&gt;
&lt;p&gt;만약 구축 대상 머신에 Xcode를 새로 깔았거나 시스템 업데이트를 진행한 뒤, 이 &lt;code&gt;DevToolsSecurity&lt;/code&gt; 조치를 생략하면 어떻게 될까?&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;배포 정지 현상:&lt;/strong&gt; Appium이나 Xcode UI Test(XCUI)가 실행되면서 기기 또는 시뮬레이터에 테스트 앱을 밀어 넣고 디버깅 프로토콜을 바인딩하려고 시도한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;헤드리스 락(Lock):&lt;/strong&gt; 시스템 내부적으로 &lt;code&gt;task_for_pid&lt;/code&gt; 사용 허가 팝업 창이 GUI 백그라운드에 생성되지만, 모니터가 없거나 헤드리스 터미널로 구동 중인 러너는 이에 응답하지 못한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;타임아웃 실패:&lt;/strong&gt; 결국 파이프라인은 팝업 입력을 기다리다 정해진 타임아웃 시간에 도달해 강제 종료된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;따라서 Mac 빌드 서버의 초기 부트스트랩 스크립트나 프로비저닝 단계에서는 아래 두 명령어가 필수적으로 포함되어 사전 실행되어야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 개발자 도구 액세스 허용 활성화
sudo /usr/sbin/DevToolsSecurity -enable

# 빌드 실행 에이전트 계정을 개발자 그룹에 병합
sudo dscl . append /Groups/_developer GroupMembership build_agent_user
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 보안적 측면의 트레이드오프&lt;/h2&gt;
&lt;p&gt;이 기능은 확실히 디버깅 환경의 생산성을 비약적으로 높여 주지만, 컴퓨터의 보안 경계를 일정 수준 완화하는 조치임을 인지해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;system.privilege.taskport&lt;/code&gt;가 느슨해지면 동일한 사용자가 실행한 임의의 비인증 프로세스가 다른 정상 프로세스의 메모리 자원에 침투해 조작할 수 있는 취약한 통로가 확보될 수 있다. 따라서 개발 장비의 물리적 보안을 엄격히 통제하고, 신뢰할 수 없는 외부 바이너리가 개발 머신 내부에서 실행되지 않도록 보수적인 위생 관리를 병행해야 한다.&lt;/p&gt;
&lt;h3&gt;iOS 16+의 &apos;개발자 모드&apos;와 다른 점&lt;/h3&gt;
&lt;p&gt;많은 개발자들이 혼동하는 부분 중 하나는 iOS 16 이상 탑재 기기 설정 화면에서 켜는 &apos;개발자 모드(Developer Mode)&apos;와의 차이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;iOS 16+ 개발자 모드:&lt;/strong&gt; 디바이스 내부에서 신뢰되지 않은 비공식 서명 앱(사이드로딩 앱)을 실행할 수 있도록 허가하는 클라이언트 단말 레벨의 스위치다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;macOS DevToolsSecurity:&lt;/strong&gt; Mac 컴퓨터 자체의 개발 환경에서 &lt;code&gt;lldb&lt;/code&gt; 같은 도구가 메모리 내부를 제어할 수 있도록 커널 접근 규칙을 열어 주는 설정이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;두 모드는 해결하려는 도메인과 대상 하드웨어가 완전히 분리된 별개의 보안 프로토콜이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;현재 개발 모드 활성 상태 조사:&lt;/strong&gt; 터미널을 열고 &lt;code&gt;/usr/sbin/DevToolsSecurity&lt;/code&gt;를 인자 없이 호출하여 시스템이 &lt;code&gt;enabled&lt;/code&gt; 상태인지 확인한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;사용자 그룹 확인:&lt;/strong&gt; &lt;code&gt;dscl . read /Groups/_developer GroupMembership&lt;/code&gt;을 쳐서 본인의 계정 이름이 포함되어 있는지 확인한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CI/CD 환경 설정 반영:&lt;/strong&gt; 온프레미스 빌드 장비를 구성할 때 러너 설치 가이드 문서에 &lt;code&gt;sudo DevToolsSecurity -enable&lt;/code&gt; 구문을 필수 설치 작업으로 포함시킨다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;개발용 장비 보안 단속:&lt;/strong&gt; 편리함을 위해 권한 정책을 완화한 만큼, 개발용 Mac 장비에 검증되지 않은 크랙 소프트웨어나 모르는 소스 코드의 바이너리를 검증 없이 그냥 실행하지 않는 예방 조치를 습관화한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;현대적인 소프트웨어 개발, 특히 크로스 플랫폼 테스팅과 지속적인 통합(CI) 환경에서 보안의 연속성을 보장하면서도 막힘없는 빌드 파이프라인을 유지하는 것은 매우 까다로운 균형 잡기다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;DevToolsSecurity&lt;/code&gt; 명령어는 macOS의 보안 설계인 &lt;code&gt;task_for_pid&lt;/code&gt;와 충돌을 빚는 개발자 도구들을 유기적으로 이어주는 공식적이고 안전한 우회 통로다. 작동 방식과 내부 그룹 권한의 구조를 정확히 이해해 둔다면 예기치 않은 디버깅 실패나 CI 서버 마비 상황에서도 당황하지 않고 정확한 대응책을 만들어 낼 수 있을 것이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://support.apple.com/ko-kr/guide/security/sec843b0928a/web&quot;&gt;Apple Platform Security - Process security on macOS&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;macOS 상에서 프로세스 간 메모리 침투 방지 원리 및 task_for_pid 보안 통제 정책 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/security/authorization_services&quot;&gt;Apple Developer - Authorization Services&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;macOS의 시스템 권한 검증 및 인증 데이터베이스(Authorization Database) 제어 라이브러리 구조 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://formulae.brew.sh/&quot;&gt;Homebrew Formulae - security&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;명령행에서 security authorizationdb 도구를 조작하는 CLI 용례 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jetbrains.com/help/clion/debugging-on-macos.html&quot;&gt;JetBrains Help - CLion Debugging on macOS&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;macOS 환경 디버깅 중 LLDB 연결 실패 에러와 DevToolsSecurity 설정 가이드 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>macOS</category><category>Xcode</category><category>DeveloperMode</category><category>Debugging</category><category>CI-CD</category></item><item><title>Mac에서 Windows로 보낸 한글 파일명이 깨지는 이유: 자소 분리(NFD) 현상과 convmv 해결책</title><link>https://jaemyeong.com/ko/blog/macos-hangul-jaso-nfd-nfc/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/macos-hangul-jaso-nfd-nfc/</guid><description>macOS에서 한글 파일명이 자소 분리(NFD)되어 Windows에서 깨지는 원인과 convmv를 사용한 해결 방법을 정리합니다.</description><pubDate>Thu, 25 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Mac을 메인 개발 장비로 사용하면서 Windows나 Linux 환경의 협업자들과 파일을 주고받을 때, 혹은 크로스 플랫폼 빌드 파이프라인을 구축할 때 누구나 한 번쯤 겪는 골치 아픈 문제가 있다. 바로 한글 파일명이 &lt;code&gt;ㅎㅏㄴㄱㅡㄹ.txt&lt;/code&gt;처럼 자음과 모음이 하나씩 분리되어 깨지는 현상이다.&lt;/p&gt;
&lt;p&gt;단순히 파일명이 보기 싫게 깨지는 것에 그치지 않고, 형상 관리 도구(Git)가 파일명이 바뀌었다고 인식해 혼란을 주거나 로컬에서 잘 돌아가던 빌드 스크립트가 파이프라인에서 파일 경로를 찾지 못해 실패하는 등 실무적인 개발 워크플로우에 직접적인 오작동을 유발한다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 macOS 파일 시스템의 특징인 자소 분리(NFD) 현상의 기술적 배경을 살펴보고, 터미널 환경에서 &lt;code&gt;convmv&lt;/code&gt; 도구를 활용해 이 문제를 우아하게 해결하는 방법과 코드 수준에서 대응하는 가이드를 정리해 본다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 왜 Mac에서 만든 파일은 Windows에서 풀어헤쳐질까: NFD vs NFC&lt;/h2&gt;
&lt;p&gt;이 문제의 근본적인 원인은 운영체제(OS)가 유니코드(Unicode) 문자열을 정규화(Normalization)하는 표준 방식의 차이에 있다. 유니코드 표준은 한글처럼 결합이 가능한 문자를 저장하고 렌더링하기 위해 크게 두 가지 정규화 공식을 정의하고 있다.&lt;/p&gt;
&lt;h3&gt;NFD (Normalization Form Decomposition - 자소 분리형)&lt;/h3&gt;
&lt;p&gt;NFD는 하나의 완성된 글자를 초성, 중성, 종성이라는 각각의 자모 코드 포인트로 분해하여 저장하는 방식이다. macOS는 시스템 파일 시스템 레벨(과거 HFS+부터 현재의 APFS까지)에서 이 NFD 정규화를 기본 규격으로 채택하고 있다.
예를 들어 Mac에서 &lt;code&gt;한&lt;/code&gt;이라는 글자로 파일명을 저장하면, 파일 시스템 내부에는 문자 &lt;code&gt;한&lt;/code&gt;이 아니라 &lt;code&gt;ㅎ&lt;/code&gt;(U+1106) + &lt;code&gt;ㅏ&lt;/code&gt;(U+1161) + &lt;code&gt;ㄴ&lt;/code&gt;(U+11AB)의 조합으로 데이터가 기록된다. macOS 화면에서는 이를 다시 실시간으로 합쳐서 보여주므로 Mac 사용자 본인은 깨짐을 인지하지 못한다.&lt;/p&gt;
&lt;h3&gt;NFC (Normalization Form Canonical Composition - 자소 결합형)&lt;/h3&gt;
&lt;p&gt;NFC는 결합 가능한 자모음들을 하나의 완성된 단일 문자 코드 포인트로 결합하여 저장하는 방식이다. Windows와 대부분의 Linux 파일 시스템은 이 NFC 방식을 표준으로 삼고 있다.
예를 들어 Windows에서 &lt;code&gt;한&lt;/code&gt;을 저장하면 내부적으로 하나의 코드 포인트인 &lt;code&gt;한&lt;/code&gt;(U+D55C)으로 깔끔하게 저장된다.&lt;/p&gt;
&lt;h3&gt;정규화 충돌이 만드는 파장&lt;/h3&gt;
&lt;p&gt;문제는 Mac에서 생성된 NFD 기반의 파일이 메일 첨부, 메신저 전송, 압축 파일(ZIP) 혹은 클라우드 업로드 등을 통해 Windows 환경으로 넘어갈 때 발생한다. Windows 시스템은 NFD 형태로 풀어헤쳐진 파일명을 수신했을 때 이를 자동으로 호환성이 높은 NFC 포맷으로 재구성해주지 않는다. 파일 시스템에 적힌 코드 포인트 그대로 문자 렌더링 엔진에 넘겨버리므로, Windows 사용자의 화면에는 &lt;code&gt;ㅎㅏㄴㄱㅡㄹ&lt;/code&gt;처럼 자모고 분리된 형태가 그대로 노출되는 것이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 크로스 플랫폼 개발 환경에서 발생하는 자소 분리 이슈&lt;/h2&gt;
&lt;p&gt;단순히 문서 파일명이 깨져서 상대방에게 부끄러워지는 수준이라면 해프닝으로 넘길 수 있다. 하지만 개발 워크플로우 내에서 이 정규화 방식의 불일치는 예상치 못한 문제를 야기한다.&lt;/p&gt;
&lt;h3&gt;Git 형상 관리에서의 혼선&lt;/h3&gt;
&lt;p&gt;Git은 기본적으로 파일명을 바이너리 바이트 단위로 인식하여 추적한다. Mac 개발자가 로컬에서 &lt;code&gt;검색화면.png&lt;/code&gt;라는 파일을 추가해 푸시했는데, Windows 개발자가 이를 풀(Pull)받아 변경 사항을 적용하는 과정에서 파일명이 NFC 형태로 덮어써지면, Git은 파일 내용이 전혀 바뀌지 않았음에도 파일명이 미세하게 변경(NFD → NFC)된 것으로 감지하여 변경 이력(Diff)을 새로 생성한다. 이로 인해 불필요한 커밋이 쌓이고 충돌(Conflict)이 발생하기 쉬운 환경이 된다.&lt;/p&gt;
&lt;h3&gt;빌드 파이프라인과 CI/CD 장애&lt;/h3&gt;
&lt;p&gt;웹 프론트엔드나 iOS 앱 빌드 과정에서 이미지 리소스나 마크다운 콘텐츠 파일 이름을 한글로 명명하는 경우가 종종 있다. Mac 로컬 장비에서는 번들이 정상적으로 묶이고 실행되던 프로젝트가, 리눅스 기반의 컨테이너 환경(GitHub Actions 등)으로 구성된 CI/CD 파이프라인에 들어가면 특정 리소스 파일을 읽지 못하는 에러를 뱉으며 빌드 에러가 나곤 한다. 이는 소스 코드 내부 문자열 상수(NFC 형식의 &lt;code&gt;&quot;검색화면.png&quot;&lt;/code&gt;)와 파일 시스템의 실제 이름(NFD 형식의 &lt;code&gt;ㄱㅓㅁㅅㅐㄱㅎㅘㅁㅕㄴ.png&lt;/code&gt;)의 해시값(바이트 배열)이 일치하지 않아 발생한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 터미널에서 우아하게 파일명 정규화하기: convmv 활용법&lt;/h2&gt;
&lt;p&gt;로컬 환경이나 빌드 배포 단계에서 자소 분리된 한글 파일명을 일괄적으로 Windows 호환(NFC) 포맷으로 변환해 주는 표준적인 터미널 도구가 바로 &lt;code&gt;convmv&lt;/code&gt;이다. 이 도구는 파일 인코딩 및 유니코드 정규화 상태를 실시간으로 모니터링하고 일괄 수정하는 데 매우 유용하다.&lt;/p&gt;
&lt;h3&gt;1단계: convmv 설치&lt;/h3&gt;
&lt;p&gt;macOS 환경에서 터미널을 열고 패키지 관리 도구인 Homebrew를 통해 간단하게 설치할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brew install convmv
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2단계: 파일명 복구 테스트 실행 (Dry-run)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;convmv&lt;/code&gt;는 파일 시스템의 이름을 직접 변경하는 위험성이 있는 도구이므로, 실행 전 미리 변경 예상 목록을 안전하게 보여주는 시뮬레이션 기능이 기본값으로 작동한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;convmv -f utf8 -t utf8 --nfc -r [변경을_원하는_디렉토리_경로]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-f utf8 -t utf8&lt;/code&gt;: UTF-8 포맷의 인코딩 상태를 그대로 유지하겠다는 선언이다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--nfc&lt;/code&gt;: 파일명의 정규화 규칙을 완성형(NFC) 형태로 바꾸겠다는 핵심 옵션이다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-r&lt;/code&gt;: 지정된 디렉토리 하위의 모든 폴더와 파일을 탐색하여 재귀적으로 처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;명령어를 실행하면 실제로 파일명이 바뀐 것은 아니지만 아래처럼 미리 변경 대상 목록을 스크린에 출력해 준다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mv &quot;./src/assets/ㄱㅓㅁㅅㅐㄱㅎㅘㅁㅕㄴ.png&quot;	&quot;./src/assets/검색화면.png&quot;
No changes to your files performed. Use --notest to run for real.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3단계: 실제 파일명 강제 변경 적용&lt;/h3&gt;
&lt;p&gt;시뮬레이션 결과를 확인하고 변경 사항이 안전하다고 판단되면 명령어 끝에 &lt;code&gt;--notest&lt;/code&gt; 플래그를 덧붙여 실제 파일 시스템 반영을 강제 실행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;convmv -f utf8 -t utf8 --nfc -r --notest [변경을_원하는_디렉토리_경로]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 단계를 거치고 나면 폴더 내 분리되어 있던 모든 한글 파일들이 완성형 문자 코드로 정상 결합되어, Windows나 빌드 컨테이너 환경으로 옮겨가도 절대 깨지지 않는 상태가 된다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Finder &apos;빠른 동작&apos;에 convmv 스크립트 심기&lt;/h2&gt;
&lt;p&gt;매번 터미널을 켜서 경로를 입력하는 것은 생산성 관점에서 비효율적이다. macOS의 내장 기능인 Automator를 활용하면 파일 매니저(Finder)에서 깨진 파일이나 폴더를 마우스 우클릭하는 것만으로 손쉽게 NFC로 변환할 수 있는 시스템 단축 동작을 만들 수.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Mac에서 &lt;strong&gt;Automator (자동화)&lt;/strong&gt; 앱을 실행한 뒤 문서 유형으로 **&apos;빠른 동작(Quick Action)&apos;**을 생성한다.&lt;/li&gt;
&lt;li&gt;워크플로우 상단에서 &apos;현재 수신하는 작업흐름&apos;을 **&apos;파일 또는 폴더&apos;**로 선택하고, 대상 애플리케이션을 **&apos;Finder&apos;**로 지정한다.&lt;/li&gt;
&lt;li&gt;동작 라이브러리 목록에서 **&apos;셸 스크립트 실행(Run Shell Script)&apos;**을 검색해 오른쪽 레이아웃으로 드래그한다.&lt;/li&gt;
&lt;li&gt;통과 입력(Pass input) 설정을 &apos;입력값(stdin)&apos; 대신 **&apos;인수로(as arguments)&apos;**로 바꾼다.&lt;/li&gt;
&lt;li&gt;스크립트 입력창에 아래 코드를 복사하여 기입한다. (M1 이상 Apple Silicon Mac의 Homebrew 경로 기본값인 &lt;code&gt;/opt/homebrew&lt;/code&gt;를 기준으로 작성되었다.)&lt;pre&gt;&lt;code&gt;for i in &quot;$@&quot;; do
    /opt/homebrew/bin/convmv -f utf-8 -t utf-8 --nfc --notest &quot;$i&quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;워크플로우 이름을 &lt;code&gt;한글 자소 합치기&lt;/code&gt; 등의 명확한 명칭으로 저장한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이제 Finder에서 자소 분리가 의심되는 임의의 파일이나 폴더를 선택하고 우클릭한 뒤, **[빠른 동작] → [한글 자소 합치기]**를 선택해 주면 백그라운드에서 즉시 안전하게 NFC 변환이 처리된다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 코드 수준에서의 예방: iOS 및 웹 개발자를 위한 유니코드 정규화&lt;/h2&gt;
&lt;p&gt;빌드나 파일 시스템 차원을 넘어, 직접 작성하는 코드 내부에서 유니코드 정규화 불일치를 예방하는 방법도 명확하게 인지해 두어야 한다. 사용자가 Mac 환경에서 웹 브라우저나 앱 인풋 창을 통해 업로드한 파일 이름 혹은 텍스트 정보를 그대로 백엔드 DB나 스토리지에 적재할 경우, 데이터 정합성에 틈이 생길 수 있기 때문이다.&lt;/p&gt;
&lt;h3&gt;Swift (iOS / macOS App Development)&lt;/h3&gt;
&lt;p&gt;Foundation 프레임워크 내 &lt;code&gt;NSString&lt;/code&gt; 클래스는 문자열 정규화를 쉽게 처리해 주는 메서드를 내장하고 있다. iOS 개발자 입장에서는 사용자가 입력한 문자열이나 로컬 파일명 패스를 가공할 때 다음과 같이 명시적인 정규화를 수행할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let inputFilename = &quot;ㄱㅓㅁㅅㅐㄱㅎㅘㅁㅕㄴ.png&quot; // 사용자가 입력한 NFD 문자열 가정

// NFC 완성형 구조로 정규화 변환
let normalizedFilename = inputFilename.precomposedStringWithCanonicalMapping

// 반대로 NFD로 풀어헤치려면
let decomposedFilename = inputFilename.decomposedStringWithCanonicalMapping
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;JavaScript / Node.js&lt;/h3&gt;
&lt;p&gt;웹 환경에서도 문자열 비교나 파일 전송 로직 수행 전에 유니코드 표준 정규화 메서드인 &lt;code&gt;normalize()&lt;/code&gt; 함수를 호출하여 통일성을 유지할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const userFilename = &quot;ㄱㅓㅁㅅㅐㄱㅎㅘㅁㅕㄴ.png&quot;;
const cleanFilename = userFilename.normalize(&quot;NFC&quot;); // 완성형 변환
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Python (Build Script &amp;amp; Automation)&lt;/h3&gt;
&lt;p&gt;자동화 툴이나 헬퍼 스크립트를 빌드 파이프라인에 이식할 때 파이썬 표준 라이브러리 &lt;code&gt;unicodedata&lt;/code&gt;를 활용해 일관성을 부여할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import sys
import unicodedata

def sanitize_nfc(path):
    return unicodedata.normalize(&apos;NFC&apos;, path)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;현재 개발 중인 크로스 플랫폼 프로젝트의 자소 분리 여부 확인:&lt;/strong&gt; Mac 로컬 환경에 한글 파일명이 포함되어 있다면 Windows 빌드 시 에러 가능성이 없는지 먼저 검토한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Homebrew를 통한 convmv 설치:&lt;/strong&gt; CLI 환경에서 유연하게 대처할 수 있도록 로컬 컴퓨터에 설치한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git Config 인코딩 관련 속성 점검:&lt;/strong&gt; Git 파일명 대소문자 구분 설정(&lt;code&gt;core.ignorecase&lt;/code&gt;)과 더불어 한글 파일명 변경 감지가 예민하게 작동하고 있는지 확인한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automator 단축 액션 등록:&lt;/strong&gt; Finder 우클릭 동작을 미리 만들어 두면 불필요한 터미널 사용을 최소화할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;앱 및 서버의 유니코드 정상화 검토:&lt;/strong&gt; 외부 업로드 파일명이 유입되는 인터페이스 구간에 &lt;code&gt;NFC&lt;/code&gt; 정규화 코드(예: &lt;code&gt;.precomposedStringWithCanonicalMapping&lt;/code&gt; 등)가 잘 적용되었는지 밸리데이션 검사 방식을 점검한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Mac 사용자들끼리만 일할 때는 수면 위로 잘 드러나지 않는 문제이지만, 크로스 플랫폼 협업이나 클라우드/컨테이너 빌드 자동화가 보편화된 요즘에는 언제든 장애 요소로 돌변할 수 있는 것이 바로 이 한글 자소 분리 문제이다.&lt;/p&gt;
&lt;p&gt;단순히 파일명을 영어로 짓는 소극적인 회피 수단보다, 유니코드 정규화(NFD와 NFC)의 기술적 특성을 명확히 이해하고 &lt;code&gt;convmv&lt;/code&gt;나 코드 레벨의 정규화 파이프라인을 이식하는 성숙한 대응 체계를 구축하는 것이 궁극적인 해결책이다. 지금 사용 중인 협업 리포지토리의 파일명들을 점검하는 것부터 시작해 보는 것을 추천한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.unicode.org/reports/tr15/&quot;&gt;Unicode Consortium - Unicode Normalization Forms&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;유니코드 표준에서 정의하는 NFD, NFC 정규화 양식의 명세 및 원리 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/foundation/nsstring/1412693-precomposedstringwithcanonicalma&quot;&gt;Apple Developer - precomposedStringWithCanonicalMapping&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;Swift 및 Objective-C 환경에서 Foundation 프레임워크를 이용한 NFC 변환 API 명세 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://formulae.brew.sh/formula/convmv&quot;&gt;Homebrew Formulae - convmv&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;Homebrew 환경 하의 convmv 라이브러리 설치 가이드 정보 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.j3e.de/open/convmv/&quot;&gt;convmv 공식 홈페이지&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;convmv 도구의 매뉴얼 및 옵션 구조 정보 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.bandisoft.com/bandinamer/&quot;&gt;Bandisoft - 반디네이머 공식 페이지&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;자소 분리 현상 개선용 일반 사용자용 유틸리티 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>macOS</category><category>Unicode</category><category>Troubleshooting</category><category>Git</category></item><item><title>Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 2편. FFI 경계와 API 설계</title><link>https://jaemyeong.com/ko/blog/rust-shared-core-02-ffi-api-design/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/rust-shared-core-02-ffi-api-design/</guid><description>Rust 공통 모듈을 크로스플랫폼에서 안전하고 효율적으로 호출하기 위해 FFI 경계를 잡고 API를 설계하는 구체적인 실무 전략을 정리합니다.</description><pubDate>Thu, 25 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Rust 공통 모듈을 만들 때 가장 쉽게 놓치는 부분은 API 경계다.&lt;/p&gt;
&lt;p&gt;Rust 내부에서는 좋은 모델이 Swift, Kotlin, TypeScript에서 그대로 좋은 모델이 아닐 수 있다. 반대로 플랫폼 언어에서 편한 타입을 Rust 내부까지 끌고 들어오면 core가 지저분해진다. 그래서 FFI 경계는 별도의 설계 대상이다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 Rust core를 외부 앱에서 호출하기 위한 API 표면을 어떻게 잡으면 좋은지 정리한다. 핵심은 단순하다. Rust 내부 모델은 Rust답게 두고, 외부로 내보내는 API는 플랫폼 개발자가 편하게 쓰도록 따로 설계해야 한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;내부 모델과 공개 모델을 분리한다&lt;/h2&gt;
&lt;p&gt;Rust core 안에서는 도메인 모델을 풍부하게 표현하는 것이 좋다.&lt;/p&gt;
&lt;p&gt;예를 들어 스도쿠 엔진이라면 &lt;code&gt;Grid&lt;/code&gt;, &lt;code&gt;Cell&lt;/code&gt;, &lt;code&gt;CandidateSet&lt;/code&gt;, &lt;code&gt;PuzzleSeed&lt;/code&gt;, &lt;code&gt;Difficulty&lt;/code&gt;, &lt;code&gt;Move&lt;/code&gt;, &lt;code&gt;GameState&lt;/code&gt;, &lt;code&gt;ValidationError&lt;/code&gt; 같은 타입을 충분히 둘 수 있다. Rust의 enum, pattern matching, ownership, trait을 활용하면 도메인 규칙을 꽤 선명하게 표현할 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 이 모델을 그대로 Swift, Kotlin, TypeScript로 노출하는 것은 다른 문제다.&lt;/p&gt;
&lt;p&gt;FFI 경계에는 언어별 타입 시스템 차이가 있다. generic, lifetime, nested enum, complex collection, borrowed reference 같은 개념은 외부 언어에서 자연스럽지 않다. 바인딩 생성기가 일부를 처리해주더라도 앱 개발자가 읽고 테스트하고 디버깅하기 쉬운 API가 되리라는 보장은 없다.&lt;/p&gt;
&lt;p&gt;그래서 공개 모델은 따로 잡는 편이 낫다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudoku-core
  - Rust 내부 도메인 모델
  - Rust 테스트
  - 순수 계산 로직

sudoku-uniffi / sudoku-wasm
  - 외부 공개 DTO
  - serialization
  - error 변환
  - coarse-grained 함수
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 것은 &lt;code&gt;sudoku-core&lt;/code&gt;가 바인딩 도구를 몰라도 되게 만드는 것이다. UniFFI를 쓰든 wasm-bindgen을 쓰든, 내부 도메인 로직은 가능한 한 독립적으로 유지한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;FFI API는 굵게 가져간다&lt;/h2&gt;
&lt;p&gt;처음에는 Rust 함수를 세밀하게 나누고 싶어진다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;get_cell(row, col)
set_value(row, col, value)
toggle_note(row, col, digit)
is_conflict(row, col)
get_candidates(row, col)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이런 API는 객체 내부를 조금씩 조작하는 느낌이라 익숙하다. 하지만 FFI 경계에서는 대체로 좋지 않다. 호출 횟수가 늘어나고, Swift/Kotlin/TypeScript 쪽 상태와 Rust 쪽 상태가 서로 어긋날 가능성이 커진다. 화면 렌더링 중 셀마다 Rust를 호출하는 구조라면 디버깅도 어려워진다.&lt;/p&gt;
&lt;p&gt;공통 엔진은 “로컬 서비스”처럼 호출하는 편이 낫다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;start_game(request) -&amp;gt; GameSnapshot
apply_action(snapshot, action) -&amp;gt; TransitionResult
validate_snapshot(snapshot) -&amp;gt; ValidationReport
generate_daily_puzzle(request) -&amp;gt; PuzzleEnvelope
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하나의 사용자 액션에 대해 Rust가 다음 상태를 계산해서 돌려준다. 플랫폼 앱은 그 결과를 받아 UI state로 변환한다. 이렇게 하면 FFI 호출은 줄고, 테스트 단위는 선명해진다.&lt;/p&gt;
&lt;p&gt;실무적으로는 action reducer 형태가 잘 맞는 경우가 많다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;현재 상태 + 사용자 액션 + 설정
  -&amp;gt; 다음 상태 + 효과 + 에러 또는 경고
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 모델은 iOS ViewModel, Android ViewModel, Web state layer와도 잘 맞는다. 각 플랫폼은 reducer 결과를 자신의 UI state로 바꾸면 된다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;DTO는 의도적으로 단순하게 둔다&lt;/h2&gt;
&lt;p&gt;FFI 경계의 DTO는 똑똑할 필요가 없다. 오히려 단순한 편이 좋다.&lt;/p&gt;
&lt;p&gt;나는 외부 공개 DTO에서 다음 타입을 우선한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;string&lt;/li&gt;
&lt;li&gt;integer&lt;/li&gt;
&lt;li&gt;boolean&lt;/li&gt;
&lt;li&gt;flat array&lt;/li&gt;
&lt;li&gt;명확한 enum&lt;/li&gt;
&lt;li&gt;optional value&lt;/li&gt;
&lt;li&gt;필요할 경우 JSON string&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;JSON string은 완벽한 답은 아니다. 성능 비용이 있고, compile-time type safety가 약해질 수 있다. 하지만 coarse-grained API에서 state 전체나 action 전체를 넘기는 정도라면 꽤 현실적인 선택이다. 특히 Swift, Kotlin, TypeScript에서 각자 native decoding을 하기 쉽다는 장점이 있다.&lt;/p&gt;
&lt;p&gt;중요한 것은 JSON을 어디에 쓰는지다. 렌더링 중 매 frame마다 호출되는 함수에 JSON을 쓰면 당연히 부담이 된다. 반대로 “사용자 액션 하나를 적용한다”는 단위라면 JSON serialization 비용보다 경계의 단순함이 더 큰 이득일 수 있다.&lt;/p&gt;
&lt;p&gt;예를 들어 이런 식이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apply_action(
  snapshot_json: String,
  action_json: String,
  environment_json: String
) -&amp;gt; transition_json: String
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;보기에는 투박하지만 장점이 있다. Swift, Kotlin, TypeScript 쪽에서는 각자 Codable, kotlinx.serialization, Zod나 TypeScript type으로 해석할 수 있다. Rust 쪽에서는 serde로 내부 모델에 매핑한다.&lt;/p&gt;
&lt;p&gt;팀 단위라면 JSON schema나 fixture를 같이 관리하는 것도 좋다. API 문서보다 fixture가 더 정확할 때가 많다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;에러 모델은 초기에 정해야 한다&lt;/h2&gt;
&lt;p&gt;FFI 경계에서 에러 처리는 뒤로 미루면 비용이 커진다.&lt;/p&gt;
&lt;p&gt;Rust 내부에서는 &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt;가 자연스럽다. 하지만 외부 언어에서는 에러가 thrown error인지, result object인지, nullable인지, callback error인지에 따라 사용성이 달라진다.&lt;/p&gt;
&lt;p&gt;개인적으로는 앱 도메인 엔진에서는 error object를 명시적으로 돌려주는 방식을 선호한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TransitionResult
  - ok: Boolean
  - snapshot: String?
  - effects: [Effect]
  - error: EngineError?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조는 UI와도 잘 맞는다. 실패한 action을 무시할지, alert를 띄울지, haptic만 줄지, analytics event를 남길지는 플랫폼 앱이 결정한다. Rust core는 “무엇이 잘못됐는지”를 명확히 표현하면 된다.&lt;/p&gt;
&lt;p&gt;에러 코드는 사람이 읽는 메시지와 분리하는 편이 좋다. Rust가 한국어/영어 사용자 메시지를 직접 만들기 시작하면 localization이 꼬인다. Rust는 &lt;code&gt;INVALID_MOVE&lt;/code&gt;, &lt;code&gt;PUZZLE_ALREADY_COMPLETED&lt;/code&gt;, &lt;code&gt;SEED_OUT_OF_RANGE&lt;/code&gt; 같은 안정적인 code를 주고, 문구는 플랫폼 앱이 처리한다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;버전 호환성도 API 설계의 일부다&lt;/h2&gt;
&lt;p&gt;앱이 App Store와 Play Store에 배포되면 사용자 기기에 여러 버전이 동시에 존재한다. Web은 비교적 빠르게 갱신되지만, 모바일 앱은 그렇지 않다.&lt;/p&gt;
&lt;p&gt;Rust core API를 바꿀 때도 이 현실을 봐야 한다.&lt;/p&gt;
&lt;p&gt;가능하면 request와 response에 version을 둔다. fixture도 버전별로 보관한다. breaking change가 필요하면 Rust core, Swift adapter, Kotlin adapter, Web package가 같은 PR에서 같이 바뀌는지 확인해야 한다.&lt;/p&gt;
&lt;p&gt;특히 generated binding은 diff가 크고 읽기 어려울 수 있다. 그래서 사람이 리뷰해야 하는 API는 별도 wrapper나 schema 파일로 남겨두는 것이 좋다. generated code만 보고 API 변경을 리뷰하는 것은 피곤하다.&lt;/p&gt;
&lt;p&gt;좋아 보이지만 팀 단위로 도입할 때는 코드 리뷰 기준이 필요하다. “Rust core public API가 바뀌면 어떤 fixture를 추가해야 하는가”, “Swift/Kotlin/TypeScript adapter 테스트가 같이 바뀌었는가” 같은 기준을 PR 템플릿에 넣는 것도 방법이다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Rust 내부 모델과 외부 공개 DTO를 분리한다.&lt;/li&gt;
&lt;li&gt;FFI API는 cell 단위가 아니라 action 단위로 굵게 설계한다.&lt;/li&gt;
&lt;li&gt;Swift, Kotlin, TypeScript가 모두 다루기 쉬운 타입만 공개한다.&lt;/li&gt;
&lt;li&gt;JSON string을 쓸 경우 성능 경로와 비성능 경로를 구분한다.&lt;/li&gt;
&lt;li&gt;에러 코드는 안정적인 machine-readable code로 설계한다.&lt;/li&gt;
&lt;li&gt;사용자 문구와 localization은 플랫폼 앱에 남긴다.&lt;/li&gt;
&lt;li&gt;request/response fixture를 만들어 API 계약을 테스트한다.&lt;/li&gt;
&lt;li&gt;generated binding이 아니라 adapter와 schema를 리뷰 대상으로 둔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Rust 공통 모듈의 API는 Rust 개발자만을 위한 API가 아니다. 실제로 매일 호출하는 사람은 Swift, Kotlin, TypeScript 코드다.&lt;/p&gt;
&lt;p&gt;그래서 FFI 경계에서는 Rust다운 정교함보다 플랫폼 개발자가 이해하기 쉬운 단순함이 더 중요할 때가 많다. 내부는 Rust답게, 외부는 앱 개발자가 쓰기 쉽게. 이 분리를 잘해두면 이후 UniFFI, wasm-bindgen, XCFramework, Android &lt;code&gt;.so&lt;/code&gt; 통합이 훨씬 덜 흔들린다.&lt;/p&gt;
&lt;p&gt;다음 글에서는 이 API를 iOS와 Android에서 실제로 호출하기 위해 UniFFI를 어떻게 바라보면 좋은지 정리해보겠다.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://mozilla.github.io/uniffi-rs/&quot;&gt;Mozilla UniFFI - The UniFFI user guide&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;Rust 라이브러리에서 Swift, Kotlin 등 외부 언어 바인딩을 생성하는 기본 구조와 API 노출 방식을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doc.rust-lang.org/reference/linkage.html&quot;&gt;Rust Reference - Linkage&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;Rust 라이브러리가 외부에서 로드될 수 있는 산출물로 빌드되는 crate type과 linkage 개념을 확인했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rustwasm.github.io/docs/wasm-bindgen/&quot;&gt;Rust and WebAssembly - The wasm-bindgen Guide&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;Rust와 JavaScript 사이의 고수준 상호작용, TypeScript binding 생성 흐름을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>iOS</category><category>Android</category><category>WebAssembly</category><category>FFI</category></item><item><title>Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 1편. 어디까지 Rust로 묶을 것인가</title><link>https://jaemyeong.com/ko/blog/rust-shared-core-01-architecture-boundary/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/rust-shared-core-01-architecture-boundary/</guid><description>iOS·Android·Web에서 Rust 공통 모듈을 공유할 때 도메인 엔진의 경계를 어떻게 잡을지 정리한다. 무엇을 sudoku-core에 넣고 무엇을 플랫폼에 남길지, UniFFI·WASM 바인딩 계층과 앱 adapter 구조까지 다룬다.</description><pubDate>Tue, 23 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;iOS, Android, Web 앱을 같이 만들다 보면 어느 순간 같은 코드를 세 번 쓰고 있다는 느낌이 든다.&lt;/p&gt;
&lt;p&gt;처음에는 괜찮다. 플랫폼마다 UI가 다르고, 저장소가 다르고, 앱 생명주기도 다르다. 그런데 시간이 지나면 문제가 조금 다르게 보인다. 퍼즐 생성 규칙, 상태 전이, 오프라인 검증, 가격 계산, 암호화, 동기화 충돌 해결 같은 도메인 로직까지 플랫폼마다 따로 구현하고 있다면 버그도 세 벌로 난다.&lt;/p&gt;
&lt;p&gt;2026년 기준으로 Rust 공통 모듈을 iOS, Android, Web에서 공유하는 방식은 충분히 현실적인 선택지가 됐다. 하지만 여기서 중요한 건 Rust로 앱을 “한 번만” 만들겠다는 접근이 아니다. 오히려 반대에 가깝다. 플랫폼 앱은 각 플랫폼답게 유지하고, 정말 공통이어야 하는 도메인 엔진만 Rust로 분리하는 것이다.&lt;/p&gt;
&lt;p&gt;이 시리즈의 첫 글에서는 구현 도구보다 먼저 경계를 잡아보려고 한다. Rust가 어디까지 들어와야 하고, 어디부터는 플랫폼 코드에 남겨야 하는지에 대한 이야기다.&lt;/p&gt;
&lt;h2&gt;Rust는 앱의 중심이 아니라 도메인 엔진에 가깝다&lt;/h2&gt;
&lt;p&gt;Rust를 공통 모듈로 쓴다고 하면 앱 구조 전체를 Rust 중심으로 다시 짜야 한다고 생각하기 쉽다. 실제로는 그렇게 가면 복잡도가 빠르게 올라간다.&lt;/p&gt;
&lt;p&gt;iOS 앱은 UIKit이나 SwiftUI의 생명주기, MainActor, App Extension, App Store 심사 흐름 안에서 움직인다. Android 앱은 Activity, ViewModel, Compose state, Gradle packaging, Play 정책을 따라야 한다. Web은 routing, hydration, browser storage, bundler, deployment target이 중요하다.&lt;/p&gt;
&lt;p&gt;이런 플랫폼 고유 영역까지 Rust가 알기 시작하면 공통 모듈은 장점보다 부담이 커진다. Rust 코어가 화면 상태, analytics event, 저장소 접근, 네트워크 요청, 권한 상태까지 직접 다루기 시작하면 결국 가장 복잡한 플랫폼 계층이 된다.&lt;/p&gt;
&lt;p&gt;그래서 나는 Rust의 역할을 “도메인 엔진”으로 제한하는 쪽을 선호한다.&lt;/p&gt;
&lt;p&gt;예를 들어 스도쿠 앱이라면 Rust가 맡기 좋은 것은 이런 영역이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;퍼즐 생성&lt;/li&gt;
&lt;li&gt;solver&lt;/li&gt;
&lt;li&gt;입력 검증&lt;/li&gt;
&lt;li&gt;note와 value 상태 전이&lt;/li&gt;
&lt;li&gt;daily puzzle seed 계산&lt;/li&gt;
&lt;li&gt;난이도 판정&lt;/li&gt;
&lt;li&gt;score와 streak 계산&lt;/li&gt;
&lt;li&gt;replay 가능한 action reducer&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반대로 이런 것은 플랫폼에 남기는 편이 낫다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;화면 렌더링&lt;/li&gt;
&lt;li&gt;접근성&lt;/li&gt;
&lt;li&gt;광고와 결제&lt;/li&gt;
&lt;li&gt;push notification&lt;/li&gt;
&lt;li&gt;analytics SDK&lt;/li&gt;
&lt;li&gt;로컬 저장소 선택&lt;/li&gt;
&lt;li&gt;네트워크 retry 정책&lt;/li&gt;
&lt;li&gt;앱 심사와 배포 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 구분은 코드 취향 문제가 아니다. 유지보수 단위의 문제다.&lt;/p&gt;
&lt;h2&gt;공통화할 수 있는 코드와 공통화하면 안 되는 코드&lt;/h2&gt;
&lt;p&gt;공통화의 기준은 “세 플랫폼에서 똑같이 생겼는가”가 아니라 “세 플랫폼에서 반드시 같은 결과를 내야 하는가”에 가깝다.&lt;/p&gt;
&lt;p&gt;예를 들어 같은 스도쿠 보드와 같은 입력이 들어왔을 때 valid/invalid 판정이 플랫폼마다 다르면 안 된다. 같은 seed로 생성한 daily puzzle도 플랫폼마다 달라지면 안 된다. 이런 로직은 Rust core에 들어갈 가치가 있다.&lt;/p&gt;
&lt;p&gt;반대로 같은 데이터를 어떻게 보여줄지는 플랫폼마다 달라도 된다. iOS에서는 UIKit collection view가 자연스러울 수 있고, Android에서는 Compose state로 표현하는 편이 좋을 수 있다. Web에서는 keyboard shortcut과 pointer interaction을 별도로 설계해야 한다. 이것까지 억지로 공유하면 사용자 경험이 플랫폼답지 않아진다.&lt;/p&gt;
&lt;p&gt;결국 Rust core에 넣을 코드는 다음 조건을 만족해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;같은 입력에 대해 같은 출력을 내야 한다.&lt;/li&gt;
&lt;li&gt;시간, locale, storage, network 같은 외부 상태에 직접 의존하지 않는다.&lt;/li&gt;
&lt;li&gt;UI framework를 모른다.&lt;/li&gt;
&lt;li&gt;플랫폼 SDK를 모른다.&lt;/li&gt;
&lt;li&gt;테스트를 Rust 단독으로 실행할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 조건을 만족하지 못한다면 Rust에 넣기 전에 한 번 더 의심해보는 편이 좋다.&lt;/p&gt;
&lt;h2&gt;추천하는 레이어 구조&lt;/h2&gt;
&lt;p&gt;실무적으로는 세 레이어로 나누는 것이 가장 이해하기 쉽다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;core/
  sudoku-rs/
    crates/
      sudoku-core/      # 순수 Rust 도메인 로직
      sudoku-uniffi/    # iOS/Android 바인딩 표면
      sudoku-wasm/      # WebAssembly 바인딩 표면

apps/
  ios/                  # Swift/UIKit 또는 SwiftUI 앱
  android/              # Kotlin/Compose 앱
  web/                  # TypeScript/React 앱
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sudoku-core&lt;/code&gt;는 가장 중요한 계층이다. 이 크레이트는 Swift도 Kotlin도 TypeScript도 몰라야 한다. FFI를 위한 타입 타협도 가능하면 여기로 끌고 오지 않는 편이 좋다. 내부 모델은 Rust답게 유지하고, 테스트도 이 계층에 가장 많이 둔다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sudoku-uniffi&lt;/code&gt;는 모바일 바인딩 계층이다. Rust 내부 모델을 Swift/Kotlin에서 다루기 쉬운 형태로 바꾼다. 이 계층에서는 DTO, error 변환, serialization, 공개 함수 이름 같은 외부 표면을 신경 쓴다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sudoku-wasm&lt;/code&gt;은 Web 바인딩 계층이다. wasm-bindgen이나 wasm-pack을 전제로 TypeScript에서 호출하기 좋은 API를 만든다. Web 앱에서 import하기 쉬운 package 형태를 목표로 잡는다.&lt;/p&gt;
&lt;p&gt;이렇게 나누면 중요한 장점이 생긴다. Rust core 자체는 바인딩 도구에 덜 묶인다. 나중에 UniFFI의 생성 방식이 바뀌거나, Web 쪽 bundler 구성이 바뀌어도 도메인 규칙은 비교적 안정적으로 남는다.&lt;/p&gt;
&lt;h2&gt;앱 계층에는 adapter를 둔다&lt;/h2&gt;
&lt;p&gt;Rust 바인딩을 앱 UI에서 직접 호출하게 만들면 처음에는 편해 보인다. 하지만 조금만 지나면 ViewController, ViewModel, Composable, React Component가 generated binding에 의존하기 시작한다.&lt;/p&gt;
&lt;p&gt;이 구조는 변경에 약하다. 바인딩 함수 이름이 바뀌거나, DTO가 조금만 바뀌어도 UI 코드가 넓게 흔들린다.&lt;/p&gt;
&lt;p&gt;그래서 각 플랫폼에는 adapter를 하나 더 두는 것이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;iOS ViewModel
  -&amp;gt; SudokuEngineClient protocol
    -&amp;gt; UniFFI generated binding
      -&amp;gt; Rust core

Android ViewModel
  -&amp;gt; SudokuEngineClient interface
    -&amp;gt; UniFFI generated binding
      -&amp;gt; Rust core

Web state layer
  -&amp;gt; SudokuEngineClient
    -&amp;gt; wasm package
      -&amp;gt; Rust core
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 UI는 generated binding을 모른다. 앱이 이해하는 Swift/Kotlin/TypeScript 타입만 본다. 테스트에서는 이 adapter를 fake로 바꿀 수 있다.&lt;/p&gt;
&lt;p&gt;좋아 보이지만 팀 단위로 도입할 때는 기준이 필요하다. “generated binding은 앱의 어느 레이어까지 들어올 수 있는가”를 정해두지 않으면, 시간이 지나면서 Rust와 UI 사이의 경계가 다시 흐려진다.&lt;/p&gt;
&lt;h2&gt;첫 번째 설계 질문&lt;/h2&gt;
&lt;p&gt;Rust core를 만들기 전에 바로 Cargo workspace부터 만들고 싶을 수 있다. 하지만 그 전에 더 중요한 질문이 있다.&lt;/p&gt;
&lt;p&gt;“이 로직은 세 플랫폼에서 반드시 같은 결과를 내야 하는가?”&lt;/p&gt;
&lt;p&gt;이 질문에 명확히 답할 수 있는 코드부터 Rust로 옮기는 것이 좋다. 모든 공통 코드 후보를 한 번에 옮기려고 하면 범위가 커진다. 반대로 daily seed 계산, puzzle validation, reducer처럼 결정적인 로직부터 시작하면 성공 확률이 높다.&lt;/p&gt;
&lt;p&gt;Rust 공통 모듈의 도입은 기술 선택이기도 하지만, 앱 구조를 다시 정리하는 일이기도 하다. 어디까지 공유하고, 어디부터 플랫폼에 맡길지 정하는 순간부터 유지보수 방향이 결정된다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;세 플랫폼에서 중복 구현된 도메인 로직을 목록화한다.&lt;/li&gt;
&lt;li&gt;그중 “반드시 같은 결과”가 필요한 로직만 먼저 고른다.&lt;/li&gt;
&lt;li&gt;Rust core에는 UI, storage, network, analytics, 결제 코드를 넣지 않는다.&lt;/li&gt;
&lt;li&gt;Rust 내부 모델과 외부 바인딩 모델을 분리한다.&lt;/li&gt;
&lt;li&gt;iOS, Android, Web 앱에는 각각 adapter 계층을 둔다.&lt;/li&gt;
&lt;li&gt;generated binding이 UI 레이어까지 직접 퍼지지 않게 기준을 정한다.&lt;/li&gt;
&lt;li&gt;첫 번째 마이그레이션 대상은 작고 결정적인 로직으로 잡는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Rust 공통 모듈은 플랫폼 개발을 없애주는 도구가 아니다. 오히려 플랫폼 경계를 더 명확하게 요구한다.&lt;/p&gt;
&lt;p&gt;iOS는 iOS답게, Android는 Android답게, Web은 Web답게 만든다. Rust는 그 아래에서 같은 규칙을 보장하는 도메인 엔진으로 둔다. 이 기준이 잡히면 이후 UniFFI, XCFramework, Android &lt;code&gt;.so&lt;/code&gt;, WebAssembly 같은 구현 선택도 훨씬 현실적으로 판단할 수 있다.&lt;/p&gt;
&lt;p&gt;다음 글에서는 Rust core를 외부에서 호출하기 위한 FFI API를 어떻게 설계하면 좋은지 정리해보겠다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://doc.rust-lang.org/reference/linkage.html&quot;&gt;Rust Reference - Linkage&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rust 라이브러리를 외부 언어와 연결할 때 고려해야 하는 crate type과 linkage 개념을 확인했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://mozilla.github.io/uniffi-rs/&quot;&gt;Mozilla UniFFI - The UniFFI user guide&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rust 라이브러리를 Swift, Kotlin 등 외부 언어에서 호출할 수 있게 하는 바인딩 생성 흐름을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/WebAssembly&quot;&gt;MDN Web Docs - WebAssembly&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WebAssembly가 JavaScript와 함께 실행되는 Web 플랫폼의 compilation target이라는 기본 전제를 확인했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Rust</category><category>iOS</category><category>Android</category><category>WebAssembly</category></item><item><title>iOS 런타임 폰트 등록, CTFontManagerRegisterFontsForURL 실무 정리</title><link>https://jaemyeong.com/ko/blog/ios-runtime-font-registration/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/ios-runtime-font-registration/</guid><description>iOS에서 CTFontManagerRegisterFontsForURL로 폰트를 런타임 등록하는 방법. UIAppFonts와의 차이, PostScript name 확인, 저장 위치와 해제 시점을 정리한다.</description><pubDate>Wed, 17 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;iOS 앱에서 커스텀 폰트를 쓰는 가장 익숙한 방식은 &lt;code&gt;Info.plist&lt;/code&gt;의 &lt;code&gt;UIAppFonts&lt;/code&gt;에 폰트 파일을 등록하는 것이다. 앱 번들에 고정된 폰트라면 이 방식이 가장 단순하다.&lt;/p&gt;
&lt;p&gt;하지만 모든 폰트가 빌드 시점에 결정되는 것은 아니다. 서버에서 내려받은 폰트, Swift Package나 별도 번들에 들어 있는 폰트, 특정 화면이나 콘텐츠에서만 필요한 폰트는 런타임에 등록하는 방식이 더 적합할 수 있다. 이때 사용하는 CoreText API가 &lt;code&gt;CTFontManagerRegisterFontsForURL&lt;/code&gt;과 &lt;code&gt;CTFontManagerUnregisterFontsForURL&lt;/code&gt;이다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 이 API를 iOS 앱에서 어떻게 다뤄야 하는지 정리해본다. 단순히 “등록 함수 한 번 호출하면 된다”로 끝낼 수 있는 주제는 아니다. 폰트 이름, 등록 scope, 중복 등록, 해제 시점, 캐싱 정책까지 같이 봐야 한다.&lt;/p&gt;
&lt;h2&gt;UIAppFonts와 런타임 등록은 목적이 다르다&lt;/h2&gt;
&lt;p&gt;먼저 구분이 필요하다.&lt;/p&gt;
&lt;p&gt;앱에 항상 포함되는 브랜드 폰트라면 &lt;code&gt;UIAppFonts&lt;/code&gt;가 더 낫다. Xcode 프로젝트에 폰트 파일을 추가하고, &lt;code&gt;Info.plist&lt;/code&gt;에 파일명을 선언하면 시스템이 앱 실행 시 해당 폰트를 로드한다. 코드에서 별도 등록 로직을 관리하지 않아도 된다.&lt;/p&gt;
&lt;p&gt;반대로 런타임 등록은 다음 상황에서 의미가 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버에서 폰트 파일을 내려받아야 한다.&lt;/li&gt;
&lt;li&gt;특정 테마, 언어, 콘텐츠에서만 폰트를 사용한다.&lt;/li&gt;
&lt;li&gt;앱 본체가 아니라 Swift Package, framework, 별도 bundle에 폰트가 들어 있다.&lt;/li&gt;
&lt;li&gt;폰트 목록이 빌드 시점에 고정되지 않는다.&lt;/li&gt;
&lt;li&gt;테스트나 프리뷰 환경에서 폰트를 동적으로 바꿔야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;여기서 중요한 건 유지보수 기준이다. 모든 커스텀 폰트를 런타임 등록으로 처리할 필요는 없다. 앱 전체에서 항상 쓰는 폰트는 정적으로 선언하고, 실제로 동적인 요구가 있는 폰트만 CoreText로 등록하는 쪽이 관리하기 좋다.&lt;/p&gt;
&lt;h2&gt;CTFontManagerRegisterFontsForURL의 기본 흐름&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;CTFontManagerRegisterFontsForURL&lt;/code&gt;은 특정 font file URL의 폰트를 Font Manager에 등록한다. 등록된 폰트는 font descriptor matching 대상이 된다. UIKit의 &lt;code&gt;UIFont(name:size:)&lt;/code&gt;, SwiftUI의 &lt;code&gt;Font.custom(_:size:)&lt;/code&gt;에서 사용할 수 있는 상태가 된다고 보면 된다.&lt;/p&gt;
&lt;p&gt;iOS 앱 내부에서만 사용할 폰트라면 일반적으로 &lt;code&gt;CTFontManagerScope.process&lt;/code&gt;를 사용한다. 이 scope는 현재 프로세스 동안 폰트를 사용할 수 있게 한다. 앱이 종료되면 등록 상태도 사라진다. 그래서 앱을 다시 실행하면 다시 등록해야 한다.&lt;/p&gt;
&lt;p&gt;기본 코드는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import CoreText

func registerFont(at url: URL) throws {
    var error: Unmanaged&amp;lt;CFError&amp;gt;?

    let success = CTFontManagerRegisterFontsForURL(
        url as CFURL,
        .process,
        &amp;amp;error
    )

    if !success {
        if let error = error?.takeRetainedValue() {
            throw error
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 코드는 동작은 하지만 실무 코드로는 부족하다. 파일 존재 여부도 확인하지 않고, 폰트 이름도 알 수 없고, 중복 등록도 처리하지 않는다. 특히 &lt;code&gt;UIFont(name:size:)&lt;/code&gt;에 넣어야 하는 이름은 파일명이 아니다.&lt;/p&gt;
&lt;p&gt;예를 들어 파일명이 &lt;code&gt;Pretendard-Regular.otf&lt;/code&gt;라고 해서 항상 &lt;code&gt;Pretendard-Regular&lt;/code&gt;를 그대로 쓰면 된다고 가정하면 안 된다. 폰트 내부의 PostScript name을 확인해야 한다.&lt;/p&gt;
&lt;h2&gt;파일명보다 중요한 것은 PostScript name이다&lt;/h2&gt;
&lt;p&gt;커스텀 폰트 적용에서 자주 발생하는 실수는 파일명을 폰트명으로 착각하는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UIFont(name:size:)&lt;/code&gt;는 “폰트 파일명”을 받는 API가 아니다. 폰트의 fully specified name을 기준으로 font object를 만든다. CoreText 관점에서는 &lt;code&gt;kCTFontNameAttribute&lt;/code&gt;를 통해 font descriptor의 PostScript name을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;런타임 등록을 안정적으로 하려면 등록 전에 &lt;code&gt;CTFontManagerCreateFontDescriptorsFromURL&lt;/code&gt;로 font descriptor를 읽고, 그 안에서 PostScript name을 추출하는 것이 좋다. &lt;code&gt;.ttc&lt;/code&gt;처럼 하나의 파일 안에 여러 face가 들어 있는 경우도 있기 때문이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import CoreText

func postScriptNames(in url: URL) -&amp;gt; [String] {
    guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else {
        return []
    }

    return descriptors.compactMap {
        CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 값을 로그로 남겨두면 디버깅이 훨씬 편해진다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let names = postScriptNames(in: fontURL)
print(&quot;Font PostScript names:&quot;, names)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 앱에서는 이 이름을 디자인 시스템이나 font registry에서 관리하는 편이 좋다. 뷰 코드 곳곳에서 문자열로 직접 &lt;code&gt;UIFont(name:size:)&lt;/code&gt;를 호출하면 폰트 교체나 오류 추적이 어려워진다.&lt;/p&gt;
&lt;h2&gt;실무에서는 FontRegistry를 두는 편이 안전하다&lt;/h2&gt;
&lt;p&gt;런타임 폰트 등록은 한 번만 하면 된다. 셀 생성, SwiftUI &lt;code&gt;body&lt;/code&gt;, 화면 진입 시점마다 반복해서 호출하면 중복 등록 에러와 불필요한 비용이 생긴다.&lt;/p&gt;
&lt;p&gt;그래서 실무에서는 폰트 등록을 담당하는 작은 registry를 두는 편이 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import Foundation
import CoreText

final class RuntimeFontRegistry {
    static let shared = RuntimeFontRegistry()

    private var registeredURLs: [URL: [String]] = [:]
    private let lock = NSLock()

    private init() {}

    @discardableResult
    func registerFont(at url: URL) throws -&amp;gt; [String] {
        let url = url.standardizedFileURL

        guard FileManager.default.fileExists(atPath: url.path) else {
            throw FontRegistrationError.fileNotFound(url)
        }

        let names = try Self.readPostScriptNames(from: url)

        lock.lock()
        if let cached = registeredURLs[url] {
            lock.unlock()
            return cached
        }
        lock.unlock()

        var error: Unmanaged&amp;lt;CFError&amp;gt;?
        let success = CTFontManagerRegisterFontsForURL(
            url as CFURL,
            .process,
            &amp;amp;error
        )

        if !success {
            if let error = error?.takeRetainedValue() as Error? {
                throw error
            }
        }

        lock.lock()
        registeredURLs[url] = names
        lock.unlock()

        return names
    }

    func unregisterFont(at url: URL) throws {
        let url = url.standardizedFileURL

        var error: Unmanaged&amp;lt;CFError&amp;gt;?
        let success = CTFontManagerUnregisterFontsForURL(
            url as CFURL,
            .process,
            &amp;amp;error
        )

        if !success {
            if let error = error?.takeRetainedValue() as Error? {
                throw error
            }
        }

        lock.lock()
        registeredURLs.removeValue(forKey: url)
        lock.unlock()
    }

    private static func readPostScriptNames(from url: URL) throws -&amp;gt; [String] {
        guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor],
              !descriptors.isEmpty else {
            throw FontRegistrationError.invalidFontFile(url)
        }

        let names = descriptors.compactMap {
            CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
        }

        guard !names.isEmpty else {
            throw FontRegistrationError.missingPostScriptName(url)
        }

        return names
    }
}

enum FontRegistrationError: Error {
    case fileNotFound(URL)
    case invalidFontFile(URL)
    case missingPostScriptName(URL)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 정도만 해도 중복 호출과 폰트명 추적 문제를 많이 줄일 수 있다. 실제 서비스 코드에서는 여기에 CoreText 에러 코드를 해석하는 로직을 추가하는 것이 좋다.&lt;/p&gt;
&lt;p&gt;예를 들어 이미 등록된 폰트라면 실패로 볼지, 성공으로 간주할지 결정해야 한다. 대부분의 앱 내부 registry에서는 같은 URL을 다시 등록하려는 상황을 성공으로 처리하는 편이 자연스럽다. 반면 동일한 PostScript name을 가진 다른 폰트 파일이 들어오는 경우는 충돌로 보는 것이 맞다.&lt;/p&gt;
&lt;h2&gt;다운로드 폰트는 저장 위치까지 설계해야 한다&lt;/h2&gt;
&lt;p&gt;서버에서 폰트를 내려받아 등록하는 경우에는 파일 위치가 중요하다.&lt;/p&gt;
&lt;p&gt;임시 디렉터리에 저장한 폰트를 등록하고, 나중에 시스템이 그 파일을 참조해야 하는 상황에서 파일이 삭제되면 문제가 생길 수 있다. 폰트 파일은 앱이 관리하는 안정적인 위치에 저장한 뒤 등록하는 편이 좋다. 보통 &lt;code&gt;Application Support&lt;/code&gt; 하위에 앱 전용 디렉터리를 만들고 관리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func fontStorageDirectory() throws -&amp;gt; URL {
    let baseURL = try FileManager.default.url(
        for: .applicationSupportDirectory,
        in: .userDomainMask,
        appropriateFor: nil,
        create: true
    )

    let directory = baseURL.appendingPathComponent(&quot;RuntimeFonts&quot;, isDirectory: true)

    if !FileManager.default.fileExists(atPath: directory.path) {
        try FileManager.default.createDirectory(
            at: directory,
            withIntermediateDirectories: true
        )
    }

    return directory
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다운로드 후에는 파일을 검증하고, 저장하고, 등록한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func installDownloadedFont(from temporaryURL: URL) throws -&amp;gt; [String] {
    let directory = try fontStorageDirectory()
    let destinationURL = directory.appendingPathComponent(temporaryURL.lastPathComponent)

    if FileManager.default.fileExists(atPath: destinationURL.path) {
        try FileManager.default.removeItem(at: destinationURL)
    }

    try FileManager.default.copyItem(at: temporaryURL, to: destinationURL)

    return try RuntimeFontRegistry.shared.registerFont(at: destinationURL)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서도 중요한 건 정책이다. 폰트 파일을 언제 삭제할지, 앱 재실행 시 어떻게 다시 등록할지, 다운로드 실패 시 fallback 폰트를 무엇으로 둘지 정해야 한다.&lt;/p&gt;
&lt;p&gt;런타임 폰트는 UI 문제이기도 하지만 운영 문제이기도 하다. 서버 응답, 캐시 무효화, 앱 버전, 라이선스 정책까지 같이 봐야 한다.&lt;/p&gt;
&lt;h2&gt;Unregister는 가능하지만 남용할 기능은 아니다&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;CTFontManagerUnregisterFontsForURL&lt;/code&gt;은 등록된 font URL을 Font Manager에서 해제한다. 해제된 폰트는 더 이상 font descriptor matching 대상이 아니다.&lt;/p&gt;
&lt;p&gt;하지만 앱 코드에서 이미 만들어진 &lt;code&gt;UIFont&lt;/code&gt;, &lt;code&gt;CTFont&lt;/code&gt;, &lt;code&gt;NSAttributedString&lt;/code&gt;, SwiftUI view가 있을 수 있다. 따라서 unregister를 호출했다고 해서 이미 생성된 모든 UI 객체가 즉시 안전하게 정리된다고 가정하면 안 된다.&lt;/p&gt;
&lt;p&gt;실무적으로는 다음 기준이 낫다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱 실행 중 계속 필요한 폰트는 unregister하지 않는다.&lt;/li&gt;
&lt;li&gt;일시적으로 필요한 다운로드 폰트만 명확한 소유권을 두고 unregister한다.&lt;/li&gt;
&lt;li&gt;파일 삭제 전에는 unregister를 시도한다.&lt;/li&gt;
&lt;li&gt;unregister 실패 시 파일 삭제를 무리하게 진행하지 않는다.&lt;/li&gt;
&lt;li&gt;같은 scope로 등록하고 같은 scope로 해제한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;등록할 때 &lt;code&gt;.process&lt;/code&gt;를 썼다면 해제할 때도 &lt;code&gt;.process&lt;/code&gt;를 써야 한다. scope가 달라지면 기대한 폰트가 해제되지 않을 수 있다.&lt;/p&gt;
&lt;h2&gt;CTFontManagerRegisterGraphicsFont와 혼동하지 않기&lt;/h2&gt;
&lt;p&gt;CoreText에는 &lt;code&gt;CTFontManagerRegisterGraphicsFont&lt;/code&gt;도 있다. 이름만 보면 비슷하지만 목적이 다르다.&lt;/p&gt;
&lt;p&gt;파일에 기반한 폰트라면 &lt;code&gt;CTFontManagerRegisterFontsForURL&lt;/code&gt;을 쓰는 것이 맞다. &lt;code&gt;CTFontManagerRegisterGraphicsFont&lt;/code&gt;는 &lt;code&gt;CGFont&lt;/code&gt;를 등록하는 API이고, 문서에서도 file-backed font는 URL 기반 등록 API를 사용하라고 안내한다.&lt;/p&gt;
&lt;p&gt;앱에서 폰트 파일을 직접 가지고 있다면 URL 기반으로 처리하는 쪽이 구조가 명확하다. 파일 URL을 기준으로 저장, 등록, 해제, 캐싱을 관리할 수 있기 때문이다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;p&gt;런타임 폰트 등록을 도입하기 전에 먼저 아래 항목을 확인하는 것이 좋다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱에 포함된 고정 폰트와 런타임 등록이 필요한 폰트를 구분한다.&lt;/li&gt;
&lt;li&gt;고정 폰트는 &lt;code&gt;UIAppFonts&lt;/code&gt;로 처리할 수 있는지 먼저 확인한다.&lt;/li&gt;
&lt;li&gt;런타임 폰트는 파일 저장 위치를 &lt;code&gt;Application Support&lt;/code&gt; 등으로 명확히 정한다.&lt;/li&gt;
&lt;li&gt;등록 전에 &lt;code&gt;CTFontManagerCreateFontDescriptorsFromURL&lt;/code&gt;로 PostScript name을 읽는다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UIFont(name:size:)&lt;/code&gt;나 &lt;code&gt;Font.custom(_:size:)&lt;/code&gt;에는 파일명이 아니라 실제 폰트 이름을 사용한다.&lt;/li&gt;
&lt;li&gt;폰트 등록은 view 코드가 아니라 registry 계층에서 한 번만 수행한다.&lt;/li&gt;
&lt;li&gt;중복 등록, 잘못된 파일, 손상된 파일, 이름 충돌에 대한 에러 처리를 분리한다.&lt;/li&gt;
&lt;li&gt;앱 재실행 시 필요한 폰트를 다시 등록하는 흐름을 만든다.&lt;/li&gt;
&lt;li&gt;다운로드 폰트라면 fallback 폰트와 실패 UX를 준비한다.&lt;/li&gt;
&lt;li&gt;unregister가 필요한 경우 등록 scope와 해제 scope를 동일하게 관리한다.&lt;/li&gt;
&lt;li&gt;폰트 라이선스, 캐시 정책, 서버 배포 정책을 함께 확인한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;CTFontManagerRegisterFontsForURL&lt;/code&gt;은 런타임에 폰트를 등록할 수 있게 해주는 유용한 API다. 하지만 실제 앱에 넣을 때는 단순 유틸 함수 하나로 끝내기 어렵다.&lt;/p&gt;
&lt;p&gt;핵심은 폰트 파일을 안정적인 위치에 두고, PostScript name을 확인하고, 등록 상태를 앱 내부에서 관리하는 것이다. 그리고 앱에 항상 포함되는 폰트까지 무리하게 런타임 등록으로 바꿀 필요는 없다.&lt;/p&gt;
&lt;p&gt;먼저 프로젝트의 폰트 사용 방식을 정리해보는 것이 좋다. 어떤 폰트는 정적으로 선언하고, 어떤 폰트는 런타임 등록으로 가져갈지 나누는 것부터 시작하면 된다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl%28_%3A_%3A_%3A%29&quot;&gt;Apple Developer Documentation - CTFontManagerRegisterFontsForURL(&lt;em&gt;:&lt;/em&gt;:_:)&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;font URL을 Font Manager에 등록하고 descriptor matching 대상으로 만드는 API 동작을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagerunregisterfontsforurl%28_%3A_%3A_%3A%29&quot;&gt;Apple Developer Documentation - CTFontManagerUnregisterFontsForURL(&lt;em&gt;:&lt;/em&gt;:_:)&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;등록된 font URL을 해제하고 descriptor matching 대상에서 제외하는 동작을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagercreatefontdescriptorsfromurl%28_%3A%29&quot;&gt;Apple Developer Documentation - CTFontManagerCreateFontDescriptorsFromURL(_:)&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;font URL에 포함된 각 폰트의 descriptor를 읽어오는 방식과 PostScript name 추출 흐름을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagerscope/process&quot;&gt;Apple Developer Documentation - CTFontManagerScope.process&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;현재 프로세스 범위에서 폰트를 등록하는 scope의 의미를 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coretext/kctfontnameattribute&quot;&gt;Apple Developer Documentation - kCTFontNameAttribute&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;font descriptor에서 사용하는 폰트 이름 attribute를 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/uifont/init%28name%3Asize%3A%29&quot;&gt;Apple Developer Documentation - UIFont init(name:size:)&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;UIFont(name:size:)&lt;/code&gt;가 받는 font name의 의미를 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/information-property-list/uiappfonts&quot;&gt;Apple Developer Documentation - UIAppFonts&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱 번들에 포함된 app-specific font files를 시스템이 런타임에 로드하는 Info.plist 키를 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/adding-a-custom-font-to-your-app&quot;&gt;Apple Developer Documentation - Adding a custom font to your app&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱에 커스텀 폰트를 추가하고 사용하는 기본 흐름을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/coretext/ctfontmanagerregistergraphicsfont%28_%3A_%3A%29&quot;&gt;Apple Developer Documentation - CTFontManagerRegisterGraphicsFont(&lt;em&gt;:&lt;/em&gt;:)&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;file-backed font는 URL 기반 등록 API를 사용해야 한다는 구분을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>iOS</category><category>Swift</category><category>CoreText</category><category>Font</category></item><item><title>Swift Package Registry, Git URL 의존성 이후를 준비하는 방법</title><link>https://jaemyeong.com/ko/blog/swift-package-registry/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/swift-package-registry/</guid><description>SwiftPM의 Git URL 의존성을 넘어 scope.package-name 식별자 기반 Package Registry로. iOS 팀이 도입 전에 봐야 할 CI 속도, 의존성 재현성, 사내 패키지 배포, 패키지 서명, Xcode 설정을 정리한다.</description><pubDate>Wed, 17 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Swift Package Manager를 쓰다 보면 자연스럽게 Git URL 기반 의존성에 익숙해진다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(url: &quot;https://github.com/apple/swift-log.git&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;지금까지는 이 방식이 Swift 패키지를 가져오는 가장 자연스러운 흐름이었다. 하지만 SwiftPM에는 이미 Git repository URL이 아니라 &lt;code&gt;scope.package-name&lt;/code&gt; 형태의 패키지 식별자로 의존성을 해석하고 다운로드할 수 있는 Package Registry 기능이 들어와 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(id: &quot;apple.swift-log&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 변화는 단순히 URL을 문자열 하나 바꾸는 정도가 아니다. iOS 앱 개발자 입장에서는 CI 속도, 의존성 재현성, 사내 패키지 배포, 보안 정책, Xcode 프로젝트 설정까지 같이 봐야 하는 주제다.&lt;/p&gt;
&lt;h2&gt;Swift Package Registry가 해결하려는 문제&lt;/h2&gt;
&lt;p&gt;기존 SwiftPM은 의존성을 Git repository URL로 지정한다. SwiftPM은 처음 빌드할 때 repository를 clone하고, tag를 기준으로 버전을 해석한다. 이 방식은 이해하기 쉽고 GitHub 중심 생태계와 잘 맞는다. 하지만 팀 규모가 커지고 의존성이 많아지면 몇 가지 문제가 생긴다.&lt;/p&gt;
&lt;p&gt;가장 먼저 체감되는 건 속도다. 필요한 건 특정 버전의 source archive인데, Git clone은 repository history까지 다루는 방식이다. repository가 크거나 dependency graph가 복잡하면 CI에서 패키지 resolve 시간이 무시하기 어려워진다.&lt;/p&gt;
&lt;p&gt;재현성도 문제다. Git tag는 원칙적으로 다시 가리킬 수 있다. 물론 좋은 운영에서는 그러지 않지만, 기술적으로 불가능한 건 아니다. repository가 이동하거나 삭제되면 같은 &lt;code&gt;Package.resolved&lt;/code&gt;를 가지고도 나중에 빌드가 실패할 수 있다.&lt;/p&gt;
&lt;p&gt;엔터프라이즈 환경에서는 더 직접적인 문제가 생긴다. 회사 보안 정책상 &lt;code&gt;github.com&lt;/code&gt; 접근이 차단되어 있으면 SwiftPM이 GitHub에서 dependency를 clone하지 못해 CI가 실패한다. 이 경우 보안팀은 보통 JFrog Artifactory, 내부 Git mirror, 내부 artifact 저장소 같은 승인된 경로를 요구한다. Swift Package Registry는 이런 환경에서 GitHub 직접 접근 없이 패키지를 공급하는 구조를 만들 수 있는 기반이 된다.&lt;/p&gt;
&lt;h2&gt;Git URL 대신 패키지 식별자로 의존성을 선언한다&lt;/h2&gt;
&lt;p&gt;Package Registry의 핵심은 package identity가 Git URL에서 scoped identifier로 바뀐다는 점이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(id: &quot;mona.LinkedList&quot;, .upToNextMajor(from: &quot;1.0.0&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 &lt;code&gt;mona&lt;/code&gt;는 scope이고, &lt;code&gt;LinkedList&lt;/code&gt;는 package name이다. 둘을 합친 &lt;code&gt;mona.LinkedList&lt;/code&gt;가 registry dependency의 식별자가 된다.&lt;/p&gt;
&lt;p&gt;SwiftPM은 registry가 설정되어 있으면 이 identifier를 기준으로 registry에 요청을 보낸다. 먼저 &lt;code&gt;/{scope}/{name}&lt;/code&gt;으로 사용 가능한 release 목록을 가져오고, 필요한 버전의 &lt;code&gt;Package.swift&lt;/code&gt; manifest를 가져온 뒤, 최종적으로 &lt;code&gt;/{scope}/{name}/{version}.zip&lt;/code&gt; source archive를 다운로드한다.&lt;/p&gt;
&lt;p&gt;이 구조가 중요한 이유는 Git clone이 아니라 HTTP 기반 artifact 다운로드로 의존성을 가져올 수 있기 때문이다. source archive는 immutable하게 관리할 수 있고, CDN이나 내부 artifact 저장소와도 잘 맞는다. 패키지 배포를 source control과 분리할 수 있다는 점도 크다.&lt;/p&gt;
&lt;p&gt;다만 여기서 한 가지를 분명히 해야 한다. Registry가 Git을 없애는 것은 아니다. 코드는 여전히 GitHub, GitLab, GitHub Enterprise Server, 사내 Git 서버에서 개발할 수 있다. Registry는 개발 중인 source repository를 대체한다기보다, 특정 release를 소비자에게 배포하는 경로를 표준화하는 쪽에 가깝다.&lt;/p&gt;
&lt;h2&gt;설정은 간단하지만, Xcode 프로젝트는 별도로 봐야 한다&lt;/h2&gt;
&lt;p&gt;Swift Package 기준으로 registry 설정은 비교적 단순하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swift package-registry set https://packages.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;프로젝트 단위 설정은 다음 위치에 저장된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.swiftpm/configuration/registries.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;사용자 전역 설정은 다음 위치에 저장된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.swiftpm/configuration/registries.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;생성되는 설정은 대략 이런 형태다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;registries&quot;: {
    &quot;[default]&quot;: {
      &quot;url&quot;: &quot;https://packages.example.com&quot;
    }
  },
  &quot;version&quot;: 1
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swift Package 라이브러리를 개발하는 경우에는 이 흐름이 자연스럽다. &lt;code&gt;Package.swift&lt;/code&gt;가 있고, 그 안에서 &lt;code&gt;.package(id:)&lt;/code&gt;를 선언하면 된다.&lt;/p&gt;
&lt;p&gt;하지만 iOS 앱의 Xcode 프로젝트는 조금 다르다. 일반적인 앱 프로젝트에는 &lt;code&gt;Package.swift&lt;/code&gt;가 없다. Xcode가 &lt;code&gt;project.pbxproj&lt;/code&gt;, workspace, &lt;code&gt;Package.resolved&lt;/code&gt;를 통해 SwiftPM dependency를 관리한다. 그래서 실제 앱 프로젝트에 registry를 도입하려면 “SwiftPM CLI에서 설정했으니 Xcode도 알아서 되겠지”라고 보면 안 된다.&lt;/p&gt;
&lt;p&gt;실무적으로는 Xcode workspace나 project 내부의 SwiftPM configuration 위치를 확인해야 한다. 팀 단위로 적용할 경우에는 registry URL 설정 파일은 공유하되, access token 같은 인증 정보는 절대 repository에 커밋하지 않아야 한다.&lt;/p&gt;
&lt;p&gt;이 지점 때문에 도구가 필요해진다. Xcode workspace나 &lt;code&gt;.xcodeproj&lt;/code&gt;를 받아서 &lt;code&gt;registries.json&lt;/code&gt; 위치를 찾아주고, 필요한 경우 &lt;code&gt;mirrors.json&lt;/code&gt;까지 만들어주는 CLI나 GUI Setup Assistant가 있으면 도입 난이도가 크게 낮아진다.&lt;/p&gt;
&lt;h2&gt;Registry와 Git dependency를 섞을 때 생기는 충돌&lt;/h2&gt;
&lt;p&gt;Swift Package Registry를 도입할 때 가장 조심해야 하는 부분은 Git URL dependency와 registry dependency가 같은 graph 안에 섞이는 상황이다.&lt;/p&gt;
&lt;p&gt;예를 들어 앱에서는 registry 방식으로 &lt;code&gt;apple.swift-log&lt;/code&gt;를 직접 추가했다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(id: &quot;apple.swift-log&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그런데 다른 dependency가 transitive dependency로 Git URL 방식의 &lt;code&gt;swift-log&lt;/code&gt;를 끌고 올 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(url: &quot;https://github.com/apple/swift-log.git&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 코드는 같은 패키지지만, SwiftPM 입장에서는 origin이 다르면 서로 다른 package identity로 볼 수 있다. 이 경우 같은 module이나 product가 중복되어 들어오면서 product lookup 문제나 symbol 충돌이 생길 수 있다.&lt;/p&gt;
&lt;p&gt;이를 완화하기 위해 registry specification에는 &lt;code&gt;/identifiers?url=&lt;/code&gt; lookup endpoint가 있다. Git URL에 해당하는 registry package identifier를 찾아주는 역할이다. registry가 &lt;code&gt;https://github.com/apple/swift-log.git&lt;/code&gt;를 &lt;code&gt;apple.swift-log&lt;/code&gt;로 정확히 매핑할 수 있어야 SwiftPM이 URL 기반 dependency와 registry identifier를 더 안전하게 연결할 수 있다.&lt;/p&gt;
&lt;p&gt;그래도 실무적으로는 원칙을 단순하게 가져가는 편이 좋다. 같은 dependency graph 안에서는 같은 패키지를 하나의 origin으로 통일해야 한다. registry로 전환할 패키지는 &lt;code&gt;.package(id:)&lt;/code&gt;로 명시적으로 전환하고, 아직 전환하지 않은 패키지는 Git mirror나 기존 Git URL 방식으로 유지하는 식의 단계적 전략이 필요하다.&lt;/p&gt;
&lt;h2&gt;Mirror는 Registry가 아니지만 전환 과정에서 유용하다&lt;/h2&gt;
&lt;p&gt;SwiftPM에는 dependency mirroring 기능도 있다. Mirror는 기존 Git URL dependency를 다른 Git URL로 바꿔서 fetch하도록 만드는 기능이다.&lt;/p&gt;
&lt;p&gt;예를 들어 원래 dependency가 GitHub를 가리키고 있어도:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.package(url: &quot;https://github.com/apple/swift-log.git&quot;, from: &quot;1.5.0&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;mirror 설정을 통해 실제 fetch는 사내 Git mirror에서 하게 만들 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;object&quot;: [
    {
      &quot;original&quot;: &quot;https://github.com/apple/swift-log.git&quot;,
      &quot;mirror&quot;: &quot;https://git.company.internal/mirrors/apple/swift-log.git&quot;
    }
  ],
  &quot;version&quot;: 1
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 건 mirror와 registry를 구분하는 것이다. Mirror는 &lt;code&gt;.package(url:)&lt;/code&gt;를 유지한다. Git URL dependency를 다른 Git URL로 치환할 뿐이다. 반면 registry는 &lt;code&gt;.package(id:)&lt;/code&gt;를 사용하고, source archive를 registry에서 다운로드한다.&lt;/p&gt;
&lt;p&gt;보안망이나 내부망 환경에서는 mirror가 꽤 실용적이다. 기존 Xcode 프로젝트를 한 번에 registry identifier로 바꾸기 어렵다면, 먼저 GitHub 접근을 내부 mirror로 돌리고, 이후 안정적으로 registry 기반 배포로 전환할 수 있다.&lt;/p&gt;
&lt;h2&gt;인증과 패키지 서명은 별개의 문제다&lt;/h2&gt;
&lt;p&gt;Registry를 private하게 운영하려면 인증이 필요하다. SwiftPM은 &lt;code&gt;swift package-registry login&lt;/code&gt; 명령으로 registry 인증 정보를 저장할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swift package-registry login https://packages.example.com --token &quot;$TOKEN&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CI에서는 &lt;code&gt;SWIFTPM_REGISTRY_TOKEN&lt;/code&gt; 같은 환경변수를 사용할 수 있다. 여기까지는 “registry에 접근할 권한”의 문제다.&lt;/p&gt;
&lt;p&gt;패키지 서명은 다른 문제다. 서명은 “이 package artifact가 특정 private key를 가진 publisher 또는 조직에 의해 만들어졌고, 이후 변조되지 않았다”는 증거를 제공한다. SwiftPM은 서명된 archive를 받을 때 &lt;code&gt;X-Swift-Package-Signature-Format&lt;/code&gt;, &lt;code&gt;X-Swift-Package-Signature&lt;/code&gt; header를 확인하고, 서명과 인증서 체인을 검증한다.&lt;/p&gt;
&lt;p&gt;서명하려면 code signing 용도의 X.509 인증서와 private key가 필요하다. macOS Keychain의 signing identity를 사용할 수도 있고, CI에서는 private key와 certificate chain 파일을 넘기는 방식도 가능하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swift package-registry publish mycompany.design-system 1.2.0 \
  --url https://packages.example.com \
  --private-key-path ./private-key.pkcs8.der \
  --cert-chain-paths ./leaf-cert.der ./intermediate.der ./root.der
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;엔터프라이즈 환경에서는 이 부분이 특히 중요하다. 단순히 “서명되어 있다”가 아니라, 어떤 인증서가 어떤 scope나 package를 서명할 수 있는지 정책으로 관리해야 한다. 내부 CA를 쓰는 조직이라면 SwiftPM trust root 설정도 같이 배포해야 한다.&lt;/p&gt;
&lt;h2&gt;iOS 팀이 실제로 도입할 때 봐야 할 것&lt;/h2&gt;
&lt;p&gt;Swift Package Registry는 좋아 보이지만, 모든 팀이 바로 전환해야 하는 기능은 아니다. 작은 앱에서 의존성이 몇 개 없고 GitHub 접근에 문제가 없다면 체감 이득이 크지 않을 수 있다.&lt;/p&gt;
&lt;p&gt;반대로 다음 조건에 해당하면 진지하게 볼 만하다.&lt;/p&gt;
&lt;p&gt;CI에서 SwiftPM resolve 시간이 길다. 사내 Swift package가 많다. GitHub 접근이 제한된 네트워크에서 개발하거나 빌드한다. JFrog, Nexus, Artifactory 같은 내부 artifact 저장소를 이미 운영한다. SDK를 고객사에 private하게 배포해야 한다. 패키지 checksum, signing, audit log 같은 공급망 보안 요구사항이 있다.&lt;/p&gt;
&lt;p&gt;특히 iOS 플랫폼 팀이 별도로 있는 조직이라면 Registry는 단순 개발 편의가 아니라 플랫폼 운영 문제에 가깝다. 어떤 패키지를 승인할지, 어떤 registry나 mirror를 사용할지, Xcode 프로젝트 설정을 어떻게 공유할지, CI에서 외부 host 접근을 어떻게 막을지까지 함께 설계해야 한다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;현재 프로젝트의 SwiftPM dependency 목록을 먼저 정리한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Package.resolved&lt;/code&gt;와 Xcode project 안에 어떤 Git URL이 들어있는지 확인한다.&lt;/li&gt;
&lt;li&gt;CI에서 패키지 resolve 시간이 얼마나 걸리는지 측정한다.&lt;/li&gt;
&lt;li&gt;GitHub, GitHubusercontent, 외부 binary artifact URL 접근이 회사 네트워크에서 허용되는지 확인한다.&lt;/li&gt;
&lt;li&gt;registry로 전환할 package와 mirror로 유지할 package를 구분한다.&lt;/li&gt;
&lt;li&gt;같은 package가 Git URL과 registry ID로 동시에 들어오는 mixed graph를 검사한다.&lt;/li&gt;
&lt;li&gt;Xcode workspace 또는 &lt;code&gt;.xcodeproj&lt;/code&gt; 기준으로 &lt;code&gt;registries.json&lt;/code&gt;을 어디에 둘지 정한다.&lt;/li&gt;
&lt;li&gt;private registry를 쓴다면 token 저장 방식과 CI 환경변수 전략을 정한다.&lt;/li&gt;
&lt;li&gt;package signing을 요구할지, 요구한다면 어떤 CA와 인증서를 신뢰할지 정한다.&lt;/li&gt;
&lt;li&gt;사내 JFrog나 Git mirror가 있다면 SwiftPM registry/mirror 전략과 어떻게 연결할지 검토한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Swift Package Registry는 SwiftPM의 의존성 배포 방식을 Git 중심에서 artifact 중심으로 확장하는 기능이다. 단순히 &lt;code&gt;.package(url:)&lt;/code&gt;을 &lt;code&gt;.package(id:)&lt;/code&gt;로 바꾸는 이야기가 아니다.&lt;/p&gt;
&lt;p&gt;iOS 앱 개발자 입장에서는 CI 속도, 재현성, Xcode 설정, 내부망 빌드, package signing, 사내 패키지 운영까지 연결되는 주제다. 그래서 도입 여부는 기능 하나만 보고 판단하기보다 팀의 빌드 환경과 보안 요구사항을 같이 봐야 한다.&lt;/p&gt;
&lt;p&gt;바로 할 수 있는 첫 번째 액션은 단순하다. 지금 프로젝트의 SwiftPM dependency graph를 열어보고, 어떤 패키지가 GitHub에 직접 의존하고 있는지 확인하는 것이다. 거기서부터 registry가 필요한 팀인지, mirror만으로 충분한 팀인지가 보이기 시작한다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0292-package-registry-service.md&quot;&gt;Swift Evolution - SE-0292: Package Registry Service&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Swift Package Registry의 도입 배경, Git 기반 dependency resolution의 한계, &lt;code&gt;scope.package-name&lt;/code&gt; 식별자, registry endpoint, checksum 동작을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md&quot;&gt;Swift Package Manager - Package Registry Usage&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;swift package-registry set&lt;/code&gt;, &lt;code&gt;registries.json&lt;/code&gt; 위치, &lt;code&gt;.package(id:)&lt;/code&gt; 사용법, registry authentication, CI 환경변수, signing validation, publish 명령 사용법을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md&quot;&gt;Swift Package Manager - Swift Package Registry Service Specification&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;registry service endpoint, release metadata, manifest fetch, source archive download, &lt;code&gt;/identifiers?url=&lt;/code&gt;, publish multipart request, problem response, checksum, signature header 요구사항을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0321-package-registry-publish.md&quot;&gt;Swift Evolution - SE-0321: Package Registry Service - Publish Endpoint&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;registry publish endpoint의 목적, source archive 업로드 흐름, 동기/비동기 publish 처리 개념을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0378-package-registry-auth.md&quot;&gt;Swift Evolution - SE-0378: Package Registry Authentication&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;swift package-registry login/logout&lt;/code&gt;, token authentication, credential 저장 방식의 방향을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0391-package-registry-publish.md&quot;&gt;Swift Evolution - SE-0391: Package Registry Publish&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;swift package-registry publish&lt;/code&gt;, package signing, metadata, signing certificate와 signature 처리 방향을 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/swiftlang/swift-evolution/blob/main/proposals/0219-package-manager-dependency-mirroring.md&quot;&gt;Swift Evolution - SE-0219: Package Manager Dependency Mirroring&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SwiftPM mirror 기능의 목적과 registry와의 차이를 설명하는 데 참고했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-tech</category><category>Swift</category><category>SwiftPM</category><category>Package Registry</category><category>iOS</category></item><item><title>WWDC26, iOS 개발자가 먼저 봐야 할 변화들</title><link>https://jaemyeong.com/ko/blog/wwdc26-ios-developer-changes/</link><guid isPermaLink="true">https://jaemyeong.com/ko/blog/wwdc26-ios-developer-changes/</guid><description>App Intents·Foundation Models·Core AI·Xcode 27까지, iOS 개발자 관점에서 먼저 봐야 할 WWDC26의 변화들.</description><pubDate>Wed, 17 Jun 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;WWDC26 주요 변경점을 훑어봤다.&lt;/p&gt;
&lt;p&gt;이번 WWDC를 보면서 가장 먼저 든 생각은 이거였다. 이제 앱은 사용자가 직접 열어서 쓰는 화면만 잘 만들어서는 부족하다. 앱의 콘텐츠와 기능을 Siri, Spotlight, Apple Intelligence가 이해할 수 있는 구조로 만들어야 한다.&lt;/p&gt;
&lt;p&gt;Apple은 WWDC26에서 iOS 27, iPadOS 27, macOS 27, watchOS 27, visionOS 27, tvOS 27을 공개했다. 표면적으로는 Siri AI, Apple Intelligence, Liquid Glass, Xcode 27, StoreKit 개선처럼 여러 변화가 흩어져 있는 것처럼 보인다. 그런데 iOS 개발자 관점에서 보면 방향은 꽤 명확하다.&lt;/p&gt;
&lt;p&gt;앱이 시스템과 더 깊게 연결되고 있다.&lt;/p&gt;
&lt;p&gt;사용자는 앱을 열고, 탭을 찾고, 버튼을 누르는 방식으로만 기능을 쓰지 않는다. Siri에게 말하고, Spotlight에서 찾고, 화면에 보이는 내용을 기준으로 다음 행동을 요청할 수 있다. 그러려면 앱은 자기 안의 콘텐츠와 액션을 시스템이 이해할 수 있는 형태로 제공해야 한다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 WWDC26에서 iOS 개발자가 먼저 봐야 할 변화를 App Intents, Foundation Models, Core AI, Xcode 27, Liquid Glass, StoreKit 중심으로 정리해본다.&lt;/p&gt;
&lt;h2&gt;Siri AI와 App Intents: 앱을 시스템에 설명하는 일&lt;/h2&gt;
&lt;p&gt;이번 WWDC26에서 가장 먼저 봐야 할 부분은 App Intents라고 생각한다.&lt;/p&gt;
&lt;p&gt;Siri AI는 Apple Intelligence 기반으로 앱 안의 더 많은 작업과 연결된다. 여기서 핵심 역할을 하는 것이 App Intents다. App Intents는 앱의 콘텐츠와 액션을 시스템에 노출하고, Siri가 자연어로 그 기능을 사용할 수 있게 만든다.&lt;/p&gt;
&lt;p&gt;예전에는 App Intents나 Shortcuts 대응을 하면 사용자 편의 기능을 잘 챙겼다는 느낌에 가까웠다. 이제는 조금 다르다. 앱의 주요 기능을 시스템이 이해할 수 있게 만드는 기본 작업에 가까워졌다.&lt;/p&gt;
&lt;p&gt;특히 Entity schema, Intent schema, View Annotations API가 중요해 보인다. Entity schema는 앱의 콘텐츠를 Spotlight semantic index에 제공한다. Intent schema는 사용자가 특정 명령어를 외우지 않아도 자연어로 앱의 액션을 실행할 수 있게 한다. View Annotations API는 화면의 view와 entity를 연결해, 사용자가 화면에 보이는 대상을 자연스럽게 지칭할 수 있게 만든다.&lt;/p&gt;
&lt;p&gt;이건 단순히 API 하나 붙이는 작업이 아니다.&lt;/p&gt;
&lt;p&gt;예를 들어 스포츠 앱이라면 경기, 팀, 리그, 일정, 하이라이트 같은 entity를 어떻게 정의할지 봐야 한다. 커머스 앱이라면 상품, 주문, 장바구니, 배송 상태가 중요해질 수 있다. 병원 예약 앱이라면 병원, 의사, 예약, 접수 상태 같은 모델이 대상이 될 것이다.&lt;/p&gt;
&lt;p&gt;결국 개발자는 화면 단위로만 생각하는 게 아니라, 앱의 도메인 모델을 시스템에 어떻게 설명할지 같이 설계해야 한다. 이 작업을 나중에 붙이는 부가 기능으로 보면 꽤 어려워질 수 있다.&lt;/p&gt;
&lt;p&gt;개인적으로는 올해 App Intents를 &quot;나중에 시간 나면 붙이는 기능&quot;으로 보면 안 된다고 생각한다. 앱의 핵심 entity와 action을 먼저 정리하고, 어떤 기능을 Siri와 Spotlight에 노출할지 기준을 잡는 것부터 시작하는 게 현실적이다.&lt;/p&gt;
&lt;h2&gt;Foundation Models와 Core AI: AI 기능의 실행 위치가 설계 문제가 된다&lt;/h2&gt;
&lt;p&gt;AI 기능을 앱에 넣는 방식도 바뀌고 있다.&lt;/p&gt;
&lt;p&gt;Foundation Models framework는 Apple Intelligence를 구동하는 온디바이스 모델에 접근할 수 있는 Swift API다. WWDC26에서는 Apple Foundation Models뿐 아니라 Claude, Gemini 같은 클라우드 모델이나 Language Model protocol을 따르는 다른 provider도 함께 사용할 수 있는 방향으로 확장됐다.&lt;/p&gt;
&lt;p&gt;여기에 multimodal prompt, Dynamic Profiles, Evaluations framework도 같이 봐야 한다. 이미지를 텍스트와 함께 넘겨 모델이 시각 콘텐츠를 이해하게 하거나, 세션 중 모델과 도구, instruction을 바꾸거나, AI 기능이 다양한 조건에서 기대한 대로 동작하는지 평가할 수 있다.&lt;/p&gt;
&lt;p&gt;지금까지 앱에 AI 기능을 붙인다고 하면 서버에서 LLM API를 호출하고, 앱은 결과를 보여주는 구조가 많았다. 그런데 이제는 선택지가 더 세분화된다.&lt;/p&gt;
&lt;p&gt;간단한 요약, 분류, 추천, 입력 보정 같은 작업은 온디바이스 모델로 처리할 수 있을지 먼저 볼 수 있다. 더 큰 추론이나 생성 작업은 Private Cloud Compute나 외부 provider를 고려할 수 있다. 사용자의 민감한 데이터가 들어가는 기능이라면 서버로 보내기 전에 온디바이스 처리 가능성을 먼저 확인해야 한다.&lt;/p&gt;
&lt;p&gt;Core AI도 별도로 봐야 한다.&lt;/p&gt;
&lt;p&gt;Core AI는 Apple Silicon에 맞춰 자체 AI 모델을 온디바이스에서 실행하기 위한 프레임워크다. Apple은 Core AI를 OS에 내장된 새 프레임워크로 소개하고 있고, Swift API, ahead-of-time compilation, zero-copy data path, stateful execution 같은 성능 관련 기능을 제공한다.&lt;/p&gt;
&lt;p&gt;정리하면 Foundation Models는 Apple Intelligence 기반의 언어·멀티모달 경험을 앱에 연결하는 쪽에 가깝고, Core AI는 개발자가 가진 모델을 Apple Silicon 위에서 직접 실행하는 쪽에 가깝다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 건 &quot;AI를 넣을 수 있다&quot;가 아니다. 어떤 작업을 기기 안에서 처리할지, 어떤 작업을 클라우드 모델로 보낼지, 비용과 지연 시간과 개인정보를 어떻게 균형 잡을지가 설계 이슈가 됐다.&lt;/p&gt;
&lt;p&gt;AI 기능을 추가할 일이 있다면 이제는 서버 API부터 붙이기보다 다음 질문을 먼저 해보는 게 좋겠다.&lt;/p&gt;
&lt;p&gt;이 작업은 온디바이스로 충분한가. 사용자 데이터가 외부로 나가도 되는가. 응답 속도는 얼마나 중요할까. 테스트와 평가를 어떻게 자동화할 수 있을까.&lt;/p&gt;
&lt;p&gt;이 질문에 대한 답이 AI 기능의 구조를 결정하게 될 가능성이 크다.&lt;/p&gt;
&lt;h2&gt;Xcode 27: 개발 도구도 에이전트 중심으로 이동&lt;/h2&gt;
&lt;p&gt;Xcode 27도 변화가 크다.&lt;/p&gt;
&lt;p&gt;Apple은 Xcode 27을 Apple Silicon 전용으로 전환했다. 프로젝트 로딩은 더 빨라졌고, 설정은 iCloud로 동기화되며, toolbar는 커스터마이즈할 수 있다. Device Hub는 Simulator를 대체하면서 가상 기기와 실제 기기를 한 곳에서 다룰 수 있게 한다.&lt;/p&gt;
&lt;p&gt;더 눈에 띄는 건 Xcode agents다.&lt;/p&gt;
&lt;p&gt;Xcode agents는 테스트 실행, Playground 실험, Simulator에서 앱 실행, 이슈 수정, 로컬라이제이션 같은 작업을 수행할 수 있다. 플러그인은 skills, MCP tools, Agent Client Protocol을 통해 확장된다.&lt;/p&gt;
&lt;p&gt;이건 생산성 측면에서 분명히 좋다. 반복적인 수정, 테스트 실행, 간단한 리팩터링, 로컬라이제이션 작업은 에이전트가 많이 줄여줄 수 있다. 특히 작은 단위의 변경을 빠르게 검증하거나, 테스트 실패 원인을 찾거나, string catalog를 정리하는 작업에서는 체감이 클 수 있다.&lt;/p&gt;
&lt;p&gt;다만 팀 단위로 사용할 때는 기준이 필요하다고 본다.&lt;/p&gt;
&lt;p&gt;에이전트가 수정한 코드는 반드시 diff를 봐야 한다. 아키텍처 변경, 인증, 결제, 데이터 저장소, 동기화 로직처럼 영향 범위가 큰 영역은 더 엄격하게 검토해야 한다. 에이전트가 만들어낸 코드는 &quot;자동으로 맞는 코드&quot;가 아니라 &quot;검토해야 할 초안&quot;에 가깝다.&lt;/p&gt;
&lt;p&gt;개발 도구가 똑똑해질수록 개발자의 역할이 사라지는 게 아니라, 판단해야 할 지점이 달라진다. 반복 작업은 도구에 맡기되, 구조와 품질에 대한 결정은 개발자가 계속 가져가야 한다.&lt;/p&gt;
&lt;p&gt;그리고 Xcode 27이 Apple Silicon 전용이라는 점은 개발 장비와 CI 환경에도 영향을 준다. 아직 Intel Mac 기반 개발 환경이나 빌드 머신이 남아 있다면 전환 계획을 잡아야 한다.&lt;/p&gt;
&lt;h2&gt;Liquid Glass와 UI: 좋아졌지만 다시 테스트해야 한다&lt;/h2&gt;
&lt;p&gt;iOS 27에서는 Liquid Glass도 다듬어졌다.&lt;/p&gt;
&lt;p&gt;Apple은 Liquid Glass의 readability를 높이기 위해 refraction을 더 균일하게 만들고 contrast를 개선했다고 설명한다. 앱 아이콘도 더 선명해졌고, 사용자가 Liquid Glass 표현을 ultraclear부터 fully tinted까지 조절할 수 있는 슬라이더도 추가됐다.&lt;/p&gt;
&lt;p&gt;성능 개선도 함께 언급됐다. iOS 27에서는 앱 실행이 최대 30% 빨라지고, Photos의 새 사진 로딩은 최대 70%, AirDrop 전송은 최대 80% 빨라진다고 한다.&lt;/p&gt;
&lt;p&gt;시스템 차원의 개선은 반갑다. 하지만 앱 개발자 입장에서는 &quot;이제 괜찮아졌겠지&quot;라고 넘기면 안 된다.&lt;/p&gt;
&lt;p&gt;커스텀 navigation bar, tab bar, toolbar, blur 배경, 반투명 overlay, floating button을 많이 쓰는 앱은 다시 봐야 한다. Dynamic Type, 다크 모드, Reduce Transparency, 고대비 설정에서도 주요 화면이 제대로 읽히는지 확인해야 한다.&lt;/p&gt;
&lt;p&gt;특히 iOS 앱이 iPad나 Mac의 iPhone Mirroring 환경에서 더 큰 디스플레이를 활용할 수 있도록 resizable해진 점도 중요하다. iPhone 화면 크기만 전제로 만든 레이아웃은 예상하지 못한 방식으로 늘어날 수 있다.&lt;/p&gt;
&lt;p&gt;UI 테스트는 스크린샷만 보는 것으로 끝내기 어렵다. 실제 기기에서 터치 영역, 대비, 스크롤 감각, 전환 애니메이션까지 봐야 한다. Liquid Glass는 시각적으로 예쁜지보다, 실제로 읽기 쉽고 조작하기 쉬운지가 더 중요하다.&lt;/p&gt;
&lt;h2&gt;App Store와 StoreKit: 운영 쪽 변경도 작지 않다&lt;/h2&gt;
&lt;p&gt;App Store와 StoreKit 쪽도 실무 영향이 있다.&lt;/p&gt;
&lt;p&gt;App Store Connect의 In-App Purchase 제출 경험이 개선되어 여러 IAP와 구독을 하나의 제출로 묶거나, In-App Events, custom product pages, product page optimization tests와 함께 제출할 수 있게 된다.&lt;/p&gt;
&lt;p&gt;구독 앱에서 특히 볼 만한 건 Retention Messaging이다. 사용자가 구독을 취소하려는 시점에 구독 가치를 다시 설명하거나 특별 제안을 보여줄 수 있다. Apple은 이 기능이 cancellation flow에 추가 friction을 만들지 않으면서 메시지를 제공할 수 있다고 설명한다.&lt;/p&gt;
&lt;p&gt;Subscription Bundles와 Suites도 중요하다. 여러 자동 갱신 구독을 하나의 구독으로 묶거나, 단독 판매하지 않는 구독 세트를 하나의 상품으로 구성할 수 있다. Group Purchases와 Volume Purchasing은 개인 사용자를 넘어 팀, 조직, 교육 기관, 기업 단위 판매까지 고려할 수 있게 한다.&lt;/p&gt;
&lt;p&gt;구독 앱을 운영한다면 이건 단순한 StoreKit 변경이 아니다. 가격 정책, 상품 구성, 해지 방어, 조직 판매 전략까지 같이 볼 수 있는 변경이다.&lt;/p&gt;
&lt;p&gt;개발 관점에서는 StoreKit 구현만 보면 부족하다. 상품 구조, App Store Connect 운영 프로세스, 심사 제출 방식, 결제 후 권한 처리, 구독 해지 흐름까지 같이 봐야 한다. 운영 정책과 코드 구조가 함께 움직이는 영역이다.&lt;/p&gt;
&lt;h2&gt;그래서 무엇부터 보면 좋을까&lt;/h2&gt;
&lt;p&gt;내 기준으로 WWDC26 이후 iOS 앱에서 먼저 확인할 항목은 이 정도다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱의 주요 entity와 action을 정리한다.&lt;/li&gt;
&lt;li&gt;App Intents로 노출할 수 있는 기능을 목록화한다.&lt;/li&gt;
&lt;li&gt;Spotlight semantic index에 제공할 콘텐츠 구조를 확인한다.&lt;/li&gt;
&lt;li&gt;View Annotations API가 필요한 화면을 검토한다.&lt;/li&gt;
&lt;li&gt;App Intents Testing framework로 검증할 수 있는 범위를 확인한다.&lt;/li&gt;
&lt;li&gt;Foundation Models와 Core AI 적용 가능성을 분리해서 본다.&lt;/li&gt;
&lt;li&gt;AI 기능의 온디바이스 처리, Private Cloud Compute, 외부 provider 사용 기준을 정한다.&lt;/li&gt;
&lt;li&gt;Evaluations framework로 AI 기능을 어떻게 검증할지 검토한다.&lt;/li&gt;
&lt;li&gt;Liquid Glass, Dynamic Type, 다크 모드, 접근성 설정에서 주요 화면을 다시 테스트한다.&lt;/li&gt;
&lt;li&gt;iPad와 iPhone Mirroring 환경에서 resizable 대응을 확인한다.&lt;/li&gt;
&lt;li&gt;Xcode 27 전환을 위해 개발 장비와 CI 환경을 점검한다.&lt;/li&gt;
&lt;li&gt;Xcode agents 사용 범위와 코드 리뷰 기준을 팀 차원에서 정한다.&lt;/li&gt;
&lt;li&gt;구독 앱이라면 Retention Messaging, Subscription Bundles, Group Purchases를 운영 정책과 함께 검토한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;WWDC26은 새 API가 많이 나온 행사로만 보기에는 방향성이 꽤 분명하다.&lt;/p&gt;
&lt;p&gt;Apple은 앱을 더 깊게 시스템과 연결하려고 한다. Siri가 앱의 기능을 자연어로 실행하고, Spotlight가 앱 콘텐츠를 더 잘 찾고, Foundation Models와 Core AI가 AI 기능을 앱 안으로 가져오고, Xcode agents가 개발 과정 자체를 바꾸고 있다.&lt;/p&gt;
&lt;p&gt;결국 iOS 개발자가 해야 할 일도 조금 바뀐다.&lt;/p&gt;
&lt;p&gt;좋은 화면을 만들고, 안정적인 네트워크 레이어를 만들고, 성능 좋은 리스트를 구현하는 건 여전히 중요하다. 여기에 하나가 더해졌다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;우리 앱을 시스템이 이해할 수 있게 만드는 것.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;올해는 App Intents를 가볍게 넘기지 않는 게 좋겠다. 앱의 도메인 모델, 사용자 액션, 검색 가능한 콘텐츠를 정리하는 것부터 시작하면 WWDC26 변화에 꽤 현실적으로 대응할 수 있을 것 같다.&lt;/p&gt;
&lt;h2&gt;출처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://www.apple.com/newsroom/2026/06/apple-unveils-next-generation-of-apple-intelligence-siri-ai-and-more/&quot;&gt;Apple Newsroom - WWDC26: Apple unveils next generation of Apple Intelligence, Siri AI, and more&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WWDC26 전체 발표 방향, Apple Intelligence, Siri AI, iOS 27·iPadOS 27·macOS 27 등 플랫폼 업데이트 내용 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/wwdc26/guides/ios/&quot;&gt;Apple Developer - WWDC26 iOS guide&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Foundation Models framework, App Intents framework, Entity schema, Intent schema, View Annotations API, App Intents Testing framework, Core AI 내용 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/news/?id=lvart8mq&quot;&gt;Apple Developer - 5 takeaways from the Platforms State of the Union&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Apple Intelligence와 App Intents 연결, Foundation Models 확장, Core AI, iOS 앱 resizable 대응, Xcode 27 Apple Silicon 전용 전환, Xcode agents 내용 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/wwdc26/guides/xcode/&quot;&gt;Apple Developer - WWDC26 Xcode guide&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Xcode 27, coding agents, localization agents, Device Hub, Instruments 개선 내용 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://www.apple.com/os/ios/&quot;&gt;Apple - iOS 27 Preview&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS 27 Preview, Siri AI, Liquid Glass 개선, 앱 실행 속도·Photos 로딩·AirDrop 전송 성능 개선 수치 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/wwdc26/guides/app-store/&quot;&gt;Apple Developer - WWDC26 App Store guide&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In-App Purchase 제출 플로우 개선, Retention Messaging, Subscription Bundles와 Suites, Group Purchases, Volume Purchasing 내용 참고&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>it-news</category><category>WWDC26</category><category>iOS</category><category>App Intents</category><category>Apple Intelligence</category></item></channel></rss>