[OuttaRing] MVP 개발 일지: Expo 타이머 앱의 기반부터 복구 설계까지
OuttaRing MVP 개발기: Expo 타이머 앱의 기반부터 복구 설계까지
복싱 훈련 앱 OuttaRing의 첫 MVP를 구체화하고 구현했다. 최종 제품은 개인 라운드 타이머, 단계별 기술 훈련, 운동 기록, 실제 코치 프로그램까지 연결하는 것을 목표로 하지만, 첫 MVP에서는 완전 초보자가 혼자 기본기를 반복할 수 있는 경험에 집중했다.
이번 단계의 핵심은 기능을 많이 만드는 것이 아니었다. 사용자가 초보자 프로그램을 등록하고, 오늘의 코스 훈련 또는 자유 훈련을 선택하고, 라운드를 완주하고, 결과를 안전하게 기록하는 흐름을 끝까지 연결하는 것이었다.
기술 기반 결정
기존 Next.js 스타터를 React Native로 전환하지 않고 Expo SDK 57 기반 프로젝트를 새로 만들었다. 웹과 네이티브는 UI 계층이 다르기 때문에 기존 웹 스타터를 억지로 재사용하는 것보다 모바일 제품의 실행 환경에 맞춰 시작하는 편이 명확했다.
현재 주요 기술 구성은 다음과 같다.
- Expo SDK 57
- React Native 0.86
- React 19
- TypeScript
- Expo Router
- Expo SQLite
- Zod
- Jest와 React Native Testing Library
- pnpm
Expo 패키지는 SDK가 기대하는 patch 버전에 맞춰 정렬했고, Development Build를 위해 expo-dev-client도 포함했다. 최종적으로 Expo Doctor 21개 검사를 모두 통과했다.
MVP에서 구현한 사용자 흐름
처음 실행하면 앱의 목적과 안전 안내를 확인한다. 로그인이나 서버 연결은 없다. 모든 상태는 한 기기의 SQLite에 저장한다.
사용자는 정적 초보자 프로그램을 내 훈련으로 등록할 수 있다. 프로그램은 다음 순서로 구성했다.
- 스탠스와 가드
- 잽
- 스트레이트와 원투
- 기본 이동과 원투
홈에서는 완료하지 않은 다음 Workout을 보여준다. 코스 훈련을 완료하면 기록과 프로그램 진척도를 함께 저장하고, 다음 Workout을 계산한다.
자유 훈련에서는 라운드 수, 운동 시간, 휴식 시간, 준비 시간을 설정한다. 기본값은 3라운드, 운동 3분, 휴식 30초다. 소리와 진동도 개별적으로 켜고 끌 수 있다.
하나의 세션 엔진으로 코스와 자유 훈련 실행하기
코스 훈련과 자유 훈련은 입력 데이터는 다르지만 실행 방식은 같다. 둘 다 먼저 공통 SessionPlan으로 변환한 다음 동일한 reducer와 controller를 사용한다.
핵심 상태는 다음과 같이 분리했다.
type SessionLifecycle =
| 'idle'
| 'running'
| 'paused'
| 'completed'
| 'cancelled';
type SessionState = {
sessionId: string;
lifecycle: SessionLifecycle;
plan: SessionPlan;
segmentIndex: number;
segmentEndsAt: number | null;
pausedRemainingMs: number | null;
startedAt: string | null;
};타이머는 interval 호출 횟수를 정답으로 사용하지 않는다. 현재 구간의 절대 종료 시각과 현재 시각의 차이로 남은 시간을 계산한다.
remainingMs = Math.max(0, segmentEndsAt - nowMs);이 방식 덕분에 JavaScript timer가 조금 늦게 호출되거나 앱이 백그라운드에 있다가 돌아와도 실제 경과 시간에 맞는 구간으로 한 번에 이동할 수 있다.
일시정지할 때는 남은 시간을 고정하고, 재개할 때 현재 시각을 기준으로 새 종료 시각을 만든다. 백그라운드에서는 불필요한 interval 진행을 멈추고 active 상태로 돌아오는 순간 한 번만 보정한다.
준비 시간을 0초로 설정한 경우도 별도로 다뤘다. START 이벤트에서 0초 구간을 그대로 화면에 표시하면 첫 interval까지 약 1초 동안 준비 0:00이 보일 수 있다. 시작 직후 reducer를 한 번 진행해 첫 유효한 운동 구간으로 즉시 이동하도록 수정했다.
기록의 원자성과 Expo SQLite transaction
코스 완료는 두 데이터를 함께 바꾼다.
- 전체 훈련 기록
- 프로그램 Workout 완료 상태
하나는 성공하고 하나는 실패하면 홈의 진척도와 기록 화면이 서로 달라진다. 그래서 두 쓰기를 하나의 transaction으로 묶었다.
Expo SDK 57의 async transaction에는 주의할 점이 있었다. 일반 withTransactionAsync 실행 중에는 callback 밖의 비동기 query도 실행 시점에 따라 같은 transaction에 포함될 수 있다. 완료 저장과 세션 snapshot 삭제가 동시에 실행되는 구조에서는 예상하지 않은 rollback이나 lock 경합이 생길 수 있다.
그래서 exclusive transaction과 transaction 전용 connection을 사용하도록 추상화 계약을 바꿨다.
withTransaction<T>(
task: (transaction: DatabaseConnection) => Promise<T>,
): Promise<T>;Expo adapter는 callback으로 받은 transaction handle을 다시 DatabaseConnection으로 감싸고, repository는 반드시 그 인자를 사용한다.
withTransaction<T>(
task: (transaction: DatabaseConnection) => Promise<T>,
): Promise<T> {
let result: T;
return this.database
.withExclusiveTransactionAsync((transaction) =>
task(new ExpoDatabaseConnection(transaction)).then((value) => {
result = value;
}),
)
.then(() => result);
}rollback 테스트도 실제로 실패가 발생하는지 결정적으로 검증하도록 바꿨다. SQLite trigger와 INSERT OR IGNORE의 충돌 정책에 기대면 환경에 따라 실패가 억제될 수 있어서, 테스트 DB 경계에서 두 번째 쓰기만 명시적으로 실패시키고 첫 번째 쓰기가 rollback되는지 확인했다.
강제 종료 복구와 snapshot 경쟁
세션 시작, 구간 전환, 일시정지 때 현재 SessionState를 SQLite snapshot으로 저장한다. 앱을 다시 실행하면 snapshot을 판정해 이어 하기 또는 종료를 선택하게 한다.
실행 중이던 세션의 예정 종료 시각이 이미 지났다고 해서 자동 완료 기록을 만들지는 않는다. 실제로 사용자가 훈련을 수행했는지 알 수 없기 때문이다. 이 경우 중단된 세션으로 처리하고 완료 기록에는 포함하지 않는다.
리뷰 과정에서 중요한 경쟁 조건도 발견했다. 이전 세션의 저장이나 삭제가 지연된 동안 사용자가 새 세션을 시작하면, 오래된 작업이 singleton snapshot을 덮어쓰거나 새 snapshot을 삭제할 가능성이 있었다.
이를 막기 위해 두 규칙을 적용했다.
- 더 오래된 updatedAt의 snapshot은 새 snapshot을 덮어쓰지 않는다.
- 세션 종료 시 삭제는 현재 sessionId가 일치할 때만 수행한다.
DELETE FROM active_session
WHERE singleton_id = 1
AND json_extract(snapshot_json, '$.state.sessionId') = ?완료 화면으로 넘어가는 순서도 바꿨다. snapshot 삭제를 요청만 해둔 채 완료 저장을 시작하면 exclusive transaction과 삭제가 동시에 실행될 수 있다. 이제 snapshot 정리가 끝난 뒤에만 완료 callback을 호출한다.
이 문제는 화면만 눌러서는 거의 발견하기 어려웠다. 비동기 저장 순서를 일부러 지연시키는 테스트와 독립 코드 리뷰가 큰 역할을 했다.
화면 생명주기에서 배운 점
Expo Router의 탭 화면은 다른 화면으로 이동해도 항상 unmount되는 것이 아니다. 홈 화면이 mount 시 한 번만 SQLite를 읽으면 프로그램을 등록하고 돌아왔을 때 이전 상태가 남을 수 있다.
따라서 홈과 기록 화면은 화면이 focus될 때 repository를 다시 조회한다. 네이티브 앱에서는 웹 페이지의 mount/unmount 감각만으로 화면 갱신 시점을 판단하면 안 된다는 점을 확인했다.
훈련 시작 버튼에는 동기 ref 잠금도 적용했다. React state만으로 disabled를 설정하면 같은 render frame 안에 들어온 두 번의 탭을 모두 막지 못할 수 있다. 먼저 ref를 즉시 잠그고 state는 사용자에게 disabled 상태를 보여주는 용도로 사용했다.
테스트 전략
기능은 가능한 한 도메인 테스트부터 작성하고, repository 통합 테스트와 화면 테스트를 이어 붙였다.
최종 핵심 흐름 테스트는 다음 두 경로를 검증한다.
- 온보딩 완료 → 프로그램 등록 → 첫 코스 선택 → 세션 완료 → 기록과 진척도 저장 → 다음 Workout 계산
- 기본 자유 훈련 설정 → SessionPlan 생성 → fake clock 완료 → 자유 기록 저장 → 프로그램 진척도 불변
전체 결과는 다음과 같다.
- Jest 33개 test suite
- 테스트 135개
- 전체 테스트 두 번 연속 통과
- TypeScript 오류 0개
- ESLint 오류와 경고 0개
- Expo Doctor 21/21
- Expo SDK 의존성 검사 통과
독립 리뷰에서는 stale snapshot, SQLite transaction 격리, 홈 focus 갱신, 준비 시간 0초, 코스 시작 중복 탭, 완료 시 SQLite 쓰기 경합을 발견했고 모두 회귀 테스트와 함께 수정했다. 마지막 재리뷰에서 Critical 또는 Important 잔여 항목이 없다는 승인을 받았다.
현재 위치와 남은 일
구현 플랜은 12개 Task 중 11개가 완료됐다. MVP 완료 조건 9개 중 6개를 충족했다.
자동화와 문서화 범위는 끝났지만 iOS와 Android 실기기 검증은 완료하지 않았다. 현재 개발 환경에 전체 Xcode, iOS Simulator, Android SDK, adb와 emulator가 없기 때문이다. 확인하지 않은 기기 항목을 통과로 표시하지 않고 BLOCKED 상태로 남겼다.
남은 핵심 검증은 다음과 같다.
- 실제 3분 운동과 30초 휴식 완주
- 소리, 진동, 화면 켜짐 유지
- 짧은 백그라운드 복귀와 한 라운드 이상 경과 후 복귀
- 실행 중 또는 일시정지 상태에서 강제 종료 후 복구
- iOS와 Android Development Build 전체 흐름
다음 단계에서는 Mac에 Xcode를 준비해 iOS Development Build부터 검증한다. 그 뒤 Android 환경을 구성하고 같은 체크리스트를 실행한다.
이번 작업에서 가장 크게 남은 교훈은 모바일 타이머의 정확성보다 더 넓은 문제를 함께 봐야 한다는 점이다. 시간 계산, 화면 생명주기, 기기 효과, SQLite transaction, process termination, 비동기 작업 순서가 하나의 사용자 경험 안에서 연결된다. 각각을 따로 구현하는 것만으로는 부족했고, 전체 흐름 테스트와 의도적인 지연 테스트가 있어야 실제 데이터 손실 가능성을 줄일 수 있었다.