- [Part 1] 방치된 뽀모도로 앱의 환골탈태 — 왜 'Lo-Fi 작업실'과 'Focus-to-Earn(주간 복권)'인가?
- [Part 2] 수익화와 공정성의 줄타기 — AdMob 보상형 광고와 해커·매크로를 막는 4중 방어 아키텍처
- [Part 3] "합성음은 가짜 같다" — Google 공식 현장 녹음 음원 도입과 다중 채널 백색소음 믹서 엔진 제작기
- [Part 4] iOS 26 JIT 메모리 보호 크래시 돌파와 Apple Watch Series 10 원형 UI & 아이콘 제작기 (현재 글)
- [Part 5] 화면이 꺼져도, 앱을 강제 종료해도 멈추지 않는 타이머 — Android Doze·iOS 무음 루프와 워치 양방향 실시간 동기화 정복기
크로스플랫폼 개발에서 가장 아찔한 순간은 "시뮬레이터에서는 완벽했는데 최신 OS 실기기에서 켜자마자 죽어버리는 상황"입니다. 본 4편에서는 최신 iOS 26.3.1 실기기에서 Flutter Debug 실행 시 발생한
EXC_BAD_ACCESS (SIGKILL) / brk #0xf00d 크래시가 Apple의 강화된 TXM(Trusted Execution Monitor) JIT 메모리 보호 정책 때문임을 밝혀내고 AOT Release 빌드로 우회한 과정과, Apple Watch Series 10(watchOS 26.2)을 위한 SwiftUI TimelineView 원형 게이지 UI 및 알파 채널 제거 아이콘 제작기를 공유합니다.
1. 첫 번째 난관: iOS 26.3.1 실기기에서 앱이 켜지자마자 0.1초 만에 죽다
안드로이드 빌드를 마치고 설레는 마음으로 맥북에 iOS 26.3.1이 설치된 아이폰 실기기를 연결한 뒤, Xcode 26.4에서 Run (▶) 버튼을 눌렀습니다. 그런데 앱 아이콘이 생성되고 실행되는가 싶더니, 하얀 스플래시 화면만 잠깐 깜빡이고 0.1초 만에 앱이 강제 종료되었습니다.
더 당황스러웠던 점은 처음에는 Xcode 콘솔에 Dart 예외 로그조차 한 줄 찍히지 않았다는 것입니다. 원인을 정확히 파헤치기 위해 디바이스의 로우레벨 크래시 리포트(.ips 파일)를 추출해 콜스택을 열어보았습니다.
Exception Type: EXC_BAD_ACCESS (SIGKILL)
Exception Subtype: KERN_PROTECTION_FAILURE at 0x0000000105a5c000
Termination Reason: CODESIGNING 2 Invalid Page
Thread 0 Crashed:
0 dyld 0x00000001a1b2c4d8 dyld4::APIs::dyld_sim_prepare(...) + 192
-> brk #0xf00d
Kernel Triage:
VM - (arg = 0x0)Returned success with no page
TXM [Error]: CodeSignature validation failed for JIT region
범인은 코드 버그가 아닌 'iOS 26 커널의 TXM JIT 메모리 차단'
크래시 덤프의 brk #0xf00d 명령어와 TXM (Trusted Execution Monitor) 커널 트리아지 로그에 모든 단서가 들어 있었습니다.
- 원인 규명: Flutter의
Debug모드는 빠른 핫 리로드(Hot Reload)를 위해 런타임에 메모리에 기계어를 동적으로 쓰고 실행하는 JIT(Just-In-Time) 컴파일 방식을 사용합니다. 하지만 최신 iOS 26.x(arm64e)에서는 Apple의 하드웨어/커널 보안 계층인 TXM(Trusted Execution Monitor)이 서명되지 않은 동적 실행 메모리 페이지 할당을 엄격히 차단하면서brk #0xf00d트랩과 함께 프로세스를 즉사(SIGKILL)시켰던 것입니다. - 돌파 솔루션: JIT를 사용하지 않고 모든 Dart 코드를 사전에 네이티브 ARM64 기계어로 컴파일하는 AOT(Ahead-Of-Time) Release 모드로 실행하면 TXM 위반이 원천적으로 발생하지 않습니다!
터미널에서는 flutter run --release -d [DeviceID]로 실행하고, 나아가 Xcode에서 Run (▶) 버튼을 그냥 눌러도 크래시 없이 즉시 실행되도록 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme의 LaunchAction 설정을 Debug에서 Release로 영구 변경했습니다.
<!-- ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme -->
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES">
이 한 줄의 스킴 변경 직후, 아이폰 실기기에서 거짓말처럼 크래시가 사라지고 60fps의 부드러운 다크 스튜디오 화면이 켜졌습니다.
2. 두 번째 난관: Apple Watch Series 10(42mm)에 진짜 워치다운 원형 UI 입히기
아이폰 문제를 해결한 뒤, 손목에 찬 Apple Watch Series 10 42mm(watchOS 26.2)에 워치 앱(PomodoroWatchAppRico Watch App)을 빌드해 올렸습니다. 기존 v1.0의 워치 화면은 스마트폰 화면을 그대로 축소한 듯한 어색한 가로 막대(Linear Progress Bar)와 텍스트 버튼뿐이었습니다.
스마트워치는 0.5초 만에 힐끗 보고(Glanceable), 손가락 하나로 즉시 누를 수 있어야 합니다. 이를 위해 SwiftUI로 워치 UI(ContentView.swift)를 완전히 새로 설계했습니다.
| 워치 UI 구성 요소 | SwiftUI 구현 디테일 | UX 및 엔지니어링 효과 |
|---|---|---|
| 상단 세션 캡슐 배지 | 🍅 집중 세션 / ☕ 짧은 휴식 |
현재 세션에 맞춰 앰버(#F59E0B)와 에메랄드(#10B981) 컬러가 자동 전환 |
| 중앙 원형 링 게이지 | Circle().trim(from: 0, to: ratio) |
108×108pt 원형 트랙 한가운데에 32pt 고정폭 숫자와 ON AIR / PAUSED 표시 |
| 하단 3버튼 컨트롤러 | 초기화(↺) · 재생/정지(▶/⏸) · 스킵(⏭) |
작은 워치 화면에서도 오터치 없이 조작 가능한 원형 아이콘 버튼 3개 배치 |
| TimelineView 렌더링 | TimelineView(.periodic(by: 1.0)) |
watchOS가 백그라운드 Timer를 쓰로틀링하더라도 화면 숫자가 매초 정확히 갱신됨 |
실제 watchOS SwiftUI 원형 스튜디오 구현 코드
특히 watchOS는 배터리 절약을 위해 일반 Timer 객체를 공격적으로 쓰로틀링합니다. 이를 극복하기 위해 SwiftUI의 TimelineView(.periodic(from: .now, by: 1.0))과 목표 종료 시각(targetEndEpochMs) 계산을 결합하여 어떤 상황에서도 워치 화면의 초침이 멈추지 않도록 구현했습니다.
// ios/PomodoroWatchAppRico Watch App/ContentView.swift — 원형 링 게이지 & 3버튼 UI
struct ContentView: View {
@StateObject private var viewModel = WatchViewModel()
@Environment(\.scenePhase) private var scenePhase
private let amberColor = Color(red: 0.96, green: 0.62, blue: 0.04)
private let emeraldColor = Color(red: 0.06, green: 0.73, blue: 0.51)
private let stopRedColor = Color(red: 0.94, green: 0.27, blue: 0.27)
var activeAccentColor: Color {
viewModel.isFocusState ? amberColor : emeraldColor
}
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0)) { context in
let currentRatio = viewModel.displayProgressRatio(at: context.date)
let currentTimeText = viewModel.displayTimeRemaining(at: context.date)
VStack(spacing: 8) {
// 1. 상단 세션 캡슐 배지
Text(viewModel.stateTitle)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundColor(activeAccentColor)
.padding(.horizontal, 10)
.padding(.vertical, 3)
.background(activeAccentColor.opacity(0.18))
.clipShape(Capsule())
// 2. 중앙 원형 프로그레스 링 + 고정폭 타이머 숫자
ZStack {
Circle()
.stroke(Color.white.opacity(0.12), lineWidth: 6)
Circle()
.trim(from: 0, to: CGFloat(currentRatio))
.stroke(
activeAccentColor,
style: StrokeStyle(lineWidth: 6, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.easeOut(duration: 0.25), value: currentRatio)
VStack(spacing: 2) {
Text(currentTimeText)
.font(.system(size: 32, weight: .heavy, design: .rounded))
.monospacedDigit()
.foregroundColor(.white)
Text(viewModel.isRunning ? "ON AIR" : "PAUSED")
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundColor(viewModel.isRunning ? activeAccentColor : .gray)
}
}
.frame(width: 108, height: 108)
// 3. 하단 3버튼 컨트롤러 (초기화 / 시작·일시정지 / 스킵)
HStack(spacing: 12) {
Button(action: { viewModel.sendResetCommand() }) {
Image(systemName: "arrow.counterclockwise")
}
.buttonStyle(.bordered).tint(.gray)
Button(action: { viewModel.sendStartPauseCommand() }) {
Image(systemName: viewModel.isRunning ? "pause.fill" : "play.fill")
}
.buttonStyle(.borderedProminent)
.tint(viewModel.isRunning ? stopRedColor : activeAccentColor)
Button(action: { viewModel.sendSkipCommand() }) {
Image(systemName: "forward.end.fill")
}
.buttonStyle(.bordered).tint(.gray)
}
}
}
}
}
3. 디테일의 완성: 왜 워치 홈 화면에서 아이콘이 까만 네모로 보였을까?
워치 UI를 멋지게 띄우고 워치 홈 화면(벌집 모양 앱 그리드)으로 나갔는데, 우리 앱 아이콘 자리에 까만색 빈 동그라미만 떠 있었습니다. 확인 결과 두 가지 원인이 있었습니다.
- 워치 타겟의
Assets.xcassets/AppIcon.appiconset에1024x1024마스터 이미지가 누락되어 있었고, - 가장 중요한 포인트로, Apple(iOS/watchOS) 앱 아이콘은 PNG 파일에 투명도(Alpha Channel, RGBA)가 포함되어 있으면 시스템 마스킹 과정에서 검은색으로 깨지거나 스토어 심사에서 리젝됩니다.
이에 다크 차콜 배경 위에 황금빛 앰버 링과 토마토 심볼을 고해상도로 렌더링한 뒤, 알파 채널을 완전히 제거한 순수 24-bit RGB(`1024x1024`) PNG를 생성하여 iPhone과 Apple Watch 양쪽 에셋 카탈로그에 모두 등록했습니다. 그제야 Apple Watch Series 10 홈 화면에서도 선명하고 고급스러운 아이콘이 빛을 발했습니다.
4. 마지막 이야기 예고: 4대 기기 실시간 양방향 동기화와 백그라운드 생존기
이제 화면과 소리는 완벽해졌습니다. 하지만 시리즈 중 가장 치열했던 진짜 끝판왕 엔지니어링 과제가 남아 있었습니다.
- "아이폰이나 갤럭시 화면을 끄거나(잠금화면), 심지어 앱을 위로 쓸어 올려 강제 종료(Swipe Kill)했다가 다시 켜도 타이머가 1초의 오차도 없이 돌아가게 하려면?"
- "애플워치나 갤럭시 워치에서 일시정지/스킵을 눌렀을 때 주머니 속 스마트폰과 0.1초 만에 충돌 없이 동기화하려면?"
대단원의 마지막 편인 [Part 5: 화면이 꺼져도, 앱을 강제 종료해도 멈추지 않는 타이머 — Android Doze·iOS 무음 루프와 워치 양방향 실시간 동기화 정복기]에서 그 모든 해답과 네이티브 Swift/Kotlin 코드를 공개합니다.