플랜핏
경력근거: 문제 정의 → 솔루션 아이디어 발굴 → UX 설계 → 개발/배포까지 전 과정 수행
플랜핏 Product Engineer 는 문제 정의부터 UX 설계와 개발·배포까지 한 사람이 끝내고 제품 지표를 직접 책임집니다. 운동 중 화면은 사용자가 헬스장에서 세트마다 손을 대는 자리라 지표가 가장 먼저 움직이는 곳이고, 계획과 다르게 수행한 값을 그 자리에서 받아내지 못하면 뒤따르는 기록도 다음 주 무게도 전부 어긋납니다. 결과를 보여주는 대시보드가 아니라 사람의 확정 없이는 다음 세트가 열리지 않는 화면 하나를 상태 전이까지 직접 설계해 만들었습니다. 이 포트폴리오가 보여주는 것은 상태 전이를 지키는 화면 구현과 규칙 기반 재계산 역량이며, 운동 처방·재활 같은 피트니스 도메인 전문성은 주장하지 않습니다. 모르는 깊이는 면접에서 모른다고 말하는 편이 낫습니다.
플랜핏 Product Engineer 는 문제 정의부터 UX 설계와 개발·배포까지 전 과정을 맡고 제품 지표를 직접 책임집니다. 화면 하나를 상태 전이까지 직접 설계해 끝내는 결과물이 그 역할과 그대로 겹칩니다.
자격요건이 React 와 TypeScript 활용 능력이고 실제 스택은 React Native·Redux Toolkit·Swift 입니다. 운동 중 화면은 사용자가 헬스장에서 손을 대는 자리라 이 스택이 장식이 아니라 골조로 쓰입니다.
A/B 테스트와 실험 설계·분석·반복 개선이 주요업무에 있고 Amplitude 를 씁니다. 같은 화면을 제안 자동 채움 변형과 매번 입력 변형으로 나눠 세트 완료율을 비교하는 단계까지 이어 붙입니다.
오늘의 운동 플랜을 세트 카드 한 장씩 열어, 실제로 든 무게와 반복을 사용자가 고쳐 완료해야 다음 세트 카드의 잠금이 풀리고, 계획과 어긋난 만큼 다음 세트 제안 무게가 규칙표로 다시 계산되는 운동 기록 화면입니다. 근거는 채용공고 정렬입니다.
헬스장에서 플랜핏 앱을 켜 놓고 여덟 세트를 도는 사용자입니다. 계획한 무게를 그대로 든 날이 거의 없는데 화면은 계획만 보여줘서, 실제로 든 무게는 매번 다른 메모 앱에 적어 두다가 다음 주에 무슨 무게로 시작해야 할지 잊어버립니다.
세트 완료를 자동 판정이 아니라 사람이 화면에서 확정하는 행동으로 두면, (a) 계획과 다르게 수행한 무게와 반복이 그 자리에서 기록되고, (b) 다음 세트 제안 무게가 규칙표로 다시 계산돼 사용자가 받거나 고칠 수 있으며, (c) 통증 표시나 기구 점유처럼 계획이 깨지는 상황에서도 세션이 끊기지 않고 이어집니다.
오늘의 플랜을 열면 세트 카드가 잠긴 채 쌓이고, 맨 위 카드에서 실제로 든 무게와 반복을 고쳐 완료를 눌러야 다음 카드가 열리는 화면입니다. 계획과 어긋난 만큼 다음 세트 제안 무게가 규칙표로 다시 계산되고, 통증 표시와 기구 점유와 사전에 없는 운동 표기는 사용자가 감량안·대체 운동·직접 입력 중 하나를 골라야 넘어갑니다. 서버 저장은 목 API 로 가정하고 기록은 기기 안에 남깁니다.
Node.js 세션 저장 API 와 Django Rest Framework 플랜 조회를 붙이고 Docker 이미지로 AWS Elastic Beanstalk 에 배포합니다. 지하 헬스장에서 끊긴 요청은 기기 큐에 쌓였다가 세트 단위 멱등키로 재전송돼 같은 세트가 두 번 저장되지 않습니다.
제안 무게를 미리 채워 두는 변형과 매번 사용자가 입력하는 변형을 Firebase 원격 설정으로 나누고, Amplitude 세트 단위 이벤트로 완료율과 중도 이탈을 비교하는 단계입니다.
여덟 세트짜리 오늘의 플랜을 화면에서 하나씩 열어 마지막 세트까지 끝내는 과정을 시연합니다. 세트마다 실제로 든 무게와 반복을 고쳐 완료를 눌러야 다음 카드가 열리고, 확정하지 않은 세트는 기록에 남지 않습니다
완료를 누르지 않고 다음 세트로 넘어가려는 우회 조작 20건을 전부 막습니다
측정: 상태 전이표 대조, 표본: 우회 시도 20건
골든 조작 시퀀스 40개를 재생하면 세트 상태와 다음 세트 제안 무게가 규칙표 재계산 값과 전부 일치합니다. 통증 표시가 들어간 세트에서 증량 제안이 한 건도 나오지 않는 것도 같은 방식으로 확인합니다
측정: 규칙표 재계산 대조, 표본: 시퀀스 40건
3주차에는 제안 무게 자동 채움 변형과 매번 입력 변형을 각 60세션씩 합성 로그로 돌려 세트 완료율 차이를 냅니다. 세션 로그는 작성자가 만든 합성 기준이라 이 수치는 성능 검증이 아니라 데모 조건에서의 동작임을 명시합니다
진짜 성능 검증은 여기서 멈춥니다. 위 수치는 전부 작성자가 만든 더미데이터 자가채점이며, 실제 세트 완료율과 증량 폭의 적정성은 플랜핏 실사용자 기록과 실제 트레이너 검수가 있어야 측정 가능합니다. 이 화면은 그 측정을 바로 돌릴 수 있는 상태로 만듭니다.
수치는 합성 데이터 자가채점이라 성능 증명이 아닙니다. 면접에서 데모 조건 동작과 실검증 준비 상태로 솔직히 설명합니다
조작 시퀀스를 작성자가 직접 짰으니 잘 도는 게 당연합니다. 무게를 계획보다 낮춰 든 세트, 반복이 절반만 나온 세트, 통증 표시 후 되돌리기, 사전에 없는 운동 표기, 기구 점유로 대체를 고른 경우를 더미에 일부러 섞어 쉬운 경로만 시연하는 게 아님을 보여줍니다
부상 안전이 걸린 화면입니다. 통증을 표시한 세트에서는 증량 제안을 아예 만들지 않고 감량안과 휴식만 내놓으며, 화면 어디에서도 통증 원인이나 의학적 판단을 말하지 않습니다. 통증 표시가 이어지면 전문가 상담 안내로 세션을 닫습니다
제안 무게를 미리 채워 두면 완료율은 오르지만 실제로 든 무게와 다른 값이 그대로 굳습니다. 자동 채움으로 확정된 세트를 따로 표시하고 되돌리기를 함께 만들어, 완료율이 오른 만큼 기록이 부정확해지는 대가를 숨기지 않습니다
이 포트폴리오가 보여주는 것은 상태 전이를 지키는 화면 구현과 규칙 기반 재계산 역량이며, 운동 처방·재활 같은 피트니스 도메인 전문성은 주장하지 않습니다. 모르는 깊이는 면접에서 모른다고 말하는 편이 낫습니다. 실제 도입 시 증량·감량 폭과 대체 운동 매핑은 플랜핏 실사용자 분포와 트레이너 기준으로 다시 맞춰야 합니다
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
오늘의 운동 플랜을 세트 단위로 편 표입니다. 화면에 잠긴 카드로 그대로 쌓입니다. 사전에 없는 운동 표기, 목표 무게가 없는 맨몸 운동, 반복 대신 시간으로 재는 운동, 기구가 차 있는 세트를 일부러 섞어 계획이 그대로 굴러가지 않는 경우를 화면이 어떻게 받는지 보여줍니다.
| set_id | plan_id | set_no | exercise_raw | exercise_key | target_weight_kg | target_reps | target_seconds | rest_sec | equipment_id |
|---|---|---|---|---|---|---|---|---|---|
| s_001 | pl_0801 | 1 | 바벨 스쿼트 | barbell_squat | 60 | 10 | — | 120 | rack_a |
| s_002 | pl_0801 | 2 | 바벨 스쿼트 | barbell_squat | 62.5 | 8 | — | 120 | rack_a |
| s_003 | pl_0801 | 3 | 바벨 스쿼트 | barbell_squat | 65 | 6 | — | 150 | rack_a |
| s_004 | pl_0801 | 4 | 벤치 프레스 | bench_press | 45 | 10 | — | 90 | bench_1 |
| s_005 | pl_0801 | 5 | 벤치프레스 | bench_press | 47.5 | 8 | — | 90 | bench_1 |
아래 JSON Schema 에 맞는 오늘의 플랜 세트 행 30건을 JSON 배열로 생성.
{"type":"object","properties":{"set_id":{"type":"string"},"plan_id":{"type":"string"},"set_no":{"type":"integer"},"exercise_raw":{"type":"string"},"exercise_key":{"type":["string","null"]},"target_weight_kg":{"type":["number","null"]},"target_reps":{"type":["integer","null"]},"target_seconds":{"type":["integer","null"]},"rest_sec":{"type":"integer"},"equipment_id":{"type":["string","null"]}},"required":["set_id","plan_id","set_no","exercise_raw","rest_sec"]}
쉬운 데이터만 만들지 말 것. 하드 케이스 혼입: (1) exercise_key 가 null 인 사전 미등록 표기 4건(국문 축약·영문 표기 섞어서, 화면에서 사용자가 직접 매핑해야 하는 대상), (2) target_weight_kg 가 null 인 맨몸 운동 3건, (3) target_reps 대신 target_seconds 를 쓰는 시간 기반 운동 3건, (4) 같은 운동을 exercise_raw 표기만 다르게 적은 행 4건(벤치 프레스/벤치프레스/Bench Press), (5) 같은 equipment_id 를 연속되지 않은 set_no 가 나눠 쓰는 행 4건(기구 점유로 대체 선택이 뜨는 경우). plan_id 는 두세 개로 묶고 set_no 는 plan_id 안에서 1부터 연속. JSON 배열만.{"type":"object","properties":{"set_id":{"type":"string"},"plan_id":{"type":"string"},"set_no":{"type":"integer"},"exercise_raw":{"type":"string"},"exercise_key":{"type":["string","null"]},"target_weight_kg":{"type":["number","null"]},"target_reps":{"type":["integer","null"]},"target_seconds":{"type":["integer","null"]},"rest_sec":{"type":"integer"},"equipment_id":{"type":["string","null"]}},"required":["set_id","plan_id","set_no","exercise_raw","rest_sec"]}사용자가 화면에서 실제로 누른 조작을 시간순으로 남긴 표입니다. 이 시퀀스를 리듀서에 흘리면 세트 상태와 다음 세트 제안 무게가 하나로 정해지므로 골든 테스트의 입력이 됩니다. 완료를 누르지 않고 다음 세트를 열려는 우회 조작과 되돌리기를 섞어, 화면이 잠금을 지키는지 재생만으로 확인합니다.
| event_id | plan_id | set_no | action | value | ts |
|---|---|---|---|---|---|
| e_001 | pl_0801 | 1 | open_set | — | 2026-08-01T19:02:11+09:00 |
| e_002 | pl_0801 | 1 | edit_weight | 57.5 | 2026-08-01T19:05:40+09:00 |
| e_003 | pl_0801 | 1 | complete_set | — | 2026-08-01T19:05:52+09:00 |
| e_004 | pl_0801 | 2 | open_set | — | 2026-08-01T19:08:03+09:00 |
| e_005 | pl_0801 | 2 | edit_reps | 5 | 2026-08-01T19:11:20+09:00 |
아래 JSON Schema 에 맞는 화면 조작 이벤트 40건을 시간순 JSON 배열로 생성.
{"type":"object","properties":{"event_id":{"type":"string"},"plan_id":{"type":"string"},"set_no":{"type":"integer"},"action":{"type":"string","enum":["open_set","edit_weight","edit_reps","mark_pain","request_substitute","pick_substitute","accept_suggestion","complete_set","skip_set","undo"]},"value":{"type":["string","number","null"]},"ts":{"type":"string"}},"required":["event_id","plan_id","set_no","action","ts"]}
쉬운 데이터만 만들지 말 것. 하드 케이스 혼입: (1) 직전 세트에 complete_set 이 없는데 다음 set_no 로 open_set 을 시도하는 우회 시퀀스 5건(전부 차단돼야 함), (2) mark_pain 뒤에 감량안을 고르지 않고 바로 complete_set 을 누르는 시퀀스 3건(차단 대상), (3) complete_set 직후 undo 로 되돌리는 시퀀스 3건, (4) request_substitute 만 하고 pick_substitute 없이 넘어가려는 시퀀스 2건, (5) edit_weight 를 같은 세트에서 세 번 고쳐 마지막 값만 남는 시퀀스 2건, (6) skip_set 으로 건너뛴 뒤 다음 세트 제안이 어떻게 되는지 보는 시퀀스 2건. ts 는 단조 증가, set_no 는 plan_sets 에 있는 값만. JSON 배열만.{"type":"object","properties":{"event_id":{"type":"string"},"plan_id":{"type":"string"},"set_no":{"type":"integer"},"action":{"type":"string","enum":["open_set","edit_weight","edit_reps","mark_pain","request_substitute","pick_substitute","accept_suggestion","complete_set","skip_set","undo"]},"value":{"type":["string","number","null"]},"ts":{"type":"string"}},"required":["event_id","plan_id","set_no","action","ts"]}다음 세트 제안 무게와 잠금 해제 조건을 정하는 규칙표입니다. 화면이 무엇을 자동으로 적용하고 무엇을 사용자에게 물어야 하는지가 여기서 갈리며, 골든 테스트는 이 표로 다시 계산한 값과 화면 상태를 대조합니다. 도메인이 바뀌면 이 표만 갈아끼웁니다.
| rule_id | when_state | condition | delta_kg | delta_pct | unlock_next | requires_user_confirm | note |
|---|---|---|---|---|---|---|---|
| r_001 | met | 실제 반복 >= 목표 반복 이고 통증 표시 없음 | 2.5 | — | true | false | 계획대로 끝냈으면 다음 세트 제안 무게를 2.5kg 올립니다 |
| r_002 | over_reps | 실제 반복 >= 목표 반복 + 3 | 5 | — | true | true | 여유가 크면 5kg 증량을 제안하되 사용자가 받아야 적용합니다 |
| r_003 | under_reps | 목표 반복의 60% 이상 80% 미만 | 0 | — | true | false | 다음 세트는 같은 무게로 유지합니다 |
| r_004 | under_reps | 목표 반복의 60% 미만 | — | -10 | true | true | 10% 감량안을 띄우고 사용자가 받거나 직접 고쳐야 다음 세트가 열립니다 |
| r_005 | under_weight | 실제 무게 < 목표 무게 이고 반복은 채움 | 0 | — | true | false | 다음 세트 기준을 실제로 든 무게로 바꿔 이어갑니다 |
아래 JSON Schema 에 맞는 증량·감량 규칙 행 20건을 JSON 배열로 생성.
{"type":"object","properties":{"rule_id":{"type":"string"},"when_state":{"type":"string","enum":["met","under_reps","over_reps","under_weight","pain","skipped","substituted","unmapped","missing_log"]},"condition":{"type":"string"},"delta_kg":{"type":["number","null"]},"delta_pct":{"type":["number","null"]},"unlock_next":{"type":"boolean"},"requires_user_confirm":{"type":"boolean"},"note":{"type":"string"}},"required":["rule_id","when_state","condition","unlock_next","requires_user_confirm","note"]}
쉬운 데이터만 만들지 말 것. 하드 케이스 혼입: (1) unlock_next 가 false 인 차단 규칙 5건 이상(통증·미등록 표기·빈 입력처럼 사람이 고르기 전에는 다음 세트가 열리지 않는 경우), (2) requires_user_confirm 이 true 인 확인 규칙 6건 이상, (3) when_state=pain 인 규칙은 delta_kg 가 양수이면 안 됨(증량 금지), (4) 같은 when_state 에 조건 구간이 겹치지 않는 규칙 두 개씩(60% 미만 / 60% 이상 80% 미만처럼 경계가 붙되 겹치지 않게), (5) 맨몸·시간 기반 운동처럼 무게 대신 반복이나 시간을 움직이는 규칙 3건. 이 enum 은 도메인이 바뀌면 통째로 갈아끼우는 자리입니다. JSON 배열만.{"type":"object","properties":{"rule_id":{"type":"string"},"when_state":{"type":"string","enum":["met","under_reps","over_reps","under_weight","pain","skipped","substituted","unmapped","missing_log"]},"condition":{"type":"string"},"delta_kg":{"type":["number","null"]},"delta_pct":{"type":["number","null"]},"unlock_next":{"type":"boolean"},"requires_user_confirm":{"type":"boolean"},"note":{"type":"string"}},"required":["rule_id","when_state","condition","unlock_next","requires_user_confirm","note"]}| React Native | 운동 중 화면 본체입니다. 잠긴 세트 카드 스택, 무게·반복 스텝퍼, 통증 표시 토글, 전부 정해야 열리는 완료 버튼을 만듭니다 |
| TypeScript | 세트 상태 여섯 가지와 허용 전이를 타입으로 정의합니다. 완료 이벤트 없이 다음 세트를 여는 전이는 타입 단계에서 존재하지 않습니다 |
| Redux Toolkit | 화면 조작을 액션으로 받아 세트 상태를 바꾸는 리듀서입니다. 화면과 규칙을 나눠 놔서 조작 이벤트 시퀀스만으로 같은 상태를 재생합니다 |
| Swift | 세트 사이 휴식 타이머 네이티브 모듈입니다. 화면이 꺼져도 로컬 알림으로 다음 세트를 알립니다 |
| React | 골든 조작 시퀀스를 브라우저에서 재생해 상태 전이와 제안 무게 변화를 눈으로 보는 웹 재생기입니다 |
| Node.js | 세션 저장 API(`POST /sessions/:id/sets`)입니다. 세트 단위 멱등키로 재전송 중복을 막습니다 |
| Django Rest Framework | 사용자·오늘의 플랜 조회 백엔드입니다 |
| Python | 골든 조작 시퀀스 40개를 규칙표로 재계산해 화면 상태와 대조하는 채점 러너입니다 |
| MySQL, AWS RDS | 플랜·세트 기록·조작 이벤트를 남기는 단일 진실원입니다. 확정되지 않은 세트는 기록 테이블에 존재하지 않습니다 |
| Docker, AWS Elastic Beanstalk | 2주차에 API 를 컨테이너 이미지로 만들어 배포합니다 |
| Firebase | 3주차에 화면 두 변형을 원격 설정으로 나눕니다 |
| Amplitude | 3주차에 세트 단위 이벤트 스펙을 정의해 완료율과 중도 이탈을 셉니다 |
| GitHub Actions | 골든 시퀀스 재생 테스트와 우회 조작 차단 테스트를 PR 마다 돌립니다 |
✓ pass → 그대로 멈춰도 완결입니다. 더 키우고 싶으면 2주차(세션 저장 API 와 배포)로 진행합니다.
✓ pass → 여기서 멈춰도 완결입니다. 더 키우면 3주차(화면 두 변형의 세트 완료율 비교)로 진행합니다.
조작 이벤트 시퀀스를 리듀서에 흘렸을 때 세트 하나가 어떤 상태로 확정됐는지, 다음 세트가 열렸는지, 다음 제안 무게가 어떤 규칙으로 얼마가 됐는지 남기는 기록입니다. 골든 테스트는 이 출력을 규칙표로 다시 계산한 값과 대조합니다.
{"type":"object","properties":{"transition_id":{"type":"string"},"plan_id":{"type":"string"},"set_no":{"type":"integer"},"state":{"type":"string","enum":["locked","open","done","skipped","substituted","blocked"]},"logged_weight_kg":{"type":["number","null"]},"logged_reps":{"type":["integer","null"]},"substituted_exercise":{"type":["string","null"]},"next_set_unlocked":{"type":"boolean"},"next_suggest_weight_kg":{"type":["number","null"]},"rule_id":{"type":["string","null"]},"reason":{"type":"string"}},"required":["transition_id","plan_id","set_no","state","next_set_unlocked","reason"]}[
{
"transition_id": "t_001",
"plan_id": "pl_0801",
"set_no": 1,
"state": "done",
"logged_weight_kg": 57.5,
"logged_reps": 10,
"substituted_exercise": null,
"next_set_unlocked": true,
"next_suggest_weight_kg": 57.5,
"rule_id": "r_005",
"reason": "목표 60kg 보다 낮은 57.5kg 으로 반복을 채워 다음 세트 기준을 실제로 든 무게로 바꿨습니다"
},
{
"transition_id": "t_002",
"plan_id": "pl_0801",
"set_no": 2,
"state": "done",
"logged_weight_kg": 57.5,
"logged_reps": 5,
"substituted_exercise": null,
"next_set_unlocked": true,
"next_suggest_weight_kg": 51.5,
"rule_id": "r_004",
"reason": "목표 8회 중 5회로 60% 아래라 10% 감량안을 띄웠고 사용자가 받았습니다"
},
{
"transition_id": "t_003",
"plan_id": "pl_0801",
"set_no": 4,
"state": "blocked",
"logged_weight_kg": null,
"logged_reps": null,
"substituted_exercise": null,
"next_set_unlocked": false,
"next_suggest_weight_kg": 36,
"rule_id": "r_006",
"reason": "왼쪽 어깨 통증이 표시돼 20% 감량안을 띄웠고, 사용자가 고르기 전이라 다음 세트를 열지 않았습니다"
},
{
"transition_id": "t_004",
"plan_id": "pl_0801",
"set_no": 6,
"state": "substituted",
"logged_weight_kg": 30,
"logged_reps": 12,
"substituted_exercise": "assisted_pull_up",
"next_set_unlocked": true,
"next_suggest_weight_kg": null,
"rule_id": "r_009",
"reason": "기구가 차 있어 사용자가 어시스트 턱걸이를 골랐고 무게 기준이 달라 제안을 내지 않았습니다"
},
{
"transition_id": "t_005",
"plan_id": "pl_0802",
"set_no": 2,
"state": "blocked",
"logged_weight_kg": null,
"logged_reps": null,
"substituted_exercise": null,
"next_set_unlocked": false,
"next_suggest_weight_kg": null,
"rule_id": "r_010",
"reason": "루마니안데드 표기가 사전에 없어 사용자가 운동을 지정하기 전까지 완료 버튼을 열지 않았습니다"
}
][자동 검증] 1. 출력이 스키마를 통과하는가(필수 필드·타입·enum). 2. 이벤트 로그에 그 세트의 complete_set 또는 skip_set 이 없는데 next_set_unlocked 가 true 인 행이 있는가(하나라도 있으면 실패). 3. state 가 blocked 인 행의 next_set_unlocked 가 전부 false 인가. 4. next_suggest_weight_kg 를 rule_id 가 가리키는 규칙으로 다시 계산했을 때 같은 값이 나오는가(제안 무게는 추정이 아니라 규칙표 재계산 값이며, 규칙 밖의 숫자는 통과시키지 않습니다). 5. mark_pain 이 있는 세트에서 next_suggest_weight_kg 가 직전 세트 무게보다 큰 행이 있는가(하나라도 있으면 실패). 6. logged_weight_kg 와 logged_reps 가 그 세트의 마지막 edit 이벤트 값과 일치하는가. 7. state 가 substituted 인 행에 substituted_exercise 가 채워져 있는가. [수동 검증] 1. 잠긴 카드를 손가락으로 밀거나 뒤로가기로 빠져나가 다음 세트를 여는 경로가 있는지 실제 기기에서 클릭으로 확인합니다. 2. reason 문장이 통증 원인이나 의학적 판단을 말하고 있지 않은지 확정 건의 5%를 다시 읽습니다.