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 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:12:17 +09:00
co-authored by Claude Sonnet 5
parent 55918d56d1
commit d212451fe0
15 changed files with 355 additions and 9 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ pytest tests/test_habits.py::test_name # 단일 테스트
### 데이터 모델 (`app/models/`) ### 데이터 모델 (`app/models/`)
- `User`: `google_sub`/`email` unique, `name`/`picture_url` nullable, 구글 로그인 시 조회/생성(`app/routers/auth.py``google_callback`). - `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`). - `HabitLog`: "행이 존재하면 그 날 체크 완료"라는 설계 — 별도 boolean 컬럼 없음. `(habit_id, log_date)` unique. 체크 해제는 행 삭제. 유저 스코핑은 `Habit`을 조인해서 한다(`log_service.list_logs`).
- `PushSubscription`(`user_id` nullable FK 포함), `HabitNotificationLog`: Web Push 구독 정보와 중복 알림 방지용 발송 기록 (5단계 마일스톤에서 실제로 사용 시작). - `PushSubscription`(`user_id` nullable FK 포함), `HabitNotificationLog`: Web Push 구독 정보와 중복 알림 방지용 발송 기록 (5단계 마일스톤에서 실제로 사용 시작).
+2
View File
@@ -2,6 +2,8 @@
개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다. 개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다.
참고: 습관이 실제로 어떻게 형성되는지에 대한 연구 정리는 [habit-formation-research.md](./habit-formation-research.md) 참고.
## 설치 ## 설치
```bash ```bash
+39 -1
View File
@@ -1,5 +1,5 @@
import enum 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 import Enum, ForeignKey, Integer, SmallInteger, String, Time
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -21,6 +21,29 @@ class HabitStatus(str, enum.Enum):
ABANDONED = "abandoned" 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): class Habit(Base):
__tablename__ = "habit" __tablename__ = "habit"
@@ -34,6 +57,9 @@ class Habit(Base):
Enum(HabitStatus, native_enum=False, length=20), nullable=False, default=HabitStatus.ACTIVE 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) 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) condition_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True) reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True)
sort_order: Mapped[int | None] = mapped_column(Integer, 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: def is_scheduled_on(self, weekday: int) -> bool:
"""weekday: Python date.weekday() 기준 (월=0 ... 일=6)""" """weekday: Python date.weekday() 기준 (월=0 ... 일=6)"""
return bool(self.weekdays_mask & (1 << weekday)) 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)
+9 -2
View File
@@ -9,12 +9,12 @@ from pydantic import ValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db 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.models.user import User
from app.schemas.habit import HabitCreate, HabitUpdate from app.schemas.habit import HabitCreate, HabitUpdate
from app.security import get_current_user_optional from app.security import get_current_user_optional
from app.services import habit_service, log_service 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() router = APIRouter()
@@ -22,6 +22,9 @@ templates = Jinja2Templates(directory="app/templates")
templates.env.globals["weekday_label"] = weekday_label templates.env.globals["weekday_label"] = weekday_label
templates.env.globals["heatmap_opacity"] = heatmap_opacity templates.env.globals["heatmap_opacity"] = heatmap_opacity
templates.env.globals["is_milestone_streak"] = is_milestone_streak 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: def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectResponse:
@@ -155,6 +158,7 @@ def create_habit_page(
name: str = Form(""), name: str = Form(""),
habit_type: str = Form(...), habit_type: str = Form(...),
weekdays_mask: int = Form(...), weekdays_mask: int = Form(...),
difficulty: str = Form(HabitDifficulty.MEDIUM.value),
condition_text: str | None = Form(None), condition_text: str | None = Form(None),
reminder_time: str | None = Form(None), reminder_time: str | None = Form(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -169,6 +173,7 @@ def create_habit_page(
name=name, name=name,
habit_type=HabitType(habit_type), habit_type=HabitType(habit_type),
weekdays_mask=weekdays_mask, weekdays_mask=weekdays_mask,
difficulty=HabitDifficulty(difficulty),
condition_text=condition_text, condition_text=condition_text,
reminder_time=parsed_time, reminder_time=parsed_time,
) )
@@ -190,6 +195,7 @@ def edit_habit_page(
habit_id: int, habit_id: int,
name: str = Form(""), name: str = Form(""),
weekdays_mask: int = Form(...), weekdays_mask: int = Form(...),
difficulty: str = Form(HabitDifficulty.MEDIUM.value),
condition_text: str | None = Form(None), condition_text: str | None = Form(None),
reminder_time: str | None = Form(None), reminder_time: str | None = Form(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -208,6 +214,7 @@ def edit_habit_page(
name=name, name=name,
habit_type=habit.habit_type, habit_type=habit.habit_type,
weekdays_mask=weekdays_mask, weekdays_mask=weekdays_mask,
difficulty=HabitDifficulty(difficulty),
condition_text=condition_text, condition_text=condition_text,
reminder_time=parsed_time, reminder_time=parsed_time,
) )
+3 -1
View File
@@ -2,13 +2,14 @@ from datetime import datetime, time
from pydantic import BaseModel, ConfigDict, field_validator 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): class HabitBase(BaseModel):
name: str name: str
habit_type: HabitType habit_type: HabitType
weekdays_mask: int = ALL_WEEKDAYS_MASK weekdays_mask: int = ALL_WEEKDAYS_MASK
difficulty: HabitDifficulty = HabitDifficulty.MEDIUM
condition_text: str | None = None condition_text: str | None = None
reminder_time: time | None = None reminder_time: time | None = None
@@ -52,6 +53,7 @@ class HabitOut(BaseModel):
habit_type: HabitType habit_type: HabitType
status: HabitStatus status: HabitStatus
weekdays_mask: int weekdays_mask: int
difficulty: HabitDifficulty
condition_text: str | None condition_text: str | None
reminder_time: time | None reminder_time: time | None
created_at: datetime created_at: datetime
+2
View File
@@ -45,6 +45,7 @@ def create_habit(db: Session, user_id: int, data: HabitCreate) -> Habit:
name=data.name, name=data.name,
habit_type=data.habit_type, habit_type=data.habit_type,
weekdays_mask=data.weekdays_mask, weekdays_mask=data.weekdays_mask,
difficulty=data.difficulty,
condition_text=data.condition_text, condition_text=data.condition_text,
reminder_time=data.reminder_time, reminder_time=data.reminder_time,
status=HabitStatus.ACTIVE, status=HabitStatus.ACTIVE,
@@ -59,6 +60,7 @@ def update_habit(db: Session, habit: Habit, data: HabitUpdate) -> Habit:
habit.name = data.name habit.name = data.name
habit.habit_type = data.habit_type habit.habit_type = data.habit_type
habit.weekdays_mask = data.weekdays_mask habit.weekdays_mask = data.weekdays_mask
habit.difficulty = data.difficulty
habit.condition_text = data.condition_text habit.condition_text = data.condition_text
habit.reminder_time = data.reminder_time habit.reminder_time = data.reminder_time
db.commit() db.commit()
+34
View File
@@ -409,6 +409,40 @@ label {
color: var(--color-accent); 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 { .habit-item {
display: flex; display: flex;
+29 -1
View File
@@ -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 from app.services.log_service import MILESTONE_STREAKS
_DAY_LABELS = ["", "", "", "", "", "", ""] _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: def is_milestone_streak(streak: int) -> bool:
return streak in MILESTONE_STREAKS return streak in MILESTONE_STREAKS
@@ -15,6 +24,25 @@ def weekday_label(mask: int) -> str:
return ", ".join(days) if days else "선택된 요일 없음" 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: def heatmap_opacity(checked_count: int, scheduled_count: int) -> float:
"""월별 캘린더 히트맵 셀의 배경 투명도(0~0.9)를 계산한다.""" """월별 캘린더 히트맵 셀의 배경 투명도(0~0.9)를 계산한다."""
if not scheduled_count: if not scheduled_count:
@@ -26,3 +26,18 @@
</label> </label>
<input x-show="alarmOn" x-cloak type="time" name="reminder_time" value="{{ reminder_value|default('') }}" style="margin-top:6px;" /> <input x-show="alarmOn" x-cloak type="time" name="reminder_time" value="{{ reminder_value|default('') }}" style="margin-top:6px;" />
</div> </div>
<div class="field">
<label>목표 기간 난이도</label>
<div class="difficulty-picker">
{% for d in habit_difficulties %}
<button
type="button"
class="difficulty-pill"
:class="{ selected: difficulty === '{{ d.value }}' }"
@click="difficulty = '{{ d.value }}'"
>{{ difficulty_label(d) }}</button>
{% endfor %}
</div>
<p class="difficulty-hint">습관 자동화까지 걸리는 기간에 대한 실증 연구(Lally et al., 2010, UCL) 기준입니다 — 하는 단순한 습관이 자동화되는 데 걸리는 기간(약 3주), 중은 전체 중앙값, 상은 노력이 많이 드는 습관의 상한(약 8개월)입니다.</p>
</div>
+2
View File
@@ -3,6 +3,7 @@
x-data="{ x-data="{
open: false, open: false,
mask: 127, mask: 127,
difficulty: 'medium',
alarmOn: false, alarmOn: false,
days: ['월', '화', '수', '목', '금', '토', '일'], days: ['월', '화', '수', '목', '금', '토', '일'],
}" }"
@@ -22,6 +23,7 @@
> >
<input type="hidden" name="habit_type" value="{{ habit_type }}" /> <input type="hidden" name="habit_type" value="{{ habit_type }}" />
<input type="hidden" name="weekdays_mask" :value="mask" /> <input type="hidden" name="weekdays_mask" :value="mask" />
<input type="hidden" name="difficulty" :value="difficulty" />
<div class="field"> <div class="field">
<label for="name-{{ habit_type }}">습관 이름</label> <label for="name-{{ habit_type }}">습관 이름</label>
+10
View File
@@ -3,6 +3,7 @@
x-data="{ x-data="{
editing: false, editing: false,
mask: {{ habit.weekdays_mask }}, mask: {{ habit.weekdays_mask }},
difficulty: '{{ habit.difficulty.value }}',
alarmOn: {{ 'true' if habit.reminder_time else 'false' }}, alarmOn: {{ 'true' if habit.reminder_time else 'false' }},
days: ['월', '화', '수', '목', '금', '토', '일'], days: ['월', '화', '수', '목', '금', '토', '일'],
}" }"
@@ -17,6 +18,14 @@
{{ weekday_label(habit.weekdays_mask) }} {{ weekday_label(habit.weekdays_mask) }}
{% if habit.reminder_time %}· 알람 {{ habit.reminder_time.strftime("%H:%M") }}{% endif %} {% if habit.reminder_time %}· 알람 {{ habit.reminder_time.strftime("%H:%M") }}{% endif %}
</div> </div>
{% set progress = goal_progress(habit) %}
<div class="goal-progress-badge">
{% if progress %}
목표 {{ difficulty_label(habit.difficulty) }} · {{ progress.elapsed }}/{{ progress.target }}일차{% if progress.reached %} · 목표 기간 달성!{% else %} (D-{{ progress.remaining }}){% endif %}
{% else %}
{{ difficulty_label(habit.difficulty) }}
{% endif %}
</div>
{% set stats = stats_map[habit.id] %} {% set stats = stats_map[habit.id] %}
{% if stats.scheduled_days > 0 %} {% if stats.scheduled_days > 0 %}
<div class="habit-item-stats"> <div class="habit-item-stats">
@@ -78,6 +87,7 @@
hx-swap="innerHTML" hx-swap="innerHTML"
> >
<input type="hidden" name="weekdays_mask" :value="mask" /> <input type="hidden" name="weekdays_mask" :value="mask" />
<input type="hidden" name="difficulty" :value="difficulty" />
<div class="field"> <div class="field">
<label for="edit-name-{{ habit.id }}">습관 이름</label> <label for="edit-name-{{ habit.id }}">습관 이름</label>
+108
View File
@@ -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)를 주면** 행동이 일어난다는 것.
- 그의 "anchorbehaviorcelebration" 패턴: 이미 확립된 행동(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)
@@ -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")
+16 -1
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from pydantic import ValidationError 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 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(): def test_weekdays_mask_defaults_to_all_days():
habit = HabitCreate(**_base_kwargs()) habit = HabitCreate(**_base_kwargs())
assert habit.weekdays_mask == ALL_WEEKDAYS_MASK 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"))
+54 -2
View File
@@ -1,5 +1,17 @@
from app.models.habit import ALL_WEEKDAYS_MASK from datetime import datetime, timedelta
from app.template_utils import heatmap_opacity, weekday_label
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(): 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(): def test_heatmap_opacity_partial_ratio():
assert heatmap_opacity(checked_count=1, scheduled_count=2) == round(0.12 + 0.5 * 0.78, 2) 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