(주)사운드마인드
오디야 자녀 단말 앱
관리 정책이 데이터를 꺼두고 로그도 볼 수 없는 단말에서, 배터리를 아끼면서 위치를 놓치지 않는 방법을 찾는 일이었습니다.
- 역할
- 위치 수집 상태기계 설계 및 Android 네이티브 구현
- 기간
- 2025.07 ~ 현재
- 사용 기술
- React NativeAndroid (Java/Kotlin)FusedLocationActivity RecognitionFCM
발견한 문제
45초 → 357초
절전 모드에서 실측한 타이머 지연
개선 결과
100%
전송 성공률 · 평균 유지 1.17초를 최소 6초로
10.2KB → 1.1KB
위치 배치 요청 본문 · 압축으로 89% 감소
43MB → 6.8MB
단말 1대 월 전송량 · 압축과 연결 재사용 적용 후
4 / 4
지하철 실측 건 중 기지국 좌표 판별
01문제
이 앱이 도는 단말은 일반 스마트폰이 아닙니다. 자녀 보호를 위해 도입된 관리 정책이 기기 전체의 모바일 데이터를 평소 꺼두고, 개발자 모드까지 막아두기 때문에 문제가 생겨도 로그를 뽑을 수 없습니다. 그런데 요구사항은 부모가 자녀의 위치를 언제든 확인할 수 있어야 한다는 것이었습니다. 데이터가 꺼진 기기에서 주기적으로 위치를 보내야 하고, 무엇이 잘못됐는지 관측할 방법이 없는 상태에서 안정성을 확보해야 했습니다.
- 기기 데이터가 기본적으로 차단되어 있어, 전송할 때마다 관리 에이전트에 개방을 요청해야 합니다
- 개발자 모드가 막혀 있어 실기기 로그 수집이 불가능합니다
- 재현되지 않는 현장 버그를 추측만으로 고쳐야 하는 상황이 반복됐습니다
02제약
흔히 쓰는 해법이 대부분 막혀 있었습니다. 네트워크가 평소 닫혀 있으니 서버가 먼저 앱에 연락할 수 없어 푸시로 제어하는 방식을 쓸 수 없었고, 배터리를 위해 GPS를 계속 켜둘 수도 없었습니다. 여기에 안드로이드 절전 정책이 예약한 타이머를 임의로 미루는 문제가 겹쳤습니다.
이 앱이 도는 근거
온보딩은 두 권한을 다 확인해야 통과합니다
- 이 앱은 두 권한 위에 서 있습니다. Knox 관리 라이선스가 있어야 데이터를 열 수 있고, 배터리 최적화 예외가 있어야 잠든 기기에서 깨어납니다. 하나만 빠져도 전송은 0건입니다
- Knox 관리 라이선스 · 앱이 직접 데이터를 켤 수 없습니다. 관리 에이전트에 브로드캐스트로 개방을 요청해 대신 켜달라고 부탁하는 구조라, 라이선스가 비활성이면 요청 자체가 받아들여지지 않습니다. 기능에 따라 제조사 승인이 필요한 항목은 별도로 신청했습니다
- 배터리 최적화 예외 · 예외 목록에 없으면 시스템이 앱을 대기 등급 하위로 내려, 정확 알람조차 더 늦게 발화하고 백그라운드 통신이 추가로 제한됩니다. 잠든 기기에서 깨어나는 것 자체가 불확실해집니다
- 데이터 개방 요청은 비동기라 3초에서 5초가 걸리고, 완료를 알려주는 신호가 없습니다
- 절전 모드에서 45초로 예약한 타이머가 최대 357초까지 밀리는 것을 직접 측정했습니다
- GPS를 상시 점등하면 배터리가 하루를 버티지 못합니다
- 정확도 값만 보고 좌표를 믿으면, 기지국 위치를 GPS로 위장한 값에 속습니다
03검토한 대안
네트워크 개방을 어떻게 관리할지가 갈림길이었습니다. 세 가지를 놓고 비교했고, 앞의 두 방식은 실제로 만들어 돌려본 뒤 문제를 확인하고 버렸습니다.
| 안 | 장점 | 문제 |
|---|---|---|
| 상시 개방 | 구현이 가장 단순하고 전송이 실패할 일이 없음 | 관리 정책의 취지에 정면으로 어긋나고 배터리와 요금이 감당되지 않음 |
| 요청마다 열고 닫기 (단일 토큰) | 필요할 때만 열리므로 정책과 배터리 양쪽을 만족 | 토큰을 공유하다 보니 먼저 끝난 쪽이 아직 전송 중인 요청의 네트워크를 끊음. 실측 유지 시간 중앙값 1.17초 |
| 단일 창구 + 요청자별 카운트채택 | 마지막 사용자가 끝날 때까지 유지되고, 최소 유지 시간을 규칙으로 강제할 수 있음 | 반납 누락 시 계속 열린 채로 남으므로 안전장치를 따로 둬야 함 |
04선택과 근거
단일 창구 방식을 골랐습니다. 개방 요청이 비동기라 완료 신호가 없다는 점이 결정적이었습니다. 요청 즉시 전송하면 아직 열리지 않은 상태에서 나가고, 전송이 끝나자마자 닫으면 다음 요청이 다시 3초를 기다립니다. 그래서 요청자를 세어 마지막 사용자가 끝날 때까지 유지하고, 최소 6초 규칙을 더했습니다. 반납이 누락될 위험은 안전장치를 네 겹 두어 막았습니다.
- 요청자별 카운트로 개방과 반납을 관리하고, 최소 6초는 유지
- 반납 보장 4중 장치: 웨이크락 시한, 워치독, 알람 틱에서의 2차 회수, 세대 토큰
- 세대 토큰은 지연 도착한 옛 콜백이 새 세션의 네트워크를 끊는 것을 막는 용도
- GPS 점등 조건을 보유 위치의 나이로 바꿔, 전송 실패가 조건을 영구히 잠그지 않게 함
- 좌표는 정확도 게이트, 위성 개수 판별, 속도 타당성 순으로 검증
05절전 모드와의 싸움
안드로이드는 화면이 꺼진 채 방치된 기기를 도즈(Doze) 상태로 내립니다. CPU를 재우고, 네트워크 접근을 막고, 앱이 예약한 알람을 일정 구간마다 몰아서 처리합니다. 문제는 이 서비스의 주 사용 조건이 정확히 그 상태라는 점입니다. 자녀 단말은 주머니와 가방 속에서 화면이 꺼진 채 하루를 보내고, 위치는 바로 그동안 계속 올라와야 합니다. 도즈를 예외 상황이 아니라 기본 무대로 놓고, 시스템이 앱을 재우려는 각 지점마다 대응을 쌓았습니다.
도즈에서 도는 한 사이클
마지막 단계가 다음 알람을 잡습니다. 이 고리가 끊기면 앱은 다시 깨어나지 못합니다
예약 대비 실제 발화
타이머 대신 벽시계로 지난 시간을 다시 계산합니다
깨어날 권한
일반 알람은 도즈에서 유지보수 창까지 통째로 미뤄집니다. 도즈 중에도 발화하는 정확 알람 API로 예약하되, 그마저도 약 9분 간격 그리드로 스로틀된다는 것을 전제로 수집 판정을 설계했습니다. 9분 안에 여러 번 깨우려 싸우는 대신, 깨어난 김에 밀린 판정을 몰아서 처리합니다
배터리 최적화 예외
예외 목록에 들어가지 않으면 앱이 대기 등급 하위로 내려가 알람과 네트워크가 한 겹 더 조여집니다. 온보딩에서 이 예외를 명시적으로 요청하고, 승인되지 않으면 다음 단계로 넘어가지 못하게 게이트로 묶었습니다
깨어 있는 동안 유지
알람이 깨워도 수집과 전송이 끝나기 전에 CPU가 다시 잠들면 그대로 중단됩니다. 작업 구간 동안 부분 웨이크락을 잡되 시한을 걸어 90초 이상 붙들지 않고, 워치독과 다음 알람 틱에서 반납 누락분을 회수합니다. 웨이크락을 오래 쥐는 것은 도즈를 이기는 게 아니라 배터리를 태우는 일입니다
시간 감각 보정
도즈에서는 경과 시간 기준 타이머가 늘어납니다. 45초로 예약한 것이 실측에서 136초에서 357초까지 벌어졌습니다. 그래서 타이머를 신뢰하지 않고, 깨어날 때마다 벽시계로 실제 지난 시간을 다시 계산해 밀린 만큼 보정합니다
늦게 도착한 과거
늘어진 타이머의 낡은 콜백이 뒤늦게 도착해 이미 새로 시작된 사이클의 네트워크를 반납해 버리는 사고가 있었습니다. 사이클마다 세대 토큰을 부여해, 자기 세대가 아닌 콜백은 무시하도록 했습니다
끊기면 끝나는 체인
알람은 반복 예약이 아니라 처리의 마지막 줄에서 다음 알람을 직접 잡는 자기 재예약 체인입니다. 한 고리가 빠지면 영영 침묵하므로 재예약 지점을 코드 한 곳으로 고정하고, 부팅이나 프로세스 종료로 체인이 끊긴 경우의 부활 경로를 따로 뒀습니다
제조사 절전 정책
안드로이드 표준 도즈 위에 제조사별 절전 정책이 한 겹 더 얹힙니다. 표준 예외를 받아도 여기서 다시 잠들 수 있어, 단말 제조사 정책까지 확인해 예외 처리를 맞춰야 했습니다
45초로 예약한 알람이 실제로 발화한 시각
최대 357초45초 예약
06요금제라는 제약
이 단말들은 자녀용 저가 요금제를 씁니다. 데이터 총량이 빠듯해서, 위치 전송이 요금제를 갉아먹으면 정작 아이에게 연락이 필요한 순간에 통신이 막힙니다. 그런데 실제로 나가는 내용을 열어보니 위치 배치가 평문 JSON 그대로 전송되고 있었습니다. 같은 키가 항목마다 반복되는 구조라 압축이 가장 잘 듣는 데이터인데도 그대로였습니다. 위치 30건을 담은 배치 하나가 평문으로 약 10.2KB인데, 이 안에서 좌표와 시각만 바뀌고 필드 이름은 서른 번 되풀이됩니다.
요청 1건의 구성
단말 1대 월 43MB → 6.8MB
- 요청 본문을 gzip으로 압축해 보내도록 앱을 바꿨습니다. 30건 배치 기준 10.2KB가 1.1KB로 줄어 약 89% 감소했고, 배치가 커질수록 비율은 더 올라갑니다
- 톰캣과 스프링은 요청 본문 압축을 기본으로 해제해 주지 않습니다. 서버에서 가장 앞 순서의 필터로 풀도록 하고, 압축 헤더가 없으면 그대로 통과시켜 구버전 앱과의 호환을 유지했습니다
- 전송마다 연결을 새로 여는 것도 비용입니다. 30초 간격으로 짧은 요청이 반복되는 패턴이라 TCP 3-way 핸드셰이크와 TLS 협상이 본문보다 무거운 상황이었습니다
- 앞단에 프록시를 두고 keep-alive로 연결을 살려 재사용하도록 하고, TLS 세션 재사용을 켜 재협상 비용까지 줄였습니다. 매 전송이 연결부터 다시 시작하던 것을 없앤 것입니다
- 압축을 켠 직후 일부 경로에서 403이 돌아와, 한동안 평문으로 되돌아가는 폴백을 함께 뒀습니다. 앞단 설정을 바로잡은 뒤 그 폴백을 봉인했습니다
- 같은 원리로 진단 기록과 재전송 대기분을 별도 요청으로 보내지 않고 위치 전송에 동승시켰습니다. 요청 수 자체가 곧 요금이기 때문입니다
- 모두 합치면 단말 한 대의 월 전송량이 약 43MB에서 6.8MB 수준으로 내려갑니다. 압축이 본문을 줄이고, 연결 재사용이 하루 96번 반복되던 핸드셰이크 비용을 걷어낸 결과입니다
- 마지막으로 단말이 자신의 데이터 사용량을 함께 보고하도록 하고, 관리자 대시보드에서 월평균 사용량을 볼 수 있게 했습니다. 줄였다는 말이 아니라 얼마나 쓰는지가 보이게 만든 것이고, 이후 어디를 더 줄일지 판단할 근거가 여기서 나옵니다
위치 배치 요청 본문 (위치 30건)
10.2KB1.1KB단말 1대 월 전송량
43MB6.8MB
07서비스센터에 가야 고칠 수 있는 앱
이 앱에는 보통의 배포가 통하지 않습니다. 데이터가 기본적으로 차단된 단말이라 스토어 자동 업데이트가 돌지 않고, 결국 버그 하나를 고치려면 사용자가 단말을 들고 서비스센터에 가서 수동으로 업데이트해야 합니다. 현장에 나간 버전이 사실상 고정된다는 뜻입니다. 실제로 대다수 단말이 구버전에 머물러 있었고, 급한 수정도 한 대씩 손으로 옮겨야 했습니다.
수정 하나가 단말에 닿기까지
네이티브는 여전히 방문이 필요하므로, 구버전은 사라지지 않습니다
- 화면 로직이 자바스크립트 번들로 분리되는 구조라는 점에 착안해, 네이티브를 다시 설치하지 않고 번들만 교체하는 자체 코드푸시 경로를 앱에 심었습니다
- 다만 번들을 내려받는 것 자체가 네트워크를 씁니다. 데이터를 따로 열지 않는다는 원칙을 지키기 위해, 위치 전송을 위해 이미 열리는 네트워크 창에 번들 확인과 수신을 얹었습니다. 코드푸시를 위한 별도 개방은 두지 않았습니다
- 요금제가 빠듯하니 번들도 비용입니다. 전체가 아니라 바뀐 부분만 받도록 하고, 받다가 끊기면 다음 창에서 이어받게 했습니다
- 적용 시점을 둘로 나눴습니다. 즉시 반영이 필요한 수정은 받자마자 재시작하고, 급하지 않은 것은 다음 실행에 조용히 적용해 사용 중인 아이를 방해하지 않습니다
- 새 번들이 부팅에 실패하면 이전 번들로 자동 복귀합니다. 서비스센터에 가야만 고칠 수 있는 단말을 원격으로 벽돌로 만들 수는 없기 때문에, 롤백은 선택이 아니라 전제였습니다
- 네이티브 코드 변경은 여전히 스토어와 방문이 필요하다는 경계를 명확히 두고, 코드푸시로 덮을 수 있는 범위를 문서로 못 박았습니다
- 그래서 구버전 호환이 선택이 아니라 제약이 됩니다. 네이티브를 고칠 수 없다는 것은 현장의 구버전이 사라지지 않는다는 뜻이고, 앞으로 나올 모든 서버 변경은 그 구버전과도 계속 맞물려야 합니다
- 서버 API는 필드를 추가하는 방향으로만 바꿨습니다. 지우거나 의미를 바꾸면 그 순간 업데이트할 수 없는 단말들이 통째로 떨어져 나갑니다. 압축 전송을 도입할 때도 압축 헤더가 없는 요청은 그대로 통과시켜, 옛 버전이 아무것도 모른 채 계속 동작하게 했습니다
- 새 기능이 보내는 필드를 구버전은 보내지 않으므로, 그 데이터에 기대는 화면은 자녀앱 버전을 확인해 아예 켜지 않도록 했습니다. 없는 데이터를 기본값으로 채워 보여주는 대신 그 기능이 없는 것으로 다룹니다
- 그 결과 서버는 여러 세대의 앱을 동시에 상대합니다. 배포 때마다 최신 버전만이 아니라 현장에 남아 있는 가장 오래된 버전에서도 깨지지 않는지를 함께 확인하는 것이 기준이 됐습니다
- 배포는 한 번에 전량이 아니라 단계적으로 넓혔습니다. 일부에 먼저 적용해 이상이 없는 것을 확인한 뒤 범위를 키웁니다
08구현과 시행착오
설계대로 만들었는데도 현장에서 세 번 더 틀렸습니다. 로그를 볼 수 없는 환경이라 매번 가설을 세우고 진단 기록을 전송에 얹어 회수하는 방식으로 좁혀갔습니다.
- ① 최소 유지 시간을 3초로 잡았다가 실패가 남아 있었습니다. 개방 요청이 3~5초 걸린다는 것을 측정한 뒤 6초로 올렸고, 그 이후 실패가 사라졌습니다.
- ② 정지 상태 판정을 시간 창으로 만들었더니, 전송에 한 번 실패한 단말이 조건을 영영 만족하지 못해 침묵했습니다. 창 대신 보유 위치의 나이를 문턱으로 삼아 재구성했습니다.
- ③ 절전 모드에서 수집이 멈추는 원인을 오래 못 찾았습니다. 예약 타이머를 실제 시각과 대조해 기록해 보니 45초 예약이 최대 357초까지 밀리고 있었습니다. 실시간 기준 백스톱을 넣어 보정했습니다.
- ④ 개발자 모드가 막혀 로그를 못 뽑으므로, 진단 기록을 위치 전송에 얹어 보내고 서버에서 중복을 흡수하도록 했습니다. 이 경로를 위해 네트워크를 따로 여는 것은 금지했습니다.
09결과
전송 도중 데이터가 끊기는 문제가 사라졌습니다. 기존에는 데이터가 켜진 상태가 평균 1.17초밖에 유지되지 않아 요청을 끝내지 못하는 경우가 잦았는데, 최소 6초 유지 규칙을 넣은 뒤로는 전송 성공률이 100%가 됐습니다. GPS 점등 조건을 바꾼 뒤로는 전송에 한 번 실패했다는 이유로 앱이 계속 침묵하는 현상도 없어졌습니다. 좌표 검증은 지하철 이동 실측 4건에서 기지국 좌표를 모두 정확히 걸러냈고, 확신이 서지 않으면 아예 보내지 않도록 해서 보호자 앱이 근거 없는 위치를 그리지 않게 됐습니다.