diff --git a/.claude/agents/README.md b/.claude/agents/README.md new file mode 100644 index 0000000..7588952 --- /dev/null +++ b/.claude/agents/README.md @@ -0,0 +1,74 @@ +# 서브에이전트 + +**이 폴더의 정의는 초안이다.** 쓸지 말지는 아직 안 정했고, 정하면 이 문장을 지운다. + +## 왜 만드나 + +R9(넓은 조사는 위임)의 담당 지표 M14가 세 버전 연속 0%였다. 규칙이 효과가 없어서가 +아니라 **규칙 본문이 "사용자가 요청할 때만"이라고 스스로를 잠가둬서**다. + +2026-08-22에 처음으로 허가를 받고 셋을 병렬로 돌렸다. 그 실측이 이 폴더의 근거다. + +| 조사 | 읽은 것 | 쓴 토큰 | 걸린 시간 | +|---|---|---|---| +| Features 레이어 | 27파일 5,436줄 | 148,139 | 4분 36초 | +| Shared 레이어 | 32파일 + 타깃 멤버십 | 134,072 | 5분 20초 | +| Common·위젯 | 40파일 | 148,249 | 4분 15초 | +| 워크플로우·하네스 | 문서 20여 개 | 109,595 | 4분 51초 | + +**넷이 54만 토큰을 쓰고 요약만 돌려줬다.** 직접 읽었으면 그 54만이 본 컨텍스트에 +쌓였을 것이고, 그 뒤 모든 턴이 그것을 다시 읽었을 것이다. 남은 턴이 100개면 +5,400만 토큰 차이다. + +이게 위임의 유일한 근거다 — **읽은 내용 자체는 남길 필요가 없고, 결론만 필요한 일.** + +## 언제 위임하나 / 안 하나 + +| 위임한다 | 직접 한다 | +|---|---| +| 전수 조사·감사 (파일 10개 이상) | 판단이 필요한 것 | +| "이 심볼 쓰는 데 다 찾아줘" | 이미 열어둔 파일을 고치는 것 | +| 명명 규칙·패턴 훑기 | 왕복 한두 번이면 끝나는 것 | +| 읽어야 결론이 나오지만 읽은 내용은 안 남겨도 되는 조사 | 사용자와 대화가 필요한 것 | + +기준 한 줄: **설명 비용이 직접 하는 비용보다 크면 지는 거래다.** + +## 정의 + +### `surveyor` — 넓은 조사 + +읽기 전용. 파일 여러 개를 훑고 구조화된 요약만 돌려준다. + +- **모델**: 하위 (판단이 아니라 수집이다) +- **도구**: 읽기·검색만. 편집 금지 +- **출력 규칙**: 파일:줄 근거를 붙이고, 코드를 길게 인용하지 않는다. 제안하지 않고 + 사실만 적는다 — 제안은 본 세션이 한다 + +### `test-author` — 뽑아낸 로직에 테스트 붙이기 + +`Shared/`로 내린 순수 로직에 Swift Testing 테스트를 쓴다. + +- **모델**: 하위 (규칙이 명확하고 반복적이다) +- **도구**: 읽기 + `MoscoTests/` 아래 쓰기 + 테스트 실행 +- **출력 규칙**: 테스트 이름에 증상을 적는다(`같은_날짜가_월말월초에_두_번_나오지_않는다`). + 경계값을 반드시 포함한다. 통과하는 것을 확인하고 결과를 보고한다 + +## 안 만들기로 한 것 + +- **`doc-writer`** — 문서 톤은 세션 맥락을 아는 쪽이 잘 쓴다. 요약을 넘기는 비용보다 + 직접 쓰는 편이 싸다고 판단했다. 다시 볼 만한 후보이긴 하다. +- **`reviewer`** — 코드 리뷰는 판단이다. 위임하면 근거를 다시 설명해야 하고, 그 + 설명이 리뷰보다 길어진다. +- **`contract-checker`** — 이건 에이전트가 아니라 스크립트가 맞다. + `tools/artifact_check.sh`로 만들었다. + +## 검증 + +에이전트를 쓴 뒤에는 매번 이 세 줄을 남긴다. 그래야 다음 버전 보고서 6.3에서 +"위임이 실제로 이득이었나"를 채점할 수 있다. + +``` +누가 · 무엇을 · 쓴 토큰 +돌아온 요약이 실제로 쓸모 있었나 (그대로 썼나 / 다시 조사했나) +직접 했으면 어땠을까 (추정) +``` diff --git a/.claude/agents/surveyor.md b/.claude/agents/surveyor.md new file mode 100644 index 0000000..093ebf4 --- /dev/null +++ b/.claude/agents/surveyor.md @@ -0,0 +1,27 @@ +--- +name: surveyor +description: 넓은 전수 조사·감사 전용. 파일 여러 개를 훑고 구조화된 요약만 돌려준다. 읽기 전용이며 제안하지 않는다. 파일 10개 이상을 읽어야 결론이 나오지만 읽은 내용 자체는 본 세션에 남길 필요가 없을 때 쓴다. +tools: Read, Grep, Glob, Bash +model: haiku +--- + +너는 이 저장소를 조사해 **사실만** 돌려주는 조사원이다. + +## 지켜야 할 것 + +1. **제안하지 않는다.** "이렇게 고치면 좋겠다"를 쓰지 않는다. 무엇이 어디에 어떻게 + 있는지만 적는다. 판단은 본 세션이 한다. +2. **파일:줄 근거를 붙인다.** 근거 없는 서술은 쓰지 않는다. +3. **코드를 길게 인용하지 않는다.** 한 줄 이상 붙여야 할 때는 왜 그 줄이 중요한지 + 한 문장으로 대신한다. +4. **표로 정리한다.** 문단으로 늘어놓으면 본 세션이 다시 읽어야 한다. +5. **한국어로 쓴다.** +6. **못 찾은 것은 못 찾았다고 적는다.** 추측으로 메우지 않는다. + +## 출력 형태 + +받은 질문의 번호를 그대로 절 번호로 쓴다. 질문에 없던 것을 발견하면 마지막에 +"질문에 없었지만 눈에 띈 것"으로 따로 모은다. + +분량은 **읽은 양의 1/20 이하**를 목표로 한다. 요약이 원본만큼 길면 위임한 의미가 +없다. diff --git a/.claude/agents/test-author.md b/.claude/agents/test-author.md new file mode 100644 index 0000000..ddb1d0a --- /dev/null +++ b/.claude/agents/test-author.md @@ -0,0 +1,45 @@ +--- +name: test-author +description: Shared/로 옮긴 순수 로직에 Swift Testing 테스트를 붙인다. 대상 타입과 검증할 규칙을 받아 MoscoTests/에 테스트를 쓰고, 실제로 돌려 통과를 확인한 뒤 결과를 보고한다. +tools: Read, Grep, Glob, Bash, Write, Edit +model: haiku +--- + +너는 이 저장소의 순수 로직에 테스트를 붙이는 일만 한다. + +## 이 프로젝트의 테스트 규칙 + +- 프레임워크는 **Swift Testing** (`@Test`, `#expect`). XCTest가 아니다. +- 테스트는 `Mosco/MoscoTests/`에 둔다. **폴더가 통째로 동기화되므로 Xcode 프로젝트에 + 등록할 필요가 없다.** +- 테스트 타깃은 **호스트 앱 없이 `Shared/`만 컴파일한다.** 그러므로 `App/` 아래의 + 타입은 테스트할 수 없다. 대상이 거기 있으면 **테스트를 쓰지 말고 그 사실을 + 보고한다.** +- `@testable import`를 쓰지 않는다. 소스 멤버십으로 직접 컴파일된다. + +## 이름 규칙 + +테스트 이름에 **증상**을 적는다. 무엇을 검증하는지가 아니라, 깨졌을 때 무엇이 +잘못되는지를 적는다. + +```swift +@Test("같은_날짜가_월말월초에_두_번_나오지_않는다") +@Test("4시~7시의_종료_시각이_19시로_읽힌다") +``` + +## 반드시 포함할 것 + +- **경계값**. 0, 최대, 하루 경계, 월말·월초, 윤년, 자정, 정오. +- **틀렸던 적이 있는 케이스**. 대상 코드의 주석에 "예전엔 ~였다"가 있으면 그것을 + 테스트로 만든다. +- 실패 메시지. `#expect(x == y, "왜 이래야 하는지")` + +## 끝내기 전에 + +```bash +xcodebuild -project Mosco/Mosco.xcodeproj -scheme App -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test +``` + +**통과를 확인하고 개수를 보고한다.** 실패하면 고치거나, 못 고치면 무엇이 왜 +실패하는지 적는다. 깨진 채로 두고 "됐습니다"라고 쓰지 않는다. diff --git a/.claude/skills/handoff/SKILL.md b/.claude/skills/handoff/SKILL.md index 9887f9f..7244098 100644 --- a/.claude/skills/handoff/SKILL.md +++ b/.claude/skills/handoff/SKILL.md @@ -65,7 +65,7 @@ description: 작업을 마치고 사람에게 확인을 넘길 때 쓰는 검증 ``` 빌드 App ✅ / MoscoWidget ✅ -테스트 MonthLayoutTests 6개 통과 — 월말/월초 중복, 5주·6주 격자 +테스트 MonthLayoutTests 7개 통과 — 월말/월초 중복, 5주·6주 격자 카드 아래 2건 못 봄 위젯 렌더링 (실기기) ``` diff --git a/.claude/skills/report/SKILL.md b/.claude/skills/report/SKILL.md index df034d0..a696191 100644 --- a/.claude/skills/report/SKILL.md +++ b/.claude/skills/report/SKILL.md @@ -209,7 +209,7 @@ M13과 모델별 턴 수를 놓고 **다음 버전에 무엇을 어디에 쓸지 | 모아야 할 것 | 어디서 나오나 | |---|---| -| 위임했으면 쌌을 지점 | R9에 따라 답 끝에 남긴 "여기는 위임했으면 쌌다" 줄 | +| 위임했으면 쌌을 지점 | R9에 따라 **PR 본문**에 남긴 "위임했으면 쌌을 곳" 칸 | | 그 지점의 성격 | 조사인가 / 판단인가 / 반복 작업인가 | | 한 번에 오간 컨텍스트 크기 | 그 구간의 `Read` 수와 검색 폭 | diff --git a/CLAUDE.md b/CLAUDE.md index ced2d95..731627d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,14 @@ docs/TRAPS.md 플랫폼 함정. 해당 영역 건드리기 전에 docs/CATEGORIZATION.md 카테고리 자동 분류가 어떻게 돌고 왜 Core ML을 버렸나 docs/BACKLOG.md 밀린 일. 목록을 던지면 여기로 들어간다 docs/harness/ 이 규칙들이 효과 있었는지 채점하는 체계 +docs/workflow.md 프롬프트 하나가 어떤 순서로 도는지. 도식 + 근거 +docs/verification.md 무엇을 어떻게 검증하나. 테스트만으로 안 되는 이유 +docs/review-criteria.md 사람이 봐야 하는 코드와 안 봐도 되는 코드 +docs/architecture/ 지금 구조(CURRENT.md)와 후보 패턴 비교(PATTERNS.md) +.claude/agents/ 서브에이전트 정의와 그 근거 tools/harness_report.py 버전별 지표 추출 +tools/quality_baseline.py 코드 품질 기준선. 리팩터링 전후를 비교하려고 센다 +tools/artifact_check.sh entitlements·plist·버전 일치 검사. 계약 검증 tools/deadcode_audit.py 안 쓰이는 코드 훑기. periphery는 이 프로젝트에서 못 쓴다 ``` @@ -29,7 +36,8 @@ tools/deadcode_audit.py 안 쓰이는 코드 훑기. periphery는 이 프 **테스트 타깃 `MoscoTests`가 있다** (Swift Testing, 112건). **CI도 있다** — PR마다 App·MoscoWidget 빌드와 테스트, 그리고 Mac Catalyst 빌드가 자동으로 돈다 (`.github/workflows/ci.yml`). Catalyst를 따로 빌드하는 것은 **iOS가 통과해도 맥은 -깨질 수 있어서**다 — 라이브 액티비티가 조건부 컴파일로 가려져 있다. 그래도 먼저 돌리는 것은 AI다 — CI는 마지막 그물이지 +깨질 수 있어서**다 — 라이브 액티비티가 조건부 컴파일로 가려져 있다. 그래도 먼저 +돌리는 것은 AI다 — CI는 마지막 그물이지 편집 후 확인을 대신하지 않는다 (R1). > **시뮬레이터는 AI가 직접 쓰지 않는다.** 열지도, 탭하지도, 캡처하지도 않는다. @@ -63,13 +71,13 @@ UDID를 박아 쓰지 않는다 — 시뮬레이터는 지워지고 다시 생 ## 규칙 -열 개다. **각 규칙에는 담당 지표가 있고, 버전마다 채점받는다** — +열한 개다. **각 규칙에는 담당 지표가 있고, 버전마다 채점받는다** — `docs/harness/rules.md`에 도입 시점과 채점 이력이 있다. 두 버전 연속 효과가 없으면 그 규칙은 폐기한다. 지켜지지 않는 문장을 문서에 남겨두지 않는 것이 이 체계의 요점이다. ### 이 규칙들이 무엇을 향하는가 — M12 프롬프트당 토큰 -**최종 목표는 같은 결론에 토큰을 덜 쓰고 도달하는 것이다.** 규칙 열 개가 전부 +**최종 목표는 같은 결론에 토큰을 덜 쓰고 도달하는 것이다.** 규칙 열한 개가 전부 여기로 모이고, 다른 지표가 다 좋아져도 M12가 안 내려가면 그 버전의 하네스는 실패로 적는다. @@ -106,6 +114,16 @@ v1.1.0이 1.8배가 된 이유가 이 체계의 핵심 사례다. 스크린샷 거짓 완료 보고는 빌드 실패로 안 잡히고 **재지시로 돌아온다.** 그래서 이 규칙의 담당 지표에 M1이 같이 붙어 있다. +**고친 뒤 증상이 똑같으면, 다음 가설을 세우기 전에 그 코드가 실행되는지부터 +확인한다** (2026-08-22 추가). v1.3.0에서 스와이프 삭제를 세 번 연속 헛짚었다. 세 번 +모두 증상이 한 글자도 안 바뀌었는데 매번 다음 가설로 넘어갔다. 원인은 코드가 아니라 +**그때 화면에 그 코드가 없었다는 것**이었다. + +증상이 안 바뀌는 것은 "아직 못 찾았다"가 아니라 **"그 코드가 안 돈다"는 신호다.** +로그 한 줄, 조건 출력 하나, 탐침 하나 — 무엇이든 좋으니 **세 번째 시도 전에는 +반드시** 경로가 도는지 찍는다. 시뮬레이터를 못 여는 자리에서는 탐침을 심어 사람에게 +실행을 부탁한다(R2를 어기지 않는다). 자세한 것은 `docs/TRAPS.md`. + ### R2 · 시뮬레이터를 직접 쓰지 않는다. 검증은 테스트 코드로 남긴다 담당 지표 M11 시뮬레이터 호출 수, M10 회귀 테스트 수 @@ -126,6 +144,14 @@ v1.1.0이 1.8배가 된 이유가 이 체계의 핵심 사례다. 스크린샷 그리고 이 요청은 처음이 아니다 — 2026-07-31, 2026-08-05, 2026-08-18 세 번 나왔다. 앞의 두 번은 문서로 안 남겨서 다음 세션에 잊혔다. 세 번째에 규칙이 됐다. +**예외를 썼으면 무엇을 얻었는지 PR 본문에 적는다** (2026-08-22 추가). v1.3.0에서 +PR 스크린샷을 찍다가 KVS entitlement가 시뮬레이터 실행을 막고 있는 것을 발견했다 — +실기기도 CI도 초록이던 버그다. 예외가 값을 한 사례이므로 목표 0을 기계적으로 밀지 +않는다. 대신 **얻은 것이 "그려졌다" 하나뿐이면 그건 낭비로 센다.** + +`xcrun simctl`을 Bash로 부르는 것도 시뮬레이터 사용이다. 지표(M11)가 아직 그 경로를 +안 세지만 규칙은 센다. + **대신 무엇을 하나.** 확인하고 싶은 것이 로직이면 테스트를 쓴다 (R7). 화면이면 사용자에게 넘긴다. **"확인했다"고 말할 수 있는 근거는 빌드 결과와 테스트 결과뿐이다.** 그 둘로 덮이지 않는 것은 확인 안 한 것이고, 그렇게 적는다. @@ -233,7 +259,7 @@ R2로 시뮬레이터를 닫았으므로 **테스트가 AI가 쓸 수 있는 유 | 자연어 시각 파싱 | `4시~7시`의 종료 시각 · 오전/오후 해석 | | 카테고리 분류 | 임계값 `0.35`에 근거 데이터가 없다 | -**지금까지 덮은 것** (2026-08-19, 108건): +**지금까지 덮은 것** (2026-08-22, 112건): | 스위트 | 무엇을 잡나 | |---|---| @@ -292,9 +318,25 @@ AI는 자기 모델을 못 바꾼다. 바꾸는 것은 사용자다. AI가 할 한두 번이면 끝나는 것. 서브에이전트는 맥락 없이 시작하므로 **설명하는 비용이 직접 하는 비용보다 크면 지는 거래**다. -**서브에이전트는 사용자가 요청할 때만 띄운다.** 그래서 이 규칙의 지금 몫은 절반이다 — -띄우지 않은 경우에도 **"여기는 위임했으면 쌌다"를 남긴다.** 그 기록이 쌓여야 다음 -버전에 상시 위임 구성을 정할 수 있다. 보고서 6.3이 그 자리다. +**조사·감사·전수 훑기는 묻지 않고 위임한다** (2026-08-22 개정). 예전엔 "사용자가 +요청할 때만"이었는데, 그 문장이 규칙의 실행 자체를 막아 M14가 세 버전 연속 0%였다. +효과가 없었던 게 아니라 **한 번도 시험대에 못 올랐다.** + +기준은 이렇다. + +| 묻지 않고 위임 | 물어보거나 직접 | +|---|---| +| 파일 10개 이상을 읽어야 하는 조사 | 판단이 필요한 것 | +| "이 심볼 쓰는 데 다 찾아줘" | 이미 열어둔 파일을 고치는 것 | +| 명명 규칙·패턴 훑기, 감사 | 왕복 한두 번이면 끝나는 것 | +| 읽은 내용 자체는 안 남겨도 되는 것 | 편집이 따르는 것 | + +2026-08-22에 넷을 병렬로 돌려 54만 토큰을 쓰고 요약만 받았다. 직접 읽었으면 그 54만이 +본 컨텍스트에 쌓여 이후 모든 턴이 다시 읽었을 것이다. 정의와 근거는 +`.claude/agents/README.md`. + +위임하지 **않은** 경우에도 **"여기는 위임했으면 쌌다"를 남긴다.** 그 기록이 쌓여야 +다음 버전에 구성을 조정할 수 있다. 보고서 6.3이 그 자리다. **기록하는 자리는 PR 본문이다** (2026-08-19 변경). 예전엔 "답 끝에 한 줄"이었는데 매 턴 기억해야 하는 것이라 v1.2.0에서 **한 번도 안 남겼다.** PR 본문은 어차피 쓰는 @@ -312,6 +354,24 @@ AI는 자기 모델을 못 바꾼다. 바꾸는 것은 사용자다. AI가 할 - 이미 읽은 파일을 다시 읽지 않는다. 편집 도구는 실패하면 에러를 낸다 — 확인하려고 다시 읽는 것이 가장 흔한 낭비다. +**Bash로 읽을 때도 같다.** `sed -n '100,140p'`·`grep -n`·`head`는 범위를 준 것이고 +`cat`은 아니다. 지표(M15)가 `Read` 도구만 세는 것은 지표의 한계지 규칙의 한계가 +아니다 (2026-08-22). + +### R11 · 진단하려고 넣은 것은 표식을 달고, 커밋 전에 걷는다 +담당 지표 M16 임시 코드 잔존 + +원인을 좁히려고 로그·탐침·임시 분기를 넣을 때가 있다. **그것들은 진단이 끝나면 +쓰레기다.** v1.3.0에서 탐침 파일 하나와 추적 로그 두 줄이 남을 뻔했고, 사용자가 +"불필요한 코드는 제거하고"라고 말한 뒤에야 걷었다. + +- 넣을 때 **`// TEMP:` 표식**을 같이 단다. 왜 넣었는지도 한 줄 적는다. +- 커밋 전에 `grep -rn "TEMP:" Mosco/`로 찾아 전부 걷는다. +- **남길 가치가 있는 것은 표식을 떼고 이유를 주석으로 바꾼다.** 실패했을 때의 로그가 + 대개 그렇다 — v1.3.0의 날씨 실패 로그는 남겼고, 매번 찍히는 추적 로그는 걷었다. + +`tools/quality_baseline.py`가 이 표식을 센다. 0이 아니면 커밋하지 않는다. + ## 일하는 방식 - 사용자는 `auto`/`acceptEdits`로 거의 항상 열어둔다. 편집을 일일이 승인하지 않으므로 @@ -326,3 +386,7 @@ AI는 자기 모델을 못 바꾼다. 바꾸는 것은 사용자다. AI가 할 이건 취향이 아니라 M12로 채점되는 항목이다 (R8·R9·R10). - **모든 작업은 브랜치와 PR을 거친다.** `main`에 직접 커밋하지 않는다. 규칙은 `CONTRIBUTING.md`. +- **문서는 사람이 읽을 글로 쓴다** (2026-08-22 요청). 보고서·규범·계획 전부 + 해당한다. 표와 목록으로만 채우지 말고, 왜 그런지를 문장으로 적는다. 딱딱한 + 번역체("~를 수행한다", "~에 대한 검증")를 쓰지 않는다. **읽는 사람이 새벽에 + 피곤한 상태라고 가정한다** — 한 번 읽고 이해되지 않으면 그 문서는 실패한 것이다. diff --git a/README.md b/README.md index 4c75840..a5bd397 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ Firebase Analytics를 쓰지만 `GoogleService-Info.plist`는 저장소에 들 (`FirebaseAnalyticsSink.configure()`가 nil을 낸다). 시뮬레이터는 이름으로 지정한다 — UDID는 지워졌다 다시 생기면서 바뀐다. -검증 사다리는 빌드 → 테스트 → 사람 → 심사다. **`MoscoTests`(Swift Testing, 108건)와 -CI가 앞의 두 칸을 맡는다** — PR마다 App·MoscoWidget 빌드와 테스트가 자동으로 돈다 -([.github/workflows/ci.yml](.github/workflows/ci.yml)). +검증 사다리는 빌드 → 테스트 → 사람 → 심사다. **`MoscoTests`(Swift Testing, 112건)와 +CI가 앞의 두 칸을 맡는다** — PR마다 App·MoscoWidget 빌드와 테스트, 그리고 Mac +Catalyst 빌드가 자동으로 돈다 ([.github/workflows/ci.yml](.github/workflows/ci.yml)). ```bash xcodebuild -project Mosco/Mosco.xcodeproj -scheme App -sdk iphonesimulator \ @@ -68,7 +68,7 @@ Mosco/MoscoTests/ 유닛 테스트 (Swift Testing) | 문서 | 무엇이 있나 | |---|---| -| [CLAUDE.md](CLAUDE.md) | AI 작업 규칙 열 개 (R1~R10). 규칙마다 담당 지표가 있다 | +| [CLAUDE.md](CLAUDE.md) | AI 작업 규칙 열한 개 (R1~R11). 규칙마다 담당 지표가 있다 | | [CONTRIBUTING.md](CONTRIBUTING.md) | 커밋·브랜치·PR·문서 갱신 규칙 | | [RELEASING.md](RELEASING.md) | 버전 규칙, 태그, 릴리스 노트 쓰는 법 | | [docs/TRAPS.md](docs/TRAPS.md) | 한 번씩 크게 시간을 쓴 플랫폼 함정 | diff --git a/RELEASING.md b/RELEASING.md index 5e976f7..7ab45ec 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -79,7 +79,7 @@ App Store의 "새로운 기능" 칸에 들어가는 글입니다. 규칙은 넷 | | 절 구성 | 분량 | |---|---|---| | **MAJOR · MINOR** | 1~6절 전부 | 제한 없음 | -| **PATCH** | 2절(지표) · 3절(원인) · 6절(다음 하네스) | 한 쪽 | +| **PATCH** | 2절(지난 결과) · 3절(원인) · 4절(다음 하네스) | 한 쪽 | PATCH 보고서에서 **하네스를 안 바꿨으면 "안 바꿨다"고 적습니다.** 매 버전 규칙을 늘리는 것이 목적이 아니고, 안 바꾼 것도 판단입니다. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5b1da4c..411486a 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -29,7 +29,8 @@ - [ ] 애플 워치 지원 - [ ] 결제 모듈 - [ ] 가격 A/B 테스트 -- [ ] 수출 규정 문서 이슈 — `Info.plist`에 `ITSAppUsesNonExemptEncryption` 넣어 해소 +- [x] 수출 규정 문서 이슈 — `Info.plist`에 `ITSAppUsesNonExemptEncryption`가 이미 + 들어가 있다. 2026-08-22에 `tools/artifact_check.sh`로 확인했다 - [ ] 카테고리 분류 기준선 만들기 — 입력·기대 카테고리 쌍 30개. `docs/TRAPS.md` 참고 ## 하네스를 만들면서 드러난 것 @@ -75,7 +76,8 @@ 순서대로 한다. 위에서부터 반복해서 깨진 순이다. -- [x] **테스트 타깃 생성** — `MoscoTests`, Swift Testing, TEST_HOST = App. App 스킴에 +- [x] **테스트 타깃 생성** — `MoscoTests`, Swift Testing, **호스트 앱 없음** + (`TEST_HOST`를 안 쓴다 — 예전 기록이 틀렸다, 2026-08-22 정정). App 스킴에 물려서 스킴 하나로 빌드와 테스트가 같이 돈다 (2026-08-18) - [x] 날짜 경계 — 월말/월초에 같은 날짜가 두 번 나오지 않는다 · `MonthLayoutTests` 8건 - [x] 반복 일정 전개 — 3일짜리가 걸친 날짜 전부에 나온다 · `RecurrenceTests` @@ -217,7 +219,7 @@ - [ ] App Store Connect에 Mac 버전 추가 — 새 앱이 아니라 지금 레코드에 macOS 플랫폼을 얹는다. Mac 스크린샷(1280×800 이상)과 '이 버전의 새로운 기능'은 따로 채워야 하고, 심사도 따로 한 번 더 받는다. 계정으로 하는 일이다 -- [ ] 수출 규정 — `Info.plist`에 `ITSAppUsesNonExemptEncryption`. 맥 빌드에도 묻는다 +- [x] 수출 규정 — 키가 이미 있다 (2026-08-22 확인) - [~] 하루치 페이지 상단에 주간 스트립 — 77d0649에서 걷어냈던 `WeekStripView`를 되살린다. 달 전체로 접혔다 펴지는 구조는 되살리지 않는다 - [~] 주간 스트립 좌우 스와이프로 주 넘기기 — 페이지 전체 스와이프는 안 한다. @@ -250,6 +252,32 @@ 호스트 앱이 필요한데 테스트 타깃은 앱을 안 띄운다(CI 서명 때문에 그렇게 했다). R7이 "UI 테스트도 만든다"고 적어둔 것과 이 구성이 부딪힌다. 정해야 한다 +### 전수 조사 뒤에 남은 것 (2026-08-22) + +계획은 `docs/architecture/REFACTOR-PLAN.md`에 있다. 여기에는 그 밖의 것만 적는다. + +- [ ] **권한 파일과 규칙을 맞춘다** — AI가 자기 권한 파일을 못 고치므로 사람이 해야 + 한다. R2는 시뮬레이터를 금지하는데 `.claude/settings.json`의 `allow`에 + `Bash(xcrun simctl boot *)`·`Bash(xcrun simctl io * screenshot *)`가 있고 + `settings.local.json`에는 `Bash(xcrun simctl *)`가 있다. 규칙대로라면 PR + 스크린샷용 `io ... screenshot`과 읽기 전용 `list`만 남기고 나머지는 뺀다. + 반대로 R1이 매번 요구하는 `xcodebuild ... test`는 어느 `allow`에도 없어서 + 매번 승인을 탄다 — 그건 넣는다. `Bash(git commit -m ' *)`는 "커밋은 요청받을 + 때만"과 어긋나므로 뺄지 정한다 +- [ ] 지표 정의 넷 고치기 (M15·M6·M11·M4) + M16·M17·M18 구현. + 스키마를 올리고 이전 버전 전체를 다시 돌려야 한다 — `docs/harness/rules.md` +- [ ] `tools/quality_baseline.py`·`tools/artifact_check.sh`를 CI에 물릴지 정하기. + 물리면 계약·품질 검증이 PR 시점으로 당겨진다 +- [ ] `deadcode_audit.py`가 어디에도 안 물려 있다 — 정기 실행 자리를 정한다 +- [ ] `StyleGuideView`(109줄)가 어디서도 안 열린다. `DesignSystem/README.md`는 + "앱을 실행하면 뜬다"고 적혀 있는데 진입 경로가 없다. 넣든지 지우든지 +- [ ] `MonthGridCache`가 비우지 않는 정적 딕셔너리다. 무한히 자란다 +- [ ] `TodoActivityAttributes`에 `nonisolated`가 없다 — 프로세스 간 주고받는 값인데 + 암묵적 MainActor다 +- [ ] 호스트 앱을 붙일지 정한다. 지금 R7은 "UI 테스트도 만든다"고 적어놓고 구조상 + 못 한다 — 문서와 현실이 어긋난 상태다 +- [ ] AI 사용 보고서 v1.3.0을 위키에 올린다 (공개 저장소라 사람이 확인 후) + ## 버림 diff --git a/docs/architecture/CURRENT.md b/docs/architecture/CURRENT.md new file mode 100644 index 0000000..35da88d --- /dev/null +++ b/docs/architecture/CURRENT.md @@ -0,0 +1,209 @@ +# 지금 구조 + +2026-08-22에 소스 전체(Swift 106파일 13,964줄)를 훑고 정리한 것이다. 세 갈래로 +나눠 조사했다 — `App/Features/`, `Shared/`, `App/Common/` + `MoscoWidget/`. + +**이 문서는 진단이 아니라 지도다.** 무엇을 어떻게 고칠지는 +[`PATTERNS.md`](PATTERNS.md)에서 다룬다. + +## 한눈에 + +```mermaid +flowchart TB + subgraph T3["MoscoTests 타깃"] + TESTS["MoscoTests/
10파일 · 112건"] + end + subgraph T2["MoscoWidget 타깃"] + W["MoscoWidget/
8파일"] + end + subgraph T1["App 타깃"] + F["App/Features/
27파일 5,436줄"] + C["App/Common/
40파일"] + ROOT["RootTabView · SceneDelegate
AppDelegate"] + end + + SH["Shared/
32파일"] + + F --> C + F --> SH + C --> SH + ROOT --> F + ROOT --> C + W --> SH + TESTS --> SH + + style SH fill:#e8e0ff,stroke:#8B5CF6,stroke-width:2px + style TESTS fill:#e0f0e0 +``` + +**`Shared/`가 세 타깃 모두에 들어간다.** 그리고 테스트 타깃은 `Shared/`와 +`MoscoTests/`만 컴파일한다 — 호스트 앱이 없다. + +여기서 이 프로젝트의 **가장 중요한 구조적 사실**이 나온다. + +> **테스트를 쓸 수 있는 코드와 없는 코드가 이미 폴더로 갈려 있다.** +> `Shared/`에 있으면 테스트할 수 있고, `App/`에 있으면 못 한다. + +레이어 이름을 새로 붙일 필요가 없다. 경계는 이미 있고, **문제는 로직이 잘못된 쪽에 +많이 있다**는 것이다. + +## 레이어별 현황 + +### `Shared/` — 32파일, 세 타깃 공유 + +| 갈래 | 개수 | 예 | +|---|---|---| +| SwiftData 모델 | 5 | `TodoItem`, `TodoCategory`, `TodoCalendar` + 확장 2 | +| 순수 계산 | 14 | `MonthLayout`, `TimelineLayout`, `TimeExpressionParser`, `PermissionGate`, `CalendarEventExpander` | +| 저장소·전역 접근 | 5 | `SharedModelContainer`, `ThemeStore`, `Analytics`, `TodoCompletionWriter` | +| 시스템 연동 | 3 | `TodoLiveActivityController`, `TodoActivityAttributes`, 인텐트 | +| 그 밖 | 5 | `Date+Korean`, `KoreanHoliday`, `ThemeColor`, `AnalyticsIdentity`, `CalendarDayTone` | + +**잘 되어 있는 것**: 순수 계산 14개는 대부분 테스트가 붙어 있고, `nonisolated` +표시가 필요한 자리에 잘 붙어 있다. `TodoSnapshot`은 SwiftData 모델을 값 사본으로 +바꿔주는 경로를 일부러 열어둬서 테스트가 가능하다. + +**섞여 있는 것 7개**: 한 파일 안에 순수 로직과 부작용이 같이 있다. +`Analytics`(이벤트 정의는 순수, 전역 상태는 아님), `AnalyticsIdentity`(결정 규칙은 +순수, 저장소 어댑터는 아님), `MonthLayout`(계산은 순수, 같은 파일의 +`MonthGridCache`는 무한히 자라는 정적 딕셔너리), `KoreanHoliday`(계산은 순수, 정적 +캐시가 붙어 있음), `CategoryColorPalette`, `TodoCalendar`, `TodoSnapshot`. + +**테스트가 없는데 진짜 로직인 것 9개**. 값 순서로 위 셋만 적으면, + +1. `KoreanHoliday` (199줄) — 음력 변환, 대체공휴일 4규칙, 연휴 겹침 회피. 주석이 + 2025년 어린이날 사례를 콕 집어 설명하는데 그 케이스를 지키는 테스트가 없다. +2. `TodoItem+Recurrence` (96줄) — **`TodoSnapshot` 쪽 복사본만 테스트가 있고 모델 + 쪽은 한 줄도 없다.** 앱 화면·알림·위젯이 실제로 부르는 건 모델 쪽이다. +3. `CalendarSnapshotBuilder` — 앱과 위젯이 같은 달력 그림을 그리게 하는 핵심. + 구성 요소는 각각 덮였는데 엮는 부분이 안 덮였다. + +### `App/Features/` — 27파일 5,436줄, 테스트 0 + +화면 셋(Calendar 20파일, TodayTodo 1파일, Settings 4파일)이다. **이 폴더에는 테스트가 +하나도 없다.** 구조상 못 쓴다. + +가장 큰 문제는 **뷰 안에 비자명 로직이 50곳 넘게 있다**는 것이다. 갈래별로 세면 +날짜/시간 계산 13곳, 정렬·필터 8곳, 파싱·문자열 8곳, 레이아웃 산술 16곳, +지속성·부수효과 9곳이다. + +특히 아픈 자리 셋을 꼽으면, + +- `QuickAddView:481-536` — 오전/오후 모호성 해소 56줄. `TimeExpressionParser`는 + 원자 값만 주고 **정책은 전부 뷰 안에** 있다. 옆에 `TimeExpressionParserTests` + 21건이 이미 있는데 정작 정책은 안 덮인다. +- `EventScheduleSheet:181-214` — 반복 × 종일 × 종료 조합의 날짜 확정. 틀리면 + 데이터가 조용히 망가진다. +- `TodayTodoScreen:64-157` — 파생 컬렉션 8개(지난 할 일, 디데이, 검색, 정렬). 전부 + "말로 된 규칙"인데 검증이 없다. + +**같은 로직의 복사본 20건**도 여기서 나온다. 그중 실제로 갈라진 것이 하나 있다 — +목록 정렬 비교자가 `TodayTodoScreen`은 2단, `DayTodosContentView`는 3단이다. 같은 +할 일이 화면에 따라 다른 자리에 설 수 있다. + +### `App/Common/` — 40파일 + +디자인 시스템, 튜토리얼, ML, 날씨, 동기화, 알림, 애널리틱스, 리뷰, 클립보드. + +**시스템 API를 가짜로 바꿀 수 있는 곳은 둘뿐이다.** `Analytics`는 `AnalyticsSink` +프로토콜이 있고, ML은 `CategoryClassifying` 프로토콜이 있다(다만 뷰가 구체 타입을 +직접 만들어서 주입이 안 된다). 날씨·알림·동기화는 시스템 타입을 안에서 직접 잡는다. + +그리고 **`Common/`은 App 타깃에만 있으므로 어차피 테스트에서 안 보인다.** + +### `MoscoWidget/` — 8파일 + +`Shared/`만 보고 산다. `App/Common/`을 못 쓰므로 디자인 토큰도 못 쓴다 — 폰트·간격이 +전부 리터럴이다. 테마 색만 App Group UserDefaults를 통해 따라간다. + +## 결합이 센 다섯 곳 + +1. **`RootTabView` (396줄)** — 앱의 유일한 조립 지점이자 조율자. 스토어 5개를 만들어 + 주입하고, 그 외에 **최소 13가지 일**을 한다(탭 제어, 튜토리얼 강제 이동, 리뷰 + 발사, 시딩, iCloud 중복 병합, 알림 재예약 키 계산, 라이브 액티비티 핑거프린트, + 위젯 리로드, 날씨 로드, 권한 순서, 분석 배출). 새 전역 상태가 생기면 반드시 이 + 파일이 바뀌고, 이 뷰 없이는 어떤 화면도 프리뷰가 안 된다. + +2. **`TutorialStep` enum이 화면 레이아웃을 안다** — `holeInsets`의 `top: 66`은 + `QuickAddView` 시간칩 높이이고, `holeRadius`는 각 뷰의 코너 반경이다. 입력창 + 디자인을 바꾸면 이 enum을 고쳐야 하고, 단계를 하나 추가하면 열 군데를 동시에 + 고쳐야 한다. + +3. **문자열 핑거프린트 두 개** — `RootTabView:55`와 `:86`이 `TodoItem` 필드를 손으로 + 나열해서 "언제 다시 계산할지"를 정한다. 필드를 추가하고 여기 안 넣으면 조용히 + 갱신이 안 된다. 주석이 메모 누락으로 실제 그 버그를 겪었다고 적어뒀다. + +4. **튜토리얼 창과 코디네이터의 양방향 싱글턴** — `PassthroughWindow.hitTest`가 매 + 터치마다 `TutorialCoordinator.shared`를 읽고, 코디네이터는 창을 직접 켜고 끈다. + 둘 다 `static let shared`라 어느 쪽도 따로 검증할 수 없다. + +5. **디자인 토큰이 저장소 싱글턴에 묶여 있다** — `MoscoPalette.accent`가 + `ThemeStore.shared`를 읽고, 그건 App Group UserDefaults다. 색 하나 참조하는 순수한 + 뷰도 메인 액터와 App Group 설정을 요구한다. + +## 데이터 접근이 두 갈래다 + +같은 앱 안에 **완전히 다른 두 방식**이 공존한다. + +| | 방식 | 쓰는 곳 | +|---|---|---| +| A | `TodoQueryBridge`가 리프에서 `@Query`를 격리하고, 값 스냅샷을 백그라운드에서 만든다 | 달력 화면 | +| B | 뷰 본체에 `@Query`를 놓고 매 `body`마다 필터·정렬 | 오늘 할 일, 하루치, 빠른 입력 | + +A는 성능 문제를 겪고 나서 만든 구조이고 주석이 그 경위를 적어뒀다. B는 그전 방식이 +그대로 남은 것이다. 그리고 A의 주석은 "캘린더 필터는 여기 한 곳에서만"이라고 +말하는데 **실제로는 네 곳에서 각자 필터링하고 있다.** + +## 품질 기준선 (2026-08-22) + +리팩터링을 시작하기 전에 찍어두는 숫자다. 나중에 "좋아졌는가"에 답하려면 출발점이 +있어야 한다. + +| 항목 | 값 | +|---|---| +| Swift 파일 · 줄 | 106 · 13,964 | +| 테스트 | 112건 / 9스위트 (전부 `Shared/` 대상) | +| Features 폴더의 테스트 | **0** | +| 뷰 안의 비자명 로직 | **50곳 이상** | +| 한 파일 최대 책임 수 | **11** (`QuickAddView` 679줄) | +| 500줄 넘는 파일 | 3 (`QuickAddView` 679, `TodayTodoScreen` 662, `SettingsScreen` 538) | +| 같은 로직의 복사본 | **20건** | +| Feature 간 순환 의존 | 1쌍 (Calendar ↔ Settings) | +| 테스트 없는 순수 로직 | 9개 | +| 시스템 API를 가짜로 못 바꾸는 모듈 | 4 (날씨·알림·동기화·저장소) | +| 디자인 토큰 우회 | 폰트 33곳, 여백 42곳, 반경 3곳 | +| 싱글턴·전역 가변 상태 | 11 | +| 조건부 컴파일 분기 | 6 | + +두 가지 방법으로 셌고 값이 조금 다르다. **위 표는 사람(조사 에이전트)이 읽어서 센 +것**이고, `tools/quality_baseline.py`는 정규식으로 센다. 예를 들어 "뷰 안의 비자명 +로직"은 사람이 50곳, 스크립트가 71건으로 센다 — 스크립트는 한 함수 안의 여러 호출을 +따로 세고 `App/Common/`의 뷰도 포함한다. + +**추세를 볼 때는 스크립트 값을 쓴다.** 사람이 센 값은 매번 달라지고, 스크립트는 같은 +방법으로 센다. 아래가 스크립트 기준 2026-08-22 값이다. + +| 항목 | 스크립트 값 | +|---|---| +| 뷰 안의 비자명 로직 | 71 (날짜 36 · 정렬·필터 19 · 저장소 14 · 문자열 2) | +| 500줄 넘는 파일 | 3 | +| 싱글턴 | 4 | +| 조건부 컴파일 | 15 | +| 폰트 하드코딩 | 48 | +| 숫자 여백 | 59 | +| `// TEMP:` 잔존 | 0 | + +## 잘 되어 있는 것도 적는다 + +진단만 적으면 다 뜯어고쳐야 할 것처럼 보이는데, 그렇지 않다. + +- **순수 로직을 뽑아 테스트하는 습관이 이미 자리 잡았다.** 112건이 그 증거이고, + `TimeExpressionParser`는 뷰 안에 있던 것을 꺼내서 21건을 붙인 실제 사례다. +- **되돌린 결정을 문서로 남긴다.** `DesignSystem/README.md`와 `docs/TRAPS.md`가 + 실제로 작동하고 있다 — 규범 드리프트 지표가 두 버전 연속 0이다. +- **주석이 "왜"를 적는다.** 이 조사가 빨랐던 이유의 절반이 주석이다. 대부분의 + 이상해 보이는 코드 옆에 그렇게 한 이유가 적혀 있었다. +- **앱과 위젯이 계산을 공유한다.** `CalendarSnapshotBuilder`를 양쪽이 같이 써서 같은 + 그림을 그린다. 이건 많은 앱이 못 하는 것이다. +- **동시성 표시가 대체로 정확하다.** 기본이 `MainActor`인 설정에서 백그라운드로 + 가야 하는 것마다 `nonisolated`가 붙어 있다. diff --git a/docs/architecture/DECISIONS.md b/docs/architecture/DECISIONS.md new file mode 100644 index 0000000..63ee222 --- /dev/null +++ b/docs/architecture/DECISIONS.md @@ -0,0 +1,219 @@ +# 결정할 것들 + +2026-08-22 전수 조사 뒤에 정리한 판단 문서다. **읽고 고르기 위한 것**이라, 조사 +내용은 최소만 담고 나머지는 링크로 뺐다. + +세 부분이다 — 알고 있어야 하는 것 / 결정해야 하는 것 / 지금은 안 정해도 되는 것. + +--- + +## 한 장 요약 + +앱은 잘 돌고 있고 출시돼 있다. 지난 네 버전에서 만든 규칙 체계도 작동하고 있다 — +프롬프트당 토큰이 두 구간 연속 절반이 됐고, 규범 드리프트는 두 버전 연속 0이다. + +문제는 **코드가 아니라 코드를 검증할 수 있는 범위**다. 화면 쪽 로직 71곳이 테스트를 +쓸 수 없는 자리에 있고, v1.3.0에서 잡힌 버그 넷 중 테스트로 잡을 수 있었던 것은 +0개였다. + +**결정해야 할 것은 여섯 개다.** 그중 셋은 지금 정해야 하고, 셋은 1~2단계가 끝난 뒤에 +정해도 된다. 아래에 선택지와 장단점을 적었다. + +--- + +## 1부. 알고 있어야 하는 것 (결정이 아니라 사실) + +### 1-1. 테스트 가능한 코드와 아닌 코드가 이미 폴더로 갈려 있다 + +테스트 타깃이 `Shared/`만 컴파일한다(호스트 앱 없음). **그래서 어떤 아키텍처를 +고르든 "테스트하려면 `Shared/`로 내려야 한다"가 먼저다.** 새 레이어 이름을 붙이는 +것으로는 아무것도 안 바뀐다. + +이 사실 하나가 아래 판단 대부분을 결정한다. + +### 1-2. 순수 함수 추출은 아키텍처 중립이다 + +값 타입 + 순수 함수로 뽑아둔 것은 MVVM을 고르든 TCA를 고르든 클린을 고르든 **그대로 +쓰인다.** ViewModel이 부르든 리듀서가 부르든 UseCase가 부르든 같다. + +**즉 1·2단계 작업은 어떤 결정을 하든 낭비되지 않는다.** 단, "이참에 프로토콜도 만들고 +주입도 하고"로 번지면 그 순간 특정 구조를 반쯤 고른 게 된다. + +### 1-3. 이 저장소에서 실제로 일관성을 만든 것은 규범 문서였다 + +`DesignSystem/README.md`가 색·문구를 붙들고 있고 규범 드리프트가 두 버전 연속 0이다. +**아키텍처 이름이 한 일이 아니다.** 반대로 코드 배치에는 그런 문서가 없고, 그 결과 +데이터 접근이 두 갈래로 갈렸다. + +### 1-4. 지금 숫자 + +| | 값 | 뜻 | +|---|---|---| +| 뷰 안의 비자명 로직 | **71곳** | 테스트를 쓸 수 없는 코드의 양 | +| Features 폴더의 테스트 | **0** | 구조상 못 쓴다 | +| 같은 로직의 복사본 | 20건 | 그중 1건은 이미 갈라졌다(정렬 순서) | +| 500줄 넘는 파일 | 3 | 최대 책임 11개 | +| 싱글턴 | 5 | 정당한 것 2, 편의 3 | +| 데이터 접근 방식 | **2갈래** | 한 앱 안에 다른 두 구조 | +| 테스트 | 112건 | 전부 `Shared/` 대상 | + +### 1-5. 사람만 할 수 있는 일이 둘 밀려 있다 + +- **권한 파일이 규칙과 어긋나 있다.** R2는 시뮬레이터를 금지하는데 `allow`에 + `xcrun simctl`이 열려 있다. AI는 자기 권한 파일을 못 고친다. +- **App Store Connect에 macOS 플랫폼 추가**와 1.3.0 업로드. + +--- + +## 2부. 결정해야 하는 것 + +### D1. 순서 — 추출을 먼저 할 것인가, 아키텍처를 먼저 고를 것인가 + +| | 장점 | 단점 | +|---|---|---| +| **A. 추출 먼저** (권고) | 되돌리기 쉽고 낭비가 없다(1-2). 추출하면서 도메인의 실제 모양이 드러나 아키텍처 판단의 근거가 생긴다 | 중간에 "이걸 왜 하고 있나" 구간이 생길 수 있다 | +| B. 아키텍처 먼저 | 목표가 분명하다. 배치 규칙이 곧바로 생긴다 | 모양을 모르는 로직을 틀에 맞춰 뽑게 된다. 틀이 틀리면 두 번 일한다 | + +**권고: A.** 다만 "아무 규칙 없이 추출"이 아니라 **D2와 같이** 간다. + +**미루면**: 지금 상태 유지. 새 기능마다 뷰 안 로직이 늘어난다. + +--- + +### D2. 규칙을 어떤 형태로 둘 것인가 + +"아키텍처 없음"과 "규칙 없음"은 다르다. 지금은 **규칙이 암묵적**이라 데이터 접근이 +두 갈래로 갈렸다. + +| | 장점 | 단점 | +|---|---|---| +| **A. 코드 배치 규범 문서** (권고) | 이 저장소에서 이미 작동이 증명된 방식(1-3). 점진적으로 채울 수 있다. 미정인 것을 "미정"으로 남길 수 있다 | 이름이 주는 공유 어휘가 없다. 강제력이 약하다 | +| B. 이름 붙은 아키텍처 (MVVM 등) | 새로 오는 사람이 절반을 짐작한다. 배치 질문에 기본 답이 있다 | **이름은 절반만 답한다** — `@Query`는 어디, 시트 상태는 어디, 위젯은 어떻게가 여전히 남는다. 그리고 제약 1-1 때문에 MVVM은 이 프로젝트에서 얻는 게 적다 | +| C. 둘 다 | 표지와 세부를 같이 가진다 | 지금 정하면 근거 없이 이름을 고르게 된다 | + +**권고: A로 시작하고, 1~2단계 뒤에 이름이 필요한지 다시 본다.** + +**중요한 단서**: **혼자가 아니게 될 계획이 있으면 저울이 B로 기운다.** 사람이 한 명만 +붙어도 공유 어휘의 값이 크게 오른다. 그 계획이 있으면 지금 알려달라. + +--- + +### D3. 호스트 앱을 붙일 것인가 (UI 테스트) + +**지금 문서와 현실이 어긋나 있다.** R7은 "UI 테스트도 만든다"고 적어놨는데 구조상 +못 한다. 어느 쪽이든 정해야 문서가 거짓말을 멈춘다. + +| | 장점 | 단점 | +|---|---|---| +| A. 붙인다 | 제스처·화면 흐름이 검증 대상이 된다. v1.3.0에서 세 번 물린 종류가 덮인다 | **예전에 이것 때문에 CI가 죽었다**(서명 없어 저장소 생성 실패). 테스트가 느려지고 불안정해진다 | +| **B. 별도 UI 테스트 타깃을 새로 만든다** (권고) | 지금 112건은 호스트 없이 빠르게 유지. UI 테스트만 따로 느리게 돈다 | 타깃이 하나 늘고 CI 시간이 는다 | +| C. 안 붙인다 | 지금 구성이 단순하게 유지된다 | **R7에서 "UI 테스트"를 빼야 한다.** 제스처는 영원히 사람이 본다 | + +**권고: B.** 다만 **1~2단계 뒤에** 한다 — 그때쯤 뷰가 얇아져 UI 테스트가 덜 필요할 +수도 있다. + +**미루면**: R7의 그 문장을 지금 지워야 한다. 지켜지지 않는 문장을 남겨두지 않는 것이 +이 체계의 요점이다. + +--- + +### D4. 검증 도구를 CI에 물릴 것인가 + +어젯밤 만든 두 스크립트(`quality_baseline.py`, `artifact_check.sh`)는 지금 손으로만 +돈다. + +| | 장점 | 단점 | +|---|---|---| +| **A. 둘 다 CI에** (권고) | 계약 검증이 PR 시점으로 당겨진다. **v1.3.0의 버그 둘을 잡았을 것이다.** 품질 숫자가 매 PR에 남아 추세가 보인다 | CI가 30초쯤 는다. 기준선이 나빠지면 PR이 빨개져 성가실 수 있다 | +| B. 계약 검사만 CI에 | 위험한 것만 막는다 | 품질 추세는 손으로 재야 한다 | +| C. 안 물린다 | 지금 그대로 | 만들어놓고 안 쓰게 된다. `deadcode_audit.py`가 이미 그 신세다 | + +**권고: A.** 단 품질 검사는 **실패시키지 말고 기록만** 한다(숫자를 PR에 남기는 정도). +기준선이 올라갔다고 빨간불이면 리팩터링 중에 계속 걸린다. + +--- + +### D5. 싱글턴을 어디까지 정리할 것인가 + +정당한 것 둘(위젯·인텐트처럼 **시스템이 밖에서 부르는 진입점**)과 편의 셋으로 +갈린다. + +| | 무엇 | 비용 | 얻는 것 | +|---|---|---|---| +| **A. 두 줄만** (권고, 지금) | `TodoActions`가 이미 주입된 `@Environment`를 쓰게 | 2줄 | 일관성. 위험 0 | +| B. + 튜토리얼 창 | 코디네이터가 창을 소유 | 20줄 | 양방향 참조가 끊긴다 | +| C. + 테마 색 | `ThemeStore`에 갈아끼울 자리 | 10줄 | 테마 색 테스트 가능. 호출부 29곳은 안 건드림 | +| D. 전부 | 정당한 둘도 프로토콜 뒤로 | 중간 | 인텐트·위젯 경로까지 테스트 가능 | + +**권고: 지금 A, 1~2단계와 같이 B·C, D는 나중.** + +--- + +### D6. PR #21을 병합할 것인가 + +문서와 도구는 위험이 없는데, **`CLAUDE.md` 규칙 변경 넷이 들어 있다.** 이건 앞으로 +AI의 행동이 달라지는 것이라 따로 본다. + +| 변경 | 무엇이 달라지나 | 위험 | +|---|---|---| +| R1 확장 | 증상이 안 바뀌면 다음 가설 전에 경로가 도는지 확인 | 없음. 왕복이 준다 | +| R2 단서 | 예외를 썼으면 무엇을 얻었는지 PR에 적는다 | 없음 | +| **R9 개정** | **조사·감사는 묻지 않고 위임한다** | 잘못 위임하면 재작업이 는다. M14와 M1을 같이 봐야 한다 | +| R11 신설 | 진단용 코드에 `// TEMP:` 달고 커밋 전에 걷는다 | 없음 | + +**권고: 병합.** R9만 한 버전 지켜보고, M1이 오르면 조건을 좁힌다. + +--- + +## 3부. 지금은 안 정해도 되는 것 + +미루는 게 손해가 아닌 것들이다. **1~2단계 뒤에 보면 답이 달라질 가능성이 높다.** + +| | 왜 미뤄도 되나 | +|---|---| +| TCA 도입 | 이 앱의 문제는 상태 흐름이 복잡한 게 아니라 로직이 테스트 못 하는 자리에 있는 것이다. 그건 훨씬 싼 방법으로 풀린다 | +| SPM 모듈로 쪼개기 | 정리 안 된 것을 지금 쪼개면 그대로 굳는다. 경계가 보인 뒤에 | +| Repository 도입 | `@Query`를 포기해야 한다. 추출하다 보면 정말 필요한지가 드러난다 | +| MVVM | 제약 1-1 때문에 지금은 얻는 게 적다. 호스트 앱을 붙이기로 하면 다시 본다 | +| Coordinator | 화면이 셋이라 이동 규칙이 단순하다. 화면이 두 배가 되면 | + +--- + +## 권고 요약 + +| | 결정 | 권고 | 언제 | +|---|---|---|---| +| D1 | 순서 | **추출 먼저** | 지금 | +| D2 | 규칙 형태 | **배치 규범 문서**, 이름은 나중 | 지금 | +| D3 | 호스트 앱 | **별도 UI 테스트 타깃** | 1~2단계 뒤 | +| D4 | CI 도구 | **둘 다, 품질은 기록만** | 지금 | +| D5 | 싱글턴 | **두 줄만 지금**, 나머지는 같이 | 지금/뒤 | +| D6 | PR #21 | **병합**, R9는 한 버전 관찰 | 지금 | + +**지금 답이 필요한 것은 D1·D2·D4·D6 넷이고, 나머지 둘은 나중에 봐도 된다.** + +--- + +## 아무것도 안 하면 어떻게 되나 + +정직하게 적는다. **당장은 아무 일도 안 일어난다.** 앱은 돌고 기능도 계속 낼 수 있다. + +다만 셋이 계속 쌓인다. + +1. **뷰 안 로직이 는다.** 새 기능마다 몇 곳씩 는다. 지금 71곳이고, 지난 세 버전에서 + 화면 코드가 5,436줄이 됐다. +2. **복사본이 갈라진다.** 이미 정렬 순서가 화면마다 다르다. 이런 건 사람이 눈으로 + 찾기 전엔 안 걸린다. +3. **검증을 사람이 진다.** 테스트가 못 덮는 종류가 나올 때마다 검증 카드가 나가고, + 그건 새벽에 밟아야 하는 일이다. v1.3.0에 카드가 9건 나갔다. + +--- + +## 같이 보는 문서 + +- [CURRENT.md](CURRENT.md) — 조사 결과 전체 +- [PATTERNS.md](PATTERNS.md) — 설계 13가지 비교 +- [REFACTOR-PLAN.md](REFACTOR-PLAN.md) — 단계별 계획 +- [../verification.md](../verification.md) — 검증 체계 +- [../review-criteria.md](../review-criteria.md) — 사람이 봐야 하는 코드 +- [../harness/reports/v1.3.0.md](../harness/reports/v1.3.0.md) — 이번 버전 보고서 diff --git a/docs/architecture/PATTERNS.md b/docs/architecture/PATTERNS.md new file mode 100644 index 0000000..51a8e17 --- /dev/null +++ b/docs/architecture/PATTERNS.md @@ -0,0 +1,341 @@ +# 쓸 수 있는 설계와 패턴 + +나중에 비교해서 고르기 위한 목록이다. **여기서 아무것도 결정하지 않는다.** 각각이 +무엇이고, 이 프로젝트에 넣으면 무엇이 어떻게 바뀌고, 무엇을 얻고 무엇을 잃는지만 +적는다. + +읽기 전에 [`CURRENT.md`](CURRENT.md)의 "품질 기준선"을 보면 숫자로 비교할 수 있다. + +## 고를 때 걸리는 제약 다섯 + +패턴을 평가하려면 먼저 이 프로젝트가 이미 가진 조건을 알아야 한다. 이게 점수표의 +기준이 된다. + +| # | 제약 | 왜 중요한가 | +|---|---|---| +| 1 | **테스트 타깃은 `Shared/`만 컴파일한다** (호스트 앱 없음) | 어떤 패턴을 고르든, 테스트하고 싶은 코드는 물리적으로 `Shared/`에 있어야 한다. 이게 가장 센 제약이다 | +| 2 | **위젯이 같은 `Shared/`를 쓴다** | 도메인 로직에 UIKit·앱 전용 의존이 끼면 위젯이 깨진다 | +| 3 | **SwiftData `@Query`에 이미 깊이 기대고 있다** | `@Query`는 뷰에서만 산다. 데이터 접근을 추상화하려면 이걸 어떻게 할지부터 답해야 한다 | +| 4 | **1인 + AI가 주 작성자** | 보일러플레이트를 치는 비용은 싸다. 대신 **읽어야 하는 파일이 늘면 토큰이 든다** — 사람에게도 AI에게도 | +| 5 | **이미 출시된 앱이고 계속 기능을 낸다** | 몇 주 멈추고 재작성하는 선택지는 없다. 기능을 내면서 옮겨야 한다 | + +--- + +## A. 전체 구조를 정하는 패턴 + +### A1. 지금 방식 — SwiftUI 네이티브 (MV) + +뷰가 상태를 직접 들고, `@Observable` 스토어를 `@Environment`로 나눠 쓰고, 데이터는 +`@Query`로 바로 읽는다. 별도 ViewModel 층이 없다. + +**장점** +- SwiftUI가 의도한 방식이라 프레임워크와 안 싸운다. `@Query`의 자동 갱신, 애니메이션, + 프리뷰가 그대로 작동한다. +- 파일 수가 적다. 화면 하나가 파일 하나다. +- 새 기능을 붙이는 속도가 가장 빠르다. + +**단점** +- **뷰 안의 로직을 테스트할 수 없다.** 지금 50곳 이상이 그 상태다. +- 화면이 커지면 책임이 무한히 쌓인다. `QuickAddView` 679줄에 책임 11개가 그 결과다. +- 같은 로직이 화면마다 복제된다. 지금 20건. + +**비용**: 0 (이미 이 방식이다) + +**적합도**: 유지하되 **로직만 밖으로 빼면** 단점 대부분이 사라진다. 아래 B2와 짝이다. + +--- + +### A2. MVVM — 화면마다 ViewModel + +각 화면에 `@Observable` ViewModel을 두고, 뷰는 그리기만 한다. + +**장점** +- 익숙하고 설명이 필요 없다. +- 화면 로직을 클래스로 옮기니 **원칙적으로는** 테스트 가능해진다. + +**단점** +- **이 프로젝트에서는 그 "원칙적으로"가 안 통한다.** ViewModel을 `App/Features/`에 + 두면 테스트 타깃이 못 본다(제약 1). `Shared/`로 내리면 위젯도 그걸 컴파일한다(제약 2). +- `@Query`를 ViewModel에서 못 쓴다. 뷰가 데이터를 받아 ViewModel에 밀어 넣는 구조가 + 되고, 그러면 "뷰가 얇아진다"는 목적이 반쯤 무너진다. +- 파일이 두 배가 된다. 화면 3개면 괜찮은데 시트·행까지 포함하면 27 → 50개. + +**비용**: 중 (화면 3개 + 주요 시트 4개 ≈ 2~3일) + +**적합도**: **낮다.** 제약 1 때문에 얻는 게 생각보다 적다. 호스트 앱을 붙이기로 +결정하면 그때 다시 볼 만하다. + +--- + +### A3. TCA (The Composable Architecture) + +상태·액션·리듀서로 단방향 흐름을 만들고, 의존성을 명시적으로 주입한다. 외부 +라이브러리다. + +**장점** +- 테스트가 이 라이브러리의 존재 이유다. 상태 전이를 전부, 빠짐없이 검증할 수 있다. +- 의존성 주입이 설계에 내장돼 있다 — 날씨·알림·동기화를 가짜로 바꾸는 문제가 통째로 + 풀린다. +- 부수효과가 한 자리에 모인다. 지금 뷰에 흩어진 9곳이 여기로 온다. + +**단점** +- **외부 의존이 하나 늘고, 그게 앱 구조 전체를 정한다.** 나중에 빼는 것이 사실상 + 불가능하다. +- 빌드 시간과 컴파일 부담이 는다. 지금 CI가 5분인데 더 늘어난다. +- 러닝 커브가 있고, 규칙이 많아 AI가 쓸 때도 실수하면 재작업이 크다. +- `@Query`와 궁합이 나쁘다. SwiftData를 TCA 방식으로 감싸는 별도 작업이 필요하다. +- 코드량이 늘어난다 — 같은 기능에 상태·액션·리듀서가 붙는다. + +**비용**: 대 (전면 도입이면 몇 주. 부분 도입도 화면 하나에 며칠) + +**적합도**: **낮다.** 이 앱의 문제는 "상태 흐름이 복잡해서 못 따라가겠다"가 아니라 +"로직이 테스트 못 하는 자리에 있다"다. TCA는 전자를 푸는 도구고, 후자는 훨씬 싼 +방법으로 풀린다. + +--- + +### A4. 레이어드 / 클린 아키텍처 + +Domain(순수) / Data(저장소) / Presentation(화면)으로 나누고 의존 방향을 안쪽으로만 +둔다. + +**장점** +- **이 프로젝트는 이미 절반쯤 이 모양이다.** `Shared/`가 Domain, `App/`이 + Presentation, `SharedModelContainer`가 Data다. 이름만 안 붙였을 뿐이다. +- 제약 1과 정확히 맞아떨어진다 — 테스트 가능한 층이 물리적으로 갈려 있다. +- 위젯이 Domain만 쓰는 지금 구조와도 맞는다. + +**단점** +- 엄격하게 하면 `@Query`를 쓸 수 없다. Repository를 통해 데이터를 받아야 하는데, + 그러면 SwiftData의 자동 갱신을 손으로 다시 만들어야 한다. +- 층을 넘는 변환 코드(DTO ↔ 모델)가 는다. +- 과하게 적용하면 파일 다섯 개를 열어야 한 줄을 고칠 수 있게 된다. + +**비용**: 중~대. 다만 **점진적으로 가능**하다 — 로직을 하나씩 `Shared/`로 내리면 +그게 곧 이 패턴이다. + +**적합도**: **높다.** 단, "클린 아키텍처를 도입한다"가 아니라 **"이미 있는 경계를 +분명히 하고 잘못된 쪽에 있는 것을 옮긴다"**로 읽을 때만. + +--- + +### A5. VIPER 등 더 무거운 것 + +**적합도**: 없음. 1인 프로젝트에 5층 구조는 읽는 비용만 는다. 기록용으로만 적는다. + +--- + +## B. 부분만 도입할 수 있는 패턴 + +전체 구조를 안 바꾸고 하나씩 넣을 수 있는 것들이다. **이 프로젝트에는 이쪽이 훨씬 +현실적이다.** + +### B1. 순수 로직 추출 — `Shared/`로 내리기 + +뷰 안의 계산을 `nonisolated struct`/`enum`으로 뽑아 `Shared/`에 둔다. + +**이미 해본 것이다.** `TimeExpressionParser`가 `QuickAddView` 안의 `private static` +이었는데 꺼내니 테스트 21건이 붙었다. `TimelineLayout`, `MonthLayout`, +`PermissionGate`도 같은 경로였다. + +**장점** +- 제약 1·2를 정면으로 만족한다. 옮기는 즉시 테스트가 가능해진다. +- 점진적이다. 오늘 하나 옮기고 내일 하나 옮겨도 된다. +- 되돌리기 쉽다. 옮긴 것이 아니다 싶으면 다시 넣으면 된다. +- **AI가 하기 가장 쉬운 종류의 작업이다** — 규칙이 명확하고 컴파일러가 검증한다. + +**단점** +- 구조 문제(RootTabView가 13가지를 한다)는 안 풀린다. +- 뷰와 로직 사이에 값 타입을 하나씩 만들어야 할 때가 있다. + +**비용**: 소~중. 대상 50곳 중 값이 큰 13곳부터 하면 2~3일. + +**적합도**: **가장 높다.** 첫 순서로 추천. + +--- + +### B2. Repository / DataSource 추상화 + +SwiftData 접근을 프로토콜 뒤로 숨긴다. + +**장점** +- 저장소를 가짜로 바꿀 수 있다. 지금 못 하는 것 중 큰 것. +- 캘린더 필터가 네 곳에 흩어진 문제가 한 곳으로 모인다. + +**단점** +- **`@Query`를 포기해야 한다.** 자동 갱신을 손으로 만들면 그 자체가 새 버그 밭이다. + `CalendarSnapshotStore`가 이미 그 길을 절반 갔고, 그 파일 주석이 왜 그렇게 + 힘들었는지 적어뒀다. +- 위젯도 같은 추상화를 써야 하는데 위젯은 타임라인마다 한 번 읽고 끝이라 요구가 다르다. + +**비용**: 중~대 + +**적합도**: **중간.** 전면 도입은 위험하고, **읽기 전용 조회(필터·정렬)만** 값 타입 +함수로 뽑는 절충이 현실적이다. 그건 사실상 B1이다. + +--- + +### B3. 의존성 주입 정리 + +지금은 세 방식이 섞여 있다 — `RootTabView`가 만들어 `@Environment`로 주입, 싱글턴 +직접 참조, 뷰가 직접 생성. 하나로 모은다. + +**장점** +- 프리뷰가 살아난다. 지금은 `@Environment` 다섯 개가 없으면 화면이 크래시한다. +- 테스트에서 가짜를 넣을 길이 생긴다. +- `SettingsScreen`이 테마만 싱글턴으로 읽는 것 같은 예외가 사라진다. + +**단점** +- 조립 코드가 는다. +- `Analytics`처럼 어디서나 부르는 전역은 주입으로 바꾸면 호출부가 전부 바뀐다. + +**비용**: 소~중 (스토어 7개 + 싱글턴 11개 정리) + +**적합도**: **높다.** B1 다음 순서로 좋다. + +--- + +### B4. Coordinator / Router + +네비게이션을 뷰에서 빼낸다. + +**장점**: 화면 이동 규칙이 한 곳에 모인다. 지금 Calendar ↔ Settings 순환 의존이 풀린다. + +**단점**: SwiftUI의 `NavigationStack`·`sheet`와 겹친다. 이 앱은 화면이 3개 + 시트 +몇 개라 이동 규칙 자체가 단순하다. + +**비용**: 중 + +**적합도**: **낮다.** 지금 규모에서는 얻는 게 적다. 화면이 두 배가 되면 다시 본다. + +--- + +### B5. 단방향 흐름만 빌려오기 (State/Action, 라이브러리 없이) + +TCA를 안 쓰고 개념만 가져온다. 화면 상태를 struct 하나로 모으고, 변경을 함수로만 +하게 한다. + +**장점** +- 상태 struct와 변경 함수가 `Shared/`로 내려가면 **테스트 가능해진다.** +- 라이브러리 의존이 없다. +- `QuickAddView`의 `@State` 15개 같은 자리에 정확히 맞는다. + +**단점** +- 직접 만든 규칙이라 강제하는 것이 없다. 다음 사람이(=다음 세션의 AI가) 안 지킬 수 있다. +- SwiftUI 바인딩과 이어 붙이는 코드가 필요하다. + +**비용**: 중 (화면당 반나절~하루) + +**적합도**: **중간~높음.** 특히 상태가 많은 화면 두 곳(`QuickAddView`, +`TodayTodoScreen`)에 국소 적용하면 값이 크다. + +--- + +## C. 물리적 분리 + +### C1. SPM 로컬 패키지로 모듈 쪼개기 + +`Domain`, `DesignSystem` 같은 로컬 패키지를 만들고 앱이 그걸 가져다 쓴다. + +**장점** +- 의존 방향이 **컴파일러로 강제**된다. 문서로 부탁하는 것과 차원이 다르다. +- 패키지 단위로 테스트 타깃을 붙일 수 있다 — **제약 1이 풀린다.** 호스트 앱 없이도 + `DesignSystem` 테스트가 가능해진다. +- 빌드 캐시가 쪼개져 증분 빌드가 빨라질 수 있다. + +**단점** +- Xcode 프로젝트 구조를 크게 건드린다. 지금 폴더 동기화 방식(`fileSystemSynchronizedGroups`)을 + 다시 짜야 한다. +- 위젯·앱·테스트의 멤버십을 전부 다시 정해야 한다. +- 한 번 쪼개면 되돌리기 어렵다. + +**비용**: 대 (2~4일 + 이후 잔버그) + +**적합도**: **나중에.** B1·B3으로 로직을 정리한 뒤, "이제 경계를 못 넘게 막자"가 +필요해질 때. 지금 하면 정리 안 된 것을 그대로 굳힌다. + +--- + +### C2. 테스트 호스트 앱 붙이기 + +테스트 타깃에 `TEST_HOST`를 붙여 앱을 띄우고 테스트한다. + +**장점** +- `App/Common/`과 `App/Features/`가 테스트 사정권에 들어온다. +- **XCUITest를 쓸 수 있다** — 지금 R7이 "UI 테스트도 만든다"고 적어놓고 못 하고 있다. +- 제스처·화면 흐름 같은, v1.3.0에서 세 번 물린 종류가 덮인다. + +**단점** +- **예전에 이것 때문에 CI가 죽었다.** 서명이 없어 저장소를 못 만들었고, 로컬에서도 + CloudKit 오류가 났다. `CLAUDE.md`에 그 경위가 적혀 있다. +- 테스트가 느려지고 불안정해진다. +- 지금 112건이 호스트 없이 도는 이점을 잃을 수 있다(타깃을 나누면 유지 가능). + +**비용**: 중 (CI 서명 문제 해결이 대부분) + +**적합도**: **결정이 필요한 항목.** 지금은 문서와 현실이 어긋난 상태라 어느 쪽이든 +정해야 한다. 절충안은 **유닛 테스트 타깃은 그대로 두고 UI 테스트 타깃을 따로 만드는 +것**이다. + +--- + +## D. 검증 쪽 패턴 + +### D1. 스냅샷(골든) 테스트 + +뷰를 이미지로 렌더해 기준 이미지와 비교한다. + +**장점**: 레이아웃 회귀를 기계가 잡는다. 지금 사람이 카드로 보는 것의 일부가 넘어온다. +**단점**: 호스트 앱이 필요하고(C2), 기준 이미지 관리 비용이 있고, OS 버전이 오르면 +전부 깨진다. +**적합도**: 중간. C2를 하기로 하면 같이 검토. + +### D2. 산출물 검사 스크립트 + +entitlements·plist·버전 일치를 빌드 결과에서 확인한다. +**적합도**: **높다.** [`../verification.md`](../verification.md)에 근거를 적었다. +v1.3.0의 버그 두 개를 이것 하나가 잡았을 것이다. + +### D3. 정적 품질 검사 + +[`CURRENT.md`](CURRENT.md)의 기준선 일곱 개를 매번 세는 스크립트. +**적합도**: **높다.** 리팩터링 전에 기준선을 찍는 용도로 먼저 필요하다. + +--- + +## 한눈에 비교 + +| | 얻는 것 | 잃는 것 | 비용 | 되돌리기 | 제약 1 해결 | 추천 순서 | +|---|---|---|---|---|---|---| +| **B1** 순수 로직 추출 | 테스트 가능 영역 | 없음 | 소~중 | 쉬움 | ✅ | **1** | +| **D3** 정적 품질 검사 | 기준선·추세 | 없음 | 소 | 쉬움 | — | **2** | +| **D2** 산출물 검사 | 계약 검증 | 없음 | 소 | 쉬움 | — | **3** | +| **B3** 의존성 주입 정리 | 프리뷰·가짜 주입 | 조립 코드 증가 | 소~중 | 보통 | 부분 | **4** | +| **B5** 단방향 흐름(국소) | 화면 상태 테스트 | 직접 만든 규칙 | 중 | 보통 | ✅ | 5 | +| **A4** 레이어 분명히 | 방향 일관성 | 변환 코드 | 중 | 보통 | ✅ | 6 (B1의 결과) | +| **C2** 호스트 앱 | UI·제스처 검증 | CI 취약성 | 중 | 어려움 | ✅ | 결정 필요 | +| **C1** SPM 모듈 | 컴파일러 강제 | 프로젝트 재구성 | 대 | 어려움 | ✅ | 나중 | +| **B2** Repository | 저장소 교체 | `@Query` 포기 | 중~대 | 어려움 | 부분 | 보류 | +| **A2** MVVM | (제약 1 때문에 적음) | 파일 2배 | 중 | 보통 | ✕ | 보류 | +| **B4** Coordinator | 순환 의존 해소 | SwiftUI와 중복 | 중 | 보통 | ✕ | 보류 | +| **A3** TCA | 완전한 상태 검증 | 외부 의존·빌드 시간 | 대 | 매우 어려움 | ✅ | 권하지 않음 | + +## 제안하는 조합 (결정은 같이) + +**1단계 — 기준선과 안전망** (코드는 거의 안 건드린다) +D3으로 지금 숫자를 찍고, D2로 계약 검증을 커밋 시점으로 당긴다. + +**2단계 — 로직을 옮긴다** (B1) +`CURRENT.md`가 지목한 13곳을 값 순서로 `Shared/`에 내리고, 내릴 때마다 테스트를 +같이 쓴다. **이 단계에서 파일 구조는 안 바꾼다.** + +**3단계 — 조립을 정리한다** (B3) +`RootTabView`의 13가지 책임을 쪼개고, 싱글턴 11개를 주입으로 모은다. + +**4단계 — 그때 다시 판단한다** (C1/C2/B5) +2·3단계가 끝나면 남은 문제가 무엇인지 지금과 다르게 보일 것이다. 모듈 분리도 +호스트 앱도 그때 결정한다. + +**왜 이 순서인가**: 1·2단계는 되돌리기 쉽고 지금 있는 제약을 하나도 안 건드린다. +3단계부터 구조가 바뀌는데, 그때는 이미 테스트가 늘어 있어서 안전하다. **가장 위험한 +것(모듈 분리·호스트 앱)을 가장 늦게** 둔 것이 이 순서의 요점이다. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..53cfc89 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,37 @@ +# 아키텍처 문서 + +리팩터링을 시작하기 전에 지금을 기록해두는 자리다. 2026-08-22에 소스 전체를 훑고 +만들었다. + +| 문서 | 무엇이 있나 | +|---|---| +| [DECISIONS.md](DECISIONS.md) | **판단 문서.** 무엇을 정해야 하고 선택지가 무엇인지. 여기부터 읽는다 | +| [CURRENT.md](CURRENT.md) | **지금 구조.** 레이어별 현황, 결합이 센 다섯 곳, 품질 기준선 | +| [PATTERNS.md](PATTERNS.md) | **쓸 수 있는 설계와 패턴.** 13가지를 장단점·비용·적합도로 비교 | +| [REFACTOR-PLAN.md](REFACTOR-PLAN.md) | **무엇을 어떤 순서로 고칠 것인가.** 위험이 낮은 것부터 다섯 단계 | + +읽는 순서는 `CURRENT.md` → `PATTERNS.md`다. 지금 무엇이 문제인지 모른 채 패턴을 +고르면 유행을 고르게 된다. + +## 같이 보는 문서 + +- [`../verification.md`](../verification.md) — 무엇을 어떻게 검증하나. 테스트 코드만으로 + 안 되는 이유와 그 대안 +- [`../review-criteria.md`](../review-criteria.md) — 사람이 봐야 하는 코드와 안 봐도 + 되는 코드 +- [`../workflow.md`](../workflow.md) — 프롬프트 하나가 어떤 순서로 도는지 + +## 기준선을 다시 재려면 + +```bash +python3 tools/quality_baseline.py +``` + +리팩터링 전후를 비교하려면 **바꾸기 전에** 이걸 돌려서 숫자를 남겨둔다. +`CURRENT.md`의 "품질 기준선" 표가 2026-08-22 기준값이다. + +## 이 문서를 언제 고치나 + +- 구조를 실제로 바꿨을 때 (`CURRENT.md`) +- 패턴 하나를 채택하거나 기각했을 때 — **기각도 적는다.** 왜 안 골랐는지가 다음에 + 같은 논의를 반복하지 않게 한다 (`PATTERNS.md`) diff --git a/docs/architecture/REFACTOR-PLAN.md b/docs/architecture/REFACTOR-PLAN.md new file mode 100644 index 0000000..a77f361 --- /dev/null +++ b/docs/architecture/REFACTOR-PLAN.md @@ -0,0 +1,167 @@ +# 리팩터링 준비 + +**아직 아무것도 시작하지 않았다.** 이 문서는 "무엇을 어떤 순서로 할 것인가"에 대한 +제안이고, 착수는 같이 정한다. + +순서를 정할 때 쓴 기준 하나: **위험이 낮은 것부터.** 여기서 위험은 "잘못됐을 때 +알아차리기 어려운 정도"다. 테스트를 쓰는 일은 위험이 0이고, 조립을 바꾸는 일은 +높다. 그리고 앞 단계가 뒤 단계의 안전망이 된다. + +```mermaid +flowchart LR + S0["0단계
기준선
완료"] --> S1["1단계
테스트만 붙이기
위험 0"] + S1 --> S2["2단계
로직 추출
위험 낮음"] + S2 --> S3["3단계
중복 제거
위험 중간"] + S3 --> S4["4단계
조립 정리
위험 높음"] + S4 --> S5["5단계
다시 판단"] +``` + +--- + +## 0단계 — 기준선 (완료) + +`tools/quality_baseline.py`로 2026-08-22 값을 찍었다. 숫자는 +[`CURRENT.md`](CURRENT.md)에 있다. **바꾸기 전 값이 있어야 "좋아졌다"를 말할 수 있다.** + +같이 만든 `tools/artifact_check.sh`는 계약 검증용이다. 두 스크립트를 CI에 물릴지는 +1단계와 같이 정한다. + +--- + +## 1단계 — 옮기지 않고 테스트만 붙인다 (위험 0) + +**이미 `Shared/`에 있는데 테스트가 없는 것들이다.** 코드를 한 줄도 안 옮기므로 +동작이 바뀔 수 없다. 그런데 이 아홉 개가 앱에서 제일 자주 불린다. + +| # | 대상 | 줄 | 왜 먼저인가 | +|---|---|---:|---| +| 1 | `KoreanHoliday` | 199 | 음력 변환, 대체공휴일 4규칙, 연휴 겹침 회피. **가장 큰 무커버리지 덩어리**이고 주석이 2025년 어린이날 사례를 콕 집어 설명하는데 그 케이스를 지키는 테스트가 없다 | +| 2 | `TodoItem+Recurrence` | 96 | `TodoSnapshot` 쪽 복사본만 테스트가 있다. **앱·알림·위젯이 실제로 부르는 건 모델 쪽**인데 한 줄도 안 덮였다. 두 구현이 같아야 한다는 주석까지 있다 | +| 3 | `CalendarSnapshotBuilder` | — | 앱과 위젯이 같은 달력을 그리게 하는 핵심. 부품은 덮였는데 엮는 부분이 안 덮였다 | +| 4 | `CalendarSelection` | 25 | 순수 함수 넷. "숨긴 쪽을 저장한다"는 반전 규칙이 깨지면 **데이터가 통째로 사라진 것처럼 보인다.** 여섯 화면이 쓴다 | +| 5 | `MonthPageMetrics` | 68 | 이미 순수 struct인데 미테스트. `barCapacity` 경계는 맥 창 최소 크기의 근거이기도 하다 | +| 6 | `AnalyticsEvent`의 이름·파라미터 | 90 | `notification_open`이 Firebase 예약어라 이름을 바꿨다는 주석이 있는데 그 이름을 고정하는 테스트가 없다 | +| 7 | `WidgetDeepLink` | 14 | URL 왕복. 5줄짜리인데 테스트 0 | +| 8 | `CalendarMonth`의 `floorDiv` | — | 음수 처리를 주석이 일부러 설명하는데 테스트가 없다 | +| 9 | `Date+Korean`의 `dDayLabel`·`koreanTime` | — | D-DAY/D-n 경계. `koreanTime`은 `TimeExpressionParser`와 같은 로직의 두 번째 복사본이라 **둘이 같은지**도 같이 본다 | + +**끝났다고 판정하는 기준**: 아홉 개 전부에 테스트가 붙고, `MonthLayoutTests`처럼 +이름에 증상이 적혀 있고, 전체가 통과한다. 예상 테스트 수 +60~90건. + +**PR 단위**: 대상 하나에 PR 하나. 되돌릴 일이 없지만 리뷰가 쉬워진다. + +**위임**: `test-author` 에이전트 후보다. 규칙이 명확하고 반복적이다. + +--- + +## 2단계 — 뷰 안의 로직을 `Shared/`로 (위험 낮음) + +여기부터 코드가 움직인다. **한 번에 하나씩, PR 하나에 하나씩.** + +값이 큰 순서다. "값"은 (깨졌을 때의 손해) × (지금 검증이 없는 정도)로 봤다. + +| # | 무엇 | 지금 위치 | 옮길 곳(제안) | 테스트로 덮을 것 | +|---|---|---|---|---| +| 1 | 오전/오후 모호성 해소 정책 (56줄) | `QuickAddView:481-536` | `Shared/TimeSuggestionPolicy` | "11시~1시는 정오를 넘긴다", "6시~7시는 후보 셋" | +| 2 | 일정 확정 규칙 (반복×종일×종료) | `EventScheduleSheet:181-214` | `Shared/SchedulePlan` | 네 조합의 시작·종료 확정. 틀리면 데이터가 조용히 망가진다 | +| 3 | 목록 파생 규칙 넷 | `TodayTodoScreen:64-157` | `Shared/TodoListRules` | "반복은 지난 것이 아니다", "지난 디데이 제외", nil 날짜 4-case 비교 | +| 4 | 정렬 비교자 **통합** | `TodayTodoScreen:121` + `DayTodosContentView:286` | `Shared/TodoOrdering` | **두 화면이 같은 순서를 낸다** (지금 2단 vs 3단으로 갈려 있다) | +| 5 | 일정 라벨 정책 **통합** | `TodoRow:288` + `TodoDetailSheet:141` | `Shared/ScheduleLabel` | 멀티데이·시간만·날짜포함 분기 | +| 6 | 막대 넘침 계산 | `WeekBarsView:27-64` | `Shared/MonthEventLayout`에 합류 | 7열 카운팅, `+N` 경계 | +| 7 | 타임라인 픽셀 환산 | `DayTimelineView:201-248` | `Shared/TimelineLayout`에 합류 | 같은 공식 세 벌이 일치하는지 | +| 8 | 동기화 상태 → 문구·심볼 | `SettingsScreen:382-413` | `Shared/CloudSyncPresentation` | 5-state × 3출력 전수 | +| 9 | 칩 라벨 ("전체"/이름/"N개") | `CalendarScreen:231-242` | `Shared/CalendarChip` | "전체 == 개수 같을 때" 경계 | +| 10 | 알림 리드타임 라벨 | `CategoryEditorSheet:148` | `Shared/LeadTimeLabel` | 0/59/60/120 경계 | +| 11 | 디데이 라벨 | `TodayTodoScreen:628` | 1단계 9번과 합침 | — | +| 12 | 터치 판정(이동 임계·풀다운) | `CellTouchBridge:97-133` | `Shared/TouchDecision` | 주석에 "실기기에서만 재현"이라 적힌 버그 이력이 있다 | +| 13 | 저장 시 필드 전파 규칙 | `QuickAddView:592-659` | `Shared/TodoDraft` | `startDate == nil`이 일곱 필드에 퍼지는 규칙 | + +**하는 방법** (13번 다 같다) + +1. 순수 함수/값 타입으로 뽑아 `Shared/`에 새 파일로 만든다 +2. **테스트를 먼저 쓴다.** 지금 동작을 그대로 고정하는 테스트다 +3. 뷰가 그것을 부르게 바꾼다 +4. 빌드 + 테스트 + `quality_baseline.py`로 "뷰 안의 로직" 수치가 내려갔는지 확인 + +**끝났다고 판정하는 기준**: 스크립트의 "뷰 안의 비자명 로직"이 71 → 30 이하. + +**주의**: 4번과 5번은 **두 화면의 동작을 하나로 맞추는 것**이라 사용자에게 보이는 +변화가 있을 수 있다. 이 둘은 🔴(반드시 확인)이고 검증 카드가 필요하다. + +--- + +## 3단계 — 중복 걷어내기 (위험 중간) + +조사가 찾은 복사본 20건 중, 2단계에서 자동으로 사라지는 것을 빼고 남는 것들이다. + +| 무엇 | 몇 벌 | 어떻게 | +|---|---|---| +| 목록 재인덱싱 `move` | 2 | `Shared`로 하나 | +| `showsCalendarTag` | 2 | 한 글자도 안 다르다. 하나로 | +| `dismissKeyboard` | 3 | 뷰 확장 하나로 | +| 캘린더 가시성 필터 | 4 | 1단계 4번(`CalendarSelection`)으로 모은다 | +| 권한 토글 바인딩 | 3 | 공통 컴포넌트 하나 (`PermissionToggle`) | +| 에디터 시트 쌍 | 2 | 카테고리/캘린더가 알림 섹션 빼고 같다 | +| 목록 화면 쌍 | 2 | 위와 같은 이유 | +| `weekendKind(column:)` | 2 | `Shared`로 | +| 리스트 행 3종 세트 | 8 | 뷰 확장 하나 | + +**주의**: 에디터 시트와 목록 화면을 합치는 것은 **추상화를 하나 만드는 일**이라 +성격이 다르다. 잘못 합치면 나중에 갈라질 때 더 비싸다. 이 둘은 마지막에 하거나 안 +해도 된다. + +--- + +## 4단계 — 조립 정리 (위험 높음) + +`RootTabView`가 지고 있는 13가지를 쪼갠다. 여기부터는 앱 전체가 걸리므로 **1~3단계가 +끝나 테스트가 충분히 쌓인 뒤에** 한다. + +쪼갤 후보는 이렇다. + +- 시딩·iCloud 중복 병합 → `AppBootstrap` +- 알림 재예약 키 + 라이브 액티비티 핑거프린트 → **문자열 조립을 그만두고** 모델 + 변경 감지로 바꿀지 검토 (지금은 필드를 손으로 나열한다) +- 튜토리얼 탭 강제 이동 → 코디네이터 쪽으로 +- 권한 요청 순서 → 이미 `StartupPermissionRequest`가 있다. 거기로 모은다 + +**같이 볼 것**: 싱글턴 11개를 주입으로 모으는 일(`PATTERNS.md` B3)과 겹친다. +프리뷰가 살아나는 것이 부수 효과인데, 그게 생각보다 크다 — 지금은 화면 하나를 +따로 띄워볼 수 없다. + +--- + +## 5단계 — 그때 다시 판단 + +1~4단계가 끝나면 남은 문제가 지금과 다르게 보일 것이다. 그 시점에 정할 것들이다. + +- **호스트 앱을 붙일 것인가** (UI 테스트·제스처 검증). 지금 R7이 "UI 테스트도 + 만든다"고 적어놓고 못 하고 있어서 **어느 쪽이든 정해야 한다** +- **SPM 모듈로 쪼갤 것인가** (컴파일러가 의존 방향을 강제) +- **화면 상태를 struct 하나로 모을 것인가** (`PATTERNS.md` B5) + +--- + +## 규칙 — 이 리팩터링을 하는 동안 + +1. **PR 하나에 대상 하나.** 되돌리기가 전부다. +2. **테스트를 먼저 쓰고 옮긴다.** 옮기고 나서 쓰면 옮기다 생긴 버그를 테스트가 + 그대로 고정한다. +3. **동작을 바꾸지 않는다.** 바꿔야 할 것을 발견하면 별도 항목으로 적고 따로 한다. + 리팩터링 PR에 기능 변경이 섞이면 사고가 나도 원인을 못 가린다. +4. **매 PR에 `quality_baseline.py` 결과를 붙인다.** 숫자가 안 내려가면 그 PR은 + 리팩터링이 아니라 이사다. +5. **`docs/review-criteria.md`의 등급을 PR마다 붙인다.** 4번(정렬)과 5번(라벨)처럼 + 사용자에게 보이는 변화가 있는 것은 🔴이다. + +## 규모 감 + +| 단계 | 대상 | 예상 | 위험 | +|---|---|---|---| +| 1 | 9개에 테스트 | 2~3일 (위임 가능) | 없음 | +| 2 | 13개 추출 | 4~6일 | 낮음. 둘만 🔴 | +| 3 | 중복 9종 | 2~3일 | 중간 | +| 4 | 조립 정리 | 3~5일 | 높음 | + +**한 번에 다 하지 않는다.** 1단계만 해도 테스트가 두 배가 되고, 그것만으로 다음 +단계가 훨씬 안전해진다. diff --git a/docs/harness/README.md b/docs/harness/README.md index b9592e8..ae384d1 100644 --- a/docs/harness/README.md +++ b/docs/harness/README.md @@ -102,7 +102,7 @@ CLAUDE.md 규칙 본문 (AI가 매 세션 읽는다) | R10 읽는 범위를 좁힌다 | v1.1.0 보고서 | M15 | 그리고 그 위에 **최종 지표 M12 프롬프트당 토큰**이 있다. 규칙 하나가 담당하지 않고 -열 개가 전부 여기로 모인다 — 다른 지표가 다 좋아져도 M12가 안 내려가면 그 버전의 +열한 개가 전부 여기로 모인다 — 다른 지표가 다 좋아져도 M12가 안 내려가면 그 버전의 하네스는 실패로 적는다. **v1.2.0에서 처음 채점됐다.** R2·R7·R10이 효과, R9가 무효, 나머지 여섯은 표본 diff --git a/docs/harness/rules.md b/docs/harness/rules.md index 329b972..1e585fe 100644 --- a/docs/harness/rules.md +++ b/docs/harness/rules.md @@ -74,6 +74,27 @@ v1.4.0에서도 표본이 안 차면 그때 판단한다. | M13 | 상위 모델 비중 | 맥락 | 가장 비싼 모델이 어시스턴트 턴의 몇 %를 먹었나 | | M14 | 위임 비중 | ↑ | 서브에이전트(사이드체인)가 돈 턴의 비율 | | M15 | 전체 읽기 비율 | ↓ | 범위(`offset`/`limit`)를 안 준 `Read`가 전체 `Read` 중 몇 % | +| M16 | 임시 코드 잔존 | ↓ 0 | 커밋에 남은 `// TEMP:` 표식 수 (R11) | +| M17 | 같은 증상 재시도 | ↓ | 한 증상에 대해 고치고 다시 실패한 최대 횟수 (R1) | +| M18 | 규칙↔권한 불일치 | ↓ 0 | 규칙이 금지한 것이 `allow`에 있거나, 규칙이 요구하는 것이 `allow`에 없는 항목 수 | + +### 아직 스크립트에 없는 것 (2026-08-22) + +M16·M17·M18은 v1.3.0 보고서에서 신설했고 **아직 `harness_report.py`에 구현이 +없다.** M16은 `tools/quality_baseline.py`가 세고 있고, 나머지 둘은 다음 구간에 +붙인다. 구현 전까지는 정성적으로 적는다. + +**고쳐야 하는 정의 넷**도 같이 적어둔다. v1.3.0에서 드러난 것들이다. + +| 지표 | 지금 | 어떻게 고칠 것인가 | +|---|---|---| +| M15 | `Read` 도구만 센다 | Bash의 파일 읽기도 센다. `sed -n 'a,bp'`·`head -n`·`grep -n`은 범위 있음, `cat`은 전체 | +| M6 | 머지 커밋 제목을 본다 | `git log --no-merges`로 브랜치 커밋을 본다 | +| M11 | MCP 도구만 센다 | Bash의 `xcrun simctl`도 센다. 예외분(PR 스크린샷)은 따로 나눈다 | +| M4 | 사용자 발화만 센다 | AI가 측정 없이 감각어를 쓴 것을 센다 — 규칙이 금지한 것이 그쪽이다 | + +정의를 고치면 **스키마 번호를 올리고 이전 버전 전체를 다시 돌려** 원장을 재생성한다. +한 버전만 새 정의로 재면 그 순간 추세가 거짓말이 된다. M5·M6·M9·M13은 담당 규칙이 없는 **맥락 지표**다. 특정 규칙으로 움직이는 게 아니라 그 버전이 어떤 성격이었는지 읽는 데 쓴다. @@ -127,6 +148,53 @@ M12에서 캐시 읽기를 빼지 않는 것이 그 이유다. 빼면 "컨텍스 ## 원장 +### v1.3.0 채점 결과 (2026-08-22) + +**프롬프트 31개.** 여전히 50개 미만이라 비율 지표는 흔들린다. 다만 v1.2.0과 같은 +방향으로 움직인 것들은 두 구간 연속이므로 "효과"로 올렸다. + +| 규칙 | 담당 | v1.2.0 | v1.3.0 | 판정 | +|---|---|---:|---:|---| +| R1 | M3 · M1 | 15.9% · 17.1% | **4.8% · 12.9%** | **효과** (2회차) | +| R2 | M11 · M10 | 26 · 108 | 18 · 112 | **효과** — 단 M11은 과소 계수(아래) | +| R3 | M1 · 통과율 | 17.1% | 12.9% · 카드 9건 | **효과** | +| R4 | M4 | 14.3% | 0.0% | **미확인** — 지표가 규칙과 다른 것을 본다(아래) | +| R5 | M2 | 2.9 | **1.67** | **부분 효과** — 목표 1.0 미달 | +| R6 | M7 · M8 | 0 · 0 | 0 · 0 | **효과** | +| R7 | M10 | 108 | 112 | **부분 효과** — 못 덮는 종류가 세 번 나왔다 | +| R8 | M13 | 100% | 100% | **미확인** — 3회차. 모델이 고정이다 | +| R9 | M14 | 0% | 0% | **실행 불가** — 무효가 아니다(아래) | +| R10 | M15 | 33.3% | 100.0% | **측정 불능** — 정의가 무너졌다(아래) | + +**최종 지표 M12: 15,026,683 → 6,743,875 (55% 감소).** 두 구간 연속 절반이 됐다. +직접 원인은 턴 수(1,259 → 563)이고, 그건 읽기·편집을 Bash 한 번에 묶은 결과다. + +이번 채점에서 **지표 쪽에 문제가 셋 드러났다.** + +1. **M15·M6가 측정 불능이 됐다.** M15는 `Read` 도구만 세는데 이번 구간의 읽기는 + 대부분 `sed -n`·`grep`으로 나갔다. M6는 스쿼시 머지라 커밋 제목에 접두사가 없다. + **둘 다 "규칙을 안 지켰다"가 아니라 "여기서는 안 보인다"는 뜻이다.** 정의를 고치기 + 전까지 두 지표의 값은 판정에 쓰지 않는다. +2. **M11이 과소 계수다.** MCP 도구 호출만 세고 Bash의 `xcrun simctl`은 안 센다. + 그리고 권한 파일이 그 Bash 경로를 자동 승인으로 열어두고 있었다 — 규칙은 금지, + 권한은 허용이었다. +3. **M4는 사용자 발화만 센다.** R4가 금지한 것은 AI가 측정 없이 감각어를 쓰는 + 것인데 지표는 사용자가 감각어를 쓴 비율을 본다. 서로 다른 것을 보고 있다. + +**규칙 변경**: R1 확장(증상이 그대로면 경로부터 확인), R2 단서(예외를 썼으면 얻은 +것을 적는다), R9 개정(조사류는 묻지 않고 위임 — "사용자 요청 시에만"이 규칙의 실행을 +막고 있었다), **R11 신설**(진단용 코드에 표식을 달고 커밋 전에 걷는다). + +### R11 (2026-08-22 도입) + +| 규칙 | 담당 지표 | 기준 | 목표 | 판정 | +|---|---|---|---|---| +| R11 진단용 코드는 표식 달고 걷는다 | M16 임시 코드 잔존 | (미측정) | 0 | — | + +넣은 이유: v1.3.0에서 탐침 파일 하나와 추적 로그 두 줄이 커밋될 뻔했고, 사용자가 +"불필요한 코드는 제거하고"라고 말한 뒤에야 걷었다. PR 템플릿에 체크박스가 있었지만 +스스로 체크하는 것이라 작동하지 않았다. `tools/quality_baseline.py`가 표식을 센다. + ### v1.2.0 채점 결과 (2026-08-19) **프롬프트 35개.** 판정 기준의 "50개 미만이면 미확인"에 걸려 비율 지표 다섯을 diff --git a/docs/review-criteria.md b/docs/review-criteria.md new file mode 100644 index 0000000..0121f7e --- /dev/null +++ b/docs/review-criteria.md @@ -0,0 +1,116 @@ +# 무엇을 사람이 봐야 하는가 + +혼자 만드는 앱에서 가장 비싼 자원은 **사람이 코드를 읽는 시간**이다. 전부 읽으면 +AI를 쓰는 의미가 없고, 안 읽으면 조용히 망가진다. 그 사이 어디에 선을 그을지에 +대한 기준이다. + +먼저 용어를 나눈다. 이 문서에서 "확인"은 두 가지 다른 일을 뜻한다. + +| | 무엇을 하나 | 비용 | +|---|---|---| +| **코드 확인** | diff를 읽고 판단한다 | 분 단위. 집중이 필요하다 | +| **동작 확인** | 앱을 켜서 만져본다 | 분~시간. 기기·계정·상태가 필요할 때가 있다 | + +둘은 따로 정한다. 코드는 안 봐도 되는데 동작은 봐야 하는 것이 있고(예: 위젯 렌더), +그 반대도 있다(예: 삭제 로직 리팩터링). + +## 판단 기준 — 두 축 + +```mermaid +flowchart TD + S([이 변경을 사람이 봐야 하나]) --> M{기계가 틀렸다고
말해줄 수 있나} + M -->|예| R{틀렸을 때
되돌리기 쉬운가} + M -->|아니오| R2{틀렸을 때
되돌리기 쉬운가} + + R -->|쉽다| G["🟢 안 봐도 된다
CI만 초록이면 통과"] + R -->|어렵다| Y["🟡 훑어본다
diff 제목과 요약만"] + R2 -->|쉽다| Y + R2 -->|어렵다| RD["🔴 반드시 본다
diff를 읽고, 앱을 켠다"] +``` + +**"기계가 말해줄 수 있나"** — 테스트·빌드·CI·스크립트 중 무엇이든 실패로 알려주면 +예다. 사람이 안 보면 아무도 모르는 것이면 아니오다. + +**"되돌리기 쉬운가"** — 커밋을 되돌리면 끝나면 쉽다. 데이터가 이미 바뀌었거나, +사용자에게 나갔거나, 스토어에 올라갔으면 어렵다. + +## 이 저장소에 적용하면 + +### 🟢 안 봐도 되는 것 + +CI가 초록이면 그대로 통과시켜도 되는 것들이다. + +| 무엇 | 왜 | +|---|---| +| `Mosco/Shared/`의 순수 로직 **중 테스트가 붙은 변경** | 테스트가 판정한다. 112건이 이 자리를 지킨다 | +| 테스트 코드 자체의 추가 | 틀리면 빨갛다. 안 틀리면 안전망이 는 것뿐 | +| 문서 오타·서식·링크 | 되돌리기가 한 줄 | +| 주석 추가 | 동작이 안 바뀐다 | +| 기계적 이름 바꾸기 (컴파일러가 전수 확인) | 빌드가 판정한다 | +| 죽은 코드 삭제 **중 `deadcode_audit.py`가 지목한 것** | 도구가 근거를 준다 | + +### 🟡 훑어보는 것 + +diff 전체를 읽을 필요는 없고, **무엇을 왜 바꿨는지 한 문단과 파일 목록**만 보면 +되는 것들이다. 이상하면 그때 파고든다. + +| 무엇 | 무엇을 볼 것인가 | +|---|---| +| 화면 안쪽 레이아웃·간격·색 | 규범을 지켰다고 적혀 있는지. 새 색·새 반경을 만들지 않았는지 | +| 새 순수 로직 + 그 테스트 | 테스트 이름이 증상을 말하는지 | +| 리팩터링 (동작 불변 주장) | **무엇이 안 바뀐다고 주장하는지**. 그 주장의 근거가 테스트인지 | +| CI·스크립트 변경 | 그 변경이 실제로 돌았는지 (CI 로그) | +| 위젯 코드 | 앱과 공유하는 타입을 건드렸는지 | + +### 🔴 반드시 보는 것 + +여기는 예외 없이 사람이 본다. **기계가 판정 못 하고, 틀리면 되돌리기 어렵다.** + +| 무엇 | 왜 | 코드 | 동작 | +|---|---|---|---| +| **삭제·초기화 흐름** | 되돌릴 수 없다. `TodoCategory.delete`, `resetAllData`, 스와이프/메뉴 삭제 | ✅ | ✅ | +| **SwiftData 스키마 변경** | 마이그레이션. 기존 사용자 데이터가 걸려 있다 | ✅ | ✅ | +| **entitlements · `Info.plist` · 빌드 설정** | 빌드도 CI도 통과하면서 틀릴 수 있다. v1.3.0에 두 번 물렸다 | ✅ | ✅ | +| **권한을 묻는 순서·시점** | 한 번 거부되면 앱에서 다시 못 묻는다 | ✅ | ✅ | +| **사용자에게 보이는 문구** | 규범 11개가 걸려 있고, 나가면 못 주워담는다 | ✅ | — | +| **기존 동작을 없애는 변경** | 쓰던 사람이 "어? 왜 안 되지"를 겪는다. 스와이프 삭제 제거가 그랬다 | ✅ | ✅ | +| **동기화·앱 그룹 경계** | 앱·위젯·잠금화면 세 프로세스가 걸려 있다 | ✅ | ✅ | +| **릴리스 산출물** (버전, 릴리스 노트, 스토어 제출물) | 외부로 나간다 | ✅ | — | +| **조건부 컴파일이 들어간 변경** | 한쪽 플랫폼에서만 도는 코드는 다른 쪽에서 안 도는 것을 아무도 안 알려준다 | ✅ | ✅ | + +### 판정이 갈리면 + +**🔴로 올린다.** 이 프로젝트에서 지금까지 손해를 본 쪽은 항상 "안 봐도 되겠지"였다. + +## AI가 해야 하는 일 — 등급을 스스로 붙인다 + +기준이 있어도 사람이 매번 분류하면 그 분류가 일이 된다. **PR을 열 때 AI가 등급을 +붙이고 근거를 적는다.** + +PR 본문에 이런 줄이 들어간다. + +``` +## 확인 등급 + +🔴 Mosco/App/App-Mac.entitlements — 샌드박스 추가. 빌드로 판정 불가, 되돌리려면 재제출 +🟡 Mosco/App/Features/Calendar/DayTimelineView.swift — 현재 시각 줄. 규범 확인함 +🟢 Mosco/MoscoTests/TimelineLayoutTests.swift — 테스트 4건 추가 +``` + +사용자는 🔴만 읽으면 된다. 🟡은 시간이 있을 때, 🟢은 CI를 믿는다. + +**등급을 낮게 매기는 것이 AI에게 이득이 되면 안 된다.** 그래서 이렇게 둔다. + +- 🔴을 🟡로 낮춰 적었는데 나중에 문제가 나오면, 그 사례를 `docs/TRAPS.md`에 남긴다. +- 등급 분포는 매 릴리스 보고서에 적는다. 🔴이 0인 PR이 계속 나오면 기준이 + 느슨해진 것이다. + +## 이 기준이 바뀌어야 할 때 + +- 새 검증 수단이 생기면 🔴이 🟡로 내려온다. 예를 들어 **산출물 검사 스크립트가 + 생기면 entitlements는 🟡가 된다** — 기계가 말해주기 시작하니까. +- 반대로 사고가 나면 올라간다. + +즉 이 표는 고정된 규범이 아니라 **지금 우리가 무엇을 기계에 맡길 수 있는지에 대한 +현재 상태**다. `docs/verification.md`에 적은 도구들이 하나씩 생길 때마다 이 표를 +같이 고친다. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..10bbed2 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,193 @@ +# 검증 체계 + +무엇을, 언제, 누가 판정하는가. 그리고 **테스트 코드만으로는 왜 부족한가.** + +이 문서는 2026-08-22에 나온 질문에서 시작했다. 원문 그대로 옮긴다. + +> 검증에 관해서는 지금 내 생각으로 크게 2가지로 나눌 수 있다고 생각하거든, +> 기능 그 자체에 대한 검증, 지속 가능한 코드로 작성하는지 코드 퀄리티에 대한 검증 +> 이 2가지가 내가 생각하는 검증이야 + +## 먼저, 이 두 분류에 대한 평가 + +**뼈대는 맞다.** "동작하는가"와 "계속 고칠 수 있는가"는 실제로 성질이 다른 문제이고, +전자는 사용자가, 후자는 미래의 나 자신이 손해를 본다. 나눠서 보는 게 맞다. + +다만 **이 프로젝트의 실제 사고 기록을 보면 두 칸으로는 안 들어가는 것이 반복해서 +나온다.** v1.3.0 한 버전에서 잡힌 버그 넷을 놓고 보자. + +| 버그 | 기능 검증인가 | 품질 검증인가 | +|---|---|---| +| 스와이프 삭제가 안 되던 것 | 애매 — 기능은 멀쩡했고 **화면이 다른 모드**였다 | 아니다 | +| KVS entitlement 누락 | 아니다 — 코드는 정상, **빌드 산출물의 서명**이 문제 | 아니다 | +| 그 entitlement가 시뮬레이터 실행을 막던 것 | 아니다 — **환경에 따라** 다르게 동작 | 아니다 | +| 맥에서 날씨가 안 불리던 것 | 반쯤 — iOS는 정상, **맥에서만** 호출 경로가 안 열림 | 아니다 | + +**넷 중 어느 것도 두 칸에 깔끔히 안 들어간다.** 그래서 칸을 넷으로 늘리는 것을 +제안한다. + +## 제안하는 분류 — 네 종류 + +### 1. 기능 검증 — "의도한 대로 도는가" + +입력을 넣으면 기대한 출력이 나오는가. 날짜 계산, 반복 전개, 시각 파싱, 정렬 규칙, +권한 진리표 같은 것들. + +**이게 테스트 코드가 가장 잘하는 영역이고, 지금 112건이 여기 있다.** + +### 2. 계약 검증 — "약속한 모양이 지켜지는가" + +코드가 아니라 **코드 바깥의 약속**이다. 이 프로젝트에는 이런 것들이 있다. + +- entitlements (App Group, iCloud, KVS, 샌드박스) +- `Info.plist` 키 (위치·알림 사용 설명, 수출 규정) +- 앱과 위젯이 같은 버전인가 (다르면 업로드가 거부된다) +- 프로세스 간 `Codable` 계약 (`TodoActivityAttributes`) +- App Group 컨테이너 경로가 앱·위젯·테스트에서 같은가 +- 애널리틱스 이벤트 이름과 `PRIVACY.md`가 맞는가 + +**컴파일도 통과하고 테스트도 통과하는데 틀릴 수 있다.** v1.3.0의 KVS 건이 정확히 +그랬다 — 실기기는 되고 시뮬레이터는 안 되고 CI는 초록이었다. + +### 3. 환경 검증 — "다른 데서도 같은가" + +같은 코드가 환경에 따라 다르게 도는 것. iOS / Mac Catalyst / 시뮬레이터 / 실기기 / +위젯 프로세스 / 잠금화면 인텐트. + +조건부 컴파일(`#if targetEnvironment(macCatalyst)`)이 있는 순간 이 축이 생긴다. +지금 이 프로젝트에는 그런 자리가 여섯 군데 있다. + +### 4. 품질 검증 — "계속 고칠 수 있는 모양인가" + +지속 가능성. 여기가 제일 애매하고, 그래서 뒤에서 따로 다룬다. + +## 두 번째 축 — 언제 판정이 나오는가 + +분류만큼 중요한 것이 **판정 시점**이다. 같은 문제라도 늦게 잡을수록 비싸다. + +```mermaid +flowchart LR + E["편집 직후
초"] --> C["커밋 전
분"] + C --> P["PR / CI
분~시간"] + P --> H["사람 확인
시간~일"] + H --> S["심사·출시
일~주"] +``` + +v1.3.0의 실제 사례를 이 축에 얹어보면 무엇이 문제인지 바로 보인다. + +| 문제 | 실제로 잡힌 시점 | 잡을 수 있었던 가장 이른 시점 | +|---|---|---| +| 스와이프 삭제 오진 | 사람 확인 (5회 왕복) | **편집 직후** — 그 코드가 도는지 로그 한 줄 | +| KVS entitlement | 사람 확인 (실기기 로그) | **커밋 전** — 산출물 entitlement 검사 | +| 시뮬레이터 실행 불가 | 사람 확인 (스크린샷 찍다가) | **PR/CI** — 시뮬레이터에 설치·실행해보기 | +| 맥 날씨 미호출 | 사람 확인 | **PR/CI** — 맥에서 실행 | + +**넷 다 원래 잡혔어야 할 시점보다 두세 칸 늦게 잡혔다.** 그리고 늦게 잡힌 대가는 +전부 사람의 왕복으로 지불됐다. + +## 그래서, 테스트 코드만으로 검증할 수 있는가 + +**아니다.** 위 표가 그 답이다. v1.3.0에서 잡힌 문제 넷 중 **테스트 코드로 잡을 수 +있었던 것은 0개**다. + +그렇다고 테스트가 쓸모없다는 뜻은 전혀 아니다. 테스트는 **기능 검증에서는 +독보적**이고, 지금 112건이 날짜 경계·반복 전개·시각 파싱 같은 반복해서 깨지던 +자리를 실제로 지키고 있다. 문제는 **테스트가 못 닿는 세 영역을 그동안 전부 사람에게 +떠넘겨왔다**는 것이다. + +검증 종류마다 맞는 수단이 다르다. 이렇게 붙이는 것을 제안한다. + +| 종류 | 수단 | 시점 | 지금 상태 | +|---|---|---|---| +| 기능 | 유닛 테스트 (Swift Testing) | 편집~커밋 | ✅ 112건 | +| 기능 (화면 흐름) | UI 테스트 (XCUITest) | PR/CI | ❌ 호스트 앱이 없어 못 쓴다 | +| 계약 | **산출물 검사 스크립트** | 커밋~CI | ❌ 없음 (초안 있음) | +| 환경 | **플랫폼 매트릭스 빌드** | CI | ⚠️ 절반 — Catalyst 빌드는 추가됐지만 실행은 안 해본다 | +| 환경 (실행) | 시뮬레이터 설치·실행 스모크 | CI | ❌ 없음 | +| 품질 (셀 수 있는 것) | **정적 검사 스크립트** | 커밋~CI | ⚠️ `deadcode_audit.py`가 있지만 어디에도 안 물려 있다 | +| 품질 (판단이 필요한 것) | 규범 문서 + 리뷰 | PR | ⚠️ 규범은 있는데 대조하는 절차가 없다 | +| 최종 | 사람 검증 카드 | 사람 | ✅ `/handoff` | + +## 품질 검증을 어떻게 셀 것인가 + +여기가 제일 어렵다. "지속 가능한 코드"는 느낌이라 그대로 두면 판정이 매번 달라진다. +**셀 수 있는 것과 판단이 필요한 것을 나누는 것**이 유일한 방법이다. + +### 셀 수 있는 것 — 기계가 판정 + +이번 전수 조사에서 실제로 세어본 것들이다. 즉, 스크립트로 만들 수 있다는 뜻이다. + +| 무엇 | 지금 값 | 왜 이게 품질인가 | +|---|---|---| +| 뷰 안에 있는 비자명 로직 | **50곳 이상** | 테스트를 쓸 수 없는 코드의 양 그 자체 | +| 한 파일이 지는 책임 수 | 최대 11개 (`QuickAddView` 679줄) | 고칠 때 읽어야 하는 양 | +| 같은 로직의 복사본 | **20건** (라벨 정책, 정렬 비교자, 재인덱싱…) | 한쪽만 고쳐서 갈라진 사례가 이미 있다 | +| Feature 간 순환 의존 | 1쌍 (Calendar ↔ Settings) | 한쪽을 못 떼어낸다 | +| 순수 로직 중 테스트 없는 것 | 9개 (공휴일 계산, 반복 판정 모델 쪽…) | 덮을 수 있는데 안 덮은 것 | +| 토큰 우회 | 폰트 33곳, 여백 42곳 | 규범이 실제로 안 지켜지는 양 | +| 임시 코드 잔존 | (표식 도입 후 측정) | 커밋에 섞여 들어간 디버그 흔적 | + +**이 일곱 개를 매 릴리스에 세면 "품질이 좋아지고 있는가"에 숫자로 답할 수 있다.** +지금은 답할 수 없다. + +### 판단이 필요한 것 — 사람 또는 규범 대조 + +- 이 추상화가 과한가, 모자란가 +- 이 이름이 무엇인지 말하는가 +- 주석이 "왜"를 적었는가, "무엇"을 되풀이했는가 +- 이 결정이 나중에 되돌릴 수 있는 크기인가 + +이건 셀 수 없다. 대신 **되돌린 기록을 남기는 것**으로 대신해왔고 +(`DesignSystem/README.md`, `docs/TRAPS.md`) 그 방식은 실제로 작동했다 — 규범 드리프트 +지표(M8)가 두 버전 연속 0이다. + +## 일관된 품질을 만드는 장치 + +질문의 나머지 절반은 "AI가 일관된 품질로 코드를 작성하게 하는 검증 시스템"이었다. +이 프로젝트의 지난 네 버전이 알려준 것은 **문서보다 강제 장치가 세다**는 것이다. + +| 층 | 예 | 세기 | 잊히나 | +|---|---|---|---| +| 문서에 적힌 규칙 | `CLAUDE.md` R1~R10 | 약 | 잊힌다. 같은 요청이 세 번 나온 적 있다 | +| 절차 스킬 | `/intake` `/handoff` | 중 | 부를 때만 | +| 템플릿 칸 | PR 템플릿 다섯 칸 | 중 | 빈칸이 눈에 띈다 | +| 기계 검사 | CI, 테스트, 스크립트 | 강 | 안 잊힌다 | +| **권한 설정** | `.claude/settings*.json` | 최강 | 안 잊힌다. 우회도 못 한다 | + +**v1.3.0에서 이게 실증됐다.** 시뮬레이터를 열려고 했을 때 막은 것은 R2 문서가 +아니라 권한 파일의 `deny`였다. 그리고 그 파일을 AI가 스스로 고치려 하자 그것도 +막혔다. + +다만 같은 조사에서 **그 강제 장치가 반쯤 새고 있다**는 것도 드러났다. `deny`는 MCP +도구 두 개만 막는데, `allow` 쪽에 `Bash(xcrun simctl *)`가 열려 있다. 규칙과 권한이 +서로 다른 말을 하고 있으면 강제 장치가 아니다. + +→ **제안**: 규칙을 하나 만들 때마다 "이걸 문서로 둘 것인가, 템플릿 칸으로 둘 것인가, +기계 검사로 둘 것인가, 권한으로 둘 것인가"를 같이 정한다. 그리고 **규칙과 권한 +파일의 불일치를 세는 검사**를 만든다. + +## 다음에 만들 것 (우선순위) + +전부 만들자는 게 아니라, 값이 큰 순서로 적는다. 실제 착수는 같이 정한다. + +1. **산출물 검사 스크립트** — entitlements, plist 키, 앱·위젯 버전 일치. 한 번 + 만들면 계약 검증 전체를 커밋 시점으로 당긴다. v1.3.0의 두 버그를 다 잡았을 것이다. +2. **품질 지표 스크립트** — 위 일곱 개를 세는 것. 리팩터링을 시작하기 전에 + **기준선**을 찍어둬야 나중에 좋아졌는지 말할 수 있다. +3. **호스트 앱 결정** — UI 테스트를 쓸 것인지. 쓰려면 테스트 타깃 구성을 바꿔야 + 하고, 그러면 CI 서명 문제가 다시 온다. 안 쓰기로 하면 R7에서 "UI 테스트도 + 만든다"를 빼야 한다. **지금은 문서와 현실이 다르다.** +4. **시뮬레이터 스모크** — CI에서 앱을 설치·실행해 몇 초 살아 있는지만 본다. + v1.3.0의 시뮬레이터 실행 불가 버그를 CI가 잡았을 것이다. +5. **규칙↔권한 대조 검사** — 위에 적은 그것. + +## 정리 — 원래 질문에 대한 답 + +- **두 분류는 뼈대로 맞다.** 다만 계약과 환경을 추가해 넷으로 나누는 것을 제안한다. + 이 프로젝트에서 실제로 물린 버그가 그 두 칸에 있었다. +- **테스트 코드만으로는 안 된다.** v1.3.0에서 잡힌 문제 넷 중 테스트로 잡을 수 + 있었던 것은 없다. 종류마다 다른 수단이 필요하다. +- **품질 검증은 셀 수 있는 것과 판단이 필요한 것으로 다시 나눈다.** 셀 수 있는 + 일곱 개는 이미 세어봤고 스크립트로 만들 수 있다. +- **일관성은 문서가 아니라 층위로 만든다.** 문서 < 템플릿 < 기계 검사 < 권한. + 규칙마다 어느 층에 둘지 같이 정한다. diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..748335e --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,125 @@ +# 워크플로우 + +프롬프트 하나가 들어왔을 때 무엇이 어떤 순서로 도는지. **지금 실제로 도는 것**을 +그린 것이고, 앞으로 이 순서가 바뀔 때마다 여기를 같이 고친다. + +각 단계에 근거 문서를 붙여뒀다. 근거가 없는 칸이 있으면 그건 관행이지 규칙이 +아니라는 뜻이다 — 그런 칸은 아래 "근거가 없는 칸"에 따로 모아뒀다. + +## 전체 그림 + +```mermaid +flowchart TD + P([프롬프트 도착]) --> C{어떤 종류인가} + + C -->|여러 항목·목록| INTAKE["/intake
쪼개기 → 되묻기 → 백로그 → 순서"] + C -->|릴리스| REL["RELEASING.md
0~9단계"] + C -->|단일 작업| SIZE + + INTAKE --> SIZE[작업 크기 한 줄] + SIZE --> BR[브랜치 생성] + BR --> CANON{규범·함정
영역인가} + CANON -->|색·문구·컴포넌트| RD1[DesignSystem/README.md 읽기] + CANON -->|플랫폼 함정| RD2[docs/TRAPS.md 읽기] + CANON -->|아니오| SURVEY + RD1 --> SURVEY + RD2 --> SURVEY + + SURVEY{조사 범위} -->|좁다| NARROW[grep으로 줄 잡고
그 언저리만 읽기] + SURVEY -->|넓다| DELEGATE[서브에이전트에 위임] + NARROW --> EDIT + DELEGATE --> EDIT + + EDIT[편집
되돌리기 쉬운 크기] --> BUILD[빌드] + BUILD --> TEST[테스트] + TEST --> APPLIED{바꿨다고 한 것이
실제로 들어갔나} + APPLIED -->|스크립트 편집| GREP[grep으로 되읽기] + APPLIED -->|동작을 문구로 약속| PATH[그 코드 경로 확인] + GREP --> COVER + PATH --> COVER + + COVER{테스트로 덮이나} -->|예| WRITE[테스트 작성] + COVER -->|아니오| WHY[이유 적고 카드로] + WRITE --> SCREEN + WHY --> SCREEN + + SCREEN{화면 확인이
필요한가} -->|예| HANDOFF["/handoff
검증 카드 4줄"] + SCREEN -->|아니오| MSG + HANDOFF --> HUMAN[[사람이 밟는다]] + HUMAN --> LOG[verify-log.tsv에 한 줄] + LOG --> MSG + + MSG[커밋 메시지 제안] --> ASK{커밋 요청받았나} + ASK -->|아니오| END1([끝 — 항목별 상태 반환]) + ASK -->|예| COMMIT[커밋] --> PR[PR 열기] --> CI[CI] + CI -->|초록| MERGE[스쿼시 머지] --> END2([끝]) + CI -->|빨강| EDIT +``` + +## 단계별 근거 + +| # | 단계 | 근거 | 없으면 | +|---|---|---|---| +| 1 | 목록이면 `/intake` | `CLAUDE.md` R5, `.claude/skills/intake/SKILL.md` | 조용히 일부만 처리되고 다음 프롬프트가 "아직 안 됐는데"가 된다 | +| 2 | 작업 크기 한 줄 | `CLAUDE.md` R8 | 사용자가 모델을 내릴 판단 재료가 없다 | +| 3 | 브랜치 생성 | `CONTRIBUTING.md` 브랜치 전략 | `main`이 더러워진다. 예외 없음 | +| 4 | 규범·함정 읽기 | `CLAUDE.md` R6, `DesignSystem/README.md`, `docs/TRAPS.md` | 되돌린 시도를 다시 제안한다 | +| 5 | 읽는 범위 좁히기 / 넓으면 위임 | `CLAUDE.md` R10 · R9 | 컨텍스트가 부풀고 그 뒤 모든 턴이 비싸진다 | +| 6 | 편집 | `CLAUDE.md` 일하는 방식 | 되돌리기 어려운 덩어리가 된다 | +| 7 | 빌드 → 테스트 | `CLAUDE.md` R1 | 컴파일 에러를 사용자가 발견한다 | +| 8 | 반영 확인 | `CLAUDE.md` R1 (2026-08-19 추가) | 빌드는 통과했는데 의도한 변경이 안 들어가 있다 | +| 9 | 테스트 남기기 | `CLAUDE.md` R7 | 다음에 깨졌을 때 무엇이 깨졌는지 모른다 | +| 10 | 화면이면 `/handoff` | `CLAUDE.md` R2 · R3, `.claude/skills/handoff/SKILL.md` | "확인해줘"가 되고 확인이 대충 된다 | +| 11 | 검증 결과 기록 | `.claude/skills/handoff/SKILL.md` | R3의 통과율 데이터가 안 쌓인다 | +| 12 | 커밋은 요청받을 때만 | `CLAUDE.md` 일하는 방식 | 사용자가 안 본 것이 이력에 박힌다 | +| 13 | PR 템플릿 다섯 칸 | `.github/pull_request_template.md` | R1·R9의 기록 자리가 사라진다 | +| 14 | CI | `.github/workflows/ci.yml` | 맥·위젯이 깨진 채로 나간다 | + +## 검증 사다리 + +워크플로우 안에서 "확인했다"고 말할 수 있는 근거는 네 칸으로 나뉜다. 아래로 갈수록 +정확하고 비싸다. + +```mermaid +flowchart LR + B["1. 빌드
컴파일된다"] --> T["2. 테스트
로직이 맞다"] + T --> H["3. 사람
실제로 그렇게 보인다"] + H --> R["4. 심사
내보내도 된다"] + + B -.->|AI| B + T -.->|AI| T + H -.->|사람| H + R -.->|외부| R +``` + +1번과 2번만 AI가 할 수 있다. **그래서 AI가 "확인했다"고 쓸 수 있는 범위는 딱 +거기까지다.** 3번은 검증 카드로 넘기고, 4번은 릴리스에서 만난다. + +실제 버그는 대부분 3번과 4번에서 나왔다. v1.0.0 빌드 3은 WeatherKit 표기 누락으로 +심사에서 반려됐고, v1.3.0의 KVS entitlement 문제는 사용자가 붙여준 실기기 로그에서 +나왔다. + +## 근거가 없는 칸 + +조사해보니 규칙은 있는데 그 규칙을 받아주는 자리가 없는 것들이 있다. 관행으로만 +도는 부분이다. + +| 규칙 | 없는 것 | +|---|---| +| R4 성능은 숫자로 | 절차·템플릿 칸이 전혀 없다. 지표(M4)는 사용자 발화만 세서 규칙이 금지한 것과 다른 것을 본다 | +| R10 읽는 범위 | 절차도 강제 장치도 없다. 지표(M15)는 `Read` 도구만 봐서 Bash로 읽으면 안 잡힌다 | +| R8 작업 크기 한 줄 | 말하고 끝이다. 어디에도 안 남아서 나중에 검증할 수 없다 | +| R6 "지킨 것도 한 줄 적는다" | PR 템플릿에 받는 칸이 없다 | +| R1 "반영 확인" | PR 템플릿의 `확인한 것`이 유일한 자리이고, 이것만 담당하는 지표가 없다 | +| R3 "검증 통과율" | 담당 지표인데 추출 스크립트에도 원장 열에도 없다. 손으로 쓰는 TSV 7행이 전부 | + +## 이 문서를 언제 고치나 + +워크플로우가 바뀔 때마다. 구체적으로는 이런 때다. + +- 규칙이 늘거나 줄었을 때 (`CLAUDE.md`) +- 스킬이 생기거나 절차가 바뀌었을 때 (`.claude/skills/`) +- CI 단계가 바뀌었을 때 (`.github/workflows/ci.yml`) +- 서브에이전트 구성이 바뀌었을 때 (`.claude/agents/`) + +보고서를 쓸 때 이 문서가 최신인지 같이 확인한다. diff --git a/tools/artifact_check.sh b/tools/artifact_check.sh new file mode 100755 index 0000000..2ec51a9 --- /dev/null +++ b/tools/artifact_check.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# 빌드 산출물과 설정의 "계약"을 검사한다. +# +# 컴파일도 통과하고 테스트도 통과하는데 틀릴 수 있는 것들이 있다 — entitlements, +# plist 키, 앱과 위젯의 버전 일치 같은 것. v1.3.0에서 두 번 물렸다. +# · iCloud 키-값 저장소 entitlement가 빠져서 재설치를 못 건너왔다 +# · 그걸 고친 값이 시뮬레이터에서 앱을 아예 못 뜨게 했다 (실기기·CI는 초록) +# +# 근거: docs/verification.md "계약 검증" +# +# tools/artifact_check.sh 설정 파일만 검사 (빠르다) +# tools/artifact_check.sh <앱경로> 서명된 산출물까지 검사 +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +FAIL=0 +ok() { printf ' ✅ %s\n' "$1"; } +bad() { printf ' ❌ %s\n' "$1"; FAIL=1; } +warn() { printf ' ⚠️ %s\n' "$1"; } + +PBX=Mosco/Mosco.xcodeproj/project.pbxproj +APP_ENT=Mosco/App/App.entitlements +MAC_ENT=Mosco/App/App-Mac.entitlements +WIDGET_ENT=Mosco/MoscoWidget/MoscoWidget.entitlements +WIDGET_MAC_ENT=Mosco/MoscoWidget/MoscoWidget-Mac.entitlements +INFO=Mosco/App/Info.plist + +echo +echo " 산출물·설정 계약 검사" +echo " ─────────────────────────────────────" + +# 1. 앱과 위젯의 버전이 같은가 (다르면 업로드가 거부된다) +echo " 버전" +VERSIONS=$(grep -o 'MARKETING_VERSION = [^;]*' "$PBX" | awk '{print $3}' | grep -v '^1\.0$' | sort -u) +BUILDS=$(grep -o 'CURRENT_PROJECT_VERSION = [^;]*' "$PBX" | awk '{print $3}' | grep -v '^1$' | sort -u) +if [ "$(echo "$VERSIONS" | wc -l | tr -d ' ')" = "1" ]; then + ok "MARKETING_VERSION 일치 ($VERSIONS)" +else + bad "MARKETING_VERSION이 갈렸다: $(echo $VERSIONS | tr '\n' ' ')" +fi +if [ "$(echo "$BUILDS" | wc -l | tr -d ' ')" = "1" ]; then + ok "CURRENT_PROJECT_VERSION 일치 ($BUILDS)" +else + bad "빌드 번호가 갈렸다: $(echo $BUILDS | tr '\n' ' ')" +fi + +# 2. entitlements 필수 키 +echo " entitlements" +need_key() { # 파일, 키, 설명 + if grep -q "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 — $1 에 $2 없음"; fi +} +no_key() { + if grep -q "$2" "$1" 2>/dev/null; then bad "$3 — $1 에 $2 가 있으면 안 된다"; else ok "$3"; fi +} +need_key "$APP_ENT" "com.apple.security.application-groups" "앱: App Group" +need_key "$APP_ENT" "ubiquity-kvstore-identifier" "앱: iCloud 키-값 저장소" +need_key "$APP_ENT" "icloud-container-identifiers" "앱: iCloud 컨테이너" +need_key "$APP_ENT" "com.apple.developer.weatherkit" "앱: WeatherKit" +no_key "$APP_ENT" "com.apple.security.app-sandbox" "앱(iOS): 샌드박스 키 없음" +need_key "$MAC_ENT" "com.apple.security.app-sandbox" "앱(맥): 샌드박스" +need_key "$MAC_ENT" "com.apple.security.network.client" "앱(맥): 네트워크" +need_key "$MAC_ENT" "personal-information.location" "앱(맥): 위치" +need_key "$WIDGET_ENT" "com.apple.security.application-groups" "위젯: App Group" +need_key "$WIDGET_MAC_ENT" "com.apple.security.app-sandbox" "위젯(맥): 샌드박스" + +# 3. App Group id가 코드와 같은가 +echo " App Group" +CODE_GROUP=$(grep -o 'appGroupID = "[^"]*"' Mosco/Shared/SharedModelContainer.swift | cut -d'"' -f2) +if grep -q "$CODE_GROUP" "$APP_ENT" && grep -q "$CODE_GROUP" "$WIDGET_ENT"; then + ok "코드와 entitlements가 같다 ($CODE_GROUP)" +else + bad "코드의 $CODE_GROUP 가 entitlements와 안 맞는다" +fi + +# 4. Info.plist 사용 설명 +echo " Info.plist" +need_key "$INFO" "NSLocationWhenInUseUsageDescription" "위치 사용 설명" +if grep -q "ITSAppUsesNonExemptEncryption" "$INFO"; then + ok "수출 규정" +else + warn "수출 규정 키가 없다 — 제출할 때마다 물어본다 (docs/BACKLOG.md)" +fi + +# 5. 서명된 산출물 (경로를 줬을 때만) +if [ $# -ge 1 ]; then + APP="$1" + echo " 서명된 산출물 $APP" + if [ ! -d "$APP" ]; then + bad "그 경로에 .app이 없다" + else + ENT=$(codesign -d --entitlements :- "$APP" 2>/dev/null | tr -d '\0') + if [ -z "$ENT" ]; then + bad "서명 정보를 못 읽었다 (CODE_SIGNING_ALLOWED=NO로 빌드했나?)" + else + for KEY in application-identifier com.apple.security.application-groups; do + echo "$ENT" | grep -q "$KEY" && ok "산출물: $KEY" || bad "산출물에 $KEY 없음" + done + case "$APP" in + *maccatalyst*) + echo "$ENT" | grep -q "com.apple.security.app-sandbox" \ + && ok "산출물(맥): 샌드박스" || bad "맥 산출물에 샌드박스가 없다" ;; + esac + fi + fi +fi + +echo " ─────────────────────────────────────" +[ $FAIL -eq 0 ] && echo " 통과" || echo " 실패 — 위의 ❌를 보라" +echo +exit $FAIL diff --git a/tools/quality_baseline.py b/tools/quality_baseline.py new file mode 100755 index 0000000..459d044 --- /dev/null +++ b/tools/quality_baseline.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""코드 품질 기준선을 센다. + +"지속 가능한 코드인가"는 느낌이라 그대로 두면 판정이 매번 달라진다. 셀 수 있는 +것만이라도 매번 같은 방법으로 세서, 리팩터링 전후를 비교할 수 있게 한다. + +근거: docs/verification.md "품질 검증을 어떻게 셀 것인가" +기준선: docs/architecture/CURRENT.md "품질 기준선" + + python3 tools/quality_baseline.py # 표로 출력 + python3 tools/quality_baseline.py --tsv # 원장에 붙일 한 줄 +""" +import argparse +import re +import subprocess +import sys +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "Mosco" + +# 이 폴더들만 센다. DerivedData·빌드 산출물은 제외. +AREAS = { + "App(조립)": SRC / "App", # Features·Common을 뺀 나머지 = RootTabView 등 + "App/Features": SRC / "App" / "Features", + "App/Common": SRC / "App" / "Common", + "Shared": SRC / "Shared", + "MoscoWidget": SRC / "MoscoWidget", + "MoscoTests": SRC / "MoscoTests", +} + +# 뷰 안에 있으면 곤란한 것들. 완벽한 판정은 불가능하고, 추세를 보는 용도다. +LOGIC_IN_VIEW = { + "날짜 계산": re.compile(r"Calendar\.current|dateComponents\(|date\(bySetting|startOfDay"), + "정렬·필터": re.compile(r"\.sorted\s*[({]|\.filter\s*[({]|\.first\s*\{"), + "정규식·문자열 수술": re.compile(r"NSRegularExpression|removeSubrange|replacingOccurrences|components\(separatedBy"), + "저장소 접근": re.compile(r"modelContext\.(insert|delete|save)|FetchDescriptor"), +} + +HARDCODED_FONT = re.compile(r"\.font\(\.system\(size:") +# Metrics.* 가 아닌 숫자 여백 +HARDCODED_PADDING = re.compile(r"\.padding\((?:\.\w+,\s*)?\d") +SINGLETON = re.compile(r"static\s+(?:let|var)\s+shared\b") +COND_COMPILE = re.compile(r"#if\s+(?:!)?targetEnvironment|#if\s+canImport") +TEMP_MARK = re.compile(r"//\s*TEMP:") +TEST_CASE = re.compile(r"@Test\b|func\s+test[A-Z_]") + + +def swift_files(base: Path, top_only: bool = False): + if not base.exists(): + return [] + pattern = base.glob("*.swift") if top_only else base.rglob("*.swift") + return sorted(pattern) + + +def is_view_file(text: str) -> bool: + return ": View" in text or ": ViewModifier" in text + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--tsv", action="store_true", help="원장에 붙일 한 줄만 출력") + args = ap.parse_args() + + stats = Counter() + per_area = {} + big_files = [] + logic_hits = Counter() + + for name, base in AREAS.items(): + files = swift_files(base, top_only=(name == "App(조립)")) + lines = 0 + for path in files: + text = path.read_text(encoding="utf-8", errors="replace") + n = text.count("\n") + 1 + lines += n + if n >= 500: + big_files.append((n, str(path.relative_to(ROOT)))) + + stats["fonts"] += len(HARDCODED_FONT.findall(text)) + stats["paddings"] += len(HARDCODED_PADDING.findall(text)) + stats["singletons"] += len(SINGLETON.findall(text)) + stats["cond"] += len(COND_COMPILE.findall(text)) + stats["temp"] += len(TEMP_MARK.findall(text)) + if name == "MoscoTests": + stats["tests"] += len(TEST_CASE.findall(text)) + + # 뷰 파일 안의 비자명 로직 + if name.startswith("App/") and is_view_file(text): + for label, pattern in LOGIC_IN_VIEW.items(): + hits = len(pattern.findall(text)) + if hits: + logic_hits[label] += hits + + per_area[name] = (len(files), lines) + stats["files"] += len(files) + stats["lines"] += lines + + if args.tsv: + head = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, + capture_output=True, text=True).stdout.strip() + print("\t".join(str(x) for x in [ + head, stats["files"], stats["lines"], stats["tests"], + sum(logic_hits.values()), len(big_files), stats["singletons"], + stats["cond"], stats["fonts"], stats["paddings"], stats["temp"], + ])) + return 0 + + print(f"\n 코드 품질 기준선 {stats['files']}파일 · {stats['lines']:,}줄\n") + print(" ─ 영역별 ─────────────────────────────") + for name, (count, lines) in per_area.items(): + print(f" {name:<16} {count:>4}파일 {lines:>7,}줄") + + print("\n ─ 테스트 ─────────────────────────────") + print(f" 테스트 수 {stats['tests']:>6}") + feature_tests = 0 # Features를 대상으로 하는 테스트는 구조상 존재할 수 없다 + print(f" Features 대상 테스트 {feature_tests:>6} (테스트 타깃이 Shared/만 컴파일한다)") + + print("\n ─ 뷰 안의 비자명 로직 ────────────────") + for label, count in logic_hits.most_common(): + print(f" {label:<20} {count:>6}") + print(f" {'합계':<20} {sum(logic_hits.values()):>6} (낮을수록 좋음)") + + print("\n ─ 덩치 ───────────────────────────────") + print(f" 500줄 넘는 파일 {len(big_files):>6}") + for n, path in sorted(big_files, reverse=True): + print(f" {n:>5}줄 {path}") + + print("\n ─ 결합·우회 ──────────────────────────") + print(f" 싱글턴(static shared) {stats['singletons']:>6}") + print(f" 조건부 컴파일 분기 {stats['cond']:>6}") + print(f" 폰트 크기 하드코딩 {stats['fonts']:>6} (토큰 우회)") + print(f" 숫자 여백 {stats['paddings']:>6} (Metrics 우회 가능성)") + + print("\n ─ 임시 코드 ──────────────────────────") + mark = "✅" if stats["temp"] == 0 else "⚠️" + print(f" {mark} // TEMP: 표식 {stats['temp']:>6} (0이어야 한다)") + + print("\n 세지 못하는 것: 같은 로직의 복사본, Feature 간 순환 의존,") + print(" 추상화의 적정성. 이건 사람이 본다 (docs/review-criteria.md).\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())