← 제작기

같은 앱을 iOS와 안드로이드로 두 번 만들면서 배운 것

Classical Mood 는 애플 건강 데이터와 지금의 시간, 위치, 날씨를 읽어 Claude 에게 "지금 이 사람의 상태에 맞는 클래식 곡"을 묻는 앱입니다. 곡 제목만 받는 데서 그치지 않고 어떤 연주의 음반인지까지 고르게 한 뒤, 애플 뮤직에서 검색해 바로 재생합니다.

iOS 로 먼저 만들고, 같은 앱을 안드로이드로 다시 만들었습니다. 두 번째가 더 어려웠습니다. 코드를 옮기는 일보다 없는 것을 어떻게 처리할지 정하는 일이 더 컸기 때문입니다.

추천의 재료는 결국 신호의 목록이다

추천의 품질은 모델보다 입력에 더 크게 좌우됩니다. iOS 에서 모을 수 있었던 신호는 이렇습니다.

  • HealthKit 의 걸음 수, 심박, 안정 시 심박, 수면, 활동 링 진행도, 복약 기록
  • WeatherKit 의 현재 위치 날씨
  • 시간대와 요일

안드로이드에서는 Health Connect 가 HealthKit 의 자리를 대신합니다. 걸음 수, 심박, 안정 시 심박, 수면, 체중, 혈압, 운동 기록, 활동 칼로리까지는 읽기 전용 권한으로 그대로 가져올 수 있었습니다. 문제는 나머지였습니다.

없는 것은 없는 대로 둔다

이번 프로젝트의 원칙: 플랫폼에 없는 기능은 비슷하게 흉내 내지 않습니다. 대신 무엇이 없고 왜 없는지를 설정 문서에 적어 둡니다. 나중에 "이게 왜 안 되지"를 다시 조사하는 시간이 가장 아깝기 때문입니다.

구체적으로 세 군데에서 두 플랫폼이 갈렸습니다.

1. 활동 링이 없다

watchOS 의 움직이기, 운동하기, 일어서기 목표는 Health Connect 에 대응하는 값이 없습니다. 목표치를 임의로 정해 달성률을 만들어 낼 수도 있었지만, 그렇게 나온 수는 사용자가 실제로 설정한 값이 아니라 제가 지어낸 숫자입니다. 안드로이드의 HealthSnapshot 은 달성률 대신 운동 시간(분) 원래 값을 그대로 넘깁니다. 모델에 들어가는 신호가 하나 단순해질 뿐, 틀린 정보를 주지는 않습니다.

2. 복약 기록이 없다

복약 기록은 사실 HealthKit 에도 목록 형태로는 없습니다. 양쪽 모두 설정에 "진정 작용이 있는 약 복용 중" 스위치 하나를 두는 것으로 대신했습니다. 자동으로 읽을 수 없으면 물어보면 됩니다.

3. 온디바이스 AI 엔진이 없다

iOS 판은 애플 인텔리전스(FoundationModels)를 완전한 오프라인 선택지로 제공합니다. 안드로이드에는 모든 기기에서 믿고 쓸 수 있는 같은 수준의 기능이 없습니다. ML Kit GenAI 를 거쳐 쓰는 Gemini Nano 가 일부 픽셀이나 제조사 기기에 있기는 하지만 널리 쓸 수 있지 않고, 일부 기기에서만 되는 기능을 설정 화면에 두면 곧바로 지원 문의가 따라옵니다. 안드로이드에는 Claude 와 Groq 두 가지만 두고, 둘 다 API 키가 필요하다는 것을 처음부터 분명히 알렸습니다.

나중에 이 결정을 뒤집었습니다. 지금은 안드로이드에도 온디바이스 엔진이 있습니다. ML Kit 의 Prompt API 로 AICore 의 Gemini Nano 를 쓰는 경로를 찾았기 때문입니다. "일부 기기에서만 되는 기능은 지원 문의를 부른다"는 걱정은 그대로 들어맞았습니다. AICore 배포가 기기와 지역에 따라 서버 쪽에서 막혀 있어서, 지원 목록에 있는 기기에서도 "기능을 사용할 수 없음"이 떴습니다.

기능을 감추는 대신 실패 메시지를 바꿨습니다. AICore 내부 오류 코드를 그대로 보여 주지 않고 "이 기기는 온디바이스 AI를 지원하지 않습니다. Groq 또는 Claude를 사용해 주세요"로 고정해 두니 목록에 다시 올릴 만해졌습니다. 사용자가 손쓸 수 없는 오류라면 원인 대신 대안을 보여 주면 된다는 것을 이번에 배웠습니다.

같은 이유로 Pollinations(키가 필요 없는 무료 커뮤니티 프록시)는 코드에 남아 있지만 설정 목록에서는 빼 두었습니다. 어떤 네트워크에서는 되고 다른 네트워크에서는 402 를 돌려주는데, 이런 문제는 안내 문구로 덮을 수 있는 종류가 아니었습니다.

날씨: 가입 절차를 없애는 것이 곧 기능이었다

iOS 의 WeatherKit 은 잘 동작하지만 유료 애플 개발자 프로그램에 가입해야 쓸 수 있습니다. MusicKit 도 마찬가지여서, 무료 개인 팀 서명으로는 아예 빌드가 되지 않습니다.

안드로이드에서는 Open-Meteo 를 골랐습니다. 계정도, 키도, 요청 서명도 필요 없이 위도와 경도만 넘기면 됩니다. WeatherKit 처럼 "그냥 되는" 경험에 가장 가까운 것을 가입 화면 없이 만드는 방법이었습니다. 나중에 더 정밀한 제공자로 바꾸고 싶으면 LocationWeatherManager.kt 한 파일만 고치면 됩니다. 다른 코드는 Open-Meteo 를 모릅니다.

프로젝트 파일도 코드로

iOS 쪽은 ClassicalMood.xcodeproj 를 손으로 관리하지 않고 XcodeGen 으로 project.yml 에서 만들어 냅니다. 번들 ID, Info.plist 키, 배포 대상이 모두 한 파일에 텍스트로 들어 있어서, 나중에 무엇이 어떻게 설정돼 있는지 읽기가 훨씬 쉽습니다.

xcodegen generate   # 파일을 손으로 추가한 뒤에만 필요

"빌드된다"를 실제로 확인하고 넘어가기

안드로이드 판의 설정 문서에는 실제 기기에 연결해 ./gradlew :app:assembleDebug 와 installDebug 까지 돌려서 설치되고 크래시 없이 실행되는 것까지 확인했다고 적어 두었습니다. 반대로 Fab Alert 는 처음 문서를 쓸 때 그 환경에 JDK 가 없어 명령줄 빌드를 하지 못했고, 그 사실도 README 에 그대로 적었습니다.

검증했는지를 문서에 남기는 일은 사소해 보여도, 몇 주 뒤에 다시 열었을 때 어디부터 의심해야 하는지 알려 줍니다.

이 방식은 오래가지 않습니다. 안드로이드 스튜디오에서 계속 작업하다 보면 문서가 코드보다 뒤처지고, 그때부터는 "확인했다"는 기록이 오히려 사람을 엉뚱한 곳으로 보냅니다. 실제로 Fab Alert 의 README 는 그 뒤 앱 이름, 인터넷 권한 유무, 매칭 규칙, 배포 방식 네 군데가 코드와 어긋난 채로 며칠을 보냈습니다. 전부 "그때는 맞았던" 설명이었습니다.

정리하면

  • 이식은 코드를 번역하는 일보다 쓸 수 있는 신호의 목록을 다시 정하는 일이었습니다.
  • 없는 기능은 흉내 내지 말고, 없다고 적어 둡니다.
  • 키가 필요 없는 API 를 고르는 것도 사용자 경험에 관한 결정입니다.
  • 검증한 것과 하지 않은 것을 구분해서 남깁니다.

Classical Mood reads Apple Health data along with the current time, place, and weather, and asks Claude for "a classical piece that suits how this person is right now." It does not just get a title back — it makes the model specify which recording, then searches Apple Music and plays it.

I built it on iOS first, then built the same thing again on Android. The second was harder, because it was not a matter of porting code — it was deciding what to do about what is not there.

The raw material of a recommendation is a list of signals

Recommendation quality is driven by the input more than the model. Here is what iOS could collect.

  • HealthKit — steps, heart rate, resting heart rate, sleep, activity ring progress, medication logs
  • WeatherKit — weather at the current location
  • Time of day, day of week

On Android, Health Connect takes HealthKit's place. Steps, heart rate, resting heart rate, sleep, weight, blood pressure, exercise sessions, and active calories all came across with read-only permissions. The problem was the rest.

Leave what is missing missing

The principle for this project: do not approximate a feature the platform does not have. Write down in the setup doc what is missing and why instead. The most wasteful thing later is re-investigating "why doesn't this work?"

Concretely, it diverged in three places.

1. There are no activity rings

watchOS's Move, Exercise, and Stand goals have no counterpart in Health Connect. I could have picked arbitrary targets and manufactured a "completion percentage," but that would be a number I invented, not a value the user actually set. So Android's HealthSnapshot passes through raw exercise minutes instead of a completion rate. One signal into the model gets simpler; nothing lies.

2. There are no medication logs

This is not really available as a list on HealthKit either. Both platforms substitute a single settings toggle: "currently taking medication with a sedative effect." If you cannot read it automatically, you can ask.

3. There is no on-device AI engine

The iOS version offers Apple Intelligence (FoundationModels) as a fully offline option. Android has no equivalent you can rely on across all devices — Gemini Nano through ML Kit GenAI exists on some Pixel and OEM devices, but it is not universal. A "works only on some devices" feature invites support questions the moment it appears in settings. So Android shipped with only Claude and Groq, and made it clear from the start that both require an API key.

Then I reversed that. Android does have an on-device engine now — I found a path to AICore's Gemini Nano through ML Kit's Prompt API. But the worry that "a feature that works on only some devices invites support questions" turned out to be exactly right. AICore rollout is gated server-side by device and region, so "feature unavailable" appeared even on devices in the supported list.

So instead of hiding the feature, I changed the failure message. Rather than surfacing AICore's internal error code, it is now fixed to "This device does not support on-device AI. Please use Groq or Claude" — and that made it worth listing again. What I learned here: when there is nothing the user can do about an error, show the alternative rather than the cause.

For the same reason, Pollinations (a free community proxy that needs no key) exists in the code but is left out of the settings list. It works on one network and returns a 402 on another, and that is not the kind of thing a message can paper over.

Weather: removing the sign-up was the feature

iOS's WeatherKit works well, but requires a paid Apple Developer Program membership. So does MusicKit. With a free personal team signing certificate, it will not even build.

On Android I chose Open-Meteo. No account, no key, no request signing — just pass latitude and longitude. It was the way to get as close as possible to "WeatherKit just works" with no sign-up screen in between. If I ever want a more precise provider, only LocationWeatherManager.kt changes — no other code knows Open-Meteo exists.

The project file as code too

On iOS, ClassicalMood.xcodeproj is not managed by hand; it is generated from project.yml with XcodeGen. With the bundle ID, Info.plist keys, and deployment target all sitting as text in one file, reading back what is configured how is far easier later.

xcodegen generate   # only needed after adding files by hand

Confirming "it builds" before handing it over

The Android version's setup doc says it plainly: ./gradlew :app:assembleDebug and installDebug were run against a real device, and installation and a crash-free launch were confirmed. Conversely, when Fab Alert's documentation was first written there was no JDK in that environment, so a command-line build was impossible — and the README says exactly that.

Recording whether something was verified sounds trivial, but weeks later it tells you where to start doubting.

The shelf life of this approach is short, though. Keep working in Android Studio and the documentation falls behind the code — and from that point on, a record saying "verified" actively sends people to the wrong place. Fab Alert's README did in fact spend days out of sync with the code in four places: the app name, whether it holds the internet permission, the matching rules, and the distribution method. Every one of them was a statement that had been correct at the time.

In short

  • Porting was not code translation — it was renegotiating the list of signals.
  • Do not imitate a missing feature. Write down that it is missing.
  • Choosing an API that needs no key is a UX decision too.
  • Record what was verified separately from what was not.