
서버를 단 한 대도 갖지 않고 등산 기록 앱을 만드는 기술 선택
요약
서버 없이 단말기 내에서 모든 데이터가 처리되는 서버리스 방식의 등산 기록 앱 개발 사례를 소개합니다. 운영 비용을 최소화하여 일시불 판매 모델을 가능하게 하고, 프라이버시를 강화한 설계 방침과 기술적 구현 방법을 다룹니다.
핵심 포인트
- 서버리스 설계를 통해 런닝 코스트를 0에 가깝게 유지하여 일시불 수익 모델 구현
- 데이터를 외부로 전송하지 않는 구조로 강력한 프라이버시 보호 제공
- 인증이 필요 없는 공개 API를 활용하여 프록시 서버 없이 아키텍처 단순화
- 오프라인 지도 타일 다운로드 시 병렬 처리를 통한 성능 최적화 및 트래픽 조절
등산 기록 앱을 개인적으로 개발한 지 3개월이 지났습니다. GPS 트래킹, 오프라인 지형도, 코스 타임 계획, AI를 이용한 게시글 생성, 음성 메모의 텍스트 변환(Transcription). 일련의 기능들이 탑재되어 있지만, 서버는 단 한 대도 가지고 있지 않습니다. 계정 등록도 없습니다. 모든 데이터는 단말기 내에만 저장됩니다.
이것은 기술적인 제약에서 비롯된 타협이 아니라, 처음에 결정한 설계 방침입니다. 이 기사에서는 그 방침이 어떤 기술 선택으로 이어졌는지에 대해 쓰겠습니다.
왜 서버리스(Serverless)로 했는가
등산 앱의 국내 시장은 YAMAP과 Yamareco의 양강 체제이며, 둘 다 구독형(Subscription) 모델입니다. 서버를 유지하고, 지도를 배포하며, 커뮤니티를 운영하기 위해서는 지속적인 수입이 필요하므로 당연한 선택이라고 생각합니다.
제가 만들고 싶었던 것은 '기록을 남기는 도구'였습니다. 결제를 중단하는 순간 과거 기록에 대한 액세스가 제한되는 설계는 피하고 싶었습니다. 그러기 위해서는 일시불(Buy-out) 방식으로 만들어야 했고, 일시불 판매를 성립시키기 위해서는 런닝 코스트(Running Cost)를 0에 가깝게 만들 필요가 있었습니다.
서버가 없다면 월간 비용은 실질적으로 0입니다. 그래서 일시불 판매가 성립됩니다. 여기서 방침과 기술 구성이 맞물렸습니다.
부차적인 효과로 프라이버시 설계가 매우 심플해졌습니다. "데이터를 전송하지 않습니다"가 아니라 "전송할 대상이 존재하지 않습니다"라고 말할 수 있습니다. 이 차이는 큽니다.
전체 구성은 다음과 같습니다.

단말기 내에서 완결되며, 외부에는 인증이 필요 없는 공개 API 3개만 존재
외부 API는 공개 API만 사용
서버리스라고 해도 지도나 날씨를 직접 보유할 수는 없습니다. 사용하고 있는 것은 다음 3가지이며, 모두 API 키가 필요 없고 인증이 필요 없는 공개 API입니다.
| 용도 | 서비스 |
|---|---|
| 지형도 타일 · 표고 | 국토지리원 |
| ... |
API 키가 필요하지 않다는 것은, 키를 은닉하기 위한 프록시 서버(Proxy Server)가 필요 없다는 뜻입니다. 여기서도 서버리스가 유지됩니다.
다만 공개 API에는 공개 API 나름의 제약이 있습니다.
Overpass API는 레이트 리밋(Rate Limit)이 엄격합니다. 공유 인프라이므로 짧은 시간에 연속해서 요청하면 차단됩니다. 앱 측에서 최소 15초의 인터벌(Interval)을 두었고, 시가지처럼 결과가 방대해지는 지역에서는 취득 건수와 묘사(Rendering) 수에 상한을 두었습니다. 상한에 도달했을 때는 배너로 사용자에게 알리고, '미선택 항목 클리어'를 통해 필터링할 수 있는 동선을 마련했습니다. 무제한으로 가져오려는 구현은 본인이 곤란해질 뿐만 아니라 공유 인프라에 부하를 줍니다.
국토지리원 타일은 이용 조건 확인이 필요합니다. 측량법에 따른 승인이 필요한 케이스가 있어 승인 번호를 명시하고 있습니다. 라이선스나 이용 약관은 반드시 직접 확인하시기 바랍니다.
오프라인 지도: 병렬 다운로드 조절
오프라인 지도는 줌 레벨(Zoom Level) 12~16의 타일을 사전에 취득하는 방식입니다. 당초에는 순차 다운로드 방식이었으나, 넓은 영역의 경우 대기 시간이 너무 길어 실용적으로 사용하기 어려웠습니다.
6병렬로 변경했더니 체감상 약 5배 빨라졌습니다. 여기서 중요한 점은 병렬 수를 높인다고 해서 무조건 빨라지는 것은 아니라는 점입니다. 상대는 타일 서버이며, 과도한 병렬 요청은 상대에게 폐가 될 뿐만 아니라 모바일 회선에서는 혼잡(Congestion)이 발생하여 오히려 느려집니다. 실측하면서 적절한 타협점을 찾는 작업이었습니다.
타일 저장에는 FileProtection을 설정하고, 쓰기는 원자적(Atomic)으로 수행하고 있습니다. 산속에서 앱이 종료되었을 때, 깨진 타일이 남아 지도가 표시되지 않는 사태를 방지하기 위해서입니다.
코스 타임 계산: 공개된 식을 사용
등산에서는 "이 구간은 몇 분이 걸리는가"라는 소요 시간 예측이 안전과 직결됩니다. 직접 적당한 계수를 만들기보다 실적이 있는 식을 사용하는 것이 옳다고 생각했습니다.
YAMAP이 공개하고 있는 계산식이 경사도에 따른 이차식으로 오르막과 내리막을 나누어 산출하는 형태입니다. 구간마다 수평 거리와 표고차로부터 경사를 구하고, 대응하는 식으로 소요 시간을 산출하여 쌓아 올립니다. 구현 자체는 정직합니다.
// 개념 코드: 구간마다 경사를 구하여 소요 시간을 적산함
func estimatedMinutes(for segments: [Segment], pace: Double) -> Double {
segments.reduce(0) { total, seg in
...
여기에 보행 페이스 계수(표준 × 1.0을 기준으로 사용자가 조정 가능)와 체크포인트마다의 휴식 시간을 더하여, 출발 시각으로부터 도착 예상 시각을 산출합니다.
또한 이 계산을 사용하여 일몰 전에 하산할 수 있는지를 판정합니다. 일몰 시각은 경로의 좌표와 표고(elevation)로부터 산출할 수 있으므로, 사용자의 수동 설정은 필요하지 않습니다. 실제 보행 페이스와 남은 거리를 바탕으로 도착 예상 시각을 업데이트하며, 일몰 시각이 다가오면 배너로 주의를 환기하고, 일몰을 넘길 것으로 예상되면 즉시 경고합니다.
안전 기능은 단순히 "계산된 값을 표시하는 것"만으로는 불충분하며, 임계값(threshold)을 초과했을 때 능동적으로 개입할 필요가 있다고 생각합니다.
Live Activity: 화면을 보지 않는 것을 전제로 한 UI
등산 중 사용자는 스마트폰을 계속 보고 있지 않습니다. 배낭의 숄더 포켓에 넣어두고 가끔 꺼내서 확인할 정도입니다.
그래서 Live Activity와 Dynamic Island에 대응했습니다. 잠금 화면에 보행 거리, 표고, 경과 시간, 다음 체크포인트까지의 거리를 표시하여, 손목을 들어 올리는 것만으로도 진행 상황을 알 수 있도록 했습니다. 경로 이탈 경고도 이곳에 표시됩니다.


거리, 표고, 경과 시간, 다음 체크포인트까지의 거리를 표시
Widget Extension을 별도의 타겟(target)으로 구성하였으며, 업데이트 빈도는 배터리 소모와의 트레이드오프(trade-off)를 고려하여 조정했습니다. GPS 추적 중에는 백그라운드에서 위치 정보를 계속 수집하기 때문에, 이 부분이 허술하면 "배터리를 많이 먹는 앱"으로 간주되어 즉시 삭제될 것입니다. 산에서는 배터리 방전이 조난으로 이어질 수 있으므로, 기능 추가보다 우선순위가 높은 영역입니다.
GPS 로그의 자동 백업
장시간의 산행 중에 앱이 예기치 않게 종료되면 그때까지의 기록이 사라집니다. 이는 등산 앱으로서 허용할 수 없는 실패입니다.
대책으로서, 트래킹(tracking) 중에는 30초마다 기록을 로컬(local)로 자동 백업합니다. 다음 실행 시 완료되지 않은 세션(session)을 감지하면, 이를 복원하여 계속할 수 있습니다.
화려한 기능은 아니지만, 이러한 "고장 났을 때 무엇이 남는가"에 대한 설계가 기록 계열 앱의 신뢰성을 결정하는 부분이라고 생각합니다.
사진 스캔: 과거의 등산을 복원하기
개인적으로 가장 마음에 드는 기능입니다. 사진 라이브러리를 스캔하여 사진의 위치 정보와 촬영 일시를 바탕으로, 과거에 올랐던 산과 날짜를 추정하여 등산 기록을 자동으로 생성합니다.
앱을 설치한 첫날, 20년 치의 등산 이력이 한꺼번에 나타납니다. 이 경험이 이 앱의 핵심이라고 생각합니다.
구현상의 포인트는 산 정상 좌표 데이터베이스와의 대조입니다. 약 900개 명산의 데이터(좌표, 표고, 소재지)를 보유하고 있으므로, 사진의 좌표로부터 일정 범위 내의 산을 후보로 추출하고, 같은 날의 사진군을 클러스터링(clustering)하여 하나의 산행으로 묶습니다.
참고로, 저장 시에는 사진의 EXIF에서 위치 정보를 제거합니다. 추정에는 사용하지만, 보유하지는 않습니다. 개인정보 보호 정책과의 일관성을 유지하기 위한 처리입니다.
Apple Intelligence: 온디바이스(On-device)로 SNS 게시글 생성
iOS 26 이후부터 이용 가능한 FoundationModels 프레임워크를 사용하여, SystemLanguageModel.default로 산행 데이터로부터 SNS 게시글을 생성하고 있습니다.
서버리스(serverless) 방침에 있어, 온디바이스 LLM(Large Language Model)은 이상적인 선택지였습니다. 외부의 AI API를 사용하면 키(key) 보안을 위해 프록시(proxy)가 필요하며, 사용자의 산행 데이터를 외부로 전송하게 됩니다. 온디바이스라면 이 두 가지 문제가 모두 발생하지 않습니다.
구현 시 주의점은 두 가지가 있습니다.
폴백(fallback)이 필수적입니다. 미지원 단말기에서는 작동하지 않습니다. 템플릿 기반의 생성 로직을 별도로 마련하여, AI 미지원 환경에서도 게시글이 나올 수 있도록 했습니다. "새로운 API를 사용하는 것"과 "모든 사용자가 사용할 수 있는 것"은 별개의 문제입니다.
생성 결과에 의존하는 요소를 섞지 마십시오. 게시글 끝에 앱 링크를 붙이고 싶었지만, 이를 프롬프트(prompt)에 포함하면 재생성할 때마다 사라지거나 변형되곤 합니다. 푸터(footer)는 코드 측에서 결정론적(deterministic)으로 결합하도록 설계했습니다.
func finalShareText(body: String, includeLink: Bool) -> String {
guard includeLink else { return body }
return body + AppLink.url(.sharecard)
...
}
LLM의 출력은 "변해도 괜찮은 부분"으로 한정하고, 확실성이 필요한 부분은 코드로 담보합니다. 당연한 이야기지만, 구현하다 보면 이 둘이 섞이기 쉽습니다.
음성 메모: Speech Framework의 온디바이스 인식
등산 중에 깨달은 점을 목소리로 기록하고, SFSpeechRecognizer
(ja-JP)로 텍스트를 변환(Transcription)하여 기록의 메모란에 추가합니다. 여기서도 온디바이스 인식(On-device recognition)을 우선 설정으로 사용하고 있습니다. 서버 전송을 피하려는 방침과, 통신 불능 지역(圏外)에서도 동작해야 할 필요성 모두를 고려했을 때 자연스러운 선택이었습니다.
백업: AppleArchive와 SHA-256
클라우드 동기화가 없기 때문에, 기기 변경 시의 데이터 이관은 사용자 자신의 조작이 됩니다. 이 부분이 약점이 될 수 있으므로, 백업 기능에는 공을 들였습니다.
기록과 사진을 묶어 AppleArchive (LZFSE 압축)로 아카이브하며, 파일 앱이나 AirDrop으로 다룰 수 있도록 구현했습니다. 복원 시에는 SHA-256으로 정합성을 검증하며, 병합(Merge)할지 덮어쓸지를 선택할 수 있습니다.
클라우드 동기화가 없는 대가를 로컬의 견고함으로 치른다. 서버리스(Serverless) 설계는 '만들지 않아도 된다'가 아니라 '비용이 다른 곳으로 옮겨간다'는 것을 의미합니다.
보안: 모든 외부 입력을 의심하라
서버가 없더라도 외부에서 들어오는 데이터는 존재합니다. GPX 파일, CSV 파일, URL Scheme, Overpass API의 응답 등이 그것입니다.
- GPX/XML 파싱 시 XXE (외부 엔티티 참조, External Entity Reference) 무효화
- CSV 임포트 시 인젝션(Injection) 대책 (앞부분의
=,+,-,@처리) - URL Scheme은 알려진 경로만 허용하고, 알 수 없는 것은 무시
- 파일 압축 해제 시 경로 탐색(Path Traversal) 방지
- 구매 상태는 Keychain에 저장
- 네트워크 요청에 타임아웃(Timeout) 설정
'서버가 없으니 보안은 상관없다'는 성립하지 않습니다. 공격 표면(Attack surface)이 바뀔 뿐입니다.
3개월간 해보니
기술적인 배움보다 먼저 뼈저리게 느낀 점이 있습니다.
기능을 계속 만들어도, 다운로드 수는 움직이지 않았습니다.
3개월 동안 25개 이상의 기능을 구현하고 업데이트를 반복했지만, 월간 다운로드 수는 30건 전후로 정체되어 있었습니다. 반면, 다운로드해 준 사람들의 결제율은 14%(시기별로 22%)로, 프리미엄(Freemium) 모델의 평균이라 불리는 2~5%를 크게 상회했습니다.
즉, 사용해 본 사람은 가치를 인정하고 있었다. 단지 사용되지 않았을 뿐이었습니다.
App Store의 퍼널(Funnel)을 분해했을 때, 인프레션(Impression) → 페이지 조회 → 다운로드 → 결제 중 망가져 있는 것은 최상단뿐이었고, 하단 단계는 모두 건전했습니다. 이 구조가 보이기 전까지, 저는 '기능이 부족한 것이겠지'라고 생각하며 구현을 계속하고 있었습니다.

망가져 있는 것은 최상단뿐이었다
만드는 것은 즐겁습니다. 발견되도록 노력하는 것은 수수하고 고통스럽습니다. 그래서 전자로 도망치게 됩니다. 개인 개발자가 가장 빠지기 쉬운 함정이라고 생각합니다.
기술 선택에 대해 이야기해 왔지만, 그것이 옳더라도 전달되지 않으면 의미가 없습니다. 이 3개월 동안 가장 크게 배운 점은 바로 그것이었습니다.
사업 측면의 회고(숫자의 상세 내용, ASO 개선 전후 비교 등)는 별도의 글로 작성했습니다.
질문이나 지적이 있다면 댓글로 남겨주세요.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기