Skip to content

appController 리팩토링 (2026-08-21)

조립 루트 appController.tsx에 누적된 상태·조율 로직을 하루에 세 캠페인 — 1차 분해 → (c) 군집 경계 → (a) 스토어 확산 — 으로 이어 분해·재설계한 기록이다. 결과는 4,123줄 → 1,856줄(-55%) + 군집 컨트롤러 8개 + 스토어/무프레임워크 조율자 10개 + React 없이 도는 행동 테스트 44계약이고, 동작은 전 구간 불변이다.

관련 PR: #501 / #508 #512 #514 #516 #519 / #521 #528 #529 #530 #531 #532

1차 분해의 전말은 부채 상환 캠페인 기록에 있다. (c)·(a) 캠페인의 상세는 이 문서가 정본이다.


1. 배경

앱의 모든 화면은 조립 루트 훅 하나가 만든 renderCtx(~130키)를 받아 그린다. 기능이 추가될 때마다 상태·이펙트·로더가 이 훅 안에 쌓여 4,123줄이 됐다. 소유가 분명한 섬 (달력·PR·프로필·원격 데이터 등)은 이미 별도 모듈로 빠져 있었는데도 그랬다.

부채 상환 캠페인의 1차 분해(#501)가 로그테이블 군집을 추출한 뒤 판정을 내렸다: 잔여는 "추출 가능한 소유 섬"이 아니라 조율 로직 그 자체다. 예로 restoreWorkoutDraftForUser 하나가 달력·에디터·내비에 걸친 상태 9개를 조율한다. 여기서 더 줄이려면 슬라이스 추출이 아니라 상태 재설계가 필요했다.

그래서 코드 무변경 타당성 조사를 먼저 했다. 판정은 "조건부 가능 — 권고 (c) 군집 훅 추출". 핵심 논리는 두 가지였다.

  • renderCtx가 단일 객체 계약인 한 어떤 재설계안도 렌더 입자성을 얻지 못한다. 이번 단계에서 사는 것은 소유권 경계·리셋 불변식·테스트 가능성이다.
  • (c)의 산출물(군집 경계 결정)은 (a) 스토어 전환의 공통 접두사라 버려지는 작업이 없다.

오너가 장기 로드맵을 확정했다: (c) 군집 경계 → (a) 군집별 스토어 → 렌더 입자성. 각 단은 앞 단 완주·검증이 전제다. 이 문서 시점에 (c)·(a)가 완주됐고, 렌더 입자성은 계획서 발행·발주 대기 상태다.


2. 문제 제기

조사에서 확인한 것들을 성격별로 묶으면 네 덩어리였다.

한 훅이 모든 것을 소유했다

3,926줄(1차 분해 후 기준) 훅 안에 서로 무관한 군집 여러 개 — 운동 드래프트, 저장 파이프라인, 내비게이션, 세션 상세, 로그테이블 캐시, 통계 복구, 토스트 — 의 상태와 이펙트가 한 스코프에 섞여 있었다. 어떤 상태를 누가 읽고 누가 바꾸는지가 코드 구조에 드러나지 않아서, 수정 하나의 영향 범위를 판단하려면 훅 전체를 읽어야 했다.

계정 전환 리셋이 나열식이었다

계정을 전환할 때 이전 계정의 상태를 지우는 코드가 개별 상태 조작 ~36개를 손으로 나열하고 있었다. 새 상태를 추가하면서 이 목록에 빠뜨리면 계정 간 데이터 누출이 조용히 일어나는 구조다. 실제로 숨어 있던 승계 예외 1건(계획 편집기 요청)이 (a) 과정에서 드러나 폐쇄됐다(#528) — 목록이 아니라 구조가 문제였다는 증거다.

핵심 쓰기 계약을 직접 테스트할 수 없었다

저장 영수증-후-정리 순서, OCC 충돌 수렴, 오프라인 게이트 같은 이 앱에서 가장 값비싼 계약들이 React 훅 안에만 존재했다. 훅은 React 없이 실행할 수 없으므로 테스트는 소스 텍스트 단언(readFileSync + slice)으로 우회했는데, 이 방식은 앵커가 사라져도 조용히 통과한다. 실제로 캠페인 중 이미 헛돌고 있던 앵커가 누계 7건 발견됐다 — 초록인데 아무것도 검증하지 않는 테스트들이었다.

구조가 억제와 렌더 상한을 강요했다

달력 리비전 틱처럼 "상태 변경을 React에 알리기 위한 우회 장치"가 exhaustive-deps 억제를 요구했고, 억제는 사유 검토 비용으로 남았다. 그리고 어떤 상태가 바뀌든 조립 루트 전체가 재실행된다 — 측정된 성능 통증은 아직 없지만, 상태·화면이 계속 늘어나는 앱의 구조적 상한이었다.


3. 해결 방안

원칙

  1. 동작 불변 — verbatim 이동. 이동과 개선을 한 커밋에 섞지 않는다. 개선(리셋 일반화 등)은 별도 단계로 분리해 diff가 각각 검증 가능하게 한다.
  2. renderCtx 키 계약은 동결. 화면(ui/** = Design 캔버스)과 컨테이너 소비자 (mobileApp/desktopApp)는 전 캠페인 무접촉. 렌더 입자성은 다음 캠페인의 몫이다.
  3. 매 단계 전 게이트 재실행 + 머지는 verify=pass 확인 후에만. types·lint·test만 으론 부족하다(§4의 사고 참조). check:unused는 별도 실행.
  4. 이동한 계약은 소스 앵커 대신 행동 테스트로. 코드가 스토어/순수 팩토리가 되는 순간 소스 텍스트 단언을 React 없는 행동 계약으로 교체한다.
  5. 스토어는 프레임워크 무의존 + 동일 표면 어댑터. create*Store()(상태+리스너+ getSnapshot/subscribe) + useSyncExternalStore 어댑터 훅 + setEnv(deps) 렌더당 최신 환경 주입. 소비자 입장에서 훅 표면이 변하지 않는다.
  6. 계정 전환 리셋 = 초기 상태 리터럴 교체(state = initial*State()). 필드를 빠뜨리는 실수가 구조적으로 불가능해진다.

접근

(c)를 먼저 했다 — 경계 결정이 작업의 9할이고 (a)가 그 경계를 그대로 물려받는다. (c) 완주 후 곧바로 (a)로 가지 않고 드래프트 군집 하나로 파일럿(Phase 6, #521)을 돌려 레시피(스토어+어댑터+리터럴 리셋+행동 테스트)를 실증했고, 실증 결과를 근거로 오너가 확산을 일괄 승인했다.

쓰기 경로를 만진 단계는 전용 로컬 Supabase 스택(barbelic-rdwt)에서 db reset 후 CRUD 왕복 e2e로 이중 검증했다.


4. 적용한 내용

(c) 군집 경계 캠페인 — Phase 0~5 (구 P0~P5)

PhasePR내용appController.tsx
Phase 0#508sourceSliceAnchors 파일 단위 검사 신설(§아래) + ownerScope 스파인(ownerScopeController) 추출. 첫 실행에서 이미 헛돌던 앵커 1건 적발·수리3,926 →
Phase 1·2#512읽기 3군집: pendingWorkoutSavesController · statsRecoveryController(+resetStatsReconciliation 진입점) · screenLoadPolicyController(로드 정책 이펙트 7종) + navigationController(내비 상태 5종·히스토리 매니저 — 닫기 디스패치는 latest-ref 주입으로 조립 루트 유지)
Phase 3#514sessionDetailController: ensure* 정확 상세 경계 3종 + 상세 열기 플로우 — 쓰기 경로의 주입 계약 고정
Phase 4·5#516+#519workoutDraftController(스냅샷·자동저장·복원 — CASE-011·015 계약 대상) + workoutWriteController(저장·수정·삭제·폐기·OCC·pending 폴백, ~963줄 — CASE-002 계약 대상) + 리셋 지휘자 일반화(개별 조작 ~36개 → 군집 리셋 진입점 조합)→ 1,902

(c) 종료 시점 게이트: 스위트 1,577/1,577 + 전 체인 + check:unused, 쓰기 안전망으로 로컬 스택 e2e 8/8(스킵 1 = 알려진 issue #321). CASE-001·002·011·015 재발 방지 계약 전량 초록.

재조준 장부: 이동 코드를 소스 텍스트로 단언하던 테스트 약 20파일 재조준(읽기 경로 갱신 또는 결합 표면화). 부수 수확으로 이미 헛돌던 앵커 3건 수리(const currentPlanDraft 중괄호 누락, const selectDashboardTab · const refreshPendingWorkoutProjection 소멸 끝 앵커 — BROKEN_ANCHOR_BASELINE에서 제거). 억제 2건 파일 이동(eslint-baseline), dual-key 사이트 1건 파일 이동(총량 469 불변), any-boundary 신규 6파일 등재(모두 appController 승계 사유).

Phase 6 파일럿과 (a) 확산 승인

드래프트 군집을 workoutDraftStore(프레임워크 무의존)로 전환하고 훅을 uSES 어댑터로 축소했다(#521). appController·renderCtx 무변경. 실수확 2종이 실증됐다 — ① React 없는 행동 테스트 5계약(발행 이원·CASE-011 가드·보존-후-리셋·복원·구독) ② 리터럴 교체 리셋. 이 실증을 근거로 오너가 확산을 일괄 승인하며 결정②(계획 편집기 요청 승계 예외 폐지)를 편입시켰다(#528).

(a) 스토어 확산 캠페인 — Phase 1~4 (구 W1~W4)

PhasePR내용appController.tsx
Phase 1#529읽기 캐시: logTableStore(LRU·dedupe·세대 가드) · calendarReadModelStore(모델 배열이 상태 — 달력 리비전 틱 폐지로 억제 2건 소멸, 14→12)1,902 →
Phase 2#530화면 조율: navigationStore(히스토리 스택·스크롤 복원) · sessionDetailStore(열기 경합·OCC 펜스). e2e 8/8
Phase 3#531소형 3종: toastStore · pendingWorkoutSavesStore · customExerciseStore + 명부 재판정 3건(§아래)
Phase 4#532React-free 순수화: createWorkoutWriteCommands(1,193줄 훅 탈피) · createStatsRecoveryCoordinator(지연 주입). 억제 1건 추가 소멸, 12→11. e2e 8/8→ 1,856

신설 행동 테스트 39계약(파일럿 5 포함 총 44) — 전부 React 없이 실행.

주요 결정과 그 근거

조율 디스패치는 조립 루트에 남기고 latest-ref로 주입했다. 레이어 닫기처럼 여러 군집을 가로지르는 콜백을 군집 안으로 옮기면, 콜백 재생성이 오너 리셋 이펙트를 편집할 때마다 재발화시키는 폭탄이 된다. 안정 표면(ref 위임)으로 주입해 훅 경계를 넘는 결합을 명시화했다 — #499 로더 파사드 패턴의 일반화이고, 스토어의 setEnv도 같은 원리다.

훅 탈피는 함수별 최소 destructure로. 1,193줄 workoutWriteController 훅을 순수 팩토리로 바꿀 때, 62개 env 키를 함수마다 본문에 등장하는 것만 requireEnv()에서 골라 쓰게 했다(본문 정규식으로 자동 산출). 최신성(매 호출 시점의 env)을 보존하면서 타입 에러 0으로 1회 변환됐다.

명부는 실측으로 재판정했다. 계획서가 스토어 전환 후보로 올렸던 pr·search는 실측 결과 핵심 전이(즐겨찾기 코디네이터·검색 디바운스)가 이미 프레임워크 무의존이라 제외(목표 상태 선취 — 잔여는 얇은 어댑터라 전환 실익 없음), socialIdentity는 4/5 이펙트가 브라우저 이벤트·URL 복귀 배선인 데다 진행 중인 소셜 로그인 트랙과 파일 충돌 위험이라 이연했다. 계획서는 명부이지 계약이 아니다.

억제는 지우는 게 아니라 불필요하게 만들었다. exhaustive-deps 리뷰 억제 14건 중 3건(달력 리비전 틱 2 + 복구 이펙트 1)은 스토어화로 우회 장치 자체가 사라지자 억제도 함께 소멸했다. 남은 11건은 여전히 의도된 불변식이다.

작업 중 드러난 것

전역 haystack의 사각(Phase 0에서 봉인). 소스 앵커 검사가 전 파일 결합 문자열을 검사해서, 앵커가 "어느 파일에든" 있으면 통과했다 — 코드를 다른 파일로 옮기면 원래 테스트가 죽은 앵커를 들고도 초록이 되는 구조. 파일 단위 검사(테스트가 실제로 읽는 파일에 대해서만 앵커를 검증)로 바꾸자, 이후 전 단계의 재조준 누락을 실시간으로 적발했다. 부수 수확으로 이미 헛돌던 앵커 누계 7건((c) 3 + (a) 4)이 나왔다. 남은 사각 하나: 여러 파일을 결합한 표면에서 슬라이스 시작·끝이 다른 파트에 걸리면 빈 문자열이 된다(2회 밟음) — 슬라이스는 반드시 한 파트 안에서.

레드 머지 사고(#516→#519). Phase 5 후 check:unused를 재실행하지 않아 분해 잔여 고아 7건이 남았고, CI 대기 스크립트가 종결 여부만 보고 결과 문자열을 확인하지 않은 채 머지했다. 즉시 #519로 수리. 원칙 3(전 게이트 + verify=pass 문자열 확인)은 이 사고의 산물이다.

GitHub Actions 결제 차단. 러너가 스텝 0개로 즉시 실패(지출 한도 annotation). 코드 탓이 아니었다 — 오너가 당일 해제 후 main 재실행 초록으로 재검증했다. CI 실패를 코드 탓으로 단정하기 전에 annotation부터 볼 것.

테스트에서 상수 정체성 비교 금지. tsx가 같은 모듈을 이중 로드해 상수 정체성 비교(intent === IDLE_…)가 CI에서만 깨졌다 — 판별 필드(intent.kind) 비교로. 비슷하게, 완료 술어의 값 형태는 실제 술어 코드를 읽고 맞출 것(모양 추측 금지).


5. 적용 결과

항목결과
appController.tsx4,123줄 → 1,856줄 (-55%) — 정식 상태 4종·리셋 지휘자·renderCtx 조립·교차 군집 조정자만 보유
군집 컨트롤러8개 (src/react/controllers/)
스토어/무프레임워크 조율자10개 — 스토어 8종 + 순수 팩토리 2종(createWorkoutWriteCommands·createStatsRecoveryCoordinator)
신설 행동 테스트44계약, 전부 React 없이 실행
exhaustive-deps 리뷰 억제14 → 11
전체 스위트1,622/1,622 통과 ((c) 종료 시점 1,577)
e2e (로컬 스택, 쓰기 단계마다)8/8 × 2회 (스킵 1 = 알려진 issue #321)
renderCtx 키 계약동결 유지 — mobileApp/desktopApp/ui/** 무접촉

행동 테스트의 왕관은 workoutWriteCommands.test.mjs다: 영수증-후-정리 순서(CASE-002 계열)·OCC 충돌 수렴·재진입 가드·오프라인 게이트가 이 구역 최초의 직접 계약이 됐다.


6. 이번 개선으로 향상된 것

소유권이 코드 구조에 드러난다

상태가 어느 군집 소유인지, 군집 간 결합이 무엇인지가 파일 경계와 주입 파라미터로 명시된다. 수정의 영향 범위를 훅 전체가 아니라 해당 스토어와 그 어댑터에서 판단한다.

리셋 실수의 종류를 없앴다

계정 전환 리셋이 나열 목록에서 초기 상태 리터럴 교체로 바뀌어, 새 필드를 추가하면 리셋은 자동으로 따라온다. 한 번의 누락을 막는 대신 누락이라는 실수의 종류를 없앴다. 숨어 있던 승계 예외 1건(계정 간 초안 누출 경로)도 이 과정에서 드러나 폐쇄됐다.

가장 값비싼 계약이 직접 테스트된다

저장 순서·OCC 수렴·오프라인 게이트가 소스 텍스트 흉내가 아니라 실제 실행으로 검증된다. 44계약 전부 React 없이 돌므로 빠르고, 앵커 소멸로 조용히 무력화되는 일이 없다.

조용히 통과하던 테스트를 시끄럽게 만들었다

파일 단위 앵커 게이트가 "초록인데 아무것도 검증하지 않는" 테스트 7건을 적발·수리했고, 앞으로 코드 이동 시 재조준 누락은 게이트가 즉시 잡는다.

다음 단의 출구 조건을 채웠다

전 군집이 subscribe 표면을 갖게 되어 렌더 입자성 캠페인(화면별 구독)의 전제가 충족됐다. (c)→(a)의 산출물이 그대로 다음 단의 입력이다.


남은 것

이번 범위 밖으로 남긴 항목이다.

  • 렌더 입자성 캠페인 발주 — 계획서 발행됨, 오너 결정 대기. 계정별 스토어 세트 인스턴싱(구 W5)은 그 캠페인의 Phase 1로 편입.
  • socialIdentity 스토어화 — 소셜 로그인 트랙과 파일 충돌 위험으로 이연, 트랙 종료 후 재상정.
  • 컨트롤러 마운트 스모크 — 어댑터 훅을 실제 마운트해 보는 보강 테스트(권고).
  • 수동 스모크 — 저장→수정→충돌→삭제 + 오프라인 큐 1회전, 오너 확인 항목.