React Native에서 Live Activity를 구현하며 이해한 App Group, Sandbox, AsyncStorage 정리
러닝 앱 PIRL을 개발하면서 Live Activity와 위젯을 연동했다. 그 과정에서 iOS의 Sandbox, App Group, AsyncStorage 개념을 자연스럽게 접했다. 처음에는 그냥 "상태를 공유하면 된다" 정도로 접근했는데, 실제로 구현해보니 왜 일반 저장소만으로는 안 되는지, 왜 App Group이 필요한지, Widget과 Live Activity는 뭐가 다른지를 하나씩 짚어야 했다. 덕분에 iOS 앱 구조 자체를 조금 더 깊게 보게 됐다.
Sandbox, 앱마다 분리된 방 하나씩
Sandbox는 앱마다 독립된 공간을 주는 iOS 보안 구조다. 카카오톡 공간, 인스타 공간, PIRL 공간처럼 앱마다 완전히 분리된 방이 하나씩 있는 셈이다.
Sandbox가 없다면 다른 앱 파일에 접근하거나, 로그인 토큰을 탈취하거나, 개인정보를 들여다보는 일이 가능해진다. 그래서 iOS는 기본적으로 파일과 저장소, 메모리를 앱 단위로 격리한다. 한 앱이 다른 앱의 공간을 들여다볼 수 없는 구조다.
앱과 위젯 Extension은 서로 다른 Sandbox에 있었다
구현하면서 흥미로웠던 부분은 앱과 위젯 Extension이 서로 독립된 프로세스와 Sandbox를 가진다는 점이었다. Live Activity는 별도 Extension이 아니라 위젯 Extension 안에서 함께 렌더링되지만, 그 위젯 Extension 자체가 React Native 앱과는 분리된 Sandbox에 있었다. 즉 React Native App ≠ 위젯 Extension(Widget · Live Activity) 구조였다.
위젯 Extension 하나가 Widget과 Live Activity를 모두 품고 있고, 그 Extension이 앱과는 다른 Sandbox에 떨어져 있다. 이 경계가 뒤에서 다룰 AsyncStorage의 한계를 만든 원인이었다.
AsyncStorage로는 위젯이 상태를 읽지 못했다
React Native에서 가장 익숙한 저장소는 AsyncStorage였다. 앱 내부 상태를 키-값으로 저장하는 방식인데, 웹의 localStorage에 가깝다. 러닝 상태를 저장하려고 처음엔 이걸 그대로 썼다.
await AsyncStorage.setItem("running_state", JSON.stringify(state));문제는 AsyncStorage가 React Native 앱 내부 저장소라는 점이었다. 앞 절에서 본 대로 위젯 Extension은 앱과 다른 Sandbox에 있다. 그래서 앱에서 running_state를 저장해도 Widget도 Live Activity도 그 값을 읽을 수 없었다. 앱 안에서만 닫힌 저장소였던 것이다.
App Group으로 Sandbox 경계를 넘는 저장소를 열었다
App Group은 서로 다른 Sandbox가 특정 저장소를 공유할 수 있게 해주는 Apple 기능이다. group.com.pirl.app 같은 그룹을 만들면, 앱과 위젯 Extension이 하나의 공유 저장소(shared container)를 함께 쓸 수 있다.
App Group을 적용하기 전과 후의 구조를 나란히 보면 차이가 분명했다. 적용 전에는 앱 Sandbox와 위젯 Extension Sandbox가 완전히 분리돼 서로의 저장소에 닿지 못했다.
App Group을 적용한 뒤에는, 앱과 위젯 Extension이 같은 shared container 하나를 바라봤다. 두 Sandbox는 여전히 분리돼 있지만, 공유 저장소만큼은 함께 읽고 쓸 수 있게 됐다.
PIRL에서 상태를 동기화한 경로
PIRL에서는 러닝 상태를 현재 거리, 진행 시간, 일시정지 여부 등으로 관리했다. 문제는 앱이 백그라운드로 가더라도 Live Activity와 Widget은 계속 최신 상태를 보여줘야 한다는 점이었다. 그래서 상태를 App Group 기반 공유 저장소에 흘려보내는 경로를 잡았다.
앱이 상태를 공유 저장소에 쓰면, 위젯 Extension 쪽에서 그 값을 읽어 UI를 갱신한다. 앱이 화면 밖으로 나가도 위젯이 직접 저장소를 보기 때문에 표시가 끊기지 않았다.
Widget과 Live Activity는 목적이 달랐다
처음에는 둘이 거의 같은 기능이라고 생각했다. 구현하면서 보니 목적 자체가 달랐다.
| 구분 | Widget | Live Activity |
|---|---|---|
| 성격 | 정보 요약 | 진행 중 상태 실시간 표시 |
| 예시 | 날씨, 일정, 배터리, 오늘 러닝 기록 | 배달 도착 시간, 운동 진행, 경기 스코어 |
| 핵심 | 현재 상태 요약 | 실시간 이벤트 추적 |
PIRL 기준으로 나눠보면 역할이 더 또렷했다. Widget에는 오늘 총 러닝 거리, 최근 운동 기록, 칼로리 같은 요약 정보를 담았다. Live Activity에는 현재 거리, 현재 페이스, 진행 시간, 일시정지 여부 같은 실시간 상태를 올렸다.
결국 보게 된 것
처음에는 "기능 하나 추가한다"는 느낌으로 시작했는데, 실제로는 iOS 프로세스 구조와 Sandbox 보안 구조, 그리고 앱과 Extension 사이의 상태 공유 방식을 이해해야 손댈 수 있는 영역이었다. 특히 "왜 AsyncStorage만으로는 안 되는가"를 따라가다 보니 앱 구조 자체를 더 깊게 보게 됐다. React Native만 다루는 게 아니라 Native Extension 구조와 플랫폼 레벨 제약을 이해해야 했던 작업이었고, 아직 Native 개발을 깊게 파는 단계는 아니지만 프론트엔드 개발자로서 플랫폼 구조를 이해해본 경험으로는 꽤 의미가 있었다.