Delivery Lab
카페 배달을 잠금화면까지 연결하며, 질문과 실험을 하나씩 쌓아가는 React Native 개발 기록입니다.
목차
지난 글에서는 앱이 확인한 주문 상태와 남은 시간을 구분했다. 이제 그 정보를 잠금화면에도 보여주고 싶었다. 주문을 만들고, Live Activity를 시작하는 버튼을 연결하고, 네이티브 빌드까지 마쳤다.
시뮬레이터를 잠갔다. 화면 아래에 카드가 하나 생겼다.
내용은 없었다.

처음 표시할 때의 허용 안내 때문인지 확인하려고 새 주문에서도 다시 시도했다. 결과는 같았다. 앱에서는 주문을 잘 읽고 있었고, 잠금화면에는 비어 있는 영역만 남았다.
이번 글은 처음 잠금화면을 연결했던 과정이다. 당시에는 준비 15초와 이동 60초를 합친 75초 시연을 사용했다. 앞선 글의 120초 시나리오보다 이전 단계이며, 아래 화면도 그때 저장한 캡처다. 글을 준비하면서 이 기록과 현재 설정을 다시 대조했다.
같은 앱의 화면이라고 생각했는데
React Native 화면에서 주문을 표시하는 코드는 이미 동작하고 있었다. 그래서 처음에는 잠금화면용 레이아웃을 잘못 작성했는지부터 구분할 필요가 있었다.
그런데 앱 화면과 Live Activity는 실행되는 곳부터 달랐다. 이번에 사용한 expo-widgets 57.0.19에서는 'widget' 표시가 붙은 레이아웃을 직렬화해 위젯 확장에서 실행한다. 앱의 React state를 그대로 읽는 자식 컴포넌트가 아니다. Expo 문서도 이 실행 환경의 분리를 설명한다.
이 버전의 구현은 레이아웃을 App Group의 공유 저장소에 저장한다. 앱이 쓴 내용을 확장이 읽으려면 양쪽이 같은 공유 저장소에 접근할 수 있어야 한다.
앱 안에서 데이터가 있다는 사실만으로 그 뒤의 전달까지 확인되지는 않았다. 확인할 대상을 세 부분으로 나눴다.
| 확인할 부분 | 알고 싶었던 것 |
|---|---|
| 레이아웃 변환 | 직렬화된 레이아웃을 위젯 런타임이 실행할 수 있는가? |
| 앱과 확장의 공유 저장소 | 앱이 저장한 레이아웃을 확장이 읽을 수 있는가? |
| 실제 잠금화면 | 읽은 내용이 화면에 나타나고 글자도 읽히는가? |
직렬화된 레이아웃을 설치된 라이브러리의 실제 위젯 런타임으로 실행하자 화면 구조가 만들어졌다. 이 결과가 잠금화면 표시까지 보장하지는 않지만, 적어도 레이아웃의 변환과 전달 경로를 따로 조사할 근거는 됐다.
설정 파일에 썼다고 공유되는 것은 아니었다
초기 빌드는 개인 유료 개발자 계정 없이 진행하려고 서명을 완전히 생략했다. 앱은 설치되고 실행됐지만, 설치된 앱의 App Group 컨테이너 목록은 비어 있었다.

레이아웃은 앱의 개인 Preferences에 남아 있었다. 확장이 읽어야 하는 공유 저장소로 전달되지 않은 것이다. 설정 파일에 App Group 이름을 적어두는 일과, 설치된 실행 파일에 그 권한이 반영되는 일은 달랐다.
빌드한 뒤 codesign으로 서명 정보만 덧붙이는 시도도 공유 저장소 검증을 통과하지 못했다. 결국 빌드 과정에서 entitlement가 포함되도록 Xcode의 시뮬레이터 ad-hoc 서명을 사용했다. entitlement는 여기서 앱과 확장이 어떤 App Group을 사용할 수 있는지 선언하는 정보다.
현재 시뮬레이터 실행 스크립트에도 당시 선택한 옵션을 남겨두었다. 아래는 xcodebuild에 넘기는 옵션 중 서명과 관련된 부분이다.
대상은 iphonesimulator SDK다. 개발 팀을 비우고, 기존 팀 정보가 자동으로 들어오지 않도록 했다. 개인 인증서나 유료 계정을 사용한 실기기용 배포 서명은 아니다. 이 설정으로 해결한 범위도 이 프로젝트의 시뮬레이터 빌드다.
설치 이후에는 공유 컨테이너가 실제로 생겼는지 확인했다. 이제 실행 스크립트는 이 확인을 통과해야 앱을 실행한다.
기대하는 App Group은 group.com.junsobi.deliverylab이다. 빌드 결과만 보고 넘어갔던 경계에, 설치된 결과를 확인하는 절차를 하나 더 넣었다.
글을 정리하면서 현재 설치된 앱과 확장의 설정도 다시 확인했다. 양쪽의 App Group 이름이 같았고, 실제 공유 컨테이너 안에는 DeliveryActivity의 레이아웃이 남아 있었다. 글을 위해 새로 빌드해 빈 화면을 재현한 결과는 아니며, 설치된 결과물을 읽어서 확인한 내용이다.
이번에는 내용이 있는데 잘 안 보였다
공유 저장소를 연결하자 주문 내용이 나타났다. 대신 다른 문제가 보였다. 어두운 카드 위에 어두운 글자가 그려졌다.

당시에는 위젯 JavaScript에서 받은 colorScheme 값으로 고정 색을 골랐다. 하지만 이 실행 경로에서는 실제로 보이는 카드 배경과 기대한 색상 분기가 맞지 않았다. 데이터 전달 문제를 해결했어도 사용자가 읽기 어려우면 표시 기능을 끝냈다고 보기 어려웠다.
상세 카드의 기본 글자색을 SwiftUI가 해석하는 semantic primary로 바꿨다.
JavaScript에서 배경을 추측해 글자색을 확정하는 대신, 실제 뷰를 그리는 쪽에 기본 색의 해석을 맡겼다. 수정 후 시뮬레이터에서는 어두운 카드 위에 밝은 글자가 표시됐다.

전후 캡처의 화면 밝기 상태까지 같지는 않다. 따라서 이 두 장을 정량적인 대비 비교로 보지는 않았다. 수정한 구조가 들어갔는지는 테스트로, 실제 카드에서 글자가 읽히는지는 시뮬레이터 화면으로 확인했다. 다른 기기와 밝기 조건 전체의 가독성까지 검증한 것은 아니다.
자동 테스트도 네이티브 화면을 본 척하지 않도록 범위를 나눴다. 레이아웃을 등록하는 네이티브 경계는 대체하되, 변환된 레이아웃을 실행하는 부분에는 설치된 ExpoWidgets.bundle을 사용한다. 이 결과에 primary 색상 modifier가 남아 있는지 검사한다.
이 테스트가 확인하는 것은 위젯 런타임이 만든 화면 구조다. 설치된 앱의 공유 저장소 접근이나 잠금화면의 실제 픽셀은 별도 확인이 필요하다.
글을 준비하면서 이 색상 관련 테스트 한 건을 다시 실행해 통과한 것을 확인했다. 같은 파일의 다른 테스트 11개는 실행 대상에서 제외했다. 전체 모바일 테스트나 화면 검증을 다시 마친 것으로 세지는 않았다.
10초 뒤 만료와 10초 뒤 화면 변경
내용이 읽히기 시작하자, 이번에는 언제까지 이 정보를 보여줄지가 눈에 들어왔다.
서버 응답에는 그 정보의 만료 시각인 staleAt이 있다. 이를 기기 시각 기준으로 변환해 Live Activity의 staleDate에 전달했다. 최초 구현에서는 environment.isStale이 참이면 카운트다운을 숨기고 ‘업데이트를 기다리고 있어요’를 표시했다.
그렇다고 화면이 정확히 10초 뒤에 바뀌지는 않았다. 당시 마지막 확인 시각이 12:41:06인 정보의 만료 안내를 약 12:43에 관찰했다. 화면을 깨우는 것만으로도 항상 즉시 바뀌지는 않았다.
이 한 번의 관찰로 ‘iOS는 만료 표시를 몇 초 늦춘다’는 규칙을 만들 수는 없다. 다만 만료 시각을 넣었다는 이유만으로 사용자가 그 시각에 새 안내를 보게 된다고 약속할 수는 없었다.
그래서 마지막 확인 시각과 ‘최신 상태는 앱에서 확인’이라는 안내를 함께 남겼다. 타이머가 줄어드는 모습만으로 새 서버 정보를 받고 있다고 오해하지 않게 하기 위해서다.
현재 구현은 이후 요청에 따라 오래된 정보에서도 마지막 ETA의 타이머를 유지하고 안내를 덧붙이는 방식으로 바뀌었다. 여기서 설명한 ‘만료되면 타이머 숨김’은 최초 구현의 동작이다. 그 선택이 바뀐 이유는 다음 편에서 이어간다.
표시된다고 갱신까지 확인한 건 아니었다
마지막으로, 잠금화면에 숫자가 있다는 것과 서버에서 받은 새 상태가 반영된다는 것을 나눠 확인했다.
준비 중에 Activity를 시작하고 앱을 전경에 유지했다. 앱에서 배달 중 상태와 진행률 56%를 확인한 뒤 잠갔더니, 잠금화면에도 배달 중 상태와 갱신된 진행률이 표시됐다. 카운트다운만 줄어든 것과 구분되는 로컬 갱신의 근거였다.
만료된 카드를 눌러 앱으로 돌아온 뒤에는 서버에서 완료 상태를 다시 확인하고 카드를 제거했다. 별도 주문을 취소했을 때도 잠금화면에서 표시가 사라지는 것을 확인했다. 이때 확인한 것은 앱이 전경에서 받은 응답을 네이티브 표시에 반영하는 경로다.
앱이 멈춰 있는 동안 서버의 새 상태를 받는 APNs와 실제 iPhone에서의 원격 갱신은 이 실험에 포함하지 않았다. 시뮬레이터에서 표시·갱신·종료가 됐다는 결과를 그대로 옮길 수 없는 부분이다.
처음에는 빌드가 끝났으니 화면만 보면 될 거라고 생각했다. 실제로는 레이아웃이 실행되는지, 공유 저장소로 건너갔는지, 화면에 읽을 수 있게 그려지는지 차례로 확인해야 했다. React Native 코드 바깥에도 확인할 경계가 있다는 것을 빈 카드 하나가 보여줬다.
잠금화면 다음에는 다이나믹 아일랜드를 확인했다. 다이나믹 아일랜드에 남은 시간을 넣었더니 이번에는 가로로 너무 길어졌다. 여기에 ‘배달 중’, ‘픽업 대기’ 같은 상태까지 넣으려면 무엇을 줄여야 할까?
