← All work

루핏

사용자가 정한 운동 루틴을 따라 다음 운동과 시간을 가볍게 기록하는 iPhone 앱입니다. SQLite 중심의 로컬 우선 구조와 네이티브 위젯·Live Activity, 선택형 클라우드 백업과 익명 랭킹까지 1인 개발·운영하고 있습니다.

Product · iOS · Backend · Infrastructure / 2026.07 — 현재

루핏프로젝트 이미지 준비 중

운동을 기록하기 위해 운동보다 기록에 더 집중하게 만들고 싶지 않았습니다. 사용자가 정한 루틴에서 다음 운동을 보여 주고, 시작과 종료만으로 시간을 남기며, 앱을 열지 않아도 같은 운동 상태를 이어 가는 경험을 만들었습니다.

Overview

루핏은 내 루틴대로 운동을 시작하고, 운동 시간과 부위를 가볍게 기록하는 앱입니다. 세트·중량·횟수를 세밀하게 입력하는 대신 사용자가 만든 루틴의 다음 순서를 안내하고, 시작과 종료 사이의 시간을 기록합니다. 완료한 운동은 캘린더와 히트맵, 부위별 통계로 돌아볼 수 있습니다.

2026년 7월부터 제품 기획과 디자인, React Native 앱, iOS 위젯과 Live Activity, Android 네이티브 기능, 인증·백업·랭킹 API와 AWS 인프라까지 혼자 설계하고 구현했습니다. iOS 앱을 App Store에 출시했으며, 앱의 핵심 기록 기능은 로그인과 네트워크 없이 사용할 수 있도록 구성했습니다.

담당 범위

  • 루틴 생성, 다음 운동 안내, 시작·완료·취소와 기록 조회를 잇는 제품 경험
  • Expo·React Native·TypeScript 기반 iOS·Android 앱과 SQLite 데이터 모델
  • WidgetKit·SwiftUI 기반 홈 화면·잠금 화면 위젯과 Live Activity
  • 앱과 네이티브 화면이 같은 운동 명령을 수행하는 Swift·Kotlin 네이티브 코어
  • Apple·Kakao 로그인, 운동 기록 백업·복원과 주간 익명 랭킹 API
  • AWS CDK 기반 Lambda·DynamoDB·SQS·KMS 인프라와 앱 배포·운영

Problem

기존 운동 기록 앱은 세트, 중량과 반복 횟수를 자세히 남기는 데 초점을 둔 경우가 많았습니다. 하지만 이미 자신만의 분할 루틴이 있는 사용자는 운동 중에 휴대폰을 반복해서 조작하기보다 오늘 할 운동을 확인하고 시작과 종료만 간단히 기록하고 싶을 수 있습니다.

입력을 줄이면 기술적으로 단순해 보이지만, 실제 경험을 완성하려면 여러 조건이 함께 해결되어야 했습니다.

  • 앱을 종료하거나 백그라운드로 보내도 진행 중인 운동이 사라지지 않아야 합니다.
  • 홈 화면 위젯이나 Live Activity에서 운동을 끝내도 앱의 상태와 어긋나지 않아야 합니다.
  • 과거 기록은 사용자가 나중에 루틴이나 운동 부위 이름을 바꿔도 당시 내용으로 남아야 합니다.
  • 로그인하지 않았거나 네트워크가 끊겨도 기록의 시작·완료·수정·삭제가 가능해야 합니다.
  • 백업 기능을 추가한 뒤에도 서버 상태가 기기의 최신 기록을 임의로 덮어쓰지 않아야 합니다.

따라서 목표는 운동 기능을 많이 넣는 것이 아니었습니다. 입력 부담은 줄이되 기록의 연속성과 일관성을 잃지 않는 로컬 우선 제품을 만드는 것이 핵심이었습니다.

Process

기록할 항목보다 기록하지 않을 항목을 먼저 정했습니다

제품의 중심을 세트 단위 기록이 아닌 루틴의 흐름에 두었습니다. 사용자는 운동 부위로 루틴을 구성하고, 홈에서 다음 순서를 확인한 뒤 운동을 시작하고 끝냅니다. 완료한 운동만 다음 루틴으로 진행하며, 취소한 운동은 이력에는 남기되 운동 시간 통계와 루틴 진행에는 반영하지 않았습니다.

이 기준으로 홈 화면이 운동 전·진행 중·완료 후 상태를 모두 다루게 했습니다. 상세 입력 화면을 여러 번 오가는 대신 지금 해야 할 한 가지 행동이 보이도록 범위를 줄였습니다. ‘다음 운동’도 추천 알고리즘이 아니라 사용자가 직접 정한 순서를 따르도록 해 제품의 역할을 명확하게 유지했습니다.

운동 상태를 화면이 아닌 SQLite에 두었습니다

진행 중인 운동을 React 상태에만 보관하면 앱이 종료되거나 위젯에서 명령을 실행했을 때 상태가 끊깁니다. SQLite를 운동 기록의 단일 진실 원천으로 두고, 앱의 Zustand 상태는 현재 세션을 빠르게 표현하는 캐시와 UI 상태에만 사용했습니다.

운동의 시작·완료·취소를 명시적인 상태 전이로 다루고, 앱 재실행 시 진행 중인 세션을 데이터베이스에서 복원했습니다. 완료 기록에는 당시 루틴 이름과 운동 부위의 이름·색상·순서를 스냅샷으로 저장했습니다. 현재 설정을 수정하거나 삭제해도 과거 캘린더와 통계의 의미가 바뀌지 않도록 한 결정입니다.

앱과 위젯이 같은 명령을 실행하게 했습니다

위젯을 단순한 요약 화면으로 만들면 사용자는 운동을 시작하거나 끝내기 위해 다시 앱을 열어야 합니다. 반대로 위젯이 별도의 상태를 수정하면 앱, 위젯과 Live Activity가 서로 다른 운동을 표시할 위험이 있습니다.

startNext, startRoutine, changeParts, complete, cancel을 공통 운동 명령으로 정의하고, iOS의 Swift와 Android의 Kotlin 네이티브 코어가 같은 SQLite 스키마와 상태 전이 규칙을 따르도록 구성했습니다. 명령에는 예상 세션 ID와 revision을 포함해 이미 바뀐 세션에 뒤늦게 도착한 조작을 거절할 수 있게 했습니다. 앱과 외부 화면의 조작 이후에는 위젯, Live Activity와 앱 상태를 같은 데이터에서 다시 발행합니다.

위젯의 기능·콘텐츠·상태 정책과 디자인 토큰도 하나의 JSON 계약에서 TypeScript·Swift·Kotlin 코드로 생성했습니다. 플랫폼마다 적절한 렌더링 방식을 사용하면서도 ‘무엇을 보여 주고 어떤 상태에서 어떤 행동이 가능한가’는 같은 기준을 유지했습니다.

자동 동기화 대신 단방향 백업과 명시적 복원을 선택했습니다

로그인과 백업을 추가하면서도 로컬 기록의 주도권을 서버로 넘기지 않았습니다. SQLite는 계속 원본이고, 서버는 완료·취소된 운동의 단방향 복제본으로만 동작합니다. 일상적인 앱 사용에서 서버가 로컬 데이터를 자동으로 병합하거나 덮어쓰지 않으며, 사용자가 설정에서 확인한 경우에만 백업을 가져옵니다.

로컬 기록 변경과 전송할 작업을 같은 SQLite 트랜잭션에 저장한 뒤 네트워크 전송은 별도로 실행했습니다. 각 기록에 syncId와 단조 증가하는 revision을 부여하고, 삭제도 더 높은 revision의 tombstone으로 남겨 오래된 요청이 기록을 되살리지 못하게 했습니다. 반복 수정은 outbox의 최신 작업 하나로 합치고, 서버는 같은 revision을 멱등 처리합니다.

복원할 때는 다운로드 전후의 서버 revision이 같은지 확인한 뒤 하나의 로컬 트랜잭션으로 병합합니다. 서버에만 있는 기록은 추가하고, 같은 기록은 높은 revision을 적용하며, 로컬에만 있거나 더 최신인 기록은 유지해 다시 백업합니다. 이를 통해 오프라인 기록과 위젯 조작은 네트워크 응답을 기다리지 않으면서도, 사용자가 원할 때 계정 백업을 안전하게 가져올 수 있게 했습니다.

계정을 핵심 기능의 전제 조건으로 만들지 않았습니다

루틴과 운동 기록은 가입 없이 바로 사용할 수 있게 두고, Apple·Kakao 로그인은 백업과 주간 익명 랭킹이 필요한 시점에만 제안했습니다. 소셜 로그인 결과는 자체 Access·Refresh Token으로 교환하고, 모바일에서는 보안 저장소에 보관했습니다.

백업된 완료 기록은 DynamoDB Stream을 통해 주간 랭킹의 파생 데이터로 집계합니다. 랭킹에는 사용자의 상세 운동 기록 대신 익명 순위에 필요한 결과만 노출했습니다. 계정 삭제는 재인증 뒤 진행하고, 공급자 연결 해제와 서버 기록 삭제를 SQS 작업으로 이어 운영 실패를 재시도할 수 있게 했습니다.

앱 출시 이후의 운영 범위까지 함께 만들었습니다

백엔드는 API Gateway, Lambda, DynamoDB, SQS와 KMS를 AWS CDK로 정의했습니다. KMS로 서비스 토큰을 서명하고, API와 작업 큐의 오류를 CloudWatch 경보로 추적했습니다. 서비스 도메인과 API 도메인을 분리하면서 기존 앱이 사용하던 issuer도 호환되도록 유지했습니다.

앱 스토어 메타데이터, 개인정보 처리방침과 고객지원 페이지, TestFlight와 심사 제출 흐름도 제품 범위에 포함했습니다. 앱과 서버의 타입 검사·자동 테스트, 네이티브 운동 코어 테스트와 위젯 시각 회귀 검사를 릴리스 전에 함께 확인했습니다.

Solution

루틴의 다음 행동에 집중한 모바일 앱

Expo·React Native·TypeScript로 루틴 구성, 다음 운동 안내, 운동 시작·완료·취소, 기록과 히트맵을 구현했습니다. SQLite가 세션과 기록의 원본을 맡고 있어 로그인이나 네트워크 상태와 관계없이 핵심 기능이 동작합니다. 현재 루틴을 수정해도 과거 기록은 저장 당시의 스냅샷으로 유지됩니다.

앱 밖에서도 이어지는 운동 경험

WidgetKit·SwiftUI 기반 홈 화면·잠금 화면 위젯과 Live Activity에서 다음 운동과 진행 시간을 확인하고 운동을 시작하거나 끝낼 수 있습니다. 앱과 네이티브 화면은 같은 운동 명령과 데이터베이스를 사용하며, revision 기반 발행 절차로 늦게 도착한 화면 갱신이 최신 상태를 덮지 않게 했습니다. Android에도 동일한 명령 코어와 위젯·진행 알림 구조를 구현했습니다.

선택해서 사용하는 계정·백업·랭킹

Apple·Kakao 로그인 뒤에는 운동 기록을 서버에 자동 백업하고, 필요할 때 사용자가 확인한 뒤 다른 로컬 기록과 병합해 복원할 수 있습니다. Lambda와 DynamoDB 기반 API는 revision과 tombstone으로 중복·역순 요청을 처리하고, 백업 데이터에서 주간 익명 랭킹을 별도로 집계합니다.

Results

  • 루틴 생성부터 다음 운동 확인, 시작·완료와 캘린더·히트맵 회고까지 이어지는 iOS 앱을 App Store에 출시했습니다.
  • 앱이 종료되거나 백그라운드로 전환되어도 진행 중인 운동을 SQLite에서 복원하고, 앱·위젯·Live Activity가 같은 운동 상태를 사용하도록 만들었습니다.
  • 로그인과 네트워크를 핵심 기록 기능에서 분리하고, 로컬 변경을 막지 않는 단방향 백업과 사용자 확인 기반 복원을 추가했습니다.
  • TypeScript 앱, Swift·Kotlin 네이티브 코어, 서버리스 API와 CDK 인프라, 웹사이트와 스토어 배포까지 1인 제품의 전체 범위를 구축했습니다.
  • 자동 테스트와 네이티브 코어·위젯 회귀 검사를 릴리스 절차에 포함해 여러 실행 환경에서 같은 기록 규칙이 유지되는지 확인할 수 있게 했습니다.

Learnings

입력을 줄이는 일은 기능을 빼는 것보다 규칙을 선명하게 만드는 일이었습니다

세트와 중량을 기록하지 않기로 한 뒤에도 완료와 취소가 루틴에 미치는 영향, 과거 이름을 어떻게 보존할지, 다음 운동을 누가 결정하는지 같은 규칙은 필요했습니다. 제품이 가벼워질수록 보이지 않는 상태 규칙은 더 명확해야 했습니다.

여러 화면이 같은 상태를 다룰 때는 공통 명령이 필요했습니다

앱과 위젯이 각각 데이터베이스를 직접 수정하게 두는 대신, 가능한 명령과 예상 상태를 계약으로 정의했습니다. 그 결과 플랫폼별 UI는 달라도 운동의 시작·완료·취소가 같은 의미로 동작하고, 오래된 조작과 갱신을 판별할 수 있었습니다.

로컬 우선은 오프라인 저장만으로 완성되지 않았습니다

서버를 추가하는 순간 데이터의 원본, 충돌의 승자, 삭제의 보존 방식과 복원 시점을 모두 정해야 했습니다. 자동 양방향 동기화의 범위를 넓히기보다 단방향 백업과 명시적 병합을 선택해 사용자의 로컬 기록이 예측 가능한 방식으로 유지되도록 했습니다.

다음 단계에서는 실제 사용 데이터를 바탕으로 운동 시작에서 완료까지의 이탈, 위젯을 통한 조작 비율과 백업·복원 성공률을 측정하려 합니다. Android는 구현된 네이티브 기능과 배포 절차를 실제 스토어 출시까지 연결해 두 플랫폼의 운영 경험을 검증할 계획입니다.