이번캠 운영기: 사용자 피드백에서 커뮤니티 분리와 이동 성능 개선까지
출시는 끝이 아니라, 내가 만든 화면을 다른 사람이 자기 방식으로 사용하기 시작하는 시점이었다.
이번캠은 캠핑장을 찾고, 후보를 플랜에 담고, 다녀온 뒤 사진과 방문 기록을 남기는 개인 서비스다. 기존 이번캠 개발기 5편에서는 앱 출시까지의 과정을 다뤘다. 이번 글에서는 출시 뒤의 개선을 문제, 원인 확인, 판단, 구현, 검증과 한계 순서로 돌아본다.
구현 기반은 Next.js App Router, React, TypeScript와 Supabase다. iOS·Android 앱은 Capacitor 앱 셸에서 운영 웹을 로드하는 구조다. React Native로 화면 전체를 다시 만들거나, 모든 UI를 네이티브로 구현한 앱은 아니다.
이 글은 f3e560e, c4c868e, 4c1eff7과 관련 코드, docs/navigation-performance.md를 대조해 작성했다. 코드로 확인한 동작과 측정 기록을 구분하고, 사용자 수나 매출, 대규모 트래픽 대응 성과는 주장하지 않는다.
1. 피드백을 화면 한 곳의 문제로만 보지 않기
로그인이 유지되지 않거나, 좋아요·댓글의 반응이 늦거나, 업로드가 중간에 끊기면 사용자는 서비스를 믿고 기록을 맡기기 어렵다. 개선의 기준도 버튼이 작동하는지에서 사용자가 시작한 일을 끝내고 돌아올 수 있는지로 옮겨갔다.
로그인 복귀와 세션 유지는 서로 다른 문제였다
OAuth 로그인 화면에서 앱으로 돌아오는 과정과 앱을 종료했다 다시 열었을 때 세션이 남는 과정은 따로 확인해야 했다. NativeAuthBridge는 딥링크의 인증 코드를 세션으로 교환하고, 앱 실행·복귀 시 세션을 다시 확인한다. capacitor.config.ts에서는 Supabase SSR의 쿠키를 네이티브 쿠키 저장소에서 다루도록 CapacitorCookies를 활성화했다.
반대로 앱이 활성화될 때마다 무조건 router.refresh()를 호출하면 같은 계정으로 돌아왔는데도 현재 화면을 다시 읽게 된다. 코드에서는 이전 사용자와 현재 사용자가 달라졌을 때 서버 렌더링 인증 UI를 갱신하도록 범위를 좁혔다. 세션 확인을 없애는 대신 갱신이 필요한 경우와 그대로 유지할 경우를 나눈 것이다.
반응은 먼저 보여 주되, 서버 결과와 다시 맞췄다
좋아요 버튼은 사용자가 원하는 상태와 서버가 확인한 상태를 따로 관리한다. 눌렀다는 반응은 먼저 보여 주고, 저장은 한 흐름으로 순서대로 처리한다. 저장에 실패하면 마지막으로 확인된 상태로 돌리고 오류를 안내한다. 빠른 연속 입력을 단순히 막는 것과, 최종 의도를 서버 결과에 맞추는 것은 다른 문제였다.
댓글은 목록 조회와 등록 중 상태를 구분하고, 임시 댓글을 먼저 표시한 뒤 서버가 반환한 댓글로 교체한다. 실패하면 임시 항목을 제거한다. 54ea902에서 댓글 로딩·입력 반응을 개선한 변경을 확인할 수 있다. 다만 이 구현만으로 모든 네트워크 상황의 반응 시간이 검증됐다고 말할 수는 없다.
업로드 실패를 처음부터 다시 시작해야 하는 일로 만들지 않았다
사진은 표시용 이미지와 썸네일을 만들고 Storage로 올리는 과정이 나뉜다. 이미지 변환에 실패하면 원본을 사용하는 대체 경로가 있다. 영상은 영상 파일과 포스터 이미지를 구분한다. 작성 화면은 처리 단계를 표시하고, 이미 업로드가 끝난 미디어는 재시도에서 재사용한다.
uploadPostMedia의 previouslyUploaded와 작성 폼의 임시 저장·복구 상태를 함께 읽으며, 업로드 문제는 전송 성공 여부만이 아니라 작성한 내용과 완료된 작업을 얼마나 보존할 것인가의 문제라고 정리했다. 7734bf7에는 iOS 영상 촬영 충돌 대응과 관련 권한·선택 흐름의 변경도 남아 있다.
원본 대체 업로드는 큰 파일 전송 비용을 없애지 않는다. 기기에서 사라진 원본 파일을 항상 복구할 수도 없다. 특정 iPhone의 촬영·권한 동작과 모든 영상 형식의 정상 업로드는 별도의 실기기 확인이 필요하다.
2. 질문을 방문 기록으로 저장하지 않기
사진 피드는 다녀온 캠핑을 보여 주기에 적합하지만, 방문 전 질문이나 짧은 정보·이야기를 쓰기에는 목적이 달랐다. 질문을 기존 방문 기록에 넣으면 아직 가지 않은 장소가 방문 지도나 통계에 섞일 수 있다.
그래서 커뮤니티를 피드의 표시 방식만 바꾸는 기능으로 만들지 않고, community_posts와 community_comments로 분리했다. c4c868e에서 질문·정보·이야기 분류, 글 작성·수정·댓글, 캠핑장 연결과 운영 기능을 추가했다.
| 내용 | 저장과 노출의 기준 |
|---|---|
| 방문 기록·사진 후기 | 방문 경험을 기록하고 기존 지도·통계 흐름에 사용 |
| 캠핑 플랜 | 아직 가지 않은 후보 장소를 모아 비교 |
| 커뮤니티 글 | 질문·정보·이야기를 저장하되 방문 기록·지도·통계와 분리 |
커뮤니티 글에는 캠핑장을 선택적으로 연결할 수 있다. 글에서 장소로 이동할 수 있고, 캠핑장 상세에서도 해당 장소에 연결된 커뮤니티 글을 조회한다. 장소를 공유하되, 글의 의미까지 방문 기록과 같게 만들지는 않았다.

위 이미지는 사용자 닉네임이 노출되지 않는 기존 스토어 제출용 탐색 화면이다. 장소를 찾는 흐름을 보여 주는 자료이며, 이후 추가한 커뮤니티 영역이 표시된 최신 캡처는 아니다. 화면의 사진과 건수도 현재 운영 데이터를 뜻하지 않는다.
저장 액션은 로그인한 작성자를 확인하고 이미지 경로의 소유권과 연결할 캠핑장의 존재를 검증한다. 데이터베이스에서도 RLS와 컬럼별 권한을 사용하며, 숨김·차단 조건이 목록과 상세 조회에 적용된다. 방문 기록과 분리되는지, 권한과 커서가 동작하는지는 자동 테스트와 SQL 정책 테스트 코드로 확인한다. SQL 테스트 파일이 있다는 사실과 이번 작업에서 운영 DB의 모든 정책 테스트를 실행했다는 사실은 같지 않다.
3. 느린 페이지보다 공통으로 기다리는 일을 찾기
여러 화면의 이동이 느리다면 각 화면의 UI만 고치는 것으로 끝나지 않는다. 페이지가 공통으로 거치는 인증, 데이터 조회와 실행 위치를 먼저 봤다.
함수와 DB 사이의 거리를 줄였다
10월 7일 점검 기록에서 Supabase DB는 서울에 있었지만, 운영 동적 응답의 Vercel 경로는 icn1::iad1로 표시됐다. 국내로 들어온 요청이 미국 함수에서 처리되고 DB는 서울에 있는 구성이었다. f3e560e에서 함수 실행 지역을 다음처럼 지정했고, 배포 후 기록에는 icn1::icn1이 확인됐다.
{
"regions": ["icn1"]
}
리전 변경 하나의 효과를 다른 변경과 분리해 측정하지는 않았다. 따라서 뒤의 수치를 전부 리전 변경의 단독 효과라고 설명하지 않는다.
공통 인증과 중복 조회를 정리했다
프록시에서는 공개 키로 검증되는 프로젝트의 토큰을 getClaims()로 확인한다. 보호 경로의 로그인 이동 판단을 위한 확인과, 페이지·액션에서 실제 사용자를 확인하는 작업을 구분했다. 페이지의 getCurrentUser()와 기존 소유권·RLS 확인은 유지했다. Supabase의 getClaims() 설명에서도 서명 키 구성에 따른 검증 방식의 차이를 확인할 수 있다.
캠핑장 상세에서는 메타데이터와 화면이 장소 정보·후기를 각각 읽던 부분을 같은 메모이제이션 함수로 묶었다.
const getCampForRender = cache(fetchCampingDetail);
const getReviewsForRender = cache(getCampReviews);
이것은 개인 데이터를 모든 사용자에게 재사용하는 전역 캐시가 아니다. 동일한 서버 렌더링 요청 안에서 중복 작업을 줄이는 선택이다. React cache의 요청별 범위를 기준으로 인증 조회도 공유하고, 기존 소유권 확인을 캐싱 때문에 생략하지 않았다.
알림 읽음 저장을 이동보다 먼저 기다리지 않았다
알림을 누르는 목적은 해당 내용을 보는 것이다. 읽음 저장을 먼저 기다리면 부수 작업이 페이지 이동을 막을 수 있다. 목록에서는 읽음 상태를 먼저 반영하고, 목적지 화면을 그린 뒤 읽음 처리를 이어 가도록 순서를 나눴다.
돌아왔을 때 목록 전체를 버리지 않도록 같은 계정의 최근 알림 스냅샷을 유지한다. 일반 캐시와 최대 2분의 스냅샷을 구분하고, 인증 오류에는 이전 스냅샷을 지운다. 요청에는 10초 제한과 재시도 경로가 있다. 영구 캐싱으로 서버 상태를 숨기는 대신, 사용자가 방금 본 목록을 유지하면서 다시 맞추는 방식이다.
수치는 익명 HTTP 응답 측정으로만 해석했다
다음 표는 docs/navigation-performance.md에 남긴 2026년 10월 7일 운영 환경의 전후 기록이다. 이번 글을 작성하면서 같은 실험을 새로 측정한 표가 아니다.
- 로그인하지 않은 HTTP 요청을 경로별로 각각 3번 실행한 중앙값이다.
- 스트리밍된 내용을 포함해 HTML 응답 전체를 받는 데 걸린 시간을 측정했다.
- 이미지, 지도 SDK, hydration과 브라우저 렌더링은 포함하지 않는다.
- 콜드 샘플을 포함하며 결과는 변동할 수 있다. 표본이 적고 리전·조회·인증 개선이 함께 배포됐다.
| 경로 또는 흐름 | 변경 전 (ms) | 변경 후 (ms) |
|---|---|---|
/feed | 734 | 442 |
/notifications | 330 | 167 |
/collections/find | 353 | 165 |
/collections | 1604 | 296 |
/records 비로그인 접근 | 527 | 147 |
/profile에서 /login으로 이동하는 비로그인 접근 | 329 | 296 |
| 동일한 캠핑장 상세 URL | 1172 | 250 |
| 동일한 공개 캠퍼 카드 URL | 1164 | 220 |
이 결과는 실제 iOS·Android 앱의 탭 전환 속도나 로그인한 사용자의 체감 시간, p95가 아니다. 앞으로 그 값을 말하려면 인증된 상태, 기기·네트워크, 시작·종료 기준과 표본 수를 정한 별도 측정이 필요하다.
4. 커뮤니티 분류 전환은 목록만 바꾸기
커뮤니티의 질문·정보·이야기 탭은 같은 화면에서 목록만 다르게 보여 준다. 그런데 분류마다 전체 서버 페이지를 다시 요청하면 동일한 화면 구조와 초기 조회 비용을 반복한다.
4c1eff7에서는 첫 진입을 서버 렌더링으로 유지하면서, 분류 전환은 /api/community로 목록만 읽도록 바꿨다. URL은 window.history.pushState로 갱신해 선택한 분류와 뒤로가기 기록을 유지한다. Next.js의 History API 연동을 사용하는 부분이다. 검색 조건이나 캠핑장 범위가 바뀌는 흐름까지 모두 같은 탭 전환으로 취급하지는 않는다.
캐시는 화면 인스턴스별로 만들고 분류·검색어·캠핑장·커서를 키에 포함한다. 결과는 30초 동안 재사용하고 항목 수는 8개로 제한한다. 같은 요청이 진행 중이면 그 Promise를 공유한다. 실패한 응답은 성공 캐시에 넣지 않고, 12초 제한 뒤 재시도할 수 있게 한다. API는 private, no-store로 응답해 사용자별 차단 결과를 공용 캐시에 두지 않는다.
탭에 손을 대거나 포커스를 옮길 때 미리 읽고, 첫 화면이 로드된 뒤 다른 분류도 순차적으로 준비한다. 데이터 절약 모드나 느린 연결에서는 후자의 미리 읽기를 건너뛴다. 모든 경로를 한꺼번에 다운로드하는 방식은 아니다.
늦게 온 응답이 현재 탭을 덮지 않게 했다
질문 탭을 누른 직후 정보 탭으로 이동하면, 질문 응답이 더 늦게 도착할 수 있다. 단일 posts 상태에 도착한 응답을 그대로 넣으면 현재 선택한 분류와 화면 내용이 어긋난다.
구현에서는 분류별로 결과를 보관하고, 현재 분류의 결과만 렌더링한다. 검색 조건이나 초기 데이터가 바뀌면 세대를 올려 이전 요청의 결과가 새 화면에 반영되지 않도록 한다. 캐시와 화면 상태 양쪽에서 이 경계를 확인한다.
const requestedGeneration = generation.current;
const page = await client.load(queryFor(scope));
if (generation.current === requestedGeneration) {
updateView(scope, { ...page, fetchedAt: Date.now() });
}
community-navigation.test.mjs는 빠른 분류 전환, 중복 요청, 캐시 만료, 늦은 응답과 초기화 뒤 캐시 오염을 다룬다. 이것은 순서가 뒤집혀도 올바른 목록을 보여 주는지 확인하는 테스트이며, 실기기 전환 시간을 수치로 측정한 테스트는 아니다. 30초 캐시 안에서는 새 글이나 변경 내용의 반영이 지연될 수 있다는 한계도 남는다.
5. 웹 배포와 앱 배포의 경계를 운영하기
같은 웹을 로드해도 앱에서는 다른 실패 조건이 생긴다. 안전영역, OS 뒤로가기, 외부 인증 복귀와 푸시 알림이 그 경계였다.
커뮤니티 글쓰기 화면에는 고정 헤더의 pt-safe 영역 아래에 뒤로가기와 등록 버튼을 두고, 로딩 화면도 같은 공간을 확보했다. 코드와 자동 테스트로 구조는 확인했지만, 실제 상태바의 높이·색상·겹침이 모든 기기에서 정상이라고 검증한 것은 아니다.
Android 뒤로가기는 열린 대화상자와 화면 내 뒤로가기 처리, 루트 탭의 종료 확인, 웹 히스토리와 피드 복귀를 구분한다. 푸시 알림은 탭 이벤트 리스너를 등록 절차보다 먼저 연결하고, 대상 경로가 안전한 내부 경로인지 확인한 뒤 이동한다. 토큰 저장이나 읽음 저장을 기다리느라 이동을 막지 않도록 구성했다.
운영 웹을 로드하므로 웹 UI 수정은 웹 배포로 전달할 수 있다. 하지만 네이티브 플러그인, 권한 선언과 앱 셸 변경까지 웹 배포만으로 업데이트할 수는 없다. 이 경우에는 플랫폼 프로젝트 동기화, 새 빌드와 스토어 배포가 필요하다. 웹 응답을 받지 못할 때의 연결 오류 화면도 별도로 둔다. 현재 구조는 완전한 오프라인 앱이 아니다.
오류 관측과 사용자 콘텐츠 운영도 제품의 일부였다
자체 오류 수집은 오류 경계, 브라우저 오류와 처리되지 않은 Promise 오류를 받아 같은 오류의 반복 전송을 제한하고, 관리자 대시보드에서 확인·해결 상태를 관리한다. Sentry에는 인증·업로드·푸시의 작업 맥락과 예외를 연결하는 코드가 있다. 오류를 모으는 기능과 그 원인을 실제로 재현·해결하는 일은 구분해서 운영해야 한다.
심볼 처리도 범위가 다르다. 웹 소스맵 업로드는 Sentry 빌드 인증 설정이 있을 때 활성화되고, iOS dSYM은 별도 업로드 도구가 있다. 모든 릴리스의 웹 소스맵, iOS 네이티브 심볼, Android 난독화 매핑까지 운영 Sentry에서 정상 처리됐다고 확인한 것은 아니다. 이 글에는 실제 오류 로그, 인증 토큰, 푸시 토큰이나 내부 사용자 식별값을 싣지 않는다.
사용자 콘텐츠에는 신고·차단·관리자 숨김/복원과 조치 이력을 연결했다. 커뮤니티가 추가되면서 기존 신고 대상에도 커뮤니티 글·댓글을 연결했다. 다만 코드에 남아 있는 경고·계정 정지 조치는 아직 구현된 운영 기능으로 소개하지 않는다. 숨김 기능이 있다는 사실만으로 모든 유해 콘텐츠를 자동으로 처리한다고 말할 수도 없다.
6. 검증한 것과 남겨 둔 것
2026년 10월 12일, 4c1eff7 기준의 코드를 읽고 다음 명령으로 Node 자동 테스트 99개가 통과하는 것을 다시 확인했다.
node --experimental-strip-types --test tests/*.test.mjs
이 테스트에는 요청·캐시 로직을 가짜 응답으로 확인하는 테스트와 화면 코드의 구조를 확인하는 테스트가 포함된다. 운영 DB의 정책 검증, 실제 스토어 앱의 전체 동작 검사나 모든 실기기 검증을 대신하지 않는다.
10월 7일 기록의 브라우저 점검은 390×844와 840×900에서 피드·플랜·기록·캠핑장 상세·알림·비로그인 이동을 확인한 것이다. 당시 로그인한 앱 세션은 제공되지 않았고, 물리 기기에서 앱을 완전히 종료한 뒤 푸시를 눌러 시작하는 흐름은 시험하지 않았다.
남은 과제는 다음과 같다.
- 인증된 실제 앱 세션에서 로그인 유지, 알림 목록 복귀와 탭 전환을 반복 측정하기.
- iOS·Android 실기기에서 상태바·안전영역·키보드, 푸시 수신과 종료 상태에서의 이동을 점검하기.
- 릴리스별 소스맵·dSYM·Android 매핑의 처리 결과를 확인하기.
- 기록이 늘어날 때 전체 방문 이력을 읽는 화면에 페이지네이션이 필요한지 검토하기.
운영하면서 배운 것은 성능을 빠르게 보이게 만드는 것만이 아니다. 도메인 의미를 섞지 않고, 중복 작업을 줄이며, 비동기 응답과 배포 경계를 지키고, 확인한 범위만 설명하는 것이 다음 개선의 출발점이었다.
실제 기능 흐름과 웹·스토어 링크는 이번캠 프로젝트 소개에 함께 정리했다.
클릭할 때마다 작가에게 따뜻한 응원이 전달됩니다 ❤️
함께 읽으면 좋은 다른 개발일지
'프로젝트' 및 추천 아카이브 글 모음