Company Project@ 플럭시티2026.06 — 2026.09

VGOLF 앱

골프 라운드 기록 모바일 앱 (iOS · Android)

Role: Frontend Developer · React Native 앱 단독 개발

Overview

골프 라운드 기록을 관리하는 React Native 앱입니다. 화면 구현부터 네이티브 설정, 릴리스 서명, Play Console 제출까지 앱 전체를 맡아 개발했습니다. 555커밋 · 화면 21개 · 소스 16.5k LOC · 문서 55편 규모로, FSD 5레이어 아키텍처와 3겹 에러 안전망을 세우고, zod 2층 스키마 기반 실서버 계약 테스트로 백엔드 응답 계약을 프론트가 주도해 정리했습니다. Expo 없이 ios/ · android/ 프로젝트를 직접 소유하며 소셜 로그인 4종, 2겹 스플래시, 릴리스 서명·환경 분리까지 네이티브 영역을 직접 다뤘습니다.

이 프로젝트의 핵심

React Native 앱

555커밋, 화면 21개, 16.5k LOC, iOS·Android 동시 대응

FSD 5레이어 + 세그먼트 구조

슬라이스 공개 API 규칙으로 '기능 삭제 = 폴더 삭제'가 성립하는 코드베이스

zod 2층 스키마(런타임·엄격) 실서버 계약 테스트

서버 par: null로 모든 홀이 더블보기로 표시되던 무증상 오류를 사전 검출

API 응답 불일치 12건을 영향·우선순위(P0/P1) 표로 문서화해 백엔드에 전달

하위 호환을 지키는 형태로 제안

나머지 4개 더 보기

3겹 안전망(ErrorBoundary · 전역 핸들러 · QueryState) + axios 에러 6종 정규화

로딩·에러·빈 상태를 컴포넌트 3개로 수렴

Expo 없이 네이티브 직접 소유

소셜 로그인 4종, 2겹 스플래시, 적응형 아이콘, 릴리스 서명·cleartext 분리

MMKV 암호화 키를 Keychain/Keystore에 보관 + 토큰 재발급 single-flight

동시 401 중복 재발급·재시도 루프 제거

Play Console 제출 실무

데이터 보안 매핑, 공개 약관·개인정보처리방침·회원 탈퇴 페이지 제공

자세히

아키텍처 — 레이어를 정하고 지킨 구조3

FSD 5레이어(shared → entities → features → pages → app) 단방향 의존 + api/model/ui/lib 세그먼트 분리

슬라이스 밖에서는 index.ts로만 import

상태를 역할로 3분할

서버 데이터는 react-query, 사용자 선택값은 zustand + MMKV 영속, 폼은 react-hook-form + zod로 고정하고 겹치지 않게 규칙 문서화

버전을 '최신'이 아니라 '호환'으로 선택

MMKV v4(Nitro 네이티브 의존)·zod v4(ESM ↔ RN Babel 충돌)를 v3에 고정하고 올리지 않은 이유를 아키텍처 문서에 기록

안정성 — 사용자 폰에서 조용히 죽지 않게4

3겹 안전망

ErrorBoundary(렌더) + ErrorUtils.setGlobalHandler(렌더 밖, 기본 핸들러 체이닝) + QueryState(로딩·에러·빈 상태 render-prop 단일 분기)

모든 실패를 ApiError 하나로 정규화

axios 인터셉터에서 timeout/network/client/server/invalid/unknown 6종 분류 + 상태코드별 한국어 문구 테이블

조건부 재시도 정책

network·timeout·5xx만 1회 재시도, 4xx 제외(408·429는 예외), staleTime 60s, 스키마 불일치(invalid)는 재시도 대상에서 제외

Sentry 크래시 대응

짝 없는 서로게이트가 네이티브 UTF-8 직렬화에서 앱을 죽이는 문제를 전송 전 정제 단계로 차단

백엔드 협업 — API 계약을 먼저 정리해 제안5

두 층 스키마 계약 테스트

같은 응답을 런타임 스키마(앱이 깨지는가)와 엄격 스키마(서버가 문서대로 주는가)로 이중 파싱

실서버 호출 테스트는 자격증명 없으면 전부 skip + 기본 test/pre-commit에서 제외

서버 장애가 커밋을 막지 않게 설계

무증상 버그 검출 사례

서버 par: null을 앱이 0으로 보정해 모든 홀이 더블보기로 색칠되던 오류를 엄격 스키마로 검출, '0 보정은 임시 조치'까지 함께 전달

에러 응답 계약 요청서

인증 실패 코드 3분리 + retryAfterSeconds 추가 요청, 불일치 12건을 엔드포인트·필드·기대값·앱 영향·P0/P1 표로 정리

결정하지 않은 것도 결정으로 기록

동반자 수 계산 불일치는 추측 수정 대신 3안·비용·필요한 최소 정보를 문서화하고 계약 테스트에 비실패 [확인] 항목으로 관찰

디자이너 협업 — 시안을 시스템으로 받기5

타입 스케일 재정의

기준폭 375 → 실기기 393 차이(≈1.05)를 Tailwind 기본 램프 한 스텝 상향으로 흡수, 런타임 폭 스케일은 실익이 작아 채택하지 않음

디자이너가 고칠 파일을 3개로 축소

모든 화면을 QueryState로 감싸고 실제 모양은 LoadingState·ErrorState·EmptyState에만 두어 교체 지점을 합의

색 토큰화

임의 hex(bg-[#6600FF]) 제거, 단일 팔레트에서 Tailwind 설정과 네이티브 prop용 JS 색상값이 함께 파생되도록 통합 (디자인 커밋 65건)

역으로 스펙 제공

iOS 투명 배경 거부·Android 적응형 아이콘 세이프존 66% 등 플랫폼 규칙을 체크리스트로 정리해 재작업 없이 산출물 수령

iOS·Android 폰트 정합

fontWeight를 패밀리명으로 매핑하는 함수를 공용 Text·TextInput이 공유, includeFontPadding: false로 세로 간격까지 일치

인터랙션 — 손맛을 토큰으로 관리5

공통 모션 토큰

스프링 2종·타이밍 3종으로 어휘를 고정하고 모든 애니메이션이 motion.ts를 참조

PressableScale

눌림 축소를 Reanimated worklet으로 UI 스레드에서 실행해 JS가 바빠도 끊기지 않음

Android 리플 제거 판단

축소 애니메이션과 충돌해 양 플랫폼을 opacity 0.6 + 햅틱으로 통일, bottom-tabs PlatformPressable 기본 리플까지 tabBarButton 직접 구현으로 차단

햅틱 적용 기준 표

화면 전환 CTA·되돌릴 수 없는 확정은 light, 값 선택은 selection, 목록 카드는 없음. 네이티브 모듈 부재 기기에서는 조용히 무시

접근성

useReducedMotion()으로 눌림 축소 생략, 공용 Touchable accessibilityRole·TextField 라벨 연결을 기본값화

네이티브 — Expo 없이 ios/ · android/ 직접 소유4

bare RN 선택 근거를 작업량으로 설명

소셜 SDK 3종의 URL 스킴·Info.plist 예외·인텐트 필터·modular_headers·SHA-1 등록, 다이얼로그 테마·cleartext 예외·스플래시 조정이 상시 필요

2겹 스플래시

네이티브 런치 화면과 JS 스플래시 배경을 동일 색으로 맞춰 흰 화면 깜빡임 제거, values-v31/styles.xml·스토리보드로 라이브러리 없이 처리, 부팅 대기 구간에 홈 데이터 prefetch

키보드 대응

automaticallyAdjustKeyboardInsets/adjustResize로 부족한 부분을 measureInWindow 기반 겹침 계산 래퍼로 해결, 계산부는 순수 함수로 분리해 테스트

소셜 로그인 4종

카카오·네이버·구글은 email 기준, 애플만 socialId 기준으로 분기(최초 1회 정보 제공·릴레이 주소·Android 네이티브 SDK 부재)를 통합 훅 내부에서 처리

도메인 — 골프 스코어의 규칙에서 나온 문제5

리스트 키 충돌

자동연동(bookingId)과 직접입력(roundedNo)이 별개 시퀀스라 값이 겹치는 문제를 booking-5 / rounded-5 이름공간 분리로 해결

서버 total을 믿지 않는 무한 스크롤

다음 페이지 판단을 순수 함수로 분리하고 빈 페이지 수신 시 중단, 화면은 합쳐진 배열만 수신

입력 속도를 위한 도메인 기본값

파 72 프리셋 + 수정 방식, 직접입력 칸이 열린 '이유'에 따라 키보드 노출 여부 분기

스코어카드 캡처 공유

view-shot 캡처 직후 상태 즉시 해제로 멈춤 해결, 저장 URI에 file:// 스킴을 보장해 공유·저장 경로 통일

오프라인 대응

NetInfo를 react-query onlineManager·focusManager에 연결, isInternetReachable로 '연결됐지만 인터넷 없음'을 배제, 배너는 절대위치로 레이아웃 미영향

보안 · 세션 · 릴리스5

MMKV 암호화 키를 OS 보안 저장소에

256비트 키를 crypto.getRandomValues로 생성해 Keychain/Keystore에 보관, 부팅 시퀀스(키 조회 → 저장소 오픈 → 토큰·persist 복원) 고정

토큰 재발급 single-flight

공유 프로미스로 1회만 수행하고 재발급 호출은 인터셉터 없는 별도 axios로 보내 401 재시도 루프 차단

빌드 타입 분리

키스토어 자격을 레포 밖 gradle.properties에서 로드(없으면 debug 폴백), manifestPlaceholders로 cleartext를 debug=true/release=false 분기, version:set|build로 iOS·Android 버전 동시 상향

Play Console 제출

수집 항목을 Play 데이터 타입에 매핑(Sentry는 위탁 처리자), 약관·개인정보처리방침·회원 탈퇴를 로그인 없이 접근 가능한 공개 페이지로 제공

문서 55편

setup/app/features/api/release/troubleshooting으로 나눠 대안·비용·되돌린 이력까지 남겨 사실상 ADR 역할, pre-commit은 타입체크·린트만 실행

백오피스 — 앱 옆에 같이 만든 관리자 웹4

스토어 심사에 필요한 공개 페이지(개인정보처리방침·약관·회원 탈퇴 안내)를 인증 밖 경로에 두고, 관리자 화면은 로그인 뒤에 두었다

앱 제출에 쓴 공개 URL이 여기서 나온다

Vite + React 19 + TypeScript + Tailwind v4

커밋 32건, 66파일 3,845줄

앱에서 쓰던 방식을 그대로 옮김

queryClient·ApiError 정규화 패턴 이식, shared/ 레이어 미러링, react-query·zustand·react-hook-form+zod로 앱과 같은 컨벤션

화면은 로그인 · 대시보드 · 회원 관리 · 약관 · 회원 탈퇴. 탈퇴 화면은 심사자와 실제 이용자가 함께 보는 창구라, 목적(계정·데이터 삭제) 안내를 먼저 읽히도록 다시 짰다

되돌린 결정 — 붙였다가 뺀 것들4

PressableScale 1차 도입

cssInterop과 애니메이션 스타일이 같은 자리를 두고 충돌해 하단 CTA·FAB·카드가 사라짐. 공용 Text의 동작 방식과 비교해 원인을 규명하고 같은 방식으로 고쳐 재도입

탭 전환 페이드

첫 탭을 씹고 빈 화면이 노출되어 하단 탭은 즉시 전환으로 되돌리고 스택 전환에만 모션 유지, '레이아웃을 흔들지 않는다 · 입력 중에는 넣지 않는다'를 모션 원칙으로 문서화

인증 카운트다운 클라이언트 계산

쿨다운을 서버가 번호 기준으로 관리하므로 제거하고 판단을 서버로 이관, 그 전제에 맞는 응답 필드를 백엔드에 요청 (제거와 요청서가 한 세트)

목록 항목별 진입 애니메이션

카드가 겹쳐 보여 제거하고 첫 로딩 스태거만 유지, 로딩은 스켈레톤으로 대체

Tech Stack

React NativeTypeScriptZustandReact QueryZodReact Hook FormNativeWindReanimatedMMKVAxiosSentryJestiOSAndroidFigmaStorybook