noon
Apple Watch에 사는 눈. 측정이 아니라 동행.
noon은?
Apple Watch 화면에 "외계 신호로 존재하는 살아있는 눈"으로 사는 watchOS 네이티브 앱이다(+ iPhone 컨테이너 + 위젯). 걸음·심박·날씨 숫자를 띄우는 위젯이 아니라, 시선·깜빡임·색·글리치로만 소통하는 존재감 있는 캐릭터. 측정이 아니라 동행이다.
가족 같은 반려는 아니다. 컨셉은 관찰 표본과의 어색한 거리감에 가깝다. 외계 신호로 겨우 유지되는 눈 하나가 손목에 산다는, 그런 정서적 동행이다.

손목을 들면 시선·깜빡임·신호 글리치
왜 만들었나
대부분의 워치 앱은 메트릭을 띄운다. "오늘 걸음 5,432" 같은 것. noon은 반대로 간다. 정보가 아니라 존재. 큰 기능은 없다. 그저 늘 거기 있고, 늘 약간 다르다. 그게 정서적 동행이라고 봤다.
그리고 디테일이 생명이라는 믿음. "이런 것까지 챙겼다고?"를 단 한 사람이라도 느낀다면 성공. 그래서 시각 리소스는 전량 AI로 직접 만들고 후처리했다. SF Symbol도 이모지도 쓰지 않는다. 저급한 일반 앱과 갈라서려는 규칙이다.
전체 구조

살아있는 눈
이 프로젝트의 크라운 주얼. 눈은 이미지 텍스처가 아니라 매 프레임 그려지는 벡터다.
눈 렌더링, CGPath
눈 하나는 SKShapeNode의 path에 행동별 CGPath를 절차적으로 꽂아 그린다. idle은 둥근 캡슐, angry는 V자다. 에셋 없이 코드로 모양을 만드니 어느 워치 해상도에서도 벡터라 선명하다. 색은 매트릭스 그린(#00FF41).
핵심은 책임 분리 아키텍처다. Behavior·Blink·Gaze 세 레이어가 서로 침범하지 않고, Crown(디지털 크라운)이 외부 압력으로만 override한다. 깜빡임은 mainShape의 yScale, 시선은 위치, crush는 그룹 스케일이 맡는다. 이 불변식을 매 프레임 강제해서 "비균등 스케일 잔여로 눈이 동그래지던" 버그를 원천 차단했다.
신호 글리치 셰이더
noon의 시그니처. "외계 신호가 약해져 눈이 위태롭게 유지되는" 연출을 GLSL 프래그먼트 셰이더로 만들었다. 가로 18밴드 슬라이스 어긋남에 RGB 색분리(마젠타·시안 프린지), 스캔라인, 느린 투명도 흔들림을 얹었다.
투명도 애니메이션(SKAction)으로 하면 그냥 "버그처럼" 보인다. per-pixel 연산이라 노드 단위로는 불가능해서 셰이더가 유일한 답이었다. 프리멀티플라이 알파를 되돌려(c.rgb /= c.a) 검은 테두리 헤일로도 제거했다. 앱을 켤 때 하루 한 번 등장한다.
신호 글리치 — 슬라이스 어긋남 · 색분리 · 스캔라인
렌즈 엔진 4종
"타원 안에서 무슨 일이 벌어지는가." 눈 안에 장면·효과를 재생하는 시스템이다. 렌즈 18종을 네 가지 엔진으로 돌린다. 셰이더(글리치·컬러바), MP4를 6×5 스프라이트시트로 구운 실사 애니(해변·오로라·해파리), 정지 이미지 씬, 절차 도형(독·기포·배터리 %). 새 렌즈는 코드 몇 줄로 추가된다. 콘텐츠 플라이휠인 셈이다.
한계도 있다. 28px 픽셀 제약 때문에 실사 씬이 잘 안 읽혀서 v1에선 씬 렌즈를 대거 보류했다.
렌즈 갤러리 — 눈 안의 장면들
3슬롯 커스터마이징 + 박스
머리·눈·얼굴 3슬롯을 조합해 눈을 꾸민다. 핵심은 단일 렌더 소스다. 액세서리 하나를 정의하면 실시간 씬·상시화면·프리뷰 세 화면에 자동 전파된다(배선 기준 약 2줄). 걸음 마일스톤과 방문자 발견으로 하루 최대 3개의 박스를 얻어 액세서리를 수집한다. 출시 이후 계속 늘려서 지금은 58종이다.
박스만으로는 경제가 오래 못 갔다. 뽑을 게 다 떨어지면 상자가 쌓이기만 하고 의미가 없어진다. 그래서 까마귀 상인을 넣었다. 상자가 다섯 개 넘게 쌓이면 나뭇가지째 날아와 앉고, 탭하면 상점이 열린다(흔함 4칸 + 레어 1칸). 재고는 액세서리일 수도 렌즈일 수도 있다. 여기에 시즌 한정(2026 썸머 5종)과 컴플리트 보상을 붙여서, 모으는 행위에 끝을 만들어줬다.
함정도 하나 있다. 초기에 설계한 희귀도·pity 가챠 시스템은 실제론 균등 랜덤이다(가중치 계산이 죽은 코드로 남았다). 레벨·EXP 성장도 폐기하고 "수집 도감 경제"로 단순화했다.
센서 · 방문자 · 성장
날씨(WeatherKit)로 비가 오면 개구리 방문자가 나타나고, 심박(HealthKit)이 급변하면 놀라고, 걸음(CoreMotion)에 눈이 튕기고, 집중 모드면 졸린다. 방문자는 3~7분 간격으로 희소하게 등장해 탭하면 도감에 등재된다. 배터리가 낮으면 "기가 빠진" 비주얼, 충전 중엔 운기조식(명상 호흡).
비는 나중에 더 큰 걸 물고 왔다. 비가 오는 동안 화면에 물이 차오른다. 개면 서서히 빠지고, 코르크 마개로 뺄 수도 있다. 이 물이 그냥 연출로 끝나지 않고 수중 방문자의 등장 조건이 됐다. 물이 찬 날에만 형광 물고기들이 지나가고, 빛상어는 물이 아주 깊이 찬 날에만 아주 드물게 나온다. 날씨 반응 하나가 새 방문자 계통을 여는 문이 된 셈이다. 지금 스폰되는 방문자는 지상 3종(개구리·나비·참새) + 까마귀 상인 + 수중 4종, 모두 여덟이다.
감정 시스템, 이산 상태로 사는 눈
noon에서 감정은 장식이 아니라 정체성이다. 그래서 감정을 어떻게 구조화하느냐가 곧 이 캐릭터가 살아있느냐를 결정한다. 두 가지 갈림길이 있었다.
하나는 연속 게이지. bond 같은 0~100 스칼라 하나가 관심·방치에 따라 오르내리고 idle 톤을 물들이는 방식. 설계까지 갔다가 접었다. 게이지는 결국 화면에 안 보이면 "느껴지지 않고", 보이면 감정이 스탯이 되어 마법이 죽는다. 다른 하나는 시간표·취미·식사를 이모지로 사는 자율형 캐릭터 — 이건 이모지 전면 금지 규칙과 정면으로 부딪혀 폐기했다.
결국 간 길은 이산 상태 FSM이다. 명확한 상태, 명확한 트리거, 명확한 풀림. EyeBehavior enum이 단일 소스이고 모든 전환은 BehaviorController를 통과한다. 한 번에 하나의 상태만 산다.
감정 팔레트
기존 4상태(평온·화냄·졸림·명상) 위에, 1.4에서 다섯 감정을 얹었다. 핵심은 각 감정이 고유한 눈 모양(CGPath) + 고유한 트리거 + 고유한 풀림을 갖는다는 것.
| 감정 | 눈 모양 | 트리거 | 풀림 |
|---|---|---|---|
| 간지럼 | >< 웃는 눈 + 홍조 + 몸 꿈틀 | 탭 연타(1.2초 5회+) | 1.5초 무탭 → 평온 · 20초+ 과잉이면 화냄 |
| 만족·흐뭇 | 얇은 선 3겹 + 바깥 경사 | 쓰다듬기(문지르는 드래그) | 손 떼면 평온 |
| 삐짐 | 가는 곁눈 슬릿 + 외면 | 랜덤(5~20분) 저 혼자 팽 토라짐 | 탭 3번 달래면 풀림 · 안 달래면 2.5분 뒤 스스로 |
| 나른 | 실눈 ⌄ 반쯤 감김 | 오후(식곤증) 시간대 랜덤 | 만지면 깸 · 밤 수면과 별개 |
| 존엄 | 얇고 또렷한 라인 + 내려다봄 | 한동안 조용하면 위엄 포즈 | 방문자(벌레)가 오면 와르르 붕괴 |
가장 아끼는 건 짝을 이루는 두 개다. 삐짐은 저 혼자 랜덤으로 토라지고, 간지럼으로 달래면 풀린다. "얘가 삐졌네 → 간지럽혀서 풀어준다"는 살아있는 루프. 존엄은 조용할 때 위엄을 잡다가, 개구리·참새 한 마리에 위엄이 무너진다. "천상천하... 어 벌레다." 감정이 서사가 되는 지점이다.
스스로 움직이는 스케줄러
삐짐·나른·존엄은 사용자가 아무것도 안 해도 스스로 발동한다. 자율 스케줄러가 매 프레임 돌며 "지금 삐질 때인가, 오후 나른할 때인가, 조용해서 위엄 부릴 때인가"를 판정한다. 가만 놔둬도 뭔가 하는 결이 여기서 나온다.
bond 스칼라(연속 축)는 게이지를 숨기면 안 느껴지고 보이면 스탯이 된다. 명확한 상태·트리거·풀림을 가진 FSM이 "감정=정체성"을 더 진하게 구현했다. 새 감정 하나 = 눈 모양 함수 + 상태 케이스 + 트리거.
idle은 절대 건드리지 않는다
감정이 풍부해질수록 상태 관리가 생명이다. 원칙은 하나 — 기본 평온(idle)은 재정의하지 않는다. 무드는 idle을 떠나서 별도 상태가 되거나(간지럼·만족·삐짐), idle 위에 잠깐 얹었다 복귀한다(눈이 물결치는 '잔물결'은 별도 상태가 아니라 평온 중 가끔 뜨는 순간 비트다).
이 원칙을 어기면 바로 버그가 난다. 실제로, 자율 스케줄러가 간지럼 상태를 모르고 주기적으로 idle을 강제하는 바람에 눈만 평소로 돌아가고 몸은 계속 간지럼 타는 어긋남이 났다. 해결은 "상호작용·무드 상태는 스케줄러가 건드리지 않는다"는 가드였다. 감정 상태를 늘릴 때마다 이 가드에 등록하는 게 규칙이 됐다.
눈 모양을 고르는 축, 성형
감정이 상태에 따라 바뀌는 축이라면, 그와 별개로 사용자가 고르는 축이 하나 더 있다. 쉬고 있을 때의 base 눈 모양, 그러니까 이 개체가 원래 어떻게 생겼는지를 정하는 것이다.
프리셋은 다섯이다. 기본·흠냐(반쯤 잠긴)·찌릿(결의)·쭈뼛(위가 부드럽게 도톰)·우직(위-바깥이 살짝 각짐). 눈매 화면에서 고르면 영구 지속되고, idle 상태의 CGPath 생성이 이 선택을 참조한다. 감정 표정은 여기에 얹히되 base를 덮어쓰지 않는다.
이름을 형태가 아니라 인격으로 붙인 게 의도다. "위가 평평한 캡슐"이 아니라 "흠냐"다. 눈 모양을 고르는 행위가 스펙 선택이 아니라 성격 선택으로 느껴져야 했다. 영어도 Basic/Dozy/Jolt/Shy/Stout로, 직역이 아니라 같은 인격을 다시 지었다.
장비를 추가하는 법
머리·눈·얼굴 3슬롯에 붙는 모자·안경·장식이 "장비(액세서리)"다. 설계의 핵심은 단일 렌더 소스다. 액세서리 하나 = AccessoryRender 한 줄. 이 한 줄이 실시간 씬·상시화면(AOD)·인벤토리 프리뷰 세 화면을 동시에 그린다.
AccessoryRender는 이미지 이름과 배치를 담는다. w·h(비율 유지로 맞출 박스), xFrac·yFrac(화면 대비 기준 위치), dx·dy(픽셀 미세 오프셋), z(그리는 순서 — 안경은 눈 앞, 모자는 눈 위), gaze(시선 추종 비율 — 1이면 눈과 완전히 같이 움직임), rot(기울기). 매 프레임 syncSlots가 이 값에 눈의 호흡·시선·머리 제스처(갸웃=회전, 끄덕=위치)를 더해 위치를 갱신한다. 그래서 모자가 머리를 따라 같이 기운다.
파이프라인
말로 풀면 이렇다. 먼저 생성형 AI로 투명 배경 이미지를 만들어 에셋 카탈로그에 넣는다. Accessory enum에 케이스를 추가하고 render에 배치 한 줄을 적는다. 슬롯(머리/눈/얼굴)과 출시 웨이브를 지정하고, 이름·설명을 한/영으로 로컬라이즈한다. 그다음이 중요한데, 실기기에서 아이템을 장착한 채 디버그 튜너로 오프셋 좌표를 눈으로 맞춘다 — X/Y를 2px씩, 스케일을 0.05씩 밀어가며 눈 위 정위치를 찾는다. 찾은 값을 render 한 줄에 박고, 출시 웨이브에 넣으면 가챠 풀과 도감에 자동으로 편입된다. 별도 목록 배열을 손댈 필요가 없다.
함정
- "한 줄"은 워치 한정이다. iPhone 컴패니언 앱은 별도 렌더 테이블을 중복으로 갖는다. 워치에만 등록하면 "새 아이템이 폰에 안 뜬다." 폰 테이블에 두 번째 줄이 필요하다 — 실제로 아시아 테마 갓 8종을 넣을 때 이 버그를 겪었다. 지금은 등록 누락을 사람 대신 도구가 잡는다. 등록 스위치들의 default를 없애 새 아이템을 넣으면 빠뜨린 곳이 전부 컴파일 에러로 드러나고, 패리티 스캐너가 출시 목록을 소스에서 직접 유도해 폰·에셋 누락을 검사한다.
- 두 조각짜리는 배치가 세 화면에서 어긋난다. 좌우로 갈라진 귀 액세서리는 벌어짐·기울기·높이 값이 있는데, 인벤토리 프리뷰가 이 값을 안 읽고 통이미지 근사로 그려 귀가 20~32% 작게 나왔다. 배치 상수를 한 곳(EarLayout)으로 모아 실시간 씬·상시화면·프리뷰가 전부 거기서 읽게 고쳤다.
- 트림하면 재튜닝. 이미지 여백을 잘라내면 시각적 중심이 바뀌어 좌표가 어긋난다. 다수 아이템 주석에 "재튜닝(트림후)" 이력이 남아 있다.
- 작은 워치 오버플로우. 좌표가 45mm 기준이라 40/41mm에서 갓이 화면 천장을 뚫는다. 기기 스케일 계수를 모든 좌표에 곱해 막았다.
- 살아있는 디테일. 전립(붉은 술 갓)의 술은 정적 이미지가 아니라, 시선이 움직이면 관성으로 출렁이다 멈추면 수직으로 정지하는 감쇠 스프링으로 매달았다. "이런 것까지?"의 영역.
렌즈를 추가하는 법
렌즈는 눈 안에서 벌어지는 장면·효과다(내부 타입명은 EyeStyle). 장비가 눈 위에 얹히는 물리 오버레이라면, 렌즈는 눈 그 자체의 스킨이다. 둘은 완전히 별개 시스템으로 공존한다. 구현은 세 갈래 엔진으로 나뉜다.
- 셰이더 — 신호 글리치·무신호 컬러바·카툰처럼 기하적·신호적인 것. GLSL 프래그먼트 셰이더를 눈 그룹에 씌운다. 슬라이스 어긋남이나 색분리 같은 per-pixel 연산은 애니메이션으로는 "버그처럼" 보여서 셰이더가 유일한 답이다.
- 실사 영상 시트 — 해변·오로라·해파리처럼 유기적인 장면. MP4를 스프라이트 시트로 구워 프레임 애니로 재생한다. 유기적 질감은 셰이더로 흉내 내지 않고 실사 영상을 쓴다.
- 정적 텍스처 — 드래곤 비늘, 개구리 눈처럼 이미지 한 장을 눈 모양에 마스킹.
모든 렌즈는 눈의 mainShape 자식으로 붙어서, 시선·깜빡임·표정 변형을 자동으로 따라간다. 찡그렸다 펴도 렌즈가 눈 밖으로 새지 않게 눈 모양이 바뀔 때 렌즈 마스크도 같이 갱신하는 게 핵심이다.
영상 렌즈 파이프라인
변환은 스크립트 한 방이다. make_lens_sheet.sh에 영상과 이름을 넘기면 ffmpeg가 6fps로 가운데를 세로 크롭해 30프레임을 뽑고, ImageMagick이 6×5 몽타주로 시트를 굽고, 상시화면용 정지컷까지 자동 생성한다. 그다음 EyeStyle에 케이스를 추가하고, 씬 재생 함수를 붙이고, 스타일 디스패처에 연결하면 끝. 두 눈이 가로 영상의 좌/우 절반을 나눠 재생하거나(연속성), 살짝 엇박으로 재생(위상차)해서 좌우 눈에 미묘한 차이를 준다.
한계
28px의 벽. 눈이 작아서 실사 씬 렌즈는 잘 안 읽힌다. 공들여 만든 해변·심해가 워치에선 뭉개져서, v1에서는 씬 렌즈를 대거 숨김 처리했다. "잘 빠진 렌즈는 전부 실사영상이나 텍스처나 셰이더고, 코드로 그린 도형만 한 끗 모자란다"는 게 이 프로젝트가 얻은 감각이다.
폰↔워치 동기화, 조용히 죽은 세션을 쫓은 밤
폰에서 눈 색·아이템을 바꾸면 워치에 반영돼야 한다. noon은 이걸 두 개의 독립 채널로 맞춘다.
- iCloud 키-값 저장소(KVS) — 항상 켜져 있는 백본. 느슨하고(eventually-consistent) 재설치·기기교체에도 복원되지만, 실시간을 보장하지 않고 Wi-Fi에 의존적이다.
- WatchConnectivity(WCSession) — 페어링된 기기에 직접 꽂는 실시간/큐 경로. iCloud 지연을 우회한다.
읽기는 로컬(UserDefaults) 우선이라 빠르고 오프라인에 안전하다. 쓰기는 두 저장소에 동시에(dual-write) 흘리고, 다른 기기가 iCloud를 더 최신으로 바꾸면 외부 변경 알림을 받아 다시 읽는다. WCSession 쪽은 링크가 살아있으면 실시간 전달(sendMessage), 아니면 "결국 도달"하는 큐(transferUserInfo)로 떨어뜨리는 2단 전략을 쓴다.
이 이중화에 세 겹의 방어가 붙는다. union-merge — 보유 아이템·업적 목록은 로컬과 원격의 합집합으로만 커진다. 낡은 클라우드 스냅샷이 늦게 도착해도 방금 딴 아이템이 사라지지 않는다. 서명 dedup — 폰↔워치가 서로 push하면 무한 반향이 생기므로, 내용의 서명을 계산해 같은 상태는 되쏘지 않는다. apply-on-look — 워치가 포그라운드로 돌아오는 순간("본다") 폰에 최신 상태를 직접 요청한다. 지연을 없애는 대신, 볼 때 당겨오는 전략이다.
그런데 이중화가 한쪽 다리로 서 있었다
한동안 이 모든 게 iCloud KVS 한 다리로만 돌아갔다. 워치의 WCSession이 애초에 활성화(activate())되지 않은 채 방치돼서, 모든 동기화가 조용히 iCloud로만 흐르고 있었다.
증상은 이랬다. 폰에서 바꾼 눈 색이 워치에 간헐적으로 안 넘어오거나, 한참 뒤에 넘어오거나, 심지어 기본 초록 눈으로 리셋됐다. 첫 진단은 합리적이었다 — "KVS가 eventually-consistent라 느린 거겠지." 그래서 WCSession 직접 동기화 레이어를 새로 붙였다. 안 나았다. 악세 동기화 핫픽스를 세 번 연달아 냈다. 유력한 가설은 "isReachable 코인플립" — 실시간이냐 큐냐 갈리는 분기 때문에 어떤 땐 되고 어떤 땐 안 되는 것처럼 보였다.
시뮬레이터로는 폰↔워치 WCSession을 검증할 수 없어서, 실기기 앱 안에 TX/RX 로그를 심었다. 어느 채널로 보냈고, reachable이었고, 언제 도착했는지를 화면에서 직접 봤다.
그리고 로그가 이론을 뒤집었다. 폰 로그에 매번 reachable = N, 워치 로그엔 수신이 한 줄도 없었다. 코인플립이 아니었다. 동전이 애초에 던져지질 않았다.
하나의 원인이 다섯 증상을 설명했다
근본 원인은 12일 전 커밋이었다. "음악 감지 기능 제거"라는 곁가지 정리 커밋이, 하필 워치의 WCSession을 켜던 유일한 호출까지 같이 지워버린 것이다. 그 호출은 음악 수신기를 시작하는 함수였는데, 이름은 "음악"이었지만 실제로는 세션 활성화 + delegate 등록이라는 횡단 관심사를 겸직하고 있었다.
폰 세션은 다른 경로로 정상 활성화돼 있었으니, 폰은 열심히 큐에 넣었지만 워치는 받을 delegate도, 되쏠 세션도 없는 편도 불통 상태였다. 워치가 iCloud로만 동기화되던 게 "느림·Wi-Fi 의존·리셋"의 정체였다. 지운 줄 하나를 진입점에 되살리자 다섯 증상이 한 번에 사라졌다. 실기기에서 reachable = Y, 워치 수신 정상 — 직접 확인했다. (진단용 로그는 임무를 다하고 다시 걷어냈다.)
한 start() 호출이 음악 + 세션 활성화 + delegate 등록을 한꺼번에 하고 있었다. 지운 건 "음악"이라 생각했지만 실제론 셋 다였다. 심지어 그 뒤에 새로 붙인 WCSession 동기화 기능은, 자기가 우회하려던 바로 그 iCloud 경로로만 돌아갔다. 낡은 멘탈 모델(음악 sync가 살아 있다)이, 그 제거가 세션을 죽였다는 걸 못 보게 가렸다.
이 사가의 교훈은 동기화 기법이 아니라 결합의 은폐다. 기능을 제거할 때는 그 초기화 코드가 겸직하던 부수효과가 없는지 본다. 비결정·실기기 버그는 추측 전에 앱 안에 상태를 띄운다 — reachable = N 한 줄이 밤새 쫓던 이론을 즉시 무효화했다. 한 원인이 모든 증상을 설명할 때까지 판단을 보류한다. 지금은 세션 활성화가 진입점에 명시적으로 살아 있고, 왜 필요한지 주석이 못박혀 있다 — 다음 정리 때 또 지워지지 않도록.
기술 스택
- 플랫폼: Swift · SwiftUI · watchOS 26 (+ iPhone 컨테이너 · WidgetKit)
- 렌더: SpriteKit (SKShapeNode · SKEffectNode · SKShader · SKCropNode) · CoreGraphics
- 센서: WeatherKit(2026-06 KMA에서 전환) · HealthKit · CoreMotion · Focus(Intents)
- 동기화: WatchConnectivity · iCloud KVS (add-only 병합)
- 자산 파이프라인: ffmpeg · ImageMagick · rembg (AI 생성 → 후처리)
- 빌드/i18n: XcodeGen · Xcode 26 · String Catalog(영어 187)
왜 이 스택
- Swift · SwiftUI: watchOS 앱은 네이티브가 외길이다. 크로스플랫폼(RN·Flutter)은 watchOS 지원이 약하고, 매 프레임 눈을 그리는 데 네이티브 성능이 필요했다.
- SpriteKit (CGPath 렌더): 눈을 이미지 텍스처가 아니라 매 프레임 벡터(SKShapeNode·CGPath)로 그린다. 코드로 그려야 색·표면효과·표정을 무한히 조합한다. 텍스처였다면 조합마다 에셋을 만들어야 했다.
- SKShader (신호 글리치): 슬라이스 어긋남·색분리 같은 글리치는 SKAction 투명도로는 "버그처럼" 보인다. 셰이더로 직접 그려야 의도된 글리치가 된다.
- WeatherKit: 원래 기상청(KMA)을 직접 쓰다 옮겼다. 서버·비용이 0이고 위치를 내 서버로 보내지 않아 프라이버시에 유리하다.
Trouble Shooting
- 크래시를 엉뚱한 데서 잡을 뻔했다.
NSMotionUsageDescription누락 크래시를 오진해 SpriteKit→SwiftUI Canvas를 통째로 포팅하기까지 했는데, 원인은 무관했다. 추측 전에 크래시 로그(.ips)부터 봤어야 했다. - HealthKit 활동링 오염. 심박 상시 관측을
HKWorkoutSession(.mindAndBody)로 했더니 모든 분이 "운동 중"으로 잡혀 안 움직여도 링이 찼다.HKAnchoredObjectQuery로 교체했다. - AOD 블링크가 안 보임. 0.14초 블링크가 1Hz 상시화면 두 프레임 사이에 끝나버렸다. slowMode(0.5초 + hold)로 해결했다.
- watchOS 글자 단위 줄바꿈. 좁은 워치에서 "clic/k"처럼 글자가 쪼개졌다.
lineLimit(1)과fixedSize로 막았다.
Decision Log
절차적으로 그리면 에셋 없이 무한 변형이 되고, 벡터라 어느 워치에서도 선명하다. 모양(idle·angry·charging)을 코드가 만든다.
슬라이스 오프셋·채널별 알파 샘플은 per-pixel 연산이라 노드 단위 SKAction으로 불가능하다. 투명도 애니로 흉내 내면 그냥 깜빡이는 버그로 읽힌다. 셰이더가 유일한 답이었다.
숨 쉬듯 커졌다 작아지는 펄스를 넣었다가 뺐다. "있는지 없는지 모르겠다"는 피드백. 존재감은 시선과 깜빡임만으로 충분했다.
가챠 희귀도와 pity, 레벨·EXP를 넣었다가 게임처럼 무거워져 걷어냈다(계산 흔적은 죽은 코드로 남음). 걸음·방문자로 박스를 얻어 도감을 채우는 단순한 수집으로.
글로벌 출시를 염두에 두고, 서버·비용이 0이고 위치를 서버로 안 보내는 WeatherKit으로 옮겼다.
Extended Runtime Session 상시 유지는 배터리를 6시간 만에 급감시키고 심사 회색지대라, 끄고 상시화면 표준 방식으로 갔다.
회고: 심사의 벽, 그리고 디테일
noon은 한 번에 나온 게 아니라 여러 번 갈아엎은 산물이다. 반응형 눈("Eye Companion", 이모지도 썼다) → 자율 스케줄 설계(미구현) → 외계인 관찰자 로어로 리브랜딩. 로어 자체도 "처음부터 빛이 의식"에서 "발광 생물의 진화"로 근거를 갖춰 다시 썼다. 이모지·SF Symbol 전면 금지도 처음부터가 아니라 우회를 거쳐 확정된 규칙이다.
결국 출시했다. v1.0을 제출하고부터 App Store 심사에서 여러 건 리젝됐다. watch-only 배포를 막는 App Store Connect 자체 버그(Apple도 인정) 탓에 iPhone 컨테이너를 경유해야 했고, 리뷰어가 빈 컨테이너를 보고 "콘텐츠 부족"으로 반려했다. 폰에 "워치 눈 실시간 편집"이라는 실기능을 부여해 그 벽을 넘었고, 2026년 7월 App Store에 올랐다.
내세울 수 있는 건 이렇다. 기획·디자인·개발을 혼자, 시각 리소스까지 AI로 직접 만들고, Apple 플랫폼 심사의 벽(자체 버그 포함)을 넘어 출시까지 해냈다. "풀스택"보다 "스펙트럼 넓게, 디테일에 집착하는 사람"이다.