← Writing

Capacitor를 걷어내고 React Native로 — 모바일을 다시 짠 이유

Plan Do!의 iOS는 원래 Capacitor로 웹을 감싼 앱이었어요. 출시 회고에서도, Capacitor 글에서도 혼자 만드는 사람에겐 한 코드베이스가 맞다고 적었습니다. 그런데 몇 달 뒤에 그 결정을 뒤집고, 모바일만 React Native로 다시 짰어요. 이 글은 왜 뒤집었는지, 그리고 리라이트라는 무거운 일을 어떻게 감당 가능하게 만들었는지에 대한 기록입니다.

먼저 — 그때 Capacitor가 틀렸던 건 아니에요

리라이트를 했다고 해서 예전 결정이 실수였다고는 생각하지 않아요. 그 시점엔 앱을 일단 iOS에 올리는 것 자체가 목표였고, 1인 개발에서 코드베이스가 둘로 갈라지는 건 감당하기 어려운 비용이었습니다. Capacitor로 웹을 감싸서 몇 주 만에 App Store에 올린 건 그 단계에선 분명히 맞는 선택이었어요.

바뀐 건 조건이었던 것 같아요.

  • 앱을 실제로 매일 쓰다 보니, 웹뷰 특유의 스크롤·제스처의 미묘한 버벅임이 계속 눈에 밟혔어요.
  • 캘린더 타임라인이나 DM처럼 손끝 감이 중요한 화면이 늘어나면서, “웹인 게 티 난다”는 느낌이 커졌습니다.
  • iOS 26의 Liquid Glass 같은 네이티브 표면을 제대로 쓰고 싶어졌고요.
  • Android까지 넓힐 생각을 하니, 결국 네이티브 런타임 위에서 굴리는 게 낫겠다 싶었어요.

정리하면 유지비 하나만 보던 저울에, 사용 경험과 확장성이 올라온 거예요. 그러니 예전엔 Capacitor로 기울던 저울이 이번엔 반대로 기울었습니다.

리라이트에서 제일 무서운 건 “다 새로 짠다”는 말

모바일을 처음부터 다시 짠다는 건, 잘못하면 이미 되던 기능을 전부 다시 만드는 일이에요. 그게 리라이트가 대부분 실패하는 이유라고 생각했어요. 그래서 시작할 때 “어떻게 하면 새로 짜는 범위를 최소로 줄일까”를 먼저 정했습니다. 세 가지로 갔어요.

1. 백엔드는 한 줄도 건드리지 않기

Plan Do!는 다행히 프론트와 백엔드가 깔끔하게 나뉘어 있었어요. Spring Boot 서버, DB, 인증, WebSocket, AI 호출 — 이건 웹이든 네이티브든 똑같은 API를 부르는 클라이언트일 뿐이니까요. 그래서 이번 작업은 “백엔드는 그대로 두고 모바일 클라이언트만 교체한다”로 못 박았습니다.

OAuth도 기존 백엔드의 plando:// 딥링크 흐름을 그대로 재사용했어요. 서버가 이미 하던 걸 네이티브에서 다시 만들 이유가 없었습니다.

2. API 타입을 손으로 안 쓰기 — OpenAPI에서 자동생성

리라이트에서 제일 실수가 나기 쉬운 데가 API 계약이에요. 필드 이름 하나, nullable 하나 잘못 적으면 런타임에서야 터지니까요. 그래서 백엔드의 OpenAPI 스펙에서 TypeScript 타입을 자동생성하게 만들고, API 타입은 절대 손으로 안 쓴다는 규칙을 세웠어요.

npm run gen:spec   # 로컬 백엔드의 /v3/api-docs → openapi.json
npm run gen:api    # openapi.json → src/api/generated/schema.ts

덕분에 화면을 새로 짜면서도 데이터 모양에 대한 불안이 거의 없었어요. 스펙이 바뀌면 타입이 바뀌고, 타입이 바뀌면 tsc가 어긋난 곳을 짚어주니까요. (springdoc은 배포 브랜치엔 안 올리고 스펙 뽑는 브랜치에서만 켜뒀는데, 이건 취향이에요.)

3. 화면 구조(IA)를 새로 발명하지 않기

네이티브로 넘어오면 “이참에 구조도 다시 그려볼까” 하는 유혹이 생기는데, 그걸 의식적으로 참았어요. 바텀 탭, 더보기 허브, 화면 위계는 이미 웹에서 검증된 걸 그대로 미러했습니다. 새 화면을 만들 때마다 “웹에선 이게 어디 있었지?”를 먼저 확인하는 식으로요.

그리고 기존 앱과 RN 앱을 기능 단위로 1:1 대조하는 문서를 하나 만들어서, ✅ 동등 / ⚠️ 얕음 / ❌ 없음으로 갭을 표시하고 그걸 우선순위대로 메워나갔어요. “어디까지 왔는지”가 문서 하나에 보이니까, 혼자 하는 리라이트인데도 길을 잃지 않았습니다.

스택 — 익숙함과 네이티브 감 사이

  • Expo (SDK 56) + React Native, New Architecture + Hermes, expo-router — prebuild 워크플로로 네이티브 프로젝트를 코드에서 관리하고, 라우팅은 파일 기반으로.
  • 서버 상태는 TanStack Query, 클라 상태는 Zustand — 둘의 역할을 섞지 않는 게 핵심이었어요. 서버에서 온 건 Query가 캐싱·무효화까지 책임지고, 순수 UI 상태만 Zustand로.
  • UI는 iOS Liquid Glass — 바텀 탭은 진짜 UITabBar를 쓰는 네이티브 탭으로, 글래스 표면은 공용 GlassButton 같은 primitive로 묶었어요. 화면마다 픽셀을 만지지 않고 공통 primitive를 먼저 세운 다음, 기능을 끝내고, 디자인은 마지막에 한 번에 다듬는 순서로 갔습니다.
  • 인증 토큰은 expo-secure-store — JWT를 안전한 저장소에.

가장 크게 물린 곳 — 카카오맵

리라이트 내내 제일 오래 붙잡은 건 지도였어요. 카카오 네이티브 지도 SDK를 붙였는데, RN 0.85의 bridgeless New Architecture에서 지도 엔진 델리게이트가 아예 안 불려서 화면이 그냥 비어버렸어요. SDK 버전을 낮춰봐도 똑같았고요.

한참 헤매다가, 지도 탭 하나만 WebView로 격리하는 쪽으로 풀었어요. react-native-webview 안에서 카카오 JS SDK를 띄우고, WebView의 baseUrl을 등록해둔 도메인(https://plando.imjaewoo.dev)으로 맞춰서 JS 키의 도메인 인증을 통과시켰습니다.

여기엔 좀 웃긴 아이러니가 있어요. 웹뷰를 걷어내려고 시작한 리라이트인데, 지도는 결국 웹뷰로 남았다는 거죠. 그런데 이게 나쁜 타협은 아니라고 봤어요. 지도는 원래 카카오가 웹으로 가장 잘 그려주는 영역이고, 그 한 탭만 웹뷰로 두면 나머지 앱의 네이티브 감은 그대로 지키면서 지도 품질도 챙길 수 있으니까요. “전부 네이티브”가 목표가 아니라 *“화면마다 제일 나은 렌더링”*이 목표였다고 다시 정리하게 됐어요.

릴리즈 — 스크립트로 밀어붙인 파이프라인

빌드·심사·출시도 만만치 않았어요. 클라우드 빌드 크레딧이 진작 소진돼서 로컬 EAS 빌드로 IPA를 뽑고, 업로드는 EAS submit이 큐에서 자꾸 막혀서 altool로 직접 올리고, 심사 제출은 App Store Connect API를 스크립트로 호출하는 식으로 굳혔습니다.

한 번에 매끄럽게 된 게 아니라, 실패할 때마다 원인을 적어둔 플레이북이 쌓였어요. “빌드 번호는 실패한 시도도 소비하니까 다음 번호를 가정하지 말 것” 같은, 겪어봐야 아는 함정들이요. 혼자 하는 출시일수록 이런 걸 문서로 남겨두는 게 미래의 나를 위한 동료가 되더라고요.

아직 남은 것 — 컷오버는 진행 중

솔직하게, 다 끝난 건 아니에요.

  • 기능 패리티는 거의 맞췄지만, 지오펜스(위치 알림)와 푸시는 네이티브 모듈을 더 배선해야 하는 별도 트랙으로 남겨뒀어요.
  • 팀 모집·주간 회고처럼 자주 안 쓰는 화면은 의도적으로 WebView로 두기로 했고요. 다 네이티브로 옮기는 게 항상 이득은 아니니까요.
  • 전략은 RN으로 패리티 달성 → 사용자 컷오버 → 그 다음에 웹앱에서 모바일 분기(isMobile)를 걷어내기예요. 웹앱을 먼저 부수지 않는 게 안전하다고 봤어요.

돌아보면

  • 리라이트를 감당 가능하게 만든 건 결국 “안 새로 짠 것들”이었어요. 백엔드를 안 건드리고, 타입을 자동생성하고, 화면 구조를 그대로 미러한 세 가지가 없었으면 지금도 헤매고 있었을 것 같아요.
  • 결정은 조건에 매여 있는 것 같아요. Capacitor도, React Native도 각자 맞는 단계가 있었어요. “어느 게 정답이냐”보다 *“지금 저울에 뭐가 올라와 있냐”*를 보는 게 맞다고 다시 느꼈습니다.
  • 타협을 정직하게 두는 것도 설계라고 생각했어요. 지도를 웹뷰로 남긴 건 실패가 아니라, 화면마다 제일 나은 걸 고른 결과였어요. 순수함보다 결과가 중요하니까요.

직접 써보고 싶으시면 plando.imjaewoo.dev, iOS는 App Store에서 받으실 수 있어요.