
Granite
Granite는 국내 자연 볼더링 스팟을 탐색하고, 클라이밍 기록을 연결하기 위한 서비스다.
내 평소 취미는 클라이밍이고, 그중에서도 자연 볼더링을 특히 좋아한다. 같이 자연 볼더링 코스를 개척하던 팀원 중 한 명이 Granite에 대한 계획을 이야기해줬는데, 그 이야기가 참 좋았다. 단순히 클라이밍 앱을 하나 만드는 게 아니라, 새로운 장소를 찾아내고 그 이야기를 쌓아가는 하나의 브랜드를 만들어보려는 과정처럼 느껴졌다.
나도 예전부터 무언가를 개척하고, 그 과정을 담은 브랜드를 만들어보고 싶다는 생각이 있었다. 그래서 이 앱이 그 과정의 일부가 될 수 있다는 점이 마음에 들었고, 함께 참여해보고 싶어서 제안을 받아들였다. 그렇게 Granite 앱 개발을 시작했다.
서비스의 주요 화면과 이동은 웹앱이 소유하고, 모바일 앱은 Flutter 기반의 WebView shell로 웹 경험을 감싸는 구조를 선택했다. 모든 화면을 앱에서 다시 구현하기보다, 웹서비스를 중심에 두고 모바일 환경에 필요한 기능을 앱에서 연결하는 방식이다.
서비스는 granite.kr에서 확인할 수 있다.
소스코드와 프로젝트 저장소는 Granite GitHub 조직에서 확인할 수 있다.
목차
- 프로젝트 소개
- 웹서비스와 모바일 앱의 역할
- 서비스 구조
- 앱에서 연결한 기능
- 인증을 연결할 때의 경계
- 마치며
1. 프로젝트 소개
자연 볼더링은 같은 지역 안에서도 바위와 스팟, 섹터, 볼더, 루트 정보가 서로 연결되어 있다. Granite는 이 계층을 바탕으로 사용자가 지역과 크랙을 탐색하고, 세부 루트와 토포 정보를 확인할 수 있도록 설계한 서비스다.
단순한 목록을 보여주는 데서 끝나지 않도록 스팟의 위치와 접근 정보, 루트 정보, 베타 기록을 하나의 탐색 흐름 안에서 다루는 것을 목표로 했다. 콘텐츠를 관리하는 운영 화면과 공개 화면을 분리하고, 공개 데이터는 모바일 환경에서 먼저 확인하기 쉬운 형태로 구성했다.
2. 웹서비스와 모바일 앱의 역할
Granite 웹앱은 서비스의 화면과 이동을 소유한다. 사용자가 홈에서 지역을 선택하고, 크랙 상세 화면을 거쳐 볼더와 루트 정보를 확인하는 흐름은 웹앱에서 관리한다.
Flutter 앱은 웹서비스를 담는 얇은 shell 역할을 맡는다.
Flutter 앱
↓
WebView
↓
Granite 웹앱
├─ 화면과 URL 이동
├─ 스팟·루트·토포 탐색
└─ 로그인 이후의 웹 경험이렇게 역할을 나누면 웹서비스의 화면을 앱에서 중복 구현하지 않으면서도, 모바일 앱에서 필요한 생명주기 처리와 네이티브 기능을 별도로 붙일 수 있다.
3. 서비스 구조
웹서비스는 Next.js App Router와 Server Actions를 중심으로 구성했다. 공개 콘텐츠 조회와 관리자 기능을 분리하고, 데이터는 Cloudflare D1, 이미지는 Cloudflare R2와 CDN을 사용하는 구조다.
사용자
↓
Next.js 웹앱
├─ 공개 콘텐츠 탐색
├─ Server Actions
└─ OAuth·세션 경계
↓
Vercel Functions
├─ Cloudflare D1
└─ Cloudflare R2 / CDNInstagram 멘션을 활용한 베타 수집과 썸네일 재시도처럼 외부 callback이나 비동기 처리가 필요한 부분은 Cloudflare Workers의 역할로 분리했다. 일반적인 CRUD를 별도의 API로 늘리기보다, 각 기능의 실행 경계가 분명한 곳에만 보조 런타임을 두는 방향이다.
4. 앱에서 연결한 기능
모바일 앱은 WebView 하나만 띄우는 컨테이너가 아니라, 웹서비스와 운영체제 기능 사이의 연결을 담당한다.
현재 앱에서 확인되는 연결 기능은 다음과 같다.
- WebView 초기 로딩과 생명주기 관리
- 메인 프레임 네트워크 오류 처리와 다시 시도하기
- Android 뒤로 가기와 앱 종료 처리
- 외부 링크를 시스템 브라우저로 열기
- 공유 기능을 네이티브 공유 화면으로 연결하기
- 위치 정보를 선호하는 네이티브 지도 앱으로 열기
- 웹서비스와 Flutter 사이의 JavaScript bridge
웹에서 app, navigation, share, auth와 같이 기능별 메시지를 보내면 Flutter의 각 handler가 이를 처리한다. WebView 화면에 모든 분기 로직을 넣지 않고, 기능별 handler를 분리해 앱 shell의 책임을 관리하기 쉽게 했다.
5. 인증을 연결할 때의 경계
하이브리드 앱에서 인증은 특히 조심해야 하는 영역이다. Bridge를 token이나 cookie를 직접 공유하는 통로로 만들지 않고, 앱 세션과 웹 세션의 책임을 나누는 방향을 기준으로 삼았다.
- Flutter: 네이티브 provider SDK와 앱 세션 관리
- 웹서버: provider 검증과 HttpOnly 웹 세션 발급
- WebView: 로그인 이후 웹서비스 화면과 사용자 흐름
- Bridge: 인증 요청과 결과를 전달하는 제한된 메시지 통로
네이티브 로그인이 필요한 경우에도 access token을 WebView JavaScript에 그대로 주입하지 않는다. 앱에서 인증 결과를 서버가 검증할 수 있는 짧은 handoff 요청으로 바꾸고, 웹서버가 최종 웹 세션을 발급하는 경계를 둔다.
Kakao, Naver, Google, Apple은 callback과 플랫폼 설정이 서로 다르기 때문에 하나의 SDK 흐름으로 평탄화하기 어렵다. 그래서 provider별 native service를 분리하고, 앱에서는 공통 로그인 결과 형태로 정리한 뒤 세션 handoff로 연결한다.
6. 개발하면서 고려한 점
웹과 앱의 상태를 따로 관리하기
웹 URL과 WebView 상태가 어긋나면 사용자는 화면이 멈췄거나 이전 화면으로 돌아간 것처럼 느낄 수 있다. 앱은 웹의 URL 이동을 대신 소유하기보다 WebView의 생명주기와 네트워크 상태를 관리하고, 화면의 기준은 웹서비스에 남겨두었다.
오류를 무한 로딩으로 남기지 않기
첫 페이지를 불러오지 못했을 때 앱이 계속 로딩 중인 것처럼 보이지 않도록 메인 프레임 오류를 감지하고 다시 시도할 수 있는 화면을 제공한다. 네이티브 로그인 callback도 같은 관점에서 timeout과 취소를 구분해야 한다.
Bridge 메시지의 책임을 작게 유지하기
Bridge는 앱과 웹이 만나는 경계이기 때문에 메시지 형식과 namespace를 명확하게 유지해야 한다. 디버그 로그에는 최근 메시지만 남기고, token, cookie, authorization, 이메일 같은 민감한 값은 기록하지 않는 원칙을 적용했다.
마치며
현재 Granite의 기능은 서비스를 공개하기 위해 필요한 최소한의 범위로 구성되어 있다. 아직 완성된 서비스라기보다는, 자연 볼더링 정보를 쌓아갈 수 있는 기본 틀을 먼저 만든 단계에 가깝다.
앞으로는 다국어 처리를 추가하고, 실제로 루트를 개척한 뒤 그 루트를 직접 그려서 업로드할 수 있는 체계까지 만들어가고 싶다. 단순히 정보를 보여주는 앱을 넘어서, 사람들이 직접 개척하고 기록한 이야기가 다시 서비스 안 에 쌓이는 구조를 생각하고 있다.
Granite 앱을 만들면서 WebView 기반 앱은 웹 화면을 모바일에 복사하는 작업과는 다르다는 점을 확인했다. 웹서비스가 화면과 데이터를 소유하더라도 앱에서는 생명주기, 외부 이동, 공유, 지도, 인증, 오류 상태를 별도로 책임져야 한다.
이번 프로젝트에서는 기능을 무조건 native 화면으로 옮기기보다, 웹서비스와 앱 shell의 경계를 먼저 정했다. 웹에서 계속 관리할 기능과 모바일 운영체제에 맡길 기능을 구분하고, 둘 사이의 통신은 작은 Bridge 계약으로 제한하는 방식이다.
앞으로 Granite의 기능이 늘어나더라도 같은 원칙을 유지하려 한다. 콘텐츠와 사용자 흐름은 웹서비스에서 일관되게 관리하고, 앱은 모바일 환경에서 꼭 필요한 기능을 안전하게 연결하는 역할에 집중하는 것이다.