한 학기의 설계도
캡스톤은 좋은 아이디어 하나로 끝나지 않습니다. 그 아이디어를 한 학기 안에 어떤 순서로 검증하고, 만들고, 보여줄지를 함께 설계하는 일이 절반입니다.
저희 팀 Sudo가 만든 On-Care는 불규칙하게 생활하는 2030 고혈압·당뇨 위험군을 위한 AI 헬스케어 플랫폼입니다. 음식 사진 한 장으로 식단을 기록하고, 누적된 건강 이력을 바탕으로 코칭을 받고, 헬스장·일정까지 하나의 흐름으로 잇는 것을 목표로 했습니다.
처음부터 단계는 분명했습니다. 사용자 문제를 인터뷰로 검증하고, 브랜드와 협업 규칙으로 토대를 다진 뒤, 크로스플랫폼 앱을 구현하고, 백엔드가 준비되기 전에도 앱이 온전히 동작하도록 로컬 백엔드를 두고, 마지막으로 AI 파이프라인을 설계해 두는 것 — 이 글은 그 로드맵을 따라 실제로 무엇을 결정하고 만들었는지를 순서대로 풀어낸 기록입니다.
인터뷰가 방향을 잡아 주었다
첫 단계는 코드가 아니라 사용자였습니다. 2030 만성질환 위험군의 유병률은 구조적으로 늘고 있는데, 기존 헬스케어 앱은 여전히 기록의 번거로움, 맥락 없는 획일적 조언, 온·오프라인의 단절이라는 세 가지 한계에 머물러 있었습니다. 저희는 이 가설을 실제 타깃 사용자 세 분과의 인터뷰로 검증했습니다.
세 분 모두 같은 지점을 짚었습니다 — 건강 관리의 필요성은 알지만, 매끼 식단을 일일이 검색하고 입력하는 과정에서 지쳐 결국 앱을 떠난다는 것이었죠. 이 한 문장이 프로젝트의 무게중심을 “기능을 더 넣자”가 아니라 “기록 마찰을 줄이고 행동 변화로 잇자”로 잡아 주었습니다.
| 인터뷰에서 확인한 페인 포인트 | On-Care의 설계 방향 |
|---|---|
| 매끼 수동 입력이 번거로워 중단한다 | 사진 1장 식단 자동 인식 |
| 기록해도 무엇을 바꿔야 할지 모른다 | 내 이력을 참조하는 RAG 코치 |
| 칼로리 알림 수준의 일반적 조언뿐 | 나트륨·GI 등 질환 특화 피드백 |
| 앱·헬스장·병원이 따로 논다 | 식단·운동·일정·O2O 통합 |
이 표는 단순한 정리가 아니라, 이후 모든 기능 우선순위의 근거가 되었습니다. “사진 한 장으로 끝내는 Vision AI”를 1순위 차별점으로 둔 것도 첫 행에서 나온 결정입니다.
브랜드와 팀 규칙으로 토대를 다지다
방향이 정해지자, 본격적인 구현에 앞서 두 가지 토대를 먼저 만들었습니다. 하나는 서비스의 정체성, 다른 하나는 팀의 일하는 방식이었습니다.
브랜드 — 이름, 로고, 그리고 ‘온이’
서비스명 On-Care와 배너·로고를 직접 디자인하고, 사용자에게 피드백을 건네는 AI 캐릭터 ‘온이’의 말투와 톤까지 정했습니다. 화면마다 색과 여백이 제각각이면 완성도가 낮아 보이기 마련이라, 브랜드 일관성은 데모와 발표의 품질을 좌우하는 요소로 일찍 챙겼습니다.
팀 그라운드 룰과 역할 분담
세 사람이 한 저장소에서 매끄럽게 협업하려면 규칙이 먼저였습니다. 브랜치 전략 (GitHub Flow), 커밋 컨벤션, PR 리뷰 필수 같은 약속을 문서로 정해 두고 시작했고, 전공과 관심에 맞춰 역할을 나눴습니다.
React 프로토타입을, 의도적으로 Flutter로 재구성하다
On-Care의 첫 형태는 React + TypeScript + Vite로 만든 웹 프로토타입이었습니다. 핵심 UX 흐름 — 홈 대시보드, 식단 기록, 운동, 내 건강 — 을 빠르게 검증하기에는 좋았지만, 모바일까지 하나의 코드로 가져가려면 구조적인 결정이 필요했습니다. 그래서 iOS·Android·Web을 단일 코드베이스로 제공하는 Flutter로 재구성하기로 했습니다.
스택은 “이유와 대안”까지 적어 두고 골랐다
재구성은 좋은 기회였습니다. 원본의 MUI·Radix·Recharts·framer-motion 같은 의존성을 그대로 옮기는 대신, 각 영역마다 채택안과 대안을 비교해 문서로 남기고 결정했습니다.
| 영역 | 채택 | 이유 (검토한 대안) |
|---|---|---|
| 상태관리 | Riverpod v2 | 컴파일타임 안전성·DI·테스트 용이 (Bloc·Provider) |
| 라우팅 | go_router | Web URL·딥링크·셸 라우트 1급 지원 |
| 모델/직렬화 | freezed | 불변 데이터 클래스, sealed union |
| 네트워크 | dio + retrofit | 인터셉터·캐싱, mock-first 진행 |
| 로컬 저장소 | drift | 시계열 건강 기록에 SQLite 적합 (isar) |
| 차트 | fl_chart | Recharts 대체, 커스텀 범례·툴팁 |
기능을 바로 찍어내기보다 라우팅·상태관리·테마·네트워크·에러 처리 같은 기반을 먼저 잡고 feature-first 구조를 도입했더니, 이후 화면을 붙이는 속도가 확실히 빨라졌습니다.
일관된 화면은 ‘일관된 속도’에서 온다
화면을 여러 개 만들다 보면 카드·버튼·여백·색이 제각각으로 흐르기 쉽습니다. 그래서
색·간격·타이포그래피를 토큰화하고, 공통 카드·버튼·차트 컴포넌트를
design_system/으로 분리했습니다. atoms·molecules 단위로 가볍게 정리해
두니 화면이 늘어나도 중복이 줄고, 무엇보다 모든 화면이 하나의 서비스처럼 보였습니다.
이 위에서 핵심 사용자 흐름을 구현했습니다. 홈에서 건강 상태를 확인하고, 식단과 운동을 기록하고, My Health에서 체중·혈압·혈당 추이를 7일 차트로 확인하는 흐름입니다. 나트륨·당류 예산 표시, 통합 일정 캘린더, Streak·활동 포인트, 헬스장 찾기 UI까지 이어 붙여, 사용자가 서비스 전체를 한 흐름으로 경험할 수 있게 했습니다.
백엔드 전에도 온전히 동작하는 앱
이 부분은 같은 고민을 하는 팀이라면 그대로 가져다 쓸 수 있는, 이 글에서 가장 실용적인 대목입니다.
FastAPI 서버와 MySQL은 그로쓰 단계의 과제로 두되, 그 전에도 앱은 실제 서비스처럼 동작해야 했습니다. mock JSON만으로는 화면은 보여도 “기록을 추가하고 → 수정하고 → 다시 조회하는” 흐름을 검증할 수 없죠. 그래서 앱 안에 진짜 데이터베이스를 두기로 했습니다.
구조는 단순합니다. Flutter 안에 drift(SQLite) 로컬 DB를 두고, Dio 요청을 LocalApiInterceptor가 가로채 요청 path에 맞는 SQL을 실행한 뒤 실제 API 응답처럼 돌려줍니다. dio는 네트워크를 타지 않지만, 앱 입장에서는 서버가 있는 것과 동일합니다.
코드로 보면 의외로 간결합니다. onRequest에서 요청 path와 method를 보고,
해당하는 drift 쿼리를 실행한 결과를 handler.resolve()로 곧장 응답으로
돌려줍니다. 매칭되는 라우트가 없으면 그냥 다음 인터셉터로 흘려보내, 실서버 전환 시
자연스럽게 우회되도록 했습니다.
class LocalApiInterceptor extends Interceptor { final AppDatabase _db; LocalApiInterceptor(this._db); @override Future<void> onRequest(options, handler) async { final route = (options.method, options.path); switch (route) { // GET /diet/days/today — 오늘 끼니를 합산해 반환 case ('GET', '/diet/days/today'): final entries = await _db.dietForDay(DateTime.now()); return handler.resolve(_ok(options, DietDay.from(entries))); // POST /diet/entries — 기록을 추가하고 저장된 행을 반환 case ('POST', '/diet/entries'): final saved = await _db.insertDiet(DietEntry.fromJson(options.data)); return handler.resolve(_ok(options, saved, status: 201)); // 매칭 없음 → 실서버로 흘려보냄 (USE_MOCK_API=false 시 그대로 통과) default: return handler.next(options); } } Response _ok(o, body, {int status = 200}) => Response( requestOptions: o, statusCode: status, data: body.toJson()); }
핵심은 (method, path) 패턴 매칭으로 라우트를 한곳에 모은 점입니다.
엔드포인트가 늘어도 case 한 줄을 더하면 되고, 실제 FastAPI 라우터의
시그니처와 1:1로 대응하도록 path를 맞춰 두어 나중에 서버 코드로 옮길 때 헷갈릴 일이
없게 했습니다.
다섯 옵션을 비교한 끝에 drift
설계 문서(DUMMY_BACKEND.md)에 후보를 늘어놓고 비교했습니다.
in-memory mock은 앱을 끄면 초기화되고, localStorage는 웹 전용이라 모바일
빌드가 안 됩니다. 반면 drift는 한 코드로 세 플랫폼 모두에서 영속
백엔드가 되고, SQL 쿼리·트랜잭션이 가능해 “오늘 식단 합산 칼로리” 같은 집계가
자연스럽게 나왔습니다.
| 옵션 | 영속성 | 플랫폼 | 판단 |
|---|---|---|---|
| in-memory mock | 앱 끄면 초기화 | 전부 | 기록 검증 불가 |
| localStorage | 웹만 유지 | 웹 전용 | 모바일 빌드 불가 |
| drift (SQLite) | 영구 | AOS·iOS·Web | 채택 |
| 외부 mock 서버 | 유지 | 전부 | 별도 실행 필요 |
건강 기록은 본질적으로 시계열 데이터입니다. 체중·혈압·혈당이 recorded_at으로
쌓이고, “최근 7일 추세”나 “오늘 합산”처럼 범위·집계 쿼리가 잦죠. drift로 테이블을
선언해 두면 이런 질의가 SQL 한 줄로 끝납니다.
// 식단 기록 — 끼니별 영양소를 그대로 컬럼으로 class DietEntries extends Table { IntColumn get id => integer().autoIncrement()(); DateTimeColumn get date => dateTime()(); TextColumn get mealType => text()(); // 아침/점심/저녁 TextColumn get foodsJson => text()(); // 인식된 음식 목록 IntColumn get totalCalories => integer()(); IntColumn get sodiumMg => integer()(); // 고혈압 추적 핵심 } // 생체 지표 — 체중·혈압·혈당을 한 테이블에 시계열로 class Vitals extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get kind => text()(); // weight | bp | glucose TextColumn get valueJson => text()(); DateTimeColumn get recordedAt => dateTime()(); }
그러면 “최근 7일 체중 추세” 같은 화면용 질의도 컬렉션을 순회할 필요 없이 그대로 내려쓸 수 있습니다.
Future<List<Vital>> weightTrend({int days = 7}) { final since = DateTime.now().subtract(Duration(days: days)); return (select(vitals) ..where((v) => v.kind.equals('weight') & v.recordedAt.isBiggerThanValue(since)) ..orderBy([(v) => OrderingTerm.asc(v.recordedAt)])) .get(); }
처음부터 ‘갈아끼울 수 있게’ 설계
가장 신경 쓴 것은 API 계약을 실제 서버 기준으로 미리 맞춰 둔 점입니다.
path와 응답 형태를 REST 그대로 유지하고, USE_MOCK_API 플래그 하나로 모드를
나눴습니다. 기본값이면 로컬 인터셉터가 응답하고, 나중에 끄면 같은 시그니처의 FastAPI
서버로 그대로 전환됩니다.
# 평소 — 시연·개발 (기본값) flutter run -d chrome # 실 서버 연동 시 — path는 그대로, 인터셉터만 off flutter run -d chrome \ --dart-define=USE_MOCK_API=false \ --dart-define=API_BASE_URL=<your-api>
이 플래그는 컴파일타임에 읽혀, DioClient를 조립할 때 인터셉터를 붙일지 말지를
가릅니다. 앱 코드의 나머지는 모드를 전혀 몰라도 됩니다 — 그냥 dio.get('/diet/days/today')를
부를 뿐이고, 응답이 로컬 drift에서 왔는지 실서버에서 왔는지는 신경 쓰지 않습니다.
const useMock = bool.fromEnvironment('USE_MOCK_API', defaultValue: true); Dio buildDio(AppDatabase db) { final dio = Dio(BaseOptions(baseUrl: _baseUrl)); if (useMock) { dio.interceptors.add(LocalApiInterceptor(db)); // 로컬 백엔드 모드 } dio.interceptors.add(ApiLoggingInterceptor()); // 두 모드 공통 return dio; }
덕분에 백엔드 없이도 대시보드·식단·운동·건강 지표·일정 화면을 하나의 앱처럼 시연할 수 있었고, 실서버가 준비되면 전환 비용은 거의 들지 않도록 길을 열어 두었습니다. 한 줄 요약하면 — 모드는 부트스트랩에서 한 번 결정되고, 그 위의 모든 코드는 모드에 무지하게 동작합니다.
같은 링크로 누구나 볼 수 있도록
로컬에서만 도는 앱으로는 부족했습니다. 평가자와 팀원이 같은 링크로 접속할 수 있도록
Flutter Web 자동 배포 워크플로우를 구성하고, 커스텀 도메인
(ewhasudo.zapto.org)에 연결했습니다. 배포 과정에서 마주친 몇 가지 지점은
기록해 두면 같은 길을 걷는 분들께 도움이 될 듯합니다.
| 지점 | 상황 | 처리 |
|---|---|---|
| base-href | 서브경로(/frontend/) 배포라 라우팅 경로가 어긋남 |
배포 경로에 맞춰 base-href 지정 |
| drift web runtime | sqlite3.wasm·drift_worker.js가 .gitignore 처리됨 |
빌드 전 받아오는 스크립트 추가 |
| quality gate | 분석·테스트 없이 배포되는 것을 방지 | analyze+test를 배포 전 게이트로 |
테스트는 발표용 MVP라도 단위·위젯·golden·통합까지 갖춰 두었습니다. 화면이 늘어나도 회귀를 빠르게 잡을 수 있어, 이후 리팩터링과 배포의 안정성이 한결 높아졌습니다.
AI는 검증된 만큼, 설계는 설계대로
AI 기능은 이번 학기에 앱과 완전히 통합하는 대신, 가능성을 검증한 PoC와 아키텍처 설계로 토대를 마련하는 데 집중했습니다. 각 단계가 지금 어디에 있는지를 분명히 구분한 것이 오히려 프로젝트의 신뢰를 높여 주었습니다.
Gemini Vision 식단 분석 PoC
backend/services/gemini_service.py에 Gemini Vision을 실제로 호출하는 독립
스크립트를 만들었습니다. 음식 사진을 넣으면 음식명을 나열하고, 음식별·총 칼로리를
추정하며, 고혈압 관점의 식단평까지 한국어로 돌려줍니다 — 나트륨이
높은 음식을 짚고, DASH 식단 기준 장단점과 개선안을 제시하도록 프롬프트를 서비스
도메인에 맞춰 설계했습니다.
SYSTEM = """당신은 고혈압·당뇨 위험군을 돕는 한국인 임상영양 코치입니다. 사진 속 식단을 분석해 다음을 JSON으로 반환하세요: - foods: 음식명 리스트 - calories: 음식별 + 총합 추정 - sodium_review: 나트륨이 높은 음식 지적 - dash_score: DASH 식단 기준 장단점 - suggestion: 한 끼 단위의 구체적 개선 제안 진단·처방 표현은 쓰지 말고, 생활 습관 관점으로만 조언하세요.""" def analyze_meal(image_path: str) -> dict: img = Image.open(image_path) res = model.generate_content( [SYSTEM, img], generation_config={"response_mime_type": "application/json"}, ) return json.loads(res.text)
단순히 “이 음식이 무엇인가”를 넘어, 응답 스키마 자체에 sodium_review·
dash_score를 박아 둔 게 핵심입니다. 모델이 우리 서비스가 필요로 하는
형태로만 답하도록 강제하고, response_mime_type으로 JSON을 받아 그대로
파싱했습니다.
Vision AI 2-stage 파이프라인
단일 Vision API 호출 대신, 비용과 정확도를 함께 고려한 2-stage 구조를 설계했습니다.
앞단의 값싼 필터(YOLOv8)로 비음식 이미지에 대한 Gemini 호출을 줄이고, AI가 추정한 값은 그대로 쓰지 않고 공공데이터 식품영양성분 DB와 매핑해 한국 음식 정확도를 보완합니다. 마지막에는 사용자가 수정해 저장하도록 했습니다 — 한국 음식은 조리·양념 편차가 커서 이 수정 단계가 꼭 필요했습니다.
RAG 기반 AI 코치
On-Care의 차별점은 일반 상식을 답하는 챗봇이 아니라, 내 최근 식단·운동·건강 지표를 참조해 답하는 코치입니다. “오늘 저녁 뭐 먹지?”라는 질문에도 최근 나트륨 섭취량, 혈압 기록, 운동 여부를 함께 반영하도록 RAG 구조를 설계했습니다.
우리가 만드는 것은 ‘진단’이 아니라 ‘생활 습관 관리’다.
그래서 한국영양학회 등 공인 데이터만 인덱싱하는 closed-domain RAG에 프롬프트 가드레일을 더해, 의료 진단·처방으로 오해될 여지를 처음부터 차단하도록 설계했습니다.
GitHub를 협업의 기록으로 남기다
모든 변경은 PR 단위로 관리했습니다. 기능·문서·배포·버그 수정까지 전부 Pull Request를
거쳤고, PR 템플릿과 라벨로 변경 목적을 분명히 남겼습니다. 커밋 제목은
feat·fix·docs 같은 Conventional Commits를 따라,
이력만 훑어도 흐름이 읽히게 했습니다.
여기에 CodeRabbit AI 코드 리뷰를 연동해 자동 리뷰 흔적을 남기고,
공개 저장소로서 갖출 것을 갖췄습니다 — LICENSE, CONTRIBUTING.md,
SECURITY.md, CODE_OF_CONDUCT.md, CITATION.cff,
그리고 시연 흐름을 정리한 self_demo.md까지요. 설계·계획 문서
(PLAN·STRUCTURE·API_CATALOG·DUMMY_BACKEND)도 저장소에 함께 두어, 의사결정의 근거가
남도록 했습니다.
- 변경 목적이 PR 제목·라벨에 남아, 시간이 지나도 맥락을 복원할 수 있다
- 설계 문서가 코드 옆에 있어, 왜 그 기술을 골랐는지가 함께 읽힌다
- 커뮤니티 헬스 파일로 공개 저장소다운 완성도를 갖췄다
지금까지의 위치, 그리고 다음
한 학기를 정리하며, “지금 무엇이 동작하고 무엇이 설계 단계인지”를 한눈에 보이도록 현황표를 만들었습니다. 이 정직한 지도가 평가자에게도, 다음 학기의 우리에게도 가장 좋은 출발점이 되어 줍니다.
| 구성요소 | 상태 | 근거 |
|---|---|---|
| 크로스플랫폼 UI · AI 코치 패널 · 반응형 웹 | 동작 | lib/features/ |
| 건강 지표 기록 · 7일 추세 차트 | 동작 | features/my_health |
| 디자인 시스템 · 통합 일정 캘린더 | 동작 | lib/design_system/ |
| 로컬 백엔드 (drift + Interceptor) | 동작 | core/network·storage |
| 테스트 + 웹 자동 배포 | 동작 | test/ · deploy.yml |
| Gemini Vision 영양 분석 | PoC | gemini_service.py |
| YOLOv8 음식 필터 · RAG 코치 | 설계 | 파이프라인 설계안 |
| FastAPI · MySQL · 카카오맵 · FCM | 설계 | System Architecture |
다음 단계도 이미 그려 두었습니다. FastAPI 백엔드와 AI 엔진 통합을 잇고, 그 너머로는 연속혈당측정기·웨어러블 연동으로 수동 입력을 줄이고, LiDAR 기반 3D 식단 인식으로 영양 추정 정확도를 높이며, 트레이너 대시보드로 쌍방향 O2O 루프를 완성하는 그림입니다.
On-Care는 아직 모든 AI 엔진이 완성된 서비스는 아닙니다. 하지만 기획과 브랜드, Flutter MVP, 로컬 백엔드, 배포, 협업까지 한 학기 동안 계획한 단계를 차근차근 밟아, 실제 서비스로 자라날 수 있는 단단한 뼈대를 세웠다고 자신 있게 말할 수 있습니다.