콘텐츠로 이동

아키텍처

intro에서 guardrail 세 개를 짚었습니다. 여기서는 그 세 개가 코드에서 어떻게 생겼는지를 봅니다. FSD 6레이어 의존 방향, 메시지 타입 단일 파일, 백엔드 어댑터. 셋 다 “AI가 임의로 가로지르지 못하게” 빌드 또는 타입이 막아주는 자리입니다.

폴더는 위에서 아래로 의존이 흐릅니다. 화살표 반대로 import 하면 빌드가 깨집니다.

graph TB
app[app — 라우팅, provider, manifest 글루]
pages[pages — popup, options, sidePanel]
widgets[widgets — 화면 블록]
features[features — 사용자 동작 한 단위]
entities[entities — 도메인 모델]
shared[shared — 유틸, 메시지 타입, 어댑터 인터페이스]
app --> pages
pages --> widgets
widgets --> features
features --> entities
entities --> shared

각 레이어의 자리는 이렇게 갈립니다.

  • shared: 도메인 무관한 유틸, UI 키트, 메시지 타입, 어댑터 인터페이스. 어디서나 가져다 쓸 수 있고, 자기 자신만 의존합니다.
  • entities: “수집한 페이지”, “백엔드 설정” 같은 도메인 모델. discriminated union 으로 잡는 자리.
  • features: 사용자 동작 한 단위. “현재 페이지 분석”, “결과 백엔드로 전송”, “백엔드 모드 변경”.
  • widgets: feature 여러 개를 묶은 화면 블록. popup 헤더, sidePanel 본문 같은 것.
  • pages: popup, options, sidePanel 같은 진입 화면 단위.
  • app: 진입점 글루. 라우팅, provider, manifest 와 React 트리를 잇는 자리.

eslint-plugin-boundaries가 위 화살표를 강제합니다. 역방향 import는 물론, 같은 레이어 안에서 슬라이스끼리 가로지르는 것도 막습니다. features/analyze-page에서 features/send-to-backend를 직접 import 하면 빌드 실패. 슬라이스 바깥에서 들어가는 유일한 통로는 그 슬라이스의 index.ts 입니다.

AI에게 코드를 맡길 때 어느 레이어에 둘지를 모델이 직접 고르게 두면 십중팔구 features와 widgets 사이를 흐릿하게 섞습니다. 슬라이스 이름까지 사람이 지정해서 주는 편이 안전합니다. “features/configure-backend에 옵션 폼 컴포넌트 추가” 식으로.

의존 방향을 이 모양으로 잡은 근거는 docs/adr/0001-fsd-import-direction.md에 한 장 들어있습니다. 코드는 무엇을 했는지를, ADR은 왜 그랬는지를 들고 있습니다.

content script와 background, popup 사이로 오가는 모든 메시지는 shared/lib/messaging/messages.ts 한 장에 정의된 discriminated union을 통과합니다.

export type Message =
| { type: 'analyze-page'; tabId: number }
| { type: 'send-result'; payload: PageResult }
| { type: 'config-changed'; mode: BackendMode }

송신은 chrome.runtime.sendMessage(msg) 또는 chrome.tabs.sendMessage(tabId, msg) 에서 같은 union 으로 type 좁힘이 따라옵니다. 수신은 chrome.runtime.onMessage.addListener 안에서 switch (msg.type) 로 분기합니다.

새 메시지를 추가할 때 절차는 messages.ts 에 union 가지를 한 줄 더 적는 것뿐입니다. 송신 쪽과 수신 쪽 양쪽이 동시에 컴파일 깨지면서 강제로 같이 갱신됩니다. 같은 페이로드 모양이 두 군데에 따로 정의되는 사고가 여기서 막힙니다.

sequenceDiagram
participant CS as content_script
participant BG as background (service worker)
participant POP as popup
participant ADP as backend adapter
participant BE as backend (console / supabase / server)
CS->>BG: chrome.runtime.sendMessage (analyze-page)
BG->>ADP: backend.send(payload)
ADP->>BE: 모드별 분기
BE-->>ADP: ok / error
ADP-->>BG: Result<T>
BG-->>CS: response
POP->>BG: 사용자 동작 트리거
BG-->>POP: 상태 갱신

popup은 background를 향해서만 말을 겁니다. content script와 popup이 직접 붙는 경로는 의도적으로 비워뒀습니다. 그 자리는 늘 background를 거치게 만들어야 service worker가 깨어나고, 권한 검사와 어댑터 호출이 한 곳에 모입니다.

shared/api/backend/에 어댑터 인터페이스 한 개와 구현 셋이 들어있습니다.

shared/api/backend/
├── index.ts 공개 진입점, factory 포함
├── types.ts Backend 인터페이스, BackendMode union
├── console-log.ts 콘솔에 페이로드만 찍는다
├── supabase-direct.ts Supabase로 바로 쏜다
└── server-relay.ts 자체 서버를 거친다

호출하는 쪽 (feature 또는 entity) 은 어떤 모드인지 모르고 backend.send(payload) 만 부릅니다. 모드 결정은 옵션 페이지에서 하고 chrome.storage.sync 에 저장됩니다. factory가 그 값을 읽어 인스턴스를 고릅니다.

이 어댑터의 본 목적은 백엔드가 정해지지 않은 단계에서 UI 작업을 시작할 수 있게 하는 것입니다. 첫날은 console-log 모드로 페이로드 모양만 검증하고, Supabase 테이블이 잡힌 다음 supabase-direct 로 바꿉니다. 호출 코드는 한 줄도 안 건드립니다.

어댑터를 새로 늘릴 때는 types.tsBackendMode union 에 가지를 한 줄 추가하고, 같은 인터페이스를 구현한 파일을 shared/api/backend/ 에 한 장 더 둡니다. factory의 switch 가 컴파일 시점에 누락을 잡습니다.

Claude Code 훅 세 개가 .claude/hooks/ 에 박혀 있고 .claude/settings.jsonhooks 필드가 연결합니다. 사용자 또는 AI가 명령을 의식적으로 따르지 않아도 컨텍스트가 무너지는 자리를 막습니다.

스크립트이벤트책임
session-start.shSessionStartactive.md, progress.md 마지막 30줄, feature_list.json, extension-spec.md 첫 50줄, MEMORY.md 를 stdout 으로 prepend. fork 직후 첫 진입은 silently exit
stop-reminder.shStopactive.md 가 20자 미만이면 사용자에게 한 줄 메모 안내
pre-commit-check.shPreToolUse (Bash matcher)tool_input.commandgit commit 매치되면 pnpm run typecheck && pnpm run lint, 실패 시 exit 2 와 함께 로그 마지막 30줄을 stdout 으로 흘려 AI에게 차단 사유 전달

PreToolUse 의 Bash matcher 는 stdin으로 들어온 JSON 의 tool_input.command 를 jq 또는 sed로 추출해 git commit 패턴만 typecheck 게이트로 보냅니다. 다른 Bash 명령은 zero exit으로 통과시켜 워크플로 마찰을 늘리지 않습니다.

패키지 매니저는 pnpm-lock.yamlyarn.lockpackage-lock.json 순으로 lockfile을 보고 자동 결정합니다. package.json 이 없거나 typecheck 스크립트가 빠져 있으면 base 초기 단계로 보고 통과합니다.

훅을 새로 추가할 때는 .claude/hooks/<name>.sh 를 작성하고 (#!/usr/bin/env bash 시작, chmod +x), settings.json 의 적절한 이벤트 항목에 등록하고, .claude/hooks/README.md 표에 한 줄 더 적습니다. 모든 훅은 독립이라 한 개를 빼도 다른 두 개에 영향이 없습니다.

비개발자 호흡으로 같은 시스템을 정리한 자리는 비개발자용 07장 에 있습니다.

자동화 훅이 사용자 의식 바깥에서 도는 안전망이라면, 사용자 명시 호출 쪽에는 슬래시 명령 두 개가 박혀 있습니다. 둘 다 .claude/skills/ 안 스킬이 wire 합니다.

명령트리거 자리핵심 동작
/recover-from-blockedreview-extension 이 두 번 연속 changes_requested 끝에 blocked 으로 떨군 직후최신 .claude/state/review-*.md 에서 점수 < 3 차원 추출, 같은 영역의 builder-*.md 두 iteration 을 비교해 same-mistake 감지. 사용자에게 평이한 보고 + 3옵션 (rollback / 사용자 도움 retry / manual)
/resume/clear 직후, 또는 며칠 만에 다시 진입한 자리state 파일 8개 + 최근 git log 종합해 stage 감지 (empty → paced → analyzed → designed → customized → implemented → built → packaged + 보조 분기). session-start.sh 훅이 raw 를 prepend 한 자리에 한 화면 합성 요약을 사용자에게 출력

/recover-from-blocked 의 옵션 A (rollback) 는 git reset --hard 를 부르는 파괴적 자리라, AskUserQuestion 한 번 + yes 명시 입력 한 번. 두 번 차단이 의도적으로 들어 있습니다. dirty working tree 면 그 변경도 사라진다는 안내가 한 줄 더 따라옵니다.

/resumeactive.md 와 감지된 stage 가 어긋나면 합성을 멈추고 사용자에게 어느 쪽이 맞는지 묻습니다. 다른 PC 에서 작업이 진행됐거나 finish 후 active.md 갱신을 잊은 자리가 가끔 있어, 잘못 합성된 답보다 명시 확인이 쌉니다.

비개발자 호흡으로 같은 두 명령을 정리한 자리는 비개발자용 08장 에 있습니다.

5-customize-extension 스킬의 5-b.0 prelude 가 Figma MCP 가 등록된 환경에서 한 단계 분기를 더 만듭니다. 디자인 토큰을 손으로 옮기는 자리를 URL 한 번으로 끊습니다.

분기는 .claude/state/mcp-tools.jsonselected 배열에 figma 또는 claude_ai_Figma 키가 있을 때만 켭니다. 없으면 5-b 본 단계로 곧장 떨어져 color_tone 한 단어로 추천 색상 셋을 짜는 기존 흐름과 같습니다.

flowchart TB
start[/5-customize-extension] --> mcp{mcp-tools.json에 figma 키}
mcp -- "있음" --> pick{디자인 토큰 어디서?}
mcp -- "없음" --> tone[color_tone 한 단어 추천 셋]
pick -- "Figma 자동" --> url[Figma URL 입력]
pick -- "톤 단어" --> tone
url --> parse[fileKey + nodeId 파싱]
parse --> vars[get_variable_defs 호출]
vars --> map[text/bg/primary/secondary/border 매핑]
map --> tokens[tokens.css CSS 변수 박음]
tokens --> brand[brand.json: theme_source figma + synced_at]
vars -- "변수 일부 누락" --> ctx[get_design_context 보조]
ctx --> map

URL 파싱은 figma.com/design/:fileKey/...?node-id=:nodeId 패턴에서 nodeId 의 -: 로 변환하는 표준 규칙(Figma MCP 가이드)을 그대로 따릅니다. mcp__claude_ai_Figma__get_variable_defs(fileKey, nodeId) 가 디자인 변수를 JSON 으로 반환하면 그 중 색상 변수만 추출해 src/shared/ui/tokens.css--color-* CSS 변수에 매핑합니다. 변수 일부가 비면(예: secondary 가 없는 시안) get_design_context 가 보조 컨텍스트와 screenshot 을 같이 던져 AI 가 같은 톤 안에서 보충 추천합니다.

brand.json 에는 다음이 박힙니다.

{
"theme_source": "figma",
"figma_file_key": "<fileKey>",
"figma_node_id": "<nodeId>",
"synced_at": "<ISO>",
"manual_overrides": ["secondary"]
}

manual_overrides 가 본 prelude 의 운영 핵심입니다. 두 번째 호출(figma_file_key 가 같음) 시 sync 는 변경된 변수만 갱신하고, tokens.css 에서 사용자가 손으로 고친 자리는 덮어쓰지 않습니다. 어떤 변수를 manual override 로 잡을지는 사용자가 5-b 에서 직접 토큰 값을 편집한 직후 brand.json 에 기록되는데, 이 기록은 /5-customize-extension 재호출 시 diff 비교로 자동 감지됩니다(파일 mtime 또는 git blame 기반이 아니라 brand.json 의 마지막 sync 시점 토큰 값과 현재 값 비교).

폰트 변수는 theme_depth색상+폰트 또는 풀 테마 일 때만 같이 가져와 src/shared/ui/font.ts 에 import 줄을 박습니다. 색상만 모드에서는 폰트 변수를 무시합니다.

비개발자 호흡으로 같은 흐름을 정리한 자리는 비개발자용 05장 에 있습니다.

다음 섹션은 verification입니다. extension-spec.md §2 시나리오를 /test-extension이 어떻게 Vitest 테스트로 변환하는지, 그 결과를 raw 출력 대신 시나리오 언어로 다시 합성해 돌려주는 자리, 그리고 정적 측 review-extension과의 보완 관계를 봅니다.