From d212451fe0acd689e7a0758c6d9b32696bf341d1 Mon Sep 17 00:00:00 2001 From: shinalok Date: Sun, 19 Jul 2026 10:12:17 +0900 Subject: [PATCH] add habit goal-period difficulty and habit formation research notes Ground the 21-day habit myth in actual research (Lally et al. 2010) and let users pick a target period (21/66/254 days or unlimited) matching that study's easy/median/hard automaticity timelines when creating a habit, with progress shown on the habits list. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- README.md | 2 + app/models/habit.py | 40 ++++++- app/routers/pages.py | 11 +- app/schemas/habit.py | 4 +- app/services/habit_service.py | 2 + app/static/css/style.css | 34 ++++++ app/template_utils.py | 30 ++++- .../partials/_weekday_alarm_fields.html | 15 +++ app/templates/partials/habit_form.html | 2 + app/templates/partials/habit_item.html | 10 ++ habit-formation-research.md | 108 ++++++++++++++++++ .../versions/0007_add_habit_difficulty.py | 31 +++++ tests/test_schemas_habit.py | 17 ++- tests/test_template_utils.py | 56 ++++++++- 15 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 habit-formation-research.md create mode 100644 migrations/versions/0007_add_habit_difficulty.py diff --git a/CLAUDE.md b/CLAUDE.md index dab8667..19b7177 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ pytest tests/test_habits.py::test_name # 단일 테스트 ### 데이터 모델 (`app/models/`) - `User`: `google_sub`/`email` unique, `name`/`picture_url` nullable, 구글 로그인 시 조회/생성(`app/routers/auth.py`의 `google_callback`). -- `Habit`: `user_id`(nullable FK→`user.id`, ondelete=CASCADE — nullable인 이유는 PIN 시절 데이터 이관 때문, 아래 "구글 OAuth 전환" 참고), `habit_type`(build/quit), `status`(active/completed), `weekdays_mask`는 비트마스크(bit0=월…bit6=일, Python `date.weekday()`와 동일한 인덱스, `Habit.is_scheduled_on(weekday)`로 조회), `condition_text`(달성 조건, nullable, 빈 문자열은 `HabitBase.blank_condition_to_none` 검증기가 자동으로 None으로 변환), `reminder_time`은 nullable. +- `Habit`: `user_id`(nullable FK→`user.id`, ondelete=CASCADE — nullable인 이유는 PIN 시절 데이터 이관 때문, 아래 "구글 OAuth 전환" 참고), `habit_type`(build/quit), `status`(active/completed), `weekdays_mask`는 비트마스크(bit0=월…bit6=일, Python `date.weekday()`와 동일한 인덱스, `Habit.is_scheduled_on(weekday)`로 조회), `difficulty`(easy/medium/hard/unlimited — 습관 등록 시 선택하는 목표 기간 난이도, `HABIT_DIFFICULTY_TARGET_DAYS`가 각각 21/66/254일/무제한으로 매핑한다. 이 숫자들은 임의로 정한 게 아니라 Lally et al.(2010, UCL)의 습관 자동화 소요 기간 실증 연구값을 그대로 가져온 것 — 근거는 `habit-formation-research.md` 참고. `Habit.target_days`/`Habit.goal_target_date`가 `created_at` 기준으로 목표 종료일을 계산하고, `template_utils.goal_progress()`가 이를 "D-N"/"N일차" 형태로 `/habits` 목록에 표시한다. 요일과 마찬가지로 과거에 난이도를 바꾼 이력은 추적하지 않는다), `condition_text`(달성 조건, nullable, 빈 문자열은 `HabitBase.blank_condition_to_none` 검증기가 자동으로 None으로 변환), `reminder_time`은 nullable. - `HabitLog`: "행이 존재하면 그 날 체크 완료"라는 설계 — 별도 boolean 컬럼 없음. `(habit_id, log_date)` unique. 체크 해제는 행 삭제. 유저 스코핑은 `Habit`을 조인해서 한다(`log_service.list_logs`). - `PushSubscription`(`user_id` nullable FK 포함), `HabitNotificationLog`: Web Push 구독 정보와 중복 알림 방지용 발송 기록 (5단계 마일스톤에서 실제로 사용 시작). diff --git a/README.md b/README.md index fbcbff0..6bbefe4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다. +참고: 습관이 실제로 어떻게 형성되는지에 대한 연구 정리는 [habit-formation-research.md](./habit-formation-research.md) 참고. + ## 설치 ```bash diff --git a/app/models/habit.py b/app/models/habit.py index e0ca595..e445ae1 100644 --- a/app/models/habit.py +++ b/app/models/habit.py @@ -1,5 +1,5 @@ import enum -from datetime import datetime, time +from datetime import date, datetime, time, timedelta from sqlalchemy import Enum, ForeignKey, Integer, SmallInteger, String, Time from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -21,6 +21,29 @@ class HabitStatus(str, enum.Enum): ABANDONED = "abandoned" +class HabitDifficulty(str, enum.Enum): + """목표 기간 난이도. Lally et al.(2010, UCL)의 습관 자동화 소요 기간 실증 연구를 근거로 한다 + (자세한 내용은 habit-formation-research.md 참고). + - EASY: 기존 습관에 붙이기 쉬운 단순 행동이 자동화되는 데 걸린 기간(약 3주) + - MEDIUM: 전체 참가자의 자동화 소요 기간 중앙값 + - HARD: 노력이 많이 드는 행동이 자동화되는 데 걸린 기간의 상한(연구 관찰 범위 18~254일 중 최대값) + - UNLIMITED: 목표 종료 시점 없이 계속 트래킹만 하는 습관 + """ + + EASY = "easy" + MEDIUM = "medium" + HARD = "hard" + UNLIMITED = "unlimited" + + +HABIT_DIFFICULTY_TARGET_DAYS: dict[HabitDifficulty, int | None] = { + HabitDifficulty.EASY: 21, + HabitDifficulty.MEDIUM: 66, + HabitDifficulty.HARD: 254, + HabitDifficulty.UNLIMITED: None, +} + + class Habit(Base): __tablename__ = "habit" @@ -34,6 +57,9 @@ class Habit(Base): Enum(HabitStatus, native_enum=False, length=20), nullable=False, default=HabitStatus.ACTIVE ) weekdays_mask: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=ALL_WEEKDAYS_MASK) + difficulty: Mapped[HabitDifficulty] = mapped_column( + Enum(HabitDifficulty, native_enum=False, length=20), nullable=False, default=HabitDifficulty.MEDIUM + ) condition_text: Mapped[str | None] = mapped_column(String(300), nullable=True) reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True) sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True) @@ -48,3 +74,15 @@ class Habit(Base): def is_scheduled_on(self, weekday: int) -> bool: """weekday: Python date.weekday() 기준 (월=0 ... 일=6)""" return bool(self.weekdays_mask & (1 << weekday)) + + @property + def target_days(self) -> int | None: + return HABIT_DIFFICULTY_TARGET_DAYS[self.difficulty] + + @property + def goal_target_date(self) -> date | None: + """목표 기간이 끝나는 날짜. UNLIMITED면 None.""" + days = self.target_days + if days is None: + return None + return self.created_at.date() + timedelta(days=days - 1) diff --git a/app/routers/pages.py b/app/routers/pages.py index 7208c71..e7fd266 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -9,12 +9,12 @@ from pydantic import ValidationError from sqlalchemy.orm import Session from app.database import get_db -from app.models.habit import HabitStatus, HabitType +from app.models.habit import HabitDifficulty, HabitStatus, HabitType from app.models.user import User from app.schemas.habit import HabitCreate, HabitUpdate from app.security import get_current_user_optional from app.services import habit_service, log_service -from app.template_utils import heatmap_opacity, is_milestone_streak, weekday_label +from app.template_utils import difficulty_label, goal_progress, heatmap_opacity, is_milestone_streak, weekday_label router = APIRouter() @@ -22,6 +22,9 @@ templates = Jinja2Templates(directory="app/templates") templates.env.globals["weekday_label"] = weekday_label templates.env.globals["heatmap_opacity"] = heatmap_opacity templates.env.globals["is_milestone_streak"] = is_milestone_streak +templates.env.globals["difficulty_label"] = difficulty_label +templates.env.globals["goal_progress"] = goal_progress +templates.env.globals["habit_difficulties"] = list(HabitDifficulty) def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectResponse: @@ -155,6 +158,7 @@ def create_habit_page( name: str = Form(""), habit_type: str = Form(...), weekdays_mask: int = Form(...), + difficulty: str = Form(HabitDifficulty.MEDIUM.value), condition_text: str | None = Form(None), reminder_time: str | None = Form(None), db: Session = Depends(get_db), @@ -169,6 +173,7 @@ def create_habit_page( name=name, habit_type=HabitType(habit_type), weekdays_mask=weekdays_mask, + difficulty=HabitDifficulty(difficulty), condition_text=condition_text, reminder_time=parsed_time, ) @@ -190,6 +195,7 @@ def edit_habit_page( habit_id: int, name: str = Form(""), weekdays_mask: int = Form(...), + difficulty: str = Form(HabitDifficulty.MEDIUM.value), condition_text: str | None = Form(None), reminder_time: str | None = Form(None), db: Session = Depends(get_db), @@ -208,6 +214,7 @@ def edit_habit_page( name=name, habit_type=habit.habit_type, weekdays_mask=weekdays_mask, + difficulty=HabitDifficulty(difficulty), condition_text=condition_text, reminder_time=parsed_time, ) diff --git a/app/schemas/habit.py b/app/schemas/habit.py index dd5c0a6..e44a6a3 100644 --- a/app/schemas/habit.py +++ b/app/schemas/habit.py @@ -2,13 +2,14 @@ from datetime import datetime, time from pydantic import BaseModel, ConfigDict, field_validator -from app.models.habit import ALL_WEEKDAYS_MASK, HabitStatus, HabitType +from app.models.habit import ALL_WEEKDAYS_MASK, HabitDifficulty, HabitStatus, HabitType class HabitBase(BaseModel): name: str habit_type: HabitType weekdays_mask: int = ALL_WEEKDAYS_MASK + difficulty: HabitDifficulty = HabitDifficulty.MEDIUM condition_text: str | None = None reminder_time: time | None = None @@ -52,6 +53,7 @@ class HabitOut(BaseModel): habit_type: HabitType status: HabitStatus weekdays_mask: int + difficulty: HabitDifficulty condition_text: str | None reminder_time: time | None created_at: datetime diff --git a/app/services/habit_service.py b/app/services/habit_service.py index 5fc9895..43763c9 100644 --- a/app/services/habit_service.py +++ b/app/services/habit_service.py @@ -45,6 +45,7 @@ def create_habit(db: Session, user_id: int, data: HabitCreate) -> Habit: name=data.name, habit_type=data.habit_type, weekdays_mask=data.weekdays_mask, + difficulty=data.difficulty, condition_text=data.condition_text, reminder_time=data.reminder_time, status=HabitStatus.ACTIVE, @@ -59,6 +60,7 @@ def update_habit(db: Session, habit: Habit, data: HabitUpdate) -> Habit: habit.name = data.name habit.habit_type = data.habit_type habit.weekdays_mask = data.weekdays_mask + habit.difficulty = data.difficulty habit.condition_text = data.condition_text habit.reminder_time = data.reminder_time db.commit() diff --git a/app/static/css/style.css b/app/static/css/style.css index 09f38d0..1b0e993 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -409,6 +409,40 @@ label { color: var(--color-accent); } +/* 목표 기간 난이도 선택 */ +.difficulty-picker { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.difficulty-pill { + border-radius: 999px; + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-muted); + font-size: 13px; + padding: 8px 14px; + cursor: pointer; +} + +.difficulty-pill.selected { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.difficulty-hint { + font-size: 12px; + color: var(--color-text-muted); + margin-top: 6px; +} + +.goal-progress-badge { + font-size: 12px; + color: var(--color-text-muted); +} + /* 습관 리스트 아이템 */ .habit-item { display: flex; diff --git a/app/template_utils.py b/app/template_utils.py index f37d71e..3d51505 100644 --- a/app/template_utils.py +++ b/app/template_utils.py @@ -1,8 +1,17 @@ -from app.models.habit import ALL_WEEKDAYS_MASK +from datetime import date + +from app.models.habit import ALL_WEEKDAYS_MASK, HABIT_DIFFICULTY_TARGET_DAYS, Habit, HabitDifficulty from app.services.log_service import MILESTONE_STREAKS _DAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"] +_DIFFICULTY_LABELS: dict[HabitDifficulty, str] = { + HabitDifficulty.EASY: "하 (21일)", + HabitDifficulty.MEDIUM: "중 (66일)", + HabitDifficulty.HARD: "상 (254일)", + HabitDifficulty.UNLIMITED: "기간 무제한", +} + def is_milestone_streak(streak: int) -> bool: return streak in MILESTONE_STREAKS @@ -15,6 +24,25 @@ def weekday_label(mask: int) -> str: return ", ".join(days) if days else "선택된 요일 없음" +def difficulty_label(difficulty: HabitDifficulty) -> str: + return _DIFFICULTY_LABELS[difficulty] + + +def goal_progress(habit: Habit) -> dict | None: + """습관의 목표 기간 진행 상황. 기간 무제한(UNLIMITED)이면 None.""" + target_days = HABIT_DIFFICULTY_TARGET_DAYS[habit.difficulty] + if target_days is None: + return None + elapsed = (date.today() - habit.created_at.date()).days + 1 # 시작일을 1일차로 계산 + elapsed = max(elapsed, 1) + return { + "elapsed": min(elapsed, target_days), + "target": target_days, + "remaining": max(target_days - elapsed, 0), + "reached": elapsed >= target_days, + } + + def heatmap_opacity(checked_count: int, scheduled_count: int) -> float: """월별 캘린더 히트맵 셀의 배경 투명도(0~0.9)를 계산한다.""" if not scheduled_count: diff --git a/app/templates/partials/_weekday_alarm_fields.html b/app/templates/partials/_weekday_alarm_fields.html index b3739c1..cd1f27a 100644 --- a/app/templates/partials/_weekday_alarm_fields.html +++ b/app/templates/partials/_weekday_alarm_fields.html @@ -26,3 +26,18 @@ + +
+ +
+ {% for d in habit_difficulties %} + + {% endfor %} +
+

습관 자동화까지 걸리는 기간에 대한 실증 연구(Lally et al., 2010, UCL) 기준입니다 — 하는 단순한 습관이 자동화되는 데 걸리는 기간(약 3주), 중은 전체 중앙값, 상은 노력이 많이 드는 습관의 상한(약 8개월)입니다.

+
diff --git a/app/templates/partials/habit_form.html b/app/templates/partials/habit_form.html index 601dbbe..c1941ee 100644 --- a/app/templates/partials/habit_form.html +++ b/app/templates/partials/habit_form.html @@ -3,6 +3,7 @@ x-data="{ open: false, mask: 127, + difficulty: 'medium', alarmOn: false, days: ['월', '화', '수', '목', '금', '토', '일'], }" @@ -22,6 +23,7 @@ > +
diff --git a/app/templates/partials/habit_item.html b/app/templates/partials/habit_item.html index 1a85ac1..e7bdafd 100644 --- a/app/templates/partials/habit_item.html +++ b/app/templates/partials/habit_item.html @@ -3,6 +3,7 @@ x-data="{ editing: false, mask: {{ habit.weekdays_mask }}, + difficulty: '{{ habit.difficulty.value }}', alarmOn: {{ 'true' if habit.reminder_time else 'false' }}, days: ['월', '화', '수', '목', '금', '토', '일'], }" @@ -17,6 +18,14 @@ {{ weekday_label(habit.weekdays_mask) }} {% if habit.reminder_time %}· 알람 {{ habit.reminder_time.strftime("%H:%M") }}{% endif %}
+ {% set progress = goal_progress(habit) %} +
+ {% if progress %} + 목표 {{ difficulty_label(habit.difficulty) }} · {{ progress.elapsed }}/{{ progress.target }}일차{% if progress.reached %} · 목표 기간 달성!{% else %} (D-{{ progress.remaining }}){% endif %} + {% else %} + {{ difficulty_label(habit.difficulty) }} + {% endif %} +
{% set stats = stats_map[habit.id] %} {% if stats.scheduled_days > 0 %}
@@ -78,6 +87,7 @@ hx-swap="innerHTML" > +
diff --git a/habit-formation-research.md b/habit-formation-research.md new file mode 100644 index 0000000..e75d696 --- /dev/null +++ b/habit-formation-research.md @@ -0,0 +1,108 @@ +# 습관은 어떻게 형성되는가 — 연구 자료 정리 + +심리학·신경과학 연구와 대중서를 바탕으로 "습관이 만들어지는 과정"을 정리한 문서. `habit-tracker` 앱의 기능(연속 달성일, 완료율, 요일 스케줄, 알림 등)을 설계/보완할 때 참고할 수 있도록 실제 연구 결과 위주로 작성했다. + +## 1. 습관의 정의 + +심리학에서 습관(habit)은 **특정 맥락(context)에서 반복적으로 수행되어 자동화된 행동**으로 정의된다. 핵심은 "의식적 의도 없이도 촉발되는 행동"이라는 점이다. Wendy Wood(USC)의 연구에 따르면 하루 행동의 약 43%가 의도적 결정이 아니라 습관에 의해 이루어지며, Duke University의 2006년 연구는 이 수치를 40% 이상으로 추산했다. + +## 2. 습관 루프(Habit Loop) — Charles Duhigg + +Charles Duhigg는 *The Power of Habit*(2012)에서 신경과학 연구(MIT 쥐 미로 실험 등)를 근거로 습관을 3단계 순환 구조로 설명했다. + +1. **신호(Cue)** — 뇌에게 "자동 모드로 전환하라"고 알리는 트리거. 특정 시간, 장소, 감정 상태, 앞선 행동, 주변 사람 등이 신호가 될 수 있다. +2. **반복 행동(Routine)** — 실제 수행되는 행동 자체(신체적/정신적/감정적 모두 가능). +3. **보상(Reward)** — 행동 완료 후 뇌가 얻는 만족감. 뇌는 이 보상을 신호와 연결지어 "이 신호가 오면 이 루프를 반복할 가치가 있다"고 학습한다. + +쥐 미로 실험에서는 반복이 충분히 누적되면 미로를 달리는 중간 과정의 뇌 활동은 줄고 **신호와 보상 시점에서만 활동이 튄다** — 즉 뇌가 전체 행동 시퀀스를 하나의 자동화된 덩어리(chunk)로 묶어버린다는 것을 보여준다. 이 저장·실행을 담당하는 것이 대뇌 기저핵(basal ganglia)이며, 그 결과 전전두피질(prefrontal cortex, 의식적 사고 담당)은 다른 일에 자원을 쓸 수 있게 된다. + +이 루프가 반복되면서 신호만 봐도 보상을 기대하는 **갈망(craving)**이 형성되고, 이 갈망이 루프를 계속 돌리는 동력이 된다. + +## 3. 신경과학적 기반 — 기저핵과 도파민 + +- 습관 형성은 **배측 선조체(dorsal striatum)** 및 그와 연결된 피질 회로에서 일어난다. 학습 초기에는 연합 선조체(associative striatum)가, 자동화가 진행된 후에는 감각운동 선조체(sensorimotor striatum)가 더 활성화된다 — 즉 "무엇을 할지 고민하는 뇌"에서 "몸이 알아서 하는 뇌"로 활동 부위 자체가 이동한다. +- 보상이 주어질 때 분비되는 **도파민**이 해당 행동을 강화하는 시냅스 가소성(synaptic plasticity)을 일으킨다. 보상을 얻은 행동에 대해 "Go 경로"는 강해지고 "No-Go 경로"는 약해지는 과정이 수백 번 반복되면 행동이 거의 자동으로 발화하는 수준에 도달한다. +- 이 과정은 강화학습의 **예측 오차(reward prediction error)** 신호로 설명된다 — 실제 보상이 예상보다 크면 도파민이 늘고, 예상대로면 도파민 반응이 줄어드는 식으로 학습이 진행된다. 습관 시스템과 목표지향 시스템(goal-directed system)은 서로 다른 도파민 뉴런 집단이 인코딩하는 별개의 예측 오차를 사용한다는 것이 최근 연구의 핵심 발견이다. + +## 4. 습관이 자동화되기까지 걸리는 시간 — Lally et al. (2010) + +흔히 "습관 형성에 21일이 걸린다"고 알려져 있지만, 이는 근거가 약한 대중적 통설이다. 실제 실증 연구는 다음과 같다. + +- **Phillippa Lally 외 (UCL), *European Journal of Social Psychology*, 2010**, "How are habits formed: Modelling habit formation in the real world" + - 참가자 96명이 각자 새로 만들고 싶은 습관(음식/음료/활동)을 선택, 84일간 매일 수행 여부와 "이 행동이 얼마나 자동적으로 느껴지는가"(Self-Report Habit Index)를 기록. + - **자동성이 95% 수준(점근값)에 도달하는 데 걸린 시간의 중앙값은 66일**이었으나, 개인차가 매우 커서 **범위는 18일~254일**이었다. + - 단순한 행동(아침식사와 함께 물 마시기, 양치 후 비타민 먹기처럼 기존 습관에 붙이기 쉬운 것)은 약 3주 만에 자동화된 반면, 더 노력이 필요한 행동(아침 식전 윗몸일으키기 50회, 저녁식사 후 15분 걷기)은 2~8개월이 걸렸다. + - 대중적으로 인용될 때 "48%만 성공적으로 자동성에 도달했고 그중에서도 단순한 행동 위주로 중앙값 66일이었다"는 전제가 종종 생략된다는 점에 주의할 것. + +## 5. 맥락(context)과 반복의 역할 — Wendy Wood + +Wendy Wood(*Good Habits, Bad Habits*, 2019)의 30년치 연구 종합에 따르면: + +- 습관 시스템은 특정 **맥락(장소, 시간, 앞선 행동, 감정 상태 등)과 행동을 연합**시키는 방식으로 작동한다. 같은 행동이라도 맥락이 바뀌면(예: 집 소파 vs. 남의 집 소파) 습관적으로 촉발되지 않는다 — 이것이 "환경을 바꾸면 습관이 쉽게 깨지거나 새로 만들어지는" 이유다. +- 같은 맥락에서 같은 행동을 반복할수록 맥락-행동 신경 연결이 강화된다. 연합이 형성된 이후에도 그 연결이 약한 상태에서 강한 자동화 상태로 가려면 추가 반복이 필요하다. +- 시사점: 습관을 만들려면 "의지력"보다 **일관된 맥락(같은 시간, 같은 장소, 같은 선행 행동)** 을 고정하는 것이 훨씬 효과적이다. + +## 6. 의도적으로 습관을 설계하는 기법 + +### 6.1 실행 의도 (Implementation Intentions) — Peter Gollwitzer + +- Gollwitzer(1999)가 제안한 **"if-then" 계획**: "만약 X 상황이 오면, 나는 Y 행동을 한다"처럼 구체적인 상황(when/where)과 행동을 미리 연결해두는 것. +- Gollwitzer & Sheeran(2006)의 메타분석(94개 독립 연구)에서 평균 효과크기 **d = 0.65**(심리학 연구 기준 중간~큰 효과)로 목표 달성률을 유의미하게 높이는 것으로 나타났다. +- if-then 계획이 만드는 상황-반응 연합은 습관의 신호-반응 연합과 구조적으로 유사하다 — 차이는 **의식적으로 설계된 습관**이라는 점. + +### 6.2 작은 습관 (Tiny Habits) — BJ Fogg + +- Stanford의 BJ Fogg는 행동을 `B = MAP`(Behavior = Motivation × Ability × Prompt)로 모델링 — 동기가 낮아도 **행동을 극단적으로 쉽게(Ability↑) 만들고 명확한 신호(Prompt)를 주면** 행동이 일어난다는 것. +- 그의 "anchor–behavior–celebration" 패턴: 이미 확립된 행동(anchor) 뒤에 새 행동을 붙이고, 완료 즉시 스스로를 축하(celebration)해 긍정 정서 보상을 즉각 연결한다. + +### 6.3 습관 쌓기 (Habit Stacking) & 4가지 행동 변화 법칙 — James Clear + +- James Clear(*Atomic Habits*, 2018)는 Duhigg의 신호-루틴-보상 루프를 **신호(Cue) → 갈망(Craving) → 반응(Response) → 보상(Reward)** 4단계로 재구성하고, 각 단계에 대응하는 "행동 변화의 4가지 법칙"을 제시했다. + 1. 신호를 명확하게 만들어라 (Make it obvious) + 2. 매력적으로 만들어라 (Make it attractive) + 3. 쉽게 만들어라 (Make it easy) + 4. 만족스럽게 만들어라 (Make it satisfying) +- "습관 쌓기(habit stacking)"는 Fogg의 anchor-behavior 패턴을 대중화한 이름으로, "기존 습관 뒤에 새 습관을 붙인다"(예: "커피를 내린 직후, 나는 5분 명상을 한다")는 실행 의도 기법의 응용이다. + +## 7. 종합 — 습관 형성의 공통 요소 + +여러 연구/이론을 종합하면 습관이 형성되려면 다음 요소가 반복적으로 함께 작동해야 한다. + +| 요소 | 역할 | 관련 연구 | +|---|---|---| +| 안정된 신호(맥락) | 언제/어디서 행동할지 트리거 | Wood(맥락 의존성), Gollwitzer(if-then) | +| 낮은 실행 난이도 | 의지력 없이도 실행 가능해야 반복이 유지됨 | Fogg(B=MAP) | +| 즉각적 보상/만족 | 도파민 기반 강화가 이루어져야 자동화가 진행됨 | Duhigg(습관 루프), 기저핵/도파민 신경과학 | +| 충분한 반복 횟수·기간 | 자동성은 점진적으로 증가하며 개인·행동 난이도에 따라 18~254일 소요 | Lally et al.(2010) | +| 행동의 단순성 | 단순한 행동일수록 빠르게 자동화됨 | Lally et al.(2010) | + +## 8. habit-tracker 앱 설계에 대한 시사점 + +- **목표 기간 난이도(difficulty)**: 습관 등록 시 하(21일)/중(66일)/상(254일)/기간무제한 중 선택하도록 구현했다(`app/models/habit.py`의 `HabitDifficulty`, `HABIT_DIFFICULTY_TARGET_DAYS`). 21일이라는 대중적 통설 대신 Lally et al.(2010)의 실제 데이터 구간을 그대로 옵션화했다 — 하는 "단순 행동이 자동화되는 데 걸린 기간"(3주), 중은 "전체 참가자 중앙값"(66일), 상은 "노력이 많이 드는 행동의 관찰 범위 상한"(254일), 기간무제한은 종료 시점을 두지 않고 계속 트래킹만 하는 습관(예: 금연처럼 "언제 끝난다"고 말하기 애매한 습관)을 위한 선택지다. `/habits` 목록에서 습관 생성일 기준 경과일과 목표까지 남은 D-day를 함께 보여준다. +- **연속 달성일(streak)**: Lally 연구가 시사하듯 자동화는 "끊김 없는 반복"에서 나온다. 현재 앱의 `current_streak` 로직(오늘 미체크는 스트릭을 끊지 않음)은 하루 단위의 유연성을 주면서도 반복의 연속성을 시각화한다는 점에서 연구와 방향이 맞는다. +- **요일 스케줄(weekdays_mask)**: Wood의 맥락 의존성 연구를 고려하면, 습관은 "매일"보다 "특정 요일/맥락"에 고정할 때 오히려 더 잘 자리잡을 수 있다 — 무리하게 매일로 설정하기보다 실제로 지킬 수 있는 요일부터 시작하는 것이 실행 의도(if-then) 관점에서도 유리하다. +- **알림(reminder_time)**: Gollwitzer의 if-then 계획과 유사하게, 고정된 시각 알림은 "언제"라는 신호를 명확히 제공해 습관 형성을 돕는 역할을 한다. +- **조건 텍스트(condition_text)**: "달성 조건"을 구체적으로 적어두게 하는 것은 Fogg의 "행동을 명확하고 작게 만들기" 원칙과 맞닿아 있다 — 모호한 목표보다 구체적인 완료 기준이 실행률을 높인다. +- **완료율/통계 배지**: 즉각적인 시각적 피드백(체크 후 배지 갱신)은 Duhigg의 "보상" 단계를 앱 UI 차원에서 대체/보강하는 역할로 볼 수 있다. + +## 참고 자료 + +- [How are habits formed: Modelling habit formation in the real world (Wiley, Lally et al., 2010)](https://onlinelibrary.wiley.com/doi/abs/10.1002/ejsp.674) +- [How long does it take to form a habit? — UCL News](https://www.ucl.ac.uk/news/2009/aug/how-long-does-it-take-form-habit) +- [How Long Does It Take to Form a Habit? What Lally et al. Actually Found](https://www.thebehavioralscientist.com/articles/how-long-to-form-a-habit) +- [Cortical and basal ganglia contributions to habit learning and automaticity (PMC)](https://pmc.ncbi.nlm.nih.gov/articles/PMC2862890/) +- [Neurobiology of habit formation (ScienceDirect)](https://www.sciencedirect.com/science/article/abs/pii/S235215461730089X) +- [Dorsal Striatal Circuits for Habits, Compulsions and Addictions (Frontiers)](https://www.frontiersin.org/journals/systems-neuroscience/articles/10.3389/fnsys.2019.00028/full) +- [Implementation intention — Wikipedia](https://en.wikipedia.org/wiki/Implementation_intention) +- [Implementation Intentions (Gollwitzer, NCI DCCPS)](https://cancercontrol.cancer.gov/brp/research/constructs/implementation-intentions) +- [Good Habits, Bad Habits by Wendy Wood — Book Overview](https://www.shortform.com/blog/good-habits-bad-habits-book/) +- [Atomic Habits Summary by James Clear](https://jamesclear.com/atomic-habits-summary) +- [How To Start New Habits That Actually Stick — James Clear](https://jamesclear.com/three-steps-habit-change) +- [The Habit Loop: Cue, Routine, Reward Explained by Science](https://successodysseyhub.com/blog/habit-loop-explained) + +### 원전 서적 (직접 인용 시 참고) + +- Charles Duhigg, *The Power of Habit: Why We Do What We Do in Life and Business* (2012) +- Wendy Wood, *Good Habits, Bad Habits: The Science of Making Positive Changes That Stick* (2019) +- James Clear, *Atomic Habits: An Easy & Proven Way to Build Good Habits & Break Bad Ones* (2018) +- BJ Fogg, *Tiny Habits: The Small Changes That Change Everything* (2019) diff --git a/migrations/versions/0007_add_habit_difficulty.py b/migrations/versions/0007_add_habit_difficulty.py new file mode 100644 index 0000000..959a145 --- /dev/null +++ b/migrations/versions/0007_add_habit_difficulty.py @@ -0,0 +1,31 @@ +"""add difficulty (target period) to habit + +Revision ID: 0007_add_habit_difficulty +Revises: 0006_add_habit_abandoned +Create Date: 2026-07-17 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0007_add_habit_difficulty" +down_revision: Union[str, None] = "0006_add_habit_abandoned" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "habit", + sa.Column("difficulty", sa.String(length=20), nullable=False, server_default="medium"), + ) + op.create_check_constraint( + "ck_habit_difficulty", "habit", "difficulty in ('easy','medium','hard','unlimited')" + ) + + +def downgrade() -> None: + op.drop_constraint("ck_habit_difficulty", "habit", type_="check") + op.drop_column("habit", "difficulty") diff --git a/tests/test_schemas_habit.py b/tests/test_schemas_habit.py index c5bcb6a..4cdcdfd 100644 --- a/tests/test_schemas_habit.py +++ b/tests/test_schemas_habit.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from app.models.habit import ALL_WEEKDAYS_MASK, HabitType +from app.models.habit import ALL_WEEKDAYS_MASK, HabitDifficulty, HabitType from app.schemas.habit import HabitCreate @@ -49,3 +49,18 @@ def test_weekdays_mask_single_day_accepted(): def test_weekdays_mask_defaults_to_all_days(): habit = HabitCreate(**_base_kwargs()) assert habit.weekdays_mask == ALL_WEEKDAYS_MASK + + +def test_difficulty_defaults_to_medium(): + habit = HabitCreate(**_base_kwargs()) + assert habit.difficulty == HabitDifficulty.MEDIUM + + +def test_difficulty_accepts_explicit_value(): + habit = HabitCreate(**_base_kwargs(difficulty=HabitDifficulty.UNLIMITED)) + assert habit.difficulty == HabitDifficulty.UNLIMITED + + +def test_difficulty_rejects_invalid_value(): + with pytest.raises(ValidationError): + HabitCreate(**_base_kwargs(difficulty="impossible")) diff --git a/tests/test_template_utils.py b/tests/test_template_utils.py index c34eb4b..b02b58d 100644 --- a/tests/test_template_utils.py +++ b/tests/test_template_utils.py @@ -1,5 +1,17 @@ -from app.models.habit import ALL_WEEKDAYS_MASK -from app.template_utils import heatmap_opacity, weekday_label +from datetime import datetime, timedelta + +from app.models.habit import ALL_WEEKDAYS_MASK, Habit, HabitDifficulty, HabitType +from app.template_utils import difficulty_label, goal_progress, heatmap_opacity, weekday_label + + +def _habit_created_days_ago(days_ago: int, difficulty: HabitDifficulty) -> Habit: + return Habit( + name="테스트", + habit_type=HabitType.BUILD, + difficulty=difficulty, + weekdays_mask=ALL_WEEKDAYS_MASK, + created_at=datetime.now() - timedelta(days=days_ago), + ) def test_weekday_label_all_days_shows_daily(): @@ -29,3 +41,43 @@ def test_heatmap_opacity_full_ratio_caps_at_point_nine(): def test_heatmap_opacity_partial_ratio(): assert heatmap_opacity(checked_count=1, scheduled_count=2) == round(0.12 + 0.5 * 0.78, 2) + + +def test_difficulty_label_maps_to_target_days(): + assert difficulty_label(HabitDifficulty.EASY) == "하 (21일)" + assert difficulty_label(HabitDifficulty.MEDIUM) == "중 (66일)" + assert difficulty_label(HabitDifficulty.HARD) == "상 (254일)" + assert difficulty_label(HabitDifficulty.UNLIMITED) == "기간 무제한" + + +def test_goal_progress_none_for_unlimited(): + habit = _habit_created_days_ago(10, HabitDifficulty.UNLIMITED) + assert goal_progress(habit) is None + + +def test_goal_progress_day_one_on_creation_day(): + habit = _habit_created_days_ago(0, HabitDifficulty.EASY) + progress = goal_progress(habit) + assert progress == {"elapsed": 1, "target": 21, "remaining": 20, "reached": False} + + +def test_goal_progress_mid_period(): + habit = _habit_created_days_ago(10, HabitDifficulty.MEDIUM) + progress = goal_progress(habit) + assert progress == {"elapsed": 11, "target": 66, "remaining": 55, "reached": False} + + +def test_goal_progress_reached_caps_elapsed_at_target(): + habit = _habit_created_days_ago(100, HabitDifficulty.EASY) + progress = goal_progress(habit) + assert progress == {"elapsed": 21, "target": 21, "remaining": 0, "reached": True} + + +def test_target_days_and_goal_target_date_on_model(): + habit = _habit_created_days_ago(0, HabitDifficulty.EASY) + assert habit.target_days == 21 + assert habit.goal_target_date == habit.created_at.date() + timedelta(days=20) + + unlimited = _habit_created_days_ago(0, HabitDifficulty.UNLIMITED) + assert unlimited.target_days is None + assert unlimited.goal_target_date is None