Delivery Lab
카페 배달을 잠금화면까지 연결하며, 질문과 실험을 하나씩 쌓아가는 React Native 개발 기록입니다.
목차
커피를 전달했으면 배달 추적도 끝나야 한다. 그런데 앱에서는 완료된 주문의 카드가 잠금화면에 계속 남아 있었다. 앱으로 돌아오면 사라지는데, 홈 화면이나 잠금화면에서 기다리면 남아 있었다.
이유는 앱의 조회 조건에 있었다. React Native에서는 앱이 전경에 있을 때만 주문을 조회했다. 서버에서 배달이 끝나도 앱이 새 응답을 받지 않으면 Activity를 종료하는 코드까지 도달하지 못했다.
타이머가 0이 되면 지워버릴 수도 있다. 하지만 3편에서 정한 기준과 맞지 않았다. 예상 시간이 지났다는 사실만으로 서버가 전달을 완료했다고 판단해서는 안 됐다.

그래서 이번에는 완료를 판단하는 일과 그 결과를 화면까지 보내는 일을 함께 살펴봤다. 처음에는 앱을 다시 열지 않고 표시를 종료하는 경로를 연결했다. 이어서 같은 경로를 확장해 조리·픽업·배달 중 상태도 갱신했다.
아래의 실제 관찰은 모두 로컬 서버와 iOS 시뮬레이터에서 한 실험이다. 실제 iPhone의 ActivityKit APNs 성공 기록은 아니다.
완료를 아는 쪽에서 시작하기
서버에는 이미 주문을 만든 시각과 배달 시간표가 있었다. 앱의 조회를 기다리지 않고도 getOrderSnapshot으로 현재 상태를 계산할 수 있었다.
주문과 Live Activity를 연결해 등록하고, 등록된 주문을 1초마다 확인하는 서버 worker를 만들었다. 서버에서 전달 완료나 취소가 확인되면 그 주문에 연결된 Activity로 종료를 전달한다. 앱이 마지막 GET 요청을 언제 보냈는지는 이 판단에 필요하지 않았다.
다만 종료 전송을 어떤 방식으로 확인할지는 별개였다. 개인 유료 개발자 계정 없이 진행 중이었으므로, 실제 APNs와 구분되는 시뮬레이터 전용 경로를 두었다. simctl push로 일반 모의 알림을 주입하고 네이티브 코드가 처리하는 방식이다.
처음에는 이 명령이 성공하면 나머지도 이어질 것 같았다. 실제로는 등록과 전송이 성공했다는 기록만 남고 0초 카드는 그대로였다.
시스템 로그에서 먼저 확인한 것은 background_fetch 실행 시도였다. 시뮬레이터가 앱을 깨우려 했지만, 앱에는 remote-notification만 선언돼 있어 실행이 거부됐다. 생성 플러그인에 fetch 선언을 추가하고 다시 빌드했다.
그다음에는 앱이 깨어났는데도 카드가 남았다. 일반 알림 수신 콜백에 진입 로그를 넣고 전경·백그라운드와 payload를 바꿔 비교했다. 이 환경에서는 기대한 remote notification 콜백 대신 background fetch 경로만 호출됐다.
명령 성공, 앱 실행, 우리가 작성한 콜백 실행은 각각 확인해야 했다. 앞 단계의 성공 로그가 다음 단계의 성공까지 알려주지는 않았다.
background fetch에서 서버를 한 번 확인하기
시뮬레이터에서 실제로 호출되는 background fetch에 서버 확인을 연결했다. 현재 활성 상태이거나 오래된 상태인 DeliveryActivity의 주문 URL을 읽고, 해당 주문을 조회한다. 주문 ID와 Activity가 맞는지 확인한 뒤 서버가 완료·취소라고 답했을 때만 종료한다.
이 시연은 활성 주문이 한 건이므로 해당 주문을 한 번 확인한다. 코드에서는 현재 표시 중인 대상 Activity 각각을 한 번씩 확인한다. Swift에 반복 타이머를 넣거나 주기적인 background fetch를 예약한 것은 아니다.
최종 종료 실험에서는 앱에서 새 배달을 시작한 뒤 홈 화면으로 나가 잠갔다. 한 주문은 Mac에서 취소 API를 호출했고, 다른 주문은 120초가 지나 자연스럽게 완료되도록 기다렸다. 두 경우 모두 앱을 다시 열지 않은 채 잠금화면 카드와 다이나믹 아일랜드가 사라지는 것을 확인했다.

네이티브 로그에는 확인한 Activity 한 건과 종료한 Activity 한 건이 기록됐다. 자연 완료 실험에서는 화면을 확인한 뒤 서버의 delivered 응답도 별도로 확인했다.

이제 앱이 전경에 돌아와야만 표시가 정리되는 문제는 시뮬레이터에서 해결됐다. 그런데 중간 상태를 확인해 보니, 끝나는 것과 그동안 잘 보여주는 것은 다른 문제였다.
시간은 줄어드는데 주문 접수에 머물렀다
홈 화면의 카운트다운은 계속 줄었다. 그런데 문구는 한동안 ‘주문 접수’였다. 숫자가 움직이니 주문 상태도 계속 받아오는 것처럼 보이기 쉬웠다.
당시 서버 worker는 완료·취소 때만 알렸고, 시뮬레이터 수신기도 종료만 처리했다. 앱을 잠그면 RN의 조회는 멈추므로 중간 상태를 전달할 경로가 없었다. 5편에서 구분했던 시스템 타이머와 서버 갱신의 차이가 그대로 드러난 셈이다.
기존 시뮬레이터 경로에 refresh 신호를 추가했다. 고객에게 보여줄 단계가 바뀌면 전송을 시도하고, 같은 단계에서도 성공한 전송으로부터 5초 뒤 다시 확인할 기회를 요청한다. worker의 1초 tick과 전송 실행 시간이 더해져 실제 로그에서는 약 5~6초 간격이었다. 정확히 5초마다 화면이 바뀐다는 의미는 아니다.
네이티브 쪽도 서버 응답에 따라 동작을 나눴다.
| 서버에서 확인한 내용 | 표시 처리 |
|---|---|
| 주문 수락·조리·픽업·배달 중 | Activity.update로 마지막 상태·예상 시간·확인 시각 갱신 |
| 전달 완료·주문 취소 | .immediate 정책으로 Activity 종료 |
| 조회 실패·유효하지 않은 응답·이전 서버 시각 | 기존 정보 유지. 만료 시 갱신 대기 안내 |
모의 알림 자체를 최신 주문 상태로 믿지는 않는다. 알림이 늦어져도 실행 기회를 얻은 시점에 서버를 확인하도록 했다. 여기서 필요한 신호는 ‘이 상태로 바꿔라’보다 ‘현재 주문을 다시 확인해라’에 가까웠다.
중간 단계 전송에 실패했을 때 다음 단계까지 막히지 않는 것도 필요했다. 예를 들어 조리 중 신호의 재시도 횟수를 모두 썼더라도, 배달 중이나 취소로 상태가 바뀌면 시도 횟수를 초기화한다. 완료·취소 전송이 성공한 뒤에는 반복하지 않는다.
단, 여기서 전송 성공은 여전히 simctl 주입 성공이다. Swift의 조회나 실제 표시 갱신까지 완료됐다는 응답을 서버가 받는 구조는 아니다. 이 한계 때문에 화면과 네이티브 로그를 같이 확인했다.
늦게 온 조회가 상태를 되돌리지 않도록
새 상태를 반영할 수 있게 되면, 늦은 응답을 어디까지 받아들일지도 정해야 한다. 네이티브에서도 오래된 응답이 더 최근의 상태를 덮지 않도록 했다.
Swift가 만드는 표시 데이터에 서버 원본 serverTime을 남겼다. 서버 기준 남은 시간을 기기 시각에 옮기더라도 응답 순서를 비교하는 기준까지 기기 시각으로 바꾸지는 않았다.
현재 내용이 이미 완료·취소라면 새 응답으로 바꾸지 않는다. 진행 중이라면 새 응답의 서버 시각이 기존보다 작을 때 거부한다. 같은 서버 시각은 허용한다. 기존 serverTime이 없는 이전 버전의 표시 데이터는 호환을 위해 받아들인다.
이 규칙은 RN 캐시의 sequence 판정을 Swift에 그대로 옮긴 것은 아니다. 네이티브의 표시 데이터와 원본 서버 시각을 기준으로 정한 별도 검증이다. 응답의 주문 ID·상태·시각·진행률도 검사하지만, 지도와 사건 목록까지 포함한 전체 주문 schema를 Swift에 복제하지는 않았다.
시간 변환에서는 작은 차이도 나왔다. Swift의 Date를 밀리초로 바꾸면 소수점이 생길 수 있었다. 기존 표시 계약은 정수 밀리초를 사용하므로 수신 시각을 내림 처리했다.
이 수신 시각에 서버의 예정 시각과 서버 현재 시각의 차이를 더해 기기용 예상 시각을 만든다. 네트워크 지연을 측정해 보정하거나 두 시계를 동기화한 것은 아니다. 현재 검증에는 서버 시각이 기기보다 60초 넘게 앞선 응답을 거부하는 조건도 있다. 임의로 크게 어긋난 모든 시계를 지원한다고 설명할 수는 없다.
화면에서 확인한 것과 테스트에서 확인한 것
중간 상태 갱신 실험에서는 09:11:34에 첫 번째 주문을 시작한 뒤 홈 화면으로 나갔다. 다이나믹 아일랜드의 ‘주문 수락’과 이후 ‘조리 중’ 갱신을 확인했다. 이 주문은 앱에 복귀하지 않은 채 완료 후 잠금화면 카드와 아일랜드 표시가 제거된 것을 확인했다.
두 번째 주문은 09:14:47에 시작해 잠금화면에서 지켜봤다. 당시 관찰 기록은 다음과 같다. 아래 표는 캡처를 새로 만든 것이 아니라 그때 화면에서 읽어 기록한 값을 옮긴 것이다.
| 잠금화면 상태 | 남은 시간 | 카드의 마지막 확인 시각 |
|---|---|---|
| 주문 수락 | 1:52 | 09:14:53 |
| 조리 중 | 1:27 | 09:15:19 |
| 픽업 대기 | 1:07 | 09:15:37 |
| 배달 중 | 0:53 | 09:15:53 |
짧은 5초짜리 ‘픽업 완료’ 구간은 화면 확인을 놓쳤다. 이 단계는 worker와 Swift의 단계 테스트에서 확인한 범위로 남겼다. 두 번째 주문은 후반에 앱으로 돌아왔으므로, 이 주문의 종료를 계속 백그라운드에 머문 증거로 사용하지 않았다.
정수 밀리초 보정까지 포함한 최종 앱은 다시 빌드해 설치했다. 09:19:12에 시작한 새 주문에서 백그라운드의 ‘조리 중’ 갱신을 확인했다. 앞서 관찰한 모든 단계와 자연 완료를 이 최종 빌드에서 다시 촬영한 것은 아니다.
해당 수정의 검증 기록에는 API·도메인 120개, 모바일 97개, 생성 플러그인 6개, Swift 14개로 총 237개 테스트 통과가 남아 있다. 타입 검사와 iOS 시뮬레이터 Release 빌드·설치도 통과했다. 이는 이 수정 작업 당시의 결과이며, 블로그 발행 과정에서 전체 테스트와 네이티브 빌드를 다시 실행한 수치는 아니다.
iOS 시뮬레이터 실험을 마치며
이번 수정으로 시뮬레이터에서는 앱을 잠근 동안 조리·픽업 대기·배달 중 상태를 받고, 서버가 완료를 확인하면 표시를 지우는 흐름을 관찰했다. 백그라운드에서도 React Native의 조회를 계속 돌리는 방식은 사용하지 않았다.
중간 갱신을 추가한 것은 simulator 전송 경로다. 현재 실제 APNs용 코드는 완료·취소 전송만 다루며, 인증과 기기 수신은 아직 검증하지 않았다. ActivityKit APNs는 해당 Live Activity를 갱신·종료하기 위한 별도 경로다.
이번의 5초 갱신 간격을 실기기의 일반 silent notification 운영 방식으로 옮겨서도 안 된다. Apple의 background notification 안내는 전달을 보장하지 않으며 잦은 전송이 제한될 수 있다고 설명한다. 로컬 시뮬레이터 실험의 주기와 실제 서비스의 전달 조건은 다르다.
실제 iPhone의 APNs 중간 갱신, 사용자 강제 종료, 네트워크 실패 뒤 복구는 후속 실험으로 남아 있다. 서버도 메모리 저장소라 재시작하면 주문과 등록이 사라진다. 지금은 새 배달로 다시 시연해야 한다.
처음에는 조회할 때마다 라이더 위치를 조금씩 바꾸는 목 API를 생각했다. 여기까지 오면서 조회와 시간표, 예상 시간과 확인된 상태, 앱 화면과 OS 표시, 모의 알림 성공과 실제 갱신을 하나씩 나눴다. 같은 배달을 보여주더라도 어느 쪽이 무엇을 알고 있는지 확인하는 과정이었다.
iOS 시뮬레이터를 다룬 여섯 편은 여기서 마친다. 프로젝트는 계속 진행 중이다. 7편에서는 같은 배달 시나리오를 Android에 연결하고, 기존 RN 코드에서 무엇을 재사용했는지 살펴본다.
